mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Storybook coverage and tests for external object surfaces
- 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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
58d5adc1c7
commit
aa9e782ea3
8 files changed
+878
-12
No files matched your search
@@ -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(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Add external refs",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={4}
|
||||
sourceSummary="description, 3 comments"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: null,
|
||||
objectType: null,
|
||||
statusCategory: "unknown",
|
||||
liveness: "unknown",
|
||||
url: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-mention-kind="external-object"');
|
||||
expect(html).not.toContain("<a ");
|
||||
expect(html).toContain('aria-label="External object — Not yet resolved"');
|
||||
});
|
||||
|
||||
it("applies the dashed-border liveness overlay when stale or auth_required", () => {
|
||||
const stale = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
url: "https://github.com/acme/web/pull/242",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(stale).toContain("opacity-70");
|
||||
expect(stale).toContain("[border-style:dashed]");
|
||||
|
||||
const auth = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(auth).toContain("[border-style:dashed]");
|
||||
});
|
||||
|
||||
it("does not show a source count when only a single mention is present", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={1}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("×");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExternalObjectStatusSummary", () => {
|
||||
it("hides itself when there are no external objects", () => {
|
||||
const html = renderToStaticMarkup(<ExternalObjectStatusSummary summary={null} />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("hides itself when the highest severity is muted", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectStatusSummary
|
||||
summary={{
|
||||
total: 2,
|
||||
byStatusCategory: { closed: 2 },
|
||||
byLiveness: { fresh: 2 },
|
||||
highestSeverity: "muted",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("shows the dominant-severity icon and count", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectStatusSummary
|
||||
summary={{
|
||||
total: 5,
|
||||
byStatusCategory: { failed: 3, succeeded: 2 },
|
||||
byLiveness: { fresh: 5 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [
|
||||
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
|
||||
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
|
||||
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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\"");
|
||||
});
|
||||
});
|
||||
@@ -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">) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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 `<a>` 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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<ExternalObjectSummary, "highestSeverity" | "objects"> | 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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 (
|
||||
<div className="paperclip-story space-y-8">
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">External object status matrix</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every status category × liveness combination from the UX spec §6, used as the canonical
|
||||
presentational reference for inline markdown, pills, properties, and rollups.
|
||||
</p>
|
||||
</header>
|
||||
<table className="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-3 py-2">Category</th>
|
||||
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
|
||||
<th key={liveness} className="px-3 py-2">{externalObjectLivenessLabel(liveness)}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => (
|
||||
<tr key={category} className="border-b border-border">
|
||||
<td className="px-3 py-3 font-mono text-xs text-muted-foreground">{category}</td>
|
||||
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
|
||||
<td key={liveness} className="px-3 py-3">
|
||||
<ExternalObjectStatusIcon
|
||||
category={category}
|
||||
liveness={liveness}
|
||||
sizeClassName="h-5 w-5"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-medium">Pills (host-rendered identity, no plugin React)</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "succeeded",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Add external refs",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
sourceCount={4}
|
||||
sourceSummary="description, 3 comments"
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "stale",
|
||||
displayTitle: "Bad CI run",
|
||||
url: "https://github.com/acme/web/pull/242",
|
||||
}}
|
||||
sourceCount={2}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
displayTitle: "Acme deal",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
}}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "linear",
|
||||
objectType: "issue",
|
||||
statusCategory: "running",
|
||||
liveness: "fresh",
|
||||
displayTitle: "Spike: queues",
|
||||
url: "https://linear.app/acme/issue/INF-44",
|
||||
}}
|
||||
/>
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "unreachable",
|
||||
liveness: "unreachable",
|
||||
displayTitle: "deploy prod-0412",
|
||||
url: "https://ci.example.com/runs/88421",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="paperclip-story space-y-4 text-sm">
|
||||
<h2 className="text-lg font-medium">Inline markdown decoration</h2>
|
||||
<MarkdownBody externalReferences={references}>{markdown}</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={{
|
||||
outbound: [],
|
||||
inbound: [],
|
||||
}}
|
||||
externalObjects={externalObjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, number> = {};
|
||||
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 (
|
||||
<div className="paperclip-story w-72 rounded border border-border bg-background p-2">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (sidebar)</div>
|
||||
<ul className="flex flex-col">
|
||||
{projects.map((project) => (
|
||||
<li
|
||||
key={project.name}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium hover:bg-accent/50"
|
||||
>
|
||||
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
<ExternalObjectStatusSummary summary={project.summary} compact />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StateMatrixStory() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-12 p-6">
|
||||
<StateMatrix />
|
||||
{inlineMarkdownStory()}
|
||||
{relatedWorkStory()}
|
||||
{projectsRollupStory()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof StateMatrixStory>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullSurface: Story = {};
|
||||
Reference in new issue
Block a user