20 KiB
Design: Task Management UI
Architecture Approach
This feature is a frontend-only addition to the studio-ui React application. No backend changes are required — the feature consumes the REST endpoints provided by the data-models feature.
What's new:
- 1 new npm dependency:
@dnd-kit/core+@dnd-kit/sortablefor drag-and-drop - 1 new npm dependency:
@foundary-test-1770784989/api-client(already in monorepo, needs adding to studio-ui) - React Router setup in
App.tsx(currently no routing) - ~10 new component files under
src/features/board/ - ~3 new hook files under
src/features/board/hooks/ - ~2 new type files under
src/features/board/types/
What's modified:
apps/studio-ui/package.json— adds@dnd-kit/core,@dnd-kit/sortable,@foundary-test-1770784989/api-clientapps/studio-ui/src/App.tsx— adds React Router with route definitions and updated navigationapps/studio-ui/src/main.tsx— wraps app inBrowserRouter
What's preserved:
- All existing UI components and layout patterns
- CSS variable system — no hardcoded colors
- DashboardShell / Sidebar / Header layout wrapper
Data Model Changes
No backend data model changes. The frontend consumes existing API types.
Frontend TypeScript Types
// src/features/board/types/task.ts
interface Task {
id: string;
project_id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
created_at: string;
updated_at: string;
}
type TaskStatus = 'open' | 'in_progress' | 'done' | 'cancelled';
type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
interface Label {
id: string;
name: string;
color: string;
created_at: string;
}
interface Assignment {
id: string;
task_id: string;
user_id: string;
assigned_at: string;
}
// Enriched task with resolved associations for board rendering
interface BoardTask extends Task {
labels: Label[];
assignments: Assignment[];
}
These types mirror the {data} envelope response shapes from the data-models API.
API Response Envelope
All API responses use the {data, meta} envelope:
interface ApiResponse<T> {
data: T;
meta?: Record<string, unknown>;
}
API Changes
No new API endpoints. The feature consumes these existing endpoints from data-models:
| Method | Endpoint | Usage |
|---|---|---|
GET |
/api/studio-api/projects/{projectId}/tasks |
Fetch all tasks for the board |
POST |
/api/studio-api/projects/{projectId}/tasks |
Create a new task |
PUT |
/api/studio-api/tasks/{id} |
Update task (status change on drag, edit form) |
DELETE |
/api/studio-api/tasks/{id} |
Delete task from edit modal |
GET |
/api/studio-api/labels |
Fetch labels for filter dropdown and task forms |
GET |
/api/studio-api/tasks/{taskId}/labels |
Fetch labels attached to a task |
POST |
/api/studio-api/tasks/{taskId}/labels/{labelId} |
Attach label to task |
DELETE |
/api/studio-api/tasks/{taskId}/labels/{labelId} |
Detach label from task |
GET |
/api/studio-api/tasks/{taskId}/assignments |
Fetch assignments for a task |
POST |
/api/studio-api/tasks/{taskId}/assignments |
Assign user to task |
DELETE |
/api/studio-api/tasks/{taskId}/assignments/{id} |
Remove assignment |
GET |
/api/studio-api/projects |
Fetch projects for project selection |
Data Loading Strategy
On board mount, the UI makes parallel requests:
GET /projects/{projectId}/tasks— all tasks for the projectGET /labels— all labels (for filter and form dropdowns)- For each task:
GET /tasks/{taskId}/labelsandGET /tasks/{taskId}/assignments
To avoid N+1 requests per task, the board will:
- Fetch all tasks first
- Then batch-fetch labels and assignments for all tasks in parallel using
Promise.all - Merge into
BoardTask[]enriched objects
This is acceptable for v1 since the spec states "no pagination" and data volumes are expected to be small. If performance becomes an issue, the backend can add a /tasks?include=labels,assignments query parameter in a future iteration.
Component Diagram
apps/studio-ui/src/
├── main.tsx # BrowserRouter wrapper
├── App.tsx # Routes + DashboardShell + Sidebar
├── features/
│ └── board/
│ ├── types/
│ │ └── task.ts # Task, Label, Assignment, BoardTask types
│ ├── hooks/
│ │ ├── useTasks.ts # Fetch/mutate tasks, labels, assignments
│ │ ├── useBoardFilters.ts # Filter state (label, assignee)
│ │ └── useOptimisticUpdate.ts # Optimistic status change with rollback
│ ├── api.ts # API client calls (thin wrapper)
│ ├── BoardPage.tsx # Page component (route target)
│ ├── BoardView.tsx # Kanban layout with DnD context
│ ├── BoardColumn.tsx # Single status column (droppable)
│ ├── TaskCard.tsx # Draggable task card
│ ├── TaskCreateDialog.tsx # Create task modal
│ ├── TaskEditDialog.tsx # Edit task modal (with delete)
│ ├── FilterBar.tsx # Label + assignee filter controls
│ └── BoardSkeleton.tsx # Loading skeleton
Component Interaction Flow
┌─────────────────────────────────────────────────────────────┐
│ App.tsx (React Router) │
│ Route: /projects/:projectId/board → <BoardPage> │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ BoardPage.tsx │
│ - Extracts projectId from URL params │
│ - Calls useTasks(projectId) hook │
│ - Calls useBoardFilters() hook │
│ - Renders Header, FilterBar, BoardView, Dialogs │
│ - Manages dialog open/close state │
└──────┬──────────┬───────────┬────────────┬──────────────────┘
│ │ │ │
┌────▼────┐ ┌──▼───────┐ ┌▼─────────┐ ┌▼────────────────┐
│FilterBar│ │ BoardView │ │TaskCreate│ │ TaskEditDialog │
│ │ │ (DnD) │ │Dialog │ │ (with Delete) │
└─────────┘ └──┬───────┘ └──────────┘ └─────────────────┘
│
┌─────────┼─────────┐
│ │ │
┌────▼───┐ ┌──▼────┐ ┌──▼────┐
│Column │ │Column │ │Column │
│"To Do" │ │"In │ │"Done" │
│(open) │ │Prog" │ │ │
└──┬─────┘ └──┬────┘ └──┬────┘
│ │ │
┌──▼──┐ ┌──▼──┐ ┌──▼──┐
│Task │ │Task │ │Task │
│Card │ │Card │ │Card │
└─────┘ └─────┘ └─────┘
Data Flow
useTasks hook (React state)
│
├── tasks: BoardTask[] ← fetched from API, enriched with labels/assignments
├── labels: Label[] ← fetched from GET /labels
├── isLoading: boolean
├── error: string | null
├── createTask(input) → POST /projects/{id}/tasks + refresh
├── updateTask(id, fields) → PUT /tasks/{id} + optimistic update
├── deleteTask(id) → DELETE /tasks/{id} + remove from state
├── attachLabel(taskId, labelId)→ POST /tasks/{taskId}/labels/{labelId}
├── detachLabel(taskId, labelId)→ DELETE /tasks/{taskId}/labels/{labelId}
├── assignUser(taskId, userId) → POST /tasks/{taskId}/assignments
└── unassignUser(taskId, asgId) → DELETE /tasks/{taskId}/assignments/{id}
useBoardFilters hook (React state)
│
├── selectedLabels: string[] ← label IDs to filter by
├── selectedAssignee: string ← user ID to filter by (or empty)
├── setLabelFilter(ids)
├── setAssigneeFilter(userId)
└── clearFilters()
Filtering is applied client-side:
filteredTasks = tasks
.filter(t => t.status !== 'cancelled')
.filter(t => selectedLabels.length === 0 || t.labels.some(l => selectedLabels.includes(l.id)))
.filter(t => !selectedAssignee || t.assignments.some(a => a.user_id === selectedAssignee))
Error Handling Strategy
API Call Failures
| Operation | Failure Mode | Handling |
|---|---|---|
| Initial fetch (tasks, labels) | Network error, 500 | Show error alert in board area with "Retry" button. Board is non-functional without data. |
| Create task | 400 (validation) | Show inline validation errors in the create dialog (title required, length). Do not close dialog. |
| Create task | 500 | Show toast/alert: "Failed to create task. Please try again." Do not close dialog. |
| Update task (drag) | Any error | Revert optimistic update — move card back to original column. Show toast: "Failed to update task status." |
| Update task (edit form) | 400 | Show inline validation errors in edit dialog. |
| Update task (edit form) | 500 | Show toast: "Failed to update task." Do not close dialog. |
| Delete task | Any error | Show toast: "Failed to delete task." Keep task on board. |
| Label attach/detach | Any error | Show toast: "Failed to update labels." Revert label state on task. |
| Assignment add/remove | Any error | Show toast: "Failed to update assignments." Revert assignment state on task. |
Optimistic Update Pattern (Drag & Drop)
1. User drops card in new column
2. UI immediately moves card to new column (optimistic)
3. PUT /tasks/{id} fires with new status
4. On success: no action needed (already in correct position)
5. On failure: revert card to previous column + show error toast
This is implemented via useOptimisticUpdate hook that stores the pre-mutation state and provides a rollback() function.
Error Display Components
- Full-page error:
Alertcomponent from@foundary-test-1770784989/uiwith AlertTitle and AlertDescription, plus a retry Button - Toast notifications: Lightweight inline alert positioned at the top of the board (auto-dismiss after 5 seconds). Uses the existing
Alertcomponent styled as a floating notification. No need for a toast library in v1 — a simple state-driven alert that auto-clears is sufficient. - Form validation errors: Inline text below form fields using
text-[var(--error)]color
Security Considerations
Authentication
- The board reads data via public GET endpoints (matching existing backend pattern where reads are unauthenticated)
- Write operations (create, update, delete, label/assignment mutations) call authenticated endpoints
- The
@foundary-test-1770784989/api-clientmust be configured with a bearer token for write operations - If auth is disabled (
AUTH_ENABLED=false), write endpoints work without a token (same as backend behavior)
Input Validation
- Client-side: Title required, 1-200 chars (enforced before API call). Description max 2000 chars.
- Server-side: Backend validates independently via
app.BindAndValidate()— client validation is for UX only - XSS prevention: React's JSX escapes all interpolated strings by default. No
dangerouslySetInnerHTMLused anywhere. Label colors rendered viastyle={{ backgroundColor: label.color }}are safe since CSS color values cannot execute scripts.
Data Boundaries
- Board is scoped to a single
projectIdfrom the URL — no cross-project data leakage - User IDs in assignments are opaque strings (no PII exposure beyond what the user enters)
- No local storage or cookies used for task data
URL Parameter Safety
projectIdfrom URL params is passed directly to API calls as a path segment — the API client handles URL encoding- No user-controlled values are used in DOM attributes or event handlers unsafely
Performance Considerations
Expected Load
- Per spec: no pagination, all tasks returned. Suitable for < 500 tasks per project.
- Labels and assignments are small collections (< 100 each typically).
- All filtering is client-side — no additional API calls after initial load.
Rendering Performance
- React key strategy: Each
TaskCardkeyed bytask.idfor stable reconciliation during drag - Column rendering: Tasks grouped by status into 3 arrays once, not re-computed on every render. Use
useMemofor the grouping. - DnD performance:
@dnd-kituses CSS transforms (not layout recalculation) for drag animations, which is GPU-accelerated and performs well with hundreds of items.
Network Performance
- Initial load: 1 request for tasks + 1 for labels + N requests for task labels/assignments (parallelized)
- For a board with 50 tasks, this is ~102 parallel requests for associations. This is acceptable for v1 but should be noted as a future optimization point (backend could support
?include=labels,assignmentson the task list endpoint). - Mutations: single request per action, no debouncing needed (user-initiated discrete actions)
Caching
- No client-side caching layer in v1 (per spec: no state management library)
- Data is refetched when navigating to the board page
- After mutations (create, update, delete), affected data is updated in local state without a full refetch (except for create, which refreshes the full list to get server-generated fields)
Migration / Rollout Plan
Prerequisites
- The
data-modelsfeature must be fully implemented and deployed (provides all API endpoints) - A project must exist in the database (board requires a
projectId)
Phase 1: Project Setup
- Add dependencies to
apps/studio-ui/package.json:@dnd-kit/core@dnd-kit/sortable@foundary-test-1770784989/api-client(workspace dependency)
- Run
pnpm installfrom monorepo root
Phase 2: Routing Infrastructure
- Wrap app in
BrowserRouterinmain.tsx - Add
Routes/Routedefinitions inApp.tsx - Move existing dashboard content to a
DashboardPagecomponent at route/ - Add board route:
/projects/:projectId/board - Update sidebar navigation to include "Board" link
Phase 3: Types and API Layer
- Create
src/features/board/types/task.tswith TypeScript interfaces - Create
src/features/board/api.tswith API client wrapper functions - Wire API client with base URL from environment variable (
VITE_API_URL)
Phase 4: Core Board Components
BoardPage.tsx— page wrapper, data fetching via hooksuseTasks.ts— data fetching, mutation functions, state managementBoardView.tsx— DnD context provider, column layoutBoardColumn.tsx— droppable column with task countTaskCard.tsx— draggable card with title, priority badge, labels, assigneeBoardSkeleton.tsx— loading state
Phase 5: Task Dialogs
TaskCreateDialog.tsx— form with title, description, labels, assigneeTaskEditDialog.tsx— pre-populated form, status/priority dropdowns, delete action
Phase 6: Filtering
useBoardFilters.ts— filter state managementFilterBar.tsx— label multi-select, assignee filter, clear button- Wire filters into
BoardPageto filter the displayed tasks
Rollout Checklist
pnpm installsucceeds with new dependenciespnpm buildsucceeds inapps/studio-ui- Board route renders empty board when no tasks exist
- Tasks display in correct columns based on status
- Drag and drop changes task status with optimistic update
- Create task dialog creates task and adds to board
- Edit task dialog updates task and reflects changes
- Delete task removes from board after confirmation
- Label and assignee filters work correctly
- Error states display appropriately for failed API calls
- Loading skeleton shows during data fetch
- All UI uses CSS variables, no hardcoded colors
- Board renders correctly inside DashboardShell layout
Key Design Decisions
-
Feature folder structure (
src/features/board/) — Groups all board-related components, hooks, types, and API calls together rather than scattering acrosssrc/components/,src/hooks/, etc. This is standard for feature-scoped React code and keeps the feature self-contained. -
Custom hooks over context —
useTasksanduseBoardFiltersare plain hooks that manage state locally inBoardPage. No React Context needed since the board is a single page with a flat component tree. Props are passed at most 2 levels deep (BoardPage → BoardView → TaskCard), which doesn't warrant context. -
@dnd-kitoverreact-beautiful-dnd—@dnd-kitis actively maintained, has better accessibility support (keyboard DnD), smaller bundle size, and a more composable API.react-beautiful-dndis in maintenance mode. The spec recommends@dnd-kit. -
Optimistic updates for drag, refetch for create — Drag-and-drop must feel instant, so status changes use optimistic updates with rollback. Task creation refreshes the task list because the server generates
id,created_at, etc. that the client doesn't know upfront. -
N+1 association fetching in v1 — The board fetches labels and assignments per-task via parallel requests. This is a known tradeoff: it avoids backend changes and is acceptable for small task counts. A future backend
?include=parameter would eliminate this. -
Client-side filtering — Filters apply to the already-fetched task list in memory. No additional API calls. This is simpler and faster for the expected data volumes (< 500 tasks). Server-side filtering via query params is already supported by the backend but isn't needed for v1.
-
No toast library — Error notifications use a simple state-driven
Alertcomponent that auto-dismisses. Adding a toast library (e.g.,react-hot-toast) is unnecessary overhead for v1. If multiple features need toasts later, a shared toast system can be extracted. -
Assignee as free-text input — Since there is no user directory (per spec), the assignee field is a text input for user ID strings. The dropdown is populated by deriving unique user IDs from existing assignments on tasks, providing a "recent assignees" suggestion list.
-
Route structure:
/projects/:projectId/board— Uses path params (not query params) forprojectIdsince it's the primary resource scope. Matches the RESTful URL convention used by the backend API. -
API base URL via environment variable —
VITE_API_URLconfigures the API client base URL. Defaults to''(same origin) for production behind a reverse proxy. Set tohttp://localhost:8001for local development.
Open Question Resolutions
| Question | Resolution |
|---|---|
| Project context | Route uses /projects/:projectId/board. Sidebar shows "Board" link. For MVP, the user navigates to a project-specific URL. A project list/selector page is out of scope per spec. |
| Assignee data source | Free-text input with suggestions derived from existing task assignments. No user directory dependency. |
| DnD library | @dnd-kit/core + @dnd-kit/sortable per spec recommendation. |
| Optimistic vs. refetch | Optimistic for drag-and-drop status changes (with rollback on error). Refetch after create. Direct state update after edit/delete. |
| State management | Plain React hooks (useState, useMemo, useCallback). No Context, no external library. |
| Board URL | /projects/:projectId/board using React Router path params. |