feat(ui): add task status badges and inline blocker removal (#13097)

Add navigable status badges to task relationships and a separate blocker remove button with stable hover geometry. Keep Storybook previews passive and cover navigation, removal, and query refresh rendering with regression tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 10:06:21 -05:00 committed by GitHub
parent cd4c4ed205
commit e9a5a07ab4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 332 additions and 53 deletions

View File

@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
const storyIds = [
"product-issue-management--issue-properties-relationship-badges",
"product-issue-management--issue-properties-relationship-badges-inline",
];
for (const storyId of storyIds) {
test(`${storyId} stays still until an operator acts`, async ({ page }) => {
await page.goto(`/iframe.html?id=${storyId}&viewMode=story&globals=theme:dark`);
const remove = page.getByRole("button", { name: "Remove PAP-18313 as blocker", exact: true });
await expect(remove).toBeAttached();
// Sample successive painted frames, not just the settled end state: an
// autoplay remove/reset cycle would otherwise leave an identical screenshot.
const frames = await page.evaluate(async () => {
const samples = [];
for (let frame = 0; frame < 60; frame += 1) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const button = document.querySelector('button[aria-label="Remove PAP-18313 as blocker"]');
const link = button?.previousElementSibling;
samples.push({
route: document.querySelector('[data-testid="relationship-route"]')?.textContent,
opacity: button ? getComputedStyle(button).opacity : null,
badge: link?.getBoundingClientRect().toJSON() ?? null,
});
}
return samples;
});
expect(frames[0].route).toBe("/PAP/storybook");
expect(frames[0].opacity).toBe("0");
expect(frames[0].badge).not.toBeNull();
for (const frame of frames) expect(frame).toEqual(frames[0]);
const link = page.getByRole("link", { name: "Task PAP-18313: Review task relationships", exact: true }).first();
const before = await link.boundingBox();
await link.hover();
await expect(remove).toHaveCSS("opacity", "1");
expect(await link.boundingBox()).toEqual(before);
const textRight = await link.locator("span").evaluate((element) => element.getBoundingClientRect().right);
expect(textRight).toBeLessThan((await remove.boundingBox())!.x);
await link.getByRole("img", { name: "Todo" }).click();
await expect(page.getByTestId("relationship-route")).toHaveText("/PAP/issues/PAP-18313");
await expect(remove).toBeAttached();
await page.keyboard.press("Tab");
await expect(remove).toBeFocused();
await page.keyboard.press("Space");
await expect(remove).not.toBeAttached();
await expect(page.getByRole("button", { name: "Remove PAP-18314 as blocker" })).toBeAttached();
await expect(page.getByTestId("relationship-route")).toHaveText("/PAP/issues/PAP-18313");
});
}
test("switching relationship previews never runs removal or navigation", async ({ page }) => {
await page.goto(`/?path=/story/${storyIds[0]}`);
const preview = page.frameLocator("#storybook-preview-iframe");
for (const name of [
"IssueProperties - relationship badges inline",
"IssueProperties - relationship badges",
"IssueProperties - relationship badges inline",
]) {
await page.getByRole("link", { name, exact: true }).click();
await expect(preview.getByRole("button", { name: "Remove PAP-18313 as blocker" })).toBeAttached();
await expect(preview.getByTestId("relationship-route")).toHaveText("/PAP/storybook");
await expect(preview.getByRole("button", { name: "Remove PAP-18314 as blocker" })).toBeAttached();
}
});

View File

