mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Add workflow checklist affordances to sub-issue rows
Show workflow-only step gutters, a single current-step marker, done-title de-emphasis, and visible-blocker chips that jump to sibling rows so ordered sub-issues read like a checklist while preserving existing row interactions. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
04c9d703b7
commit
e959fba03f
3 files changed
+177
-2
No files matched your search
@@ -23,6 +23,11 @@ interface IssueRowProps {
|
||||
desktopTrailing?: ReactNode;
|
||||
trailingMeta?: ReactNode;
|
||||
titleSuffix?: ReactNode;
|
||||
titleClassName?: string;
|
||||
checklistStepNumber?: number | null;
|
||||
checklistCurrentStep?: boolean;
|
||||
checklistDependencyChips?: ReactNode;
|
||||
checklistRowId?: string;
|
||||
unreadState?: UnreadState | null;
|
||||
onMarkRead?: () => void;
|
||||
onArchive?: () => void;
|
||||
@@ -41,6 +46,11 @@ export function IssueRow({
|
||||
desktopTrailing,
|
||||
trailingMeta,
|
||||
titleSuffix,
|
||||
titleClassName,
|
||||
checklistStepNumber = null,
|
||||
checklistCurrentStep = false,
|
||||
checklistDependencyChips,
|
||||
checklistRowId,
|
||||
unreadState = null,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
@@ -53,6 +63,7 @@ export function IssueRow({
|
||||
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
|
||||
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
|
||||
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
|
||||
const hasChecklistStep = checklistStepNumber !== null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -61,20 +72,36 @@ export function IssueRow({
|
||||
disableIssueQuicklook
|
||||
issuePrefetch={issue}
|
||||
data-inbox-issue-link
|
||||
id={checklistRowId}
|
||||
aria-current={checklistCurrentStep ? "step" : undefined}
|
||||
onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)}
|
||||
className={cn(
|
||||
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
|
||||
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
|
||||
checklistCurrentStep ? "border-l-2 border-l-primary/40 pl-[calc(theme(spacing.2)-2px)] sm:pl-[calc(theme(spacing.1)-2px)]" : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasChecklistStep ? (
|
||||
<span
|
||||
className="hidden w-8 shrink-0 self-center text-right font-mono text-xs text-muted-foreground sm:inline-block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checklistStepNumber}.
|
||||
</span>
|
||||
) : null}
|
||||
<span className="shrink-0 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
|
||||
<span className={cn("line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none", titleClassName)}>
|
||||
{issue.title}{titleSuffix}
|
||||
</span>
|
||||
{checklistDependencyChips ? (
|
||||
<span className="flex flex-wrap gap-1 sm:order-3 sm:ml-[calc(theme(spacing.3)+theme(spacing.2))]">
|
||||
{checklistDependencyChips}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||
{desktopLeadingSpacer ? (
|
||||
<span className="hidden w-3.5 shrink-0 sm:block" />
|
||||
|
||||
@@ -91,15 +91,32 @@ vi.mock("./IssueRow", () => ({
|
||||
issue,
|
||||
desktopMetaLeading,
|
||||
desktopTrailing,
|
||||
titleClassName,
|
||||
checklistStepNumber,
|
||||
checklistCurrentStep,
|
||||
checklistDependencyChips,
|
||||
checklistRowId,
|
||||
}: {
|
||||
issue: Issue;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
desktopTrailing?: ReactNode;
|
||||
titleClassName?: string;
|
||||
checklistStepNumber?: number | null;
|
||||
checklistCurrentStep?: boolean;
|
||||
checklistDependencyChips?: ReactNode;
|
||||
checklistRowId?: string;
|
||||
}) => (
|
||||
<div data-testid="issue-row">
|
||||
<div
|
||||
data-testid="issue-row"
|
||||
id={checklistRowId}
|
||||
data-step={checklistStepNumber ?? undefined}
|
||||
data-current-step={checklistCurrentStep ? "true" : undefined}
|
||||
data-title-class={titleClassName ?? undefined}
|
||||
>
|
||||
<span>{issue.title}</span>
|
||||
{desktopMetaLeading}
|
||||
{desktopTrailing}
|
||||
{checklistDependencyChips}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -428,6 +445,57 @@ describe("IssuesList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds checklist affordances for workflow-sorted sub-issue lists", async () => {
|
||||
const issueDone = createIssue({
|
||||
id: "issue-done",
|
||||
identifier: "PAP-1",
|
||||
title: "Done first",
|
||||
status: "done",
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
});
|
||||
const issueBlocked = createIssue({
|
||||
id: "issue-blocked",
|
||||
identifier: "PAP-2",
|
||||
title: "Blocked issue",
|
||||
status: "blocked",
|
||||
blockedBy: [{ id: "issue-active", identifier: "PAP-3", title: "Active blocker", status: "todo", priority: "medium", assigneeAgentId: null, assigneeUserId: null }],
|
||||
createdAt: new Date("2026-04-02T00:00:00.000Z"),
|
||||
});
|
||||
const issueActive = createIssue({
|
||||
id: "issue-active",
|
||||
identifier: "PAP-3",
|
||||
title: "Active blocker",
|
||||
status: "todo",
|
||||
createdAt: new Date("2026-04-03T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[issueBlocked, issueActive, issueDone]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
defaultSortField="workflow"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]'));
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((row) => row.getAttribute("data-step"))).toEqual(["1", "2", "3"]);
|
||||
expect(rows.filter((row) => row.getAttribute("data-current-step") === "true")).toHaveLength(1);
|
||||
expect(rows.find((row) => row.textContent?.includes("Active blocker"))?.getAttribute("data-current-step")).toBe("true");
|
||||
expect(rows.find((row) => row.textContent?.includes("Done first"))?.getAttribute("data-title-class")).toContain("text-muted-foreground");
|
||||
expect(container.textContent).toContain("blocked by PAP-3");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the sub-issue progress summary unless it is enabled and populated", async () => {
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
|
||||
@@ -236,6 +236,10 @@ function issueMatchesLocalSearch(issue: Issue, normalizedSearch: string): boolea
|
||||
].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function isActionableWorkflowStatus(status: IssueStatus): boolean {
|
||||
return status !== "done" && status !== "cancelled" && status !== "blocked";
|
||||
}
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
interface Agent {
|
||||
@@ -820,6 +824,42 @@ export function IssuesList({
|
||||
: null,
|
||||
[issues, showProgressSummary],
|
||||
);
|
||||
const checklistAffordanceEnabled = useMemo(
|
||||
() =>
|
||||
defaultSortField === "workflow"
|
||||
&& viewState.sortField === "workflow"
|
||||
&& viewState.groupBy === "none",
|
||||
[defaultSortField, viewState.groupBy, viewState.sortField],
|
||||
);
|
||||
const workflowChecklistMeta = useMemo(() => {
|
||||
if (!checklistAffordanceEnabled) return null;
|
||||
|
||||
const visibleIssueIds = new Set(filtered.map((issue) => issue.id));
|
||||
const stepNumberByIssueId = new Map<string, number>();
|
||||
const unresolvedVisibleBlockersByIssueId = new Map<string, string[]>();
|
||||
|
||||
filtered.forEach((issue, index) => {
|
||||
stepNumberByIssueId.set(issue.id, index + 1);
|
||||
const unresolvedVisible = (issue.blockedBy ?? [])
|
||||
.map((blocker) => blocker.id)
|
||||
.filter((blockerId) => {
|
||||
if (!visibleIssueIds.has(blockerId)) return false;
|
||||
const blockerIssue = issueById.get(blockerId);
|
||||
if (!blockerIssue) return false;
|
||||
return blockerIssue.status !== "done" && blockerIssue.status !== "cancelled";
|
||||
});
|
||||
unresolvedVisibleBlockersByIssueId.set(issue.id, unresolvedVisible);
|
||||
});
|
||||
|
||||
const firstActionable = filtered.find((issue) => isActionableWorkflowStatus(issue.status)) ?? null;
|
||||
const currentStepIssue = firstActionable ?? filtered.find((issue) => issue.status === "blocked") ?? null;
|
||||
|
||||
return {
|
||||
stepNumberByIssueId,
|
||||
unresolvedVisibleBlockersByIssueId,
|
||||
currentStepIssueId: currentStepIssue?.id ?? null,
|
||||
};
|
||||
}, [checklistAffordanceEnabled, filtered, issueById]);
|
||||
|
||||
const { data: labels } = useQuery({
|
||||
queryKey: queryKeys.issues.labels(selectedCompanyId!),
|
||||
@@ -1237,6 +1277,41 @@ export function IssuesList({
|
||||
: viewState.collapsedParents.filter((id) => id !== issue.id),
|
||||
});
|
||||
};
|
||||
const checklistMeta = workflowChecklistMeta;
|
||||
const checklistStepNumber = checklistMeta?.stepNumberByIssueId.get(issue.id) ?? null;
|
||||
const unresolvedVisibleBlockers = checklistMeta?.unresolvedVisibleBlockersByIssueId.get(issue.id) ?? [];
|
||||
const checklistRowId = checklistMeta ? `issue-workflow-row-${issue.id}` : undefined;
|
||||
const doneRowTitleClass = checklistMeta && issue.status === "done"
|
||||
? "text-muted-foreground"
|
||||
: undefined;
|
||||
const checklistDependencyChips = checklistMeta && unresolvedVisibleBlockers.length > 0 ? (
|
||||
<>
|
||||
{unresolvedVisibleBlockers.map((blockerId) => {
|
||||
const blockerIssue = issueById.get(blockerId);
|
||||
if (!blockerIssue) return null;
|
||||
const label = blockerIssue.identifier ?? blockerIssue.id.slice(0, 8);
|
||||
return (
|
||||
<button
|
||||
key={blockerId}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const target = document.getElementById(`issue-workflow-row-${blockerId}`);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
target.focus?.();
|
||||
}}
|
||||
className="inline-flex items-center rounded-full border border-amber-400/45 bg-amber-50/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 hover:bg-amber-100/80 dark:border-amber-300/35 dark:bg-amber-400/10 dark:text-amber-300"
|
||||
title={`Blocked by ${label}`}
|
||||
aria-label={`Blocked by ${label}`}
|
||||
>
|
||||
blocked by {label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1254,6 +1329,11 @@ export function IssuesList({
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
checklistStepNumber={checklistStepNumber}
|
||||
checklistCurrentStep={checklistMeta?.currentStepIssueId === issue.id}
|
||||
checklistDependencyChips={checklistDependencyChips}
|
||||
checklistRowId={checklistRowId}
|
||||
titleClassName={doneRowTitleClass}
|
||||
titleSuffix={(
|
||||
<>
|
||||
{hasChildren && !isExpanded ? (
|
||||
|
||||
Reference in new issue
Block a user