@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.70
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/.agents/rules/components.md +4 -0
- package/AGENTS.md +3 -1
- package/client/components/flow-diagram-css.d.ts +2 -0
- package/client/components/flow-diagram.tsx +674 -0
- package/client/components/flow-layout.ts +250 -0
- package/client/components/lifecycle-diagram-model.ts +217 -0
- package/client/components/lifecycle-diagram.tsx +310 -0
- package/client/extensions/nocobase-workflow-management/README.md +70 -0
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
- package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
- package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
- package/client/locales/system/en-US.ts +22 -0
- package/client/locales/system/settings-en-US.ts +1 -0
- package/client/locales/system/settings-zh-CN.ts +1 -0
- package/client/locales/system/zh-CN.ts +22 -0
- package/client/pages/settings/page-permission-titles.ts +7 -0
- package/client/pages/settings/page-permissions.ts +42 -0
- package/client/pages/settings/role-form.tsx +8 -29
- package/package.json +13 -11
- package/tests/logic/page-permission-titles.test.ts +223 -0
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Background,
|
|
12
|
+
MiniMap,
|
|
13
|
+
Panel,
|
|
14
|
+
useReactFlow,
|
|
15
|
+
useViewport,
|
|
16
|
+
BaseEdge,
|
|
17
|
+
EdgeText,
|
|
18
|
+
Handle,
|
|
19
|
+
MarkerType,
|
|
20
|
+
Position,
|
|
21
|
+
ReactFlow,
|
|
22
|
+
type Edge,
|
|
23
|
+
type EdgeProps,
|
|
24
|
+
type Node,
|
|
25
|
+
type NodeProps,
|
|
26
|
+
} from '@xyflow/react';
|
|
27
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
28
|
+
import { cn } from 'cn';
|
|
29
|
+
import {
|
|
30
|
+
CheckIcon,
|
|
31
|
+
CircleDotIcon,
|
|
32
|
+
FlagIcon,
|
|
33
|
+
GitBranchIcon,
|
|
34
|
+
MaximizeIcon,
|
|
35
|
+
MinusIcon,
|
|
36
|
+
PlayIcon,
|
|
37
|
+
PlusIcon,
|
|
38
|
+
Settings2Icon,
|
|
39
|
+
} from 'lucide-react';
|
|
40
|
+
import { Button } from '#components/ui/button';
|
|
41
|
+
import {
|
|
42
|
+
Tooltip,
|
|
43
|
+
TooltipContent,
|
|
44
|
+
TooltipTrigger,
|
|
45
|
+
} from '#components/ui/tooltip';
|
|
46
|
+
import '@xyflow/react/dist/style.css';
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
layoutFlowDiagram,
|
|
50
|
+
type FlowDiagramDirection,
|
|
51
|
+
type FlowDiagramEdge,
|
|
52
|
+
type FlowDiagramGraph,
|
|
53
|
+
type FlowDiagramLayout,
|
|
54
|
+
type FlowDiagramNode,
|
|
55
|
+
type FlowDiagramPoint,
|
|
56
|
+
} from './flow-layout.js';
|
|
57
|
+
|
|
58
|
+
type DiagramNode = Node<{
|
|
59
|
+
item: FlowDiagramNode;
|
|
60
|
+
direction: FlowDiagramDirection;
|
|
61
|
+
content: ReactNode;
|
|
62
|
+
actions?: ReactNode;
|
|
63
|
+
description?: ReactNode;
|
|
64
|
+
}>;
|
|
65
|
+
type DiagramEdge = Edge<{
|
|
66
|
+
points: readonly FlowDiagramPoint[];
|
|
67
|
+
labelPosition?: FlowDiagramPoint;
|
|
68
|
+
}>;
|
|
69
|
+
|
|
70
|
+
function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
|
|
71
|
+
const { t } = useTranslation();
|
|
72
|
+
const flow = useReactFlow();
|
|
73
|
+
const Icon =
|
|
74
|
+
data.item.kind === 'initial'
|
|
75
|
+
? PlayIcon
|
|
76
|
+
: data.item.kind === 'final'
|
|
77
|
+
? FlagIcon
|
|
78
|
+
: GitBranchIcon;
|
|
79
|
+
const StatusIcon = data.item.highlighted ? CircleDotIcon : CheckIcon;
|
|
80
|
+
const state = data.item.highlighted
|
|
81
|
+
? t('flowDiagram.current', { defaultValue: 'Current' })
|
|
82
|
+
: data.item.visited
|
|
83
|
+
? t('flowDiagram.visited', { defaultValue: 'Visited' })
|
|
84
|
+
: data.item.kind === 'initial'
|
|
85
|
+
? t('flowDiagram.initial', { defaultValue: 'Initial' })
|
|
86
|
+
: data.item.kind === 'final'
|
|
87
|
+
? t('flowDiagram.final', { defaultValue: 'Final' })
|
|
88
|
+
: t('flowDiagram.state', { defaultValue: 'State' });
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
onFocusCapture={(event) => {
|
|
92
|
+
// Readable initial zoom can put controls outside the viewport. Keyboard
|
|
93
|
+
// focus brings their owning node into view without changing the zoom.
|
|
94
|
+
const bounds = event.currentTarget.getBoundingClientRect();
|
|
95
|
+
const canvas = event.currentTarget
|
|
96
|
+
.closest('.react-flow')
|
|
97
|
+
?.getBoundingClientRect();
|
|
98
|
+
const node = flow.getNode(id);
|
|
99
|
+
if (
|
|
100
|
+
canvas &&
|
|
101
|
+
node &&
|
|
102
|
+
(bounds.left < canvas.left ||
|
|
103
|
+
bounds.right > canvas.right ||
|
|
104
|
+
bounds.top < canvas.top ||
|
|
105
|
+
bounds.bottom > canvas.bottom)
|
|
106
|
+
) {
|
|
107
|
+
void flow.setCenter(
|
|
108
|
+
node.position.x + (node.width ?? 232) / 2,
|
|
109
|
+
node.position.y + (node.height ?? 104) / 2,
|
|
110
|
+
{ zoom: flow.getZoom() },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}}
|
|
114
|
+
data-execution-state={
|
|
115
|
+
data.item.highlighted
|
|
116
|
+
? 'current'
|
|
117
|
+
: data.item.visited
|
|
118
|
+
? 'visited'
|
|
119
|
+
: 'pending'
|
|
120
|
+
}
|
|
121
|
+
className={cn(
|
|
122
|
+
'group relative flex h-full w-full flex-col overflow-hidden rounded-xl border border-border bg-linear-to-b from-card to-muted/20 text-sm text-card-foreground shadow-md transition-[box-shadow,border-color] hover:border-primary/50 hover:shadow-lg motion-reduce:transition-none [.selected_&]:ring-2 [.selected_&]:ring-ring [.selected_&]:ring-offset-2 [.selected_&]:ring-offset-background',
|
|
123
|
+
data.item.highlighted && 'border-primary ring-4 ring-primary/10',
|
|
124
|
+
!data.item.highlighted &&
|
|
125
|
+
(data.item.kind === 'initial' || data.item.configurable) &&
|
|
126
|
+
'border-primary/25',
|
|
127
|
+
data.item.visited && !data.item.highlighted && 'border-primary/40',
|
|
128
|
+
data.item.className,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{!data.item.decorative && (
|
|
132
|
+
<div
|
|
133
|
+
aria-hidden
|
|
134
|
+
className={cn(
|
|
135
|
+
'absolute inset-x-0 top-0 h-1 bg-linear-to-r from-border to-border/40',
|
|
136
|
+
data.item.kind === 'initial' && 'from-primary/70 to-primary/20',
|
|
137
|
+
data.item.kind === 'final' && 'from-foreground/50 to-foreground/15',
|
|
138
|
+
data.item.configurable && 'from-primary/60 to-primary/15',
|
|
139
|
+
(data.item.highlighted || data.item.visited) &&
|
|
140
|
+
'from-primary to-primary/50',
|
|
141
|
+
)}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
<Handle
|
|
145
|
+
id='in'
|
|
146
|
+
type='target'
|
|
147
|
+
position={data.direction === 'RIGHT' ? Position.Left : Position.Top}
|
|
148
|
+
className='invisible'
|
|
149
|
+
/>
|
|
150
|
+
{data.item.decorative ? (
|
|
151
|
+
<div className='flex h-full items-center justify-center text-center'>
|
|
152
|
+
{data.content}
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
<div className='flex min-h-0 flex-1 items-center gap-3 px-4 pt-1'>
|
|
157
|
+
<div
|
|
158
|
+
aria-hidden
|
|
159
|
+
className={cn(
|
|
160
|
+
'flex size-9 shrink-0 items-center justify-center rounded-xl border border-border bg-linear-to-br from-muted to-card text-muted-foreground shadow-xs',
|
|
161
|
+
(data.item.kind === 'initial' ||
|
|
162
|
+
data.item.configurable ||
|
|
163
|
+
data.item.visited ||
|
|
164
|
+
data.item.highlighted) &&
|
|
165
|
+
'border-primary/15 from-primary/15 to-primary/5 text-primary',
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<Icon className='size-4' />
|
|
169
|
+
</div>
|
|
170
|
+
<div className='flex min-w-0 flex-col gap-2 leading-snug'>
|
|
171
|
+
<div className='line-clamp-2 font-semibold'>{data.content}</div>
|
|
172
|
+
{data.description && (
|
|
173
|
+
<div className='text-xs text-muted-foreground'>
|
|
174
|
+
{data.description}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div className='flex min-h-8 shrink-0 items-center justify-between gap-2 border-t border-border/60 bg-muted/35 px-4 text-xs text-muted-foreground'>
|
|
180
|
+
<span
|
|
181
|
+
className={cn(
|
|
182
|
+
'inline-flex items-center gap-1.5',
|
|
183
|
+
(data.item.highlighted || data.item.visited) &&
|
|
184
|
+
'font-medium text-primary',
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
{(data.item.highlighted || data.item.visited) && (
|
|
188
|
+
<StatusIcon className='size-3' aria-hidden />
|
|
189
|
+
)}
|
|
190
|
+
{state}
|
|
191
|
+
</span>
|
|
192
|
+
{data.actions ??
|
|
193
|
+
(data.item.configurable && (
|
|
194
|
+
<span className='inline-flex items-center gap-1 rounded-md border border-primary/15 bg-primary/5 px-1.5 py-0.5 text-primary'>
|
|
195
|
+
<Settings2Icon className='size-3' aria-hidden />
|
|
196
|
+
{t('flowDiagram.configurable', {
|
|
197
|
+
defaultValue: 'Parameters',
|
|
198
|
+
})}
|
|
199
|
+
</span>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
<Handle
|
|
205
|
+
id='out'
|
|
206
|
+
type='source'
|
|
207
|
+
position={data.direction === 'RIGHT' ? Position.Right : Position.Bottom}
|
|
208
|
+
className='invisible'
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Keep ELK's routes while rounding their right-angle bends. */
|
|
215
|
+
function routePath(points: readonly FlowDiagramPoint[]): string {
|
|
216
|
+
if (!points.length) return '';
|
|
217
|
+
let path = `M ${points[0].x} ${points[0].y}`;
|
|
218
|
+
for (let index = 1; index < points.length - 1; index++) {
|
|
219
|
+
const before = points[index - 1];
|
|
220
|
+
const point = points[index];
|
|
221
|
+
const after = points[index + 1];
|
|
222
|
+
const incoming = Math.hypot(point.x - before.x, point.y - before.y);
|
|
223
|
+
const outgoing = Math.hypot(after.x - point.x, after.y - point.y);
|
|
224
|
+
if (!incoming || !outgoing) continue;
|
|
225
|
+
const radius = Math.min(8, incoming / 2, outgoing / 2);
|
|
226
|
+
const start = {
|
|
227
|
+
x: point.x + ((before.x - point.x) / incoming) * radius,
|
|
228
|
+
y: point.y + ((before.y - point.y) / incoming) * radius,
|
|
229
|
+
};
|
|
230
|
+
const end = {
|
|
231
|
+
x: point.x + ((after.x - point.x) / outgoing) * radius,
|
|
232
|
+
y: point.y + ((after.y - point.y) / outgoing) * radius,
|
|
233
|
+
};
|
|
234
|
+
path += ` L ${start.x} ${start.y} Q ${point.x} ${point.y} ${end.x} ${end.y}`;
|
|
235
|
+
}
|
|
236
|
+
const last = points.at(-1)!;
|
|
237
|
+
return `${path} L ${last.x} ${last.y}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
|
|
241
|
+
const points = props.data?.points ?? [];
|
|
242
|
+
const path = routePath(points);
|
|
243
|
+
const label = props.data?.labelPosition;
|
|
244
|
+
return (
|
|
245
|
+
<>
|
|
246
|
+
<BaseEdge
|
|
247
|
+
id={props.id}
|
|
248
|
+
path={path}
|
|
249
|
+
markerEnd={props.markerEnd}
|
|
250
|
+
style={props.style}
|
|
251
|
+
/>
|
|
252
|
+
{label && props.label ? (
|
|
253
|
+
<EdgeText
|
|
254
|
+
x={label.x}
|
|
255
|
+
y={label.y}
|
|
256
|
+
label={props.label}
|
|
257
|
+
labelStyle={{
|
|
258
|
+
fill: 'var(--foreground)',
|
|
259
|
+
fontSize: 'var(--text-xs)',
|
|
260
|
+
fontWeight: 500,
|
|
261
|
+
}}
|
|
262
|
+
labelBgStyle={{
|
|
263
|
+
fill: 'var(--card)',
|
|
264
|
+
stroke: 'var(--border)',
|
|
265
|
+
strokeWidth: 1,
|
|
266
|
+
}}
|
|
267
|
+
labelBgPadding={[9, 5]}
|
|
268
|
+
labelBgBorderRadius={7}
|
|
269
|
+
/>
|
|
270
|
+
) : null}
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const nodeTypes = { diagram: DiagramState };
|
|
276
|
+
const edgeTypes = { diagram: DiagramConnection };
|
|
277
|
+
|
|
278
|
+
export interface FlowDiagramProps extends FlowDiagramGraph {
|
|
279
|
+
readonly direction?: FlowDiagramDirection;
|
|
280
|
+
/** Expand vertically to show the entire graph at the available width. */
|
|
281
|
+
readonly autoHeight?: boolean;
|
|
282
|
+
readonly className?: string;
|
|
283
|
+
readonly style?: CSSProperties;
|
|
284
|
+
readonly ariaLabel: string;
|
|
285
|
+
/** Interactive controls shown in the node footer. */
|
|
286
|
+
readonly nodeActions?: (node: FlowDiagramNode) => ReactNode;
|
|
287
|
+
/** Supporting content below the node title; provide sufficient fixed node dimensions. */
|
|
288
|
+
readonly nodeDescription?: (node: FlowDiagramNode) => ReactNode;
|
|
289
|
+
/** Initial fit prioritizes legibility; the Fit control still shows the entire graph. */
|
|
290
|
+
readonly initialMinZoom?: number;
|
|
291
|
+
readonly showMiniMap?: boolean;
|
|
292
|
+
readonly renderNode?: (node: FlowDiagramNode) => ReactNode;
|
|
293
|
+
readonly onNodeClick?: (node: FlowDiagramNode) => void;
|
|
294
|
+
readonly onEdgeClick?: (edge: FlowDiagramEdge) => void;
|
|
295
|
+
readonly onLayoutError?: (error: unknown) => void;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A read-only graph canvas. The parent controls its data, selection details and dimensions. */
|
|
299
|
+
export function FlowDiagram({
|
|
300
|
+
nodes,
|
|
301
|
+
edges,
|
|
302
|
+
direction = 'RIGHT',
|
|
303
|
+
autoHeight = false,
|
|
304
|
+
className,
|
|
305
|
+
style,
|
|
306
|
+
ariaLabel,
|
|
307
|
+
renderNode,
|
|
308
|
+
nodeActions,
|
|
309
|
+
nodeDescription,
|
|
310
|
+
initialMinZoom = 0.1,
|
|
311
|
+
showMiniMap = false,
|
|
312
|
+
onNodeClick,
|
|
313
|
+
onEdgeClick,
|
|
314
|
+
onLayoutError,
|
|
315
|
+
}: FlowDiagramProps): ReactElement {
|
|
316
|
+
const { t } = useTranslation();
|
|
317
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
318
|
+
const [width, setWidth] = useState(0);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!autoHeight || !containerRef.current) return;
|
|
321
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
322
|
+
if (entry) setWidth(entry.contentRect.width);
|
|
323
|
+
});
|
|
324
|
+
observer.observe(containerRef.current);
|
|
325
|
+
return () => observer.disconnect();
|
|
326
|
+
}, [autoHeight]);
|
|
327
|
+
// Layout only structural changes; polling a record or highlighting a node preserves the viewport.
|
|
328
|
+
const structure = JSON.stringify({
|
|
329
|
+
nodes: nodes.map(({ id, width, height }) => ({
|
|
330
|
+
id,
|
|
331
|
+
label: '',
|
|
332
|
+
width,
|
|
333
|
+
height,
|
|
334
|
+
})),
|
|
335
|
+
edges: edges.map(({ id, source, target, label, configurable }) => ({
|
|
336
|
+
id,
|
|
337
|
+
source,
|
|
338
|
+
target,
|
|
339
|
+
label: configurable
|
|
340
|
+
? [label, t('flowDiagram.configurable', { defaultValue: 'Parameters' })]
|
|
341
|
+
.filter(Boolean)
|
|
342
|
+
.join(' · ')
|
|
343
|
+
: label,
|
|
344
|
+
})),
|
|
345
|
+
direction,
|
|
346
|
+
});
|
|
347
|
+
const [result, setResult] = useState<{
|
|
348
|
+
key: string;
|
|
349
|
+
layout?: FlowDiagramLayout;
|
|
350
|
+
error?: unknown;
|
|
351
|
+
}>();
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
let active = true;
|
|
354
|
+
const input = JSON.parse(structure) as FlowDiagramGraph & {
|
|
355
|
+
direction: FlowDiagramDirection;
|
|
356
|
+
};
|
|
357
|
+
void layoutFlowDiagram(input, input.direction).then(
|
|
358
|
+
(layout) => {
|
|
359
|
+
if (active) setResult({ key: structure, layout });
|
|
360
|
+
},
|
|
361
|
+
(error: unknown) => {
|
|
362
|
+
if (active) setResult({ key: structure, error });
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
return () => {
|
|
366
|
+
active = false;
|
|
367
|
+
};
|
|
368
|
+
}, [structure]);
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (result?.key === structure && result.error)
|
|
371
|
+
onLayoutError?.(result.error);
|
|
372
|
+
}, [result, structure, onLayoutError]);
|
|
373
|
+
const layout = result?.key === structure ? result.layout : undefined;
|
|
374
|
+
const placedNodes = useMemo<DiagramNode[]>(
|
|
375
|
+
() =>
|
|
376
|
+
(layout?.nodes ?? []).map((placed) => {
|
|
377
|
+
const item = nodes.find((node) => node.id === placed.id)!;
|
|
378
|
+
return {
|
|
379
|
+
id: item.id,
|
|
380
|
+
type: 'diagram',
|
|
381
|
+
focusable: !item.decorative,
|
|
382
|
+
selectable: !item.decorative,
|
|
383
|
+
position: { x: placed.x, y: placed.y },
|
|
384
|
+
style: { width: placed.width, height: placed.height },
|
|
385
|
+
// ELK owns fixed geometry. Recreated controlled nodes must not clear
|
|
386
|
+
// React Flow's measurements and briefly remove every connected edge.
|
|
387
|
+
width: placed.width,
|
|
388
|
+
height: placed.height,
|
|
389
|
+
measured: { width: placed.width, height: placed.height },
|
|
390
|
+
handles: [
|
|
391
|
+
{
|
|
392
|
+
id: 'in',
|
|
393
|
+
type: 'target',
|
|
394
|
+
position: direction === 'RIGHT' ? Position.Left : Position.Top,
|
|
395
|
+
x: direction === 'RIGHT' ? 0 : placed.width / 2,
|
|
396
|
+
y: direction === 'RIGHT' ? placed.height / 2 : 0,
|
|
397
|
+
width: 0,
|
|
398
|
+
height: 0,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: 'out',
|
|
402
|
+
type: 'source',
|
|
403
|
+
position:
|
|
404
|
+
direction === 'RIGHT' ? Position.Right : Position.Bottom,
|
|
405
|
+
x: direction === 'RIGHT' ? placed.width : placed.width / 2,
|
|
406
|
+
y: direction === 'RIGHT' ? placed.height / 2 : placed.height,
|
|
407
|
+
width: 0,
|
|
408
|
+
height: 0,
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
ariaLabel: item.label,
|
|
412
|
+
data: {
|
|
413
|
+
item,
|
|
414
|
+
direction,
|
|
415
|
+
content: renderNode ? renderNode(item) : item.label,
|
|
416
|
+
actions: nodeActions?.(item),
|
|
417
|
+
description: nodeDescription?.(item),
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}),
|
|
421
|
+
[layout, nodes, direction, renderNode, nodeActions, nodeDescription],
|
|
422
|
+
);
|
|
423
|
+
const placedEdges = useMemo<DiagramEdge[]>(
|
|
424
|
+
() =>
|
|
425
|
+
(layout?.edges ?? []).map((placed) => {
|
|
426
|
+
const item = edges.find((edge) => edge.id === placed.id)!;
|
|
427
|
+
const color = item.visited
|
|
428
|
+
? 'color-mix(in srgb, var(--primary) 80%, var(--foreground))'
|
|
429
|
+
: item.highlighted
|
|
430
|
+
? 'var(--primary)'
|
|
431
|
+
: 'color-mix(in srgb, var(--muted-foreground) 65%, transparent)';
|
|
432
|
+
return {
|
|
433
|
+
...item,
|
|
434
|
+
label: placed.label,
|
|
435
|
+
type: 'diagram',
|
|
436
|
+
animated: Boolean(item.highlighted && !item.visited),
|
|
437
|
+
focusable: !item.decorative,
|
|
438
|
+
selectable: !item.decorative,
|
|
439
|
+
sourceHandle: 'out',
|
|
440
|
+
targetHandle: 'in',
|
|
441
|
+
ariaLabel: placed.label ?? `${item.source} → ${item.target}`,
|
|
442
|
+
markerEnd: { type: MarkerType.ArrowClosed, color },
|
|
443
|
+
style: {
|
|
444
|
+
stroke: color,
|
|
445
|
+
strokeWidth: item.visited || item.highlighted ? 2.5 : 1.5,
|
|
446
|
+
strokeDasharray: item.dashed ? '5 4' : undefined,
|
|
447
|
+
},
|
|
448
|
+
data: { points: placed.points, labelPosition: placed.labelPosition },
|
|
449
|
+
};
|
|
450
|
+
}),
|
|
451
|
+
[layout, edges],
|
|
452
|
+
);
|
|
453
|
+
const zoom =
|
|
454
|
+
layout && width
|
|
455
|
+
? Math.max(
|
|
456
|
+
0.1,
|
|
457
|
+
initialMinZoom,
|
|
458
|
+
Math.min(1, (width - 48) / Math.max(1, layout.width)),
|
|
459
|
+
)
|
|
460
|
+
: 1;
|
|
461
|
+
// Reserve room above and below the graph for the canvas chrome.
|
|
462
|
+
const height = layout ? Math.max(320, layout.height * zoom + 144) : 320;
|
|
463
|
+
return (
|
|
464
|
+
<div
|
|
465
|
+
ref={containerRef}
|
|
466
|
+
onKeyDown={(event) => {
|
|
467
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
468
|
+
const target = event.target as HTMLElement | SVGElement;
|
|
469
|
+
const nodeElement = target.closest<HTMLElement>('.react-flow__node');
|
|
470
|
+
const edgeElement = target.closest<HTMLElement>('.react-flow__edge');
|
|
471
|
+
if (target !== nodeElement && target !== edgeElement) return;
|
|
472
|
+
const id = target.dataset.id;
|
|
473
|
+
const node = nodes.find((item) => item.id === id);
|
|
474
|
+
const edge = edges.find((item) => item.id === id);
|
|
475
|
+
if (nodeElement && node) {
|
|
476
|
+
event.preventDefault();
|
|
477
|
+
onNodeClick?.(node);
|
|
478
|
+
}
|
|
479
|
+
if (edgeElement && edge) {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
onEdgeClick?.(edge);
|
|
482
|
+
}
|
|
483
|
+
}}
|
|
484
|
+
aria-label={ariaLabel}
|
|
485
|
+
className={cn(
|
|
486
|
+
'relative isolate h-96 w-full overflow-hidden rounded-xl border border-border bg-linear-to-br from-primary/5 via-background to-muted/40 text-foreground [&_.react-flow__attribution]:bg-card/80 [&_.react-flow__attribution]:text-muted-foreground [&_.react-flow__edge.selected_path.react-flow__edge-path]:stroke-primary [&_.react-flow__edge.animated_path]:motion-reduce:animate-none',
|
|
487
|
+
className,
|
|
488
|
+
)}
|
|
489
|
+
style={{ ...(autoHeight ? { height } : {}), ...style }}
|
|
490
|
+
>
|
|
491
|
+
{!nodes.length ? (
|
|
492
|
+
<p role='status' className='p-4 text-sm text-muted-foreground'>
|
|
493
|
+
{t('flowDiagram.empty', { defaultValue: 'No states to display.' })}
|
|
494
|
+
</p>
|
|
495
|
+
) : result?.key === structure && result.error ? (
|
|
496
|
+
<p role='alert' className='p-4 text-sm text-destructive'>
|
|
497
|
+
{t('flowDiagram.failed', {
|
|
498
|
+
defaultValue: 'Unable to lay out this diagram.',
|
|
499
|
+
})}
|
|
500
|
+
</p>
|
|
501
|
+
) : !layout ? (
|
|
502
|
+
<p role='status' className='p-4 text-sm text-muted-foreground'>
|
|
503
|
+
{t('flowDiagram.loading', { defaultValue: 'Laying out diagram…' })}
|
|
504
|
+
</p>
|
|
505
|
+
) : (
|
|
506
|
+
<ReactFlow<DiagramNode, DiagramEdge>
|
|
507
|
+
key={autoHeight ? `${structure}:${width}` : structure}
|
|
508
|
+
nodes={placedNodes}
|
|
509
|
+
edges={placedEdges}
|
|
510
|
+
nodeTypes={nodeTypes}
|
|
511
|
+
edgeTypes={edgeTypes}
|
|
512
|
+
fitView={!autoHeight}
|
|
513
|
+
fitViewOptions={{
|
|
514
|
+
padding: 0.2,
|
|
515
|
+
minZoom: initialMinZoom,
|
|
516
|
+
maxZoom: 1,
|
|
517
|
+
}}
|
|
518
|
+
defaultViewport={
|
|
519
|
+
autoHeight
|
|
520
|
+
? {
|
|
521
|
+
x: (width - layout.width * zoom) / 2,
|
|
522
|
+
y: (height - layout.height * zoom) / 2,
|
|
523
|
+
zoom,
|
|
524
|
+
}
|
|
525
|
+
: undefined
|
|
526
|
+
}
|
|
527
|
+
// A full-height process is read by scrolling the page, not wheel-zooming the canvas.
|
|
528
|
+
zoomOnScroll={!autoHeight}
|
|
529
|
+
preventScrolling={!autoHeight}
|
|
530
|
+
minZoom={0.1}
|
|
531
|
+
maxZoom={2}
|
|
532
|
+
nodesDraggable={false}
|
|
533
|
+
nodesConnectable={false}
|
|
534
|
+
edgesReconnectable={false}
|
|
535
|
+
deleteKeyCode={null}
|
|
536
|
+
onNodeClick={(_, node) => onNodeClick?.(node.data.item)}
|
|
537
|
+
onEdgeClick={(_, edge) => {
|
|
538
|
+
const item = edges.find((candidate) => candidate.id === edge.id);
|
|
539
|
+
if (item) onEdgeClick?.(item);
|
|
540
|
+
}}
|
|
541
|
+
ariaLabelConfig={{
|
|
542
|
+
'node.a11yDescription.keyboardDisabled': t('flowDiagram.nodeHelp', {
|
|
543
|
+
defaultValue: 'Press Enter or Space to select a state.',
|
|
544
|
+
}),
|
|
545
|
+
'node.a11yDescription.default': t('flowDiagram.nodeHelp', {
|
|
546
|
+
defaultValue: 'Press Enter or Space to select a state.',
|
|
547
|
+
}),
|
|
548
|
+
'edge.a11yDescription.default': t('flowDiagram.edgeHelp', {
|
|
549
|
+
defaultValue: 'Press Enter or Space to select a transition.',
|
|
550
|
+
}),
|
|
551
|
+
}}
|
|
552
|
+
>
|
|
553
|
+
<Background color='var(--border)' gap={24} size={1.5} />
|
|
554
|
+
{showMiniMap && (
|
|
555
|
+
<MiniMap
|
|
556
|
+
pannable
|
|
557
|
+
zoomable
|
|
558
|
+
ariaLabel={t('flowDiagram.overview', {
|
|
559
|
+
defaultValue: 'Diagram overview',
|
|
560
|
+
})}
|
|
561
|
+
nodeColor='var(--muted-foreground)'
|
|
562
|
+
maskColor='color-mix(in srgb, var(--background) 75%, transparent)'
|
|
563
|
+
style={{
|
|
564
|
+
background: 'var(--card)',
|
|
565
|
+
border: '1px solid var(--border)',
|
|
566
|
+
borderRadius: 'var(--radius)',
|
|
567
|
+
}}
|
|
568
|
+
className='hidden sm:block'
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
571
|
+
<Panel position='top-left' className='pointer-events-none!'>
|
|
572
|
+
<div className='inline-flex items-center gap-3 rounded-lg border bg-card/95 px-3 py-2 text-xs text-muted-foreground shadow-xs'>
|
|
573
|
+
<span className='inline-flex items-center gap-1.5'>
|
|
574
|
+
<GitBranchIcon className='size-3.5' aria-hidden />
|
|
575
|
+
{t('flowDiagram.states', { defaultValue: 'States' })}
|
|
576
|
+
<span className='font-semibold text-foreground tabular-nums'>
|
|
577
|
+
{nodes.filter((node) => !node.decorative).length}
|
|
578
|
+
</span>
|
|
579
|
+
</span>
|
|
580
|
+
<span className='h-3 w-px bg-border' aria-hidden />
|
|
581
|
+
<span>
|
|
582
|
+
{t('flowDiagram.transitions', { defaultValue: 'Transitions' })}{' '}
|
|
583
|
+
<span className='font-semibold text-foreground tabular-nums'>
|
|
584
|
+
{edges.filter((edge) => !edge.decorative).length}
|
|
585
|
+
</span>
|
|
586
|
+
</span>
|
|
587
|
+
</div>
|
|
588
|
+
</Panel>
|
|
589
|
+
<DiagramToolbar top={autoHeight} />
|
|
590
|
+
</ReactFlow>
|
|
591
|
+
)}
|
|
592
|
+
</div>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** View controls belong inside React Flow so they always use this canvas's viewport. */
|
|
597
|
+
function DiagramToolbar({
|
|
598
|
+
top = false,
|
|
599
|
+
}: {
|
|
600
|
+
readonly top?: boolean;
|
|
601
|
+
}): ReactElement {
|
|
602
|
+
const { t } = useTranslation();
|
|
603
|
+
const flow = useReactFlow();
|
|
604
|
+
const { zoom } = useViewport();
|
|
605
|
+
const controls = [
|
|
606
|
+
{
|
|
607
|
+
label: t('flowDiagram.zoomOut', { defaultValue: 'Zoom out' }),
|
|
608
|
+
icon: MinusIcon,
|
|
609
|
+
disabled: zoom <= 0.1,
|
|
610
|
+
run: () => void flow.zoomOut(),
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
label: t('flowDiagram.zoomIn', { defaultValue: 'Zoom in' }),
|
|
614
|
+
icon: PlusIcon,
|
|
615
|
+
disabled: zoom >= 2,
|
|
616
|
+
run: () => void flow.zoomIn(),
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
label: t('flowDiagram.fit', { defaultValue: 'Fit to view' }),
|
|
620
|
+
icon: MaximizeIcon,
|
|
621
|
+
disabled: false,
|
|
622
|
+
run: () => void flow.fitView({ padding: 0.2, maxZoom: 1 }),
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
return (
|
|
626
|
+
<Panel
|
|
627
|
+
position={top ? 'top-right' : 'bottom-left'}
|
|
628
|
+
className='max-sm:top-12! max-sm:bottom-auto!'
|
|
629
|
+
>
|
|
630
|
+
<div className='flex items-center gap-1 rounded-xl border bg-card/95 p-1.5 shadow-md'>
|
|
631
|
+
{controls.map(({ label, icon: Icon, disabled, run }) => (
|
|
632
|
+
<Tooltip key={label}>
|
|
633
|
+
<TooltipTrigger render={<span />}>
|
|
634
|
+
<Button
|
|
635
|
+
type='button'
|
|
636
|
+
size='sm'
|
|
637
|
+
className='size-7 p-0'
|
|
638
|
+
variant='ghost'
|
|
639
|
+
aria-label={label}
|
|
640
|
+
disabled={disabled || !flow.viewportInitialized}
|
|
641
|
+
onClick={run}
|
|
642
|
+
>
|
|
643
|
+
<Icon />
|
|
644
|
+
</Button>
|
|
645
|
+
</TooltipTrigger>
|
|
646
|
+
<TooltipContent>{label}</TooltipContent>
|
|
647
|
+
</Tooltip>
|
|
648
|
+
))}
|
|
649
|
+
<Tooltip>
|
|
650
|
+
<TooltipTrigger
|
|
651
|
+
render={
|
|
652
|
+
<Button
|
|
653
|
+
type='button'
|
|
654
|
+
variant='ghost'
|
|
655
|
+
size='sm'
|
|
656
|
+
className='min-w-12 px-2 text-xs text-muted-foreground tabular-nums'
|
|
657
|
+
aria-label={t('flowDiagram.actualSize', {
|
|
658
|
+
defaultValue: 'Actual size',
|
|
659
|
+
})}
|
|
660
|
+
disabled={!flow.viewportInitialized}
|
|
661
|
+
onClick={() => void flow.zoomTo(1)}
|
|
662
|
+
/>
|
|
663
|
+
}
|
|
664
|
+
>
|
|
665
|
+
{Math.round(zoom * 100)}%
|
|
666
|
+
</TooltipTrigger>
|
|
667
|
+
<TooltipContent>
|
|
668
|
+
{t('flowDiagram.actualSize', { defaultValue: 'Actual size' })}
|
|
669
|
+
</TooltipContent>
|
|
670
|
+
</Tooltip>
|
|
671
|
+
</div>
|
|
672
|
+
</Panel>
|
|
673
|
+
);
|
|
674
|
+
}
|