feature: added saving graph to json
This commit is contained in:
195
e2e/save.spec.ts
Normal file
195
e2e/save.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { test, expect, type Page, type Download } from '@playwright/test';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// ── types mirroring saveGraph.ts ──────────────────────────────────────────────
|
||||
interface SavedEdge { id: string; from: string; to: string; }
|
||||
interface SavedNode { id: string; label?: string; subgraph?: SavedGraph; }
|
||||
interface SavedGraph { id: string; nodes: SavedNode[]; edges: SavedEdge[]; }
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
async function waitForGraph(page: Page) {
|
||||
await page.waitForSelector('svg g.node', { timeout: 15000 });
|
||||
}
|
||||
|
||||
function nodeByLabel(page: Page, label: string) {
|
||||
return page.locator('g.node').filter({ hasText: label });
|
||||
}
|
||||
|
||||
async function openSubgraph(page: Page, nodeLabel: string) {
|
||||
await nodeByLabel(page, nodeLabel).click({ button: 'right' });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click();
|
||||
await waitForGraph(page);
|
||||
}
|
||||
|
||||
function saveButton(page: Page) {
|
||||
return page.getByTitle('Save as JSON');
|
||||
}
|
||||
|
||||
async function triggerSave(page: Page): Promise<Download> {
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
saveButton(page).click(),
|
||||
]);
|
||||
return download;
|
||||
}
|
||||
|
||||
async function getSavedJson(page: Page): Promise<SavedGraph> {
|
||||
const download = await triggerSave(page);
|
||||
const filePath = await download.path();
|
||||
expect(filePath).not.toBeNull();
|
||||
return JSON.parse(fs.readFileSync(filePath!, 'utf-8')) as SavedGraph;
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
test.describe('Graph saving', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForGraph(page);
|
||||
});
|
||||
|
||||
// ── button presence ─────────────────────────────────────────────────────────
|
||||
test('save button is visible in the header', async ({ page }) => {
|
||||
await expect(saveButton(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// ── download mechanics ──────────────────────────────────────────────────────
|
||||
test('clicking save downloads a file named concept-sketch.json', async ({ page }) => {
|
||||
const download = await triggerSave(page);
|
||||
expect(download.suggestedFilename()).toBe('concept-sketch.json');
|
||||
});
|
||||
|
||||
test('downloaded file is valid JSON', async ({ page }) => {
|
||||
const download = await triggerSave(page);
|
||||
const filePath = await download.path();
|
||||
expect(filePath).not.toBeNull();
|
||||
expect(() => JSON.parse(fs.readFileSync(filePath!, 'utf-8'))).not.toThrow();
|
||||
});
|
||||
|
||||
// ── root graph structure ────────────────────────────────────────────────────
|
||||
test('saved JSON has id, nodes and edges at the root', async ({ page }) => {
|
||||
const data = await getSavedJson(page);
|
||||
expect(data.id).toBe('main');
|
||||
expect(Array.isArray(data.nodes)).toBe(true);
|
||||
expect(Array.isArray(data.edges)).toBe(true);
|
||||
});
|
||||
|
||||
test('default graph saves Start and End nodes with one connecting edge', async ({ page }) => {
|
||||
const data = await getSavedJson(page);
|
||||
expect(data.nodes).toHaveLength(2);
|
||||
const labels = data.nodes.map(n => n.label);
|
||||
expect(labels).toContain('Start');
|
||||
expect(labels).toContain('End');
|
||||
expect(data.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('edge from/to ids reference nodes that exist in the graph', async ({ page }) => {
|
||||
const data = await getSavedJson(page);
|
||||
const nodeIds = new Set(data.nodes.map(n => n.id));
|
||||
for (const edge of data.edges) {
|
||||
expect(nodeIds.has(edge.from)).toBe(true);
|
||||
expect(nodeIds.has(edge.to)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// ── edits reflected in the save ─────────────────────────────────────────────
|
||||
test('newly added node appears in the saved JSON', async ({ page }) => {
|
||||
await nodeByLabel(page, 'Start').click();
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
expect(data.nodes).toHaveLength(3);
|
||||
expect(data.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renamed node label appears correctly in the saved JSON', async ({ page }) => {
|
||||
await nodeByLabel(page, 'Start').click({ button: 'right' });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' }).click();
|
||||
|
||||
const modal = page.locator('.ant-modal');
|
||||
await expect(modal).toBeVisible({ timeout: 3000 });
|
||||
await modal.locator('.ant-input').clear();
|
||||
await modal.locator('.ant-input').fill('Concept');
|
||||
await modal.locator('.ant-btn-primary').click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
await expect(nodeByLabel(page, 'Concept')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
const labels = data.nodes.map(n => n.label);
|
||||
expect(labels).toContain('Concept');
|
||||
expect(labels).not.toContain('Start');
|
||||
});
|
||||
|
||||
test('removed node is absent from the saved JSON', async ({ page }) => {
|
||||
await nodeByLabel(page, 'End').click({ button: 'right' });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click();
|
||||
await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 });
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
expect(data.nodes).toHaveLength(1);
|
||||
expect(data.nodes.map(n => n.label)).not.toContain('End');
|
||||
});
|
||||
|
||||
// ── subgraph serialisation ──────────────────────────────────────────────────
|
||||
test('entering a subgraph and returning adds it nested inside the parent node', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
const startNode = data.nodes.find(n => n.label === 'Start')!;
|
||||
expect(startNode.subgraph).toBeDefined();
|
||||
expect(startNode.subgraph!.nodes).toHaveLength(2);
|
||||
expect(startNode.subgraph!.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('subgraph id matches the id of the node that contains it', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
const startNode = data.nodes.find(n => n.label === 'Start')!;
|
||||
expect(startNode.subgraph!.id).toBe(startNode.id);
|
||||
});
|
||||
|
||||
test('node without a subgraph has no subgraph key in the JSON', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
const endNode = data.nodes.find(n => n.label === 'End')!;
|
||||
expect(endNode.subgraph).toBeUndefined();
|
||||
});
|
||||
|
||||
test('nodes added inside a subgraph are serialised in the nested subgraph', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
// Add one extra node inside the subgraph
|
||||
await nodeByLabel(page, 'End').click();
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
const data = await getSavedJson(page);
|
||||
const startNode = data.nodes.find(n => n.label === 'Start')!;
|
||||
expect(startNode.subgraph!.nodes).toHaveLength(3);
|
||||
expect(startNode.subgraph!.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('saving from inside a subgraph includes the full hierarchy from root', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start');
|
||||
// Still inside the subgraph when saving
|
||||
const data = await getSavedJson(page);
|
||||
// Root must still be present
|
||||
expect(data.id).toBe('main');
|
||||
expect(data.nodes.map(n => n.label)).toContain('Start');
|
||||
// And the subgraph must be nested
|
||||
const startNode = data.nodes.find(n => n.label === 'Start')!;
|
||||
expect(startNode.subgraph).toBeDefined();
|
||||
});
|
||||
});
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -5,8 +5,10 @@ import type { BreadcrumbItemType } from 'antd/es/breadcrumb/Breadcrumb';
|
||||
import { useKeysdownStore } from './stores/ArrayStore';
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined
|
||||
MenuUnfoldOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { saveConceptSketch } from './utils/saveGraph';
|
||||
import { useGraphLayersTreeStore } from './stores/TreeStore';
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
@@ -67,6 +69,18 @@ const App: React.FC = () => {
|
||||
color: colorBgContainer,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveConceptSketch}
|
||||
title="Save as JSON"
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: colorBgContainer,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Breadcrumb items={graphLevel} />
|
||||
</Space>
|
||||
|
||||
53
src/utils/saveGraph.ts
Normal file
53
src/utils/saveGraph.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { GraphModel } from '../components/Graph';
|
||||
import { useGraphsStore } from '../stores/GraphsStore';
|
||||
|
||||
interface SerializedEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface SerializedNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
subgraph?: SerializedGraph;
|
||||
}
|
||||
|
||||
interface SerializedGraph {
|
||||
id: string;
|
||||
nodes: SerializedNode[];
|
||||
edges: SerializedEdge[];
|
||||
}
|
||||
|
||||
function serializeGraph(graphId: string, graphsById: Map<string, GraphModel>): SerializedGraph | null {
|
||||
const graph = graphsById.get(graphId);
|
||||
if (!graph) return null;
|
||||
|
||||
return {
|
||||
id: graphId,
|
||||
nodes: graph.nodes.map(node => {
|
||||
const serialized: SerializedNode = { id: node.id, label: node.label };
|
||||
if (graphsById.has(node.id)) {
|
||||
serialized.subgraph = serializeGraph(node.id, graphsById) ?? undefined;
|
||||
}
|
||||
return serialized;
|
||||
}),
|
||||
edges: graph.edges.map(edge => ({ id: edge.id, from: edge.from, to: edge.to })),
|
||||
};
|
||||
}
|
||||
|
||||
export function saveConceptSketch(): void {
|
||||
const state = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
|
||||
const data = serializeGraph('main', state.graphsById);
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'concept-sketch.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user