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
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';

export type Slice<T = unknown> = {
serialize: () => T;
restore: (state: T) => void;
};

export type TabSnapshot = Record<string, unknown>;

export type DecisionGraphSnapshot = {
graph?: Record<string, unknown>;
tabs?: Record<string, TabSnapshot>;
};

type Registry = {
registerGraph: (key: string, slice: Slice) => () => void;
registerTab: (tabId: string, key: string, slice: Slice) => () => void;
serialize: () => DecisionGraphSnapshot;
restore: (snapshot: DecisionGraphSnapshot) => void;
};

const SerializerContext = createContext<Registry | null>(null);

export const SerializerProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const graphSlices = useRef(new Map<string, Slice>());
const tabSlices = useRef(new Map<string, Map<string, Slice>>());
const pendingGraph = useRef(new Map<string, unknown>());
const pendingTabs = useRef(new Map<string, Map<string, unknown>>());

const registry = useMemo<Registry>(() => {
const tryRestore = (slice: Slice, value: unknown, label: string) => {
try {
slice.restore(value);
} catch (err) {
console.warn(`Failed to restore "${label}"`, err);
}
};

return {
registerGraph: (key, slice) => {
graphSlices.current.set(key, slice);
if (pendingGraph.current.has(key)) {
const pending = pendingGraph.current.get(key);
pendingGraph.current.delete(key);
tryRestore(slice, pending, `graph.${key}`);
}
return () => {
if (graphSlices.current.get(key) === slice) graphSlices.current.delete(key);
};
},
registerTab: (tabId, key, slice) => {
if (!tabSlices.current.has(tabId)) tabSlices.current.set(tabId, new Map());
tabSlices.current.get(tabId)!.set(key, slice);

const pendingForTab = pendingTabs.current.get(tabId);
if (pendingForTab?.has(key)) {
const pending = pendingForTab.get(key);
pendingForTab.delete(key);
if (pendingForTab.size === 0) pendingTabs.current.delete(tabId);
tryRestore(slice, pending, `tabs.${tabId}.${key}`);
}
return () => {
const bag = tabSlices.current.get(tabId);
if (bag?.get(key) === slice) {
bag.delete(key);
if (bag.size === 0) tabSlices.current.delete(tabId);
}
};
},
serialize: () => {
const snapshot: DecisionGraphSnapshot = {};
const graph: Record<string, unknown> = {};
for (const [key, slice] of graphSlices.current) {
try {
const value = slice.serialize();
if (value !== undefined) graph[key] = value;
} catch (err) {
console.warn(`Failed to serialize graph.${key}`, err);
}
}
if (Object.keys(graph).length > 0) snapshot.graph = graph;

const tabs: Record<string, TabSnapshot> = {};
for (const [tabId, bag] of tabSlices.current) {
const tabBag: TabSnapshot = {};
for (const [key, slice] of bag) {
try {
const value = slice.serialize();
if (value !== undefined) tabBag[key] = value;
} catch (err) {
console.warn(`Failed to serialize tabs.${tabId}.${key}`, err);
}
}
if (Object.keys(tabBag).length > 0) tabs[tabId] = tabBag;
}
if (Object.keys(tabs).length > 0) snapshot.tabs = tabs;

return snapshot;
},
restore: (snapshot) => {
pendingGraph.current.clear();
pendingTabs.current.clear();

for (const [key, value] of Object.entries(snapshot?.graph ?? {})) {
const slice = graphSlices.current.get(key);
if (slice) tryRestore(slice, value, `graph.${key}`);
else pendingGraph.current.set(key, value);
}

for (const [tabId, bag] of Object.entries(snapshot?.tabs ?? {})) {
for (const [key, value] of Object.entries(bag ?? {})) {
const slice = tabSlices.current.get(tabId)?.get(key);
if (slice) {
tryRestore(slice, value, `tabs.${tabId}.${key}`);
} else {
if (!pendingTabs.current.has(tabId)) pendingTabs.current.set(tabId, new Map());
pendingTabs.current.get(tabId)!.set(key, value);
}
}
}
},
};
}, []);

