UNPKG

@blocknote/core

Version:

A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.

436 lines (351 loc) 14.6 kB
/** * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi, } from "vite-plus/test"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { UserStoreOrResolver } from "../../user/index.js"; import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js"; import type { VersionSnapshot } from "./Versioning.js"; import { createInMemoryPreviewController, createInMemoryVersioningEndpoints, } from "./inMemoryVersioning.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createEditor() { const editor = BlockNoteEditor.create(); const div = document.createElement("div"); editor.mount(div); return editor; } function getEditorText(editor: BlockNoteEditor<any, any, any>): string { return editor.prosemirrorState.doc.textContent; } function setEditorText(editor: BlockNoteEditor<any, any, any>, text: string) { editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]); } /** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */ function snap( id: string, createdAt: number, extra?: Partial<VersionSnapshot>, ): VersionSnapshot { return { id, createdAt, updatedAt: createdAt, ...extra }; } /** * Wire up a real editor with the in-memory versioning adapter. * * Returns the extension instance, the editor, and helpers to seed snapshots * directly into the backend (bypassing the extension). */ function setup(opts?: { initialText?: string; withoutRestore?: boolean; withoutUpdateName?: boolean; resolveUsers?: UserStoreOrResolver; }) { const editor = createEditor(); setEditorText(editor, opts?.initialText ?? "initial doc"); const endpoints = createInMemoryVersioningEndpoints(); const preview = createInMemoryPreviewController(editor); if (opts?.withoutRestore) { (endpoints as any).restore = undefined; } if (opts?.withoutUpdateName) { (endpoints as any).rename = undefined; } const ext = VersioningExtension({ endpoints, preview, getCurrentDocument: () => editor.document, resolveUsers: opts?.resolveUsers, })({ editor }); /** Seed a snapshot into the backend by capturing the current editor doc. */ const seed = async (text: string, name?: string) => { // Temporarily set editor text, create via endpoints, then restore. const savedBlocks = editor.document; setEditorText(editor, text); const blocks = editor.document; const snapshot = await endpoints.create!(blocks, { name }); // Restore original text. editor.replaceBlocks(editor.document, savedBlocks); // Refresh the store so the extension can resolve the seeded snapshot by id // (preview/restore look snapshots up in the store, as the UI would after // listing). await ext.list(); return snapshot; }; return { ext, editor, endpoints, seed }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("sortSnapshotsNewestFirst", () => { it("sorts newest-first by createdAt", () => { const input = [snap("a", 100), snap("b", 300), snap("c", 200)]; const sorted = sortSnapshotsNewestFirst(input); expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]); }); }); describe("VersioningExtension", () => { let ctx: ReturnType<typeof setup>; beforeEach(() => { ctx = setup(); }); afterEach(() => { ctx.editor.unmount(); }); // ------------------------------------------------------------------------- // Listing snapshots // ------------------------------------------------------------------------- describe("listing snapshots", () => { it("populates the store from the backend, sorted newest-first", async () => { vi.useFakeTimers(); // Seed snapshots with distinct timestamps directly via endpoints. await ctx.endpoints.create!([ { id: "1", type: "paragraph" as const, content: "v1" as any, props: {} as any, children: [], }, ]); vi.advanceTimersByTime(1000); await ctx.endpoints.create!([ { id: "2", type: "paragraph" as const, content: "v2" as any, props: {} as any, children: [], }, ]); vi.advanceTimersByTime(1000); await ctx.endpoints.create!([ { id: "3", type: "paragraph" as const, content: "v3" as any, props: {} as any, children: [], }, ]); const result = await ctx.ext.list(); expect(result).toHaveLength(3); // Newest first: v3, v2, v1 expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt); expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt); expect(ctx.ext.store.state.snapshots).toEqual(result); vi.useRealTimers(); }); it("reflects backend changes on subsequent calls", async () => { expect(await ctx.ext.list()).toEqual([]); await ctx.endpoints.create!([ { id: "1", type: "paragraph" as const, content: "external" as any, props: {} as any, children: [], }, ]); const after = await ctx.ext.list(); expect(after).toHaveLength(1); }); }); // ------------------------------------------------------------------------- // Creating snapshots // ------------------------------------------------------------------------- describe("creating snapshots", () => { it("captures the current state and adds the snapshot to the store", async () => { setEditorText(ctx.editor, "my document content"); const snapshot = await ctx.ext.create!({ name: "Draft 1" }); expect(snapshot.name).toBe("Draft 1"); expect(snapshot.id).toBeDefined(); expect(ctx.ext.store.state.snapshots).toHaveLength(1); // The snapshot content should round-trip — verify by previewing. await ctx.ext.previewSnapshot(snapshot.id); expect(getEditorText(ctx.editor)).toBe("my document content"); }); it("maintains newest-first order when adding to existing snapshots", async () => { vi.useFakeTimers(); // Seed an older snapshot. const old = await ctx.seed("old content", "Old"); vi.advanceTimersByTime(1000); // List so the store knows about the seeded snapshot. await ctx.ext.list(); const newer = await ctx.ext.create!({ name: "Newer" }); expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id); expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id); vi.useRealTimers(); }); }); // ------------------------------------------------------------------------- // Previewing snapshots // ------------------------------------------------------------------------- describe("previewing snapshots", () => { it("shows a snapshot and tracks it in the store", async () => { const snap = await ctx.seed("snapshot content"); await ctx.ext.previewSnapshot(snap.id); expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id); expect(getEditorText(ctx.editor)).toBe("snapshot content"); }); it("supports comparing against an older snapshot", async () => { const _v1 = await ctx.seed("content v1"); const v2 = await ctx.seed("content v2"); // The in-memory preview controller doesn't render diffs, but the call // should succeed and show the primary snapshot content. await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id }); expect(getEditorText(ctx.editor)).toBe("content v2"); }); it("switching previews updates to the new snapshot", async () => { const s1 = await ctx.seed("content s1"); const s2 = await ctx.seed("content s2"); await ctx.ext.previewSnapshot(s1.id); expect(getEditorText(ctx.editor)).toBe("content s1"); await ctx.ext.previewSnapshot(s2.id); expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id); expect(getEditorText(ctx.editor)).toBe("content s2"); }); }); // ------------------------------------------------------------------------- // Exiting preview // ------------------------------------------------------------------------- describe("exiting preview", () => { it("clears the preview state and restores the live document", async () => { setEditorText(ctx.editor, "live content"); const snap = await ctx.seed("snapshot content"); await ctx.ext.previewSnapshot(snap.id); expect(getEditorText(ctx.editor)).toBe("snapshot content"); ctx.ext.exitPreview(); expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); expect(getEditorText(ctx.editor)).toBe("live content"); }); }); // ------------------------------------------------------------------------- // Restoring snapshots // ------------------------------------------------------------------------- describe("restoring snapshots", () => { it("applies the snapshot content and exits any active preview", async () => { setEditorText(ctx.editor, "current doc"); const snap = await ctx.seed("old content"); // Enter preview first, then restore. await ctx.ext.previewSnapshot(snap.id); await ctx.ext.restore!(snap.id); expect(getEditorText(ctx.editor)).toBe("old content"); expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); }); it("picks up server-side backup snapshots after re-listing", async () => { const snap = await ctx.seed("original"); await ctx.ext.list(); await ctx.ext.restore!(snap.id); // The in-memory endpoints create a backup snapshot on restore. const updated = await ctx.ext.list(); expect(updated.length).toBe(2); expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe( true, ); }); it("reports restore as unavailable when endpoint omits it", () => { const noRestore = setup({ withoutRestore: true }); expect(noRestore.ext.canRestore).toBe(false); expect(noRestore.ext.restore).toBeUndefined(); noRestore.editor.unmount(); }); }); // ------------------------------------------------------------------------- // Updating snapshot names // ------------------------------------------------------------------------- describe("updating snapshot names", () => { it("renames a snapshot in the store and backend", async () => { const snap = await ctx.seed("content", "Original"); await ctx.ext.list(); await ctx.ext.rename!(snap.id, "Renamed"); // Store was updated optimistically. expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed"); // Backend was also updated (verified via list). const list = await ctx.ext.list(); expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed"); }); it("reports name updates as unavailable when endpoint omits it", () => { const noUpdate = setup({ withoutUpdateName: true }); expect(noUpdate.ext.canRename).toBe(false); expect(noUpdate.ext.rename).toBeUndefined(); noUpdate.editor.unmount(); }); }); // ------------------------------------------------------------------------- // User store (author resolution for `VersionSnapshot.by`) // ------------------------------------------------------------------------- describe("user store", () => { it("exposes an empty user store when no resolveUsers is provided", async () => { expect(ctx.ext.userStore).toBeDefined(); await ctx.ext.userStore.loadUsers(["u1"]); expect(ctx.ext.userStore.getUser("u1")).toBeUndefined(); }); it("builds a de-duped user store from a resolveUsers callback", async () => { const resolveUsers = vi.fn(async (ids: string[]) => ids.map((id) => ({ id, username: `name-${id}`, avatarUrl: "" })), ); const withUsers = setup({ resolveUsers }); await withUsers.ext.userStore.loadUsers(["u1", "u2"]); expect(withUsers.ext.userStore.getUser("u1")?.username).toBe("name-u1"); expect(withUsers.ext.userStore.getUser("u2")?.username).toBe("name-u2"); // Already-cached ids are not re-fetched. await withUsers.ext.userStore.loadUsers(["u1"]); expect(resolveUsers).toHaveBeenCalledTimes(1); withUsers.editor.unmount(); }); it("passes `by` author ids through list() untouched", async () => { const editor = createEditor(); const ext = VersioningExtension({ endpoints: { list: async () => [snap("1", 100, { by: ["u1", "u2"] })], getContent: async () => [], }, preview: createInMemoryPreviewController(editor), getCurrentDocument: () => editor.document, })({ editor }); const result = await ext.list(); // Raw ids are preserved — resolving them to user info is the view // layer's job (via `ext.userStore`), never the extension's. expect(result[0]!.by).toEqual(["u1", "u2"]); expect(result[0]!.secondaryLabel).toBeUndefined(); expect(ext.store.state.snapshots).toEqual(result); editor.unmount(); }); }); // ------------------------------------------------------------------------- // End-to-end workflow // ------------------------------------------------------------------------- describe("workflow: create, preview with diff, then restore", () => { it("handles the full version-history flow", async () => { vi.useFakeTimers(); // 1. Create version 1. setEditorText(ctx.editor, "doc v1"); const v1 = await ctx.ext.create!({ name: "Version 1" }); vi.advanceTimersByTime(1000); // 2. Modify and create version 2. setEditorText(ctx.editor, "doc v2"); const v2 = await ctx.ext.create!({ name: "Version 2" }); expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id); // 3. Preview v1 with diff comparison against v2. await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id }); expect(getEditorText(ctx.editor)).toBe("doc v1"); // 4. Restore v1. await ctx.ext.restore!(v1.id); expect(getEditorText(ctx.editor)).toBe("doc v1"); expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined(); vi.useRealTimers(); }); }); });