From 43dc5d8df408ea15e517c8a51ccfdb457a6daa72 Mon Sep 17 00:00:00 2001 From: Jonathan Lee Date: Thu, 27 Aug 2026 05:00:39 +0000 Subject: [PATCH 1/2] BM-29 show subtasks in Tasks list --- plugins/tasks/views/list/data.ts | 6 +- plugins/tasks/views/list/hierarchy.test.tsx | 264 ++++++++++++++++++++ plugins/tasks/views/list/index.tsx | 93 +++++-- plugins/tasks/views/list/lib.test.ts | 101 ++++++++ plugins/tasks/views/list/lib.ts | 37 +++ plugins/tasks/views/list/row.tsx | 55 +++- 6 files changed, 518 insertions(+), 38 deletions(-) create mode 100644 plugins/tasks/views/list/hierarchy.test.tsx diff --git a/plugins/tasks/views/list/data.ts b/plugins/tasks/views/list/data.ts index 2ebaa1b899..c0e283b6b4 100644 --- a/plugins/tasks/views/list/data.ts +++ b/plugins/tasks/views/list/data.ts @@ -18,10 +18,7 @@ interface ListTaskFilters { labelIds: readonly string[] | null; } -/** - * Server-side filtered task list. Subtasks are excluded (parentTaskId: null), - * matching the design mock — they surface on their parent's detail page. - */ +/** Server-side filtered task list, including matching direct subtasks. */ export function useListTasks( projectId: string | null, activeOnly: boolean, @@ -41,7 +38,6 @@ export function useListTasks( ? { labelIds: [...filters.labelIds] } : {}), activeOnly, - parentTaskId: null, }), ["tasks:changed", "threads:changed"], [ diff --git a/plugins/tasks/views/list/hierarchy.test.tsx b/plugins/tasks/views/list/hierarchy.test.tsx new file mode 100644 index 0000000000..f10f78767a --- /dev/null +++ b/plugins/tasks/views/list/hierarchy.test.tsx @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; +import type { Task } from "../../shared/contract.js"; + +window.matchMedia = (query: string) => ({ + matches: query === COMPACT_VIEWPORT_QUERY, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, +}); +window.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; +Element.prototype.scrollIntoView ??= () => {}; + +const app = await loadPluginApp(() => import("../../app")); + +beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); +}); + +afterEach(cleanup); + +const PROJECT_ID = "01HZZZZZZZZZZZZZZZZZZZZZP1"; +const LABEL_ID = "01HZZZZZZZZZZZZZZZZZZZZLB1"; + +const project = { + id: PROJECT_ID, + name: "Tasks Plugin", + prefix: "TSK", + nextTaskNumber: 100, + color: "blue", + folderId: null, + linkedBbProjectId: null, + createdAt: "2026-07-15T00:00:00.000Z", +}; + +const bugLabel = { + id: LABEL_ID, + projectId: PROJECT_ID, + name: "Bug", + color: "#e5484d", +}; + +function task(number: number, overrides: Partial = {}): Task { + return { + id: `01HZZZZZZZZZZZZZZZZZZZZZT${number}`, + projectId: PROJECT_ID, + number, + key: `TSK-${number}`, + title: `Task ${number}`, + description: "", + status: "todo", + priority: "none", + dueDate: null, + parentTaskId: null, + position: number, + createdAt: "2026-07-15T00:00:00.000Z", + updatedAt: "2026-07-15T00:00:00.000Z", + labelIds: [], + ...overrides, + }; +} + +type TaskSource = () => readonly Task[]; + +function renderTasks(source: TaskSource) { + const listTasksCalls: Record[] = []; + const slot = renderSlot( + app.navPanels[0]!, + { subPath: PROJECT_ID }, + { + rpc: { + listProjects: () => ({ projects: [project] }), + listFolders: () => ({ folders: [] }), + listPresets: () => ({ presets: [] }), + sidebarSummary: () => ({ projects: [] }), + listLabels: () => ({ labels: [bugLabel] }), + listTasks: (input: { + projectId?: string; + parentTaskId?: string | null; + statuses?: readonly Task["status"][]; + priorities?: readonly Task["priority"][]; + labelIds?: readonly string[]; + }) => { + listTasksCalls.push(input); + let tasks = [...source()]; + if (input.projectId !== undefined) { + tasks = tasks.filter((item) => item.projectId === input.projectId); + } + if (input.parentTaskId !== undefined) { + tasks = tasks.filter( + (item) => item.parentTaskId === input.parentTaskId, + ); + } + if (input.statuses !== undefined && input.statuses.length > 0) { + const statuses = new Set(input.statuses); + tasks = tasks.filter((item) => statuses.has(item.status)); + } + if (input.priorities !== undefined && input.priorities.length > 0) { + const priorities = new Set(input.priorities); + tasks = tasks.filter((item) => priorities.has(item.priority)); + } + if (input.labelIds !== undefined) { + const labelIds = new Set(input.labelIds); + tasks = tasks.filter((item) => + item.labelIds.some((labelId) => labelIds.has(labelId)), + ); + } + return { tasks }; + }, + listTaskThreads: () => ({ taskThreads: [] }), + listComments: () => ({ comments: [] }), + listAttachments: () => ({ attachments: [] }), + }, + }, + ); + return { slot, listTasksCalls }; +} + +function taskKeys(slot: ReturnType): string[] { + return Array.from( + slot.container.querySelectorAll("[data-task-key]"), + (row) => row.dataset.taskKey ?? "", + ); +} + +async function expectTaskKeys( + slot: ReturnType, + expected: readonly string[], +) { + await waitFor(() => expect(taskKeys(slot)).toEqual(expected)); +} + +function taskLevel( + slot: ReturnType, + key: string, +): string | undefined { + return slot.container.querySelector(`[data-task-key="${key}"]`) + ?.dataset.taskLevel; +} + +async function selectFilter( + slot: ReturnType, + filter: "Status" | "Priority" | "Label", + option: string, +) { + fireEvent.click(slot.getByRole("button", { name: filter, exact: true })); + fireEvent.click(await slot.findByRole("menuitemcheckbox", { name: option })); +} + +describe("task list hierarchy", () => { + it("renders direct children below their parent and supports collapse", async () => { + const parent = task(1); + const childA = task(2, { + parentTaskId: parent.id, + status: "done", + }); + const otherRoot = task(3, { status: "done" }); + const childB = task(4, { + parentTaskId: parent.id, + status: "in_progress", + }); + const { slot, listTasksCalls } = renderTasks(() => [ + parent, + childA, + otherRoot, + childB, + ]); + + await expectTaskKeys(slot, ["TSK-1", "TSK-2", "TSK-4", "TSK-3"]); + expect(taskLevel(slot, "TSK-1")).toBe("0"); + expect(taskLevel(slot, "TSK-2")).toBe("1"); + expect(taskLevel(slot, "TSK-4")).toBe("1"); + expect(listTasksCalls.some((input) => "parentTaskId" in input)).toBe(false); + expect( + slot.getByRole("button", { name: "Open TSK-2: Task 2" }), + ).toBeDefined(); + + const collapse = slot.getByRole("button", { + name: "Collapse 2 subtasks for TSK-1", + }); + expect(collapse.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(collapse); + await expectTaskKeys(slot, ["TSK-1", "TSK-3"]); + + const expand = slot.getByRole("button", { + name: "Expand 2 subtasks for TSK-1", + }); + expect(expand.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(expand); + await expectTaskKeys(slot, ["TSK-1", "TSK-2", "TSK-4", "TSK-3"]); + }); + + it.each([ + { + name: "status", + filter: "Status" as const, + option: "Done", + child: { status: "done" as const }, + }, + { + name: "priority", + filter: "Priority" as const, + option: "Urgent", + child: { priority: "urgent" as const }, + }, + { + name: "label", + filter: "Label" as const, + option: "Bug", + child: { labelIds: [LABEL_ID] }, + }, + ])( + "promotes a matching child when the $name filter hides its parent", + async ({ filter, option, child: childOverrides }) => { + const parent = task(1); + const child = task(2, { + parentTaskId: parent.id, + ...childOverrides, + }); + const { slot } = renderTasks(() => [parent, child]); + await expectTaskKeys(slot, ["TSK-1", "TSK-2"]); + + await selectFilter(slot, filter, option); + + await expectTaskKeys(slot, ["TSK-2"]); + expect(taskLevel(slot, "TSK-2")).toBe("0"); + }, + ); + + it("keeps a child visible when its parent row is absent", async () => { + const child = task(2, { parentTaskId: "absent-parent" }); + const { slot } = renderTasks(() => [child]); + + await expectTaskKeys(slot, ["TSK-2"]); + expect(taskLevel(slot, "TSK-2")).toBe("0"); + }); + + it("promotes a child after its parent is deleted", async () => { + const parent = task(1); + const child = task(2, { parentTaskId: parent.id }); + let tasks: Task[] = [parent, child]; + const { slot } = renderTasks(() => tasks); + await expectTaskKeys(slot, ["TSK-1", "TSK-2"]); + expect(taskLevel(slot, "TSK-2")).toBe("1"); + + tasks = [{ ...child, parentTaskId: null }]; + await slot.emitRealtime("tasks:changed", {}); + + await expectTaskKeys(slot, ["TSK-2"]); + expect(taskLevel(slot, "TSK-2")).toBe("0"); + }); +}); diff --git a/plugins/tasks/views/list/index.tsx b/plugins/tasks/views/list/index.tsx index 13741d6031..b9542f6c3c 100644 --- a/plugins/tasks/views/list/index.tsx +++ b/plugins/tasks/views/list/index.tsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import type { Label } from "../../shared/contract.js"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import type { Label, Task } from "../../shared/contract.js"; import { useProjects } from "../../shell/data.js"; import { useTasksNavigation } from "../../shell/routes.js"; import { NewTaskDialog } from "../manage/new-task-dialog.js"; @@ -21,7 +21,6 @@ import { storeListPreference, type ListPreference, } from "./list-preference.js"; -import { sortTasks } from "../../shared/sort.js"; import type { TaskSort } from "../../shared/pagination.js"; import { StatusIcon } from "./icons.js"; import { @@ -33,10 +32,11 @@ import { labelFilterOptions, selectedLabelIds, STATUS_LABELS, + taskHierarchyGroups, } from "./lib.js"; import { editedTasks, matchesFilters } from "./optimistic.js"; import { useListTaskEdits } from "./use-task-edits.js"; -import { TaskRow } from "./row.js"; +import { TaskRow, type TaskRowHierarchy } from "./row.js"; interface ListViewProps { /** null renders the cross-project "All tasks" list. */ @@ -123,6 +123,16 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { }); }; const [newTaskOpen, setNewTaskOpen] = useState(false); + const [collapsedTaskIds, setCollapsedTaskIds] = useState>( + new Set(), + ); + const toggleTask = (taskId: string) => + setCollapsedTaskIds((current) => { + const next = new Set(current); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); const labelProjectIds = useMemo( () => @@ -173,10 +183,10 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { [projects.data], ); - // Optimistic edits are overlaid before sorting/grouping so an edited row jumps - // to its new status group immediately, and the active status/priority/label - // filters are re-applied so a row that no longer matches drops out at once - // instead of waiting for the server refetch. + // Optimistic edits are overlaid before hierarchy and status + // grouping. The active filters are re-applied so a row that no longer + // matches drops out immediately. A matching child whose parent drops out is + // then promoted to a root by taskHierarchyGroups. const displayTasks = useMemo(() => { if (tasksQuery.data === undefined) return undefined; return editedTasks(tasksQuery.data, edits.entries).filter((task) => @@ -194,11 +204,6 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { filters.priorities, labelIds, ]); - const groups = useMemo( - () => groupTasksByStatus(sortTasks(displayTasks ?? [], sort)), - [displayTasks, sort], - ); - const showProject = projectId === null; const filtered = hasActiveFilters(filters); @@ -207,6 +212,18 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { // user left off. Restore only once the real rows have loaded. const scrollRef = useRef(null); const scopeKey = listScrollScopeKey({ projectId, activeOnly, filters, sort }); + const hierarchy = useMemo( + () => taskHierarchyGroups(displayTasks ?? [], sort), + [displayTasks, sort], + ); + const hierarchyByRoot = useMemo( + () => new Map(hierarchy.map((group) => [group.root.id, group])), + [hierarchy], + ); + const groups = useMemo( + () => groupTasksByStatus(hierarchy.map((group) => group.root)), + [hierarchy], + ); // `useListTasks` keeps the previous scope's rows on screen while it refetches // and only flips `isLoading` in a later effect, so on the first render after a // filter/sort change the rows are stale but `isLoading` is still false. Treat @@ -245,6 +262,22 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { revision: tasksQuery.data?.length ?? 0, }); + const renderTaskRow = (task: Task, hierarchy: TaskRowHierarchy) => ( + navigation.go({ kind: "task", taskKey: task.key })} + pending={edits.pending.has(task.id)} + /> + ); + let body: React.ReactNode; if ( routeScopeChanged || @@ -328,20 +361,26 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { {group.tasks.length} - {group.tasks.map((task) => ( - navigation.go({ kind: "task", taskKey: task.key })} - pending={edits.pending.has(task.id)} - /> - ))} + {group.tasks.map((task) => { + const taskGroup = hierarchyByRoot.get(task.id); + if (taskGroup === undefined) return null; + const collapsed = collapsedTaskIds.has(task.id); + return ( + + {renderTaskRow(task, { + level: 0, + childCount: taskGroup.children.length, + collapsed, + onToggle: () => toggleTask(task.id), + })} + {collapsed + ? null + : taskGroup.children.map((child) => + renderTaskRow(child, { level: 1 }), + )} + + ); + })} )); } diff --git a/plugins/tasks/views/list/lib.test.ts b/plugins/tasks/views/list/lib.test.ts index 4c7d5e4c62..e0f7cc3aaf 100644 --- a/plugins/tasks/views/list/lib.test.ts +++ b/plugins/tasks/views/list/lib.test.ts @@ -7,11 +7,14 @@ import { labelFilterOptions, partitionLabels, selectedLabelIds, + taskHierarchyGroups, } from "./lib.js"; const ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA"; const ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB"; const ULID_C = "01ARZ3NDEKTSV4RRFFQ69G5FAC"; +const ULID_D = "01ARZ3NDEKTSV4RRFFQ69G5FAD"; +const ULID_E = "01ARZ3NDEKTSV4RRFFQ69G5FAE"; function task(overrides: Partial & Pick): Task { return { @@ -47,6 +50,104 @@ describe("groupTasksByStatus", () => { }); }); +describe("taskHierarchyGroups", () => { + const hierarchyTasks = [ + task({ + id: ULID_A, + number: 1, + key: "TSK-1", + status: "todo", + priority: "none", + dueDate: "2026-07-30", + }), + task({ + id: ULID_C, + number: 3, + key: "TSK-3", + status: "done", + priority: "low", + parentTaskId: ULID_A, + }), + task({ + id: ULID_D, + number: 4, + key: "TSK-4", + status: "in_progress", + priority: "urgent", + dueDate: "2026-07-20", + parentTaskId: ULID_A, + }), + task({ + id: ULID_B, + number: 2, + key: "TSK-2", + status: "done", + priority: "urgent", + }), + task({ + id: ULID_E, + number: 5, + key: "TSK-5", + status: "todo", + priority: "none", + parentTaskId: ULID_B, + }), + ]; + + const keysFor = (sort: "manual" | "priority" | "due") => + taskHierarchyGroups(hierarchyTasks, sort).flatMap((group) => [ + group.root.key, + ...group.children.map((child) => child.key), + ]); + + it("sorts roots and direct children independently in every sort mode", () => { + expect(keysFor("manual")).toEqual([ + "TSK-1", + "TSK-3", + "TSK-4", + "TSK-2", + "TSK-5", + ]); + expect(keysFor("priority")).toEqual([ + "TSK-2", + "TSK-5", + "TSK-1", + "TSK-4", + "TSK-3", + ]); + expect(keysFor("due")).toEqual([ + "TSK-1", + "TSK-4", + "TSK-3", + "TSK-2", + "TSK-5", + ]); + }); + + it("promotes absent-parent and deleted-parent tasks to roots", () => { + const missingParent = task({ + id: ULID_C, + key: "TSK-3", + status: "todo", + parentTaskId: "missing-parent", + }); + const deletedParent = task({ + id: ULID_D, + key: "TSK-4", + status: "todo", + parentTaskId: null, + }); + + expect( + taskHierarchyGroups([missingParent, deletedParent], "manual").map( + (group) => ({ root: group.root.key, children: group.children }), + ), + ).toEqual([ + { root: "TSK-3", children: [] }, + { root: "TSK-4", children: [] }, + ]); + }); +}); describe("label filter options", () => { const label = (id: string, projectId: string, name: string): Label => ({ id, diff --git a/plugins/tasks/views/list/lib.ts b/plugins/tasks/views/list/lib.ts index 40279eb44a..db37961c58 100644 --- a/plugins/tasks/views/list/lib.ts +++ b/plugins/tasks/views/list/lib.ts @@ -6,6 +6,7 @@ import { type TaskStatus, } from "../../shared/contract.js"; import type { TaskSort } from "../../shared/pagination.js"; +import { sortTasks } from "../../shared/sort.js"; export const STATUS_LABELS: Record = { backlog: "Backlog", @@ -30,6 +31,42 @@ export const SORT_LABELS: Record = { due: "Due date", }; +export interface TaskHierarchyGroup { + root: Task; + children: Task[]; +} + +/** + * Builds the one-level task hierarchy used by the list. Roots and each child + * group use the selected sort independently, which keeps every child directly + * below its parent. A task becomes a root when its parent is absent from the + * filtered result. Invalid deeper data is also promoted instead of hidden. + */ +export function taskHierarchyGroups( + tasks: readonly Task[], + sort: TaskSort, +): TaskHierarchyGroup[] { + const byId = new Map(tasks.map((task) => [task.id, task])); + const roots: Task[] = []; + const childrenByParent = new Map(); + + for (const task of tasks) { + const parent = + task.parentTaskId === null ? undefined : byId.get(task.parentTaskId); + if (parent === undefined || parent.parentTaskId !== null) { + roots.push(task); + continue; + } + const children = childrenByParent.get(parent.id); + if (children) children.push(task); + else childrenByParent.set(parent.id, [task]); + } + + return sortTasks(roots, sort).map((root) => ({ + root, + children: sortTasks(childrenByParent.get(root.id) ?? [], sort), + })); +} interface StatusGroup { status: TaskStatus; tasks: Task[]; diff --git a/plugins/tasks/views/list/row.tsx b/plugins/tasks/views/list/row.tsx index 226fb17bff..c251167afb 100644 --- a/plugins/tasks/views/list/row.tsx +++ b/plugins/tasks/views/list/row.tsx @@ -109,9 +109,19 @@ function LabelChips({ ); } +export type TaskRowHierarchy = + | { + level: 0; + childCount: number; + collapsed: boolean; + onToggle: () => void; + } + | { level: 1 }; + interface TaskRowProps { /** Task with any pending optimistic edit already applied. */ task: Task; + hierarchy: TaskRowHierarchy; meta: TaskRowMeta | undefined; project: Project | undefined; showProject: boolean; @@ -134,6 +144,7 @@ interface TaskRowProps { */ export function TaskRow({ task, + hierarchy, meta, project, showProject, @@ -149,6 +160,7 @@ export function TaskRow({
@@ -178,14 +191,44 @@ export function TaskRow({ }} className="absolute inset-0 rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" /> + {hierarchy.level === 1 ? ( + + + + ) : hierarchy.childCount > 0 ? ( + + ) : ( + + )} setOpenMenu(next ? "priority" : null)} - className="col-start-1 row-start-2" + className="col-start-2 row-start-2" /> - + {task.key} setOpenMenu(next ? "status" : null)} - className="col-start-1 row-start-1" + className="col-start-2 row-start-1" /> - + {task.title} - + {meta ? : null} {task.dueDate !== null ? ( From 452af2d191ea506c15d0c255cfabf7e53f329321 Mon Sep 17 00:00:00 2001 From: Jonathan Lee Date: Thu, 27 Aug 2026 05:48:43 +0000 Subject: [PATCH 2/2] fix(BM-29): close subtask tree review gaps --- plugins/tasks/views/list/hierarchy.test.tsx | 130 +++++++++++- plugins/tasks/views/list/index.tsx | 110 ++++++---- plugins/tasks/views/list/row.tsx | 224 +++++++++++--------- 3 files changed, 317 insertions(+), 147 deletions(-) diff --git a/plugins/tasks/views/list/hierarchy.test.tsx b/plugins/tasks/views/list/hierarchy.test.tsx index f10f78767a..e847d6cd49 100644 --- a/plugins/tasks/views/list/hierarchy.test.tsx +++ b/plugins/tasks/views/list/hierarchy.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; import { COMPACT_VIEWPORT_QUERY } from "@bb/shared-ui/hooks/use-compact-viewport"; @@ -145,9 +145,17 @@ async function expectTaskKeys( function taskLevel( slot: ReturnType, key: string, -): string | undefined { - return slot.container.querySelector(`[data-task-key="${key}"]`) - ?.dataset.taskLevel; +): string | null { + return ( + slot.container + .querySelector(`[data-task-key="${key}"]`) + ?.closest('[role="listitem"]') + ?.getAttribute("aria-level") ?? null + ); +} + +function expectClasses(element: Element, expected: readonly string[]) { + expect([...element.classList]).toEqual(expect.arrayContaining(expected)); } async function selectFilter( @@ -179,13 +187,31 @@ describe("task list hierarchy", () => { ]); await expectTaskKeys(slot, ["TSK-1", "TSK-2", "TSK-4", "TSK-3"]); - expect(taskLevel(slot, "TSK-1")).toBe("0"); - expect(taskLevel(slot, "TSK-2")).toBe("1"); - expect(taskLevel(slot, "TSK-4")).toBe("1"); + expect(taskLevel(slot, "TSK-1")).toBe("1"); + expect(taskLevel(slot, "TSK-2")).toBe("2"); + expect(taskLevel(slot, "TSK-4")).toBe("2"); expect(listTasksCalls.some((input) => "parentTaskId" in input)).toBe(false); expect( slot.getByRole("button", { name: "Open TSK-2: Task 2" }), ).toBeDefined(); + expect( + slot.container.querySelector('[data-status-group-header="todo"]') + ?.lastElementChild?.textContent, + ).toBe("1 main task · 3 visible tasks"); + expect( + slot.container.querySelector('[data-status-group-header="done"]') + ?.lastElementChild?.textContent, + ).toBe("1 main task · 1 visible task"); + expect( + slot.container.querySelector('[data-status-group-header="in_progress"]'), + ).toBeNull(); + expect( + within( + slot.container.querySelector('[data-task-key="TSK-4"]')!, + ).getByRole("button", { + name: "Change status, currently In Progress", + }), + ).toBeDefined(); const collapse = slot.getByRole("button", { name: "Collapse 2 subtasks for TSK-1", @@ -193,6 +219,10 @@ describe("task list hierarchy", () => { expect(collapse.getAttribute("aria-expanded")).toBe("true"); fireEvent.click(collapse); await expectTaskKeys(slot, ["TSK-1", "TSK-3"]); + expect( + slot.container.querySelector('[data-status-group-header="todo"]') + ?.lastElementChild?.textContent, + ).toBe("1 main task · 1 visible task"); const expand = slot.getByRole("button", { name: "Expand 2 subtasks for TSK-1", @@ -202,6 +232,84 @@ describe("task list hierarchy", () => { await expectTaskKeys(slot, ["TSK-1", "TSK-2", "TSK-4", "TSK-3"]); }); + it("exposes nested list semantics and parent context", async () => { + const parent = task(1); + const child = task(2, { + parentTaskId: parent.id, + status: "done", + }); + const { slot } = renderTasks(() => [parent, child]); + + await expectTaskKeys(slot, ["TSK-1", "TSK-2"]); + const statusList = slot.getByRole("list", { name: /Todo/ }); + const parentItem = within(statusList).getByRole("listitem", { + name: "TSK-1: Task 1. Status Todo.", + }); + expect(parentItem.getAttribute("aria-level")).toBe("1"); + + const subtaskList = within(parentItem).getByRole("list", { + name: "Subtasks for TSK-1", + }); + const childItem = within(subtaskList).getByRole("listitem", { + name: "TSK-2: Task 2. Subtask of TSK-1. Status Done.", + }); + expect(childItem.getAttribute("aria-level")).toBe("2"); + expect( + within(parentItem) + .getByRole("button", { name: "Collapse 1 subtask for TSK-1" }) + .getAttribute("aria-controls"), + ).toBe(subtaskList.id); + }); + + it("keeps visible indentation, connector styling, and narrow placement", async () => { + const parent = task(1); + const child = task(2, { parentTaskId: parent.id }); + const { slot } = renderTasks(() => [parent, child]); + + await expectTaskKeys(slot, ["TSK-1", "TSK-2"]); + const row = slot.container.querySelector( + '[data-task-key="TSK-2"]', + )!; + expectClasses(row, [ + "pl-7", + "pr-3.5", + "grid-cols-[auto_auto_auto_minmax(0,1fr)]", + ]); + + const connector = row.querySelector( + "[data-subtask-connector]", + )!; + expectClasses(connector, ["col-start-1", "row-span-2", "row-start-1"]); + expectClasses(connector.firstElementChild!, [ + "rounded-bl-sm", + "border-b", + "border-l", + "border-border", + ]); + expectClasses( + within(row).getByRole("button", { + name: "Change status, currently Todo", + }), + ["col-start-2", "row-start-1"], + ); + expectClasses( + within(row).getByRole("button", { + name: "Set priority, currently No priority", + }), + ["col-start-2", "row-start-2"], + ); + expectClasses(within(row).getByText("TSK-2"), [ + "col-start-3", + "row-start-2", + ]); + expectClasses(within(row).getByText("Task 2"), [ + "col-start-3", + "col-span-2", + "row-start-1", + ]); + expectClasses(row.lastElementChild!, ["col-start-4", "row-start-2"]); + }); + it.each([ { name: "status", @@ -235,7 +343,7 @@ describe("task list hierarchy", () => { await selectFilter(slot, filter, option); await expectTaskKeys(slot, ["TSK-2"]); - expect(taskLevel(slot, "TSK-2")).toBe("0"); + expect(taskLevel(slot, "TSK-2")).toBe("1"); }, ); @@ -244,7 +352,7 @@ describe("task list hierarchy", () => { const { slot } = renderTasks(() => [child]); await expectTaskKeys(slot, ["TSK-2"]); - expect(taskLevel(slot, "TSK-2")).toBe("0"); + expect(taskLevel(slot, "TSK-2")).toBe("1"); }); it("promotes a child after its parent is deleted", async () => { @@ -253,12 +361,12 @@ describe("task list hierarchy", () => { let tasks: Task[] = [parent, child]; const { slot } = renderTasks(() => tasks); await expectTaskKeys(slot, ["TSK-1", "TSK-2"]); - expect(taskLevel(slot, "TSK-2")).toBe("1"); + expect(taskLevel(slot, "TSK-2")).toBe("2"); tasks = [{ ...child, parentTaskId: null }]; await slot.emitRealtime("tasks:changed", {}); await expectTaskKeys(slot, ["TSK-2"]); - expect(taskLevel(slot, "TSK-2")).toBe("0"); + expect(taskLevel(slot, "TSK-2")).toBe("1"); }); }); diff --git a/plugins/tasks/views/list/index.tsx b/plugins/tasks/views/list/index.tsx index b9542f6c3c..7c9be50d70 100644 --- a/plugins/tasks/views/list/index.tsx +++ b/plugins/tasks/views/list/index.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Label, Task } from "../../shared/contract.js"; import { useProjects } from "../../shell/data.js"; import { useTasksNavigation } from "../../shell/routes.js"; @@ -262,7 +262,11 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { revision: tasksQuery.data?.length ?? 0, }); - const renderTaskRow = (task: Task, hierarchy: TaskRowHierarchy) => ( + const renderTaskRow = ( + task: Task, + hierarchy: TaskRowHierarchy, + subtaskList?: React.ReactNode, + ) => ( navigation.go({ kind: "task", taskKey: task.key })} pending={edits.pending.has(task.id)} + subtaskList={subtaskList} /> ); @@ -338,9 +343,21 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { ); } } else { - body = groups.map((group) => ( -
- {/* + body = groups.map((group) => { + const statusHeaderId = `task-status-${group.status}`; + const visibleTaskCount = group.tasks.reduce((count, task) => { + const taskGroup = hierarchyByRoot.get(task.id); + if (taskGroup === undefined || collapsedTaskIds.has(task.id)) { + return count + 1; + } + return count + 1 + taskGroup.children.length; + }, 0); + const mainTaskCount = `${group.tasks.length} ${group.tasks.length === 1 ? "main task" : "main tasks"}`; + const visibleTaskLabel = `${visibleTaskCount} ${visibleTaskCount === 1 ? "visible task" : "visible tasks"}`; + + return ( +
+ {/* Opaque canvas fill + stacking above row chrome: task rows keep relative z-10 property editors so they stay clickable above the stretched open overlay. The stuck status header must sit higher @@ -351,38 +368,57 @@ export function ListView({ projectId, activeOnly = false }: ListViewProps) { Hairline bottom border separates the pin band from scrolling rows (same token family as the filter bar and row dividers). */} -
- - {STATUS_LABELS[group.status]} - - {group.tasks.length} - -
- {group.tasks.map((task) => { - const taskGroup = hierarchyByRoot.get(task.id); - if (taskGroup === undefined) return null; - const collapsed = collapsedTaskIds.has(task.id); - return ( - - {renderTaskRow(task, { - level: 0, - childCount: taskGroup.children.length, - collapsed, - onToggle: () => toggleTask(task.id), - })} - {collapsed - ? null - : taskGroup.children.map((child) => - renderTaskRow(child, { level: 1 }), - )} - - ); - })} -
- )); +

+ + {STATUS_LABELS[group.status]} + + {mainTaskCount} · {visibleTaskLabel} + +

+
+ {group.tasks.map((task) => { + const taskGroup = hierarchyByRoot.get(task.id); + if (taskGroup === undefined) return null; + const collapsed = collapsedTaskIds.has(task.id); + const subtaskListId = `task-subtasks-${task.id}`; + const subtaskList = + taskGroup.children.length === 0 ? undefined : ( +
+ {collapsed + ? null + : taskGroup.children.map((child) => + renderTaskRow(child, { + level: 1, + parentTaskKey: task.key, + }), + )} +
+ ); + + return renderTaskRow( + task, + { + level: 0, + childCount: taskGroup.children.length, + collapsed, + onToggle: () => toggleTask(task.id), + subtaskListId, + }, + subtaskList, + ); + })} +
+
+ ); + }); } return ( diff --git a/plugins/tasks/views/list/row.tsx b/plugins/tasks/views/list/row.tsx index c251167afb..0ccc84f5ee 100644 --- a/plugins/tasks/views/list/row.tsx +++ b/plugins/tasks/views/list/row.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import type { Label, Project, @@ -8,7 +8,12 @@ import type { import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import type { TaskRowMeta } from "./data.js"; -import { activeWorkLabel, formatDueDate, partitionLabels } from "./lib.js"; +import { + activeWorkLabel, + formatDueDate, + partitionLabels, + STATUS_LABELS, +} from "./lib.js"; import type { EditFn } from "./property-menus.js"; import { isBareKey, @@ -115,8 +120,9 @@ export type TaskRowHierarchy = childCount: number; collapsed: boolean; onToggle: () => void; + subtaskListId: string; } - | { level: 1 }; + | { level: 1; parentTaskKey: string }; interface TaskRowProps { /** Task with any pending optimistic edit already applied. */ @@ -132,6 +138,8 @@ interface TaskRowProps { onOpen: () => void; /** A mutation for this row is in flight. */ pending: boolean; + /** Nested list for this task's direct subtasks. */ + subtaskList?: ReactNode; } /** @@ -153,113 +161,131 @@ export function TaskRow({ onEdit, onOpen, pending, + subtaskList, }: TaskRowProps) { const [openMenu, setOpenMenu] = useState<"status" | "priority" | null>(null); + const rowLabel = + hierarchy.level === 0 + ? `${task.key}: ${task.title}. Status ${STATUS_LABELS[task.status]}.` + : `${task.key}: ${task.title}. Subtask of ${hierarchy.parentTaskKey}. Status ${STATUS_LABELS[task.status]}.`; return ( - -
+ - - ) : ( - { + if (!isBareKey(event)) return; + const key = event.key.toLowerCase(); + if (key === "s") { + event.preventDefault(); + setOpenMenu("status"); + } else if (key === "p") { + event.preventDefault(); + setOpenMenu("priority"); + } + }} + className="absolute inset-0 rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" /> - )} - setOpenMenu(next ? "priority" : null)} - className="col-start-2 row-start-2" - /> - - {task.key} - - setOpenMenu(next ? "status" : null)} - className="col-start-2 row-start-1" - /> - - {task.title} - - - {meta ? : null} - - {task.dueDate !== null ? ( - - - {formatDueDate(task.dueDate)} + {hierarchy.level === 1 ? ( + + - ) : null} - {showProject && project !== undefined ? ( + ) : hierarchy.childCount > 0 ? ( + + ) : ( - ) : null} - -
-
+ )} + setOpenMenu(next ? "priority" : null)} + className="col-start-2 row-start-2" + /> + + {task.key} + + setOpenMenu(next ? "status" : null)} + className="col-start-2 row-start-1" + /> + + {task.title} + + + {meta ? : null} + + {task.dueDate !== null ? ( + + + {formatDueDate(task.dueDate)} + + ) : null} + {showProject && project !== undefined ? ( + + ) : null} + +
+
+ {subtaskList} + ); }