mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add document reference parsing + documents board route (PAP-10569)
Path-based parser for /<co>/documents/<id>?from=issue:<key> references in @paperclipai/shared, and register "documents" as a board route root so the library nav and document deep-links keep the company prefix instead of 404-ing on the unprefixed catch-all. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3eb57fce1f
commit
0245d1f3af
5 files changed
+126
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDocumentReferenceHref, parseDocumentReferenceHref } from "./document-references.js";
|
||||
|
||||
describe("parseDocumentReferenceHref", () => {
|
||||
it("parses a company-prefixed documents path", () => {
|
||||
expect(parseDocumentReferenceHref("/PAP/documents/abc-123")).toEqual({
|
||||
documentId: "abc-123",
|
||||
fromIssueKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a company-relative documents path", () => {
|
||||
expect(parseDocumentReferenceHref("/documents/abc-123")).toEqual({
|
||||
documentId: "abc-123",
|
||||
fromIssueKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts the originating issue key from ?from=issue:<key>", () => {
|
||||
expect(parseDocumentReferenceHref("/documents/abc-123?from=issue:plan")).toEqual({
|
||||
documentId: "abc-123",
|
||||
fromIssueKey: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses absolute urls", () => {
|
||||
expect(parseDocumentReferenceHref("https://app.example.com/ACME/documents/doc_9")).toEqual({
|
||||
documentId: "doc_9",
|
||||
fromIssueKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-document paths", () => {
|
||||
expect(parseDocumentReferenceHref("/PAP/issues/PAP-1")).toBeNull();
|
||||
expect(parseDocumentReferenceHref("/documents")).toBeNull();
|
||||
expect(parseDocumentReferenceHref("not a url at all ::::")).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips with buildDocumentReferenceHref", () => {
|
||||
expect(buildDocumentReferenceHref("abc-123")).toBe("/documents/abc-123");
|
||||
expect(buildDocumentReferenceHref("abc-123", "plan")).toBe("/documents/abc-123?from=issue:plan");
|
||||
const built = buildDocumentReferenceHref("abc-123", "spec");
|
||||
expect(parseDocumentReferenceHref(built)).toEqual({ documentId: "abc-123", fromIssueKey: "spec" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Path-based references to company documents, e.g. `/PAP/documents/<id>` or the
|
||||
* company-relative `/documents/<id>`. Mirrors `issue-references.ts` so comment
|
||||
* bodies can render a cross-issue document mention chip. The `<id>` segment is
|
||||
* the company document id used by the documents library detail route.
|
||||
*/
|
||||
|
||||
export interface ParsedDocumentReference {
|
||||
documentId: string;
|
||||
/** When the href carries `?from=issue:<key>`, the originating issue document key. */
|
||||
fromIssueKey: string | null;
|
||||
}
|
||||
|
||||
/** Document ids are UUIDs in practice; accept any non-empty url-safe slug to stay forgiving. */
|
||||
const DOCUMENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
function parseFromParam(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
const match = /^issue:(.+)$/i.exec(trimmed);
|
||||
if (!match) return null;
|
||||
const key = match[1]?.trim();
|
||||
return key ? key : null;
|
||||
}
|
||||
|
||||
export function buildDocumentReferenceHref(documentId: string, fromIssueKey?: string | null): string {
|
||||
const id = documentId.trim();
|
||||
const key = fromIssueKey?.trim();
|
||||
return key ? `/documents/${id}?from=issue:${encodeURIComponent(key)}` : `/documents/${id}`;
|
||||
}
|
||||
|
||||
export function parseDocumentReferenceHref(href: string): ParsedDocumentReference | null {
|
||||
const raw = href.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = raw.startsWith("/") ? new URL(raw, "https://paperclip.invalid") : new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = url.pathname
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
if (segments[index]?.toLowerCase() !== "documents") continue;
|
||||
const candidate = segments[index + 1] ?? "";
|
||||
if (DOCUMENT_ID_RE.test(candidate)) {
|
||||
return {
|
||||
documentId: candidate,
|
||||
fromIssueKey: parseFromParam(url.searchParams.get("from")),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -813,6 +813,12 @@ export {
|
||||
type IssueReferenceMatch,
|
||||
} from "./issue-references.js";
|
||||
|
||||
export {
|
||||
buildDocumentReferenceHref,
|
||||
parseDocumentReferenceHref,
|
||||
type ParsedDocumentReference,
|
||||
} from "./document-references.js";
|
||||
|
||||
export {
|
||||
anchorSnapshotToSelector,
|
||||
createDocumentAnchorSelector,
|
||||
|
||||
@@ -65,6 +65,20 @@ describe("company routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Regression for PAP-10569: document deep-links (`/documents/<id>?from=issue:<key>`)
|
||||
// and the library nav must keep the company prefix so they resolve under
|
||||
// `/:companyPrefix` instead of 404-ing on the unprefixed catch-all.
|
||||
it("treats /documents as a board route that needs a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/documents")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/documents/doc-123")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/documents/doc-123")).toBeNull();
|
||||
expect(applyCompanyPrefix("/documents", "PAP")).toBe("/PAP/documents");
|
||||
expect(applyCompanyPrefix("/documents/doc-123?from=issue:plan", "PAP")).toBe(
|
||||
"/PAP/documents/doc-123?from=issue:plan",
|
||||
);
|
||||
expect(toCompanyRelativePath("/PAP/documents/doc-123")).toBe("/documents/doc-123");
|
||||
});
|
||||
|
||||
it("treats /artifacts as a board route that needs a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull();
|
||||
|
||||
@@ -10,6 +10,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"workspaces",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"documents",
|
||||
"routines",
|
||||
"goals",
|
||||
"artifacts",
|
||||
|
||||
Reference in new issue
Block a user