diff --git a/apps/e2e/features/projects/subtask-hierarchy.feature b/apps/e2e/features/projects/subtask-hierarchy.feature new file mode 100644 index 000000000..beeedcf1b --- /dev/null +++ b/apps/e2e/features/projects/subtask-hierarchy.feature @@ -0,0 +1,173 @@ +@projects @interactions @subtasks +Feature: Hierarchical subtasks in task views + Tasks can have children through their parent task. The list API reports + how many direct children a task has (`subtask_count`) and how many of them + are in a status of category "done" (`subtask_done_count`). The Table views + (product backlog and sprints) and the Timeline show those parents with a + chevron and a "done/total" progress chip; expanding a row loads its + children on demand, indented under the parent, using the view's filters and + sort. Board cards show the same chip but do not nest. One "Subtask + setting in the View settings panel of Table and Timeline views chooses + "Nested, collapsed" (the default), "Nested, expanded" or "Hidden (flat + list)"; it is saved with the view like any other view setting. Rows the + user opens or closes by hand are remembered per user and per view. System "Subtask" + Tasks stay out of the top level of the Table and Timeline while nesting is + on and appear only under their parent (the "exclude_subtasks" list filter); + tasks that merely belong to an Epic stay in the list. With nesting off, or + on the Board, every task is shown as its own row or card. + + @authenticated + Rule: The task list API reports subtask counts and lists children + + Background: + Given the user already has a stored authenticated session + And a project named "E2E_SUB_API_PROJECT" exists + And the project has a task "Parent" with three subtasks, one of them in a "done" status + And one more subtask of "Parent" has been deleted + + Scenario: A parent's list entry carries its subtask counts + When the task list is requested + Then "Parent" should report subtask_count 3 and subtask_done_count 1 + And a task without subtasks should report no subtask counts + + Scenario: Listing by parent returns only that parent's children + When the task list is requested with parent_task_id of "Parent" + Then exactly the three live subtasks should be returned + + Scenario: exclude_subtasks hides subtasks but keeps tasks of an Epic + Given the project has an Epic with one task + When the task list is requested with exclude_subtasks + Then the Epic, its task, "Parent" and a task without subtasks should be returned + And no subtask of "Parent" should be returned + + Scenario: Children can be filtered by status + When the task list is requested with parent_task_id of "Parent" and the "done" status + Then only the done subtask should be returned + + Scenario: Listing children ignores the sprint filter + Given "Parent" is in a sprint and its subtasks are not + When the children of "Parent" are requested together with that sprint filter + Then all three subtasks should still be returned + + @authenticated + Rule: The product backlog Table nests subtasks under their parents + + Background: + Given the user already has a stored authenticated session + And a project named "E2E_SUB_TABLE_PROJECT" exists + And the project has a task "Parent" with three subtasks, one of them done + And the user has navigated to the "Product Backlog" Table view + + Scenario: A parent shows its progress and starts collapsed + Then "Parent" should show an "Expand 3 subtasks" chevron and a "1/3" chip + And none of its subtasks should be visible + + Scenario: Expanding a parent shows its subtasks and collapsing hides them + When the user expands "Parent" + Then its three subtasks should be visible + When the user collapses "Parent" + Then its subtasks should be hidden again + + Scenario: A task without subtasks has no chevron + Given the project has a task "Lone" without subtasks + Then "Lone" should have no expand chevron and no progress chip + + Scenario: Subtasks do not appear as top-level rows + Then no subtask of "Parent" should be listed as a top-level row + + Scenario: A subtask can be added under an expanded parent + When the user expands "Parent" + And the user adds a subtask "Fourth" with the "Add subtask" button + Then "Fourth" should appear under "Parent" + And "Parent" should show a "1/4" chip + + Scenario: Clicking the chevron does not open the task + When the user expands "Parent" + Then the task detail should not be open + + Scenario: Clicking a subtask opens its details + When the user expands "Parent" + And the user clicks the subtask "Child done" + Then the task detail for "Child done" should open + + Scenario: Expanded rows are remembered after a reload + When the user expands "Parent" + And the page is reloaded + Then the subtasks of "Parent" should still be visible + + @authenticated + Rule: The Subtasks view setting controls nesting + + Background: + Given the user already has a stored authenticated session + And a project named "E2E_SUB_SETTING_PROJECT" exists + And the project has a task "Parent" with three subtasks, one of them done + And the user has navigated to the "Product Backlog" Table view + + Scenario: The setting defaults to "Nested, collapsed" + When the user opens the View settings panel + Then the "Subtasks" setting should show "Nested, collapsed" + + Scenario: "Nested, expanded" shows subtasks without clicking the chevron + When the user chooses "Nested, expanded" and saves only for themselves + Then the three subtasks of "Parent" should be visible + And "Parent" should show a "Collapse 3 subtasks" chevron + + Scenario: A row can still be collapsed by hand when subtasks start expanded + Given the "Subtasks" setting is "Nested, expanded" + When the user collapses "Parent" + Then its subtasks should be hidden and the chevron should offer to expand + + Scenario: "Hidden (flat list)" removes the chevrons and the chips + When the user chooses "Hidden (flat list)" and saves only for themselves + Then no parent should show a chevron or a progress chip + And the subtasks should be shown as plain top-level rows + + Scenario: Switching back to nested restores the chevrons + Given the "Subtasks" setting is "Hidden (flat list)" + When the user chooses "Nested, collapsed" and saves only for themselves + Then "Parent" should show its chevron and its "1/3" chip + And its subtasks should be hidden + + Scenario: The setting is remembered after a reload + When the user chooses "Nested, expanded" and saves only for themselves + And the page is reloaded + Then the subtasks of "Parent" should be visible + And the "Subtasks" setting should show "Nested, expanded" + + @authenticated + Rule: The Timeline nests tasks under their Epics + + Background: + Given the user already has a stored authenticated session + And a project named "E2E_SUB_TIMELINE_PROJECT" exists + And the project has an Epic "Epic" with two child tasks + And the user has navigated to the Timeline interaction + + Scenario: An Epic shows its child count and expands to its tasks + Then "Epic" should show an "Expand 2 subtasks" chevron + When the user expands "Epic" + Then both child tasks should be visible under "Epic" + + Scenario: The Subtasks view setting also applies to the Timeline + When the user chooses "Nested, expanded" and saves only for themselves + Then both child tasks should be visible under "Epic" + + @authenticated + Rule: Board cards show a subtask progress chip + + Background: + Given the user already has a stored authenticated session + And a project named "E2E_SUB_BOARD_PROJECT" exists + And the project has a task "Parent" with three subtasks, one of them done + And the user has navigated to the "Product Backlog" Board view + + Scenario: A card with subtasks shows "done/total" + Then the "Parent" card should show a "1/3" chip + + Scenario: The Board offers no Subtasks view setting + When the user opens the View settings panel + Then there should be no "Subtasks" setting + + Scenario: Subtasks stay visible as their own cards + Then the subtasks of "Parent" should each be shown as a card diff --git a/apps/e2e/tests/projects/subtask-hierarchy.spec.ts b/apps/e2e/tests/projects/subtask-hierarchy.spec.ts new file mode 100644 index 000000000..b3fd4bb9d --- /dev/null +++ b/apps/e2e/tests/projects/subtask-hierarchy.spec.ts @@ -0,0 +1,532 @@ +// spec: features/projects/subtask-hierarchy.feature +// seed: tests/seed.spec.ts + +import { + type APIRequestContext, + expect, + type Page, + test, +} from "@playwright/test"; +import { + API_URL, + authRequest, + BASE_URL, + cleanupProjectsByPrefix, + createProject, + newRunId, + signIn, +} from "../helpers/e2e-api"; + +const PREFIX = "E2E_SUB_"; +const RUN_ID = newRunId(); + +// ─── API helpers ───────────────────────────────────────────────────────────── + +interface Named { + id: string; + name: string; + category?: string; + is_system?: boolean; +} + +async function listNamed( + request: APIRequestContext, + projectId: string, + path: "task-statuses" | "task-types", +): Promise { + const response = await request.get( + `${API_URL}/projects/${projectId}/${path}`, + ); + expect(response.ok()).toBeTruthy(); + return (await response.json()).data.items as Named[]; +} + +interface ProjectKit { + projectId: string; + todoId: string; + doneId: string; + /** A normal (non-system) task type. */ + taskTypeId: string; + epicTypeId: string; +} + +async function setupProject( + request: APIRequestContext, + suffix: string, +): Promise { + const projectId = await createProject( + request, + `${PREFIX}${suffix}_${RUN_ID}`, + ); + const statuses = await listNamed(request, projectId, "task-statuses"); + const types = await listNamed(request, projectId, "task-types"); + const todo = statuses.find((s) => s.category === "todo") ?? statuses[0]; + const done = statuses.find((s) => s.category === "done"); + const normal = types.find((t) => !t.is_system); + const epic = types.find((t) => t.is_system && t.name === "Epic"); + if (!done || !normal || !epic) { + throw new Error("project is missing a done status or a task type"); + } + return { + projectId, + todoId: todo.id, + doneId: done.id, + taskTypeId: normal.id, + epicTypeId: epic.id, + }; +} + +async function postTask( + request: APIRequestContext, + projectId: string, + data: Record, +): Promise { + const response = await request.post( + `${API_URL}/projects/${projectId}/tasks`, + { + data, + }, + ); + expect(response.ok(), await response.text()).toBeTruthy(); + return (await response.json()).data.id as string; +} + +/** "Parent" with subtasks "Child done" (done), "Child todo A", "Child todo B". */ +async function seedParent( + request: APIRequestContext, + kit: ProjectKit, + extra: Record = {}, +): Promise { + const parentId = await postTask(request, kit.projectId, { + title: "Parent", + task_type_id: kit.taskTypeId, + status_id: kit.todoId, + ...extra, + }); + for (const [title, status] of [ + ["Child done", kit.doneId], + ["Child todo A", kit.todoId], + ["Child todo B", kit.todoId], + ] as const) { + await postTask(request, kit.projectId, { + title, + task_type_id: kit.taskTypeId, + status_id: status, + parent_task_id: parentId, + }); + } + return parentId; +} + +interface ListedTask { + id: string; + title: string; + subtask_count?: number; + subtask_done_count?: number; +} + +async function listTasks( + request: APIRequestContext, + projectId: string, + query: string, +): Promise { + const response = await request.get( + `${API_URL}/projects/${projectId}/tasks?page_size=100&${query}`, + ); + expect(response.ok(), await response.text()).toBeTruthy(); + return (await response.json()).data.items as ListedTask[]; +} + +// ─── UI helpers ────────────────────────────────────────────────────────────── + +async function openBacklogView( + page: Page, + projectId: string, + view: "Table" | "Board", +): Promise { + await page.goto(`${BASE_URL}/projects/${projectId}/interactions/backlog`); + await expect( + page.getByRole("heading", { name: "Product Backlog" }), + ).toBeVisible({ timeout: 30_000 }); + await page.getByRole("button", { name: view, exact: true }).click(); +} + +async function openTimeline(page: Page, projectId: string): Promise { + await page.goto(`${BASE_URL}/projects/${projectId}/interactions/timeline`); + await expect(page.getByRole("heading", { name: "Timeline" })).toBeVisible({ + timeout: 30_000, + }); +} + +const expandButton = (page: Page, count: number) => + page.getByRole("button", { name: `Expand ${count} subtasks`, exact: true }); +const collapseButton = (page: Page, count: number) => + page.getByRole("button", { name: `Collapse ${count} subtasks`, exact: true }); +const title = (page: Page, text: string) => + page.getByText(text, { exact: true }); + +async function openViewSettings(page: Page): Promise { + await page + .getByRole("button", { name: "View settings", exact: true }) + .click(); + await expect(page.getByText("View settings", { exact: true })).toBeVisible(); +} + +const settingRow = (page: Page, label: string) => + page + .getByText(label, { exact: true }) + .locator("xpath=following-sibling::button"); + +/** Picks `option` for the "Subtasks" setting and saves it for the current user. */ +async function chooseSubtaskMode(page: Page, option: string): Promise { + await openViewSettings(page); + await settingRow(page, "Subtasks").click(); + await page.getByRole("button", { name: option, exact: true }).last().click(); + await page.getByRole("button", { name: "Save only for me" }).click(); + await expect( + page.getByRole("button", { name: "Save only for me" }), + ).not.toBeVisible({ timeout: 5_000 }); +} + +test.beforeEach(async ({ request }) => { + await cleanupProjectsByPrefix(request, PREFIX); + await authRequest(request); +}); + +test.afterEach(async ({ request }) => { + await cleanupProjectsByPrefix(request, PREFIX); +}); + +// =========================================================================== +// Rule: The task list API reports subtask counts and lists children +// =========================================================================== + +test.describe("The task list API reports subtask counts and lists children", () => { + let kit: ProjectKit; + let parentId: string; + + test.beforeEach(async ({ request }) => { + kit = await setupProject(request, "API"); + parentId = await seedParent(request, kit); + await postTask(request, kit.projectId, { + title: "Lone", + task_type_id: kit.taskTypeId, + }); + const goneId = await postTask(request, kit.projectId, { + title: "Child gone", + task_type_id: kit.taskTypeId, + status_id: kit.doneId, + parent_task_id: parentId, + }); + const deleted = await request.delete( + `${API_URL}/projects/${kit.projectId}/tasks/${goneId}`, + ); + expect(deleted.ok()).toBeTruthy(); + }); + + test("A parent's list entry carries its subtask counts", async ({ + request, + }) => { + const tasks = await listTasks(request, kit.projectId, ""); + const parent = tasks.find((t) => t.title === "Parent"); + expect(parent?.subtask_count).toBe(3); + expect(parent?.subtask_done_count).toBe(1); + const lone = tasks.find((t) => t.title === "Lone"); + expect(lone?.subtask_count ?? 0).toBe(0); + expect(lone?.subtask_done_count ?? 0).toBe(0); + }); + + test("Listing by parent returns only that parent's children", async ({ + request, + }) => { + const children = await listTasks( + request, + kit.projectId, + `parent_task_id=${parentId}`, + ); + expect(children.map((t) => t.title).sort()).toEqual([ + "Child done", + "Child todo A", + "Child todo B", + ]); + }); + + test("exclude_subtasks hides subtasks but keeps tasks of an Epic", async ({ + request, + }) => { + const epicId = await postTask(request, kit.projectId, { + title: "Epic", + task_type_id: kit.epicTypeId, + }); + await postTask(request, kit.projectId, { + title: "In epic", + task_type_id: kit.taskTypeId, + parent_task_id: epicId, + }); + const titles = ( + await listTasks(request, kit.projectId, "exclude_subtasks=true") + ) + .map((t) => t.title) + .sort(); + expect(titles).toEqual(["Epic", "In epic", "Lone", "Parent"]); + }); + + test("Children can be filtered by status", async ({ request }) => { + const children = await listTasks( + request, + kit.projectId, + `parent_task_id=${parentId}&status_ids=${kit.doneId}`, + ); + expect(children.map((t) => t.title)).toEqual(["Child done"]); + }); + + test("Listing children ignores the sprint filter", async ({ request }) => { + const sprintResponse = await request.post( + `${API_URL}/projects/${kit.projectId}/sprints`, + { data: { name: `${PREFIX}SPRINT_${RUN_ID}` } }, + ); + expect(sprintResponse.ok()).toBeTruthy(); + const sprintId = (await sprintResponse.json()).data.id as string; + const move = await request.patch( + `${API_URL}/projects/${kit.projectId}/tasks/${parentId}`, + { data: { sprint_id: sprintId } }, + ); + expect(move.ok(), await move.text()).toBeTruthy(); + const children = await listTasks( + request, + kit.projectId, + `parent_task_id=${parentId}&sprint_id=${sprintId}`, + ); + expect(children).toHaveLength(3); + }); +}); + +// =========================================================================== +// Rule: The product backlog Table nests subtasks under their parents +// =========================================================================== + +test.describe("The product backlog Table nests subtasks under their parents", () => { + let kit: ProjectKit; + + test.beforeEach(async ({ request, page }) => { + kit = await setupProject(request, "TABLE"); + await seedParent(request, kit); + await signIn(page); + await openBacklogView(page, kit.projectId, "Table"); + }); + + test("A parent shows its progress and starts collapsed", async ({ page }) => { + await expect(title(page, "Parent")).toBeVisible(); + await expect(expandButton(page, 3)).toBeVisible(); + await expect(page.getByText("1/3", { exact: true })).toBeVisible(); + await expect(title(page, "Child done")).toHaveCount(0); + }); + + test("Expanding a parent shows its subtasks and collapsing hides them", async ({ + page, + }) => { + await expandButton(page, 3).click(); + for (const child of ["Child done", "Child todo A", "Child todo B"]) { + await expect(title(page, child)).toBeVisible(); + } + await collapseButton(page, 3).click(); + await expect(title(page, "Child done")).toHaveCount(0); + }); + + test("A task without subtasks has no chevron", async ({ page, request }) => { + await postTask(request, kit.projectId, { + title: "Lone", + task_type_id: kit.taskTypeId, + }); + await page.reload(); + await expect(title(page, "Lone")).toBeVisible(); + // Only "Parent" is expandable. + await expect( + page.getByRole("button", { name: /^Expand \d+ subtasks$/ }), + ).toHaveCount(1); + }); + + test("Subtasks do not appear as top-level rows", async ({ page }) => { + await expect(title(page, "Parent")).toBeVisible(); + await expect(title(page, "Child todo A")).toHaveCount(0); + }); + + test("A subtask can be added under an expanded parent", async ({ page }) => { + await expandButton(page, 3).click(); + await page.getByRole("button", { name: "Add subtask" }).click(); + await page.getByPlaceholder("Subtask title").fill("Fourth"); + await page.getByPlaceholder("Subtask title").press("Enter"); + await expect(title(page, "Fourth")).toBeVisible(); + await expect(page.getByText("1/4", { exact: true })).toBeVisible(); + }); + + test("Clicking the chevron does not open the task", async ({ page }) => { + await expandButton(page, 3).click(); + await expect(title(page, "Child done")).toBeVisible(); + await expect(page).not.toHaveURL(/\/tasks\//); + }); + + test("Clicking a subtask opens its details", async ({ page }) => { + await expandButton(page, 3).click(); + await title(page, "Child done").click(); + await expect( + page.getByRole("dialog").getByText("Child done").first(), + ).toBeVisible(); + }); + + test("Expanded rows are remembered after a reload", async ({ page }) => { + await expandButton(page, 3).click(); + await expect(title(page, "Child done")).toBeVisible(); + await page.reload(); + await expect(title(page, "Child done")).toBeVisible(); + }); +}); + +// =========================================================================== +// Rule: The Subtasks view setting controls nesting +// =========================================================================== + +test.describe("The Subtasks view setting controls nesting", () => { + let kit: ProjectKit; + + test.beforeEach(async ({ request, page }) => { + kit = await setupProject(request, "SETTING"); + await seedParent(request, kit); + await signIn(page); + await openBacklogView(page, kit.projectId, "Table"); + }); + + test('The setting defaults to "Nested, collapsed"', async ({ page }) => { + await openViewSettings(page); + await expect(settingRow(page, "Subtasks")).toHaveText("Nested, collapsed"); + }); + + test('"Nested, expanded" shows subtasks without clicking the chevron', async ({ + page, + }) => { + await chooseSubtaskMode(page, "Nested, expanded"); + for (const child of ["Child done", "Child todo A", "Child todo B"]) { + await expect(title(page, child)).toBeVisible(); + } + await expect(collapseButton(page, 3)).toBeVisible(); + }); + + test("A row can still be collapsed by hand when subtasks start expanded", async ({ + page, + }) => { + await chooseSubtaskMode(page, "Nested, expanded"); + await collapseButton(page, 3).click(); + await expect(title(page, "Child done")).toHaveCount(0); + await expect(expandButton(page, 3)).toBeVisible(); + }); + + test('"Hidden (flat list)" removes the chevrons and the chips', async ({ + page, + }) => { + await chooseSubtaskMode(page, "Hidden (flat list)"); + await expect( + page.getByRole("button", { name: /^Expand \d+ subtasks$/ }), + ).toHaveCount(0); + await expect(page.getByText("1/3", { exact: true })).toHaveCount(0); + await expect(title(page, "Parent")).toBeVisible(); + // Without nesting the subtasks are plain top-level rows again. + await expect(title(page, "Child todo A")).toBeVisible(); + }); + + test("Switching back to nested restores the chevrons", async ({ page }) => { + await chooseSubtaskMode(page, "Hidden (flat list)"); + await chooseSubtaskMode(page, "Nested, collapsed"); + await expect(expandButton(page, 3)).toBeVisible(); + await expect(page.getByText("1/3", { exact: true })).toBeVisible(); + await expect(title(page, "Child todo A")).toHaveCount(0); + }); + + test("The setting is remembered after a reload", async ({ page }) => { + await chooseSubtaskMode(page, "Nested, expanded"); + await page.reload(); + await expect(title(page, "Child done")).toBeVisible(); + await openViewSettings(page); + await expect(settingRow(page, "Subtasks")).toHaveText("Nested, expanded"); + }); +}); + +// =========================================================================== +// Rule: The Timeline nests tasks under their Epics +// =========================================================================== + +test.describe("The Timeline nests tasks under their Epics", () => { + let kit: ProjectKit; + + test.beforeEach(async ({ request, page }) => { + kit = await setupProject(request, "TIMELINE"); + const epicId = await postTask(request, kit.projectId, { + title: "Epic", + task_type_id: kit.epicTypeId, + status_id: kit.todoId, + }); + for (const name of ["Epic task A", "Epic task B"]) { + await postTask(request, kit.projectId, { + title: name, + task_type_id: kit.taskTypeId, + status_id: kit.todoId, + parent_task_id: epicId, + }); + } + await signIn(page); + await openTimeline(page, kit.projectId); + }); + + test("An Epic shows its child count and expands to its tasks", async ({ + page, + }) => { + await expect(title(page, "Epic")).toBeVisible(); + await expect(title(page, "Epic task A")).toHaveCount(0); + await expandButton(page, 2).click(); + await expect(title(page, "Epic task A")).toBeVisible(); + await expect(title(page, "Epic task B")).toBeVisible(); + }); + + test("The Subtasks view setting also applies to the Timeline", async ({ + page, + }) => { + await chooseSubtaskMode(page, "Nested, expanded"); + await expect(title(page, "Epic task A")).toBeVisible(); + await expect(title(page, "Epic task B")).toBeVisible(); + }); +}); + +// =========================================================================== +// Rule: Board cards show a subtask progress chip +// =========================================================================== + +test.describe("Board cards show a subtask progress chip", () => { + let kit: ProjectKit; + + test.beforeEach(async ({ request, page }) => { + kit = await setupProject(request, "BOARD"); + await seedParent(request, kit); + const view = await request.post( + `${API_URL}/projects/${kit.projectId}/views?context=backlog`, + { data: { name: "Board", view_type: "board" } }, + ); + expect(view.ok()).toBeTruthy(); + await signIn(page); + await openBacklogView(page, kit.projectId, "Board"); + }); + + test('A card with subtasks shows "done/total"', async ({ page }) => { + await expect(title(page, "Parent")).toBeVisible(); + await expect(page.getByText("1/3", { exact: true })).toBeVisible(); + }); + + test("The Board offers no Subtasks view setting", async ({ page }) => { + await openViewSettings(page); + await expect(page.getByText("Sort by", { exact: true })).toBeVisible(); + await expect(page.getByText("Subtasks", { exact: true })).toHaveCount(0); + }); + + test("Subtasks stay visible as their own cards", async ({ page }) => { + await expect(title(page, "Parent")).toBeVisible(); + await expect(title(page, "Child todo A")).toBeVisible(); + }); +}); diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index 3f98b2dd4..99d5444d6 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -53,6 +53,7 @@ import { import { allTasksQueryOptions, bulkMoveViewTaskPositions, + type ChildTaskFilters, type CustomFieldFilterQuery, clearMyViewConfig, createSprint, @@ -106,6 +107,7 @@ import { RenameViewDialog } from "./rename-view-dialog"; import { RoadmapView } from "./roadmap-view"; import type { SprintFormPayload } from "./sprint-form-modal"; import { TaskDetailModal } from "./task-detail-modal"; +import { resolveSubtaskMode } from "./use-subtask-expansion"; import { UNASSIGNED_FILTER_ID, ViewSettingsPanel } from "./view-settings-panel"; import { type EpicsPagination, @@ -652,6 +654,13 @@ export function InteractionLayout({ const isRealView = !!activeViewId && !activeViewId.startsWith("__default-"); const effectiveViewId = isManualSort && isRealView ? activeViewId : undefined; + + // Table and Timeline nest subtasks under their parents, so the top-level + // list leaves them out (the Board stays flat and keeps them as cards). + const subtaskViewKey = effectiveViewId ?? activeView?.id ?? "default"; + const excludeSubtasks = + resolveSubtaskMode(activeViewConfig) !== "hidden" && + (activeView?.layout === "Table" || activeView?.layout === "Roadmap"); const hasExplicitFilterConfig = activeViewConfig?.filters !== undefined; const apiFilters = useMemo(() => { let assignee_ids: string[] | undefined; @@ -817,6 +826,7 @@ export function InteractionLayout({ assigneeNull: columnBy !== "assignee" ? apiFilters.assignee_null : undefined, taskTypeIds: columnBy !== "type" ? apiFilters.task_type_ids : undefined, + excludeSubtasks: excludeSubtasks || undefined, pageSize: initialColPageSize, sumField: activeViewConfig?.field_sum, sortBy: activeViewConfig?.sort_by, @@ -843,9 +853,47 @@ export function InteractionLayout({ activeViewConfig?.sort_by, effectiveViewId, debouncedSearchQuery, + excludeSubtasks, ], ); + // Filters carried onto the children listed under an expanded parent row: + // the view's own filters and sort, minus what scopes the top level. + const childFilters = useMemo((): ChildTaskFilters => { + const { + sprintId: _sprintId, + sprintIds: _sprintIds, + taskTypeIds: _taskTypeIds, + taskTypeNull: _taskTypeNull, + sumField: _sumField, + viewId: _viewId, + pageSize: _pageSize, + cursor: _cursor, + parentTaskId: _parentTaskId, + excludeSubtasks: _excludeSubtasks, + ...rest + } = colBaseOpts; + return { + ...rest, + // Grouping by status/assignee splits that dimension across groups, so + // colBaseOpts omits it; children should still honour the user's filter. + statusIds: apiFilters.status_ids, + assigneeIds: apiFilters.assignee_ids, + assigneeNull: apiFilters.assignee_null, + }; + }, [colBaseOpts, apiFilters]); + const childFiltersActive = useMemo( + () => + Object.entries(childFilters).some( + ([k, v]) => + k !== "sortBy" && + v !== undefined && + v !== false && + !(Array.isArray(v) && v.length === 0), + ), + [childFilters], + ); + // Tracks per-column total visible count so WS refetches restore the same depth. const [colExpandedPageSizes, setColExpandedPageSizes] = useState< Record @@ -930,6 +978,7 @@ export function InteractionLayout({ assigneeIds: apiFilters.assignee_ids, assigneeNull: apiFilters.assignee_null, taskTypeIds: apiFilters.task_type_ids, + excludeSubtasks: excludeSubtasks || undefined, sortBy: activeViewConfig?.sort_by, viewId: effectiveViewId, search: debouncedSearchQuery || undefined, @@ -951,6 +1000,7 @@ export function InteractionLayout({ activeViewConfig?.sort_by, effectiveViewId, debouncedSearchQuery, + excludeSubtasks, ], ); @@ -2139,6 +2189,9 @@ export function InteractionLayout({ /> ) : activeView?.layout === "Roadmap" ? ( boolean; + onToggle: (taskId: string) => void; + /** The view's filters/sort, applied to each parent's children. */ + filters: ChildTaskFilters; + filtersActive: boolean; } // ── Component ──────────────────────────────────────────────────────────────── @@ -134,8 +148,10 @@ export function ListGroup({ groupPagination, totalCount, apiFieldSum, + nesting, }: ListGroupProps) { const { t } = useTranslation("projects"); + const isMobile = useIsMobile(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); @@ -281,9 +297,9 @@ export function ListGroup({ // ── Column-header row ───────────────────────────────────────────────────── const columnHeaders = ( -
- {isDraggable &&
} -
+
+ {isDraggable && !isMobile &&
} +
{t("board.listGroup.columnHeaders.id")}
@@ -309,7 +325,7 @@ export function ListGroup({ // ── Task row renderer ───────────────────────────────────────────────────── - const renderTaskRow = ( + const renderTaskRowInner = ( task: Task, index: number, groupKey: string, @@ -452,7 +468,7 @@ export function ListGroup({ customFields={customFields} visibleFields={visibleFields} onClick={() => onTaskClick(task)} - showDragHandle={isDraggable} + showDragHandle={isDraggable && !isMobile} isDragging={draggingId === task.id} canEdit={canEdit} onUpdateTaskField={onUpdateTaskField} @@ -467,11 +483,66 @@ export function ListGroup({ ? () => moveTaskToGroupDef(task, nextGroupDef) : undefined } + subtasks={ + nesting + ? { + expanded: nesting.isExpanded(task.id), + onToggle: () => nesting.onToggle(task.id), + } + : undefined + } />
); + const epicTypeId = findEpicType(taskTypes)?.id; + const renderTaskRow = ( + listed: Task, + index: number, + groupKey: string, + swimKey?: string, + ) => { + // An Epic's children are also listed flat here, so the Table doesn't + // nest them (only the Timeline does). + const task = + epicTypeId && listed.task_type_id === epicTypeId + ? { ...listed, subtask_count: 0, subtask_done_count: 0 } + : listed; + return ( + + {renderTaskRowInner(task, index, groupKey, swimKey)} + {nesting?.isExpanded(task.id) && (task.subtask_count ?? 0) > 0 && ( + + )} + + ); + }; + const showAddTask = canCreate && groupDef.key !== "__none" && diff --git a/apps/web/src/components/projects/interactions/list-view.tsx b/apps/web/src/components/projects/interactions/list-view.tsx index 452b37d2a..e0a4086cd 100644 --- a/apps/web/src/components/projects/interactions/list-view.tsx +++ b/apps/web/src/components/projects/interactions/list-view.tsx @@ -2,7 +2,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { LoadMoreSentinel } from "@/components/shared/load-more-sentinel"; -import type { Sprint, Task, ViewConfig } from "@/lib/interaction-api"; +import type { + ChildTaskFilters, + Sprint, + Task, + ViewConfig, +} from "@/lib/interaction-api"; import type { CustomFieldDefinition, ProjectMember, @@ -12,6 +17,10 @@ import type { import { ListGroup } from "./list-group"; import type { SprintFormPayload } from "./sprint-form-modal"; +import { + resolveSubtaskMode, + useSubtaskExpansion, +} from "./use-subtask-expansion"; import { applyStatusFilterToColumnDefs, type ColumnGroupDef, @@ -68,6 +77,12 @@ export interface ListViewProps { /** Opens the delete-confirmation dialog — Mod+Backspace shortcut. */ onDeleteTask?: (taskId: string) => void; onCollapseChange?: (collapsedColumns: string[]) => void; + /** The view's filters/sort, applied to subtasks listed under a parent. */ + childFilters?: ChildTaskFilters; + /** True while any filter or search narrows the list. */ + filtersActive?: boolean; + /** Identifies the view for remembering which rows are expanded. */ + viewKey?: string; columnPagination?: Record< string, { @@ -109,9 +124,37 @@ export function ListView({ onCreateSprint, onDeleteTask, onCollapseChange, + childFilters, + filtersActive = false, + viewKey = "default", columnPagination, }: ListViewProps) { const { t } = useTranslation("projects"); + const subtaskMode = resolveSubtaskMode(viewConfig); + const subtaskExpansion = useSubtaskExpansion( + projectId, + viewKey, + subtaskMode === "expanded", + ); + const nestingEnabled = subtaskMode !== "hidden"; + const nesting = useMemo( + () => + nestingEnabled + ? { + isExpanded: subtaskExpansion.isExpanded, + onToggle: subtaskExpansion.toggle, + filters: childFilters ?? {}, + filtersActive, + } + : undefined, + [ + nestingEnabled, + subtaskExpansion.isExpanded, + subtaskExpansion.toggle, + childFilters, + filtersActive, + ], + ); const columnBy = viewConfig?.column_by ?? "status"; const swimlaneBy = viewConfig?.swimlanes; const fieldSum = viewConfig?.field_sum; @@ -279,6 +322,7 @@ export function ListView({ handleGroupCollapseChange(grp.key, isCollapsed) : undefined } + nesting={nesting} groupPagination={columnPagination?.[grp.key]} totalCount={columnPagination?.[grp.key]?.totalCount} apiFieldSum={columnPagination?.[grp.key]?.fieldSum} diff --git a/apps/web/src/components/projects/interactions/pick-subtask-mode.test.ts b/apps/web/src/components/projects/interactions/pick-subtask-mode.test.ts new file mode 100644 index 000000000..026d4f4a4 --- /dev/null +++ b/apps/web/src/components/projects/interactions/pick-subtask-mode.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { pickSubtaskMode } from "./view-settings-panel"; + +describe("pickSubtaskMode", () => { + it("stores expanded and hidden as chosen", () => { + expect(pickSubtaskMode("expanded", {})).toBe("expanded"); + expect(pickSubtaskMode("hidden", {})).toBe("hidden"); + }); + + it("leaves collapsed unset when the team default is already collapsed", () => { + expect(pickSubtaskMode("collapsed", {})).toBeUndefined(); + expect( + pickSubtaskMode("collapsed", { subtask_mode: "collapsed" }), + ).toBeUndefined(); + }); + + it("stores collapsed explicitly so it can override another team default", () => { + expect(pickSubtaskMode("collapsed", { subtask_mode: "expanded" })).toBe( + "collapsed", + ); + expect(pickSubtaskMode("collapsed", { subtask_mode: "hidden" })).toBe( + "collapsed", + ); + }); +}); diff --git a/apps/web/src/components/projects/interactions/roadmap-nesting.test.tsx b/apps/web/src/components/projects/interactions/roadmap-nesting.test.tsx new file mode 100644 index 000000000..a2302320c --- /dev/null +++ b/apps/web/src/components/projects/interactions/roadmap-nesting.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task } from "@/lib/interaction-api"; +import { RoadmapView } from "./roadmap-view"; +import { resetSubtaskViewState } from "./use-subtask-expansion"; + +const get = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api-client", () => ({ + apiClient: { instance: { get, post: vi.fn() } }, +})); + +const makeTask = (overrides: Partial = {}): Task => ({ + id: "epic-1", + project_id: "p1", + title: "Epic one", + task_number: 1, + importance: 0, + custom_fields: {}, + status_id: "s1", + start_date: "2026-03-01", + due_date: "2026-03-10", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + assignment_mode: "manual", + ...overrides, +}); + +describe("RoadmapView nested tasks", () => { + beforeEach(() => { + get.mockReset(); + localStorage.clear(); + resetSubtaskViewState(); + }); + + it("expands an epic to load and show its child tasks", async () => { + get.mockResolvedValue({ + data: { + data: { + items: [makeTask({ id: "c1", title: "Child task" })], + page_size: 50, + next_cursor: null, + }, + }, + }); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + expect(screen.getByText("0/1")).toBeTruthy(); + expect(screen.queryByText("Child task")).toBeNull(); + fireEvent.click( + screen + .getAllByRole("button") + .find((b) => b.getAttribute("aria-expanded") !== null) as HTMLElement, + ); + expect(await screen.findByText("Child task")).toBeTruthy(); + expect(get.mock.calls[0][1].params.parent_task_id).toBe("epic-1"); + }); +}); diff --git a/apps/web/src/components/projects/interactions/roadmap-view.tsx b/apps/web/src/components/projects/interactions/roadmap-view.tsx index 3d15c7e6d..3825bce8d 100644 --- a/apps/web/src/components/projects/interactions/roadmap-view.tsx +++ b/apps/web/src/components/projects/interactions/roadmap-view.tsx @@ -1,8 +1,15 @@ -import { CalendarDays } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { CalendarDays, ChevronRight, Loader2 } from "lucide-react"; +import { Fragment, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { Sprint, Task, ViewConfig } from "@/lib/interaction-api"; +import { + type ChildTaskFilters, + childTasksInfiniteQueryOptions, + type Sprint, + type Task, + type ViewConfig, +} from "@/lib/interaction-api"; import type { CustomFieldDefinition, ProjectMember, @@ -11,6 +18,10 @@ import type { } from "@/lib/project-api"; import { cn } from "@/lib/utils"; import { AddTaskRow } from "./add-task-row"; +import { + resolveSubtaskMode, + useSubtaskExpansion, +} from "./use-subtask-expansion"; import { applyStatusFilterToColumnDefs, buildColumnDropUpdate, @@ -43,6 +54,11 @@ function fmtDate(ms: number): string { // ─── Types ──────────────────────────────────────────────────────────────────── interface RoadmapViewProps { + projectId?: string; + /** The view's filters/sort, applied to the tasks listed under a parent. */ + childFilters?: ChildTaskFilters; + /** Identifies the view for remembering which rows are expanded. */ + viewKey?: string; tasks: Task[]; statuses: TaskStatus[]; taskTypes: TaskType[]; @@ -89,6 +105,9 @@ interface BarData { // ─── Component ──────────────────────────────────────────────────────────────── export function RoadmapView({ + projectId, + childFilters, + viewKey = "default", tasks, statuses, taskTypes, @@ -108,6 +127,13 @@ export function RoadmapView({ pagination, }: RoadmapViewProps) { const { t } = useTranslation("projects"); + const subtaskMode = resolveSubtaskMode(viewConfig); + const expansion = useSubtaskExpansion( + projectId ?? "", + viewKey, + subtaskMode === "expanded", + ); + const nestable = !!projectId && subtaskMode !== "hidden"; const [draggingId, setDraggingId] = useState(null); const [overGroupKey, setOverGroupKey] = useState(null); const [overTaskId, setOverTaskId] = useState(null); @@ -283,7 +309,17 @@ export function RoadmapView({ }); } - return { leftPx, widthPx, singleDate, overdue, tooltip }; + // Nested rows can fall outside the range computed from the top-level + // tasks, so keep their bars inside the chart. + const clampedLeft = Math.min(Math.max(0, leftPx), chartWidth - 8); + const clampedRight = Math.min(chartWidth, Math.max(leftPx + widthPx, 8)); + return { + leftPx: clampedLeft, + widthPx: Math.max(8, clampedRight - clampedLeft), + singleDate, + overdue, + tooltip, + }; } // ── Shared chart cell ───────────────────────────────────────────────────── @@ -334,6 +370,90 @@ export function RoadmapView({ } // ── Task row ────────────────────────────────────────────────────────────── + /** The clickable row (name column + bar). `depth` > 0 marks a nested child. */ + function renderRowBody(task: Task, depth: number) { + const bar = getBar(task); + const type = taskTypes.find((tt) => tt.id === task.task_type_id) ?? null; + const count = task.subtask_count ?? 0; + const isOpen = expansion.isExpanded(task.id); + return ( + // biome-ignore lint/a11y/useSemanticElements: the chevron is a real button and cannot nest inside one +
onTaskClick(task)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onTaskClick(task); + } + }} + > + {/* Sticky task name */} +
+ {nestable && + (count > 0 ? ( + + ) : ( + + ))} + + + {task.title} + + {nestable && count > 0 && ( + + {task.subtask_done_count ?? 0}/{count} + + )} +
+ + {/* Chart area */} + +
+ ); + } + // Called as a function (not rendered as a component) so the draggable node // isn't remounted by state changes mid-drag. function renderTaskRow( @@ -341,75 +461,60 @@ export function RoadmapView({ group: ColumnGroupDef, groupTasks: Task[], ) { - const bar = getBar(task); - const type = taskTypes.find((tt) => tt.id === task.task_type_id) ?? null; // Position only matters for same-group drops; cross-group drops just // change the column, so don't show the "insert before" indicator. const draggingInGroup = groupTasks.some((tk) => tk.id === draggingId); return ( - // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop row slot -
{ - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", task.id); - // Defer: empty groups render while dragging, and changing the - // layout synchronously inside dragstart makes Chrome cancel the - // drag (hit rows below an empty group, e.g. non-backlog statuses). - const id = task.id; - dragStartTimer.current = setTimeout(() => setDraggingId(id), 0); - }} - onDragEnd={resetDrag} - onDragOver={(e) => { - if (!canDrag) return; - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "move"; - setOverGroupKey(group.key); - setOverTaskId(task.id); - }} - onDrop={(e) => handleDrop(e, group, groupTasks, task.id)} - > - -
+ )} + ); } @@ -590,3 +695,95 @@ export function RoadmapView({
); } + +// ─── Nested child rows ──────────────────────────────────────────────────────── +interface RoadmapChildRowsProps { + projectId: string; + parent: Task; + depth: number; + filters: ChildTaskFilters; + isExpanded: (taskId: string) => boolean; + renderRow: (task: Task, depth: number) => React.ReactNode; + leftColWidth: number; +} + +/** The children of an expanded timeline row, fetched when it opens with the + * view's filters and sort. Recurses for children that have their own. */ +function RoadmapChildRows({ + projectId, + parent, + depth, + filters, + isExpanded, + renderRow, + leftColWidth, +}: RoadmapChildRowsProps) { + const { t } = useTranslation("projects"); + const query = useInfiniteQuery( + childTasksInfiniteQueryOptions(projectId, parent.id, filters), + ); + const children = useMemo( + () => query.data?.pages.flatMap((p) => p.items) ?? [], + [query.data], + ); + const note = (text: React.ReactNode) => ( +
+
+ {text} +
+
+ ); + + if (query.isPending) { + return note(); + } + if (query.isError) { + return note( + <> + {t("board.subtasks.loadError")} + + , + ); + } + return ( + <> + {children.length === 0 && note(t("board.subtasks.noMatch"))} + {children.map((child) => ( + + {renderRow(child, depth)} + {isExpanded(child.id) && (child.subtask_count ?? 0) > 0 && ( + + )} + + ))} + {query.hasNextPage && + note( + , + )} + + ); +} diff --git a/apps/web/src/components/projects/interactions/subtask-rows.test.tsx b/apps/web/src/components/projects/interactions/subtask-rows.test.tsx new file mode 100644 index 000000000..ea3f526f6 --- /dev/null +++ b/apps/web/src/components/projects/interactions/subtask-rows.test.tsx @@ -0,0 +1,107 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task } from "@/lib/interaction-api"; +import type { TaskStatus } from "@/lib/project-api"; +import { SubtaskRows } from "./subtask-rows"; +import { TaskRow } from "./task-row"; + +const get = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/api-client", () => ({ + apiClient: { instance: { get, post: vi.fn() } }, +})); + +const makeTask = (overrides: Partial = {}): Task => ({ + id: "t1", + project_id: "p1", + title: "Parent", + task_number: 1, + importance: 0, + custom_fields: {}, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + assignment_mode: "manual", + ...overrides, +}); + +const statuses: TaskStatus[] = []; + +function withClient(ui: React.ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("TaskRow subtask affordances", () => { + it("shows a progress chip and toggles without opening the task", () => { + const onToggle = vi.fn(); + const onClick = vi.fn(); + withClient( + , + ); + expect(screen.getByText("3/5")).toBeTruthy(); + const toggle = screen.getByRole("button"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggle); + expect(onToggle).toHaveBeenCalledOnce(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("renders no chevron for a task without subtasks", () => { + withClient( + , + ); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); + +describe("SubtaskRows", () => { + beforeEach(() => get.mockReset()); + + it("fetches the parent's children with the view filters, ignoring sprint/type scope", async () => { + get.mockResolvedValue({ + data: { + data: { + items: [makeTask({ id: "c1", title: "Child one" })], + page_size: 50, + next_cursor: null, + }, + }, + }); + withClient( + false} + onToggle={vi.fn()} + onTaskClick={vi.fn()} + />, + ); + expect(await screen.findByText("Child one")).toBeTruthy(); + const params = get.mock.calls[0][1].params; + expect(params.parent_task_id).toBe("t1"); + expect(params.status_ids).toBe("s1"); + expect(params.sort_by).toBe("importance"); + expect(params.sprint_id).toBeUndefined(); + expect(params.task_type_ids).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/projects/interactions/subtask-rows.tsx b/apps/web/src/components/projects/interactions/subtask-rows.tsx new file mode 100644 index 000000000..8607c997d --- /dev/null +++ b/apps/web/src/components/projects/interactions/subtask-rows.tsx @@ -0,0 +1,196 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import { + type ChildTaskFilters, + childTasksInfiniteQueryOptions, + createTask, + type Task, +} from "@/lib/interaction-api"; +import type { TaskStatus, TaskType } from "@/lib/project-api"; + +import { AddTaskRow } from "./add-task-row"; +import { TaskRow } from "./task-row"; + +type RowProps = Omit< + React.ComponentProps, + "task" | "onClick" | "depth" | "subtasks" | "showDragHandle" | "isDragging" +>; + +interface SubtaskRowsProps { + projectId: string; + parent: Task; + depth: number; + filters: ChildTaskFilters; + filtersActive: boolean; + rowProps: RowProps; + statuses: TaskStatus[]; + creatableTypes: TaskType[]; + canCreate: boolean; + isExpanded: (taskId: string) => boolean; + onToggle: (taskId: string) => void; + onTaskClick: (task: Task) => void; +} + +/** + * The children of an expanded parent row, fetched lazily with the view's + * filters and sort. Recurses for children that have children of their own. + */ +export function SubtaskRows({ + projectId, + parent, + depth, + filters, + filtersActive, + rowProps, + statuses, + creatableTypes, + canCreate, + isExpanded, + onToggle, + onTaskClick, +}: SubtaskRowsProps) { + const { t } = useTranslation("projects"); + const qc = useQueryClient(); + const query = useInfiniteQuery( + childTasksInfiniteQueryOptions(projectId, parent.id, filters), + ); + const children = useMemo( + () => query.data?.pages.flatMap((p) => p.items) ?? [], + [query.data], + ); + const total = parent.subtask_count ?? 0; + const indent = { paddingLeft: `${36 + Math.min(depth, 3) * 20}px` }; + + const handleAdd = async (title: string, typeId: string | null) => { + const todo = statuses.find((s) => s.category === "todo") ?? statuses[0]; + await createTask(projectId, { + title, + task_type_id: typeId, + status_id: todo?.id ?? null, + parent_task_id: parent.id, + sprint_id: parent.sprint_id ?? null, + }); + await qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }); + }; + + if (query.isPending) { + return ( +
+ {Array.from({ length: Math.min(Math.max(total, 1), 5) }, (_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + if (query.isError) { + return ( +
+ {t("board.subtasks.loadError")} + +
+ ); + } + + return ( + <> + {filtersActive && total > 0 && children.length < total && ( +
+ {t("board.subtasks.matching", { + n: children.length, + total, + })} +
+ )} + {children.length === 0 && total > 0 && ( +
+ {t("board.subtasks.noMatch")} +
+ )} + {children.map((child) => { + const isOpen = isExpanded(child.id); + return ( +
+ onTaskClick(child)} + subtasks={{ + expanded: isOpen, + onToggle: () => onToggle(child.id), + }} + /> + {isOpen && (child.subtask_count ?? 0) > 0 && ( + + )} +
+ ); + })} + {query.hasNextPage && ( + + )} + {canCreate && creatableTypes.length > 0 && ( +
+ +
+ )} + + ); +} diff --git a/apps/web/src/components/projects/interactions/task-card.tsx b/apps/web/src/components/projects/interactions/task-card.tsx index 03f66ca13..6fb608f38 100644 --- a/apps/web/src/components/projects/interactions/task-card.tsx +++ b/apps/web/src/components/projects/interactions/task-card.tsx @@ -5,6 +5,7 @@ import { GripVertical, Layers, Link, + ListChecks, Loader2, Search, User, @@ -719,9 +720,25 @@ export function TaskCard({ {task.title} - {fieldChips.length > 0 && ( + {(fieldChips.length > 0 || (task.subtask_count ?? 0) > 0) && (
{fieldChips} + {(task.subtask_count ?? 0) > 0 && ( + + + {task.subtask_done_count ?? 0}/{task.subtask_count} + + )}
)} diff --git a/apps/web/src/components/projects/interactions/task-detail/index.tsx b/apps/web/src/components/projects/interactions/task-detail/index.tsx index 5864d75fc..78e9a28cb 100644 --- a/apps/web/src/components/projects/interactions/task-detail/index.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/index.tsx @@ -464,9 +464,10 @@ export function TaskDetailModal({ onSubtaskUpdate={(subtaskId, payload) => { if (!projectId) return; updateTask(projectId, subtaskId, payload).then(() => { + // Prefix: also refreshes the nested list rows and the + // parent's progress chip, not just the detail section. qc.invalidateQueries({ - queryKey: subtasksInfiniteQueryOptions(projectId, task.id) - .queryKey, + queryKey: ["projects", projectId, "tasks"], }); }); }} @@ -479,9 +480,10 @@ export function TaskDetailModal({ status_id: todoStatus?.id ?? payload.status_id ?? null, parent_task_id: task.id, }).then(() => { + // Prefix: also refreshes the nested list rows and the + // parent's progress chip, not just the detail section. qc.invalidateQueries({ - queryKey: subtasksInfiniteQueryOptions(projectId, task.id) - .queryKey, + queryKey: ["projects", projectId, "tasks"], }); }); }} diff --git a/apps/web/src/components/projects/interactions/task-row.tsx b/apps/web/src/components/projects/interactions/task-row.tsx index e2787900e..de56f3c0d 100644 --- a/apps/web/src/components/projects/interactions/task-row.tsx +++ b/apps/web/src/components/projects/interactions/task-row.tsx @@ -1,9 +1,11 @@ import type { TFunction } from "i18next"; import { Check, + ChevronRight, GripVertical, Layers, Link, + ListChecks, Loader2, Search, User, @@ -24,6 +26,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useJevEnabled } from "@/hooks/use-jev-enabled"; +import { useIsMobile } from "@/hooks/use-mobile"; import { customFieldBadgeStyle, getCustomFieldOptionColor, @@ -176,8 +179,22 @@ interface TaskRowProps { onDelete?: (taskId: string) => void; onMoveLeft?: () => void; onMoveRight?: () => void; + /** Nesting level of a subtask row under its parent (0 = top level). */ + depth?: number; + /** Present when the view nests subtasks: reserves the chevron slot so ids + * stay aligned, and drives the expand toggle + progress chip. */ + subtasks?: { + expanded: boolean; + onToggle: () => void; + }; } +/** Indentation stops growing past this depth so deep chains stay readable. */ +const MAX_INDENT_DEPTH = 3; +/** Child rows have no drag handle; this keeps their ids indented past the + * parent's id (handle 14px + gap 12px). */ +const DRAG_HANDLE_WIDTH = 26; + export function TaskRow({ task, taskIdPrefix = "", @@ -195,8 +212,16 @@ export function TaskRow({ onDelete, onMoveLeft, onMoveRight, + depth = 0, + subtasks, }: TaskRowProps) { const { t } = useTranslation("projects"); + const isMobile = useIsMobile(); + // Touch screens have no drag handle and little width, so nest tighter. + const indentStep = isMobile ? 12 : 20; + const handleOffset = isMobile ? 0 : DRAG_HANDLE_WIDTH; + const subtaskCount = task.subtask_count ?? 0; + const subtaskDone = task.subtask_done_count ?? 0; const status = statuses.find((s) => s.id === task.status_id); const jevEnabled = useJevEnabled(task.project_id); const getMember = useMemberDirectory(task.project_id); @@ -779,17 +804,62 @@ export function TaskRow({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={cn( - "group flex items-center gap-3 px-4 py-2.5 cursor-pointer", + "group relative flex items-center gap-2 px-4 py-2.5 cursor-pointer sm:gap-3", "hover:bg-muted/30 transition-colors duration-150 border-b border-border/20 last:border-0", isDragging && "opacity-40 bg-muted/20", + depth > 0 && "bg-muted/10", )} + style={ + depth > 0 + ? { + paddingLeft: `${16 + handleOffset + Math.min(depth, MAX_INDENT_DEPTH) * indentStep}px`, + } + : undefined + } > + {depth > 0 && ( + + )} + {subtasks && + (subtaskCount > 0 ? ( + + ) : ( + + ))} {showDragHandle && ( )} {/* Task ID — separate fixed-width column left of title */} - + {taskIdPrefix ? `${taskIdPrefix}-${task.task_number}` : task.task_number > 0 @@ -798,8 +868,25 @@ export function TaskRow({ {/* Title */} - - {task.title} + + + {task.title} + + {subtasks && subtaskCount > 0 && ( + + + {subtaskDone}/{subtaskCount} + + )} {/* Dynamic field columns */} diff --git a/apps/web/src/components/projects/interactions/use-subtask-expansion.ts b/apps/web/src/components/projects/interactions/use-subtask-expansion.ts new file mode 100644 index 000000000..e98fe772f --- /dev/null +++ b/apps/web/src/components/projects/interactions/use-subtask-expansion.ts @@ -0,0 +1,103 @@ +import { useCallback, useSyncExternalStore } from "react"; +import type { ViewConfig } from "@/lib/interaction-api"; + +export type SubtaskMode = NonNullable; + +/** How a view shows subtasks; unset means nested and collapsed. */ +export function resolveSubtaskMode(config?: ViewConfig): SubtaskMode { + const mode = config?.subtask_mode; + return mode === "expanded" || mode === "hidden" ? mode : "collapsed"; +} + +/** + * Per-user, per-view record of the rows the user opened or closed by hand, + * on top of the view's default (collapsed or expanded, see SubtaskMode). + * Kept in localStorage so one person's choices never alter a shared view, + * and in a module-level store so every consumer of a view stays in sync. + */ +type Overrides = Readonly>; + +const EMPTY: Overrides = {}; +const states = new Map(); +const listeners = new Set<() => void>(); + +const storageKey = (scope: string) => `paca:subtask-overrides:${scope}`; + +function load(scope: string): Overrides { + try { + const raw = localStorage.getItem(storageKey(scope)); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Overrides; + } + } catch { + // storage unavailable — defaults apply for this session + } + return EMPTY; +} + +function getState(scope: string): Overrides { + let state = states.get(scope); + if (!state) { + state = load(scope); + states.set(scope, state); + } + return state; +} + +function setState(scope: string, next: Overrides) { + states.set(scope, next); + try { + localStorage.setItem(storageKey(scope), JSON.stringify(next)); + } catch { + // storage unavailable — the choice lasts for this session + } + for (const l of listeners) l(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Test helper: forget cached state so the next read reloads from storage. */ +export function resetSubtaskViewState() { + states.clear(); + for (const l of listeners) l(); +} + +export function useSubtaskExpansion( + projectId: string, + viewKey: string, + defaultExpanded: boolean, +) { + const scope = `${projectId}:${viewKey}`; + const overrides = useSyncExternalStore( + subscribe, + () => getState(scope), + () => getState(scope), + ); + + const isExpanded = useCallback( + (taskId: string) => overrides[taskId] ?? defaultExpanded, + [overrides, defaultExpanded], + ); + + const toggle = useCallback( + (taskId: string) => { + const current = getState(scope); + const next = !(current[taskId] ?? defaultExpanded); + // Back at the default: drop the entry so the record doesn't grow. + const { [taskId]: _dropped, ...rest } = current; + setState( + scope, + next === defaultExpanded ? rest : { ...rest, [taskId]: next }, + ); + }, + [scope, defaultExpanded], + ); + + return { isExpanded, toggle }; +} diff --git a/apps/web/src/components/projects/interactions/view-settings-panel.tsx b/apps/web/src/components/projects/interactions/view-settings-panel.tsx index bac5a204e..c9ad4a20a 100644 --- a/apps/web/src/components/projects/interactions/view-settings-panel.tsx +++ b/apps/web/src/components/projects/interactions/view-settings-panel.tsx @@ -45,7 +45,7 @@ import { useSprintDirectory } from "@/lib/sprint-directory"; import { cn } from "@/lib/utils"; import { PRIORITY_LEVELS } from "./priority"; import { ChipField } from "./task-detail/property-field/chip-field"; - +import { resolveSubtaskMode } from "./use-subtask-expansion"; import { buildAllFieldOptions, buildColumnByOptions, @@ -58,6 +58,19 @@ import { PAGE_SIZE_OPTIONS, } from "./view-utils"; +/** + * The value to store for a chosen Subtasks option. "Collapsed" is the unset + * default, but when the team default is something else it has to be stored + * explicitly, or a personal "collapsed" could never override it. + */ +export function pickSubtaskMode( + chosen: string | undefined, + shared: ViewConfig | undefined, +): ViewConfig["subtask_mode"] { + if (chosen === "expanded" || chosen === "hidden") return chosen; + return resolveSubtaskMode(shared) === "collapsed" ? undefined : "collapsed"; +} + // ── Category visual config ──────────────────────────────────────────────────── const CATEGORY_COLORS: Record = { @@ -1479,6 +1492,33 @@ export function ViewSettingsPanel({ /> + {(view?.layout === "Table" || view?.layout === "Roadmap") && ( + + + update({ + subtask_mode: pickSubtaskMode(v, view?.shared_config), + }) + } + /> + + )} + 0) params.task_type_ids = opts.taskTypeIds.join(","); if (opts.parentTaskId) params.parent_task_id = opts.parentTaskId; + if (opts.excludeSubtasks) params.exclude_subtasks = true; if (opts.sumField && opts.sumField !== "count") params.sum_field = opts.sumField; // For non-manual sorts, pass sort_by to the backend so the database applies the @@ -870,6 +879,60 @@ export const subtasksInfiniteQueryOptions = ( staleTime: 15_000, }); +/** Filters that carry over from a view to the children listed under a parent + * row: everything except what scopes the top level (sprint, task type) or + * only makes sense for it (positions, sums, paging). */ +export type ChildTaskFilters = Omit< + ListTasksOptions, + | "parentTaskId" + | "excludeSubtasks" + | "pageSize" + | "cursor" + | "sumField" + | "viewId" + | "sprintId" + | "sprintIds" + | "taskTypeIds" + | "taskTypeNull" +>; + +/** Infinite query of one parent's direct children for the nested list rows. + * Unlike the detail panel's subtasks query, it applies the view's filters + * and sort. Keyed under ["projects", id, "tasks"] so task mutations and + * realtime events refetch it. */ +export const childTasksInfiniteQueryOptions = ( + projectId: string, + parentTaskId: string, + filters: ChildTaskFilters = {}, +) => + infiniteQueryOptions({ + queryKey: [ + "projects", + projectId, + "tasks", + parentTaskId, + "children", + filters, + ], + queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { + const { data } = await apiClient.instance.get< + SuccessEnvelope + >(`/projects/${projectId}/tasks`, { + params: buildTaskQueryParams({ + ...filters, + parentTaskId, + cursor: pageParam, + pageSize: SUBTASKS_PAGE_SIZE, + }), + }); + seedTaskPeople(data.data.items); + return data.data; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + staleTime: 15_000, + }); + /** Cursor-paged sprint list, optionally filtered by status / name search. * Keyed under ["projects", id, "sprints"] so sprint mutations and realtime * events refetch it. */ diff --git a/docs/api/http-design.md b/docs/api/http-design.md index 1229ba1e1..bf239a014 100644 --- a/docs/api/http-design.md +++ b/docs/api/http-design.md @@ -1253,6 +1253,7 @@ Function: - list all non-deleted tasks for a project, with optional filtering and pagination; - when `view_id` is supplied, each task in the response includes its manual `view_position` and `view_group_key` from that view's ordering (these fields are omitted when the task has no recorded position in the requested view); +- each task in the response carries `subtask_count` and `subtask_done_count` (live direct children, and how many are in a status of category `done`); both are omitted when zero. Pass `parent_task_id` to list one task's children — it overrides sprint filters, while status/assignee/type/sort filters still apply; - tasks with system task types (`is_system = true`, i.e. Epic and Subtask) are **included** in this general list endpoint and are distinguished by the `is_system_type` flag in the response; use `exclude_system_types=true` to omit them (default behaviour of the product-backlog and sprint-specific task list endpoints). Query parameters: @@ -1266,6 +1267,7 @@ Query parameters: | `assignee_id` | – | Filter to tasks assigned to a specific user | | `view_id` | – | UUID of a view; enriches each task with its manual position in that view | | `include` | – | `assignees`, `reporter` or both (comma-separated): embed the people behind `assignee_ids` / `reporter_id` in each task as `assignees` / `reporter` | +| `exclude_subtasks` | `false` | When `true`, omit tasks whose parent is a live, non-Epic task in the same sprint (or both in the backlog); they are listed under that parent instead. Tasks that only belong to an Epic, or whose parent is in another sprint, stay. Ignored when `parent_task_id` is set | | `exclude_system_types` | `false` | When `true`, omit tasks whose type has `is_system = true` (Epic, Subtask) | Success response data (without `view_id`): diff --git a/services/api/internal/domain/sprint/entity.go b/services/api/internal/domain/sprint/entity.go index ef54d2467..c34ba2451 100644 --- a/services/api/internal/domain/sprint/entity.go +++ b/services/api/internal/domain/sprint/entity.go @@ -198,6 +198,10 @@ type ViewConfig struct { // of this view, independent of PageSize. Zero means unset; the client // falls back to its own per-layout default. InitialPageSize int `json:"initial_page_size,omitempty"` + // SubtaskMode controls how Table and Timeline views show subtasks: + // "collapsed" (nested, collapsed — also the meaning of empty), "expanded" + // (nested, expanded by default) or "hidden" (flat, no nesting). + SubtaskMode string `json:"subtask_mode,omitempty"` // Plugin view fields (only set when view_type = "plugin") // PluginID stores the plugin manifest identifier (reverse-DNS), not the // plugin UUID used by plugin-extension-settings APIs. diff --git a/services/api/internal/domain/task/entity.go b/services/api/internal/domain/task/entity.go index 0ae723c3b..9dbf08243 100644 --- a/services/api/internal/domain/task/entity.go +++ b/services/api/internal/domain/task/entity.go @@ -232,4 +232,9 @@ type Task struct { // with a view_position sort (i.e. view_id provided + manual sort). It is not // persisted in the tasks table. ViewPosition *float64 + // SubtaskCount and SubtaskDoneCount are transient aggregates populated only + // by ListTasks: the number of live direct children, and how many of them + // sit in a status of category "done". Not persisted. + SubtaskCount int + SubtaskDoneCount int } diff --git a/services/api/internal/domain/task/repository.go b/services/api/internal/domain/task/repository.go index b2471f162..a9fc31e6c 100644 --- a/services/api/internal/domain/task/repository.go +++ b/services/api/internal/domain/task/repository.go @@ -151,8 +151,14 @@ type TaskFilter struct { ParentTaskID *uuid.UUID // non-nil → only subtasks of this parent TaskTypeIDs []uuid.UUID // multi-value; when non-empty, only tasks of these types TaskTypeNull bool // true → only tasks where task_type_id IS NULL - BacklogOnly bool // true → only tasks where sprint_id IS NULL - CursorAfter *string // opaque base64 cursor; when set, replaces offset-based paging + // ExcludeSubtasks, when true, omits tasks whose parent is a live task in the + // same sprint (or both in the backlog) that is not an Epic — they are listed + // under that parent instead. Tasks that merely belong to an Epic, or whose + // parent sits in another sprint, stay in the list. Ignored when + // ParentTaskID is set. + ExcludeSubtasks bool + BacklogOnly bool // true → only tasks where sprint_id IS NULL + CursorAfter *string // opaque base64 cursor; when set, replaces offset-based paging // Search, when non-nil and non-blank, restricts results to tasks whose title // or "#" id contains the text (case-insensitive). Search *string diff --git a/services/api/internal/repository/postgres/task_repository.go b/services/api/internal/repository/postgres/task_repository.go index 7523181d6..98d2e181f 100644 --- a/services/api/internal/repository/postgres/task_repository.go +++ b/services/api/internal/repository/postgres/task_repository.go @@ -531,6 +531,16 @@ func applyTaskFilter(b *queryBuilder, filter taskdom.TaskFilter) { b.addInClause("task_type_id", uuidSliceToStrSlice(filter.TaskTypeIDs)) } + if filter.ExcludeSubtasks { + b.whereClauses = append(b.whereClauses, `NOT EXISTS ( + SELECT 1 FROM tasks sp + WHERE sp.id = tasks.parent_task_id AND sp.project_id = tasks.project_id AND sp.deleted_at IS NULL + AND sp.sprint_id IS NOT DISTINCT FROM tasks.sprint_id + AND (sp.task_type_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM task_types spt + WHERE spt.id = sp.task_type_id AND spt.is_system AND spt.name = 'Epic')))`) + } + if filter.Search != nil { if q := strings.TrimSpace(*filter.Search); q != "" { pattern := "%" + escapeLikePattern(q) + "%" @@ -1093,6 +1103,9 @@ func (r *TaskRepository) ListTasks(ctx context.Context, projectID uuid.UUID, fil if err := r.attachAssigneeIDs(ctx, tasks); err != nil { return nil, false, err } + if err := r.attachSubtaskCounts(ctx, tasks); err != nil { + return nil, false, err + } return tasks, hasMore, nil } @@ -1117,6 +1130,9 @@ func (r *TaskRepository) ListTasks(ctx context.Context, projectID uuid.UUID, fil if err := r.attachAssigneeIDs(ctx, tasks); err != nil { return nil, false, err } + if err := r.attachSubtaskCounts(ctx, tasks); err != nil { + return nil, false, err + } return tasks, hasMore, nil } @@ -1721,6 +1737,48 @@ func (r *TaskRepository) attachAssigneeIDs(ctx context.Context, tasks []*taskdom return nil } +// attachSubtaskCounts batch-loads the direct-child count and the done-child +// count (children whose status category is "done") for each task. Epics are +// included: the Timeline lists Epics and nests their children under them. +func (r *TaskRepository) attachSubtaskCounts(ctx context.Context, tasks []*taskdom.Task) error { + if len(tasks) == 0 { + return nil + } + byID := make(map[string]*taskdom.Task, len(tasks)) + placeholders := make([]string, len(tasks)) + args := make([]interface{}, len(tasks)) + for i, t := range tasks { + id := t.ID.String() + byID[id] = t + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + type countRow struct { + ParentID string `db:"parent_task_id"` + Total int `db:"total"` + Done int `db:"done"` + } + var rows []countRow + query := `SELECT c.parent_task_id AS parent_task_id, COUNT(*) AS total, + COUNT(*) FILTER (WHERE s.category = 'done') AS done + FROM tasks c + JOIN tasks p ON p.id = c.parent_task_id AND p.project_id = c.project_id + LEFT JOIN task_statuses s ON s.id = c.status_id + WHERE c.deleted_at IS NULL AND c.parent_task_id IN (` + strings.Join(placeholders, ",") + `) + GROUP BY c.parent_task_id` + if err := r.db.SelectContext(ctx, &rows, query, args...); err != nil { + return fmt.Errorf("task repo: attach subtask counts: %w", err) + } + for _, row := range rows { + if t, ok := byID[row.ParentID]; ok { + t.SubtaskCount = row.Total + t.SubtaskDoneCount = row.Done + } + } + return nil +} + // DeleteTask soft-deletes a task by setting deleted_at. func (r *TaskRepository) DeleteTask(ctx context.Context, id uuid.UUID) error { result, err := r.db.ExecContext(ctx, `UPDATE tasks SET deleted_at=$1 WHERE id=$2 AND deleted_at IS NULL`, time.Now(), id.String()) diff --git a/services/api/internal/repository/postgres/task_repository_test.go b/services/api/internal/repository/postgres/task_repository_test.go index ad93ca359..3eec50fc4 100644 --- a/services/api/internal/repository/postgres/task_repository_test.go +++ b/services/api/internal/repository/postgres/task_repository_test.go @@ -601,3 +601,125 @@ func TestTaskRepository_DeleteRefusesTheDefault(t *testing.T) { }) } } + +func TestAttachSubtaskCounts(t *testing.T) { + db := openTaskRepoTestDB(t) + ctx := context.Background() + for _, stmt := range []string{ + `ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`, + `ALTER TABLE tasks ADD COLUMN status_id TEXT`, + `ALTER TABLE tasks ADD COLUMN task_type_id TEXT`, + `CREATE TABLE task_types (id TEXT PRIMARY KEY, name TEXT NOT NULL, is_system BOOLEAN NOT NULL)`, + `INSERT INTO task_types VALUES ('epic', 'Epic', 1)`, + `CREATE TABLE task_statuses (id TEXT PRIMARY KEY, category TEXT NOT NULL)`, + `INSERT INTO task_statuses VALUES ('s-todo', 'todo'), ('s-done', 'done')`, + `INSERT INTO tasks (id, project_id, parent_task_id, status_id, deleted_at) VALUES + ('parent', 'p', NULL, NULL, NULL), + ('lone', 'p', NULL, NULL, NULL), + ('c1', 'p', 'parent', 's-done', NULL), + ('c2', 'p', 'parent', 's-todo', NULL), + ('c3', 'p', 'parent', NULL, NULL), + ('gone', 'p', 'parent', 's-done', CURRENT_TIMESTAMP), + ('foreign', 'other', 'parent', 's-done', NULL)`, + } { + if _, err := db.ExecContext(ctx, stmt); err != nil { + t.Fatalf("setup %q: %v", stmt, err) + } + } + + parent := &taskdom.Task{ID: uuid.MustParse("00000000-0000-0000-0000-000000000001")} + lone := &taskdom.Task{ID: uuid.MustParse("00000000-0000-0000-0000-000000000002")} + // Re-key the fixtures to the UUIDs the repo will query. + for old, id := range map[string]uuid.UUID{"parent": parent.ID, "lone": lone.ID} { + if _, err := db.ExecContext(ctx, `UPDATE tasks SET id = ? WHERE id = ?`, id.String(), old); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `UPDATE tasks SET parent_task_id = ? WHERE parent_task_id = ?`, id.String(), old); err != nil { + t.Fatal(err) + } + } + + // An Epic reports its children too: the Timeline nests them under it. + db.MustExec(`INSERT INTO tasks (id, project_id, task_type_id) VALUES ('00000000-0000-0000-0000-000000000003', 'p', 'epic')`) + db.MustExec(`INSERT INTO tasks (id, project_id, parent_task_id) VALUES ('epic-child', 'p', '00000000-0000-0000-0000-000000000003')`) + epic := &taskdom.Task{ID: uuid.MustParse("00000000-0000-0000-0000-000000000003")} + + repo := &TaskRepository{db: db} + if err := repo.attachSubtaskCounts(ctx, []*taskdom.Task{parent, lone, epic}); err != nil { + t.Fatalf("attachSubtaskCounts: %v", err) + } + if parent.SubtaskCount != 3 || parent.SubtaskDoneCount != 1 { + t.Errorf("parent counts = %d/%d, want 1/3 (deleted and other-project children excluded)", parent.SubtaskDoneCount, parent.SubtaskCount) + } + if epic.SubtaskCount != 1 { + t.Errorf("epic subtask count = %d, want 1", epic.SubtaskCount) + } + if lone.SubtaskCount != 0 || lone.SubtaskDoneCount != 0 { + t.Errorf("lone counts = %d/%d, want 0/0", lone.SubtaskDoneCount, lone.SubtaskCount) + } +} + +func TestTaskRepository_CountTasks_ExcludeSubtasks(t *testing.T) { + db := openTaskRepoTestDB(t) + repo := NewTaskRepository(db) + ctx := context.Background() + projectID := uuid.New().String() + for _, stmt := range []string{ + `ALTER TABLE tasks ADD COLUMN parent_task_id TEXT`, + `ALTER TABLE tasks ADD COLUMN task_type_id TEXT`, + `ALTER TABLE tasks ADD COLUMN sprint_id TEXT`, + `CREATE TABLE task_types (id TEXT PRIMARY KEY, name TEXT NOT NULL, is_system BOOLEAN NOT NULL)`, + `INSERT INTO task_types VALUES ('epic', 'Epic', 1), ('story', 'Story', 0)`, + } { + db.MustExec(stmt) + } + db.MustExec(`INSERT INTO tasks (id, project_id, task_type_id) VALUES ('foreign-parent', 'other-project', 'story')`) + for _, row := range []struct { + id, typ, parent string + deleted bool + }{ + {"epic-1", "epic", "", false}, + {"parent", "story", "", false}, + {"in-epic", "story", "epic-1", false}, // belongs to an Epic: stays + {"child", "story", "parent", false}, // subtask of a story: hidden + {"untyped-parent", "", "", false}, + {"child-untyped", "story", "untyped-parent", false}, // parent without a type is not an Epic: hidden + {"gone-parent", "story", "", true}, + {"orphan", "story", "gone-parent", false}, // parent deleted: stays + {"cross", "story", "foreign-parent", false}, // parent is in another project: stays + {"sprinted-child", "story", "parent", false}, // parent is in another sprint: stays + } { + var typ, parent, deleted any + if row.typ != "" { + typ = row.typ + } + if row.parent != "" { + parent = row.parent + } + if row.deleted { + deleted = "2026-01-01 00:00:00" + } + var sprint any + if row.id == "sprinted-child" { + sprint = "sprint-1" + } + db.MustExec(`INSERT INTO tasks (id, project_id, task_type_id, parent_task_id, deleted_at, sprint_id) VALUES ($1, $2, $3, $4, $5, $6)`, + row.id, projectID, typ, parent, deleted, sprint) + } + + pid := uuid.MustParse(projectID) + all, err := repo.CountTasks(ctx, pid, taskdom.TaskFilter{}) + if err != nil { + t.Fatal(err) + } + if all != 9 { + t.Fatalf("without the filter want 9 live tasks, got %d", all) + } + got, err := repo.CountTasks(ctx, pid, taskdom.TaskFilter{ExcludeSubtasks: true}) + if err != nil { + t.Fatal(err) + } + if got != 7 { + t.Fatalf("ExcludeSubtasks want 7 (epic, parent, in-epic, untyped-parent, orphan, cross, sprinted-child), got %d", got) + } +} diff --git a/services/api/internal/service/sprint/view_service.go b/services/api/internal/service/sprint/view_service.go index e42d5ccff..88dffe1f3 100644 --- a/services/api/internal/service/sprint/view_service.go +++ b/services/api/internal/service/sprint/view_service.go @@ -178,7 +178,7 @@ func (s *ViewService) CreateView(ctx context.Context, in sprintdom.CreateViewInp ProjectID: in.ProjectID, Name: name, ViewType: vt, - Config: in.Config, + Config: normalizeViewConfig(in.Config), Position: in.Position, ViewContext: in.ViewContext, CreatedAt: now, @@ -214,7 +214,7 @@ func (s *ViewService) UpdateView(ctx context.Context, projectID, id uuid.UUID, i v.ViewType = *in.ViewType } if in.Config != nil { - v.Config = *in.Config + v.Config = normalizeViewConfig(*in.Config) } if in.Position != nil { v.Position = *in.Position @@ -428,7 +428,7 @@ func (s *ViewService) SetUserViewConfig(ctx context.Context, projectID, viewID, if !hasPluginConfig(v.ViewType, &cfg) { return nil, sprintdom.ErrViewPluginConfigRequired } - sparse := diffViewConfig(v.Config, cfg) + sparse := diffViewConfig(v.Config, normalizeViewConfig(cfg)) sharedConfig := v.Config if isZeroViewConfig(sparse) { if err := s.repo.DeleteUserViewConfig(ctx, viewID, accountID); err != nil { @@ -501,6 +501,17 @@ func (s *ViewService) OverlayUserConfigs(ctx context.Context, accountID uuid.UUI return nil } +// normalizeViewConfig clears values outside their allowed set so a typo is not +// persisted; unknown subtask modes fall back to the default (empty). +func normalizeViewConfig(cfg sprintdom.ViewConfig) sprintdom.ViewConfig { + switch cfg.SubtaskMode { + case "", "collapsed", "expanded", "hidden": + default: + cfg.SubtaskMode = "" + } + return cfg +} + // mergeViewConfig returns the effective config for a view: each field of // override wins when the user has personally set it; shared's value is used // otherwise. PluginID/PluginComponent always come from shared — plugin @@ -546,6 +557,9 @@ func mergeViewConfig(shared, override sprintdom.ViewConfig) sprintdom.ViewConfig if override.InitialPageSize != 0 { out.InitialPageSize = override.InitialPageSize } + if override.SubtaskMode != "" { + out.SubtaskMode = override.SubtaskMode + } // out.PluginID / out.PluginComponent intentionally left at shared's value. return out } @@ -587,6 +601,9 @@ func diffViewConfig(shared, desired sprintdom.ViewConfig) sprintdom.ViewConfig { if desired.InitialPageSize != shared.InitialPageSize { out.InitialPageSize = desired.InitialPageSize } + if desired.SubtaskMode != shared.SubtaskMode { + out.SubtaskMode = desired.SubtaskMode + } return out } diff --git a/services/api/internal/transport/http/dto/task_dto.go b/services/api/internal/transport/http/dto/task_dto.go index 896bde9cf..c204876f6 100644 --- a/services/api/internal/transport/http/dto/task_dto.go +++ b/services/api/internal/transport/http/dto/task_dto.go @@ -390,8 +390,12 @@ type TaskResponse struct { Tags []string `json:"tags"` ViewPosition *float64 `json:"view_position,omitempty"` ViewGroupKey *string `json:"view_group_key,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // SubtaskCount / SubtaskDoneCount are direct-child aggregates, present on + // list responses only (absent means "not computed or none"). + SubtaskCount int `json:"subtask_count,omitempty"` + SubtaskDoneCount int `json:"subtask_done_count,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // AssignmentMode is "manual" or "auto" — see taskdom.Task.AssignmentMode's // doc comment. AssignmentMode string `json:"assignment_mode"` @@ -418,26 +422,28 @@ func TaskFromEntity(t *taskdom.Task) TaskResponse { assigneeIDs = []uuid.UUID{} } return TaskResponse{ - ID: t.ID, - ProjectID: t.ProjectID, - TaskNumber: t.TaskNumber, - Title: t.Title, - TaskTypeID: t.TaskTypeID, - StatusID: t.StatusID, - SprintID: t.SprintID, - ParentTaskID: t.ParentTaskID, - Description: t.Description, - Importance: t.Importance, - StoryPoints: t.StoryPoints, - AssigneeIDs: assigneeIDs, - ReporterID: t.ReporterID, - CustomFields: cf, - StartDate: t.StartDate, - DueDate: t.DueDate, - Tags: tags, - CreatedAt: t.CreatedAt, - UpdatedAt: t.UpdatedAt, - AssignmentMode: t.AssignmentMode, + ID: t.ID, + ProjectID: t.ProjectID, + TaskNumber: t.TaskNumber, + Title: t.Title, + TaskTypeID: t.TaskTypeID, + StatusID: t.StatusID, + SprintID: t.SprintID, + ParentTaskID: t.ParentTaskID, + Description: t.Description, + Importance: t.Importance, + StoryPoints: t.StoryPoints, + AssigneeIDs: assigneeIDs, + ReporterID: t.ReporterID, + CustomFields: cf, + StartDate: t.StartDate, + DueDate: t.DueDate, + Tags: tags, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + AssignmentMode: t.AssignmentMode, + SubtaskCount: t.SubtaskCount, + SubtaskDoneCount: t.SubtaskDoneCount, } } diff --git a/services/api/internal/transport/http/dto/view_dto.go b/services/api/internal/transport/http/dto/view_dto.go index 56e01a7e5..c6f0a0664 100644 --- a/services/api/internal/transport/http/dto/view_dto.go +++ b/services/api/internal/transport/http/dto/view_dto.go @@ -59,6 +59,8 @@ type ViewConfigDTO struct { // InitialPageSize is the saved number of tasks to fetch on the first load // of this view, independent of PageSize. InitialPageSize int `json:"initial_page_size,omitempty"` + // SubtaskMode is "collapsed", "expanded" or "hidden"; empty means "collapsed". + SubtaskMode string `json:"subtask_mode,omitempty"` // PluginManifestID is the reverse-DNS plugin manifest identifier // (for example "com.paca.bdd"). PluginManifestID string `json:"plugin_manifest_id,omitempty"` @@ -99,6 +101,7 @@ func viewConfigToDTO(cfg sprintdom.ViewConfig) ViewConfigDTO { CollapsedColumns: cfg.CollapsedColumns, PageSize: cfg.PageSize, InitialPageSize: cfg.InitialPageSize, + SubtaskMode: cfg.SubtaskMode, PluginManifestID: cfg.PluginID, PluginComponent: cfg.PluginComponent, } @@ -144,6 +147,7 @@ func toViewConfig(d *ViewConfigDTO) sprintdom.ViewConfig { CollapsedColumns: d.CollapsedColumns, PageSize: d.PageSize, InitialPageSize: d.InitialPageSize, + SubtaskMode: d.SubtaskMode, PluginID: d.PluginManifestID, PluginComponent: d.PluginComponent, } diff --git a/services/api/internal/transport/http/handler/task_handler.go b/services/api/internal/transport/http/handler/task_handler.go index cc6d82b6f..a27afc032 100644 --- a/services/api/internal/transport/http/handler/task_handler.go +++ b/services/api/internal/transport/http/handler/task_handler.go @@ -626,6 +626,7 @@ func (h *TaskHandler) parseTaskFilter(r *http.Request, projectID uuid.UUID) (tas } filter.ParentTaskID = &id } + filter.ExcludeSubtasks = filter.ParentTaskID == nil && r.URL.Query().Get("exclude_subtasks") == "true" if raw := strings.TrimSpace(r.URL.Query().Get("search")); raw != "" { filter.Search = &raw filter.SearchContent = r.URL.Query().Get("search_content") == "true" @@ -701,6 +702,7 @@ func (h *TaskHandler) parseTaskFilter(r *http.Request, projectID uuid.UUID) (tas // - assignee_id= or assignee_ids= // - task_type_ids= // - parent_task_id= +// - exclude_subtasks=true (omit tasks whose parent is a non-Epic task in the same sprint; ignored with parent_task_id) // - search= (matches title or "#", case-insensitive) // - search_content=true (with search: also match description text) // - custom_field_filters= (object keyed by custom field key; see diff --git a/services/api/migrations/000072_add_tasks_parent_task_id_index.sql b/services/api/migrations/000072_add_tasks_parent_task_id_index.sql new file mode 100644 index 000000000..dab8c0864 --- /dev/null +++ b/services/api/migrations/000072_add_tasks_parent_task_id_index.sql @@ -0,0 +1,5 @@ +-- Subtask lookups: listing a parent's children and batched subtask counts +-- filter on parent_task_id. +CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id + ON tasks (parent_task_id) + WHERE parent_task_id IS NOT NULL AND deleted_at IS NULL;