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:
parent
cd4c4ed205
commit
e9a5a07ab4
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 "removed" 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>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue