From aa9e782ea3de7811789a19e8d889d891c5526e00 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 18:41:14 -0500 Subject: [PATCH] Storybook coverage and tests for external object surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New foundations/external-objects story renders the §6 state matrix, pill examples, inline markdown decoration, severity-sorted related-work panel, and the projects sidebar rollup - Make storybook bundle by aliasing node:crypto to a browser-safe stub so shared/external-objects.ts (server-only canonicalizer) can be statically imported without breaking Vite - Inline a browser-safe mention-source label helper in useIssueExternalObjects so the UI does not pull node:crypto at runtime - externalObjectDominantCount now returns 0 when the highest severity is muted, matching the rollup-hide rule - Backfill inbox-test fixtures for the new externalObjectStatuses field - Add vitest coverage for external-objects helpers, the inline markdown decorator (known/unknown URL, code exclusion, casing tolerance, liveness copy), and the pill / status summary components Co-Authored-By: Paperclip --- ui/src/components/ExternalObjectPill.test.tsx | 145 ++++++ .../components/MarkdownExternalLink.test.tsx | 91 ++++ ui/src/hooks/useIssueExternalObjects.ts | 36 +- ui/src/lib/external-objects.test.ts | 133 ++++++ ui/src/lib/external-objects.ts | 6 +- ui/storybook/.storybook/main.ts | 5 + .../.storybook/node-crypto-browser-shim.ts | 31 ++ .../stories/external-objects.stories.tsx | 443 ++++++++++++++++++ 8 files changed, 878 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/ExternalObjectPill.test.tsx create mode 100644 ui/src/components/MarkdownExternalLink.test.tsx create mode 100644 ui/src/lib/external-objects.test.ts create mode 100644 ui/storybook/.storybook/node-crypto-browser-shim.ts create mode 100644 ui/storybook/stories/external-objects.stories.tsx diff --git a/ui/src/components/ExternalObjectPill.test.tsx b/ui/src/components/ExternalObjectPill.test.tsx new file mode 100644 index 00000000000..9ca507900d3 --- /dev/null +++ b/ui/src/components/ExternalObjectPill.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ExternalObjectPill } from "./ExternalObjectPill"; +import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; + +describe("ExternalObjectPill", () => { + it("renders a clickable anchor when a URL is provided", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('href="https://github.com/acme/web/pull/241"'); + expect(html).toContain('data-mention-kind="external-object"'); + expect(html).toContain('data-external-status="succeeded"'); + expect(html).toContain('data-external-liveness="fresh"'); + expect(html).toContain("×4"); + expect(html).toContain('aria-label="GitHub pull request — Succeeded: Add external refs"'); + }); + + it("falls back to a non-interactive span when no URL is supplied", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-mention-kind="external-object"'); + expect(html).not.toContain(" { + const stale = renderToStaticMarkup( + , + ); + expect(stale).toContain("opacity-70"); + expect(stale).toContain("[border-style:dashed]"); + + const auth = renderToStaticMarkup( + , + ); + expect(auth).toContain("[border-style:dashed]"); + }); + + it("does not show a source count when only a single mention is present", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("×"); + }); +}); + +describe("ExternalObjectStatusSummary", () => { + it("hides itself when there are no external objects", () => { + const html = renderToStaticMarkup(); + expect(html).toBe(""); + }); + + it("hides itself when the highest severity is muted", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); + + it("shows the dominant-severity icon and count", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-external-status="failed"'); + expect(html).toContain('data-external-tone="danger"'); + expect(html).toContain(">3<"); + expect(html).toContain("aria-label=\"External objects: 3 failed, 2 succeeded, 5 total\""); + }); +}); diff --git a/ui/src/components/MarkdownExternalLink.test.tsx b/ui/src/components/MarkdownExternalLink.test.tsx new file mode 100644 index 00000000000..660f6cc6cb4 --- /dev/null +++ b/ui/src/components/MarkdownExternalLink.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment node + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ThemeProvider } from "../context/ThemeContext"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + ...props + }: { children: ReactNode; to: string } & React.ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { get: vi.fn() }, +})); + +function render(children: string, externalReferences?: MarkdownExternalReferenceMap) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + {children} + + , + ); +} + +describe("MarkdownBody external object decoration", () => { + const successReference: MarkdownExternalReferenceMap = { + "https://github.com/acme/web/pull/241": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "succeeded", + liveness: "fresh", + statusLabel: "Merged", + displayTitle: "Add external refs", + }, + }; + + it("decorates a known URL with the external status icon and metadata attributes", () => { + const html = render("Take a look: https://github.com/acme/web/pull/241", successReference); + expect(html).toContain('class="paperclip-markdown-external-ref"'); + expect(html).toContain('data-external-link="resolved"'); + expect(html).toContain('data-external-status="succeeded"'); + expect(html).toContain('data-external-liveness="fresh"'); + expect(html).toContain('aria-label="GitHub Merged: Add external refs"'); + expect(html).toContain("github.com/acme/web/pull/241"); + }); + + it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => { + const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference); + expect(html).toContain('data-external-status="succeeded"'); + }); + + it("renders an unresolved URL as a plain external link with no status affordance", () => { + const html = render("https://random.example.com/path"); + expect(html).not.toContain("paperclip-markdown-external-ref"); + expect(html).toContain('href="https://random.example.com/path"'); + }); + + it("never decorates a URL that lives inside a fenced or inline code block", () => { + const html = render( + "```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`", + successReference, + ); + // The fenced/inline literal should still be present as text but the + // decorated anchor should not appear since no `` is created in code. + expect(html).not.toContain('class="paperclip-markdown-external-ref"'); + }); + + it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => { + const html = render("Auth-blocked: https://app.hubspot.com/leads/99", { + "https://app.hubspot.com/leads/99": { + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + statusLabel: "Reconnect", + displayTitle: "Acme deal", + }, + }); + expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"'); + }); +}); diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index 2b19f8f04fd..51b0c95eb05 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -1,18 +1,40 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import type { + ExternalObjectMention, ExternalObjectMentionGroup, ExternalObjectSummary, } from "@paperclipai/shared"; import { externalObjectsApi } from "../api/externalObjects"; import { queryKeys } from "../lib/queryKeys"; import { normalizeExternalObjectHref } from "../lib/external-object-href"; -import { - formatExternalObjectMentionSourceLabel, -} from "@paperclipai/shared"; import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import type { ExternalObjectPillData } from "../components/ExternalObjectPill"; +/** + * Browser-safe mention-source label. Mirrors the shared/server helper but + * avoids importing `@paperclipai/shared/external-objects.ts` (which pulls in + * `node:crypto`). Keep in sync with the shared formatter. + */ +function formatMentionSourceLabel(mention: ExternalObjectMention): string { + switch (mention.sourceKind) { + case "title": + return "Title"; + case "description": + return "Description"; + case "comment": + return "Comment"; + case "document": + return mention.documentKey ? `Document: ${mention.documentKey}` : "Document"; + case "property": + return mention.propertyKey ? `Property: ${mention.propertyKey}` : "Property"; + case "plugin": + return "Plugin"; + default: + return "Source"; + } +} + export interface IssueExternalObjectGroup { pill: ExternalObjectPillData; mentionCount: number; @@ -52,13 +74,7 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss const object = entry.object!; const sourceLabels = entry.sourceLabels && entry.sourceLabels.length > 0 ? entry.sourceLabels - : Array.from(new Set(entry.mentions.map((mention) => - formatExternalObjectMentionSourceLabel({ - sourceKind: mention.sourceKind, - documentKey: mention.documentKey, - propertyKey: mention.propertyKey, - }), - ))); + : Array.from(new Set(entry.mentions.map(formatMentionSourceLabel))); return { group: entry, mentionCount: entry.mentionCount ?? entry.mentions.length, diff --git a/ui/src/lib/external-objects.test.ts b/ui/src/lib/external-objects.test.ts new file mode 100644 index 00000000000..f94cc98101e --- /dev/null +++ b/ui/src/lib/external-objects.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { + dominantExternalObjectTone, + externalObjectCategoryLabel, + externalObjectDominantCount, + externalObjectFallbackTone, + externalObjectIconForCategory, + externalObjectLivenessLabel, + externalObjectProviderLabel, + externalObjectToneSeverity, + externalObjectTypeLabel, + sortExternalObjectsBySeverity, +} from "./external-objects"; +import { normalizeExternalObjectHref } from "./external-object-href"; + +describe("normalizeExternalObjectHref", () => { + it("lowercases the host (case preserving the path) and drops query/fragment", () => { + expect( + normalizeExternalObjectHref("HTTPS://Github.com/Acme/Web/pull/241?token=abc#frag"), + ).toBe("https://github.com/Acme/Web/pull/241"); + }); + + it("rejects non-http(s) and userinfo-bearing URLs", () => { + expect(normalizeExternalObjectHref("javascript:alert(1)")).toBeNull(); + expect(normalizeExternalObjectHref("ftp://example.com/file")).toBeNull(); + expect(normalizeExternalObjectHref("https://user:pass@example.com/")).toBeNull(); + expect(normalizeExternalObjectHref(null)).toBeNull(); + expect(normalizeExternalObjectHref(undefined)).toBeNull(); + expect(normalizeExternalObjectHref("not a url")).toBeNull(); + }); + + it("defaults pathless URLs to /", () => { + expect(normalizeExternalObjectHref("https://example.com")).toBe("https://example.com/"); + }); +}); + +describe("external-objects helpers", () => { + it("labels categories with copy from the UX spec", () => { + expect(externalObjectCategoryLabel("auth_required")).toBe("Authorization required"); + expect(externalObjectCategoryLabel("succeeded")).toBe("Succeeded"); + expect(externalObjectCategoryLabel("unknown")).toBe("Not yet resolved"); + }); + + it("labels liveness states with non-tech copy", () => { + expect(externalObjectLivenessLabel("stale")).toBe("Stale"); + expect(externalObjectLivenessLabel("auth_required")).toBe("Requires auth"); + expect(externalObjectLivenessLabel("fresh")).toBe("Fresh"); + }); + + it("falls back to a humanised label for unknown providers and types", () => { + expect(externalObjectProviderLabel("github")).toBe("GitHub"); + expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing"); + expect(externalObjectProviderLabel(null)).toBe("External"); + expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run"); + expect(externalObjectTypeLabel(null)).toBe("object"); + }); + + it("orders tones from danger down to muted", () => { + expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning")); + expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info")); + expect(externalObjectToneSeverity("info")).toBeGreaterThan(externalObjectToneSeverity("success")); + expect(externalObjectToneSeverity("success")).toBeGreaterThan(externalObjectToneSeverity("muted")); + expect(externalObjectToneSeverity(null)).toBe(0); + expect(externalObjectToneSeverity("nonsense")).toBe(0); + }); + + it("maps every spec category to a fallback tone", () => { + expect(externalObjectFallbackTone("failed")).toBe("danger"); + expect(externalObjectFallbackTone("waiting")).toBe("warning"); + expect(externalObjectFallbackTone("running")).toBe("info"); + expect(externalObjectFallbackTone("succeeded")).toBe("success"); + expect(externalObjectFallbackTone("auth_required")).toBe("warning"); + expect(externalObjectFallbackTone("unreachable")).toBe("danger"); + }); + + it("returns the spec lucide icon names for every category", () => { + expect(externalObjectIconForCategory("succeeded").displayName ?? "").toMatch(/CheckCircle2|Check/); + expect(externalObjectIconForCategory("failed").displayName ?? "").toMatch(/XCircle|X/); + expect(externalObjectIconForCategory("auth_required").displayName ?? "").toMatch(/KeyRound|Key/); + expect(externalObjectIconForCategory("unreachable").displayName ?? "").toMatch(/CloudOff|Cloud/); + expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/); + }); + + it("sorts items by severity first, preserving insertion order within a tone", () => { + const items = [ + { id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false }, + { id: "b", statusTone: "danger", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", liveness: "fresh", isTerminal: false }, + { id: "c", statusTone: "warning", providerKey: "hubspot", objectType: "lead", displayTitle: null, statusCategory: "waiting", liveness: "fresh", isTerminal: false }, + { id: "d", statusTone: "danger", providerKey: "github", objectType: "issue", displayTitle: null, statusCategory: "blocked", liveness: "fresh", isTerminal: false }, + ] as const; + const sorted = sortExternalObjectsBySeverity(items as never); + expect(sorted.map((item) => item.id)).toEqual(["b", "d", "c", "a"]); + }); + + it("hides the rollup when every item is in a muted tone", () => { + const summary = { + total: 2, + byStatusCategory: { closed: 2 }, + byLiveness: { fresh: 2 }, + highestSeverity: "muted" as const, + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [ + { id: "a", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true }, + { id: "b", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true }, + ], + }; + expect(dominantExternalObjectTone(summary)).toBeNull(); + expect(externalObjectDominantCount(summary)).toBe(0); + }); + + it("counts only the dominant-severity items in the rollup", () => { + const summary = { + total: 5, + byStatusCategory: { failed: 3, succeeded: 2 }, + byLiveness: { fresh: 5 }, + highestSeverity: "danger" as const, + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [ + { id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true }, + { id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true }, + ], + }; + expect(dominantExternalObjectTone(summary)).toBe("danger"); + expect(externalObjectDominantCount(summary)).toBe(3); + }); +}); diff --git a/ui/src/lib/external-objects.ts b/ui/src/lib/external-objects.ts index ca7bb558c14..c1d9e41bd17 100644 --- a/ui/src/lib/external-objects.ts +++ b/ui/src/lib/external-objects.ts @@ -189,13 +189,15 @@ export function dominantExternalObjectTone( /** * For the sidebar / list rollup we want the count of objects matching the - * dominant severity (e.g. "3 failed PRs"), not the global total. + * dominant severity (e.g. "3 failed PRs"), not the global total. Returns 0 + * whenever the dominant tone is muted so callers can render based on the + * count without double-checking the rollup-hide rule. */ export function externalObjectDominantCount( summary: Pick | null | undefined, ): number { if (!summary) return 0; - const tone = summary.highestSeverity; + const tone = dominantExternalObjectTone(summary); if (!tone) return 0; return summary.objects.filter((object) => object.statusTone === tone).length; } diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index e7e2dadaf0c..592ab9d9e91 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -24,6 +24,11 @@ const config: StorybookConfig = { alias: { "@": path.resolve(storybookConfigDir, "../../src"), lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"), + // Vite's bundled `node:crypto` polyfill omits `createHash`, which + // `@paperclipai/shared/external-objects.ts` imports server-side. Use + // a no-op browser shim so the import resolves; the canonicalizer + // only runs server-side. + "node:crypto": path.resolve(storybookConfigDir, "node-crypto-browser-shim.ts"), }, }, }), diff --git a/ui/storybook/.storybook/node-crypto-browser-shim.ts b/ui/storybook/.storybook/node-crypto-browser-shim.ts new file mode 100644 index 00000000000..7a6cb01d73c --- /dev/null +++ b/ui/storybook/.storybook/node-crypto-browser-shim.ts @@ -0,0 +1,31 @@ +/** + * Browser-safe shim for the slice of `node:crypto` that + * `@paperclipai/shared/external-objects.ts` imports. + * + * The shared canonicalizer runs server-side and never executes in the browser, + * but the static `import { createHash } from "node:crypto"` is still pulled + * into Storybook's module graph. Vite's built-in `node:crypto` polyfill does + * not export `createHash`, so the build fails before our code runs. This shim + * provides a no-op implementation so the bundler can resolve the import; if a + * code path ever reaches it in the browser we throw loudly so we notice. + */ + +class BrowserHash { + update(_value: string): this { + throw new Error( + "createHash from node:crypto is not available in the browser bundle", + ); + } + + digest(_encoding: string): string { + throw new Error( + "createHash from node:crypto is not available in the browser bundle", + ); + } +} + +export function createHash(_algorithm: string): BrowserHash { + return new BrowserHash(); +} + +export default { createHash }; diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/external-objects.stories.tsx new file mode 100644 index 00000000000..77f6a43aa06 --- /dev/null +++ b/ui/storybook/stories/external-objects.stories.tsx @@ -0,0 +1,443 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + EXTERNAL_OBJECT_LIVENESS_STATES, + EXTERNAL_OBJECT_STATUS_CATEGORIES, + type ExternalObject, + type ExternalObjectLivenessState, + type ExternalObjectMention, + type ExternalObjectStatusCategory, + type ExternalObjectSummary, +} from "@paperclipai/shared"; +import { ExternalObjectPill } from "@/components/ExternalObjectPill"; +import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon"; +import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary"; +import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody"; +import { + externalObjectCategoryLabel, + externalObjectFallbackTone, + externalObjectLivenessLabel, + externalObjectProviderLabel, + externalObjectTypeLabel, +} from "@/lib/external-objects"; +import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects"; + +function makeObject(args: { + id: string; + providerKey: string; + objectType: string; + statusCategory: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + displayTitle?: string; + url: string; + statusLabel?: string; +}): ExternalObject { + return { + id: args.id, + companyId: "company-1", + providerKey: args.providerKey, + pluginId: null, + objectType: args.objectType, + externalId: args.id, + sanitizedCanonicalUrl: args.url, + canonicalIdentityHash: args.id, + displayTitle: args.displayTitle ?? null, + statusKey: args.statusCategory, + statusLabel: args.statusLabel ?? externalObjectCategoryLabel(args.statusCategory), + statusCategory: args.statusCategory, + statusTone: externalObjectFallbackTone(args.statusCategory), + liveness: args.liveness, + isTerminal: ["succeeded", "failed", "closed", "archived"].includes(args.statusCategory), + data: {}, + remoteVersion: null, + etag: null, + lastResolvedAt: "2026-04-24T22:45:00.000Z", + lastChangedAt: "2026-04-24T22:45:00.000Z", + lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null, + nextRefreshAt: null, + lastErrorCode: null, + lastErrorMessage: null, + createdAt: "2026-04-24T20:00:00.000Z", + updatedAt: "2026-04-24T22:45:00.000Z", + }; +} + +function makeMention(args: { + id: string; + objectId: string; + sourceKind: ExternalObjectMention["sourceKind"]; + documentKey?: string | null; +}): ExternalObjectMention { + return { + id: args.id, + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: args.sourceKind, + sourceRecordId: null, + documentKey: args.documentKey ?? null, + propertyKey: null, + matchedTextRedacted: null, + sanitizedDisplayUrl: "https://example.com/object", + canonicalIdentityHash: args.objectId, + canonicalIdentity: null, + objectId: args.objectId, + providerKey: null, + detectorKey: null, + objectType: null, + confidence: "exact", + createdByPluginId: null, + createdAt: "2026-04-24T20:00:00.000Z", + updatedAt: "2026-04-24T22:45:00.000Z", + }; +} + +function makeGroup(args: { + object: ExternalObject; + mentionCount?: number; + sourceLabels?: string[]; +}): IssueExternalObjectGroup { + return { + group: { + object: args.object, + mentions: [makeMention({ id: `${args.object.id}-m`, objectId: args.object.id, sourceKind: "description" })], + mentionCount: args.mentionCount ?? 1, + sourceLabels: args.sourceLabels ?? ["description"], + }, + pill: { + providerKey: args.object.providerKey, + objectType: args.object.objectType, + statusCategory: args.object.statusCategory, + liveness: args.object.liveness, + displayTitle: args.object.displayTitle, + statusLabel: args.object.statusLabel, + url: args.object.sanitizedCanonicalUrl, + }, + mentionCount: args.mentionCount ?? 1, + sourceLabels: args.sourceLabels ?? ["description"], + }; +} + +function StateMatrix() { + return ( +
+
+

External object status matrix

+

+ Every status category × liveness combination from the UX spec §6, used as the canonical + presentational reference for inline markdown, pills, properties, and rollups. +

+
+ + + + + {EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => ( + + ))} + + + + {EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => ( + + + {EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => ( + + ))} + + ))} + +
Category{externalObjectLivenessLabel(liveness)}
{category} + +
+ +
+

Pills (host-rendered identity, no plugin React)

+
+ + + + + +
+
+
+ ); +} + +function inlineMarkdownStory() { + const references: MarkdownExternalReferenceMap = { + "https://github.com/acme/web/pull/241": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "succeeded", + liveness: "fresh", + statusLabel: "Merged", + displayTitle: "Add external refs", + }, + "https://github.com/acme/web/pull/242": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "stale", + statusLabel: "CI failed", + displayTitle: "Flaky tests", + }, + "https://github.com/acme/web/pull/243": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "waiting", + liveness: "fresh", + statusLabel: "Awaiting review", + displayTitle: "Add liveness overlay", + }, + "https://app.hubspot.com/leads/99": { + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + statusLabel: "Reconnect", + displayTitle: "Acme deal", + }, + "https://ci.example.com/runs/88421": { + providerKey: "ci", + objectType: "deployment", + statusCategory: "unreachable", + liveness: "unreachable", + statusLabel: "Unreachable", + displayTitle: "Prod-0412", + }, + }; + const markdown = `Status of recent integrations:\n\n- Merged PR: https://github.com/acme/web/pull/241\n- Stale failed CI: https://github.com/acme/web/pull/242\n- Awaiting review: https://github.com/acme/web/pull/243\n- Auth required: https://app.hubspot.com/leads/99\n- Unreachable deploy: https://ci.example.com/runs/88421\n- Unrelated control link: https://random.example.com/path\n\n\`\`\`\n# Code blocks must be left alone — https://github.com/acme/web/pull/241\n\`\`\`\n\nInline code stays plain too: \`https://github.com/acme/web/pull/241\`.`; + return ( +
+

Inline markdown decoration

+ {markdown} +
+ ); +} + +function relatedWorkStory() { + const externalObjects = [ + makeGroup({ + object: makeObject({ + id: "obj-1", + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "fresh", + displayTitle: "Add external refs", + url: "https://github.com/acme/web/pull/241", + }), + mentionCount: 4, + sourceLabels: ["description", "comments", "plan document"], + }), + makeGroup({ + object: makeObject({ + id: "obj-2", + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + displayTitle: "Acme deal", + url: "https://app.hubspot.com/leads/99", + }), + mentionCount: 1, + sourceLabels: ["External links property"], + }), + makeGroup({ + object: makeObject({ + id: "obj-3", + providerKey: "ci", + objectType: "deployment", + statusCategory: "running", + liveness: "fresh", + displayTitle: "deploy prod-0412", + url: "https://ci.example.com/runs/88421", + }), + mentionCount: 2, + sourceLabels: ["comments"], + }), + makeGroup({ + object: makeObject({ + id: "obj-4", + providerKey: "github", + objectType: "issue", + statusCategory: "succeeded", + liveness: "stale", + displayTitle: "Closed parent issue", + url: "https://github.com/acme/web/issues/15", + }), + mentionCount: 1, + sourceLabels: ["comments"], + }), + ]; + + return ( + + ); +} + +function projectsRollupStory() { + function summary(args: { + failed?: number; + waiting?: number; + running?: number; + succeeded?: number; + auth?: number; + stale?: number; + }): ExternalObjectSummary { + const objects: ExternalObjectSummary["objects"] = []; + function pushMany(category: ExternalObjectStatusCategory, count: number) { + for (let i = 0; i < count; i += 1) { + objects.push({ + id: `obj-${category}-${i}`, + providerKey: "github", + objectType: "pull_request", + displayTitle: null, + statusCategory: category, + statusTone: externalObjectFallbackTone(category), + liveness: args.stale && i % 2 === 0 ? "stale" : "fresh", + isTerminal: false, + }); + } + } + pushMany("failed", args.failed ?? 0); + pushMany("waiting", args.waiting ?? 0); + pushMany("running", args.running ?? 0); + pushMany("succeeded", args.succeeded ?? 0); + pushMany("auth_required", args.auth ?? 0); + const tones = objects.map((o) => o.statusTone); + const dominant: ExternalObjectSummary["highestSeverity"] = + tones.includes("danger") ? "danger" + : tones.includes("warning") ? "warning" + : tones.includes("info") ? "info" + : tones.includes("success") ? "success" + : "muted"; + const byStatusCategory: Record = {}; + for (const obj of objects) { + byStatusCategory[obj.statusCategory] = (byStatusCategory[obj.statusCategory] ?? 0) + 1; + } + return { + total: objects.length, + byStatusCategory, + byLiveness: { fresh: objects.length, stale: 0, auth_required: 0, unreachable: 0, unknown: 0 }, + highestSeverity: dominant, + staleCount: args.stale ?? 0, + authRequiredCount: args.auth ?? 0, + unreachableCount: 0, + objects, + }; + } + + const projects = [ + { name: "Paperclip App", color: "#6366f1", summary: summary({ failed: 3, running: 12 }) }, + { name: "Marketing site", color: "#22c55e", summary: summary({ waiting: 2 }) }, + { name: "Experimental", color: "#a855f7", summary: summary({ succeeded: 6 }) }, + { name: "Auth provider", color: "#f97316", summary: summary({ auth: 1 }) }, + ]; + return ( +
+
Projects (sidebar)
+
    + {projects.map((project) => ( +
  • + + {project.name} + +
  • + ))} +
+
+ ); +} + +function StateMatrixStory() { + return ( +
+ + {inlineMarkdownStory()} + {relatedWorkStory()} + {projectsRollupStory()} +
+ ); +} + +const meta = { + title: "Foundations/External Objects", + component: StateMatrixStory, + parameters: { + docs: { + description: { + component: + "External-object surface gallery: the §6 state matrix, the inline markdown decoration, the related-work section, and the project-rollup sidebar marker. Mirrors the Phase 6 acceptance set from the UX spec.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FullSurface: Story = {};