mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(ui): show parent breadcrumb titles without task IDs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a4acfc9c92
commit
c848073ee1
5 files changed
+28
-23
No files matched your search
@@ -847,8 +847,9 @@ Task breadcrumbs link the immediate parent before the current task. Each task
|
||||
title displays at most 48 Unicode code points, including an ellipsis; saved
|
||||
titles, hover text and accessible names retain the full title. The parent uses
|
||||
at most 40% of the breadcrumb row, and both titles truncate when space runs out.
|
||||
Mobile omits the collection link and parent identifier to preserve both titles
|
||||
and the current task identifier. Private parents show a noninteractive label.
|
||||
Parent breadcrumbs show only the task title, with no identifier, at every width.
|
||||
Mobile omits the collection link to preserve both titles and the current task
|
||||
identifier. Private parents show a noninteractive Private label without an identifier.
|
||||
|
||||
The local task detail also offers a **Thread preview** selector. DES-1 opens a
|
||||
complete synthetic conversation matching the staging transcript layout; other
|
||||
|
||||
@@ -82,7 +82,7 @@ function TaskBreadcrumbs({
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: "Tasks", href: sourceHref },
|
||||
...(parentTitle ? [{ label: parentTitle, identifier: "PAP-120", href: "/issues/PAP-120" }] : []),
|
||||
...(parentTitle ? [{ label: parentTitle, href: "/issues/PAP-120" }] : []),
|
||||
{
|
||||
label: title,
|
||||
identifier,
|
||||
@@ -273,8 +273,9 @@ describe("BreadcrumbBar", () => {
|
||||
<BreadcrumbProvider><TaskBreadcrumbs taskDetailLayout parentTitle="Prepare the engineering team" /></BreadcrumbProvider>,
|
||||
));
|
||||
const parent = container.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-120"]');
|
||||
expect(parent?.textContent).toContain("Prepare the engineering team");
|
||||
expect(parent?.getAttribute("aria-label")).toBe("Prepare the engineering team PAP-120");
|
||||
expect(parent?.textContent).toBe("Prepare the engineering team");
|
||||
expect(parent?.querySelector('[data-slot="task-title-identifier"]')).toBeNull();
|
||||
expect(parent?.getAttribute("aria-label")).toBe("Prepare the engineering team");
|
||||
expect(container.querySelector('[aria-current="page"]')?.textContent).toContain("PAP-16679");
|
||||
if (mobile) {
|
||||
expect(container.querySelector('a[href="/issues"]')).toBeNull();
|
||||
|
||||
@@ -2923,7 +2923,7 @@ describe("IssueDetail", () => {
|
||||
expect(container.textContent).toContain("Issue detail smoke");
|
||||
expect(mockSetBreadcrumbs).toHaveBeenLastCalledWith([
|
||||
{ label: "Tasks", href: "/issues" },
|
||||
expect.objectContaining({ label: "Parent task visible in Properties", identifier: "PAP-0", href: "/issues/PAP-0" }),
|
||||
expect.objectContaining({ label: "Parent task visible in Properties", href: "/issues/PAP-0" }),
|
||||
expect.objectContaining({ label: "Issue detail smoke" }),
|
||||
]);
|
||||
});
|
||||
@@ -2939,6 +2939,8 @@ describe("IssueDetail", () => {
|
||||
await act(async () => { renderIssueView(); });
|
||||
await flushReact();
|
||||
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
|
||||
expect(parentCrumb.identifier).toBeUndefined();
|
||||
expect(parentCrumb.label).toBe("Parent task");
|
||||
expect(parentCrumb.onClick).toBeTypeOf("function");
|
||||
parentCrumb.onClick();
|
||||
const expected = { label: source === "query" ? "Inbox" : "Mine", href: state.issueDetailBreadcrumb!.href };
|
||||
@@ -2951,7 +2953,9 @@ describe("IssueDetail", () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ id: "parent-1", identifier: "PAP-0", title: "Parent task" }));
|
||||
await act(async () => { renderIssueView(); });
|
||||
await waitForAssertion(() => {
|
||||
expect(mockSetBreadcrumbs.mock.calls.at(-1)![0][0]).toEqual(expected);
|
||||
const breadcrumbs = mockSetBreadcrumbs.mock.calls.at(-1)![0];
|
||||
expect(breadcrumbs[0]).toEqual(expected);
|
||||
expect(breadcrumbs.at(-1)?.label).toBe(parentCrumb.label);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2964,19 +2968,20 @@ describe("IssueDetail", () => {
|
||||
await flushReact();
|
||||
expect(mockSetBreadcrumbs).toHaveBeenLastCalledWith([
|
||||
{ label: "Tasks", href: "/issues" },
|
||||
expect.objectContaining({ label: "Private task", identifier: "PAP-0", href: undefined }),
|
||||
expect.objectContaining({ label: "Private task", href: undefined }),
|
||||
expect.objectContaining({ label: "Issue detail smoke" }),
|
||||
]);
|
||||
const parentCrumb = mockSetBreadcrumbs.mock.calls.at(-1)![0][1];
|
||||
expect(parentCrumb.identifier).toBeUndefined();
|
||||
expect(parentCrumb.onClick).toBeUndefined();
|
||||
await act(async () => { renderIssueView(parentCrumb.control); });
|
||||
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip?.textContent).toBe(isMobile ? "Private" : "PAP-0");
|
||||
expect(chip?.textContent).toBe("Private");
|
||||
expect(container.querySelector('a, [role="link"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("updates the private parent label when an open task changes viewport", async () => {
|
||||
it("keeps the private parent title free of IDs when an open task changes viewport", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
ancestors: [{ id: "private-parent", identifier: "PAP-0", locked: true }] as unknown as Issue["ancestors"],
|
||||
}));
|
||||
@@ -3000,7 +3005,7 @@ describe("IssueDetail", () => {
|
||||
await act(async () => {
|
||||
renderIssueView(<BreadcrumbProvider><RenderBreadcrumbControl /></BreadcrumbProvider>);
|
||||
});
|
||||
for (const [index, expected] of ["PAP-0", "Private", "PAP-0"].entries()) {
|
||||
for (const [index, expected] of ["Private", "Private", "Private"].entries()) {
|
||||
await act(async () => { updateBreadcrumbs(captured[index]); });
|
||||
expect(container.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toBe(expected);
|
||||
expect(container.querySelector('a, [role="link"]')).toBeNull();
|
||||
|
||||
@@ -5994,15 +5994,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
sourceBreadcrumb,
|
||||
...(breadcrumbParent ? [{
|
||||
label: isLockedIssueStub(breadcrumbParent) ? "Private task" : breadcrumbParent.title,
|
||||
identifier: breadcrumbParent.identifier ?? undefined,
|
||||
href: isLockedIssueStub(breadcrumbParent)
|
||||
? undefined
|
||||
: createIssueDetailPath(breadcrumbParent.identifier ?? breadcrumbParent.id),
|
||||
onClick: isLockedIssueStub(breadcrumbParent) ? undefined : rememberBreadcrumbParent,
|
||||
control: isLockedIssueStub(breadcrumbParent)
|
||||
? <LockedIssueChip identifier={isMobile ? null : breadcrumbParent.identifier} />
|
||||
? <LockedIssueChip identifier={null} />
|
||||
: undefined,
|
||||
controlKey: isLockedIssueStub(breadcrumbParent) ? `private-parent:${isMobile}` : undefined,
|
||||
controlKey: isLockedIssueStub(breadcrumbParent) ? "private-parent" : undefined,
|
||||
}] : []),
|
||||
{
|
||||
// The status glyph (leading) already conveys in-progress/live state;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BreadcrumbBar } from "@/components/BreadcrumbBar";
|
||||
import { LockedIssueChip } from "@/components/LockedIssueChip";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
|
||||
@@ -23,21 +22,19 @@ function TaskBreadcrumbPreview({
|
||||
source?: "Tasks" | "Mine";
|
||||
}) {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const { isMobile } = useSidebar();
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: source, href: source === "Mine" ? "/inbox" : "/issues" },
|
||||
...(showParent ? [{
|
||||
label: privateParent ? "Private task" : parentTitle,
|
||||
identifier: "PAP-120",
|
||||
href: privateParent ? undefined : "/issues/PAP-120",
|
||||
control: privateParent ? <LockedIssueChip identifier={isMobile ? null : "PAP-120"} /> : undefined,
|
||||
controlKey: privateParent ? `private-parent:${isMobile}` : undefined,
|
||||
control: privateParent ? <LockedIssueChip identifier={null} /> : undefined,
|
||||
controlKey: privateParent ? "private-parent" : undefined,
|
||||
}] : []),
|
||||
{ label: title, identifier: "PAP-121", leading: <StatusIcon status="in_progress" className="size-3" />, leadingKey: "in_progress" },
|
||||
]);
|
||||
}, [parentTitle, title, showParent, privateParent, source, isMobile, setBreadcrumbs]);
|
||||
}, [parentTitle, title, showParent, privateParent, source, setBreadcrumbs]);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh w-full bg-background text-foreground">
|
||||
@@ -67,7 +64,7 @@ const meta = {
|
||||
status: "current",
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
notes: "Mounts the shipped BreadcrumbBar with fixture breadcrumb context. Covers linked immediate parent, long titles, private parent, standalone task, Mine source, light/dark themes and responsive widths. Page data loading and parent authorization are outside this isolated header story.",
|
||||
notes: "Mounts the shipped BreadcrumbBar with fixture breadcrumb context. Covers title-only linked immediate parent, long titles, private parent, standalone task, Mine source, light/dark themes and responsive widths. Page data loading and parent authorization are outside this isolated header story.",
|
||||
},
|
||||
},
|
||||
args: { parentTitle: "Prepare the product launch", title: "Review the release checklist" },
|
||||
@@ -79,7 +76,8 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Subtask: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const nav = within(await within(canvasElement).findByRole("navigation", { name: "breadcrumb" }));
|
||||
await expect(nav.getByRole("link", { name: "Prepare the product launch PAP-120" })).toHaveAttribute("href", "/PAP/issues/PAP-120");
|
||||
await expect(nav.getByRole("link", { name: "Prepare the product launch" })).toHaveAttribute("href", "/PAP/issues/PAP-120");
|
||||
await expect(nav.getByRole("link", { name: "Prepare the product launch" })).toHaveTextContent(/^Prepare the product launch$/);
|
||||
await expect(nav.getByRole("link", { name: "Review the release checklist PAP-121" })).toHaveAttribute("aria-current", "page");
|
||||
},
|
||||
};
|
||||
@@ -108,7 +106,8 @@ export const PrivateParent: Story = {
|
||||
const nav = await within(canvasElement).findByRole("navigation", { name: "breadcrumb" });
|
||||
await expect(within(nav).queryByRole("link", { name: /Prepare the product launch/ })).not.toBeInTheDocument();
|
||||
await expect(within(nav).queryByRole("link", { name: /private/i })).not.toBeInTheDocument();
|
||||
await expect(within(nav).getByTestId("locked-issue-chip")).toBeVisible();
|
||||
await expect(within(nav).getByTestId("locked-issue-chip")).toHaveTextContent(/^Private$/);
|
||||
await expect(nav).not.toHaveTextContent("PAP-120");
|
||||
await expect(nav.querySelectorAll('[aria-current="page"]')).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user