Prepare external object references for PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseIssueReferenceHref } from "./issue-references.js";
|
||||
import type {
|
||||
ExternalObjectCanonicalIdentity,
|
||||
ExternalObjectCanonicalUrl,
|
||||
ExternalObjectMentionSource,
|
||||
ExternalObjectUrlCanonicalizationOptions,
|
||||
ExternalObjectUrlMatch,
|
||||
} from "./external-objects.js";
|
||||
|
||||
const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi;
|
||||
|
||||
function preserveNewlinesAsWhitespace(value: string) {
|
||||
return value.replace(/[^\n]/g, " ");
|
||||
}
|
||||
|
||||
function stripMarkdownCode(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < markdown.length) {
|
||||
const remaining = markdown.slice(index);
|
||||
const fenceMatch = /^(?:```+|~~~+)/.exec(remaining);
|
||||
const atLineStart = index === 0 || markdown[index - 1] === "\n";
|
||||
|
||||
if (atLineStart && fenceMatch) {
|
||||
const fence = fenceMatch[0]!;
|
||||
const blockStart = index;
|
||||
index += fence.length;
|
||||
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
||||
if (index < markdown.length) index += 1;
|
||||
|
||||
while (index < markdown.length) {
|
||||
const lineStart = index === 0 || markdown[index - 1] === "\n";
|
||||
if (lineStart && markdown.startsWith(fence, index)) {
|
||||
index += fence.length;
|
||||
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
||||
if (index < markdown.length) index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markdown[index] === "`") {
|
||||
let tickCount = 1;
|
||||
while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") {
|
||||
tickCount += 1;
|
||||
}
|
||||
const fence = "`".repeat(tickCount);
|
||||
const inlineStart = index;
|
||||
index += tickCount;
|
||||
const closeIndex = markdown.indexOf(fence, index);
|
||||
if (closeIndex === -1) {
|
||||
output += markdown.slice(inlineStart, inlineStart + tickCount);
|
||||
index = inlineStart + tickCount;
|
||||
continue;
|
||||
}
|
||||
index = closeIndex + tickCount;
|
||||
output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
output += markdown[index]!;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function trimTrailingPunctuation(token: string): string {
|
||||
let trimmed = token;
|
||||
while (trimmed.length > 0) {
|
||||
const last = trimmed[trimmed.length - 1]!;
|
||||
if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break;
|
||||
|
||||
if (
|
||||
(last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0))
|
||||
|| (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname || "/";
|
||||
}
|
||||
|
||||
export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] {
|
||||
if (!markdown) return [];
|
||||
|
||||
const scrubbed = stripMarkdownCode(markdown);
|
||||
const matches: ExternalObjectUrlMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(EXTERNAL_URL_TOKEN_RE);
|
||||
|
||||
while ((match = re.exec(scrubbed)) !== null) {
|
||||
const matchedText = trimTrailingPunctuation(match[0]);
|
||||
if (!matchedText || parseIssueReferenceHref(matchedText)) continue;
|
||||
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: matchedText.length,
|
||||
matchedText,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function canonicalizeExternalObjectUrl(
|
||||
value: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const path = normalizePathname(url.pathname);
|
||||
const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`;
|
||||
const identityQueryParams = new Set(options.identityQueryParams ?? []);
|
||||
const queryParamHashes: Record<string, string> = {};
|
||||
|
||||
for (const key of [...identityQueryParams].sort()) {
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (values.length === 0) continue;
|
||||
queryParamHashes[key] = sha256Hex(values.join("\u0000"));
|
||||
}
|
||||
|
||||
const canonicalIdentity: ExternalObjectCanonicalIdentity = {
|
||||
scheme,
|
||||
host: url.host.toLowerCase(),
|
||||
path,
|
||||
...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
sanitizedCanonicalUrl,
|
||||
sanitizedDisplayUrl: sanitizedCanonicalUrl,
|
||||
canonicalIdentity,
|
||||
canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)),
|
||||
redactedMatchedText: sanitizedCanonicalUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractExternalObjectCanonicalUrls(
|
||||
markdown: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: ExternalObjectCanonicalUrl[] = [];
|
||||
|
||||
for (const match of findExternalObjectUrlMatches(markdown)) {
|
||||
const canonical = canonicalizeExternalObjectUrl(match.matchedText, options);
|
||||
if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue;
|
||||
seen.add(canonical.canonicalIdentityHash);
|
||||
ordered.push(canonical);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function buildExternalObjectScopedIdentityKey(args: {
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
canonicalIdentityHash: string;
|
||||
}): string {
|
||||
return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":");
|
||||
}
|
||||
|
||||
export function buildExternalObjectMentionSourceKey(source: Required<Pick<
|
||||
ExternalObjectMentionSource,
|
||||
"companyId" | "sourceIssueId" | "sourceKind"
|
||||
>> & ExternalObjectMentionSource): string {
|
||||
return [
|
||||
source.companyId,
|
||||
source.sourceIssueId,
|
||||
source.sourceKind,
|
||||
source.sourceRecordId ?? "",
|
||||
source.documentKey ?? "",
|
||||
source.propertyKey ?? "",
|
||||
].join(":");
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatExternalObjectMentionSourceLabel } from "./external-objects.js";
|
||||
import {
|
||||
buildExternalObjectMentionSourceKey,
|
||||
buildExternalObjectScopedIdentityKey,
|
||||
canonicalizeExternalObjectUrl,
|
||||
extractExternalObjectCanonicalUrls,
|
||||
findExternalObjectUrlMatches,
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
} from "./external-objects.js";
|
||||
} from "./external-objects-server.js";
|
||||
import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./validators/external-object.js";
|
||||
|
||||
describe("external object references", () => {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ExternalObjectMentionSourceKind } from "./constants.js";
|
||||
import { parseIssueReferenceHref } from "./issue-references.js";
|
||||
|
||||
export interface ExternalObjectUrlMatch {
|
||||
index: number;
|
||||
@@ -36,214 +34,6 @@ export interface ExternalObjectMentionSource {
|
||||
propertyKey?: string | null;
|
||||
}
|
||||
|
||||
const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi;
|
||||
|
||||
function preserveNewlinesAsWhitespace(value: string) {
|
||||
return value.replace(/[^\n]/g, " ");
|
||||
}
|
||||
|
||||
function stripMarkdownCode(markdown: string): string {
|
||||
if (!markdown) return "";
|
||||
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < markdown.length) {
|
||||
const remaining = markdown.slice(index);
|
||||
const fenceMatch = /^(?:```+|~~~+)/.exec(remaining);
|
||||
const atLineStart = index === 0 || markdown[index - 1] === "\n";
|
||||
|
||||
if (atLineStart && fenceMatch) {
|
||||
const fence = fenceMatch[0]!;
|
||||
const blockStart = index;
|
||||
index += fence.length;
|
||||
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
||||
if (index < markdown.length) index += 1;
|
||||
|
||||
while (index < markdown.length) {
|
||||
const lineStart = index === 0 || markdown[index - 1] === "\n";
|
||||
if (lineStart && markdown.startsWith(fence, index)) {
|
||||
index += fence.length;
|
||||
while (index < markdown.length && markdown[index] !== "\n") index += 1;
|
||||
if (index < markdown.length) index += 1;
|
||||
break;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markdown[index] === "`") {
|
||||
let tickCount = 1;
|
||||
while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") {
|
||||
tickCount += 1;
|
||||
}
|
||||
const fence = "`".repeat(tickCount);
|
||||
const inlineStart = index;
|
||||
index += tickCount;
|
||||
const closeIndex = markdown.indexOf(fence, index);
|
||||
if (closeIndex === -1) {
|
||||
output += markdown.slice(inlineStart, inlineStart + tickCount);
|
||||
index = inlineStart + tickCount;
|
||||
continue;
|
||||
}
|
||||
index = closeIndex + tickCount;
|
||||
output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index));
|
||||
continue;
|
||||
}
|
||||
|
||||
output += markdown[index]!;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function trimTrailingPunctuation(token: string): string {
|
||||
let trimmed = token;
|
||||
while (trimmed.length > 0) {
|
||||
const last = trimmed[trimmed.length - 1]!;
|
||||
if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break;
|
||||
|
||||
if (
|
||||
(last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0))
|
||||
|| (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
trimmed = trimmed.slice(0, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname || "/";
|
||||
}
|
||||
|
||||
export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] {
|
||||
if (!markdown) return [];
|
||||
|
||||
const scrubbed = stripMarkdownCode(markdown);
|
||||
const matches: ExternalObjectUrlMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(EXTERNAL_URL_TOKEN_RE);
|
||||
|
||||
while ((match = re.exec(scrubbed)) !== null) {
|
||||
const matchedText = trimTrailingPunctuation(match[0]);
|
||||
if (!matchedText || parseIssueReferenceHref(matchedText)) continue;
|
||||
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: matchedText.length,
|
||||
matchedText,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function canonicalizeExternalObjectUrl(
|
||||
value: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const path = normalizePathname(url.pathname);
|
||||
const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`;
|
||||
const identityQueryParams = new Set(options.identityQueryParams ?? []);
|
||||
const queryParamHashes: Record<string, string> = {};
|
||||
|
||||
for (const key of [...identityQueryParams].sort()) {
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (values.length === 0) continue;
|
||||
queryParamHashes[key] = sha256Hex(values.join("\u0000"));
|
||||
}
|
||||
|
||||
const canonicalIdentity: ExternalObjectCanonicalIdentity = {
|
||||
scheme,
|
||||
host: url.host.toLowerCase(),
|
||||
path,
|
||||
...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
sanitizedCanonicalUrl,
|
||||
sanitizedDisplayUrl: sanitizedCanonicalUrl,
|
||||
canonicalIdentity,
|
||||
canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)),
|
||||
redactedMatchedText: sanitizedCanonicalUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractExternalObjectCanonicalUrls(
|
||||
markdown: string,
|
||||
options: ExternalObjectUrlCanonicalizationOptions = {},
|
||||
): ExternalObjectCanonicalUrl[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: ExternalObjectCanonicalUrl[] = [];
|
||||
|
||||
for (const match of findExternalObjectUrlMatches(markdown)) {
|
||||
const canonical = canonicalizeExternalObjectUrl(match.matchedText, options);
|
||||
if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue;
|
||||
seen.add(canonical.canonicalIdentityHash);
|
||||
ordered.push(canonical);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function buildExternalObjectScopedIdentityKey(args: {
|
||||
companyId: string;
|
||||
providerKey: string;
|
||||
objectType: string;
|
||||
canonicalIdentityHash: string;
|
||||
}): string {
|
||||
return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":");
|
||||
}
|
||||
|
||||
export function buildExternalObjectMentionSourceKey(source: Required<Pick<
|
||||
ExternalObjectMentionSource,
|
||||
"companyId" | "sourceIssueId" | "sourceKind"
|
||||
>> & ExternalObjectMentionSource): string {
|
||||
return [
|
||||
source.companyId,
|
||||
source.sourceIssueId,
|
||||
source.sourceKind,
|
||||
source.sourceRecordId ?? "",
|
||||
source.documentKey ?? "",
|
||||
source.propertyKey ?? "",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function formatExternalObjectMentionSourceLabel(source: ExternalObjectMentionSource): string {
|
||||
switch (source.sourceKind) {
|
||||
case "title":
|
||||
|
||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -134,6 +134,49 @@ const SHOTS = [
|
||||
viewport: { width: 1280, height: 900 },
|
||||
theme: "light",
|
||||
},
|
||||
// PAP-2279 §9 acceptance shots — integration surfaces missing from the prior set.
|
||||
{
|
||||
storyId: "foundations-external-objects--properties-row-desktop",
|
||||
label: "external-properties-row-desktop-1440",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--properties-row-mobile-sheet",
|
||||
label: "external-properties-row-mobile-390",
|
||||
viewport: { width: 390, height: 844 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--related-work-empty",
|
||||
label: "external-related-work-empty-1440",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--sidebar-mobile",
|
||||
label: "external-sidebar-mobile-390",
|
||||
viewport: { width: 390, height: 844 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--issue-list-row",
|
||||
label: "external-issue-list-row-1440",
|
||||
viewport: { width: 1440, height: 600 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--filter-popover-open",
|
||||
label: "external-filter-popover-open-1440",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
storyId: "foundations-external-objects--integration-surfaces",
|
||||
label: "external-integration-surfaces-dark-1440",
|
||||
viewport: { width: 1440, height: 1600 },
|
||||
theme: "dark",
|
||||
},
|
||||
];
|
||||
|
||||
async function applyTheme(page, theme) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
// Captures the §9 acceptance screenshots for PAP-2279 from a logged-in
|
||||
// dev server (local_trusted mode auto-authenticates as local-board).
|
||||
// Seed: PAP-2232 has 3 external objects (failed/auth_required/succeeded);
|
||||
// PAP-2179 has one running object; PAP-2210 has none (related-work empty state).
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
const localRequire = createRequire(import.meta.url);
|
||||
const { chromium } = localRequire("playwright");
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const outDir = path.join(repoRoot, "screenshots", "pap-2279");
|
||||
|
||||
const baseUrl = process.env.PAP_DEV_URL ?? "http://127.0.0.1:3104";
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
|
||||
async function shoot(page, dest) {
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
console.log("captured", path.relative(repoRoot, dest));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// ====== Desktop ======
|
||||
const desktop = await browser.newContext({ viewport: DESKTOP, deviceScaleFactor: 2 });
|
||||
const page = await desktop.newPage();
|
||||
|
||||
// ---- #03 Properties panel @ 1440x900 with 3 pills + tooltip ----
|
||||
await page.goto(`${baseUrl}/PAP/issues/PAP-2232`, { waitUntil: "networkidle" });
|
||||
await page.locator('text=External objects').first().waitFor({ state: "visible", timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
// Hover the failed-status pill to open its Radix tooltip. We aim for the
|
||||
// pill in the properties rail; the test scope is the Properties aside.
|
||||
const propertiesRail = page.locator('aside, [data-slot="sidebar"], [class*="properties" i]').first();
|
||||
const railScope = (await propertiesRail.count()) > 0 ? propertiesRail : page;
|
||||
const firstPill = railScope.locator('a, button').filter({ hasText: /pull request|lead/i }).first();
|
||||
if ((await firstPill.count()) > 0) {
|
||||
try {
|
||||
await firstPill.scrollIntoViewIfNeeded();
|
||||
const box = await firstPill.boundingBox();
|
||||
if (box) {
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
// Move from far away first, then to the pill center, to ensure pointermove fires
|
||||
await page.mouse.move(0, 0);
|
||||
await page.waitForTimeout(50);
|
||||
await page.mouse.move(cx, cy, { steps: 10 });
|
||||
}
|
||||
await firstPill.focus().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
} catch (e) { console.warn("pill hover failed:", e.message); }
|
||||
} else {
|
||||
console.warn("no pill in properties rail found");
|
||||
}
|
||||
await shoot(page, path.join(outDir, "03-properties-desktop-3-pills-tooltip.png"));
|
||||
|
||||
// ---- #07 Related-work empty state @ 1440x900 (PAP-2210) ----
|
||||
await page.goto(`${baseUrl}/PAP/issues/PAP-2210`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(800);
|
||||
// Click the "Related work" tab
|
||||
const relatedTab = page.locator('[role="tab"]', { hasText: /Related work/i }).first();
|
||||
if ((await relatedTab.count()) > 0) {
|
||||
try { await relatedTab.click({ timeout: 5000 }); } catch (e) { console.warn("related tab click failed:", e.message); }
|
||||
await page.waitForTimeout(800);
|
||||
} else {
|
||||
console.warn("Related work tab not found");
|
||||
}
|
||||
// Scroll down so the empty External objects copy is in viewport
|
||||
const emptyCopy = page.locator('text=does not reference any external objects').first();
|
||||
if ((await emptyCopy.count()) > 0) {
|
||||
try { await emptyCopy.scrollIntoViewIfNeeded(); } catch {}
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
await shoot(page, path.join(outDir, "07-related-work-empty-desktop.png"));
|
||||
|
||||
// ---- #10 Issue-list row @ 1440x900 (badge + non-badge) ----
|
||||
// Note: the Phase 6 plumbing ships the IssueRow `externalObjectSummary` prop
|
||||
// but no IssuesList caller fetches/forwards summaries today. So the live-app
|
||||
// list cannot render the badge yet — we still capture the list view as the
|
||||
// truthful current state and flag the wiring gap in the issue comment.
|
||||
await page.goto(`${baseUrl}/PAP/issues`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1500);
|
||||
// Search for PAP-2232 so the seeded "with-badge" issue shows up at the top
|
||||
// alongside any other matches (control rows without badge).
|
||||
const search = page.locator('input[placeholder*="Search issues" i], input[type="search"]').first();
|
||||
if ((await search.count()) > 0) {
|
||||
try {
|
||||
await search.click();
|
||||
await search.fill("Follow up PR");
|
||||
await page.waitForTimeout(800);
|
||||
} catch {}
|
||||
} else {
|
||||
console.warn("Search input not found");
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(500);
|
||||
await shoot(page, path.join(outDir, "10-issue-list-rows-desktop.png"));
|
||||
|
||||
// ---- #11 Filter popover @ 1440x900, External object status section, "Failed" checked ----
|
||||
// Click the icon-only Filter button (title="Filter")
|
||||
const filterButton = page.locator('button[title="Filter"], button[title*="Filters" i]').first();
|
||||
if ((await filterButton.count()) > 0) {
|
||||
try {
|
||||
await filterButton.click({ timeout: 5000 });
|
||||
await page.waitForTimeout(600);
|
||||
} catch (e) { console.warn("filter click failed:", e.message); }
|
||||
} else {
|
||||
console.warn("Filter button not found");
|
||||
}
|
||||
// Scroll the popover so the "External object status" section is visible
|
||||
const externalSection = page.locator('text=External object status').first();
|
||||
if ((await externalSection.count()) > 0) {
|
||||
try { await externalSection.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
|
||||
// Click the Failed checkbox label
|
||||
const failedLabel = page.locator('label', { hasText: /^\s*Failed\s*$/ }).first();
|
||||
const failedFallback = page.locator('label:has-text("Failed")').first();
|
||||
const target = (await failedLabel.count()) > 0 ? failedLabel : failedFallback;
|
||||
if ((await target.count()) > 0) {
|
||||
try { await target.click({ timeout: 3000 }); } catch (e) { console.warn("failed-check click failed:", e.message); }
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
try { await externalSection.scrollIntoViewIfNeeded(); } catch {}
|
||||
} else {
|
||||
console.warn("External object status section not found in popover");
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
await shoot(page, path.join(outDir, "11-filter-popover-desktop.png"));
|
||||
|
||||
await desktop.close();
|
||||
|
||||
// ====== Mobile ======
|
||||
const mobile = await browser.newContext({
|
||||
viewport: MOBILE,
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
|
||||
});
|
||||
const m = await mobile.newPage();
|
||||
|
||||
// ---- #04 Properties mobile sheet expanded ----
|
||||
await m.goto(`${baseUrl}/PAP/issues/PAP-2232`, { waitUntil: "networkidle" });
|
||||
await m.waitForTimeout(1200);
|
||||
// Click button[title="Properties"] (SlidersHorizontal icon in mobile toolbar)
|
||||
const propsTrigger = m.locator('button[title="Properties"]').first();
|
||||
if ((await propsTrigger.count()) > 0) {
|
||||
try { await propsTrigger.click({ timeout: 5000 }); } catch (e) { console.warn("props trigger click failed:", e.message); }
|
||||
await m.waitForTimeout(800);
|
||||
} else {
|
||||
console.warn("mobile Properties trigger not found");
|
||||
}
|
||||
// Scroll sheet so external objects row visible
|
||||
const extLabel = m.locator('text=External objects').first();
|
||||
if ((await extLabel.count()) > 0) {
|
||||
try { await extLabel.scrollIntoViewIfNeeded(); } catch {}
|
||||
}
|
||||
await m.waitForTimeout(400);
|
||||
await shoot(m, path.join(outDir, "04-properties-mobile.png"));
|
||||
|
||||
// ---- #09 Sidebar mobile drawer ----
|
||||
await m.goto(`${baseUrl}/PAP/issues`, { waitUntil: "networkidle" });
|
||||
await m.waitForTimeout(1000);
|
||||
// The hamburger to open the sidebar drawer is "Open Paperclip menu" in mobile
|
||||
// BUT the drawer that contains nav (with project list) is opened via the
|
||||
// dropdown trigger or a separate sidebar trigger; try both.
|
||||
const menuButton = m.locator('button[aria-label="Open Paperclip menu"]').first();
|
||||
const sidebarTrigger = m.locator('[data-slot="sidebar-trigger"], button[aria-label*="sidebar" i], button[aria-label*="navigation" i]').first();
|
||||
let opened = false;
|
||||
for (const cand of [sidebarTrigger, menuButton]) {
|
||||
if ((await cand.count()) > 0) {
|
||||
try {
|
||||
await cand.scrollIntoViewIfNeeded({ timeout: 1000 });
|
||||
await cand.click({ timeout: 3000, force: true });
|
||||
opened = true;
|
||||
break;
|
||||
} catch (e) { console.warn("drawer trigger click failed:", e.message); }
|
||||
}
|
||||
}
|
||||
if (!opened) console.warn("no drawer trigger clicked");
|
||||
await m.waitForTimeout(700);
|
||||
await shoot(m, path.join(outDir, "09-sidebar-mobile-drawer.png"));
|
||||
|
||||
await mobile.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("capture failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -23,6 +23,7 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
|
||||
const mockExternalObjectsService = vi.hoisted(() => ({
|
||||
getIssueSummary: vi.fn(),
|
||||
getIssueSummaries: vi.fn(),
|
||||
listForIssue: vi.fn(),
|
||||
refreshIssueObjects: vi.fn(),
|
||||
}));
|
||||
@@ -145,6 +146,9 @@ describe("external object routes", () => {
|
||||
{ id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } },
|
||||
]);
|
||||
mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] });
|
||||
mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([
|
||||
[issueId, { total: 1, objects: [] }],
|
||||
]));
|
||||
mockExternalObjectsService.listForIssue.mockResolvedValue([]);
|
||||
mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([
|
||||
{ object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" },
|
||||
@@ -170,6 +174,29 @@ describe("external object routes", () => {
|
||||
expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId);
|
||||
});
|
||||
|
||||
it("allows board users to fetch company-scoped external object summaries in bulk", async () => {
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
|
||||
.send({ issueIds: [issueId] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.summaries[issueId].total).toBe(1);
|
||||
expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]);
|
||||
});
|
||||
|
||||
it("enforces company access on bulk external object summaries", async () => {
|
||||
const app = await createApp({ ...ownerActor(), companyId: "other-company" });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/companies/${companyId}/issues/external-object-summaries`)
|
||||
.send({ issueIds: [issueId] });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires active checkout ownership for agent manual refresh", async () => {
|
||||
const app = await createApp(peerActor());
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
externalObjectService,
|
||||
type ExternalObjectResolver,
|
||||
} from "../services/external-objects.js";
|
||||
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared";
|
||||
import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js";
|
||||
|
||||
@@ -156,6 +156,9 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
const refreshExternalObjectsSchema = z.object({
|
||||
objectIds: z.array(z.string().uuid()).max(50).optional(),
|
||||
}).strict();
|
||||
const externalObjectSummariesSchema = z.object({
|
||||
issueIds: z.array(z.string().uuid()).max(1000),
|
||||
}).strict();
|
||||
|
||||
const promoteLowTrustOutputSchema = z.object({
|
||||
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
|
||||
@@ -3736,6 +3739,13 @@ export function issueRoutes(
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds);
|
||||
res.json({ summaries: Object.fromEntries(summaries) });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { and, asc, eq, inArray, isNull, lte } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db";
|
||||
import {
|
||||
extractExternalObjectCanonicalUrls,
|
||||
formatExternalObjectMentionSourceLabel,
|
||||
type ExternalObjectCanonicalUrl,
|
||||
type ExternalObjectLivenessState,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
type ExternalObjectStatusTone,
|
||||
type PaperclipPluginManifestV1,
|
||||
} from "@paperclipai/shared";
|
||||
import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server";
|
||||
import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk";
|
||||
import { notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
@@ -642,12 +642,10 @@ export function externalObjectService(
|
||||
};
|
||||
}
|
||||
|
||||
async function getIssueSummary(issueId: string) {
|
||||
const groups = await listForIssue(issueId);
|
||||
const objects = groups.flatMap((group) => (group.object ? [group.object] : []));
|
||||
function summarizeObjectPayloads(objects: Array<ReturnType<typeof toObjectPayload>>, objectLimit = objects.length) {
|
||||
return {
|
||||
...summarizeObjects(objects),
|
||||
objects: objects.map((object) => ({
|
||||
objects: objects.slice(0, objectLimit).map((object) => ({
|
||||
id: object.id,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
@@ -660,6 +658,44 @@ export function externalObjectService(
|
||||
};
|
||||
}
|
||||
|
||||
async function getIssueSummary(issueId: string) {
|
||||
const groups = await listForIssue(issueId);
|
||||
const objects = groups.flatMap((group) => (group.object ? [group.object] : []));
|
||||
return summarizeObjectPayloads(objects);
|
||||
}
|
||||
|
||||
async function getIssueSummaries(companyId: string, issueIds: string[]) {
|
||||
const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0);
|
||||
const summaries = new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
|
||||
if (uniqueIssueIds.length === 0) return summaries;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
issueId: externalObjectMentions.sourceIssueId,
|
||||
object: externalObjects,
|
||||
})
|
||||
.from(externalObjectMentions)
|
||||
.innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id))
|
||||
.where(and(
|
||||
eq(externalObjectMentions.companyId, companyId),
|
||||
inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const objectsByIssueId = new Map<string, Map<string, ReturnType<typeof toObjectPayload>>>();
|
||||
for (const row of rows) {
|
||||
const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map<string, ReturnType<typeof toObjectPayload>>();
|
||||
issueObjects.set(row.object.id, toObjectPayload(row.object, now));
|
||||
objectsByIssueId.set(row.issueId, issueObjects);
|
||||
}
|
||||
|
||||
for (const [issueId, issueObjects] of objectsByIssueId) {
|
||||
const objects = [...issueObjects.values()];
|
||||
if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
async function getProjectSummary(projectId: string) {
|
||||
const projectIssues = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
@@ -681,19 +717,7 @@ export function externalObjectService(
|
||||
const objectsById = new Map<string, ReturnType<typeof toObjectPayload>>();
|
||||
for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now));
|
||||
const objects = [...objectsById.values()];
|
||||
return {
|
||||
...summarizeObjects(objects),
|
||||
objects: objects.slice(0, 25).map((object) => ({
|
||||
id: object.id,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayTitle: object.displayTitle,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
})),
|
||||
};
|
||||
return summarizeObjectPayloads(objects, 25);
|
||||
}
|
||||
|
||||
async function refreshObject(
|
||||
@@ -853,6 +877,7 @@ export function externalObjectService(
|
||||
syncDocumentSafely,
|
||||
listForIssue,
|
||||
getIssueSummary,
|
||||
getIssueSummaries,
|
||||
getProjectSummary,
|
||||
refreshObject,
|
||||
refreshIssueObjects,
|
||||
|
||||
@@ -6,6 +6,11 @@ export const externalObjectsApi = {
|
||||
api.get<ExternalObjectMentionGroup[]>(`/issues/${issueId}/external-objects`),
|
||||
getIssueSummary: (issueId: string) =>
|
||||
api.get<ExternalObjectSummary>(`/issues/${issueId}/external-object-summary`),
|
||||
getIssueSummaries: (companyId: string, issueIds: string[]) =>
|
||||
api.post<{ summaries: Record<string, ExternalObjectSummary> }>(
|
||||
`/companies/${companyId}/issues/external-object-summaries`,
|
||||
{ issueIds },
|
||||
),
|
||||
refreshIssueObjects: (issueId: string, data?: { objectIds?: string[] }) =>
|
||||
api.post<{ refreshed: unknown[] }>(`/issues/${issueId}/external-objects/refresh`, data ?? {}),
|
||||
getProjectSummary: (projectId: string) =>
|
||||
|
||||
@@ -42,6 +42,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExternalObjectsApi = vi.hoisted(() => ({
|
||||
getIssueSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => companyState,
|
||||
}));
|
||||
@@ -87,6 +91,10 @@ vi.mock("../api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/externalObjects", () => ({
|
||||
externalObjectsApi: mockExternalObjectsApi,
|
||||
}));
|
||||
|
||||
vi.mock("./IssueRow", () => ({
|
||||
IssueRow: ({
|
||||
issue,
|
||||
@@ -97,6 +105,7 @@ vi.mock("./IssueRow", () => ({
|
||||
checklistCurrentStep,
|
||||
checklistDependencyChips,
|
||||
checklistRowId,
|
||||
externalObjectSummary,
|
||||
}: {
|
||||
issue: Issue;
|
||||
desktopMetaLeading?: ReactNode;
|
||||
@@ -106,6 +115,7 @@ vi.mock("./IssueRow", () => ({
|
||||
checklistCurrentStep?: boolean;
|
||||
checklistDependencyChips?: ReactNode;
|
||||
checklistRowId?: string;
|
||||
externalObjectSummary?: { total: number } | null;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="issue-row"
|
||||
@@ -115,6 +125,9 @@ vi.mock("./IssueRow", () => ({
|
||||
data-title-class={titleClassName ?? undefined}
|
||||
>
|
||||
<span>{issue.title}</span>
|
||||
{externalObjectSummary ? (
|
||||
<span data-testid="external-object-summary">{externalObjectSummary.total}</span>
|
||||
) : null}
|
||||
{desktopMetaLeading}
|
||||
{desktopTrailing}
|
||||
{checklistDependencyChips}
|
||||
@@ -286,6 +299,7 @@ describe("IssuesList", () => {
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||
mockExternalObjectsApi.getIssueSummaries.mockReset();
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||
@@ -295,6 +309,7 @@ describe("IssuesList", () => {
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 });
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -303,6 +318,43 @@ describe("IssuesList", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("forwards external-object summaries into issue rows", async () => {
|
||||
mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({
|
||||
summaries: {
|
||||
"issue-1": {
|
||||
total: 2,
|
||||
byStatusCategory: { failed: 1, succeeded: 1 },
|
||||
byLiveness: { fresh: 2 },
|
||||
highestSeverity: "danger",
|
||||
staleCount: 0,
|
||||
authRequiredCount: 0,
|
||||
unreachableCount: 0,
|
||||
objects: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { root } = renderWithQueryClient(
|
||||
<IssuesList
|
||||
issues={[createIssue()]}
|
||||
agents={[]}
|
||||
projects={[]}
|
||||
viewStateKey="paperclip:test-issues"
|
||||
onUpdateIssue={() => undefined}
|
||||
/>,
|
||||
container,
|
||||
);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]);
|
||||
expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders server search results instead of filtering the full issue list locally", async () => {
|
||||
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||
const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" });
|
||||
|
||||
@@ -9,6 +9,7 @@ import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
shouldBlurPageSearchOnEnter,
|
||||
shouldBlurPageSearchOnEscape,
|
||||
@@ -995,6 +996,15 @@ export function IssuesList({
|
||||
issueFilterWorkspaceContext,
|
||||
]);
|
||||
|
||||
const issueIdsForExternalObjectSummaries = useMemo(
|
||||
() => (viewState.viewMode === "list" ? filtered.map((issue) => issue.id) : []),
|
||||
[filtered, viewState.viewMode],
|
||||
);
|
||||
const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries(
|
||||
selectedCompanyId,
|
||||
issueIdsForExternalObjectSummaries,
|
||||
);
|
||||
|
||||
const progressSummary = useMemo(
|
||||
() => shouldRenderSubIssueProgressSummary(showProgressSummary, issues.length)
|
||||
? buildSubIssueProgressSummary(issues)
|
||||
@@ -1749,6 +1759,7 @@ export function IssuesList({
|
||||
checklistDependencyChips={checklistDependencyChips}
|
||||
checklistRowId={checklistRowId}
|
||||
titleClassName={doneRowTitleClass}
|
||||
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
|
||||
titleSuffix={(
|
||||
<>
|
||||
{hasChildren && !isExpanded ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
ExternalObjectMention,
|
||||
@@ -12,9 +12,8 @@ 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.
|
||||
* Browser-side mention-source label. Keep in sync with the shared formatter
|
||||
* without coupling this hook to the server-only URL canonicalization helpers.
|
||||
*/
|
||||
function formatMentionSourceLabel(mention: ExternalObjectMention): string {
|
||||
switch (mention.sourceKind) {
|
||||
@@ -130,12 +129,16 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss
|
||||
return result;
|
||||
}, [groups]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void query.refetch();
|
||||
}, [query.refetch]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
markdownReferences,
|
||||
isLoading: enabled && query.isLoading,
|
||||
isError: query.isError,
|
||||
refetch: () => { void query.refetch(); },
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,6 +159,34 @@ export function useIssueExternalObjectSummary(issueId: string | null | undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueExternalObjectSummaries(
|
||||
companyId: string | null | undefined,
|
||||
issueIds: readonly string[],
|
||||
): {
|
||||
summaries: Map<string, ExternalObjectSummary>;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const normalizedIssueIds = useMemo(
|
||||
() => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(),
|
||||
[issueIds],
|
||||
);
|
||||
const enabled = Boolean(companyId) && normalizedIssueIds.length > 0;
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds),
|
||||
queryFn: () => externalObjectsApi.getIssueSummaries(companyId!, normalizedIssueIds),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const summaries = useMemo(
|
||||
() => new Map(Object.entries(query.data?.summaries ?? {})),
|
||||
[query.data?.summaries],
|
||||
);
|
||||
return {
|
||||
summaries,
|
||||
isLoading: enabled && query.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProjectExternalObjectSummary(projectId: string | null | undefined): {
|
||||
summary: ExternalObjectSummary | null;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -142,6 +142,8 @@ export const queryKeys = {
|
||||
externalObjects: {
|
||||
byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const,
|
||||
issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const,
|
||||
issueSummaries: (companyId: string, issueIds: readonly string[]) =>
|
||||
["external-objects", "issue-summaries", companyId, issueIds] as const,
|
||||
projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const,
|
||||
},
|
||||
goals: {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useGeneralSettings } from "../context/GeneralSettingsContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects";
|
||||
import {
|
||||
applyIssueFilters,
|
||||
countActiveIssueFilters,
|
||||
@@ -1276,6 +1277,20 @@ export function Inbox() {
|
||||
if (!defaults) return;
|
||||
openNewIssue(defaults);
|
||||
}, [groupBy, inboxWorkspaceGrouping, openNewIssue]);
|
||||
|
||||
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
|
||||
const issueIds = new Set<string>();
|
||||
for (const section of groupedSections) {
|
||||
for (const item of section.displayItems) {
|
||||
if (item.kind === "issue") issueIds.add(item.issue.id);
|
||||
}
|
||||
}
|
||||
return [...issueIds];
|
||||
}, [groupedSections]);
|
||||
const { summaries: externalObjectSummaryByIssueId } = useIssueExternalObjectSummaries(
|
||||
selectedCompanyId,
|
||||
inboxIssueIdsForExternalObjectSummaries,
|
||||
);
|
||||
const totalVisibleWorkItems = useMemo(
|
||||
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
|
||||
[groupedSections],
|
||||
@@ -2334,6 +2349,7 @@ export function Inbox() {
|
||||
key={`issue:${issue.id}`}
|
||||
issue={issue}
|
||||
issueLinkState={issueLinkState}
|
||||
externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null}
|
||||
selected={selected}
|
||||
className={
|
||||
isArchiving
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
EXTERNAL_OBJECT_LIVENESS_STATES,
|
||||
@@ -11,8 +12,16 @@ import {
|
||||
import { ExternalObjectPill } from "@/components/ExternalObjectPill";
|
||||
import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon";
|
||||
import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary";
|
||||
import { IssueFiltersPopover } from "@/components/IssueFiltersPopover";
|
||||
import { IssueProperties } from "@/components/IssueProperties";
|
||||
import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel";
|
||||
import { IssueRow } from "@/components/IssueRow";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
|
||||
import {
|
||||
countActiveIssueFilters,
|
||||
defaultIssueFilterState,
|
||||
type IssueFilterState,
|
||||
} from "@/lib/issue-filters";
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectFallbackTone,
|
||||
@@ -21,6 +30,13 @@ import {
|
||||
externalObjectTypeLabel,
|
||||
} from "@/lib/external-objects";
|
||||
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookExecutionWorkspaces,
|
||||
storybookIssueLabels,
|
||||
storybookIssues,
|
||||
storybookProjects,
|
||||
} from "../fixtures/paperclipData";
|
||||
|
||||
function makeObject(args: {
|
||||
id: string;
|
||||
@@ -423,6 +439,239 @@ function StateMatrixStory() {
|
||||
);
|
||||
}
|
||||
|
||||
function makeIntegrationGroups(): IssueExternalObjectGroup[] {
|
||||
return [
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-failed",
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
statusCategory: "failed",
|
||||
liveness: "fresh",
|
||||
displayTitle: "CI broken on main",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
statusLabel: "CI failed",
|
||||
}),
|
||||
mentionCount: 4,
|
||||
sourceLabels: ["Description", "3 comments"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-auth",
|
||||
providerKey: "hubspot",
|
||||
objectType: "lead",
|
||||
statusCategory: "auth_required",
|
||||
liveness: "auth_required",
|
||||
displayTitle: "Acme deal — needs reconnect",
|
||||
url: "https://app.hubspot.com/leads/99",
|
||||
statusLabel: "Reconnect",
|
||||
}),
|
||||
mentionCount: 1,
|
||||
sourceLabels: ["External links property"],
|
||||
}),
|
||||
makeGroup({
|
||||
object: makeObject({
|
||||
id: "obj-int-running",
|
||||
providerKey: "ci",
|
||||
objectType: "deployment",
|
||||
statusCategory: "running",
|
||||
liveness: "fresh",
|
||||
displayTitle: "deploy prod-0412",
|
||||
url: "https://ci.example.com/runs/88421",
|
||||
statusLabel: "Running",
|
||||
}),
|
||||
mentionCount: 2,
|
||||
sourceLabels: ["2 comments"],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeIntegrationSummary(): ExternalObjectSummary {
|
||||
const objects = makeIntegrationGroups()
|
||||
.map((entry) => entry.group.object)
|
||||
.filter((object): object is ExternalObject => Boolean(object))
|
||||
.map((object) => ({
|
||||
id: object.id,
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayTitle: object.displayTitle,
|
||||
statusCategory: object.statusCategory,
|
||||
statusTone: object.statusTone,
|
||||
liveness: object.liveness,
|
||||
isTerminal: object.isTerminal,
|
||||
}));
|
||||
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 o of objects) {
|
||||
byStatusCategory[o.statusCategory] = (byStatusCategory[o.statusCategory] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
total: objects.length,
|
||||
byStatusCategory,
|
||||
byLiveness: { fresh: objects.length - 1, stale: 0, auth_required: 1, unreachable: 0, unknown: 0 },
|
||||
highestSeverity: dominant,
|
||||
staleCount: 0,
|
||||
authRequiredCount: 1,
|
||||
unreachableCount: 0,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
function PropertiesPanelDesktop() {
|
||||
const issue = storybookIssues[0]!;
|
||||
return (
|
||||
<div className="paperclip-story w-[420px] rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue properties — desktop @ 1440×900
|
||||
</div>
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={[]}
|
||||
externalObjects={makeIntegrationGroups()}
|
||||
onAddSubIssue={() => undefined}
|
||||
onUpdate={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertiesPanelMobile() {
|
||||
const issue = storybookIssues[0]!;
|
||||
return (
|
||||
<div className="paperclip-story mx-auto w-[358px] rounded-lg border border-border bg-background/70 p-3">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue properties — mobile sheet @ 390×844
|
||||
</div>
|
||||
<IssueProperties
|
||||
issue={issue}
|
||||
childIssues={[]}
|
||||
externalObjects={makeIntegrationGroups()}
|
||||
onAddSubIssue={() => undefined}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelatedWorkEmptyDesktop() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-3 p-6">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Related work — empty external objects (zero refs, empty copy visible)
|
||||
</div>
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={{ outbound: [], inbound: [] }}
|
||||
externalObjects={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMobileDrawer() {
|
||||
const summary = makeIntegrationSummary();
|
||||
return (
|
||||
<div className="paperclip-story mx-auto w-[320px] rounded border border-border bg-background p-2">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (mobile drawer)</div>
|
||||
<ul className="flex flex-col">
|
||||
{[
|
||||
{ name: "Paperclip App", color: "#6366f1", summary },
|
||||
{ name: "Marketing site", color: "#22c55e", summary: { ...summary, highestSeverity: "warning", byStatusCategory: { waiting: 2 }, total: 2, objects: [] } },
|
||||
{ name: "Experimental", color: "#a855f7", summary: { ...summary, highestSeverity: "muted", byStatusCategory: {}, total: 0, objects: [] } },
|
||||
].map((project) => (
|
||||
<li
|
||||
key={project.name}
|
||||
className="flex items-center gap-2.5 px-3 py-2 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 as ExternalObjectSummary} compact />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueListWithBadge() {
|
||||
const summary = makeIntegrationSummary();
|
||||
return (
|
||||
<div className="paperclip-story p-6">
|
||||
<div className="mb-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Issue list — desktop @ 1440×900 (badge + control row)
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
|
||||
{storybookIssues.slice(0, 2).map((issue, index) => (
|
||||
<IssueRow
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
externalObjectSummary={index === 0 ? summary : null}
|
||||
desktopTrailing={
|
||||
<span className="text-xs text-muted-foreground">{issue.priority}</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPopoverWithExternalChecked() {
|
||||
const [state, setState] = useState<IssueFilterState>({
|
||||
...defaultIssueFilterState,
|
||||
externalObjectStatuses: ["failed", "auth_required"],
|
||||
});
|
||||
const triggerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
triggerRef.current?.querySelector("button")?.click();
|
||||
}, 150);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="paperclip-story flex min-h-[640px] items-start justify-end p-6">
|
||||
<div ref={triggerRef}>
|
||||
<IssueFiltersPopover
|
||||
state={state}
|
||||
onChange={(patch) => setState((current) => ({ ...current, ...patch }))}
|
||||
activeFilterCount={countActiveIssueFilters(state, true)}
|
||||
agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))}
|
||||
projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))}
|
||||
labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))}
|
||||
currentUserId="user-board"
|
||||
enableRoutineVisibilityFilter
|
||||
buttonVariant="outline"
|
||||
workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))}
|
||||
creators={[
|
||||
{ id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegrationSurfacesStory() {
|
||||
return (
|
||||
<div className="paperclip-story space-y-10 p-6">
|
||||
<PropertiesPanelDesktop />
|
||||
<PropertiesPanelMobile />
|
||||
<RelatedWorkEmptyDesktop />
|
||||
<SidebarMobileDrawer />
|
||||
<IssueListWithBadge />
|
||||
<FilterPopoverWithExternalChecked />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Foundations/External Objects",
|
||||
component: StateMatrixStory,
|
||||
@@ -441,3 +690,31 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullSurface: Story = {};
|
||||
|
||||
export const PropertiesRowDesktop: StoryObj = {
|
||||
render: () => <PropertiesPanelDesktop />,
|
||||
};
|
||||
|
||||
export const PropertiesRowMobileSheet: StoryObj = {
|
||||
render: () => <PropertiesPanelMobile />,
|
||||
};
|
||||
|
||||
export const RelatedWorkEmpty: StoryObj = {
|
||||
render: () => <RelatedWorkEmptyDesktop />,
|
||||
};
|
||||
|
||||
export const SidebarMobile: StoryObj = {
|
||||
render: () => <SidebarMobileDrawer />,
|
||||
};
|
||||
|
||||
export const IssueListRow: StoryObj = {
|
||||
render: () => <IssueListWithBadge />,
|
||||
};
|
||||
|
||||
export const FilterPopoverOpen: StoryObj = {
|
||||
render: () => <FilterPopoverWithExternalChecked />,
|
||||
};
|
||||
|
||||
export const IntegrationSurfaces: StoryObj = {
|
||||
render: () => <IntegrationSurfacesStory />,
|
||||
};
|
||||