diff --git a/apps/web/src/components/projects/interactions/interaction-layout.tsx b/apps/web/src/components/projects/interactions/interaction-layout.tsx index cbdd04e7..86258231 100644 --- a/apps/web/src/components/projects/interactions/interaction-layout.tsx +++ b/apps/web/src/components/projects/interactions/interaction-layout.tsx @@ -66,6 +66,7 @@ import { sprintsQueryOptions, type Task, type TaskListResult, + taskQueryOptions, updateSprint, updateTask, updateViewById, @@ -1022,6 +1023,55 @@ export function InteractionLayout({ globalExtraTasks, ]); + // Epics referenced by tasks currently on screen but not yet among the + // paginated epicTasks pages (the picker loads 20 at a time — a task's + // epic may sort past that window even though it hasn't been "loaded + // more" by anyone). Fetch those specific epics directly so the epic + // badge on cards/rows resolves instead of silently rendering as unset. + const loadedEpicIds = useMemo( + () => new Set(epicTasks.map((e) => e.id)), + [epicTasks], + ); + const missingEpicIds = useMemo(() => { + // Once every epic page has been loaded, any parent_task_id that's + // still absent from loadedEpicIds is guaranteed to be a non-epic + // parent (story/task nesting) rather than an unpaginated epic — skip + // fetching it. This keeps the direct-fetch fallback scoped to the + // (typically brief) window before pagination catches up, instead of + // firing for every non-epic parent on every board/list render. + if (!hasMoreEpics) return []; + const ids = new Set(); + for (const task of tasks) { + if (task.parent_task_id && !loadedEpicIds.has(task.parent_task_id)) { + ids.add(task.parent_task_id); + } + } + return Array.from(ids); + }, [tasks, loadedEpicIds, hasMoreEpics]); + // `combine` gives the filtered result structural-sharing/memoization + // across renders (plain useQueries() returns a new array every render, + // which would otherwise defeat displayEpics' useMemo below). + const missingEpics = useQueries({ + queries: missingEpicIds.map((epicId) => ({ + ...taskQueryOptions(projectId, epicId), + enabled: !!epicType?.id, + })), + combine: (results) => + // parent_task_id also links non-epic parents (story/task nesting), so + // only fold fetched tasks in here when they're actually Epic-typed — + // otherwise a task's non-epic parent could render as its "epic". + results + .map((r) => r.data) + .filter( + (task): task is Task => !!task && task.task_type_id === epicType?.id, + ), + }); + const displayEpics = useMemo( + () => + missingEpics.length > 0 ? [...epicTasks, ...missingEpics] : epicTasks, + [epicTasks, missingEpics], + ); + const tasksLoading = viewsQuery.isLoading || (colQueriesEnabled @@ -1724,7 +1774,7 @@ export function InteractionLayout({ members={members} customFields={customFields} sprints={sprints} - epics={epicTasks} + epics={displayEpics} epicsPagination={epicsPagination} viewConfig={activeViewConfig} canCreate={canCreate} @@ -1775,7 +1825,7 @@ export function InteractionLayout({ taskTypes={creatableTaskTypes} members={members} customFields={customFields} - epics={epicTasks} + epics={displayEpics} epicsPagination={epicsPagination} viewConfig={activeViewConfig} canCreate={canCreate} diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.test.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.test.tsx new file mode 100644 index 00000000..33a00f2e --- /dev/null +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.test.tsx @@ -0,0 +1,225 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import type { Task } from "@/lib/interaction-api"; +import type { TaskType } from "@/lib/project-api"; +import { getPriority } from "../priority"; +import { PropertiesPanel } from "./properties-panel"; + +// PropertiesPanel's epic picker calls useEpicSearch (useInfiniteQuery) +// unconditionally, so it needs a QueryClientProvider ancestor even though the +// query stays disabled (the dropdown is never opened) in these tests. +function wrapper({ children }: { children: ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return {children}; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const makeTask = (overrides: Partial = {}): Task => ({ + id: "task-1", + project_id: "proj-1", + title: "Fix the login bug", + task_number: 1, + sprint_id: null, + status_id: null, + task_type_id: "type-task", + parent_task_id: null, + description: null, + importance: 0, + assignee_ids: [], + reporter_id: null, + custom_fields: {}, + view_position: null, + view_group_key: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, +}); + +const epicType: TaskType = { + id: "type-epic", + project_id: "proj-1", + name: "Epic", + icon: null, + color: "#8b5cf6", + description: null, + is_system: true, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const storyType: TaskType = { + id: "type-story", + project_id: "proj-1", + name: "Story", + icon: null, + color: "#22c55e", + description: null, + is_system: false, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const TASK_TYPES = [epicType, storyType]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("PropertiesPanel epic field", () => { + it("resolves the epic from epicTasks when it's already paginated", () => { + const epic = makeTask({ + id: "epic-1", + title: "Epic Already Loaded", + task_type_id: "type-epic", + }); + const task = makeTask({ parent_task_id: "epic-1" }); + + render( + , + { wrapper }, + ); + + expect(screen.getByText("Epic Already Loaded")).toBeInTheDocument(); + }); + + it("falls back to parentTask when the epic isn't in the paginated epicTasks list", () => { + // Regression test for the bug fixed in this change: epicTasks is + // paginated 20-per-page, so a task's own epic can legitimately be + // missing from it. The field must still resolve via parentTask + // (fetched directly, independent of pagination) instead of rendering + // as unset. + const parentTask = makeTask({ + id: "epic-1", + title: "Unpaginated Epic", + task_type_id: "type-epic", + }); + const task = makeTask({ parent_task_id: "epic-1" }); + + render( + , + { wrapper }, + ); + + expect(screen.getByText("Unpaginated Epic")).toBeInTheDocument(); + // It must resolve via the Epic field specifically, not the generic + // Parent field (which is where pre-fix code rendered it, since it only + // checked epicTasks membership rather than parentTask's actual type). + expect(screen.queryByText("Parent")).not.toBeInTheDocument(); + }); + + it("does not fall back to parentTask when it isn't Epic-typed", () => { + // A non-epic parentTask (story/task nesting) is a legitimate value for + // the *Parent* field, but must never be picked up by the Epic field's + // fallback. If it were, "Nested Story" would render twice: once + // (wrongly) as the epic and once (correctly) in the Parent field. + const parentTask = makeTask({ + id: "story-1", + title: "Nested Story", + task_type_id: "type-story", + }); + const task = makeTask({ parent_task_id: "story-1" }); + + render( + , + { wrapper }, + ); + + expect(screen.getAllByText("Nested Story")).toHaveLength(1); + }); +}); + +describe("PropertiesPanel parent field", () => { + it("shows the Parent field when parentTask is not Epic-typed", () => { + const parentTask = makeTask({ + id: "story-1", + title: "Parent Story", + task_type_id: "type-story", + }); + const task = makeTask({ parent_task_id: "story-1" }); + + render( + , + { wrapper }, + ); + + expect(screen.getByText("Parent Story")).toBeInTheDocument(); + }); + + it("hides the Parent field when parentTask is Epic-typed but absent from paginated epicTasks", () => { + // Regression test: previously this checked `epicTasks.find(...)`, so an + // unpaginated epic parent would be misclassified as a generic "Parent" + // instead of being recognized as the task's epic. + const parentTask = makeTask({ + id: "epic-1", + title: "Unpaginated Epic", + task_type_id: "type-epic", + }); + const task = makeTask({ parent_task_id: "epic-1" }); + + render( + , + { wrapper }, + ); + + expect(screen.queryByText("Parent")).not.toBeInTheDocument(); + // The epic field should be the one rendering it, not a generic parent field. + expect(screen.getByText("Unpaginated Epic")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx index 3bc39500..9ce44da5 100644 --- a/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx +++ b/apps/web/src/components/projects/interactions/task-detail/properties-panel.tsx @@ -334,8 +334,16 @@ export function PropertiesPanel({ {taskRole === "normal" && (epicTasks.length > 0 || task.parent_task_id) && (() => { + // epicTasks is paginated 20-per-page, so a task's own epic may + // not be loaded into it yet — fall back to parentTask (fetched + // directly for this task regardless of pagination) when it's + // actually Epic-typed, so the field doesn't render as unset. const epic = task.parent_task_id - ? epicTasks.find((e) => e.id === task.parent_task_id) + ? (epicTasks.find((e) => e.id === task.parent_task_id) ?? + (parentTask?.id === task.parent_task_id && + parentTask.task_type_id === epicTypeId + ? parentTask + : undefined)) : undefined; const otherEpics = epicTasks.filter( (e) => e.id !== task.parent_task_id, @@ -447,11 +455,14 @@ export function PropertiesPanel({ ); })()} - {/* Parent field – shown when the parent task is not an epic (story/task/bug nesting) */} + {/* Parent field – shown when the parent task is not an epic (story/task/bug nesting). + * Checked against parentTask's own type rather than epicTasks membership — + * epicTasks is paginated, so an actual epic parent can be legitimately + * absent from it without meaning "this parent isn't an epic". */} {taskRole === "normal" && task.parent_task_id && parentTask && - !epicTasks.find((e) => e.id === task.parent_task_id) && ( + parentTask.task_type_id !== epicTypeId && ( {(() => { const parentType = taskTypes.find(