@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.
Files changed (36) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +136 -103
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -5
  5. package/src/audit-log/Page.test.tsx +53 -0
  6. package/src/audit-log/Page.tsx +13 -5
  7. package/src/audit-log/template.meta.ts +1 -1
  8. package/src/help-home/Page.tsx +3 -1
  9. package/src/help-home/data.ts +3 -4
  10. package/src/incident-board/Page.tsx +6 -1
  11. package/src/incident-board/data.ts +9 -3
  12. package/src/index.ts +1 -1
  13. package/src/keyboard-shortcuts/Page.test.tsx +39 -1
  14. package/src/keyboard-shortcuts/ShortcutSheet.tsx +4 -1
  15. package/src/keyboard-shortcuts/data.ts +29 -14
  16. package/src/live-event-stream/Page.test.tsx +8 -4
  17. package/src/resource-detail/Page.labels.test.tsx +67 -0
  18. package/src/resource-detail/Page.tsx +18 -7
  19. package/src/resource-detail/examples/Labels.tsx +18 -0
  20. package/src/resource-detail/examples/Spanish.tsx +11 -0
  21. package/src/resource-detail/template.meta.ts +1 -1
  22. package/src/resource-gallery/Page.tsx +3 -1
  23. package/src/resource-gallery/data.ts +3 -4
  24. package/src/resource-index/Page.tsx +3 -1
  25. package/src/resource-index/data.ts +7 -4
  26. package/src/search-results/Page.tsx +5 -5
  27. package/src/settings-profile/fixtures/editable.snap +1 -1
  28. package/src/shared/ConsoleShell.test.tsx +13 -0
  29. package/src/shared/ConsoleShell.tsx +35 -30
  30. package/src/test/match-text.test.ts +51 -0
  31. package/src/usage-analytics/Page.tsx +3 -1
  32. package/src/usage-analytics/data.ts +9 -3
  33. package/src/workflow-builder/Page.test.tsx +103 -2
  34. package/src/workflow-builder/Page.tsx +34 -8
  35. package/src/workflow-builder/examples/SaveGraph.tsx +17 -0
  36. 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 { ConsoleEntry } from '@gntik-ai/blocks';
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 [savedNote, setSavedNote] = useState(flowBuilderContent.lastSaved);
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
- setSavedNote('All changes saved');
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
  };