@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.76
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/build-and-deploy.md +4 -0
- package/.agents/rules/components.md +3 -1
- package/README.MD +2 -2
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +21 -5
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +37 -1
- package/client/locales/system/settings-zh-CN.ts +33 -2
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +4 -1
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/role-form.tsx +1 -1
- package/client/pages/settings/roles.tsx +44 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/config.example.yml +6 -5
- package/package.json +11 -11
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +1 -1
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tsconfig.json +1 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
3
4
|
useMemo,
|
|
4
5
|
useRef,
|
|
5
6
|
useState,
|
|
@@ -7,14 +8,17 @@ import {
|
|
|
7
8
|
type ReactElement,
|
|
8
9
|
type ReactNode,
|
|
9
10
|
} from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
10
12
|
import {
|
|
11
|
-
|
|
13
|
+
BaseEdge,
|
|
14
|
+
Controls,
|
|
15
|
+
ControlButton,
|
|
12
16
|
MiniMap,
|
|
13
17
|
Panel,
|
|
18
|
+
Background,
|
|
14
19
|
useReactFlow,
|
|
15
20
|
useViewport,
|
|
16
|
-
|
|
17
|
-
EdgeText,
|
|
21
|
+
type MiniMapNodeProps,
|
|
18
22
|
Handle,
|
|
19
23
|
MarkerType,
|
|
20
24
|
Position,
|
|
@@ -27,6 +31,16 @@ import {
|
|
|
27
31
|
import { useTranslation } from '@nocobase/i18n/client';
|
|
28
32
|
import { cn } from 'cn';
|
|
29
33
|
import {
|
|
34
|
+
Expand,
|
|
35
|
+
Shrink,
|
|
36
|
+
X,
|
|
37
|
+
UserCheck,
|
|
38
|
+
Wrench,
|
|
39
|
+
CircleCheck,
|
|
40
|
+
CircleX,
|
|
41
|
+
TriangleAlert,
|
|
42
|
+
ArrowLeftIcon,
|
|
43
|
+
ArrowRightIcon,
|
|
30
44
|
CheckIcon,
|
|
31
45
|
CircleDotIcon,
|
|
32
46
|
FlagIcon,
|
|
@@ -43,10 +57,13 @@ import {
|
|
|
43
57
|
TooltipContent,
|
|
44
58
|
TooltipTrigger,
|
|
45
59
|
} from '#components/ui/tooltip';
|
|
60
|
+
|
|
46
61
|
import '@xyflow/react/dist/style.css';
|
|
47
62
|
|
|
48
63
|
import {
|
|
49
64
|
layoutFlowDiagram,
|
|
65
|
+
flowLabelLines,
|
|
66
|
+
revealRoute,
|
|
50
67
|
type FlowDiagramDirection,
|
|
51
68
|
type FlowDiagramEdge,
|
|
52
69
|
type FlowDiagramGraph,
|
|
@@ -65,8 +82,28 @@ type DiagramNode = Node<{
|
|
|
65
82
|
type DiagramEdge = Edge<{
|
|
66
83
|
points: readonly FlowDiagramPoint[];
|
|
67
84
|
labelPosition?: FlowDiagramPoint;
|
|
85
|
+
labelAnchor?: 'end' | 'middle';
|
|
86
|
+
title?: string;
|
|
68
87
|
}>;
|
|
69
88
|
|
|
89
|
+
function DiagramMiniMapNode(props: MiniMapNodeProps): ReactElement {
|
|
90
|
+
return (
|
|
91
|
+
<rect
|
|
92
|
+
x={props.x}
|
|
93
|
+
y={props.y}
|
|
94
|
+
width={props.width}
|
|
95
|
+
height={props.height}
|
|
96
|
+
rx={props.className.includes('minimap-pill') ? props.height / 2 : 8}
|
|
97
|
+
fill={props.color}
|
|
98
|
+
stroke={props.strokeColor}
|
|
99
|
+
strokeWidth={props.strokeWidth}
|
|
100
|
+
onClick={
|
|
101
|
+
props.onClick ? (event) => props.onClick?.(event, props.id) : undefined
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
|
|
71
108
|
const { t } = useTranslation();
|
|
72
109
|
const flow = useReactFlow();
|
|
@@ -86,6 +123,27 @@ function DiagramState({ data, id }: NodeProps<DiagramNode>): ReactElement {
|
|
|
86
123
|
: data.item.kind === 'final'
|
|
87
124
|
? t('flowDiagram.final', { defaultValue: 'Final' })
|
|
88
125
|
: t('flowDiagram.state', { defaultValue: 'State' });
|
|
126
|
+
const inPosition = data.direction === 'RIGHT' ? Position.Left : Position.Top;
|
|
127
|
+
const outPosition =
|
|
128
|
+
data.direction === 'RIGHT' ? Position.Right : Position.Bottom;
|
|
129
|
+
if (data.item.bare)
|
|
130
|
+
return (
|
|
131
|
+
<div className={cn('h-full w-full', data.item.className)}>
|
|
132
|
+
<Handle
|
|
133
|
+
id='in'
|
|
134
|
+
type='target'
|
|
135
|
+
position={inPosition}
|
|
136
|
+
className='invisible'
|
|
137
|
+
/>
|
|
138
|
+
{data.content}
|
|
139
|
+
<Handle
|
|
140
|
+
id='out'
|
|
141
|
+
type='source'
|
|
142
|
+
position={outPosition}
|
|
143
|
+
className='invisible'
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
89
147
|
return (
|
|
90
148
|
<div
|
|
91
149
|
onFocusCapture={(event) => {
|
|
@@ -237,6 +295,60 @@ function routePath(points: readonly FlowDiagramPoint[]): string {
|
|
|
237
295
|
return `${path} L ${last.x} ${last.y}`;
|
|
238
296
|
}
|
|
239
297
|
|
|
298
|
+
/** Measure the actual SVG bounds so multiline labels have equal chip padding. */
|
|
299
|
+
function TransitionLabel({
|
|
300
|
+
x,
|
|
301
|
+
y,
|
|
302
|
+
label,
|
|
303
|
+
anchor = 'middle',
|
|
304
|
+
}: {
|
|
305
|
+
x: number;
|
|
306
|
+
y: number;
|
|
307
|
+
label: ReactNode;
|
|
308
|
+
anchor?: 'end' | 'middle';
|
|
309
|
+
}) {
|
|
310
|
+
const ref = useRef<SVGTextElement>(null);
|
|
311
|
+
const backgroundRef = useRef<SVGRectElement>(null);
|
|
312
|
+
useLayoutEffect(() => {
|
|
313
|
+
const box = ref.current?.getBBox();
|
|
314
|
+
if (!box || !backgroundRef.current) return;
|
|
315
|
+
for (const [key, value] of Object.entries({
|
|
316
|
+
x: box.x - 6,
|
|
317
|
+
y: box.y - 3,
|
|
318
|
+
width: box.width + 12,
|
|
319
|
+
height: box.height + 6,
|
|
320
|
+
})) {
|
|
321
|
+
backgroundRef.current.setAttribute(key, String(value));
|
|
322
|
+
}
|
|
323
|
+
}, [label, anchor]);
|
|
324
|
+
return (
|
|
325
|
+
<g transform={`translate(${x} ${y})`}>
|
|
326
|
+
<rect ref={backgroundRef} rx={6} fill='var(--muted)' />
|
|
327
|
+
<text
|
|
328
|
+
ref={ref}
|
|
329
|
+
textAnchor={anchor}
|
|
330
|
+
dominantBaseline='central'
|
|
331
|
+
fill='var(--muted-foreground)'
|
|
332
|
+
fontSize='var(--text-xs)'
|
|
333
|
+
>
|
|
334
|
+
{typeof label === 'string'
|
|
335
|
+
? flowLabelLines(label).map((line, index, lines) => (
|
|
336
|
+
<tspan
|
|
337
|
+
// Label lines have no state; duplicate text needs a position key.
|
|
338
|
+
// eslint-disable-next-line @eslint-react/no-array-index-key
|
|
339
|
+
key={`${index}:${line}`}
|
|
340
|
+
x={0}
|
|
341
|
+
y={(index - (lines.length - 1) / 2) * 16}
|
|
342
|
+
>
|
|
343
|
+
{line}
|
|
344
|
+
</tspan>
|
|
345
|
+
))
|
|
346
|
+
: label}
|
|
347
|
+
</text>
|
|
348
|
+
</g>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
240
352
|
function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
|
|
241
353
|
const points = props.data?.points ?? [];
|
|
242
354
|
const path = routePath(points);
|
|
@@ -249,24 +361,23 @@ function DiagramConnection(props: EdgeProps<DiagramEdge>): ReactElement {
|
|
|
249
361
|
markerEnd={props.markerEnd}
|
|
250
362
|
style={props.style}
|
|
251
363
|
/>
|
|
364
|
+
{props.data?.title ? (
|
|
365
|
+
// A wide transparent stroke so the hover text is easy to reach.
|
|
366
|
+
<path d={path} fill='none' stroke='transparent' strokeWidth={12}>
|
|
367
|
+
<title>{props.data.title}</title>
|
|
368
|
+
</path>
|
|
369
|
+
) : null}
|
|
252
370
|
{label && props.label ? (
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
fill: 'var(--card)',
|
|
264
|
-
stroke: 'var(--border)',
|
|
265
|
-
strokeWidth: 1,
|
|
266
|
-
}}
|
|
267
|
-
labelBgPadding={[9, 5]}
|
|
268
|
-
labelBgBorderRadius={7}
|
|
269
|
-
/>
|
|
371
|
+
<g
|
|
372
|
+
style={{ opacity: props.style?.opacity, transition: 'opacity 150ms' }}
|
|
373
|
+
>
|
|
374
|
+
<TransitionLabel
|
|
375
|
+
x={label.x}
|
|
376
|
+
y={label.y}
|
|
377
|
+
label={props.label}
|
|
378
|
+
anchor={props.data?.labelAnchor}
|
|
379
|
+
/>
|
|
380
|
+
</g>
|
|
270
381
|
) : null}
|
|
271
382
|
</>
|
|
272
383
|
);
|
|
@@ -277,19 +388,32 @@ const edgeTypes = { diagram: DiagramConnection };
|
|
|
277
388
|
|
|
278
389
|
export interface FlowDiagramProps extends FlowDiagramGraph {
|
|
279
390
|
readonly direction?: FlowDiagramDirection;
|
|
391
|
+
/** reduce layer spacing while retaining room for multiline conditions. */
|
|
392
|
+
readonly compact?: boolean;
|
|
393
|
+
readonly controls?: boolean;
|
|
280
394
|
/** Expand vertically to show the entire graph at the available width. */
|
|
281
395
|
readonly autoHeight?: boolean;
|
|
396
|
+
/** Limit automatic fitting to this scale; manual zoom remains available. */
|
|
397
|
+
readonly initialZoomLimit?: number;
|
|
282
398
|
readonly className?: string;
|
|
283
399
|
readonly style?: CSSProperties;
|
|
284
400
|
readonly ariaLabel: string;
|
|
285
|
-
/**
|
|
401
|
+
/** transitions left out of the layout. Hovering or focusing a node draws
|
|
402
|
+
* the ones touching it in lanes beside the graph and dims unrelated states. */
|
|
403
|
+
readonly revealEdges?: readonly FlowDiagramEdge[];
|
|
404
|
+
/** Routes left off the canvas, such as returns a node lists in its own
|
|
405
|
+
* details. Hovering or focusing a node keeps the states they connect it to at
|
|
406
|
+
* full strength, dims the rest and passes `linked` to `renderNode`; nothing
|
|
407
|
+
* is drawn. */
|
|
408
|
+
readonly auxiliaryEdges?: readonly FlowDiagramEdge[];
|
|
286
409
|
readonly nodeActions?: (node: FlowDiagramNode) => ReactNode;
|
|
287
|
-
/** Supporting content below the node title; provide sufficient fixed node dimensions. */
|
|
288
410
|
readonly nodeDescription?: (node: FlowDiagramNode) => ReactNode;
|
|
289
|
-
/** Initial fit prioritizes legibility; the Fit control still shows the entire graph. */
|
|
290
411
|
readonly initialMinZoom?: number;
|
|
291
412
|
readonly showMiniMap?: boolean;
|
|
292
|
-
readonly renderNode?: (
|
|
413
|
+
readonly renderNode?: (
|
|
414
|
+
node: FlowDiagramNode,
|
|
415
|
+
state: { active: boolean; linked: boolean },
|
|
416
|
+
) => ReactNode;
|
|
293
417
|
readonly onNodeClick?: (node: FlowDiagramNode) => void;
|
|
294
418
|
readonly onEdgeClick?: (edge: FlowDiagramEdge) => void;
|
|
295
419
|
readonly onLayoutError?: (error: unknown) => void;
|
|
@@ -300,10 +424,15 @@ export function FlowDiagram({
|
|
|
300
424
|
nodes,
|
|
301
425
|
edges,
|
|
302
426
|
direction = 'RIGHT',
|
|
427
|
+
compact = false,
|
|
428
|
+
controls = false,
|
|
303
429
|
autoHeight = false,
|
|
430
|
+
initialZoomLimit = 1,
|
|
304
431
|
className,
|
|
305
432
|
style,
|
|
306
433
|
ariaLabel,
|
|
434
|
+
revealEdges,
|
|
435
|
+
auxiliaryEdges,
|
|
307
436
|
renderNode,
|
|
308
437
|
nodeActions,
|
|
309
438
|
nodeDescription,
|
|
@@ -314,16 +443,25 @@ export function FlowDiagram({
|
|
|
314
443
|
onLayoutError,
|
|
315
444
|
}: FlowDiagramProps): ReactElement {
|
|
316
445
|
const { t } = useTranslation();
|
|
446
|
+
const [expanded, setExpanded] = useState(false);
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (!expanded) return;
|
|
449
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
450
|
+
if (event.key === 'Escape') setExpanded(false);
|
|
451
|
+
};
|
|
452
|
+
document.addEventListener('keydown', onKeyDown);
|
|
453
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
454
|
+
}, [expanded]);
|
|
317
455
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
318
456
|
const [width, setWidth] = useState(0);
|
|
319
457
|
useEffect(() => {
|
|
320
|
-
if (!autoHeight || !containerRef.current) return;
|
|
458
|
+
if (!autoHeight || expanded || !containerRef.current) return;
|
|
321
459
|
const observer = new ResizeObserver(([entry]) => {
|
|
322
460
|
if (entry) setWidth(entry.contentRect.width);
|
|
323
461
|
});
|
|
324
462
|
observer.observe(containerRef.current);
|
|
325
463
|
return () => observer.disconnect();
|
|
326
|
-
}, [autoHeight]);
|
|
464
|
+
}, [autoHeight, expanded]);
|
|
327
465
|
// Layout only structural changes; polling a record or highlighting a node preserves the viewport.
|
|
328
466
|
const structure = JSON.stringify({
|
|
329
467
|
nodes: nodes.map(({ id, width, height }) => ({
|
|
@@ -343,6 +481,7 @@ export function FlowDiagram({
|
|
|
343
481
|
: label,
|
|
344
482
|
})),
|
|
345
483
|
direction,
|
|
484
|
+
compact,
|
|
346
485
|
});
|
|
347
486
|
const [result, setResult] = useState<{
|
|
348
487
|
key: string;
|
|
@@ -353,8 +492,9 @@ export function FlowDiagram({
|
|
|
353
492
|
let active = true;
|
|
354
493
|
const input = JSON.parse(structure) as FlowDiagramGraph & {
|
|
355
494
|
direction: FlowDiagramDirection;
|
|
495
|
+
compact: boolean;
|
|
356
496
|
};
|
|
357
|
-
void layoutFlowDiagram(input, input.direction).then(
|
|
497
|
+
void layoutFlowDiagram(input, input.direction, input.compact).then(
|
|
358
498
|
(layout) => {
|
|
359
499
|
if (active) setResult({ key: structure, layout });
|
|
360
500
|
},
|
|
@@ -371,6 +511,49 @@ export function FlowDiagram({
|
|
|
371
511
|
onLayoutError?.(result.error);
|
|
372
512
|
}, [result, structure, onLayoutError]);
|
|
373
513
|
const layout = result?.key === structure ? result.layout : undefined;
|
|
514
|
+
const [hovered, setHovered] = useState<string>();
|
|
515
|
+
const [focused, setFocused] = useState<string>();
|
|
516
|
+
const active = hovered ?? focused;
|
|
517
|
+
const revealed = useMemo(
|
|
518
|
+
() =>
|
|
519
|
+
active
|
|
520
|
+
? (revealEdges ?? []).filter(
|
|
521
|
+
(edge) => edge.source === active || edge.target === active,
|
|
522
|
+
)
|
|
523
|
+
: [],
|
|
524
|
+
[active, revealEdges],
|
|
525
|
+
);
|
|
526
|
+
// States joined to the active one only by routes left off the canvas.
|
|
527
|
+
const linked = useMemo(
|
|
528
|
+
() =>
|
|
529
|
+
new Set(
|
|
530
|
+
(active ? (auxiliaryEdges ?? []) : [])
|
|
531
|
+
.flatMap((edge) =>
|
|
532
|
+
edge.source === active
|
|
533
|
+
? [edge.target]
|
|
534
|
+
: edge.target === active
|
|
535
|
+
? [edge.source]
|
|
536
|
+
: [],
|
|
537
|
+
)
|
|
538
|
+
.filter((id) => id !== active),
|
|
539
|
+
),
|
|
540
|
+
[active, auxiliaryEdges],
|
|
541
|
+
);
|
|
542
|
+
// The active state and every state one transition away stay at full strength.
|
|
543
|
+
const related = useMemo(
|
|
544
|
+
() =>
|
|
545
|
+
active && (revealEdges || auxiliaryEdges)
|
|
546
|
+
? new Set(
|
|
547
|
+
[...edges, ...revealed]
|
|
548
|
+
.filter(
|
|
549
|
+
(edge) => edge.source === active || edge.target === active,
|
|
550
|
+
)
|
|
551
|
+
.flatMap((edge) => [edge.source, edge.target])
|
|
552
|
+
.concat(active, ...linked),
|
|
553
|
+
)
|
|
554
|
+
: undefined,
|
|
555
|
+
[active, edges, revealed, revealEdges, auxiliaryEdges, linked],
|
|
556
|
+
);
|
|
374
557
|
const placedNodes = useMemo<DiagramNode[]>(
|
|
375
558
|
() =>
|
|
376
559
|
(layout?.nodes ?? []).map((placed) => {
|
|
@@ -381,7 +564,14 @@ export function FlowDiagram({
|
|
|
381
564
|
focusable: !item.decorative,
|
|
382
565
|
selectable: !item.decorative,
|
|
383
566
|
position: { x: placed.x, y: placed.y },
|
|
384
|
-
|
|
567
|
+
// Raised so content the active node draws outside its box stays on top.
|
|
568
|
+
zIndex: item.id === active ? 1 : 0,
|
|
569
|
+
style: {
|
|
570
|
+
width: placed.width,
|
|
571
|
+
height: placed.height,
|
|
572
|
+
opacity: related && !related.has(item.id) ? 0.35 : undefined,
|
|
573
|
+
transition: 'opacity 150ms',
|
|
574
|
+
},
|
|
385
575
|
// ELK owns fixed geometry. Recreated controlled nodes must not clear
|
|
386
576
|
// React Flow's measurements and briefly remove every connected edge.
|
|
387
577
|
width: placed.width,
|
|
@@ -412,13 +602,28 @@ export function FlowDiagram({
|
|
|
412
602
|
data: {
|
|
413
603
|
item,
|
|
414
604
|
direction,
|
|
415
|
-
content: renderNode
|
|
605
|
+
content: renderNode
|
|
606
|
+
? renderNode(item, {
|
|
607
|
+
active: item.id === active,
|
|
608
|
+
linked: linked.has(item.id),
|
|
609
|
+
})
|
|
610
|
+
: item.label,
|
|
416
611
|
actions: nodeActions?.(item),
|
|
417
612
|
description: nodeDescription?.(item),
|
|
418
613
|
},
|
|
419
614
|
};
|
|
420
615
|
}),
|
|
421
|
-
[
|
|
616
|
+
[
|
|
617
|
+
layout,
|
|
618
|
+
nodes,
|
|
619
|
+
direction,
|
|
620
|
+
renderNode,
|
|
621
|
+
active,
|
|
622
|
+
related,
|
|
623
|
+
linked,
|
|
624
|
+
nodeActions,
|
|
625
|
+
nodeDescription,
|
|
626
|
+
],
|
|
422
627
|
);
|
|
423
628
|
const placedEdges = useMemo<DiagramEdge[]>(
|
|
424
629
|
() =>
|
|
@@ -428,41 +633,97 @@ export function FlowDiagram({
|
|
|
428
633
|
? 'color-mix(in srgb, var(--primary) 80%, var(--foreground))'
|
|
429
634
|
: item.highlighted
|
|
430
635
|
? 'var(--primary)'
|
|
431
|
-
:
|
|
636
|
+
: item.faint
|
|
637
|
+
? 'color-mix(in srgb, var(--muted-foreground) 25%, transparent)'
|
|
638
|
+
: 'color-mix(in srgb, var(--muted-foreground) 45%, transparent)';
|
|
432
639
|
return {
|
|
433
640
|
...item,
|
|
434
641
|
label: placed.label,
|
|
435
|
-
type: 'diagram',
|
|
436
642
|
animated: Boolean(item.highlighted && !item.visited),
|
|
643
|
+
type: 'diagram',
|
|
437
644
|
focusable: !item.decorative,
|
|
438
645
|
selectable: !item.decorative,
|
|
439
646
|
sourceHandle: 'out',
|
|
440
647
|
targetHandle: 'in',
|
|
441
|
-
ariaLabel:
|
|
648
|
+
ariaLabel:
|
|
649
|
+
item.title ?? item.label ?? `${item.source} → ${item.target}`,
|
|
442
650
|
markerEnd: { type: MarkerType.ArrowClosed, color },
|
|
443
651
|
style: {
|
|
444
652
|
stroke: color,
|
|
445
|
-
strokeWidth:
|
|
653
|
+
strokeWidth:
|
|
654
|
+
item.visited || item.highlighted ? 2.5 : item.faint ? 1 : 1.5,
|
|
446
655
|
strokeDasharray: item.dashed ? '5 4' : undefined,
|
|
656
|
+
opacity:
|
|
657
|
+
(revealEdges || auxiliaryEdges) &&
|
|
658
|
+
active &&
|
|
659
|
+
item.source !== active &&
|
|
660
|
+
item.target !== active
|
|
661
|
+
? 0.25
|
|
662
|
+
: undefined,
|
|
663
|
+
transition: 'opacity 150ms',
|
|
664
|
+
},
|
|
665
|
+
data: {
|
|
666
|
+
points: placed.points,
|
|
667
|
+
labelPosition: placed.labelPosition,
|
|
668
|
+
title: item.title,
|
|
447
669
|
},
|
|
448
|
-
data: { points: placed.points, labelPosition: placed.labelPosition },
|
|
449
670
|
};
|
|
450
671
|
}),
|
|
451
|
-
[layout, edges],
|
|
672
|
+
[layout, edges, active, revealEdges, auxiliaryEdges],
|
|
452
673
|
);
|
|
674
|
+
const revealedEdges = useMemo<DiagramEdge[]>(() => {
|
|
675
|
+
if (!layout) return [];
|
|
676
|
+
const placed = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
677
|
+
const routes = revealed.flatMap((item) => {
|
|
678
|
+
const source = placed.get(item.source),
|
|
679
|
+
target = placed.get(item.target);
|
|
680
|
+
return source && target ? [{ item, source, target }] : [];
|
|
681
|
+
});
|
|
682
|
+
// Shorter spans take the inner lanes, so nested routes never cross.
|
|
683
|
+
const span = ({ source, target }: (typeof routes)[number]) =>
|
|
684
|
+
Math.abs(
|
|
685
|
+
direction === 'DOWN' ? source.y - target.y : source.x - target.x,
|
|
686
|
+
);
|
|
687
|
+
routes.sort((a, b) => span(a) - span(b));
|
|
688
|
+
const color = 'var(--primary)';
|
|
689
|
+
return routes.map(({ item, source, target }, lane) => {
|
|
690
|
+
const route = revealRoute(layout.nodes, source, target, direction, lane);
|
|
691
|
+
return {
|
|
692
|
+
...item,
|
|
693
|
+
id: `reveal:${item.id}`,
|
|
694
|
+
type: 'diagram',
|
|
695
|
+
focusable: false,
|
|
696
|
+
selectable: false,
|
|
697
|
+
sourceHandle: 'out',
|
|
698
|
+
targetHandle: 'in',
|
|
699
|
+
ariaLabel:
|
|
700
|
+
item.title ?? item.label ?? `${item.source} → ${item.target}`,
|
|
701
|
+
markerEnd: { type: MarkerType.ArrowClosed, color },
|
|
702
|
+
style: { stroke: color, strokeWidth: 1.5, strokeDasharray: '5 4' },
|
|
703
|
+
data: { ...route, title: item.title },
|
|
704
|
+
};
|
|
705
|
+
});
|
|
706
|
+
}, [layout, revealed, direction]);
|
|
453
707
|
const zoom =
|
|
454
708
|
layout && width
|
|
455
709
|
? Math.max(
|
|
456
|
-
0.1,
|
|
457
710
|
initialMinZoom,
|
|
458
|
-
Math.min(
|
|
711
|
+
Math.min(initialZoomLimit, (width - 48) / Math.max(1, layout.width)),
|
|
459
712
|
)
|
|
460
713
|
: 1;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
714
|
+
const height = layout
|
|
715
|
+
? Math.max(compact ? 240 : 320, layout.height * zoom + (compact ? 48 : 144))
|
|
716
|
+
: 320;
|
|
717
|
+
const canvas = (
|
|
464
718
|
<div
|
|
465
719
|
ref={containerRef}
|
|
720
|
+
onFocus={(event) => {
|
|
721
|
+
const target = event.target as HTMLElement;
|
|
722
|
+
setFocused(
|
|
723
|
+
target.closest<HTMLElement>('.react-flow__node')?.dataset.id,
|
|
724
|
+
);
|
|
725
|
+
}}
|
|
726
|
+
onBlur={() => setFocused(undefined)}
|
|
466
727
|
onKeyDown={(event) => {
|
|
467
728
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
468
729
|
const target = event.target as HTMLElement | SVGElement;
|
|
@@ -483,10 +744,12 @@ export function FlowDiagram({
|
|
|
483
744
|
}}
|
|
484
745
|
aria-label={ariaLabel}
|
|
485
746
|
className={cn(
|
|
486
|
-
'relative
|
|
747
|
+
'relative h-96 w-full rounded-lg border border-border bg-muted/20 text-foreground [&_.react-flow__edge.animated_path]:motion-reduce:animate-none',
|
|
487
748
|
className,
|
|
749
|
+
expanded &&
|
|
750
|
+
'fixed inset-0 z-[100] h-dvh! w-screen rounded-none bg-background',
|
|
488
751
|
)}
|
|
489
|
-
style={{ ...(autoHeight ? { height } : {}), ...style }}
|
|
752
|
+
style={{ ...(autoHeight && !expanded ? { height } : {}), ...style }}
|
|
490
753
|
>
|
|
491
754
|
{!nodes.length ? (
|
|
492
755
|
<p role='status' className='p-4 text-sm text-muted-foreground'>
|
|
@@ -504,19 +767,24 @@ export function FlowDiagram({
|
|
|
504
767
|
</p>
|
|
505
768
|
) : (
|
|
506
769
|
<ReactFlow<DiagramNode, DiagramEdge>
|
|
507
|
-
key={autoHeight ? `${structure}:${width}` : structure}
|
|
770
|
+
key={autoHeight ? `${structure}:${width}:${zoom}` : structure}
|
|
771
|
+
proOptions={controls ? { hideAttribution: true } : undefined}
|
|
508
772
|
nodes={placedNodes}
|
|
509
|
-
edges={
|
|
773
|
+
edges={
|
|
774
|
+
revealedEdges.length
|
|
775
|
+
? [...placedEdges, ...revealedEdges]
|
|
776
|
+
: placedEdges
|
|
777
|
+
}
|
|
510
778
|
nodeTypes={nodeTypes}
|
|
511
779
|
edgeTypes={edgeTypes}
|
|
512
|
-
fitView={!autoHeight}
|
|
780
|
+
fitView={expanded || !autoHeight}
|
|
513
781
|
fitViewOptions={{
|
|
514
|
-
padding: 0.
|
|
782
|
+
padding: 0.08,
|
|
515
783
|
minZoom: initialMinZoom,
|
|
516
|
-
maxZoom:
|
|
784
|
+
maxZoom: initialZoomLimit,
|
|
517
785
|
}}
|
|
518
786
|
defaultViewport={
|
|
519
|
-
autoHeight
|
|
787
|
+
autoHeight && !expanded
|
|
520
788
|
? {
|
|
521
789
|
x: (width - layout.width * zoom) / 2,
|
|
522
790
|
y: (height - layout.height * zoom) / 2,
|
|
@@ -524,21 +792,27 @@ export function FlowDiagram({
|
|
|
524
792
|
}
|
|
525
793
|
: undefined
|
|
526
794
|
}
|
|
527
|
-
// A full-height process is read by scrolling the page, not wheel-zooming the canvas.
|
|
528
|
-
zoomOnScroll={!autoHeight}
|
|
529
|
-
preventScrolling={!autoHeight}
|
|
530
795
|
minZoom={0.1}
|
|
531
796
|
maxZoom={2}
|
|
797
|
+
zoomOnScroll={false}
|
|
798
|
+
preventScrolling={false}
|
|
532
799
|
nodesDraggable={false}
|
|
533
800
|
nodesConnectable={false}
|
|
534
801
|
edgesReconnectable={false}
|
|
535
802
|
deleteKeyCode={null}
|
|
536
803
|
onNodeClick={(_, node) => onNodeClick?.(node.data.item)}
|
|
804
|
+
onNodeMouseEnter={(_, node) => setHovered(node.id)}
|
|
805
|
+
onNodeMouseLeave={() => setHovered(undefined)}
|
|
537
806
|
onEdgeClick={(_, edge) => {
|
|
538
807
|
const item = edges.find((candidate) => candidate.id === edge.id);
|
|
539
808
|
if (item) onEdgeClick?.(item);
|
|
540
809
|
}}
|
|
541
810
|
ariaLabelConfig={{
|
|
811
|
+
'controls.zoomIn.ariaLabel': t('flowDiagram.zoomIn'),
|
|
812
|
+
'controls.zoomOut.ariaLabel': t('flowDiagram.zoomOut'),
|
|
813
|
+
'controls.fitView.ariaLabel': t('flowDiagram.fitView', {
|
|
814
|
+
defaultValue: 'Fit to view',
|
|
815
|
+
}),
|
|
542
816
|
'node.a11yDescription.keyboardDisabled': t('flowDiagram.nodeHelp', {
|
|
543
817
|
defaultValue: 'Press Enter or Space to select a state.',
|
|
544
818
|
}),
|
|
@@ -550,47 +824,117 @@ export function FlowDiagram({
|
|
|
550
824
|
}),
|
|
551
825
|
}}
|
|
552
826
|
>
|
|
553
|
-
<Background color='var(--border)' gap={24} size={1.5} />
|
|
554
|
-
{
|
|
555
|
-
<
|
|
827
|
+
{!compact && <Background color='var(--border)' gap={24} size={1.5} />}
|
|
828
|
+
{!compact && (
|
|
829
|
+
<Panel position='top-left' className='pointer-events-none!'>
|
|
830
|
+
<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'>
|
|
831
|
+
<span className='inline-flex items-center gap-1.5'>
|
|
832
|
+
<GitBranchIcon className='size-3.5' aria-hidden />
|
|
833
|
+
{t('flowDiagram.states', { defaultValue: 'States' })}
|
|
834
|
+
<span className='font-semibold text-foreground tabular-nums'>
|
|
835
|
+
{nodes.filter((node) => !node.decorative).length}
|
|
836
|
+
</span>
|
|
837
|
+
</span>
|
|
838
|
+
<span className='h-3 w-px bg-border' aria-hidden />
|
|
839
|
+
<span>
|
|
840
|
+
{t('flowDiagram.transitions', {
|
|
841
|
+
defaultValue: 'Transitions',
|
|
842
|
+
})}{' '}
|
|
843
|
+
<span className='font-semibold text-foreground tabular-nums'>
|
|
844
|
+
{edges.filter((edge) => !edge.decorative).length}
|
|
845
|
+
</span>
|
|
846
|
+
</span>
|
|
847
|
+
</div>
|
|
848
|
+
</Panel>
|
|
849
|
+
)}
|
|
850
|
+
{!controls && <DiagramToolbar top={autoHeight} />}
|
|
851
|
+
{controls && (
|
|
852
|
+
<>
|
|
853
|
+
<Controls
|
|
854
|
+
fitViewOptions={{
|
|
855
|
+
padding: 0.08,
|
|
856
|
+
minZoom: initialMinZoom,
|
|
857
|
+
maxZoom: initialZoomLimit,
|
|
858
|
+
}}
|
|
859
|
+
showInteractive={false}
|
|
860
|
+
aria-label={t('flowDiagram.controls', {
|
|
861
|
+
defaultValue: 'Diagram controls',
|
|
862
|
+
})}
|
|
863
|
+
>
|
|
864
|
+
<ControlButton
|
|
865
|
+
type='button'
|
|
866
|
+
onClick={() => setExpanded((value) => !value)}
|
|
867
|
+
aria-label={t(
|
|
868
|
+
expanded
|
|
869
|
+
? 'flowDiagram.exitFullscreen'
|
|
870
|
+
: 'flowDiagram.fullscreen',
|
|
871
|
+
{
|
|
872
|
+
defaultValue: expanded ? 'Exit fullscreen' : 'Fullscreen',
|
|
873
|
+
},
|
|
874
|
+
)}
|
|
875
|
+
title={t(
|
|
876
|
+
expanded
|
|
877
|
+
? 'flowDiagram.exitFullscreen'
|
|
878
|
+
: 'flowDiagram.fullscreen',
|
|
879
|
+
{
|
|
880
|
+
defaultValue: expanded ? 'Exit fullscreen' : 'Fullscreen',
|
|
881
|
+
},
|
|
882
|
+
)}
|
|
883
|
+
>
|
|
884
|
+
{expanded ? <Shrink /> : <Expand />}
|
|
885
|
+
</ControlButton>
|
|
886
|
+
</Controls>
|
|
887
|
+
{expanded && (
|
|
888
|
+
<Panel position='top-right'>
|
|
889
|
+
<div className='flex items-center gap-3 rounded-lg border bg-card p-2 text-card-foreground shadow-sm'>
|
|
890
|
+
<span className='text-xs text-muted-foreground'>
|
|
891
|
+
{t('flowDiagram.escapeHint', {
|
|
892
|
+
defaultValue: 'Press Escape to exit',
|
|
893
|
+
})}
|
|
894
|
+
</span>
|
|
895
|
+
<Button
|
|
896
|
+
variant='outline'
|
|
897
|
+
size='sm'
|
|
898
|
+
onClick={() => setExpanded(false)}
|
|
899
|
+
>
|
|
900
|
+
<X />
|
|
901
|
+
{t('flowDiagram.exitFullscreen', {
|
|
902
|
+
defaultValue: 'Exit fullscreen',
|
|
903
|
+
})}
|
|
904
|
+
</Button>
|
|
905
|
+
</div>
|
|
906
|
+
</Panel>
|
|
907
|
+
)}
|
|
908
|
+
</>
|
|
909
|
+
)}
|
|
910
|
+
{(expanded || showMiniMap) && (
|
|
911
|
+
<MiniMap<DiagramNode>
|
|
556
912
|
pannable
|
|
557
913
|
zoomable
|
|
558
|
-
ariaLabel={t('flowDiagram.
|
|
914
|
+
ariaLabel={t('flowDiagram.minimap', {
|
|
559
915
|
defaultValue: 'Diagram overview',
|
|
560
916
|
})}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
917
|
+
style={{ background: 'var(--card)' }}
|
|
918
|
+
nodeComponent={DiagramMiniMapNode}
|
|
919
|
+
nodeClassName={(node) =>
|
|
920
|
+
node.data.item.minimapShape === 'pill' ? 'minimap-pill' : ''
|
|
921
|
+
}
|
|
922
|
+
nodeColor={(node) =>
|
|
923
|
+
`color-mix(in srgb, ${node.data.item.minimapColor ?? 'var(--muted-foreground)'} 18%, var(--card))`
|
|
924
|
+
}
|
|
925
|
+
nodeStrokeColor={(node) =>
|
|
926
|
+
`color-mix(in srgb, ${node.data.item.minimapColor ?? 'var(--muted-foreground)'} 35%, var(--card))`
|
|
927
|
+
}
|
|
928
|
+
nodeStrokeWidth={2}
|
|
929
|
+
maskColor='color-mix(in srgb, var(--muted) 70%, transparent)'
|
|
930
|
+
className='max-w-[35%]'
|
|
569
931
|
/>
|
|
570
932
|
)}
|
|
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
933
|
</ReactFlow>
|
|
591
934
|
)}
|
|
592
935
|
</div>
|
|
593
936
|
);
|
|
937
|
+
return expanded ? createPortal(canvas, document.body) : canvas;
|
|
594
938
|
}
|
|
595
939
|
|
|
596
940
|
/** View controls belong inside React Flow so they always use this canvas's viewport. */
|
|
@@ -672,3 +1016,221 @@ function DiagramToolbar({
|
|
|
672
1016
|
</Panel>
|
|
673
1017
|
);
|
|
674
1018
|
}
|
|
1019
|
+
|
|
1020
|
+
export type FlowDiagramStepKind =
|
|
1021
|
+
'start' | 'approval' | 'work' | 'success' | 'negative' | 'exception';
|
|
1022
|
+
|
|
1023
|
+
const stepIcons = {
|
|
1024
|
+
start: PlayIcon,
|
|
1025
|
+
approval: UserCheck,
|
|
1026
|
+
work: Wrench,
|
|
1027
|
+
success: CircleCheck,
|
|
1028
|
+
negative: CircleX,
|
|
1029
|
+
exception: TriangleAlert,
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// Only intermediate steps use filled icon tiles; boundary icons inherit their pill tone.
|
|
1033
|
+
const stepIconTones = {
|
|
1034
|
+
approval: 'bg-primary text-primary-foreground',
|
|
1035
|
+
work: 'bg-[var(--area-administration,var(--chart-2))] text-background',
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
// The tone of the state at the other end of a route listed beside a node.
|
|
1039
|
+
const stepDotTones: Record<FlowDiagramStepKind, string> = {
|
|
1040
|
+
start: 'bg-muted-foreground',
|
|
1041
|
+
approval: 'bg-primary',
|
|
1042
|
+
work: 'bg-[var(--area-administration,var(--chart-2))]',
|
|
1043
|
+
success: 'bg-(--status-success)',
|
|
1044
|
+
negative: 'bg-destructive',
|
|
1045
|
+
exception: 'bg-(--status-warning)',
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
export interface FlowDiagramStepProps {
|
|
1049
|
+
readonly kind: FlowDiagramStepKind;
|
|
1050
|
+
readonly label: ReactNode;
|
|
1051
|
+
readonly description?: ReactNode;
|
|
1052
|
+
readonly icon?: ReactNode;
|
|
1053
|
+
readonly progress?: 'plan' | 'current' | 'visited' | 'unreached';
|
|
1054
|
+
/** Joined to the active state by a route left off the canvas. */
|
|
1055
|
+
readonly linked?: boolean;
|
|
1056
|
+
readonly actions?: ReactNode;
|
|
1057
|
+
/** Supporting exits or details revealed by the canvas's active state. */
|
|
1058
|
+
readonly children?: ReactNode;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Shared process vocabulary: boundary pills, work cards and actual execution status. */
|
|
1062
|
+
export function FlowDiagramStep({
|
|
1063
|
+
kind,
|
|
1064
|
+
label,
|
|
1065
|
+
description,
|
|
1066
|
+
icon,
|
|
1067
|
+
progress = 'plan',
|
|
1068
|
+
linked = false,
|
|
1069
|
+
actions,
|
|
1070
|
+
children,
|
|
1071
|
+
}: FlowDiagramStepProps): ReactElement {
|
|
1072
|
+
const { t } = useTranslation();
|
|
1073
|
+
const card = kind === 'approval' || kind === 'work';
|
|
1074
|
+
const Icon = stepIcons[kind];
|
|
1075
|
+
return (
|
|
1076
|
+
<div
|
|
1077
|
+
data-execution-state={progress}
|
|
1078
|
+
className={cn(
|
|
1079
|
+
'relative flex h-full border bg-card text-card-foreground transition-[border-color,box-shadow,opacity]',
|
|
1080
|
+
card
|
|
1081
|
+
? 'items-center gap-3 rounded-lg px-3 shadow-xs'
|
|
1082
|
+
: 'flex-col items-center justify-center rounded-full px-4',
|
|
1083
|
+
kind === 'success' &&
|
|
1084
|
+
'border-(--status-success)/30 bg-(--status-success-background) text-(--status-success)',
|
|
1085
|
+
kind === 'negative' &&
|
|
1086
|
+
'border-dashed border-destructive/40 bg-destructive/5 text-destructive',
|
|
1087
|
+
kind === 'exception' &&
|
|
1088
|
+
'border-dashed border-(--status-warning)/35 bg-(--status-warning-background) text-(--status-warning)',
|
|
1089
|
+
progress === 'current' && 'border-primary ring-2 ring-primary/40',
|
|
1090
|
+
progress === 'unreached' && 'border-dashed opacity-55',
|
|
1091
|
+
linked &&
|
|
1092
|
+
'opacity-100 outline-2 outline-offset-2 outline-primary/60 outline-dashed',
|
|
1093
|
+
)}
|
|
1094
|
+
>
|
|
1095
|
+
<span
|
|
1096
|
+
className={cn(
|
|
1097
|
+
'flex max-w-full items-center gap-1.5',
|
|
1098
|
+
card && 'w-full gap-3',
|
|
1099
|
+
)}
|
|
1100
|
+
>
|
|
1101
|
+
<span
|
|
1102
|
+
aria-hidden
|
|
1103
|
+
className={cn(
|
|
1104
|
+
'flex shrink-0 items-center justify-center',
|
|
1105
|
+
card && 'size-8 rounded-md shadow-xs',
|
|
1106
|
+
card && stepIconTones[kind],
|
|
1107
|
+
)}
|
|
1108
|
+
>
|
|
1109
|
+
{icon ?? <Icon className='size-4' />}
|
|
1110
|
+
</span>
|
|
1111
|
+
<span
|
|
1112
|
+
className={cn(
|
|
1113
|
+
'flex min-w-0 flex-1 flex-col text-sm font-medium',
|
|
1114
|
+
card && 'text-left',
|
|
1115
|
+
)}
|
|
1116
|
+
>
|
|
1117
|
+
<span className='truncate'>{label}</span>
|
|
1118
|
+
{description && (
|
|
1119
|
+
<span className='truncate text-xs font-normal text-muted-foreground'>
|
|
1120
|
+
{description}
|
|
1121
|
+
</span>
|
|
1122
|
+
)}
|
|
1123
|
+
</span>
|
|
1124
|
+
{card && actions}
|
|
1125
|
+
</span>
|
|
1126
|
+
{!card && actions}
|
|
1127
|
+
{progress === 'current' && (
|
|
1128
|
+
<span className='absolute -top-2.5 right-2 rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground'>
|
|
1129
|
+
{t('flowDiagram.current', { defaultValue: 'Current' })}
|
|
1130
|
+
</span>
|
|
1131
|
+
)}
|
|
1132
|
+
{progress === 'visited' && card && (
|
|
1133
|
+
<span
|
|
1134
|
+
className='absolute -top-2 right-2 flex size-4 items-center justify-center rounded-full bg-(--status-success) text-primary-foreground'
|
|
1135
|
+
title={t('flowDiagram.visited', { defaultValue: 'Visited' })}
|
|
1136
|
+
>
|
|
1137
|
+
<CheckIcon aria-hidden className='size-3' />
|
|
1138
|
+
<span className='sr-only'>
|
|
1139
|
+
{t('flowDiagram.visited', { defaultValue: 'Visited' })}
|
|
1140
|
+
</span>
|
|
1141
|
+
</span>
|
|
1142
|
+
)}
|
|
1143
|
+
{children}
|
|
1144
|
+
</div>
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
export interface FlowDiagramTransitionNote {
|
|
1149
|
+
readonly id: string;
|
|
1150
|
+
/** Short translated action. */
|
|
1151
|
+
readonly label: ReactNode;
|
|
1152
|
+
/** Complete translated route, read by assistive technology. */
|
|
1153
|
+
readonly detail?: string;
|
|
1154
|
+
/** Tone of the state at the other end of the route. */
|
|
1155
|
+
readonly kind?: FlowDiagramStepKind;
|
|
1156
|
+
/** Lists the route under "Leaves to" (the default) or "Arrives from". */
|
|
1157
|
+
readonly direction?: 'out' | 'in';
|
|
1158
|
+
/** Translated labels of the states at the other end of the route. */
|
|
1159
|
+
readonly peers?: readonly string[];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Routes left off the canvas, listed in one card beside the active node. */
|
|
1163
|
+
export function FlowDiagramTransitionNotes({
|
|
1164
|
+
notes,
|
|
1165
|
+
}: {
|
|
1166
|
+
readonly notes: readonly FlowDiagramTransitionNote[];
|
|
1167
|
+
}): ReactElement | null {
|
|
1168
|
+
const { t } = useTranslation();
|
|
1169
|
+
if (!notes.length) return null;
|
|
1170
|
+
const sections = [
|
|
1171
|
+
{
|
|
1172
|
+
direction: 'out',
|
|
1173
|
+
title: t('flowDiagram.leavesTo', { defaultValue: 'Leaves to' }),
|
|
1174
|
+
icon: ArrowRightIcon,
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
direction: 'in',
|
|
1178
|
+
title: t('flowDiagram.arrivesFrom', { defaultValue: 'Arrives from' }),
|
|
1179
|
+
icon: ArrowLeftIcon,
|
|
1180
|
+
},
|
|
1181
|
+
] as const;
|
|
1182
|
+
return (
|
|
1183
|
+
<div
|
|
1184
|
+
role='group'
|
|
1185
|
+
aria-label={t('flowDiagram.otherRoutes', {
|
|
1186
|
+
defaultValue: 'Other routes',
|
|
1187
|
+
})}
|
|
1188
|
+
className='nodrag nopan absolute top-1/2 left-full z-10 -translate-y-1/2 pl-2 text-left'
|
|
1189
|
+
>
|
|
1190
|
+
<div className='flex w-max max-w-72 flex-col gap-2 rounded-lg border bg-popover p-2.5 text-xs text-popover-foreground shadow-md'>
|
|
1191
|
+
{sections.map(({ direction, title, icon: Icon }) => {
|
|
1192
|
+
const items = notes.filter(
|
|
1193
|
+
(note) => (note.direction ?? 'out') === direction,
|
|
1194
|
+
);
|
|
1195
|
+
if (!items.length) return null;
|
|
1196
|
+
return (
|
|
1197
|
+
<div key={direction} className='flex flex-col gap-1'>
|
|
1198
|
+
<p className='font-medium text-muted-foreground'>{title}</p>
|
|
1199
|
+
<ul className='flex flex-col gap-1'>
|
|
1200
|
+
{items.map((note) => (
|
|
1201
|
+
<li key={note.id}>
|
|
1202
|
+
<span
|
|
1203
|
+
role='note'
|
|
1204
|
+
aria-label={note.detail}
|
|
1205
|
+
className='flex min-w-0 items-center gap-1.5'
|
|
1206
|
+
>
|
|
1207
|
+
<span
|
|
1208
|
+
aria-hidden
|
|
1209
|
+
className={cn(
|
|
1210
|
+
'size-1.5 shrink-0 rounded-full',
|
|
1211
|
+
stepDotTones[note.kind ?? 'start'],
|
|
1212
|
+
)}
|
|
1213
|
+
/>
|
|
1214
|
+
<span className='shrink-0 font-medium'>{note.label}</span>
|
|
1215
|
+
{note.peers?.length ? (
|
|
1216
|
+
<>
|
|
1217
|
+
<Icon
|
|
1218
|
+
aria-hidden
|
|
1219
|
+
className='size-3 shrink-0 text-muted-foreground'
|
|
1220
|
+
/>
|
|
1221
|
+
<span className='truncate text-muted-foreground'>
|
|
1222
|
+
{note.peers.join(', ')}
|
|
1223
|
+
</span>
|
|
1224
|
+
</>
|
|
1225
|
+
) : null}
|
|
1226
|
+
</span>
|
|
1227
|
+
</li>
|
|
1228
|
+
))}
|
|
1229
|
+
</ul>
|
|
1230
|
+
</div>
|
|
1231
|
+
);
|
|
1232
|
+
})}
|
|
1233
|
+
</div>
|
|
1234
|
+
</div>
|
|
1235
|
+
);
|
|
1236
|
+
}
|