return <SerializerContext.Provider value={registry}>{children}</SerializerContext.Provider>;
};

export const useSerializerRegistry = (): Registry | null => useContext(SerializerContext);

function useSlice<T>(
registerFn: ((slice: Slice) => () => void) | null,
slice: Slice<T>,
deps: React.DependencyList,
): void {
const sliceRef = useRef(slice);
sliceRef.current = slice;

useEffect(() => {
if (!registerFn) return;
return registerFn({
serialize: () => sliceRef.current.serialize(),
restore: (state) => sliceRef.current.restore(state as T),
});
}, deps);
}

export function useGraphSerializer<T = unknown>(
key: string | null | undefined,
slice: Slice<T>,
deps: React.DependencyList = [],
): void {
const registry = useContext(SerializerContext);
const registerFn = registry && key ? (s: Slice) => registry.registerGraph(key, s) : null;
useSlice(registerFn, slice, [registry, key, ...deps]);
}

export function useTabSerializer<T = unknown>(
tabId: string | null | undefined,
key: string | null | undefined,
slice: Slice<T>,
deps: React.DependencyList = [],
): void {
const registry = useContext(SerializerContext);
const registerFn = registry && tabId && key ? (s: Slice) => registry.registerTab(tabId, key, s) : null;
useSlice(registerFn, slice, [registry, tabId, key, ...deps]);
}
185 changes: 183 additions & 2 deletions packages/jdm-editor/src/components/decision-graph/dg.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { ApartmentOutlined, ApiOutlined, LeftOutlined, PlayCircleOutlined, RightOutlined } from '@ant-design/icons';
import type { Meta, StoryObj } from '@storybook/react';
import { Button, Select, Space } from 'antd';
import { Button, Select, Space, Typography } from 'antd';
import json5 from 'json5';
import React, { useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { P, match } from 'ts-pattern';

import type { DictionaryMap } from '../../theme';
import type { JdmUiMode } from '../decision-table/context/dt-store.context';
import type { DecisionGraphSnapshot } from './context/serializer.context';
import type { DecisionGraphRef } from './dg';
import { DecisionGraph } from './dg';
import type { DecisionGraphType } from './dg-types';
Expand Down Expand Up @@ -509,6 +510,186 @@ const businessModeGraph: DecisionGraphType = {
],
};

const buildLargeSerializeGraph = (): DecisionGraphType => {
const inputId = 'serialize-input';
const outputId = 'serialize-output';
const tableId = 'serialize-table';
const expressionId = 'serialize-expression';
const functionId = 'serialize-function';

const tableInputs = [
{ id: 'in_weight', field: 'cart.weight', name: 'Cart Weight (Kg)' },
{ id: 'in_country', field: 'customer.country', name: 'Customer Country' },
{ id: 'in_tier', field: 'customer.tier', name: 'Customer Tier' },
];
const tableOutputs = [{ id: 'out_fee', field: 'shippingFee', name: 'Shipping Fee' }];

const rules = Array.from({ length: 100 }, (_, i) => ({
_id: `rule-${i + 1}`,
_description: `Rule ${i + 1}: shipping fee for tier ${i % 5}`,
in_weight: `[${i * 2}..${i * 2 + 10}]`,
in_country: i % 2 === 0 ? '"US"' : '"CA"',
in_tier: `"tier${i % 5}"`,
out_fee: `${10 + i}`,
}));

const expressions = Array.from({ length: 50 }, (_, i) => ({
id: `expr-${i + 1}`,
key: `field_${i + 1}`,
value: `customer.score + ${i} * order.total / 100`,
}));

const functionSource = [
"import zen from 'zen';",
'',
'/** @type {Handler} **/',
'export const handler = async (input) => {',
' const lines = [];',
...Array.from({ length: 80 }, (_, i) => ` lines.push('line ${i + 1}: ' + JSON.stringify(input));`),
'',
' const result = {',
' received: input,',
' processedAt: new Date().toISOString(),',
' lineCount: lines.length,',
" summary: lines.join('\\n'),",
' };',
'',
' return result;',
'};',
].join('\n');

return {
nodes: [
{ id: inputId, type: 'inputNode', position: { x: 80, y: 240 }, name: 'Request' },
{
id: tableId,
type: 'decisionTableNode',
position: { x: 360, y: 100 },
name: 'Big Table',
content: { hitPolicy: 'first', inputs: tableInputs, outputs: tableOutputs, rules },
},
{
id: expressionId,
type: 'expressionNode',
position: { x: 360, y: 360 },
name: 'Big Expression',
content: { expressions, passThrough: true, executionMode: 'single' },
},
{
id: functionId,
type: 'functionNode',
position: { x: 360, y: 600 },
name: 'Big Function',
content: { source: functionSource },
},
{ id: outputId, type: 'outputNode', position: { x: 720, y: 360 }, name: 'Response' },
],
edges: [
{ id: 'e-in-table', type: 'edge', sourceId: inputId, targetId: tableId },
{ id: 'e-in-expr', type: 'edge', sourceId: inputId, targetId: expressionId },
{ id: 'e-in-func', type: 'edge', sourceId: inputId, targetId: functionId },
{ id: 'e-table-out', type: 'edge', sourceId: tableId, targetId: outputId },
{ id: 'e-expr-out', type: 'edge', sourceId: expressionId, targetId: outputId },
{ id: 'e-func-out', type: 'edge', sourceId: functionId, targetId: outputId },
],
};
};

