diff --git a/README.md b/README.md index 2573ba9..ed4d7e7 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,14 @@ instead of uploading a copy. Images that cannot be downloaded degrade to links, AT Protocol accounts are linked through discovery, PAR, PKCE, DPoP-bound token exchange, encrypted token/key persistence, DPoP nonce retry, and refresh-token rotation. Existing accounts created before these fields and scopes were added must reconnect. Production startup requires `TOKEN_ENCRYPTION_KEY` to be valid base64 containing at least 32 bytes. +## Research + +The Research tab shows the linked account’s Semble collections and Margin notes. Choose **Use in post** +on an item to select its quote, source link, or comment and preview the content. Add it to the end of +an existing draft, or start a new draft in a chosen publication; either action opens the post editor. +Existing draft writing and metadata are preserved. Published, scheduled, and publishing posts are +excluded from insertion. Research source records are unchanged, and publication remains a separate action. + ## Development ```bash diff --git a/apps/web/components/cms/cms-workspace.tsx b/apps/web/components/cms/cms-workspace.tsx index cfb5cef..1111051 100644 --- a/apps/web/components/cms/cms-workspace.tsx +++ b/apps/web/components/cms/cms-workspace.tsx @@ -21,6 +21,8 @@ import { import { RightPanel } from "@/components/cms/right-panel"; import { PublicationsDashboard } from "@/components/cms/publications-dashboard"; import { ResearchSection } from "@/components/cms/research-section"; +import { ResearchPostDialog, type ResearchPostSubmission } from "@/components/cms/research-post-dialog"; +import { appendResearchToDraft, type ResearchPostMaterial } from "@/lib/research-composer"; import { WorkspaceHeader } from "@/components/cms/workspace-header"; import { FeedbackSection } from "@/components/cms/feedback-section"; import { MobileWorkspaceFooter } from "@/components/cms/mobile-workspace-footer"; @@ -84,6 +86,8 @@ export function CmsWorkspace() { const [revertDraftToConfirm, setRevertDraftToConfirm] = React.useState(null); const [unpublishDraftToConfirm, setUnpublishDraftToConfirm] = React.useState(null); const [isMutatingDraft, setIsMutatingDraft] = React.useState(false); + const [researchMaterial, setResearchMaterial] = React.useState(null); + const researchSubmission = React.useRef(false); const [search, setSearch] = React.useState(""); const [draftListTab, setDraftListTab] = React.useState("drafts"); const [draftListGrouping, setDraftListGrouping] = React.useState("all"); @@ -346,7 +350,10 @@ export function CmsWorkspace() { } function trackDraftSave(draft: Draft, version: number, notify: boolean) { - const save = persistDraftSnapshot(draft, version, notify); + // Keep writes for one draft in order so an older autosave cannot overwrite a later insertion. + const previousSave = inFlightSaves.current.get(draft.id); + setDraftSaveState(draft.id, "saving"); + const save = Promise.resolve(previousSave).then(() => persistDraftSnapshot(draft, version, notify)); inFlightSaves.current.set(draft.id, save); void save.finally(() => { if (inFlightSaves.current.get(draft.id) === save) { @@ -391,19 +398,20 @@ export function CmsWorkspace() { scheduleAutosave(next, version); } - async function createDraft(publicationURI: string) { + async function createDraft(publicationURI: string, seed?: { title: string; markdown: string }) { const publication = accountPublications.find((candidate) => candidate.uri === publicationURI); if (!publication) { return false; } const id = crypto.randomUUID(); - const next: Draft = { + const title = seed?.title.trim() || "Untitled article"; + const empty: Draft = { id, accountDID: activeAccountDID, publicationURI: publication.uri, publicationURL: publication.url, - title: "Untitled article", - path: slugPathFromTitle("Untitled article", slugDiscriminatorFromDraftID(id)), + title, + path: slugPathFromTitle(title, slugDiscriminatorFromDraftID(id)), excerpt: "", tags: [], markdown: "", @@ -413,6 +421,7 @@ export function CmsWorkspace() { updatedAt: new Date().toISOString(), }; try { + const next = seed ? appendResearchToDraft(empty, seed.markdown) : empty; const persisted = await draftAPI.createDraft(next); setDrafts((current) => [persisted, ...current]); editVersions.current.set(persisted.id, 0); @@ -427,6 +436,32 @@ export function CmsWorkspace() { } } + async function useResearchInPost(submission: ResearchPostSubmission) { + if (researchSubmission.current || !activeAccountDID || draftsLoadedForAccount !== activeAccountDID || !submission.markdown.trim()) return false; + researchSubmission.current = true; + try { + if (submission.destination.type === "new") { + return await createDraft(submission.destination.publicationURI, submission); + } + const targetID = submission.destination.draftID; + const target = drafts.find((draft) => draft.id === targetID && draft.accountDID === activeAccountDID); + if (!target || (target.status !== "draft" && target.status !== "failed")) return false; + clearAutosave(target.id); + const next = appendResearchToDraft(target, submission.markdown); + const version = (editVersions.current.get(target.id) ?? 0) + 1; + editVersions.current.set(target.id, version); + // Persist the combined snapshot before changing the UI. Failed attempts can retry without appending twice. + if (!await trackDraftSave(next, version, false)) return false; + setSearch(""); + setDraftListTab("drafts"); + selectDraft(target.id); + toast.success("Research added to draft"); + return true; + } finally { + researchSubmission.current = false; + } + } + async function saveDraft() { if (!activeDraft || draftSaveStates[activeDraft.id] === "saving") { return; @@ -684,7 +719,7 @@ export function CmsWorkspace() { onSync={syncPublications} /> ) : activeView === "research" ? ( - + ) : activeView === "feedback" ? ( ) : ( @@ -776,6 +811,15 @@ export function CmsWorkspace() { onPublish={publishDraft} /> + {researchMaterial ? ( + draft.accountDID === activeAccountDID && (draft.status === "draft" || draft.status === "failed")))} + publications={accountPublications} + onOpenChange={(open) => { if (!open) setResearchMaterial(null); }} + onSubmit={useResearchInPost} + /> + ) : null} void; + onSubmit: (submission: ResearchPostSubmission) => Promise; +}) { + const [destination, setDestination] = React.useState<"existing" | "new">(drafts.length ? "existing" : "new"); + const [draftID, setDraftID] = React.useState(drafts[0]?.id ?? ""); + const [publicationURI, setPublicationURI] = React.useState(publications[0]?.uri ?? ""); + const [parts, setParts] = React.useState({ + quote: Boolean(material.quote), link: Boolean(material.sourceURL), comment: Boolean(material.comment), + }); + const [busy, setBusy] = React.useState(false); + const submitting = React.useRef(false); + const [error, setError] = React.useState(""); + const markdown = researchMarkdown(material, parts); + const selectedDraftID = drafts.some((draft) => draft.id === draftID) ? draftID : drafts[0]?.id ?? ""; + const selectedPublicationURI = publications.some((publication) => publication.uri === publicationURI) + ? publicationURI : publications[0]?.uri ?? ""; + const hasDestination = destination === "existing" + ? Boolean(selectedDraftID) + : Boolean(selectedPublicationURI); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + if (submitting.current || !hasDestination || !markdown) return; + submitting.current = true; + setBusy(true); + setError(""); + try { + const saved = await onSubmit({ + title: material.title, + markdown, + destination: destination === "existing" ? { type: "existing", draftID: selectedDraftID } : { type: "new", publicationURI: selectedPublicationURI }, + }); + if (saved) onOpenChange(false); + else setError("Could not save this research to your draft. Please try again."); + } catch { + setError("Could not save this research to your draft. Please try again."); + } finally { + submitting.current = false; + setBusy(false); + } + } + + return ( + { if (!submitting.current) onOpenChange(open); }}> + + + Use in post + Choose what to include from “{material.title}”, then continue writing. + +
+
+ Include + {([ + ["quote", "Quote", material.quote], + ["link", "Source link", material.sourceURL], + ["comment", "Comment", material.comment], + ] as const).map(([part, label, available]) => available ? ( + + ) : null)} +
+
+ {parts.quote && material.quote ?
{material.quote}
: null} + {parts.link && material.sourceURL ? ( +

{material.title}{material.author ? ` — ${material.author}` : ""}

+ ) : null} + {parts.comment && material.comment ?

{material.comment}

: null} + {!markdown ?

Select something to include in your post.

: null} +
+
+ Add to +
+ + +
+ {destination === "existing" ? drafts.length ? ( + + Draft + +

Appends to the end of your draft. Your existing writing stays in place.

+
+ ) :

No editable drafts yet. Choose New draft to start a post.

: publications.length ? ( + + Publication + +

Starts a draft with this source’s title and your selected material.

+
+ ) :

No publications found. Sync your publications before creating a draft.

} +
+ {error ?

{error}

: null} +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/components/cms/research-section.tsx b/apps/web/components/cms/research-section.tsx index 05c0eef..5f04754 100644 --- a/apps/web/components/cms/research-section.tsx +++ b/apps/web/components/cms/research-section.tsx @@ -9,6 +9,7 @@ import { LibraryBigIcon, LoaderCircleIcon, RefreshCwIcon, + SquarePenIcon, } from "lucide-react"; import { loadResearch, @@ -17,13 +18,19 @@ import { type SembleResearchCard, type SembleResearchCollection, } from "@/lib/research-api"; +import { materialFromMargin, materialFromSemble, type ResearchPostMaterial } from "@/lib/research-composer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"; import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -export function ResearchSection() { +type ResearchActions = { + onUseInPost: (material: ResearchPostMaterial) => void; + canUseInPost?: boolean; +}; + +export function ResearchSection({ onUseInPost, canUseInPost = true }: ResearchActions) { const [research, setResearch] = React.useState(null); const [error, setError] = React.useState(""); const [isLoading, setIsLoading] = React.useState(true); @@ -57,7 +64,7 @@ export function ResearchSection() {

Your AT Protocol library

Research

- Browse your Semble collections and Margin annotations when you are looking for something worth developing. + Turn your Semble links and Margin quotes and comments into your next post.

) : null} + ); } -function MarginAnnotations({ annotations }: { annotations: MarginResearchAnnotation[] }) { +function MarginAnnotations({ annotations, onUseInPost, canUseInPost }: { annotations: MarginResearchAnnotation[] } & ResearchActions) { if (!annotations.length) { return ( @@ -208,6 +218,8 @@ function MarginAnnotations({ annotations }: { annotations: MarginResearchAnnotat {annotation.tags.map((tag) => {tag})} ) : null} + ))} @@ -222,6 +235,16 @@ function MarginAnnotations({ annotations }: { annotations: MarginResearchAnnotat ); } +function UseInPostButton({ material, onUseInPost, canUseInPost }: { material: ResearchPostMaterial } & ResearchActions) { + const hasContent = Boolean(material.quote || material.sourceURL || material.comment); + return ( + + ); +} + function ResearchDate({ value }: { value: string }) { let label: string; try { diff --git a/apps/web/lib/research-composer.ts b/apps/web/lib/research-composer.ts new file mode 100644 index 0000000..495c095 --- /dev/null +++ b/apps/web/lib/research-composer.ts @@ -0,0 +1,105 @@ +import { importMarkdownDocument } from "@stygian/markdown-editor/model"; +import type { MarginResearchAnnotation, SembleResearchCard } from "@/lib/research-api"; +import type { Draft } from "@/lib/types"; +import { markdownToPlaintext } from "@/lib/validation"; + +export type ResearchPostMaterial = { + title: string; + sourceURL?: string; + quote?: string; + comment?: string; + author?: string; +}; + +export type ResearchPostParts = { + quote: boolean; + link: boolean; + comment: boolean; +}; + +export function materialFromSemble(card: SembleResearchCard): ResearchPostMaterial { + const sourceURL = safeSourceURL(card.url); + return { + title: cleanText(card.title) || sourceTitle(sourceURL), + sourceURL, + comment: cleanText(card.note), + author: cleanText(card.author), + }; +} + +export function materialFromMargin(annotation: MarginResearchAnnotation): ResearchPostMaterial { + const sourceURL = safeSourceURL(annotation.source); + return { + title: cleanText(annotation.title) || sourceTitle(sourceURL), + sourceURL, + quote: cleanText(annotation.quote), + comment: cleanText(annotation.body), + }; +} + +export function researchMarkdown(material: ResearchPostMaterial, parts: ResearchPostParts): string { + const blocks: string[] = []; + const quote = cleanText(material.quote); + const comment = cleanText(material.comment); + const sourceURL = safeSourceURL(material.sourceURL); + + if (parts.quote && quote) { + blocks.push(escapeMarkdownText(quote).split("\n").map((line) => line ? `> ${line}` : ">").join("\n")); + } + if (parts.link && sourceURL) { + const title = escapeMarkdownText(singleLine(material.title) || sourceTitle(sourceURL)); + const author = singleLine(material.author); + blocks.push(`[${title}](${sourceURL})${author ? ` — ${escapeMarkdownText(author)}` : ""}`); + } + if (parts.comment && comment) { + blocks.push(escapeMarkdownText(comment)); + } + return blocks.join("\n\n"); +} + +export function appendResearchToDraft(draft: Draft, markdown: string): Draft { + if (!markdown.trim()) return draft; + const separator = !draft.markdown || draft.markdown.endsWith("\n\n") + ? "" + : draft.markdown.endsWith("\n") ? "\n" : "\n\n"; + const nextMarkdown = `${draft.markdown}${separator}${markdown}`; + const document = importMarkdownDocument(nextMarkdown, { revision: (draft.blockRevision ?? 0) + 1 }); + return { + ...draft, + markdown: document.markdown, + plaintext: markdownToPlaintext(document.markdown), + blockDocumentJSON: JSON.stringify(document), + blockSchemaVersion: document.schemaVersion, + blockRevision: document.revision, + updatedAt: new Date().toISOString(), + }; +} + +function cleanText(value?: string) { + return value?.replace(/\r\n?/g, "\n").trim() || undefined; +} + +function singleLine(value?: string) { + return cleanText(value)?.replace(/\s+/g, " "); +} + +function sourceTitle(sourceURL?: string) { + return sourceURL ? new URL(sourceURL).hostname.replace(/^www\./, "") : "Research note"; +} + +function safeSourceURL(value?: string): string | undefined { + if (!value || Array.from(value).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) return undefined; + try { + const url = new URL(value.trim()); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + // Both the editor and publishing parser terminate destinations at a closing + // parenthesis, so encode punctuation that could escape the generated link. + return url.href.replace(/[()\[\]<>\\]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + } catch { + return undefined; + } +} + +function escapeMarkdownText(value: string) { + return value.replace(/[\\`*_{}[\]()<>#+.!|~=-]/g, "\\$&"); +} diff --git a/apps/web/test/research-composer.test.ts b/apps/web/test/research-composer.test.ts new file mode 100644 index 0000000..329a182 --- /dev/null +++ b/apps/web/test/research-composer.test.ts @@ -0,0 +1,191 @@ +import { createElement } from "react"; +import { render, screen } from "@testing-library/react"; +import { BlockDocumentRenderer, importMarkdownDocument, parseBlockDocument } from "@stygian/markdown-editor"; +import { describe, expect, it } from "vitest"; +import { + appendResearchToDraft, + materialFromMargin, + materialFromSemble, + researchMarkdown, + type ResearchPostParts, +} from "@/lib/research-composer"; +import type { Draft } from "@/lib/types"; + +const allParts: ResearchPostParts = { quote: true, link: true, comment: true }; + +describe("research post materials", () => { + it("uses a Semble note without duplicating the source description", () => { + expect(materialFromSemble({ + uri: "at://did:plc:writer/network.cosmik.card/card", + title: " Source article ", + url: "https://example.com/article", + note: " My response ", + description: "Source summary", + author: " A. Writer ", + })).toEqual({ + title: "Source article", + sourceURL: "https://example.com/article", + comment: "My response", + author: "A. Writer", + }); + }); + + it("keeps Margin quotes and comments separate and falls back to the source host", () => { + expect(materialFromMargin({ + uri: "at://did:plc:writer/at.margin.note/note", + motivation: "highlighting", + source: "https://www.example.com/essay", + quote: " First line\r\nSecond line ", + body: "My response", + tags: ["do-not-import"], + createdAt: "2026-09-01T00:00:00.000Z", + })).toEqual({ + title: "example.com", + sourceURL: "https://www.example.com/essay", + quote: "First line\nSecond line", + comment: "My response", + }); + }); + + it("supports a standalone note without inventing a quote or source link", () => { + const material = materialFromSemble({ + uri: "at://did:plc:writer/network.cosmik.card/note", + note: "A standalone thought", + }); + expect(material.title).toBe("Research note"); + expect(material.sourceURL).toBeUndefined(); + expect(researchMarkdown(material, allParts)).toBe("A standalone thought"); + }); +}); + +describe("research Markdown", () => { + it("inserts multiline quotations, linked attribution, and a separate comment", () => { + expect(researchMarkdown({ + title: "An essay", + sourceURL: "https://example.com/essay", + quote: "First paragraph\n\nSecond paragraph", + comment: "My response\nA second line", + author: "A Writer", + }, allParts)).toBe( + "> First paragraph\n>\n> Second paragraph\n\n[An essay](https://example.com/essay) — A Writer\n\nMy response\nA second line", + ); + }); + + it("includes only the selected parts and skips empty content", () => { + const material = { title: "Source", sourceURL: "https://example.com/", quote: "A quotation", comment: "A comment" }; + expect(researchMarkdown(material, { quote: true, link: false, comment: false })).toBe("> A quotation"); + expect(researchMarkdown(material, { quote: false, link: true, comment: false })).toBe("[Source](https://example.com/)"); + expect(researchMarkdown(material, { quote: false, link: false, comment: true })).toBe("A comment"); + expect(researchMarkdown(material, { quote: false, link: false, comment: false })).toBe(""); + expect(researchMarkdown({ title: "Source", quote: " \n ", comment: " " }, allParts)).toBe(""); + }); + + it.each([ + "javascript:alert(1)", + "data:text/html,

Hello

", + "file:///etc/passwd", + "/relative-path", + "https://example.com/\ninjected", + "https://example.com/\u0000injected", + ])("does not turn unsafe source %j into a link", (sourceURL) => { + const material = materialFromSemble({ uri: "card", title: "Source", url: sourceURL, note: "Keep the note" }); + expect(material.sourceURL).toBeUndefined(); + expect(researchMarkdown({ ...material, sourceURL }, allParts)).toBe("Keep the note"); + }); + + it("encodes URL whitespace and punctuation without allowing Markdown breakout", () => { + expect(researchMarkdown({ + title: "Source", + sourceURL: "https://example.com/a b_(c)?q=[one]", + }, allParts)).toBe("[Source](https://example.com/a%20b_%28c%29?q=%5Bone%5D)"); + }); + + it("renders imported punctuation literally without creating links, images, or formatting", () => { + const quote = "[A link](https://evil.example/) and **bold**"; + const comment = "![Tracking image](https://evil.example/pixel)\n# A literal heading"; + const markdown = researchMarkdown({ title: "Source", quote, comment }, allParts); + const document = importMarkdownDocument(markdown); + expect(document.blocks.map((block) => block.kind)).toEqual(["quote", "paragraph"]); + const { container } = render(createElement(BlockDocumentRenderer, { document })); + expect(container.textContent).toContain(quote); + expect(container.textContent).toContain("![Tracking image](https://evil.example/pixel)"); + expect(container.textContent).toContain("# A literal heading"); + expect(container.querySelector("a, img, strong, em, h1")).toBeNull(); + }); + + it("renders a source title with punctuation as one safe attribution link", () => { + const document = importMarkdownDocument(researchMarkdown({ + title: "Source [with brackets] and *literal stars*", + sourceURL: "https://example.com/a_(b)", + author: "A. Writer", + }, allParts)); + render(createElement(BlockDocumentRenderer, { document })); + expect(screen.getByRole("link", { name: "Source [with brackets] and *literal stars*" })).toHaveAttribute( + "href", "https://example.com/a_%28b%29", + ); + expect(screen.getByText(/A\. Writer/)).toBeInTheDocument(); + }); +}); + +describe("appending research to a draft", () => { + const draft: Draft = { + id: "draft-id", + accountDID: "did:plc:writer", + publicationURI: "at://did:plc:writer/site.standard.publication/blog", + publicationURL: "https://writer.example", + title: "Work in progress", + path: "/custom-path", + excerpt: "Existing excerpt", + tags: ["original"], + markdown: "# Existing heading\n\nUnsaved local text\n\n![Photo](anypub-asset://asset-id)", + plaintext: "Existing heading\nUnsaved local text\nPhoto", + status: "draft", + blockRevision: 8, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; + + it("preserves existing content, assets, and post metadata in a consistent block document", () => { + const appended = appendResearchToDraft(draft, "> Research quote\n\n[Source](https://example.com/)"); + expect(appended.markdown).toBe(`${draft.markdown}\n\n> Research quote\n\n[Source](https://example.com/)`); + expect(appended).toMatchObject({ + id: draft.id, + accountDID: draft.accountDID, + publicationURI: draft.publicationURI, + publicationURL: draft.publicationURL, + title: draft.title, + path: draft.path, + excerpt: draft.excerpt, + tags: draft.tags, + status: "draft", + createdAt: draft.createdAt, + blockSchemaVersion: 1, + blockRevision: 9, + }); + const document = parseBlockDocument(JSON.parse(appended.blockDocumentJSON!)); + expect(document.markdown).toBe(appended.markdown); + expect(document.revision).toBe(appended.blockRevision); + expect(document.blocks.find((block) => block.kind === "image")).toMatchObject({ url: "anypub-asset://asset-id" }); + expect(draft.markdown).not.toContain("Research quote"); + }); + + it("rebuilds a stale snapshot from canonical Markdown and normalizes it consistently", () => { + const appended = appendResearchToDraft({ + ...draft, + markdown: "First paragraph\r\n\r\n\r\nSecond paragraph\n", + blockDocumentJSON: JSON.stringify(importMarkdownDocument("Stale content")), + }, "A comment"); + const document = parseBlockDocument(JSON.parse(appended.blockDocumentJSON!)); + expect(document.markdown).toBe("First paragraph\n\nSecond paragraph\n\nA comment"); + expect(appended.markdown).toBe(document.markdown); + expect(appended.plaintext).toContain("A comment"); + }); + + it("starts an empty draft without leading whitespace and ignores empty selections", () => { + expect(appendResearchToDraft({ ...draft, markdown: "", blockRevision: undefined }, "> Quote")).toMatchObject({ + markdown: "> Quote", + blockRevision: 1, + }); + expect(appendResearchToDraft(draft, " \n")).toBe(draft); + }); +}); diff --git a/apps/web/test/research-post-dialog.test.tsx b/apps/web/test/research-post-dialog.test.tsx new file mode 100644 index 0000000..be775af --- /dev/null +++ b/apps/web/test/research-post-dialog.test.tsx @@ -0,0 +1,266 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { ResearchPostDialog } from "@/components/cms/research-post-dialog"; +import type { Draft, Publication } from "@/lib/types"; + +const publication: Publication = { + id: "publication-one", + accountDID: "did:plc:writer", + uri: "at://did:plc:writer/site.standard.publication/field-notes", + name: "Field Notes", + url: "https://field-notes.example", + syncedAt: "2026-09-01T12:00:00.000Z", +}; + +const otherPublication: Publication = { + ...publication, + id: "publication-two", + uri: "at://did:plc:writer/site.standard.publication/workbench", + name: "Workbench", + url: "https://workbench.example", +}; + +const draft: Draft = { + id: "draft-one", + accountDID: publication.accountDID, + publicationURI: publication.uri, + publicationURL: publication.url, + title: "An existing essay", + tags: [], + markdown: "An earlier paragraph.", + plaintext: "An earlier paragraph.", + status: "draft", + createdAt: "2026-09-01T12:00:00.000Z", + updatedAt: "2026-09-01T12:00:00.000Z", +}; + +const otherDraft: Draft = { ...draft, id: "draft-two", title: "Another essay" }; + +const material = { + title: "A source worth discussing", + sourceURL: "https://example.org/essay", + quote: "A highlighted passage from the source", + comment: "My response to the central claim", + author: "A. Writer", +}; + +function showDialog(overrides: Partial> = {}) { + const onSubmit = vi.fn["onSubmit"]>().mockResolvedValue(true); + const onOpenChange = vi.fn(); + const props = { + material, + drafts: [draft, otherDraft], + publications: [publication, otherPublication], + onSubmit, + onOpenChange, + ...overrides, + }; + const { rerender } = render(); + return { + onSubmit: props.onSubmit, + onOpenChange: props.onOpenChange, + updateProps: (next: Partial>) => rerender(), + }; +} + +describe("adding research to a post", () => { + it("previews the available material and adds it to the selected existing draft", async () => { + const { onSubmit, onOpenChange } = showDialog(); + + expect(screen.getByRole("radio", { name: "Existing draft" })).toBeChecked(); + for (const name of ["Quote", "Source link", "Comment"]) { + expect(screen.getByRole("checkbox", { name })).toBeChecked(); + } + const preview = screen.getByRole("region", { name: "Post preview" }); + expect(preview).toHaveTextContent(material.quote); + expect(preview).toHaveTextContent(material.comment); + expect(preview).toHaveTextContent(material.title); + + fireEvent.change(screen.getByLabelText("Draft", { exact: true }), { target: { value: otherDraft.id } }); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + title: material.title, + destination: { type: "existing", draftID: otherDraft.id }, + markdown: expect.stringContaining(material.quote), + })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ markdown: expect.stringContaining(material.sourceURL) })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ markdown: expect.stringContaining(material.comment) })); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it("lets the writer include just a comment", async () => { + const { onSubmit } = showDialog(); + fireEvent.click(screen.getByRole("checkbox", { name: "Quote" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Source link" })); + + const preview = screen.getByRole("region", { name: "Post preview" }); + expect(preview).toHaveTextContent(material.comment); + expect(preview).not.toHaveTextContent(material.quote); + expect(preview).not.toHaveTextContent(material.sourceURL); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ markdown: material.comment }))); + }); + + it("disables submission when no material is selected", () => { + const { onSubmit } = showDialog(); + for (const name of ["Quote", "Source link", "Comment"]) { + fireEvent.click(screen.getByRole("checkbox", { name })); + } + + expect(screen.getByRole("button", { name: "Add to draft" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Quote" })); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeEnabled(); + }); + + it("creates a new draft with the chosen publication and source title", async () => { + const { onSubmit } = showDialog(); + fireEvent.click(screen.getByRole("radio", { name: "New draft" })); + fireEvent.change(screen.getByLabelText("Publication", { exact: true }), { target: { value: otherPublication.uri } }); + fireEvent.click(screen.getByRole("button", { name: "Create draft" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + title: material.title, + destination: { type: "new", publicationURI: otherPublication.uri }, + markdown: expect.stringContaining(material.comment), + }))); + }); + + it("starts with a new draft when no existing drafts are available", () => { + showDialog({ drafts: [] }); + + expect(screen.getByRole("radio", { name: "New draft" })).toBeChecked(); + expect(screen.getByRole("button", { name: "Create draft" })).toBeEnabled(); + fireEvent.click(screen.getByRole("radio", { name: "Existing draft" })); + expect(screen.getByText(/no .*drafts/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeDisabled(); + }); + + it("explains unavailable publications and prevents creating a draft without one", () => { + const { onSubmit } = showDialog({ drafts: [], publications: [] }); + + expect(screen.getByText(/no publications/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create draft" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Create draft" })); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("uses a publication that loads after the dialog opens", async () => { + const { onSubmit, updateProps } = showDialog({ drafts: [], publications: [] }); + expect(screen.getByRole("button", { name: "Create draft" })).toBeDisabled(); + + updateProps({ publications: [publication] }); + + expect(screen.getByLabelText("Publication", { exact: true })).toHaveValue(publication.uri); + expect(screen.getByRole("button", { name: "Create draft" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Create draft" })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + destination: { type: "new", publicationURI: publication.uri }, + }))); + }); + + it("submits the visible publication when discovery replaces the previous selection", async () => { + const { onSubmit, updateProps } = showDialog({ drafts: [], publications: [publication] }); + expect(screen.getByLabelText("Publication", { exact: true })).toHaveValue(publication.uri); + + updateProps({ publications: [otherPublication] }); + + expect(screen.getByLabelText("Publication", { exact: true })).toHaveValue(otherPublication.uri); + expect(screen.getByRole("button", { name: "Create draft" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Create draft" })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + destination: { type: "new", publicationURI: otherPublication.uri }, + }))); + }); + + it("uses an existing draft that loads while the dialog is open", async () => { + const { onSubmit, updateProps } = showDialog({ drafts: [] }); + fireEvent.click(screen.getByRole("radio", { name: "Existing draft" })); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeDisabled(); + + updateProps({ drafts: [draft] }); + + expect(screen.getByLabelText("Draft", { exact: true })).toHaveValue(draft.id); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + destination: { type: "existing", draftID: draft.id }, + }))); + }); + + it("submits the visible draft when the previous destination becomes unavailable", async () => { + const { onSubmit, updateProps } = showDialog({ drafts: [draft] }); + expect(screen.getByLabelText("Draft", { exact: true })).toHaveValue(draft.id); + + updateProps({ drafts: [otherDraft] }); + + expect(screen.getByLabelText("Draft", { exact: true })).toHaveValue(otherDraft.id); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + destination: { type: "existing", draftID: otherDraft.id }, + }))); + }); + + it("allows a source link without a quote or comment", async () => { + const { onSubmit } = showDialog({ material: { title: material.title, sourceURL: material.sourceURL } }); + expect(screen.getByRole("checkbox", { name: "Source link" })).toBeChecked(); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + markdown: expect.stringContaining(material.sourceURL), + }))); + }); + + it.each(["rejected", "thrown"])("keeps the selection open after a %s save and allows retry", async (failure) => { + const onSubmit = vi.fn["onSubmit"]>(); + if (failure === "thrown") onSubmit.mockRejectedValueOnce(new Error("The draft service is unavailable.")); + else onSubmit.mockResolvedValueOnce(false); + onSubmit.mockResolvedValueOnce(true); + const { onOpenChange } = showDialog({ onSubmit }); + fireEvent.change(screen.getByLabelText("Draft", { exact: true }), { target: { value: otherDraft.id } }); + fireEvent.click(screen.getByRole("checkbox", { name: "Quote" })); + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Draft", { exact: true })).toHaveValue(otherDraft.id); + expect(screen.getByRole("checkbox", { name: "Quote" })).not.toBeChecked(); + expect(screen.getByRole("button", { name: "Add to draft" })).toBeEnabled(); + + fireEvent.click(screen.getByRole("button", { name: "Add to draft" })); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit.mock.calls[1]).toEqual(onSubmit.mock.calls[0]); + }); + + it("prevents duplicate writes, changed selections, and dismissal while saving", async () => { + let finish!: (success: boolean) => void; + const pending = new Promise((resolve) => { finish = resolve; }); + const onSubmit = vi.fn["onSubmit"]>().mockReturnValue(pending); + const { onOpenChange } = showDialog({ onSubmit }); + const submit = screen.getByRole("button", { name: "Add to draft" }); + fireEvent.click(submit); + fireEvent.click(submit); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(submit).toBeDisabled(); + expect(screen.getByLabelText("Draft", { exact: true })).toBeDisabled(); + expect(screen.getByRole("radio", { name: "New draft" })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: "Quote" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + const close = screen.queryByRole("button", { name: "Close" }); + if (close) fireEvent.click(close); + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + expect(onOpenChange).not.toHaveBeenCalled(); + + await act(async () => finish(true)); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/test/research-section.test.tsx b/apps/web/test/research-section.test.tsx index 4c52384..6849a86 100644 --- a/apps/web/test/research-section.test.tsx +++ b/apps/web/test/research-section.test.tsx @@ -47,7 +47,7 @@ beforeEach(() => { describe("research workspace", () => { it("browses the linked user's Semble collections and Margin annotations", async () => { - render(); + render(); expect(await screen.findByRole("heading", { name: "Things to write about" })).toBeInTheDocument(); expect(screen.getByText("A promising source")).toBeInTheDocument(); @@ -67,7 +67,7 @@ describe("research workspace", () => { margin: { annotations: [] }, }); - render(); + render(); expect(await screen.findByText("Semble records could not be loaded.")).toBeInTheDocument(); expect(screen.getByText("No Semble collections found")).toBeInTheDocument(); @@ -82,7 +82,7 @@ describe("research workspace", () => { .mockRejectedValueOnce(new Error("Research service unavailable")) .mockResolvedValueOnce({ semble: { collections: [] }, margin: { annotations: [] } }); - render(); + render(); expect(await screen.findByText("Research service unavailable")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Try again" })); @@ -107,7 +107,7 @@ describe("research workspace", () => { margin: { annotations: [] }, }); - render(); + render(); expect(await screen.findByText("Untrusted item")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Open source/ })).not.toBeInTheDocument(); diff --git a/apps/web/test/research-workspace.test.tsx b/apps/web/test/research-workspace.test.tsx new file mode 100644 index 0000000..6ab4a70 --- /dev/null +++ b/apps/web/test/research-workspace.test.tsx @@ -0,0 +1,296 @@ +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CmsWorkspace } from "@/components/cms/cms-workspace"; +import type { Draft, Publication } from "@/lib/types"; + +const navigation = vi.hoisted(() => ({ replace: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: navigation.replace }) })); + +// Keep the workspace's real state, persistence, navigation, and research UI while +// exposing a simple body input for testing edits racing with asynchronous saves. +vi.mock("@/components/cms/editor-panel", () => ({ + EditorPanel: ({ draft, onChange, saveState }: { + draft: Draft; + onChange: (patch: Partial) => void; + saveState: string; + }) => ( +
+

{draft.title}

+