diff --git a/src/editors/TiptapTemplateEditor.tsx b/src/editors/TiptapTemplateEditor.tsx index f9e74be3..7f82be39 100644 --- a/src/editors/TiptapTemplateEditor.tsx +++ b/src/editors/TiptapTemplateEditor.tsx @@ -29,6 +29,9 @@ function TiptapTemplateEditor() { const isSyncingRef = useRef(false); // Track the last markdown we sent to the store to detect external changes const lastMarkdownRef = useRef(editorValue); + // External template loads can be parsed before rebuild provides the matching model. + const pendingModelRetryRef = useRef(editorValue); + const lastModelManagerRef = useRef(modelManager); // Parse markdown into TemplateMarkDocument for the editor // Initialize as null - parsing is deferred to useEffect to ensure libraries are ready @@ -40,45 +43,41 @@ function TiptapTemplateEditor() { // Determine theme based on isDarkMode const theme = getThemeName(isDarkMode); - // Capture initial values in refs to avoid re-running the initial parse effect - // when these values change (the sync effect handles subsequent changes) - const initialEditorValue = useRef(editorValue); - const initialModelManager = useRef(modelManager); - - // Parse initial markdown after mount (runs once) - useEffect(() => { - if (doc === null && !parseError) { - const parsed = parseMarkdownTemplate( - initialEditorValue.current, - undefined, - initialModelManager.current - ); - if (parsed) { - setDoc(parsed); - lastMarkdownRef.current = initialEditorValue.current; - } else { - setParseError("Could not parse template markdown"); - } - } - }, [doc, parseError]); - // Sync from store to editor when editorValue changes externally // (e.g., loading a sample, loading from shareable link) useEffect(() => { - // Skip if doc hasn't been initialized yet - if (doc === null) return; - - // Only sync if the change came from outside (not from our own onChange) - if (editorValue !== lastMarkdownRef.current && !isSyncingRef.current) { - const parsed = parseMarkdownTemplate(editorValue, undefined, modelManager); - if (parsed) { - setDoc(parsed); - setParseError(null); - } else { - setParseError("Could not parse template markdown"); + if (isSyncingRef.current) return; + + const needsInitialParse = doc === null; + const markdownChanged = editorValue !== lastMarkdownRef.current; + const modelChanged = modelManager !== lastModelManagerRef.current; + const shouldRetryWithModel = + modelChanged && pendingModelRetryRef.current === editorValue; + + if (!needsInitialParse && !markdownChanged && !shouldRetryWithModel) { + if (modelChanged) { + lastModelManagerRef.current = modelManager; } - lastMarkdownRef.current = editorValue; + return; } + + const parsed = parseMarkdownTemplate(editorValue, undefined, modelManager); + if (parsed) { + setDoc(parsed); + setParseError(null); + } else { + setParseError("Could not parse template markdown"); + } + + if (markdownChanged || needsInitialParse) { + pendingModelRetryRef.current = editorValue; + } + if (shouldRetryWithModel) { + pendingModelRetryRef.current = null; + } + + lastMarkdownRef.current = editorValue; + lastModelManagerRef.current = modelManager; }, [editorValue, doc, modelManager]); // Handle changes from the TipTap editor @@ -90,6 +89,8 @@ function TiptapTemplateEditor() { // Serialize back to markdown const markdown = serializeToMarkdown(newDoc); lastMarkdownRef.current = markdown; + pendingModelRetryRef.current = null; + lastModelManagerRef.current = modelManager; // Update store (this triggers rebuild) isSyncingRef.current = true; @@ -107,7 +108,7 @@ function TiptapTemplateEditor() { isSyncingRef.current = false; }); }, - [setEditorValue, setTemplateMarkdown] + [modelManager, setEditorValue, setTemplateMarkdown] ); // Handle validation errors from the TipTap editor diff --git a/src/store/store.ts b/src/store/store.ts index 37597d75..01db1d34 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -320,12 +320,12 @@ const useAppStore = create()( setData: async (data: string) => { set(() => ({ data })); try { - const result = await rebuildDeBounce( + const { html, modelManager } = await rebuildDeBounce( get().templateMarkdown, get().modelCto, data ); - set(() => ({ agreementHtml: result, error: undefined })); + set(() => ({ agreementHtml: html, modelManager, error: undefined })); } catch (error: unknown) { set(() => ({ error: formatError(error), diff --git a/src/tests/components/TiptapTemplateEditor.test.tsx b/src/tests/components/TiptapTemplateEditor.test.tsx new file mode 100644 index 00000000..cda83926 --- /dev/null +++ b/src/tests/components/TiptapTemplateEditor.test.tsx @@ -0,0 +1,80 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, beforeEach, expect, vi } from "vitest"; +import TiptapTemplateEditor from "../../editors/TiptapTemplateEditor"; +import useAppStore from "../../store/store"; + +const { + parseMarkdownTemplateMock, + serializeToMarkdownMock, +} = vi.hoisted(() => ({ + parseMarkdownTemplateMock: vi.fn(), + serializeToMarkdownMock: vi.fn(() => "serialized markdown"), +})); + +vi.mock("../../tiptap-editor", () => ({ + TemplateEditor: ({ value }: { value: unknown }) => ( +
{JSON.stringify(value)}
+ ), + parseMarkdownTemplate: parseMarkdownTemplateMock, + serializeToMarkdown: serializeToMarkdownMock, +})); + +describe("TiptapTemplateEditor", () => { + beforeEach(() => { + parseMarkdownTemplateMock.mockReset(); + serializeToMarkdownMock.mockClear(); + + useAppStore.setState({ + editorValue: "Loaded sample markdown", + modelManager: undefined, + isDarkMode: false, + }); + }); + + it("retries parsing when modelManager changes after an external markdown load", async () => { + const loadedMarkdown = "Loaded sample markdown"; + const modelManager = { id: "new-model-manager" }; + const parsedDoc = { + $class: "org.accordproject.commonmark@0.5.0.Document", + nodes: [], + }; + + parseMarkdownTemplateMock + .mockReturnValueOnce(null) + .mockReturnValueOnce(parsedDoc); + + render(); + + await waitFor(() => { + expect(parseMarkdownTemplateMock).toHaveBeenCalledTimes(1); + }); + expect(parseMarkdownTemplateMock).toHaveBeenNthCalledWith( + 1, + loadedMarkdown, + undefined, + undefined + ); + + act(() => { + useAppStore.setState({ modelManager }); + }); + + await waitFor(() => { + expect(parseMarkdownTemplateMock).toHaveBeenCalledTimes(2); + }); + expect(parseMarkdownTemplateMock).toHaveBeenNthCalledWith( + 2, + loadedMarkdown, + undefined, + modelManager + ); + + await waitFor(() => { + expect(screen.getByTestId("template-editor")).toBeInTheDocument(); + }); + expect(screen.getByTestId("template-editor")).toHaveTextContent( + JSON.stringify(parsedDoc) + ); + }); +}); diff --git a/src/tests/store/setData.test.tsx b/src/tests/store/setData.test.tsx new file mode 100644 index 00000000..5006d613 --- /dev/null +++ b/src/tests/store/setData.test.tsx @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const validateBeforeRebuildMock = vi.fn(); +const addCTOModelMock = vi.fn(); +const updateExternalModelsMock = vi.fn(); +const fromMarkdownTemplateMock = vi.fn(); +const generateMock = vi.fn(); +const transformMock = vi.fn(); +const createdModelManagers: MockModelManager[] = []; + +class MockModelManager { + constructor(_options?: unknown) { + createdModelManagers.push(this); + } + + addCTOModel = addCTOModelMock; + updateExternalModels = updateExternalModelsMock; +} + +class MockTemplateMarkInterpreter { + generate = generateMock; +} + +class MockTemplateMarkTransformer { + fromMarkdownTemplate = fromMarkdownTemplateMock; +} + +vi.mock("ts-debounce", () => ({ + debounce: unknown>(fn: T) => fn, +})); + +vi.mock("../../utils/validators", () => ({ + validateBeforeRebuild: validateBeforeRebuildMock, +})); + +vi.mock("@accordproject/concerto-core", () => ({ + ModelManager: MockModelManager, +})); + +vi.mock("@accordproject/template-engine", () => ({ + TemplateMarkInterpreter: MockTemplateMarkInterpreter, +})); + +vi.mock("@accordproject/markdown-template", () => ({ + TemplateMarkTransformer: MockTemplateMarkTransformer, +})); + +vi.mock("@accordproject/markdown-transform", () => ({ + transform: transformMock, +})); + +describe("useAppStore - setData", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + createdModelManagers.length = 0; + + validateBeforeRebuildMock.mockResolvedValue(undefined); + fromMarkdownTemplateMock.mockReturnValue({ $class: "TemplateMarkDocument" }); + generateMock.mockResolvedValue({ + toJSON: () => ({ $class: "CiceroMarkDocument" }), + }); + transformMock.mockResolvedValue("

rebuilt html

"); + }); + + it("stores rebuilt html as a string and updates modelManager", async () => { + const { default: useAppStore } = await import("../../store/store"); + + useAppStore.setState({ + templateMarkdown: "Sample template markdown", + modelCto: "namespace org.example@1.0.0", + data: "{\"before\":true}", + agreementHtml: "", + modelManager: undefined, + error: undefined, + }); + + const nextData = "{\"after\":true}"; + await useAppStore.getState().setData(nextData); + + const state = useAppStore.getState(); + + expect(validateBeforeRebuildMock).toHaveBeenCalledWith( + "Sample template markdown", + "namespace org.example@1.0.0", + nextData + ); + expect(state.agreementHtml).toBe("

rebuilt html

"); + expect(typeof state.agreementHtml).toBe("string"); + expect(state.modelManager).toBe(createdModelManagers[0]); + expect(state.error).toBeUndefined(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index b26695da..6ef02fc8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,10 @@ -import { defineConfig as defineViteConfig, mergeConfig } from "vite"; -import { defineConfig as defineVitestConfig, configDefaults } from "vitest/config"; +import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { nodePolyfills } from "vite-plugin-node-polyfills"; import { visualizer } from "rollup-plugin-visualizer"; + // https://vitejs.dev/config/ -const viteConfig = defineViteConfig({ +export default defineConfig({ plugins: [nodePolyfills(), react(), visualizer({ emitFile: true, filename: "stats.html", @@ -18,30 +18,3 @@ const viteConfig = defineViteConfig({ needsInterop: ['@accordproject/template-engine'], }, }); - - -// https://vitest.dev/config/ -const vitestConfig = defineVitestConfig({ test: { - globals: true, - environment: "jsdom", - setupFiles: "./src/utils/testing/setup.ts", - exclude: [...configDefaults.exclude, "**/e2e/**"], - server: { - deps: { - inline: ["monaco-editor"], - }, - }, - coverage: { - provider: 'v8', - reporter: ['text'], - include: ['src/**/*.{ts,tsx}'], - }, - }, - resolve: { - alias: process.env.VITEST ? { - "monaco-editor": "monaco-editor/esm/vs/editor/editor.api", - } : {}, - }, -}); - -export default mergeConfig(viteConfig, vitestConfig); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..8ca9e5e2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,28 @@ +import { mergeConfig } from "vite"; +import { configDefaults, defineConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +// https://vitest.dev/config/ +export default mergeConfig(viteConfig, defineConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/utils/testing/setup.ts", + exclude: [...configDefaults.exclude, "**/e2e/**"], + server: { + deps: { + inline: ["monaco-editor"], + }, + }, + coverage: { + provider: "v8", + reporter: ["text"], + include: ["src/**/*.{ts,tsx}"], + }, + }, + resolve: { + alias: process.env.VITEST ? { + "monaco-editor": "monaco-editor/esm/vs/editor/editor.api", + } : {}, + }, +}));