Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
sprintsQueryOptions,
type Task,
type TaskListResult,
taskQueryOptions,
updateSprint,
updateTask,
updateViewById,
Expand Down Expand Up @@ -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<string>();
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
Expand Down Expand Up @@ -1724,7 +1774,7 @@ export function InteractionLayout({
members={members}
customFields={customFields}
sprints={sprints}
epics={epicTasks}
epics={displayEpics}
epicsPagination={epicsPagination}
viewConfig={activeViewConfig}
canCreate={canCreate}
Expand Down Expand Up @@ -1775,7 +1825,7 @@ export function InteractionLayout({
taskTypes={creatableTaskTypes}
members={members}
customFields={customFields}
epics={epicTasks}
epics={displayEpics}
epicsPagination={epicsPagination}
viewConfig={activeViewConfig}
canCreate={canCreate}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

// ── Fixtures ──────────────────────────────────────────────────────────────────

const makeTask = (overrides: Partial<Task> = {}): 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(
<PropertiesPanel
task={task}
status={undefined}
taskType={storyType}
priority={getPriority(0)}
assignees={[]}
reporter={undefined}
taskTypes={TASK_TYPES}
taskRole="normal"
epicTasks={[epic]}
/>,
{ 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(
<PropertiesPanel
task={task}
status={undefined}
taskType={storyType}
priority={getPriority(0)}
assignees={[]}
reporter={undefined}
taskTypes={TASK_TYPES}
taskRole="normal"
epicTasks={[]}
parentTask={parentTask}
/>,
{ 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(
<PropertiesPanel
task={task}
status={undefined}
taskType={storyType}
priority={getPriority(0)}
assignees={[]}
reporter={undefined}
taskTypes={TASK_TYPES}
taskRole="normal"
epicTasks={[]}
parentTask={parentTask}
/>,
{ 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(
<PropertiesPanel
task={task}
status={undefined}
taskType={storyType}
priority={getPriority(0)}
assignees={[]}
reporter={undefined}
taskTypes={TASK_TYPES}
taskRole="normal"
epicTasks={[]}
parentTask={parentTask}
/>,
{ 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(
<PropertiesPanel
task={task}
status={undefined}
taskType={storyType}
priority={getPriority(0)}
assignees={[]}
reporter={undefined}
taskTypes={TASK_TYPES}
taskRole="normal"
epicTasks={[]}
parentTask={parentTask}
/>,
{ 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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -447,11 +455,14 @@ export function PropertiesPanel({
</FieldRow>
);
})()}
{/* 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 && (
<FieldRow label={t("taskDetail.properties.parent")}>
{(() => {
const parentType = taskTypes.find(
Expand Down
Loading