Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 36 additions & 35 deletions src/editors/TiptapTemplateEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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
Expand All @@ -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;

Comment thread
Rishabh060105 marked this conversation as resolved.
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
Expand All @@ -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;
Expand All @@ -107,7 +108,7 @@ function TiptapTemplateEditor() {
isSyncingRef.current = false;
});
},
[setEditorValue, setTemplateMarkdown]
[modelManager, setEditorValue, setTemplateMarkdown]
);

// Handle validation errors from the TipTap editor
Expand Down
4 changes: 2 additions & 2 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,12 +320,12 @@ const useAppStore = create<AppState>()(
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) {
Comment thread
Rishabh060105 marked this conversation as resolved.
set(() => ({
error: formatError(error),
Expand Down
80 changes: 80 additions & 0 deletions src/tests/components/TiptapTemplateEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="template-editor">{JSON.stringify(value)}</div>
),
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(<TiptapTemplateEditor />);

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)
);
});
});
93 changes: 93 additions & 0 deletions src/tests/store/setData.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <T extends (...args: never[]) => 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("<p>rebuilt html</p>");
});

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("<p>rebuilt html</p>");
expect(typeof state.agreementHtml).toBe("string");
expect(state.modelManager).toBe(createdModelManagers[0]);
expect(state.error).toBeUndefined();
});
});
33 changes: 3 additions & 30 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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);
28 changes: 28 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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",
} : {},
},
}));