@ -209,7 +209,7 @@ async function flush() {
function findRowTrigger(container: HTMLElement, label: string): HTMLButtonElement | undefined {
const labelSpan = container.querySelector(`[data-property-label="${label}"]`);
const row = labelSpan?.closest('[data-property-row="true"]');
return (row?.querySelector("button") as HTMLButtonElement | null) ?? undefined;
return ((row?.querySelector(`button[aria-label="Edit ${label.toLowerCase()}"]`) ?? row?.querySelector("button")) as HTMLButtonElement | null) ?? undefined;
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
@ -590,7 +590,9 @@ describe("IssueProperties", () => {
for (const label of ["Labels", "Blocked by", "Subtasks"]) {
const trigger = findRowTrigger(container, label);
const chipStack = trigger?.querySelector("div");
const chipStack = label === "Labels"
? trigger?.querySelector("div")
: trigger?.closest('[data-property-value="true"]')?.querySelector(".flex-col");
expect(chipStack?.classList).toContain("flex-col");
expect(chipStack?.classList).toContain("items-start");
}
@ -1206,6 +1208,66 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it.each([false, true])("keeps relationship badges mounted as queries settle (inline=%s)", async (inline) => {
let resolveProjects!: (projects: Project[]) => void;
mockProjectsApi.list.mockReturnValue(new Promise<Project[]>((resolve) => { resolveProjects = resolve; }));
const onUpdate = vi.fn();
const issue = createIssue({
blockedBy: [createIssue({ id: "blocker-1", identifier: "PAP-2", status: "in_progress" })],
});
const props = { issue, childIssues: [], onUpdate, inline, sidePanelContentOnly: true };
const { root, queryClient } = renderPropertiesWithQueryClient(container, props);
const link = container.querySelector('a[href="/issues/PAP-2"]');
const remove = container.querySelector('[aria-label="Remove PAP-2 as blocker"]');
expect(link).not.toBeNull();
expect(remove).not.toBeNull();
await flush();
await act(async () => resolveProjects([]));
await flush();
// A parent page can provide a fresh task object after a query refresh.
await act(async () => root.render(
<QueryClientProvider client={queryClient}>
<IssueProperties {...props} issue={{ ...issue, blockedBy: [...issue.blockedBy!] }} />
</QueryClientProvider>,
));
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBe(link);
expect(container.querySelector('[aria-label="Remove PAP-2 as blocker"]')).toBe(remove);
expect(link?.textContent).toContain("in_progress");
expect(onUpdate).not.toHaveBeenCalled();
expect(container.querySelector('[aria-expanded="true"]')).toBeNull();
act(() => root.unmount());
});
it("links relationship status and IDs and removes only the selected blocker with its X", async () => {
const onUpdate = vi.fn();
const blockers = [
createIssue({ id: "issue-2", identifier: "PAP-2", title: "Existing blocker", status: "in_progress" }),
createIssue({ id: "issue-3", identifier: "PAP-3", title: "Keep blocker", status: "todo" }),
];
const root = renderProperties(container, {
issue: createIssue({ blockedBy: blockers }),
childIssues: [createIssue({ id: "child-1", identifier: "PAP-4", status: "done" })],
onUpdate,
inline: true,
});
await flush();
const row = container.querySelector('[data-property-label="Blocked by"]')!.closest('[data-property-row]')!;
const link = row.querySelector<HTMLAnchorElement>('a[href="/issues/PAP-2"]')!;
expect(link).not.toBeNull();
expect(link.textContent).toContain("in_progress");
expect(link.closest("button")).toBeNull();
link.addEventListener("click", (event) => event.preventDefault());
await act(async () => link.click());
expect(onUpdate).not.toHaveBeenCalled();
expect(container.querySelector('input[aria-label="Search tasks to add as blockers"]')).toBeNull();
const remove = row.querySelector<HTMLButtonElement>('button[aria-label="Remove PAP-2 as blocker"]')!;
expect(remove.closest("a")).toBeNull();
await act(async () => remove.click());
expect(onUpdate).toHaveBeenCalledExactlyOnceWith({ blockedByIssueIds: ["issue-3"] });
expect(container.querySelector('a[href="/issues/PAP-4"]')?.textContent).toContain("done");
act(() => root.unmount());
});
it("edits blockers from the blocked-by relationship flyout", async () => {
const onUpdate = vi.fn();
mockIssuesApi.list.mockResolvedValue([
@ -1233,7 +1295,7 @@ describe("IssueProperties", () => {
await flush();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2");
expect(container.textContent).not.toContain("Add blocker");
expect(container.querySelector('input[placeholder="Search tasks..."]')).toBeNull();
@ -1395,7 +1457,7 @@ describe("IssueProperties", () => {
await flush();
const blockerTrigger = findRowTrigger(container, "Blocked by");
expect(blockerTrigger?.textContent).toContain("PAP-2");
expect(blockerTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("PAP-2");
await act(async () => {
blockerTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -1439,16 +1501,16 @@ describe("IssueProperties", () => {
await flush();
const blockedByTrigger = findRowTrigger(container, "Blocked by");
expect(blockedByTrigger?.textContent).toContain("BLOCK-1");
expect(blockedByTrigger?.textContent).toContain("BLOCK-2");
expect(blockedByTrigger?.textContent).toContain("+5 more");
expect(blockedByTrigger?.textContent).not.toContain("BLOCK-7");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-2");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
expect(blockedByTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-7");
const subtasksTrigger = findRowTrigger(container, "Subtasks");
expect(subtasksTrigger?.textContent).toContain("SUB-1");
expect(subtasksTrigger?.textContent).toContain("SUB-2");
expect(subtasksTrigger?.textContent).toContain("+5 more");
expect(subtasksTrigger?.textContent).not.toContain("SUB-7");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-1");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("SUB-2");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
expect(subtasksTrigger?.closest('[data-property-row="true"]')?.textContent).not.toContain("SUB-7");
await act(async () => {
blockedByTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@ -1632,8 +1694,8 @@ describe("IssueProperties", () => {
});
await flush();
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("+5 more");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("+5 more");
const nextBlockedBy = [{
id: "next-blocker",
@ -1659,9 +1721,9 @@ describe("IssueProperties", () => {
});
await flush();
expect(findRowTrigger(container, "Blocked by")?.textContent).toContain("NEXT-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.textContent).not.toContain("more");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).toContain("NEXT-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("BLOCK-1");
expect(findRowTrigger(container, "Blocked by")?.closest('[data-property-row="true"]')?.textContent).not.toContain("more");
act(() => root.unmount());
});
@ -2407,8 +2469,7 @@ describe("IssueProperties", () => {
});
await flush();
const selectedParentTrigger = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-2 Candidate parent"));
const selectedParentTrigger = findRowTrigger(container, "Parent");
expect(selectedParentTrigger).not.toBeUndefined();
const parentLink = container.querySelector('a[href="/issues/PAP-2"]');
expect(parentLink).not.toBeNull();

View File

@ -1,7 +1,9 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { badgeVariants } from "./ui/badge";
import { StatusIcon } from "./StatusIcon";
export function IssueReferencePill({
@ -9,29 +11,65 @@ export function IssueReferencePill({
strikethrough,
className,
children,
onRemove,
variant = "mention",
}: {
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
Partial<Pick<IssueRelationIssueSummary, "status">>;
{ status?: string };
strikethrough?: boolean;
variant?: "mention" | "property";
className?: string;
children?: ReactNode;
/** Reserves space for a separate hover/focus action without moving the task link. */
onRemove?: (issueId: string) => void;
}) {
const issueLabel = issue.identifier ?? issue.title;
const classNames = cn(
"paperclip-mention-chip paperclip-mention-chip--issue",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
variant === "property" || onRemove
? cn(badgeVariants({ variant: "outline" }), "min-w-0 max-w-full shrink font-normal no-underline")
: "paperclip-mention-chip paperclip-mention-chip--issue inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
onRemove && "pr-6",
strikethrough && "opacity-60 line-through decoration-muted-foreground",
className,
);
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
{children !== undefined ? children : <span>{issue.identifier ?? issue.title}</span>}
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
</>
);
if (!issue.identifier) {
if (onRemove) {
return (
<span className="group/issue-reference relative inline-flex min-w-0 max-w-full">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook
data-mention-kind="issue"
className={cn(classNames, "min-w-0 max-w-full")}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/issue-reference:opacity-100 group-focus-within/issue-reference:opacity-100 pointer-coarse:opacity-100"
aria-label={`Remove ${issueLabel} as blocker`}
title={`Remove ${issueLabel} as blocker`}
onClick={(event) => {
event.stopPropagation();
onRemove(issue.id);
}}
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
);
}
if (!issue.identifier && variant === "mention") {
return (
<span
data-mention-kind="issue"
@ -46,7 +84,8 @@ export function IssueReferencePill({
return (
<Link
to={`/issues/${issueLabel}`}
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook={variant === "property"}
data-mention-kind="issue"
className={classNames}
title={issue.title}

View File

@ -2046,13 +2046,15 @@ export function IssueProperties({
const blockedByTrigger = blockedByRelations.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{blockedByRelations.slice(0, 2).map((relation) => (
<PropertyChip key={relation.id}>
{relation.identifier ?? relation.title}
</PropertyChip>
<IssueReferencePill
key={relation.id}
issue={relation}
onRemove={(id) => onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== id) })}
/>
))}
{blockedByRelations.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{blockedByRelations.length - 2} more
<Badge asChild variant="outline" className="border-border text-muted-foreground hover:bg-accent/50">
<button type="button" onClick={() => setBlockedByOpen(true)}>+{blockedByRelations.length - 2} more</button>
</Badge>
) : null}
</div>
@ -2062,13 +2064,11 @@ export function IssueProperties({
const subtasksTrigger = childIssues.length > 0 ? (
<div className="flex min-w-0 flex-col items-start gap-1">
{childIssues.slice(0, 2).map((child) => (
<PropertyChip key={child.id}>
{child.identifier ?? child.title}
</PropertyChip>
<IssueReferencePill variant="property" key={child.id} issue={child} className="min-w-0 max-w-full" />
))}
{childIssues.length > 2 ? (
<Badge variant="outline" className="border-border text-muted-foreground">
+{childIssues.length - 2} more
<Badge asChild variant="outline" className="border-border text-muted-foreground hover:bg-accent/50">
<button type="button" onClick={() => setSubtasksOpen(true)}>+{childIssues.length - 2} more</button>
</Badge>
) : null}
</div>
@ -2106,25 +2106,19 @@ export function IssueProperties({
const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier;
const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8);
const parentTrigger = issue.parentId ? (
<span
className="text-sm truncate min-w-0"
title={`${parentIdentifier ? `${parentIdentifier} ` : ""}${parentTitle ?? ""}`.trim()}
>
{parentIdentifier ? `${parentIdentifier} ` : ""}
{parentTitle}
</span>
<IssueReferencePill
variant="property"
issue={{
id: issue.parentId,
identifier: parentIdentifier ?? issue.parentId,
title: parentTitle ?? "Parent task",
status: issue.ancestors?.[0]?.status ?? currentParentIssue?.status,
}}
className="min-w-0 max-w-full"
/>
) : (
<span className="text-sm text-muted-foreground">None</span>
);
const parentLink = issue.parentId ? (
<Link
to={`/issues/${parentIdentifier ?? issue.parentId}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined;
const parentSearchActive = normalizedParentSearch.length > 0;
// When the user types, search on the server. The default list caps at 500 rows
// and sorts priority-first, so a medium-priority or low-priority match past that
@ -2438,7 +2432,7 @@ export function IssueProperties({
triggerContent={parentTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
extra={parentLink}
separateTrigger={!!issue.parentId}
>
{parentContent}
</PropertyPicker>
@ -2452,6 +2446,7 @@ export function IssueProperties({
setBlockedByOpen(open);
if (!open) setBlockedBySearch("");
}}
separateTrigger={blockedByRelations.length > 0}
triggerContent={blockedByTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"
@ -2517,7 +2512,7 @@ export function IssueProperties({
{blockingIssues.length > 0 ? (
<div className="flex flex-col items-start gap-1.5">
{visibleBlockingIssues.map((relation) => (
<IssueReferencePill key={relation.id} issue={relation} />
<IssueReferencePill variant="property" key={relation.id} issue={relation} />
))}
<ExpandRelationListButton
hiddenCount={hiddenBlockingIssueCount}
@ -2536,6 +2531,7 @@ export function IssueProperties({
label="Subtasks"
open={subtasksOpen}
onOpenChange={setSubtasksOpen}
separateTrigger={childIssues.length > 0}
triggerContent={subtasksTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName="w-72"

View File

@ -17,6 +17,7 @@ export function PropertyPicker({
popoverAlign = "end",
extra,
stacked = false,
separateTrigger = false,
children,
}: {
inline?: boolean;
@ -30,6 +31,8 @@ export function PropertyPicker({
extra?: ReactNode;
/** Top-aligns the row and vertically stacks chip collections in the trigger. */
stacked?: boolean;
/** Keep navigable relationship badges outside the picker button. */
separateTrigger?: boolean;
children: ReactNode;
}) {
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
@ -39,6 +42,43 @@ export function PropertyPicker({
triggerClassName,
);
if (separateTrigger) {
const trigger = (
<button
type="button"
className={cn(btnCn, "shrink-0")}
aria-label={`Edit ${label.toLowerCase()}`}
aria-expanded={open}
onClick={inline ? () => onOpenChange(!open) : undefined}
>
<ChevronDown className={cn("h-3 w-3 text-muted-foreground", open && "rotate-180")} aria-hidden />
</button>
);
return (
<div>
<PropertyRow label={label} wrap={stacked}>
<div className="flex min-w-0 max-w-full items-start gap-1.5">
{triggerContent}
{inline ? trigger : (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className={cn("p-1", popoverClassName)} align={popoverAlign} collisionPadding={16}>
{children}
</PopoverContent>
</Popover>
)}
</div>
{extra}
</PropertyRow>
{inline && open ? (
<div className={cn("rounded-md border border-border bg-popover p-1 mb-2", popoverClassName)}>
{children}
</div>
) : null}
</div>
);
}
if (inline) {
return (
<div>

View File

@ -802,6 +802,8 @@ export function DesignGuide() {
<p className="text-xs text-muted-foreground">
Used wherever a task is referenced in markdown, the Related Work tab, and activity summaries.
Pass <code className="font-mono">status</code> to show the target issue&apos;s state at a glance.
Use <code className="font-mono">variant="property"</code> for compact badges with direct navigation.
Pass <code className="font-mono">onRemove</code> for a separate blocker removal control with reserved space.
Use <code className="font-mono">strikethrough</code> for &quot;removed&quot; contexts.
</p>
<div className="flex items-center gap-2 flex-wrap">
@ -809,6 +811,7 @@ export function DesignGuide() {
<IssueReferencePill issue={{ id: "demo-2", identifier: "PAP-456", title: "With in_progress status", status: "in_progress" }} />
<IssueReferencePill issue={{ id: "demo-3", identifier: "PAP-789", title: "Done status", status: "done" }} />
<IssueReferencePill issue={{ id: "demo-4", identifier: "PAP-101", title: "Blocked status", status: "blocked" }} />
<IssueReferencePill onRemove={() => window.alert("Blocker removed")} issue={{ id: "demo-blocker", identifier: "PAP-303", title: "Hover or focus to remove blocker", status: "in_review" }} />
<IssueReferencePill strikethrough issue={{ id: "demo-5", identifier: "PAP-202", title: "Removed (strikethrough)", status: "todo" }} />
</div>
</SubSection>

View File

@ -21,6 +21,7 @@ import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
import { IssueGroupHeader } from "@/components/IssueGroupHeader";
import { IssueLinkQuicklook, IssueQuicklookCard } from "@/components/IssueLinkQuicklook";
import { useLocation } from "@/lib/router";
import { IssueProperties } from "@/components/IssueProperties";
import { IssueRunLedgerContent } from "@/components/IssueRunLedger";
import { IssuesList } from "@/components/IssuesList";
@ -191,6 +192,7 @@ function hydrateStorybookQueries(queryClient: ReturnType<typeof useQueryClient>)
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
queryClient.setQueryData(queryKeys.agents.list(companyId), storybookAgents);
queryClient.setQueryData(queryKeys.projects.list(companyId), storybookProjects);
queryClient.setQueryData(queryKeys.projects.list(companyId, { includeArchived: true }), storybookProjects);
queryClient.setQueryData(queryKeys.issues.list(companyId), storybookIssues);
queryClient.setQueryData(queryKeys.issues.labels(companyId), storybookIssueLabels);
queryClient.setQueryData(queryKeys.issues.documents(primaryIssue.id), storybookIssueDocuments);
@ -282,6 +284,7 @@ function LongValueStorybookData({ children }: { children: React.ReactNode }) {
const [ready] = useState(() => {
hydrateStorybookQueries(queryClient);
queryClient.setQueryData(queryKeys.projects.list(companyId), [longProject, ...storybookProjects]);
queryClient.setQueryData(queryKeys.projects.list(companyId, { includeArchived: true }), [longProject, ...storybookProjects]);
queryClient.setQueryData(queryKeys.issues.list(companyId), [
longValueIssue,
longParentIssue,
@ -324,6 +327,62 @@ function IssuePropertiesLongValuePane({ inline = false }: { inline?: boolean })
);
}
const relationshipChildren: Issue[] = ["in_progress", "todo", "in_review", "done"].map((status, index) => ({
...storybookIssues[0]!,
id: `relationship-child-${index}`,
identifier: `PAP-${18312 + index}`,
title: ["Implement task badges", "Review task relationships", "Verify keyboard navigation", "Ship task properties"][index]!,
status: status as Issue["status"],
parentId: "relationship-demo",
}));
const relationshipIssue: Issue = {
...longValueIssue,
id: "relationship-demo",
projectId: primaryIssue.projectId,
project: primaryIssue.project,
identifier: "PAP-18311",
labels: [],
labelIds: [],
blockedBy: [relationshipChildren[1]!, relationshipChildren[2]!],
blocks: [relationshipChildren[3]!],
};
function IssuePropertiesRelationshipBadgesPane({ inline = false }: { inline?: boolean }) {
const [issue, setIssue] = useState(relationshipIssue);
const location = useLocation();
return (
<LongValueStorybookData>
<div className="paperclip-story flex flex-wrap items-start gap-6 p-6">
<div className="w-80 max-w-full border border-border bg-card">
<div className="border-b border-border px-4 py-2 text-sm font-medium">Properties</div>
<div className="p-4">
<IssueProperties
issue={issue}
childIssues={relationshipChildren}
inline={inline}
sidePanelContentOnly
onUpdate={(patch) => setIssue((current) => ({
...current,
...patch,
blockedBy: patch.blockedByIssueIds
? [...relationshipChildren, ...storybookIssues, longParentIssue, longValueIssue].filter((child) => (patch.blockedByIssueIds as string[]).includes(child.id))
: current.blockedBy,
}))}
/>
</div>
</div>
<div className="max-w-sm space-y-3 text-sm">
<h2 className="font-semibold">Task relationship badges</h2>
<p className="text-muted-foreground">Click a status icon or task ID to navigate. Hover or focus a blocker to reveal its remove button. Only the X removes that blocker.</p>
<p className="text-muted-foreground">The arrow opens the relationship picker. Badge widths stay fixed on hover.</p>
<p>Current route: <code data-testid="relationship-route" className="font-mono text-xs">{location.pathname}</code></p>
<Button variant="outline" size="sm" onClick={() => setIssue(relationshipIssue)}>Reset blockers</Button>
</div>
</div>
</LongValueStorybookData>
);
}
function IssuePropertiesModelOverridePane() {
return (
<StorybookData>
@ -852,3 +911,16 @@ export const IssuePropertiesMobileBlockerActions: Story = {
render: () => <IssuePropertiesMobileBlockerActionsPane />,
globals: { viewport: { value: "mobile1" } },
};
// Keep preview stories passive. Interaction coverage lives in
// tests/storybook-visual/relationship-badges.spec.ts so switching stories never
// automatically focuses, navigates, removes, or restores a visible badge.
export const IssuePropertiesRelationshipBadges: Story = {
name: "IssueProperties - relationship badges",
render: () => <IssuePropertiesRelationshipBadgesPane />,
};
export const IssuePropertiesRelationshipBadgesInline: Story = {
name: "IssueProperties - relationship badges inline",
render: () => <IssuePropertiesRelationshipBadgesPane inline />,
};