export const Serialize: Story = {
render: () => <DecisionGraphSerializeTest />,
};

const DecisionGraphSerializeTest: React.FC = () => {
const ref = useRef<DecisionGraphRef>(null);
const [value, setValue] = useState<DecisionGraphType>(() => buildLargeSerializeGraph());
const [snapshot, setSnapshot] = useState<DecisionGraphSnapshot | null>(null);
const [mountKey, setMountKey] = useState(0);
const [showJson, setShowJson] = useState(true);
const pendingRestoreRef = useRef<DecisionGraphSnapshot | null>(null);

useEffect(() => {
if (!pendingRestoreRef.current) return;
const snap = pendingRestoreRef.current;
pendingRestoreRef.current = null;
requestAnimationFrame(() => ref.current?.restore(snap));
}, [mountKey]);

const handleSave = () => {
const snap = ref.current?.serialize() ?? {};
setSnapshot(snap);
};

const handleRestore = () => {
if (snapshot) ref.current?.restore(snapshot);
};

const handleRemountAndRestore = () => {
pendingRestoreRef.current = snapshot;
setMountKey((k) => k + 1);
};

const handleClear = () => setSnapshot(null);

return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 8, borderBottom: '1px solid #eee' }}>
<Space wrap>
<Button size='small' type='primary' onClick={handleSave}>
Serialize
</Button>
<Button size='small' disabled={!snapshot} onClick={handleRestore}>
Restore (live)
</Button>
<Button size='small' disabled={!snapshot} onClick={handleRemountAndRestore}>
Remount + Restore
</Button>
<Button size='small' disabled={!snapshot} onClick={handleClear}>
Clear snapshot
</Button>
<Button size='small' onClick={() => setShowJson((v) => !v)}>
{showJson ? 'Hide' : 'Show'} JSON
</Button>
<Typography.Text type='secondary' style={{ fontSize: 12 }}>
{snapshot ? 'Snapshot saved' : 'No snapshot'}
</Typography.Text>
</Space>
</div>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<DecisionGraph
key={mountKey}
ref={ref}
value={value}
onChange={(val) => setValue(val)}
components={components}
customNodes={customNodes}
/>
</div>
{showJson && (
<div
style={{
width: 360,
borderLeft: '1px solid #eee',
padding: 8,
overflow: 'auto',
fontFamily: 'var(--mono-font-family, monospace)',
fontSize: 11,
background: '#fafafa',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
Snapshot
</Typography.Text>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{snapshot ? JSON.stringify(snapshot, null, 2) : '// click "Serialize" to capture'}
</pre>
</div>
)}
</div>
</div>
);
};

export const BusinessMode: Story = {
render: () => {
const [value, setValue] = useState<DecisionGraphType>(businessModeGraph);
Expand Down
Loading
Loading