feat(FN-1005): add mission title caching, abbreviation, and display on TaskCard
- Add mission title abbreviation utility (truncate long titles with ellipsis) - Add mission title caching to avoid repeated lookups per render - Display abbreviated mission title as a badge on TaskCard component - Add comprehensive tests for mission title display, caching, and edge cases
This commit is contained in:
@@ -20,9 +20,10 @@ vi.mock("lucide-react", () => ({
|
||||
vi.mock("../api", () => ({
|
||||
fetchTaskDetail: vi.fn(),
|
||||
uploadAttachment: vi.fn(),
|
||||
fetchMission: vi.fn(),
|
||||
}));
|
||||
|
||||
import { uploadAttachment } from "../api";
|
||||
import { uploadAttachment, fetchMission } from "../api";
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
@@ -257,3 +258,148 @@ describe("TaskCard", () => {
|
||||
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard mission badge", () => {
|
||||
// Access the internal cache reset helper
|
||||
let clearCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("./TaskCard");
|
||||
clearCache = (mod as any).__test_clearMissionTitleCache;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache?.();
|
||||
vi.mocked(fetchMission).mockReset();
|
||||
});
|
||||
|
||||
it("displays mission title instead of missionId", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-ABC123",
|
||||
title: "Database Optimization",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-ABC123" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badge?.textContent).toContain("Database Optimiza...");
|
||||
});
|
||||
});
|
||||
|
||||
it("abbreviates long mission titles with ellipsis", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-LONG1",
|
||||
title: "This Is A Very Long Mission Title That Exceeds Twenty Characters",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-LONG1" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
// MAX_MISSION_TITLE_LENGTH is 20, so first 17 chars + "..."
|
||||
expect(badge?.textContent).toContain("This Is A Very Lo...");
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to missionId on fetch error", async () => {
|
||||
vi.mocked(fetchMission).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-ERR99" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badge?.textContent).toContain("M-ERR99");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mission title in title attribute", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-TITLE",
|
||||
title: "Refactor Auth",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-TITLE" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows short mission title without abbreviation", async () => {
|
||||
vi.mocked(fetchMission).mockResolvedValue({
|
||||
id: "M-SHORT",
|
||||
title: "Auth Fix",
|
||||
status: "active",
|
||||
interviewState: "completed",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={makeTask({ missionId: "M-SHORT" })}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".card-mission-badge");
|
||||
expect(badge).not.toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
// "Auth Fix" is 8 chars, well under 20 — no abbreviation needed
|
||||
expect(badge?.textContent).toContain("Auth Fix");
|
||||
expect(badge?.textContent).not.toContain("...");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
|
||||
import { fetchTaskDetail, uploadAttachment } from "../api";
|
||||
import { fetchTaskDetail, uploadAttachment, fetchMission } from "../api";
|
||||
import { GitHubBadge } from "./GitHubBadge";
|
||||
import { pickPreferredBadge } from "./TaskCardBadge";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
@@ -11,6 +11,37 @@ import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
// ── Mission title caching ───────────────────────────────────────────────────
|
||||
|
||||
const missionTitleCache = new Map<string, string>();
|
||||
|
||||
/** @internal Test helper to reset the mission title cache between tests */
|
||||
export function __test_clearMissionTitleCache(): void {
|
||||
missionTitleCache.clear();
|
||||
}
|
||||
|
||||
async function getMissionTitle(missionId: string, projectId?: string): Promise<string> {
|
||||
const cached = missionTitleCache.get(missionId);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const mission = await fetchMission(missionId, projectId);
|
||||
missionTitleCache.set(missionId, mission.title);
|
||||
return mission.title;
|
||||
} catch {
|
||||
return missionId;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MISSION_TITLE_LENGTH = 20;
|
||||
|
||||
function abbreviateMissionTitle(title: string): string {
|
||||
if (title.length <= MAX_MISSION_TITLE_LENGTH) return title;
|
||||
return title.slice(0, MAX_MISSION_TITLE_LENGTH - 3) + "...";
|
||||
}
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "rgba(210,153,34,0.15)",
|
||||
todo: "rgba(88,166,255,0.15)",
|
||||
@@ -151,6 +182,7 @@ function TaskCardComponent({
|
||||
task.column === "in-progress" ||
|
||||
(task.column === "triage" && task.steps.some(s => s.status === "done" || s.status === "skipped"))
|
||||
);
|
||||
const [missionTitle, setMissionTitle] = useState<string | null>(null);
|
||||
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
@@ -172,6 +204,27 @@ function TaskCardComponent({
|
||||
setEditDescription(task.description || "");
|
||||
}, [task.id, task.description]);
|
||||
|
||||
// Fetch mission title when missionId is set
|
||||
useEffect(() => {
|
||||
if (!task.missionId) {
|
||||
setMissionTitle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache synchronously first
|
||||
const cached = missionTitleCache.get(task.missionId);
|
||||
if (cached) {
|
||||
setMissionTitle(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void getMissionTitle(task.missionId, projectId).then((title) => {
|
||||
if (!cancelled) setMissionTitle(title);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [task.missionId, projectId]);
|
||||
|
||||
// Auto-focus and auto-resize description textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (isEditing && descTextareaRef.current) {
|
||||
@@ -616,12 +669,12 @@ function TaskCardComponent({
|
||||
<span
|
||||
className="card-mission-badge"
|
||||
onClick={handleMissionClick}
|
||||
title={`Mission: ${task.missionId}`}
|
||||
title={`Mission: ${missionTitle ?? task.missionId}`}
|
||||
role={onOpenMission ? "button" : undefined}
|
||||
tabIndex={onOpenMission ? 0 : undefined}
|
||||
>
|
||||
<Target size={11} />
|
||||
{task.missionId}
|
||||
{abbreviateMissionTitle(missionTitle ?? task.missionId)}
|
||||
</span>
|
||||
)}
|
||||
<div className="card-header-actions">
|
||||
|
||||
Reference in New Issue
Block a user