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
1,436 changes: 1,430 additions & 6 deletions canvas/package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions canvas/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
Expand All @@ -15,10 +17,14 @@
"reactflow": "^11.11.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^2.1.9"
}
}
32 changes: 30 additions & 2 deletions canvas/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,15 +185,43 @@ export interface ImportResult {
export const importModel = (format: string, content: string, dialect?: string) =>
post<ImportResult>("/api/import", { format, content, dialect });

/** A semantic diff of an import against the current model (the review view's ModelDiffDoc),
* WITHOUT writing — plus the import's change list + warnings, so the review can apply the
* selected objects afterwards. Powers "review before it lands" for erwin imports. */
export type ImportPreviewDoc = ModelDiffDoc & {
changes: { op: string; payload: Record<string, unknown> }[];
warnings: string[];
};

export const importPreview = (format: string, content: string) =>
post<ImportPreviewDoc>("/api/import/preview", { format, content });

/** Fetch import content a host (VS Code) stashed by token — the webview can't read local
* files, so /sme?import=<token> hands off the file this way. One-shot. */
export const importStashGet = (token: string) =>
get<{ ok: boolean; content?: string; error?: string }>(
`/api/import/stash/${encodeURIComponent(token)}`,
);

/** Apply an imported change list to the working tree as one batch (direct-write
* canvas). Each command is sent WITHOUT a fingerprint so the server's stale-model
* check is skipped: the guard exists to catch an external edit between a human's
* view and their action, not to serialise a machine applying its own batch. */
* view and their action, not to serialise a machine applying its own batch.
*
* `tolerateExisting` skips "already exists" errors (idempotent scaffolding): an import
* into an existing model re-emits shared domains / subject areas / categories, and a
* create for one already present is a no-op, not a failure. Any OTHER error still throws. */
export const applyImportBatch = async (
changes: { op: string; payload: Record<string, unknown> }[],
tolerateExisting = false,
) => {
for (const c of changes) {
await sendCommand(c.op, c.payload); // no fingerprint -> no stale-check
try {
await sendCommand(c.op, c.payload); // no fingerprint -> no stale-check
} catch (e) {
if (tolerateExisting && e instanceof Error && /already exists/i.test(e.message)) continue;
throw e;
}
}
};

Expand Down
104 changes: 104 additions & 0 deletions canvas/src/sme/DiffView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { makeDiff } from "../test/diffFixture";
import { DiffView } from "./DiffView";
import { allFieldKeys, fieldKey, includedUlids, toggleField } from "./reviewModel";

/** The list-pane row for an entity (its name also appears in the detail <h2>, so scope
* to the .rv-list to disambiguate). */
function listRow(name: string): HTMLElement {
const list = document.querySelector(".rv-list") as HTMLElement;
return within(list).getByText(name).closest(".rv-obj") as HTMLElement;
}

/** A host harness mirroring how ReviewScreen would drive per-field selection: it owns the
* field-keyed Set and exposes the derived included ULIDs so a test can assert them. */
function PerFieldHost() {
const diff = makeDiff();
const [selected, setSelected] = useState<Set<string>>(() => allFieldKeys(diff));
const included = includedUlids(diff, selected);
return (
<div>
<div data-testid="included">{included.sort().join(",")}</div>
<DiffView
diff={diff}
selectable
selected={selected}
onToggleField={(ulid, field) => setSelected((s) => toggleField(fieldKey(ulid, field), s))}
/>
</div>
);
}

describe("DiffView — filters (view only)", () => {
it("shows all changes by default and filters visibility without dropping selection", () => {
render(<PerFieldHost />);
const list = () => document.querySelector(".rv-list") as HTMLElement;
// all four objects present initially
expect(within(list()).getByText("counterparty")).toBeInTheDocument();
expect(within(list()).getByText("instrument")).toBeInTheDocument();
expect(within(list()).getByText("legacy")).toBeInTheDocument();

// click the Additions filter -> only the added entity is visible in the list
fireEvent.click(screen.getByRole("tab", { name: /Additions/ }));
expect(within(list()).getByText("instrument")).toBeInTheDocument();
expect(within(list()).queryByText("counterparty")).not.toBeInTheDocument();
expect(within(list()).queryByText("legacy")).not.toBeInTheDocument();

// filtering did NOT change selection: everything is still included
expect(screen.getByTestId("included")).toHaveTextContent("E_ADD,E_DEL,E_MOD,E_REN");

// back to All -> everything visible again
fireEvent.click(screen.getByRole("tab", { name: /^All/ }));
expect(within(list()).getByText("counterparty")).toBeInTheDocument();
});

it("only offers filters that have matches", () => {
render(<PerFieldHost />);
// the fixture has one of each, so every filter with count>0 shows
for (const label of ["All", "Additions", "Modifications", "Renames", "Deletions", "Conflicts"]) {
expect(screen.getByRole("tab", { name: new RegExp(label) })).toBeInTheDocument();
}
});
});

describe("DiffView — per-field selection", () => {
it("unchecking every field of an object excludes it from the action", () => {
render(<PerFieldHost />);
// focus the modified entity (it has two field changes)
fireEvent.click(listRow("counterparty"));
// its two per-field checkboxes live in the detail pane
const defBox = screen.getByLabelText("include change: definition");
const domBox = screen.getByLabelText("include change: domain");
expect(defBox).toBeChecked();
expect(domBox).toBeChecked();

// uncheck both -> E_MOD drops out, others stay
fireEvent.click(defBox);
fireEvent.click(domBox);
expect(screen.getByTestId("included")).toHaveTextContent("E_ADD,E_DEL,E_REN");
expect(screen.getByTestId("included")).not.toHaveTextContent("E_MOD");
});

it("the object header checkbox reflects a partial (indeterminate) selection", () => {
render(<PerFieldHost />);
fireEvent.click(listRow("counterparty"));
// uncheck ONE field -> the object stays included, header is indeterminate
fireEvent.click(screen.getByLabelText("include change: definition"));
expect(screen.getByTestId("included")).toHaveTextContent("E_MOD");
const row = listRow("counterparty");
const header = within(row as HTMLElement).getByRole("checkbox") as HTMLInputElement;
expect(header.indeterminate).toBe(true);
});

it("the header checkbox toggles all of an object's fields at once", () => {
render(<PerFieldHost />);
fireEvent.click(listRow("counterparty"));
const row = listRow("counterparty");
const header = within(row as HTMLElement).getByRole("checkbox");
// header on -> off excludes the whole object
fireEvent.click(header);
expect(screen.getByTestId("included")).not.toHaveTextContent("E_MOD");
});
});
Loading
Loading