@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.77
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/default-access-editor.tsx +79 -52
- 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/long-text.tsx +200 -0
- package/client/components/permission-editor.tsx +534 -341
- 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 +51 -4
- package/client/locales/system/settings-zh-CN.ts +46 -5
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +14 -2
- package/client/pages/settings/default-access-model.ts +6 -2
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/page-permissions.ts +166 -1
- package/client/pages/settings/permission-filters.tsx +9 -2
- package/client/pages/settings/permission-workspace.ts +330 -0
- package/client/pages/settings/permissions-model.ts +62 -53
- package/client/pages/settings/role-form.tsx +31 -95
- 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 +17 -17
- package/server/config/app.ts +1 -1
- package/server/config/session.ts +7 -5
- package/tests/components/default-access-editor.test.tsx +130 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/permission-editor.test.tsx +613 -572
- package/tests/components/permission-filter-binding.test.tsx +20 -0
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +34 -9
- 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/components/user-permissions.test.tsx +47 -13
- package/tests/logic/config.test.ts +3 -1
- package/tests/logic/page-permission-navigation.test.ts +394 -0
- package/tests/logic/permission-workspace.test.ts +570 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tests/logic/session-isolation.test.ts +250 -0
- package/tsconfig.json +1 -0
|
@@ -18,6 +18,10 @@ export interface FlowDiagramNode {
|
|
|
18
18
|
readonly visited?: boolean;
|
|
19
19
|
readonly highlighted?: boolean;
|
|
20
20
|
readonly className?: string;
|
|
21
|
+
/** the caller's rendered content draws the whole node, without the default box or state tint. */
|
|
22
|
+
readonly bare?: boolean;
|
|
23
|
+
readonly minimapShape?: 'pill' | 'card';
|
|
24
|
+
readonly minimapColor?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export interface FlowDiagramEdge {
|
|
@@ -32,6 +36,10 @@ export interface FlowDiagramEdge {
|
|
|
32
36
|
readonly visited?: boolean;
|
|
33
37
|
readonly highlighted?: boolean;
|
|
34
38
|
readonly dashed?: boolean;
|
|
39
|
+
/** a secondary route drawn thin and light, so the main path reads first. */
|
|
40
|
+
readonly faint?: boolean;
|
|
41
|
+
/** hover and accessible text for an edge that shows no label on the canvas. */
|
|
42
|
+
readonly title?: string;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export type FlowDiagramDirection = 'RIGHT' | 'DOWN';
|
|
@@ -57,6 +65,28 @@ export interface FlowDiagramLayout {
|
|
|
57
65
|
})[];
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** Wrap condition labels without splitting words. */
|
|
69
|
+
export function flowLabelLines(text: string): string[] {
|
|
70
|
+
const lines: string[] = [];
|
|
71
|
+
let line = '',
|
|
72
|
+
width = 0;
|
|
73
|
+
for (const token of text.match(/[a-zA-Z0-9_]+|[^a-zA-Z0-9_]/g) ?? []) {
|
|
74
|
+
const tokenWidth = [...token].reduce(
|
|
75
|
+
(n, char) => n + (char.charCodeAt(0) > 255 ? 14 : 7),
|
|
76
|
+
0,
|
|
77
|
+
);
|
|
78
|
+
if (line && width + tokenWidth > 252) {
|
|
79
|
+
lines.push(line.trim());
|
|
80
|
+
line = '';
|
|
81
|
+
width = 0;
|
|
82
|
+
}
|
|
83
|
+
line += token;
|
|
84
|
+
width += tokenWidth;
|
|
85
|
+
}
|
|
86
|
+
if (line.trim()) lines.push(line.trim());
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
60
90
|
type ElkConstructor = new (args?: ELKConstructorArguments) => ELK;
|
|
61
91
|
let elkPromise: Promise<ELK> | undefined;
|
|
62
92
|
|
|
@@ -73,6 +103,7 @@ function getElk(): Promise<ELK> {
|
|
|
73
103
|
export async function layoutFlowDiagram(
|
|
74
104
|
graph: FlowDiagramGraph,
|
|
75
105
|
direction: FlowDiagramDirection = 'RIGHT',
|
|
106
|
+
compact = false,
|
|
76
107
|
): Promise<FlowDiagramLayout> {
|
|
77
108
|
const ids = new Set<string>();
|
|
78
109
|
for (const node of graph.nodes) {
|
|
@@ -104,16 +135,16 @@ export async function layoutFlowDiagram(
|
|
|
104
135
|
'elk.algorithm': 'layered',
|
|
105
136
|
'elk.direction': direction,
|
|
106
137
|
'elk.edgeRouting': 'ORTHOGONAL',
|
|
107
|
-
'elk.spacing.nodeNode': '40',
|
|
108
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': '72',
|
|
138
|
+
'elk.spacing.nodeNode': compact ? '20' : '40',
|
|
139
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': compact ? '28' : '72',
|
|
109
140
|
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
110
141
|
// Declared state order keeps retry edges from moving later states before the start.
|
|
111
142
|
'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
|
|
112
143
|
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
113
144
|
'elk.layered.thoroughness': '32',
|
|
114
|
-
'elk.spacing.edgeEdge': '20',
|
|
115
|
-
'elk.layered.spacing.edgeEdgeBetweenLayers': '20',
|
|
116
|
-
'elk.layered.spacing.edgeNodeBetweenLayers': '24',
|
|
145
|
+
'elk.spacing.edgeEdge': compact ? '8' : '20',
|
|
146
|
+
'elk.layered.spacing.edgeEdgeBetweenLayers': compact ? '8' : '20',
|
|
147
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': compact ? '8' : '24',
|
|
117
148
|
},
|
|
118
149
|
children: graph.nodes.map((node) => {
|
|
119
150
|
const id = nodeIds.get(node.id)!;
|
|
@@ -166,13 +197,13 @@ export async function layoutFlowDiagram(
|
|
|
166
197
|
'elk.edgeLabels.inline': 'true',
|
|
167
198
|
},
|
|
168
199
|
width: Math.min(
|
|
169
|
-
|
|
200
|
+
268,
|
|
170
201
|
[...edge.label].reduce(
|
|
171
202
|
(total, char) => total + (char.charCodeAt(0) > 255 ? 14 : 7),
|
|
172
203
|
16,
|
|
173
204
|
),
|
|
174
205
|
),
|
|
175
|
-
height:
|
|
206
|
+
height: flowLabelLines(edge.label).length * 18 + 8,
|
|
176
207
|
},
|
|
177
208
|
]
|
|
178
209
|
: [],
|
|
@@ -248,3 +279,82 @@ export async function layoutFlowDiagram(
|
|
|
248
279
|
}),
|
|
249
280
|
};
|
|
250
281
|
}
|
|
282
|
+
|
|
283
|
+
type PlacedFlowNode = FlowDiagramLayout['nodes'][number];
|
|
284
|
+
|
|
285
|
+
/** a transient route for an edge left out of the layout. It runs in a lane
|
|
286
|
+
* outside every node it passes, so revealing it never moves the laid-out graph.
|
|
287
|
+
* Deeper lanes keep nested routes from crossing one another; a self-transition loops
|
|
288
|
+
* beside its own node. The label sits outside the lane instead of over the nodes. */
|
|
289
|
+
export function revealRoute(
|
|
290
|
+
nodes: readonly PlacedFlowNode[],
|
|
291
|
+
source: PlacedFlowNode,
|
|
292
|
+
target: PlacedFlowNode,
|
|
293
|
+
direction: FlowDiagramDirection,
|
|
294
|
+
lane = 0,
|
|
295
|
+
): {
|
|
296
|
+
points: FlowDiagramPoint[];
|
|
297
|
+
labelPosition: FlowDiagramPoint;
|
|
298
|
+
labelAnchor: 'end' | 'middle';
|
|
299
|
+
} {
|
|
300
|
+
// Measure along the flow (main) and across it; the lane runs before the nodes on the cross axis.
|
|
301
|
+
const down = direction === 'DOWN';
|
|
302
|
+
const main = (node: PlacedFlowNode) => (down ? node.y : node.x);
|
|
303
|
+
const cross = (node: PlacedFlowNode) => (down ? node.x : node.y);
|
|
304
|
+
const size = (node: PlacedFlowNode) => (down ? node.height : node.width);
|
|
305
|
+
const point = (along: number, across: number): FlowDiagramPoint =>
|
|
306
|
+
down ? { x: across, y: along } : { x: along, y: across };
|
|
307
|
+
const loop = source === target;
|
|
308
|
+
const start = Math.min(main(source), main(target));
|
|
309
|
+
const end = Math.max(
|
|
310
|
+
main(source) + size(source),
|
|
311
|
+
main(target) + size(target),
|
|
312
|
+
);
|
|
313
|
+
const lanePosition =
|
|
314
|
+
Math.min(
|
|
315
|
+
...(loop
|
|
316
|
+
? [cross(source)]
|
|
317
|
+
: nodes
|
|
318
|
+
.filter(
|
|
319
|
+
(node) => main(node) < end && main(node) + size(node) > start,
|
|
320
|
+
)
|
|
321
|
+
.map(cross)),
|
|
322
|
+
) -
|
|
323
|
+
16 -
|
|
324
|
+
lane * 10;
|
|
325
|
+
const from = main(source) + size(source) * (loop ? 0.3 : 0.5);
|
|
326
|
+
const to = main(target) + size(target) * (loop ? 0.7 : 0.5);
|
|
327
|
+
return {
|
|
328
|
+
points: [
|
|
329
|
+
point(from, cross(source)),
|
|
330
|
+
point(from, lanePosition),
|
|
331
|
+
point(to, lanePosition),
|
|
332
|
+
point(to, cross(target)),
|
|
333
|
+
],
|
|
334
|
+
// DOWN lanes run left of the nodes, so labels end at the lane; RIGHT lanes run above them.
|
|
335
|
+
labelPosition: point((from + to) / 2, lanePosition - (down ? 6 : 14)),
|
|
336
|
+
labelAnchor: down ? 'end' : 'middle',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Keep return and self-transitions out of the process layout. Declaration order
|
|
341
|
+
* describes forward progress; even adjacent returns can become long routes in ELK. */
|
|
342
|
+
export function partitionFlowDiagramReturns(graph: FlowDiagramGraph): {
|
|
343
|
+
edges: FlowDiagramEdge[];
|
|
344
|
+
extraEdges: FlowDiagramEdge[];
|
|
345
|
+
} {
|
|
346
|
+
const order = new Map(graph.nodes.map((node, index) => [node.id, index]));
|
|
347
|
+
const edges: FlowDiagramEdge[] = [],
|
|
348
|
+
extraEdges: FlowDiagramEdge[] = [];
|
|
349
|
+
for (const edge of graph.edges) {
|
|
350
|
+
const source = order.get(edge.source),
|
|
351
|
+
target = order.get(edge.target);
|
|
352
|
+
const returnRoute =
|
|
353
|
+
!edge.decorative &&
|
|
354
|
+
source !== undefined &&
|
|
355
|
+
target !== undefined &&
|
|
356
|
+
source >= target;
|
|
357
|
+
(returnRoute ? extraEdges : edges).push(edge);
|
|
358
|
+
}
|
|
359
|
+
return { edges, extraEdges };
|
|
360
|
+
}
|
|
@@ -15,8 +15,18 @@ import {
|
|
|
15
15
|
PopoverTrigger,
|
|
16
16
|
} from '#components/ui/popover';
|
|
17
17
|
import type { LifecycleDescription } from '@nocobase/lifecycle';
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
FlowDiagram,
|
|
20
|
+
FlowDiagramStep,
|
|
21
|
+
FlowDiagramTransitionNotes,
|
|
22
|
+
type FlowDiagramProps,
|
|
23
|
+
type FlowDiagramStepKind,
|
|
24
|
+
} from '#components/flow-diagram';
|
|
25
|
+
import {
|
|
26
|
+
partitionFlowDiagramReturns,
|
|
27
|
+
type FlowDiagramEdge,
|
|
28
|
+
type FlowDiagramNode,
|
|
29
|
+
} from '#components/flow-layout';
|
|
20
30
|
|
|
21
31
|
import {
|
|
22
32
|
lifecycleDiagramGraph,
|
|
@@ -41,6 +51,13 @@ export interface LifecycleDiagramProps
|
|
|
41
51
|
| 'onEdgeClick'
|
|
42
52
|
> {
|
|
43
53
|
readonly description: LifecycleDescription;
|
|
54
|
+
/** Opt into compact boundary pills and work cards; the classic presentation remains the default. */
|
|
55
|
+
readonly presentation?: 'classic' | 'process';
|
|
56
|
+
/** Business semantics belong to the caller; unknown intermediate states default to work. */
|
|
57
|
+
readonly stateKind?: (
|
|
58
|
+
state: LifecycleDescription['stateInfo'][number],
|
|
59
|
+
) => FlowDiagramStepKind;
|
|
60
|
+
|
|
44
61
|
/** Opens the application-owned, authorized parameter editor for declared bindings. */
|
|
45
62
|
readonly configuration?: LifecycleDiagramConfiguration;
|
|
46
63
|
readonly stateActions?: (
|
|
@@ -61,6 +78,9 @@ export interface LifecycleDiagramProps
|
|
|
61
78
|
/** Definitions are source-owned; clicking opens consumer-owned details, never fires a transition. */
|
|
62
79
|
export function LifecycleDiagram({
|
|
63
80
|
description,
|
|
81
|
+
presentation = 'classic',
|
|
82
|
+
stateKind,
|
|
83
|
+
nodeDescription,
|
|
64
84
|
currentState,
|
|
65
85
|
endLabel,
|
|
66
86
|
history,
|
|
@@ -126,51 +146,169 @@ export function LifecycleDiagram({
|
|
|
126
146
|
})),
|
|
127
147
|
}
|
|
128
148
|
: sourceGraph;
|
|
149
|
+
const process = presentation === 'process';
|
|
150
|
+
const routes = process
|
|
151
|
+
? partitionFlowDiagramReturns(graph)
|
|
152
|
+
: { edges: graph.edges, extraEdges: [] };
|
|
153
|
+
const notesForNode = (id: string) => {
|
|
154
|
+
const nodeOf = (nodeId: string) =>
|
|
155
|
+
graph.nodes.find((node) => node.id === nodeId);
|
|
156
|
+
const labelOf = (nodeId: string) => nodeOf(nodeId)?.label ?? nodeId;
|
|
157
|
+
// A transition with several sources or targets becomes one edge per pair;
|
|
158
|
+
// list it once per direction with every state at the other end.
|
|
159
|
+
const groups = new Map<
|
|
160
|
+
string,
|
|
161
|
+
{ edge: FlowDiagramEdge; direction: 'out' | 'in'; peers: string[] }
|
|
162
|
+
>();
|
|
163
|
+
for (const edge of routes.extraEdges) {
|
|
164
|
+
const direction =
|
|
165
|
+
edge.source === id ? 'out' : edge.target === id ? 'in' : undefined;
|
|
166
|
+
if (!direction) continue;
|
|
167
|
+
const [name] = JSON.parse(edge.id) as string[];
|
|
168
|
+
const key = JSON.stringify([name, direction]);
|
|
169
|
+
const group = groups.get(key) ?? { edge, direction, peers: [] };
|
|
170
|
+
groups.set(key, group);
|
|
171
|
+
const peer = direction === 'out' ? edge.target : edge.source;
|
|
172
|
+
if (!group.peers.includes(peer)) group.peers.push(peer);
|
|
173
|
+
}
|
|
174
|
+
return [...groups.values()].map(({ edge, direction, peers }) => {
|
|
175
|
+
const self = labelOf(id);
|
|
176
|
+
const names = peers.map(labelOf);
|
|
177
|
+
return {
|
|
178
|
+
id: `${direction}:${edge.id}`,
|
|
179
|
+
label: edge.label,
|
|
180
|
+
direction,
|
|
181
|
+
peers: names,
|
|
182
|
+
detail:
|
|
183
|
+
direction === 'out'
|
|
184
|
+
? `${self} · ${edge.label ?? ''} → ${names.join(', ')}`
|
|
185
|
+
: `${names.join(', ')} · ${edge.label ?? ''} → ${self}`,
|
|
186
|
+
kind: kindForNode(nodeOf(peers[0])!),
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
const kindForNode = (node: FlowDiagramNode): FlowDiagramStepKind => {
|
|
191
|
+
const state = description.stateInfo.find((state) => state.name === node.id);
|
|
192
|
+
return (
|
|
193
|
+
(state && stateKind?.(state)) ??
|
|
194
|
+
(node.kind === 'initial'
|
|
195
|
+
? 'start'
|
|
196
|
+
: node.kind === 'final'
|
|
197
|
+
? 'success'
|
|
198
|
+
: 'work')
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
const nodeActions = (node: FlowDiagramNode): ReactNode => {
|
|
202
|
+
const state = description.stateInfo.find((state) => state.name === node.id);
|
|
203
|
+
if (!state) return undefined;
|
|
204
|
+
const actions = stateActions?.(state);
|
|
205
|
+
const selections =
|
|
206
|
+
configuration &&
|
|
207
|
+
lifecycleNodeConfigurationSelections(
|
|
208
|
+
description,
|
|
209
|
+
state.name,
|
|
210
|
+
configuration.canConfigure,
|
|
211
|
+
);
|
|
212
|
+
if (!selections?.length || !configuration) return actions;
|
|
213
|
+
return (
|
|
214
|
+
<div className='nodrag nopan flex items-center gap-1'>
|
|
215
|
+
{actions}
|
|
216
|
+
<NodeConfigurationActions
|
|
217
|
+
configuration={configuration}
|
|
218
|
+
selections={selections}
|
|
219
|
+
state={{ kind: 'state', name: state.name }}
|
|
220
|
+
title={(target) => {
|
|
221
|
+
if (target.kind === 'state')
|
|
222
|
+
return stateLabel?.(state) ?? state.title;
|
|
223
|
+
const transition = description.transitions.find(
|
|
224
|
+
(item) => item.name === target.name,
|
|
225
|
+
)!;
|
|
226
|
+
return transitionLabel?.(transition) ?? transition.title;
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
// A transition declared from several states draws one edge per source, which
|
|
233
|
+
// would repeat its label beside the target. Label only the edge from the
|
|
234
|
+
// nearest source; the others keep the action as hover text.
|
|
235
|
+
const order = new Map(graph.nodes.map((node, index) => [node.id, index]));
|
|
236
|
+
const labelledEdges = new Map<string, FlowDiagramEdge>();
|
|
237
|
+
for (const edge of routes.edges) {
|
|
238
|
+
if (edge.decorative) continue;
|
|
239
|
+
const [name] = JSON.parse(edge.id) as string[];
|
|
240
|
+
const key = JSON.stringify([name, edge.target]);
|
|
241
|
+
const current = labelledEdges.get(key);
|
|
242
|
+
if (!current || order.get(edge.source)! > order.get(current.source)!)
|
|
243
|
+
labelledEdges.set(key, edge);
|
|
244
|
+
}
|
|
245
|
+
const processEdges = routes.edges.map((edge) =>
|
|
246
|
+
edge.decorative || [...labelledEdges.values()].includes(edge)
|
|
247
|
+
? { ...edge, title: edge.label }
|
|
248
|
+
: { ...edge, title: edge.label, label: undefined, configurable: false },
|
|
249
|
+
);
|
|
250
|
+
const displayGraph = process
|
|
251
|
+
? {
|
|
252
|
+
...graph,
|
|
253
|
+
nodes: graph.nodes.map((node) => {
|
|
254
|
+
if (node.decorative) return node;
|
|
255
|
+
const kind = kindForNode(node);
|
|
256
|
+
const card = kind === 'work' || kind === 'approval';
|
|
257
|
+
return {
|
|
258
|
+
...node,
|
|
259
|
+
bare: true,
|
|
260
|
+
width: card ? 240 : 176,
|
|
261
|
+
height: card ? 80 : nodeDescription?.(node) ? 64 : 44,
|
|
262
|
+
minimapShape: card ? ('card' as const) : ('pill' as const),
|
|
263
|
+
};
|
|
264
|
+
}),
|
|
265
|
+
edges: processEdges,
|
|
266
|
+
}
|
|
267
|
+
: graph;
|
|
129
268
|
return (
|
|
130
269
|
<FlowDiagram
|
|
131
270
|
direction='DOWN'
|
|
132
|
-
initialMinZoom={0.7}
|
|
271
|
+
initialMinZoom={process ? 0.1 : 0.7}
|
|
272
|
+
compact={process}
|
|
273
|
+
controls={process}
|
|
274
|
+
nodeDescription={nodeDescription}
|
|
133
275
|
autoHeight
|
|
134
276
|
{...props}
|
|
135
|
-
{...
|
|
136
|
-
|
|
277
|
+
{...displayGraph}
|
|
278
|
+
revealEdges={process ? undefined : props.revealEdges}
|
|
279
|
+
auxiliaryEdges={process ? routes.extraEdges : props.auxiliaryEdges}
|
|
280
|
+
nodeActions={nodeActions}
|
|
281
|
+
renderNode={(node, { active, linked }) => {
|
|
137
282
|
const state = description.stateInfo.find(
|
|
138
283
|
(state) => state.name === node.id,
|
|
139
284
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
configuration &&
|
|
144
|
-
lifecycleNodeConfigurationSelections(
|
|
145
|
-
description,
|
|
146
|
-
state.name,
|
|
147
|
-
configuration.canConfigure,
|
|
148
|
-
);
|
|
149
|
-
if (!selections?.length || !configuration) return actions;
|
|
285
|
+
const label =
|
|
286
|
+
state && renderState ? renderState(state, node) : node.label;
|
|
287
|
+
if (!process || node.decorative) return label;
|
|
150
288
|
return (
|
|
151
|
-
<
|
|
152
|
-
{
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
289
|
+
<FlowDiagramStep
|
|
290
|
+
kind={kindForNode(node)}
|
|
291
|
+
label={label}
|
|
292
|
+
description={nodeDescription?.(node)}
|
|
293
|
+
actions={nodeActions(node)}
|
|
294
|
+
linked={linked}
|
|
295
|
+
progress={
|
|
296
|
+
node.highlighted
|
|
297
|
+
? 'current'
|
|
298
|
+
: node.visited
|
|
299
|
+
? 'visited'
|
|
300
|
+
: history !== undefined ||
|
|
301
|
+
currentState !== undefined ||
|
|
302
|
+
visitedStates !== undefined
|
|
303
|
+
? 'unreached'
|
|
304
|
+
: 'plan'
|
|
305
|
+
}
|
|
306
|
+
>
|
|
307
|
+
{active && (
|
|
308
|
+
<FlowDiagramTransitionNotes notes={notesForNode(node.id)} />
|
|
309
|
+
)}
|
|
310
|
+
</FlowDiagramStep>
|
|
172
311
|
);
|
|
173
|
-
return state && renderState ? renderState(state, node) : node.label;
|
|
174
312
|
}}
|
|
175
313
|
onNodeClick={(node) => {
|
|
176
314
|
const state = description.stateInfo.find(
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
type MouseEvent,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { cn } from 'cn';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
Popover,
|
|
15
|
+
PopoverContent,
|
|
16
|
+
PopoverHeader,
|
|
17
|
+
PopoverTitle,
|
|
18
|
+
PopoverTrigger,
|
|
19
|
+
} from '#components/ui/popover';
|
|
20
|
+
|
|
21
|
+
// The last line fades out rather than ending in an ellipsis mid-sentence.
|
|
22
|
+
const FADE = '[mask-image:linear-gradient(to_bottom,black_55%,transparent)]';
|
|
23
|
+
// Cut at a height of whole lines rather than with line-clamp, which would add an ellipsis under the fade.
|
|
24
|
+
const CLAMP = 'overflow-hidden max-h-[calc(var(--long-text-lines)*1lh)]';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether the element's content is taller than the lines it shows, measured again on every resize. The element comes
|
|
28
|
+
* through a callback ref, so measuring follows it when it is replaced, as LongTextPreview's is once it overflows and
|
|
29
|
+
* moves into the pop-up's trigger.
|
|
30
|
+
*/
|
|
31
|
+
function useOverflows(
|
|
32
|
+
watch: unknown,
|
|
33
|
+
): [(element: HTMLDivElement | null) => void, boolean | undefined] {
|
|
34
|
+
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
|
35
|
+
// Undefined until measured, so a remounted element does not first report that it fits.
|
|
36
|
+
const [overflows, setOverflows] = useState<boolean | undefined>(undefined);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!element || typeof ResizeObserver === 'undefined') return;
|
|
39
|
+
const measure = (): void =>
|
|
40
|
+
setOverflows(element.scrollHeight > element.clientHeight + 1);
|
|
41
|
+
// The observer reports once when it starts, then on every resize.
|
|
42
|
+
const observer = new ResizeObserver(measure);
|
|
43
|
+
observer.observe(element);
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, [element, watch]);
|
|
46
|
+
return [setElement, overflows];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LongTextProps {
|
|
50
|
+
/** The text, or rendered content such as Markdown. A string keeps its line breaks. */
|
|
51
|
+
readonly children: ReactNode;
|
|
52
|
+
/** How many lines show while folded. */
|
|
53
|
+
readonly lines?: number;
|
|
54
|
+
readonly className?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A long text where there is room for it, such as the description under a page's title or a comment: up to `lines`
|
|
59
|
+
* lines, the last fading out while there is more, unfolding in place on a click and folding again on the next. It
|
|
60
|
+
* reacts only when it overflows; a double-click or a text selection does not fold it, and Enter or Space works as a
|
|
61
|
+
* click.
|
|
62
|
+
*/
|
|
63
|
+
export function LongText({
|
|
64
|
+
children,
|
|
65
|
+
lines = 5,
|
|
66
|
+
className,
|
|
67
|
+
}: LongTextProps): ReactElement {
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const [ref, measured] = useOverflows(open);
|
|
70
|
+
const overflows = measured === true;
|
|
71
|
+
const foldable = overflows || open;
|
|
72
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
73
|
+
useEffect(() => () => clearTimeout(timerRef.current), []);
|
|
74
|
+
const toggle = (): void => setOpen((current) => !current);
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
ref={ref}
|
|
78
|
+
role={foldable ? 'button' : undefined}
|
|
79
|
+
tabIndex={foldable ? 0 : undefined}
|
|
80
|
+
aria-expanded={foldable ? open : undefined}
|
|
81
|
+
style={{ '--long-text-lines': lines } as CSSProperties}
|
|
82
|
+
className={cn(
|
|
83
|
+
'leading-relaxed break-words whitespace-pre-line',
|
|
84
|
+
foldable && 'cursor-pointer',
|
|
85
|
+
!open && CLAMP,
|
|
86
|
+
!open && overflows && FADE,
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
onClick={(event: MouseEvent<HTMLDivElement>) => {
|
|
90
|
+
// A double-click selects a word rather than folding twice, and a selection is not a click.
|
|
91
|
+
if (!foldable || event.detail > 1) return;
|
|
92
|
+
if (!window.getSelection()?.isCollapsed) return;
|
|
93
|
+
clearTimeout(timerRef.current);
|
|
94
|
+
timerRef.current = setTimeout(toggle, 220);
|
|
95
|
+
}}
|
|
96
|
+
onDoubleClick={() => clearTimeout(timerRef.current)}
|
|
97
|
+
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
|
98
|
+
if (!foldable || event.target !== event.currentTarget) return;
|
|
99
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
toggle();
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface FadingTextProps {
|
|
110
|
+
/** The text, or rendered content. A string keeps its line breaks. */
|
|
111
|
+
readonly children: ReactNode;
|
|
112
|
+
/** How many lines show. */
|
|
113
|
+
readonly lines?: number;
|
|
114
|
+
readonly className?: string;
|
|
115
|
+
/** Told whether the text overflows, each time that changes. */
|
|
116
|
+
readonly onOverflowChange?: (overflows: boolean) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The part both components are drawn with, for a consumer that opens the text its own way, such as a field editing it
|
|
121
|
+
* in a pop-up: up to `lines` lines, the last fading out while there is more, and nothing that reacts to a click.
|
|
122
|
+
*/
|
|
123
|
+
export function FadingText({
|
|
124
|
+
children,
|
|
125
|
+
lines = 3,
|
|
126
|
+
className,
|
|
127
|
+
onOverflowChange,
|
|
128
|
+
}: FadingTextProps): ReactElement {
|
|
129
|
+
const [ref, overflows] = useOverflows(children);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (overflows !== undefined) onOverflowChange?.(overflows);
|
|
132
|
+
}, [overflows, onOverflowChange]);
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={ref}
|
|
136
|
+
style={{ '--long-text-lines': lines } as CSSProperties}
|
|
137
|
+
className={cn(
|
|
138
|
+
'text-left leading-relaxed break-words whitespace-pre-line',
|
|
139
|
+
CLAMP,
|
|
140
|
+
overflows && FADE,
|
|
141
|
+
className,
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface LongTextPreviewProps {
|
|
150
|
+
/** The text, or rendered content. A string keeps its line breaks. */
|
|
151
|
+
readonly children: ReactNode;
|
|
152
|
+
/** What the text is, such as "Introduction": the heading of the pop-up that shows it whole. */
|
|
153
|
+
readonly label: ReactNode;
|
|
154
|
+
/** How many lines show in the column. */
|
|
155
|
+
readonly lines?: number;
|
|
156
|
+
readonly className?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A long text in a narrow column, such as a record's introduction or address beside its page: up to `lines` lines in
|
|
161
|
+
* muted text, the last fading out while there is more, the whole text opening in a pop-up beside it on a click, so
|
|
162
|
+
* the column never grows and pushes what is under it. It reacts only when it overflows.
|
|
163
|
+
*/
|
|
164
|
+
export function LongTextPreview({
|
|
165
|
+
children,
|
|
166
|
+
label,
|
|
167
|
+
lines = 3,
|
|
168
|
+
className,
|
|
169
|
+
}: LongTextPreviewProps): ReactElement {
|
|
170
|
+
const [overflows, setOverflows] = useState(false);
|
|
171
|
+
const preview = (
|
|
172
|
+
<FadingText
|
|
173
|
+
lines={lines}
|
|
174
|
+
className={cn('text-muted-foreground', className)}
|
|
175
|
+
onOverflowChange={setOverflows}
|
|
176
|
+
>
|
|
177
|
+
{children}
|
|
178
|
+
</FadingText>
|
|
179
|
+
);
|
|
180
|
+
if (!overflows) return preview;
|
|
181
|
+
return (
|
|
182
|
+
<Popover>
|
|
183
|
+
<PopoverTrigger className='block w-full cursor-pointer rounded-md text-left'>
|
|
184
|
+
{preview}
|
|
185
|
+
</PopoverTrigger>
|
|
186
|
+
<PopoverContent
|
|
187
|
+
align='start'
|
|
188
|
+
side='left'
|
|
189
|
+
className='max-h-[min(36rem,70vh)] w-[28rem] max-w-[calc(100vw-2rem)] overflow-y-auto'
|
|
190
|
+
>
|
|
191
|
+
<PopoverHeader>
|
|
192
|
+
<PopoverTitle>{label}</PopoverTitle>
|
|
193
|
+
</PopoverHeader>
|
|
194
|
+
<div className='text-sm leading-relaxed break-words whitespace-pre-line'>
|
|
195
|
+
{children}
|
|
196
|
+
</div>
|
|
197
|
+
</PopoverContent>
|
|
198
|
+
</Popover>
|
|
199
|
+
);
|
|
200
|
+
}
|