@gntik-ai/templates 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +13 -5
- package/dist/index.js +136 -103
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/audit-log/Page.test.tsx +53 -0
- package/src/audit-log/Page.tsx +13 -5
- package/src/audit-log/template.meta.ts +1 -1
- package/src/help-home/Page.tsx +3 -1
- package/src/help-home/data.ts +3 -4
- package/src/incident-board/Page.tsx +6 -1
- package/src/incident-board/data.ts +9 -3
- package/src/index.ts +1 -1
- package/src/keyboard-shortcuts/Page.test.tsx +39 -1
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +4 -1
- package/src/keyboard-shortcuts/data.ts +29 -14
- package/src/live-event-stream/Page.test.tsx +8 -4
- package/src/resource-detail/Page.labels.test.tsx +67 -0
- package/src/resource-detail/Page.tsx +18 -7
- package/src/resource-detail/examples/Labels.tsx +18 -0
- package/src/resource-detail/examples/Spanish.tsx +11 -0
- package/src/resource-detail/template.meta.ts +1 -1
- package/src/resource-gallery/Page.tsx +3 -1
- package/src/resource-gallery/data.ts +3 -4
- package/src/resource-index/Page.tsx +3 -1
- package/src/resource-index/data.ts +7 -4
- package/src/search-results/Page.tsx +5 -5
- package/src/settings-profile/fixtures/editable.snap +1 -1
- package/src/shared/ConsoleShell.test.tsx +13 -0
- package/src/shared/ConsoleShell.tsx +35 -30
- package/src/test/match-text.test.ts +51 -0
- package/src/usage-analytics/Page.tsx +3 -1
- package/src/usage-analytics/data.ts +9 -3
- package/src/workflow-builder/Page.test.tsx +103 -2
- package/src/workflow-builder/Page.tsx +34 -8
- package/src/workflow-builder/examples/SaveGraph.tsx +17 -0
- package/src/workflow-builder/template.meta.ts +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
1
|
+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
4
|
import { useState } from 'react';
|
|
5
|
-
import type
|
|
5
|
+
import { builderNodes, builderEdges, type ConsoleEntry, type FlowGraph } from '@gntik-ai/blocks';
|
|
6
6
|
import FlowBuilderPage from './Page';
|
|
7
7
|
|
|
8
8
|
// React Flow in jsdom reads the zoom from DOMMatrixReadOnly and node sizes from offset*.
|
|
@@ -19,6 +19,107 @@ beforeAll(() => {
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
describe('FlowBuilderPage', () => {
|
|
22
|
+
it('keeps nodes and edges as initial values when uncontrolled', async () => {
|
|
23
|
+
const onSave = vi.fn<(graph: FlowGraph) => void>();
|
|
24
|
+
const { rerender } = render(<FlowBuilderPage nodes={builderNodes} edges={builderEdges} onSave={onSave} />);
|
|
25
|
+
rerender(<FlowBuilderPage nodes={[]} edges={[]} onSave={onSave} />);
|
|
26
|
+
expect(screen.getByText('5 nodes · 4 edges')).toBeInTheDocument();
|
|
27
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
28
|
+
expect(onSave.mock.calls[0]?.[0].nodes).toHaveLength(5);
|
|
29
|
+
expect(onSave.mock.calls[0]?.[0].edges).toHaveLength(4);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('saves the controlled graph on screen when the host ignores a reported edit', async () => {
|
|
33
|
+
const graph = {
|
|
34
|
+
nodes: builderNodes.map((node) => ({
|
|
35
|
+
...node,
|
|
36
|
+
selected: node.id === 'validate',
|
|
37
|
+
dragging: false,
|
|
38
|
+
measured: { width: 208, height: 90 },
|
|
39
|
+
})),
|
|
40
|
+
edges: builderEdges,
|
|
41
|
+
};
|
|
42
|
+
const changed = vi.fn();
|
|
43
|
+
const onSave = vi.fn<(graph: FlowGraph) => void>();
|
|
44
|
+
render(<FlowBuilderPage graph={graph} onGraphChange={changed} onSave={onSave} />);
|
|
45
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), { target: { value: 'Ignored edit' } });
|
|
46
|
+
expect(changed).toHaveBeenCalledOnce();
|
|
47
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
48
|
+
const saved = onSave.mock.calls[0]![0];
|
|
49
|
+
expect(saved.nodes.find((node) => node.id === 'validate')?.data.title).toBe('Validate payload');
|
|
50
|
+
for (const node of saved.nodes) {
|
|
51
|
+
expect(node).not.toHaveProperty('selected');
|
|
52
|
+
expect(node).not.toHaveProperty('dragging');
|
|
53
|
+
expect(node).not.toHaveProperty('measured');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps edits made during a pending save unsaved', async () => {
|
|
58
|
+
let finish = () => {};
|
|
59
|
+
const onSave = () =>
|
|
60
|
+
new Promise<void>((resolve) => {
|
|
61
|
+
finish = resolve;
|
|
62
|
+
});
|
|
63
|
+
render(<FlowBuilderPage onSave={onSave} />);
|
|
64
|
+
fireEvent.click(screen.getByTestId('rf__node-validate'));
|
|
65
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), { target: { value: 'First' } });
|
|
66
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
67
|
+
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true');
|
|
68
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), { target: { value: 'Second' } });
|
|
69
|
+
await act(async () => finish());
|
|
70
|
+
expect(screen.getByText('You have unsaved changes')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByRole('button', { name: 'Save' })).not.toHaveAttribute('aria-busy', 'true');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('marks edits unsaved, marks a resolved save saved and leaves failed saves unsaved', async () => {
|
|
75
|
+
const onSave = vi
|
|
76
|
+
.fn<(graph: FlowGraph) => Promise<void>>()
|
|
77
|
+
.mockResolvedValueOnce()
|
|
78
|
+
.mockRejectedValueOnce(new Error('Offline'));
|
|
79
|
+
render(<FlowBuilderPage onSave={onSave} />);
|
|
80
|
+
fireEvent.click(screen.getByTestId('rf__node-validate'));
|
|
81
|
+
const title = screen.getByRole('textbox', { name: 'Title' });
|
|
82
|
+
fireEvent.change(title, { target: { value: 'Check order' } });
|
|
83
|
+
expect(screen.getByText('You have unsaved changes')).toBeInTheDocument();
|
|
84
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
85
|
+
expect(await screen.findByText('All changes saved')).toBeInTheDocument();
|
|
86
|
+
fireEvent.change(title, { target: { value: 'Check again' } });
|
|
87
|
+
expect(screen.getByText('You have unsaved changes')).toBeInTheDocument();
|
|
88
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
89
|
+
expect(screen.getByText('You have unsaved changes')).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByRole('button', { name: 'Save' })).not.toHaveAttribute('aria-busy', 'true');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('saves the initial sample graph before an edit', async () => {
|
|
94
|
+
const onSave = vi.fn<(graph: FlowGraph) => Promise<void>>(() => Promise.resolve());
|
|
95
|
+
render(<FlowBuilderPage onSave={onSave} />);
|
|
96
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
97
|
+
expect(onSave).toHaveBeenCalledOnce();
|
|
98
|
+
expect(onSave.mock.calls[0]).toEqual([
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
nodes: expect.arrayContaining([expect.objectContaining({ id: 'validate' })]),
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
expect(onSave.mock.calls[0]?.[0].nodes).toHaveLength(5);
|
|
104
|
+
expect(onSave.mock.calls[0]?.[0].edges).toHaveLength(4);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('saves the edited title in the graph', async () => {
|
|
108
|
+
const onSave = vi.fn(() => Promise.resolve());
|
|
109
|
+
render(<FlowBuilderPage onSave={onSave} />);
|
|
110
|
+
fireEvent.click(screen.getByTestId('rf__node-validate'));
|
|
111
|
+
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), { target: { value: 'Check order' } });
|
|
112
|
+
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
113
|
+
expect(onSave).toHaveBeenCalledExactlyOnceWith(
|
|
114
|
+
expect.objectContaining({
|
|
115
|
+
nodes: expect.arrayContaining([
|
|
116
|
+
expect.objectContaining({ id: 'validate', data: expect.objectContaining({ title: 'Check order' }) }),
|
|
117
|
+
]),
|
|
118
|
+
edges: expect.any(Array),
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
22
123
|
it('renders the editor chrome and the flow builder', { timeout: 15000 }, async () => {
|
|
23
124
|
render(<FlowBuilderPage />);
|
|
24
125
|
expect(screen.getByRole('main', { name: 'Order sync editor' })).toBeInTheDocument();
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CodePanel,
|
|
3
|
+
builderNodes,
|
|
4
|
+
builderEdges,
|
|
5
|
+
cleanFlowGraph,
|
|
3
6
|
type CodePanelProps,
|
|
4
7
|
FlowBuilder,
|
|
5
8
|
type ConsoleEntry,
|
|
6
9
|
type FlowBuilderProps,
|
|
7
10
|
type FlowEdge,
|
|
11
|
+
type FlowGraph,
|
|
8
12
|
type FlowNode,
|
|
9
13
|
type FlowNodeKind,
|
|
10
14
|
type PaletteItem,
|
|
11
15
|
} from '@gntik-ai/blocks';
|
|
12
16
|
import { Save, Upload } from '@gntik-ai/icons';
|
|
13
|
-
import { Breadcrumbs, Button, CanvasLayout, StatusTag, type BreadcrumbItem } from '@gntik-ai/ui';
|
|
14
|
-
import { useState } from 'react';
|
|
17
|
+
import { Breadcrumbs, Button, CanvasLayout, StatusTag, useI18n, type BreadcrumbItem } from '@gntik-ai/ui';
|
|
18
|
+
import { useRef, useState } from 'react';
|
|
15
19
|
import { flowBuilderBreadcrumbs, flowBuilderContent } from './data';
|
|
16
20
|
|
|
17
21
|
export interface FlowBuilderPageProps<Kind extends string = FlowNodeKind> extends Pick<
|
|
@@ -25,6 +29,8 @@ export interface FlowBuilderPageProps<Kind extends string = FlowNodeKind> extend
|
|
|
25
29
|
| 'mode'
|
|
26
30
|
| 'nodeStatus'
|
|
27
31
|
| 'onNodeSelect'
|
|
32
|
+
| 'graph'
|
|
33
|
+
| 'onGraphChange'
|
|
28
34
|
> {
|
|
29
35
|
/** Optional companion code panel; run mode forces readOnly. */
|
|
30
36
|
codePanel?: CodePanelProps;
|
|
@@ -43,7 +49,7 @@ export interface FlowBuilderPageProps<Kind extends string = FlowNodeKind> extend
|
|
|
43
49
|
defaultConsoleEntries: ConsoleEntry[];
|
|
44
50
|
onConsoleChange: (entries: ConsoleEntry[]) => void;
|
|
45
51
|
onConsoleClear: () => void;
|
|
46
|
-
onSave: () => void | Promise<void>;
|
|
52
|
+
onSave: (graph: FlowGraph<Kind>) => void | Promise<void>;
|
|
47
53
|
onPublish: () => void;
|
|
48
54
|
breadcrumbs: BreadcrumbItem[];
|
|
49
55
|
/** Builder height in px on wide screens. */
|
|
@@ -61,8 +67,8 @@ export default function FlowBuilderPage<Kind extends string = FlowNodeKind>(
|
|
|
61
67
|
const {
|
|
62
68
|
title = flowBuilderContent.title,
|
|
63
69
|
status = flowBuilderContent.status,
|
|
64
|
-
nodes,
|
|
65
|
-
edges,
|
|
70
|
+
nodes = builderNodes,
|
|
71
|
+
edges = builderEdges,
|
|
66
72
|
palette,
|
|
67
73
|
onRun,
|
|
68
74
|
consoleEntries,
|
|
@@ -80,19 +86,32 @@ export default function FlowBuilderPage<Kind extends string = FlowNodeKind>(
|
|
|
80
86
|
mode,
|
|
81
87
|
nodeStatus,
|
|
82
88
|
onNodeSelect,
|
|
89
|
+
graph,
|
|
90
|
+
onGraphChange,
|
|
83
91
|
codePanel,
|
|
84
92
|
breadcrumbs = flowBuilderBreadcrumbs,
|
|
85
93
|
height = 640,
|
|
86
94
|
fullScreen = true,
|
|
87
95
|
} = props;
|
|
88
96
|
const [saving, setSaving] = useState(false);
|
|
89
|
-
const [
|
|
97
|
+
const [initialGraph] = useState<FlowGraph<Kind>>(() => ({ nodes, edges }));
|
|
98
|
+
const [latestGraph, setLatestGraph] = useState<FlowGraph<Kind>>();
|
|
99
|
+
const [saveState, setSaveState] = useState<'initial' | 'unsaved' | 'saved'>('initial');
|
|
100
|
+
const revision = useRef(0);
|
|
101
|
+
const { t } = useI18n();
|
|
102
|
+
const savedNote =
|
|
103
|
+
saveState === 'initial'
|
|
104
|
+
? flowBuilderContent.lastSaved
|
|
105
|
+
: t(saveState === 'unsaved' ? 'actionBar.unsaved' : 'actionBar.saved');
|
|
90
106
|
|
|
91
107
|
const save = async () => {
|
|
92
108
|
setSaving(true);
|
|
109
|
+
const savedRevision = revision.current;
|
|
93
110
|
try {
|
|
94
|
-
await onSave?.();
|
|
95
|
-
|
|
111
|
+
await onSave?.(cleanFlowGraph(graph ?? latestGraph ?? initialGraph));
|
|
112
|
+
if (revision.current === savedRevision) setSaveState('saved');
|
|
113
|
+
} catch {
|
|
114
|
+
// The host owns persistence errors; retain the unsaved note and allow a retry.
|
|
96
115
|
} finally {
|
|
97
116
|
setSaving(false);
|
|
98
117
|
}
|
|
@@ -133,6 +152,13 @@ export default function FlowBuilderPage<Kind extends string = FlowNodeKind>(
|
|
|
133
152
|
title={title}
|
|
134
153
|
nodes={nodes}
|
|
135
154
|
edges={edges}
|
|
155
|
+
graph={graph}
|
|
156
|
+
onGraphChange={(next, change) => {
|
|
157
|
+
revision.current += 1;
|
|
158
|
+
setLatestGraph(next);
|
|
159
|
+
setSaveState('unsaved');
|
|
160
|
+
onGraphChange?.(next, change);
|
|
161
|
+
}}
|
|
136
162
|
palette={palette}
|
|
137
163
|
onRun={onRun}
|
|
138
164
|
consoleEntries={consoleEntries}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { builderNodes, builderEdges, cleanFlowGraph, type FlowGraph } from '@gntik-ai/blocks';
|
|
3
|
+
import WorkflowBuilderPage from '../Page';
|
|
4
|
+
|
|
5
|
+
/** Save the graph currently on screen to browser storage; products own persistence. */
|
|
6
|
+
export default function SaveGraph() {
|
|
7
|
+
const [graph, setGraph] = useState<FlowGraph>(() => cleanFlowGraph({ nodes: builderNodes, edges: builderEdges }));
|
|
8
|
+
return (
|
|
9
|
+
<WorkflowBuilderPage
|
|
10
|
+
graph={graph}
|
|
11
|
+
onGraphChange={setGraph}
|
|
12
|
+
onSave={(next) => {
|
|
13
|
+
localStorage.setItem('workflow-builder-example', JSON.stringify(next));
|
|
14
|
+
}}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -6,7 +6,7 @@ export const meta: TemplateMeta = {
|
|
|
6
6
|
priority: 'P3',
|
|
7
7
|
status: 'experimental',
|
|
8
8
|
description:
|
|
9
|
-
'Full-screen workflow editor with optional nodeTypes, onConnectEdge, onConnect, isValidConnection, onConnectionRejected, validationIssues, mode="run", nodeStatus and onNodeSelect passed to FlowBuilder; codePanel passes readOnly, markers, onValidityChange, onMonacoReady and loader to an optional CodePanel (branch, parallel, sequence and worker-backed YAML examples). The app must import \'@gntik-ai/flow/styles.css\' once for the canvas styles. Optional nodeTypes, onConnectEdge, onConnect, isValidConnection, onConnectionRejected, validationIssues, mode, nodeStatus and onNodeSelect pass to FlowBuilder unchanged (see its branch, parallel and sequence examples). mode="run" hides Save, Publish and the saved notice, and preserves focus and node selection. Optional codePanel renders a companion CodePanel and passes all its extensions: readOnly, markers, onValidityChange, onMonacoReady and loader; run mode forces its readOnly to true. For example, WorkflowBuilderPage mode="run" nodeStatus={{step:"running"}} onNodeSelect={openDetails}; codePanel={{files,problems:[],loader:yamlLoader,markers}} uses consumer-owned same-origin YAML workers and inline schemas.',
|
|
9
|
+
'onSave(graph) receives the graph on screen (controlled graph, latest reported edit, or initial starter graph), without selected, dragging or measured node fields. Zero-argument handlers remain valid. Optional graph and onGraphChange pass through to FlowBuilder. Committed edits show actionBar.unsaved, a resolved save shows actionBar.saved, and a rejected save retains unsaved status and releases loading. Edits during a pending save remain unsaved. examples/SaveGraph.tsx shows a controlled graph and onSave={(graph) => localStorage.setItem("workflow-builder-example", JSON.stringify(graph))}; hosts own persistence. Full-screen workflow editor with optional nodeTypes, onConnectEdge, onConnect, isValidConnection, onConnectionRejected, validationIssues, mode="run", nodeStatus and onNodeSelect passed to FlowBuilder; codePanel passes readOnly, markers, onValidityChange, onMonacoReady and loader to an optional CodePanel (branch, parallel, sequence and worker-backed YAML examples). The app must import \'@gntik-ai/flow/styles.css\' once for the canvas styles. Optional nodeTypes, onConnectEdge, onConnect, isValidConnection, onConnectionRejected, validationIssues, mode, nodeStatus and onNodeSelect pass to FlowBuilder unchanged (see its branch, parallel and sequence examples). mode="run" hides Save, Publish and the saved notice, and preserves focus and node selection. Optional codePanel renders a companion CodePanel and passes all its extensions: readOnly, markers, onValidityChange, onMonacoReady and loader; run mode forces its readOnly to true. For example, WorkflowBuilderPage mode="run" nodeStatus={{step:"running"}} onNodeSelect={openDetails}; codePanel={{files,problems:[],loader:yamlLoader,markers}} uses consumer-owned same-origin YAML workers and inline schemas.',
|
|
10
10
|
layout: 'CanvasLayout',
|
|
11
11
|
blocks: ['flow-builder', 'code-panel'],
|
|
12
12
|
};
|