@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.71

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.
@@ -0,0 +1,250 @@
1
+ import type {
2
+ ELK,
3
+ ELKConstructorArguments,
4
+ ElkNode,
5
+ } from 'elkjs/lib/elk-api.js';
6
+
7
+ export interface FlowDiagramNode {
8
+ readonly id: string;
9
+ readonly label: string;
10
+ readonly kind?: 'initial' | 'final' | 'state';
11
+ /** True only when the lifecycle definition binds configurable parameters to this state. */
12
+ readonly configurable?: boolean;
13
+ /** Fixed canvas dimensions; customize these when supplying richer node content. */
14
+ readonly width?: number;
15
+ readonly height?: number;
16
+ /** A visual annotation, not an inspectable business state or transition. */
17
+ readonly decorative?: boolean;
18
+ readonly visited?: boolean;
19
+ readonly highlighted?: boolean;
20
+ readonly className?: string;
21
+ }
22
+
23
+ export interface FlowDiagramEdge {
24
+ readonly id: string;
25
+ readonly source: string;
26
+ readonly target: string;
27
+ readonly label?: string;
28
+ /** True when the transition binds editable business parameters. */
29
+ readonly configurable?: boolean;
30
+ /** A visual annotation, not an inspectable business state or transition. */
31
+ readonly decorative?: boolean;
32
+ readonly visited?: boolean;
33
+ readonly highlighted?: boolean;
34
+ readonly dashed?: boolean;
35
+ }
36
+
37
+ export type FlowDiagramDirection = 'RIGHT' | 'DOWN';
38
+
39
+ export interface FlowDiagramGraph {
40
+ readonly nodes: readonly FlowDiagramNode[];
41
+ readonly edges: readonly FlowDiagramEdge[];
42
+ }
43
+
44
+ export interface FlowDiagramPoint {
45
+ readonly x: number;
46
+ readonly y: number;
47
+ }
48
+
49
+ export interface FlowDiagramLayout {
50
+ readonly width: number;
51
+ readonly height: number;
52
+ readonly nodes: readonly (FlowDiagramNode &
53
+ FlowDiagramPoint & { width: number; height: number })[];
54
+ readonly edges: readonly (FlowDiagramEdge & {
55
+ points: readonly FlowDiagramPoint[];
56
+ labelPosition?: FlowDiagramPoint;
57
+ })[];
58
+ }
59
+
60
+ type ElkConstructor = new (args?: ELKConstructorArguments) => ELK;
61
+ let elkPromise: Promise<ELK> | undefined;
62
+
63
+ function getElk(): Promise<ELK> {
64
+ return (elkPromise ??= import('elkjs/lib/elk.bundled.js').then((module) => {
65
+ // elkjs is CommonJS: NodeNext and Vite expose its constructor differently.
66
+ const imported = module.default as
67
+ ElkConstructor | { default: ElkConstructor };
68
+ return new (typeof imported === 'function' ? imported : imported.default)();
69
+ }));
70
+ }
71
+
72
+ /** Layout and route the same graph, including cycles, parallel edges and self-transitions. */
73
+ export async function layoutFlowDiagram(
74
+ graph: FlowDiagramGraph,
75
+ direction: FlowDiagramDirection = 'RIGHT',
76
+ ): Promise<FlowDiagramLayout> {
77
+ const ids = new Set<string>();
78
+ for (const node of graph.nodes) {
79
+ if (ids.has(node.id)) throw new Error(`Duplicate node: ${node.id}`);
80
+ ids.add(node.id);
81
+ if (
82
+ !Number.isFinite(node.width ?? 232) ||
83
+ (node.width ?? 232) <= 0 ||
84
+ !Number.isFinite(node.height ?? 104) ||
85
+ (node.height ?? 104) <= 0
86
+ )
87
+ throw new Error(`Invalid dimensions: ${node.id}`);
88
+ }
89
+ const edgeIds = new Set<string>();
90
+ for (const edge of graph.edges) {
91
+ if (edgeIds.has(edge.id)) throw new Error(`Duplicate edge: ${edge.id}`);
92
+ edgeIds.add(edge.id);
93
+ if (!ids.has(edge.source) || !ids.has(edge.target))
94
+ throw new Error(`Unknown endpoint: ${edge.id}`);
95
+ }
96
+ // Internal ids occupy their own namespace, regardless of caller-provided ids.
97
+ const nodeIds = new Map(
98
+ graph.nodes.map((node, index) => [node.id, `n${index}`]),
99
+ );
100
+ const elk = await getElk();
101
+ const result = await elk.layout<ElkNode>({
102
+ id: 'root',
103
+ layoutOptions: {
104
+ 'elk.algorithm': 'layered',
105
+ 'elk.direction': direction,
106
+ 'elk.edgeRouting': 'ORTHOGONAL',
107
+ 'elk.spacing.nodeNode': '40',
108
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '72',
109
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
110
+ // Declared state order keeps retry edges from moving later states before the start.
111
+ 'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
112
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
113
+ 'elk.layered.thoroughness': '32',
114
+ 'elk.spacing.edgeEdge': '20',
115
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '20',
116
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '24',
117
+ },
118
+ children: graph.nodes.map((node) => {
119
+ const id = nodeIds.get(node.id)!;
120
+ const width = node.width ?? 232;
121
+ const height = node.height ?? 104;
122
+ const connections = graph.edges.map((edge, index) => ({ edge, index }));
123
+ const incoming = connections.filter(
124
+ ({ edge }) => edge.target === node.id,
125
+ );
126
+ const outgoing = connections.filter(
127
+ ({ edge }) => edge.source === node.id,
128
+ );
129
+ return {
130
+ id,
131
+ width,
132
+ height,
133
+ // Let crossing minimization order ports instead of freezing declaration order.
134
+ layoutOptions: { 'elk.portConstraints': 'FIXED_SIDE' },
135
+ ports: [
136
+ ...incoming.map(({ index }) => ({
137
+ id: `e${index}:in`,
138
+ width: 0,
139
+ height: 0,
140
+ layoutOptions: {
141
+ 'elk.port.side': direction === 'RIGHT' ? 'WEST' : 'NORTH',
142
+ },
143
+ })),
144
+ ...outgoing.map(({ index }) => ({
145
+ id: `e${index}:out`,
146
+ width: 0,
147
+ height: 0,
148
+ layoutOptions: {
149
+ 'elk.port.side': direction === 'RIGHT' ? 'EAST' : 'SOUTH',
150
+ },
151
+ })),
152
+ ],
153
+ };
154
+ }),
155
+ edges: graph.edges.map((edge, index) => ({
156
+ id: `e${index}`,
157
+ sources: [`e${index}:out`],
158
+ targets: [`e${index}:in`],
159
+ labels: edge.label
160
+ ? [
161
+ {
162
+ text: edge.label,
163
+ // Reserve space on the routed edge so labels remain attached to it.
164
+ layoutOptions: {
165
+ 'elk.edgeLabels.placement': 'CENTER',
166
+ 'elk.edgeLabels.inline': 'true',
167
+ },
168
+ width: Math.min(
169
+ 320,
170
+ [...edge.label].reduce(
171
+ (total, char) => total + (char.charCodeAt(0) > 255 ? 14 : 7),
172
+ 16,
173
+ ),
174
+ ),
175
+ height: 24,
176
+ },
177
+ ]
178
+ : [],
179
+ })),
180
+ });
181
+ return {
182
+ width: result.width ?? 0,
183
+ height: result.height ?? 0,
184
+ nodes: graph.nodes.map((node, index) => {
185
+ const placed = result.children![index];
186
+ return {
187
+ ...node,
188
+ x: placed.x ?? 0,
189
+ y: placed.y ?? 0,
190
+ width: placed.width ?? node.width ?? 232,
191
+ height: placed.height ?? node.height ?? 104,
192
+ };
193
+ }),
194
+ edges: graph.edges.map((edge, index) => {
195
+ const routed = result.edges![index];
196
+ const label = routed.labels?.[0];
197
+ const points = (routed.sections ?? []).flatMap((section) => [
198
+ section.startPoint,
199
+ ...(section.bendPoints ?? []),
200
+ section.endPoint,
201
+ ]);
202
+ // ELK may round a self-loop's port but leave its neighboring bend fractional.
203
+ // Align those bends with the port so even short endpoint segments stay orthogonal.
204
+ if (points.length >= 4) {
205
+ const axis = direction === 'RIGHT' ? 'y' : 'x';
206
+ for (const [endpoint, bend] of [
207
+ [0, 1],
208
+ [points.length - 1, points.length - 2],
209
+ ]) {
210
+ if (Math.abs(points[endpoint][axis] - points[bend][axis]) < 1) {
211
+ points[bend] = { ...points[bend], [axis]: points[endpoint][axis] };
212
+ }
213
+ }
214
+ }
215
+ let labelPosition: FlowDiagramPoint | undefined;
216
+ if (label) {
217
+ const center = {
218
+ x: (label.x ?? 0) + (label.width ?? 0) / 2,
219
+ y: (label.y ?? 0) + (label.height ?? 0) / 2,
220
+ };
221
+ // ELK reserves inline label space, but can offset its center slightly
222
+ // in horizontal layouts. Anchor it precisely to that reserved segment.
223
+ let distance = Infinity;
224
+ for (let index = 1; index < points.length; index++) {
225
+ const start = points[index - 1];
226
+ const end = points[index];
227
+ const candidate = {
228
+ x: Math.max(
229
+ Math.min(start.x, end.x),
230
+ Math.min(center.x, Math.max(start.x, end.x)),
231
+ ),
232
+ y: Math.max(
233
+ Math.min(start.y, end.y),
234
+ Math.min(center.y, Math.max(start.y, end.y)),
235
+ ),
236
+ };
237
+ const nextDistance = Math.hypot(
238
+ candidate.x - center.x,
239
+ candidate.y - center.y,
240
+ );
241
+ if (nextDistance < distance) {
242
+ labelPosition = candidate;
243
+ distance = nextDistance;
244
+ }
245
+ }
246
+ }
247
+ return { ...edge, points, labelPosition };
248
+ }),
249
+ };
250
+ }
@@ -0,0 +1,217 @@
1
+ import type { LifecycleDescription } from '@nocobase/lifecycle';
2
+ import type { FlowDiagramGraph } from '#components/flow-layout';
3
+
4
+ export interface LifecycleDiagramHistoryEntry {
5
+ readonly transition: string;
6
+ readonly from: string | null;
7
+ readonly to: string;
8
+ }
9
+
10
+ export interface LifecycleDiagramOptions {
11
+ /** Localized label for separate end markers; omitted to hide them. */
12
+ readonly endLabel?: string;
13
+ readonly history?: readonly LifecycleDiagramHistoryEntry[];
14
+ readonly visitedStates?: readonly string[];
15
+ readonly currentState?: string;
16
+ /** Names from view.available filtered by the caller to transitions actually available. */
17
+ readonly availableTransitions?: readonly string[];
18
+ readonly stateLabel?: (
19
+ state: LifecycleDescription['stateInfo'][number],
20
+ ) => string;
21
+ readonly transitionLabel?: (
22
+ transition: LifecycleDescription['transitions'][number],
23
+ ) => string;
24
+ }
25
+
26
+ /** Builds edges for every declared source/destination pair; it never infers a record's history. */
27
+ export function lifecycleDiagramGraph(
28
+ description: LifecycleDescription,
29
+ options: LifecycleDiagramOptions = {},
30
+ ): FlowDiagramGraph {
31
+ const history = options.history ?? [];
32
+ const visitedStates = new Set([
33
+ ...(options.visitedStates ?? []),
34
+ ...history.flatMap((entry) =>
35
+ entry.from === null ? [entry.to] : [entry.from, entry.to],
36
+ ),
37
+ ]);
38
+ const visitedEdges = new Set(
39
+ history
40
+ .filter((entry) => entry.from !== null)
41
+ .map((entry) => JSON.stringify([entry.transition, entry.from, entry.to])),
42
+ );
43
+ const graph: FlowDiagramGraph = {
44
+ nodes: description.stateInfo.map((state) => ({
45
+ id: state.name,
46
+ width: 232,
47
+ height: 104,
48
+ label: options.stateLabel?.(state) ?? state.title,
49
+ configurable: (description.parameterConfiguration?.groups ?? []).some(
50
+ (group) =>
51
+ Object.values(group.fields).some((field) =>
52
+ field.bindings?.some((binding) => binding.state === state.name),
53
+ ),
54
+ ),
55
+ visited: visitedStates.has(state.name),
56
+ highlighted: state.name === options.currentState,
57
+ kind: state.final
58
+ ? 'final'
59
+ : description.initialStates.includes(state.name)
60
+ ? 'initial'
61
+ : 'state',
62
+ })),
63
+ edges: description.transitions.flatMap((transition) =>
64
+ transition.from.flatMap((source) =>
65
+ transition.to.map((target) => ({
66
+ id: JSON.stringify([transition.name, source, target]),
67
+ source,
68
+ target,
69
+ label: options.transitionLabel?.(transition) ?? transition.title,
70
+ configurable: (description.parameterConfiguration?.groups ?? []).some(
71
+ (group) =>
72
+ Object.values(group.fields).some((field) =>
73
+ field.bindings?.some(
74
+ (binding) => binding.transition === transition.name,
75
+ ),
76
+ ),
77
+ ),
78
+ visited: visitedEdges.has(
79
+ JSON.stringify([transition.name, source, target]),
80
+ ),
81
+ highlighted:
82
+ source === options.currentState &&
83
+ (options.availableTransitions?.includes(transition.name) ?? false),
84
+ })),
85
+ ),
86
+ ),
87
+ };
88
+ if (!options.endLabel) return graph;
89
+ const ids = new Set(graph.nodes.map((node) => node.id));
90
+ const endings = description.stateInfo
91
+ .filter((state) => state.final)
92
+ .map((state) => {
93
+ let id = JSON.stringify(['$end', state.name]);
94
+ while (ids.has(id)) id += ':';
95
+ ids.add(id);
96
+ return { state, id };
97
+ });
98
+ return {
99
+ nodes: [
100
+ ...graph.nodes,
101
+ ...endings.map(({ state, id }) => ({
102
+ id,
103
+ label: `◎ ${options.endLabel}`,
104
+ // Compact fixed canvas dimensions for a terminal annotation.
105
+ width: 88,
106
+ height: 32,
107
+ decorative: true,
108
+ visited:
109
+ state.name === options.currentState || visitedStates.has(state.name),
110
+ className:
111
+ 'rounded-full border-0 bg-transparent text-muted-foreground shadow-none',
112
+ })),
113
+ ],
114
+ edges: [
115
+ ...graph.edges,
116
+ ...endings.map(({ state, id }) => ({
117
+ id: JSON.stringify(['$end', state.name]),
118
+ source: state.name,
119
+ target: id,
120
+ decorative: true,
121
+ visited:
122
+ state.name === options.currentState || visitedStates.has(state.name),
123
+ })),
124
+ ],
125
+ };
126
+ }
127
+
128
+ export type LifecycleConfigurationTarget =
129
+ | { readonly kind: 'state'; readonly name: string }
130
+ | { readonly kind: 'transition'; readonly name: string };
131
+ export type LifecycleParameterGroup = NonNullable<
132
+ LifecycleDescription['parameterConfiguration']
133
+ >['groups'][number];
134
+ export type LifecycleParameterField = LifecycleParameterGroup['fields'][string];
135
+ export interface LifecycleConfigurationSelection {
136
+ readonly target: LifecycleConfigurationTarget;
137
+ /** Only bound, authorized fields. Editors must merge into a complete group before saving. */
138
+ readonly groups: readonly LifecycleParameterGroup[];
139
+ }
140
+ export interface LifecycleDiagramConfiguration {
141
+ readonly label: (target: LifecycleConfigurationTarget) => string;
142
+ /** UI affordance only: the server must independently authorize reads and writes. */
143
+ readonly canConfigure: (
144
+ group: LifecycleParameterGroup,
145
+ field: string,
146
+ definition: LifecycleParameterField,
147
+ ) => boolean;
148
+ readonly onConfigure: (selection: LifecycleConfigurationSelection) => void;
149
+ }
150
+
151
+ /** Selects declared bindings only; never guesses parameters from node names or labels. */
152
+ export function lifecycleConfigurationSelection(
153
+ description: LifecycleDescription,
154
+ target: LifecycleConfigurationTarget,
155
+ canConfigure: LifecycleDiagramConfiguration['canConfigure'],
156
+ ): LifecycleConfigurationSelection | undefined {
157
+ const exists =
158
+ target.kind === 'state'
159
+ ? description.stateInfo.some((state) => state.name === target.name)
160
+ : description.transitions.some(
161
+ (transition) => transition.name === target.name,
162
+ );
163
+ if (!exists) return undefined;
164
+ const groups = (description.parameterConfiguration?.groups ?? []).flatMap(
165
+ (group) => {
166
+ const fields = Object.fromEntries(
167
+ Object.entries(group.fields).filter(
168
+ ([key, field]) =>
169
+ field.bindings?.some(
170
+ (binding) => binding[target.kind] === target.name,
171
+ ) && canConfigure(group, key, field),
172
+ ),
173
+ );
174
+ return Object.keys(fields).length ? [{ ...group, fields }] : [];
175
+ },
176
+ );
177
+ return groups.length ? { target, groups } : undefined;
178
+ }
179
+
180
+ /** Surface an outgoing transition's declared policy on its owning node as well. */
181
+ export function lifecycleNodeConfigurationSelections(
182
+ description: LifecycleDescription,
183
+ state: string,
184
+ canConfigure: LifecycleDiagramConfiguration['canConfigure'],
185
+ ): readonly LifecycleConfigurationSelection[] {
186
+ if (!description.stateInfo.some((item) => item.name === state)) return [];
187
+ const own = lifecycleConfigurationSelection(
188
+ description,
189
+ { kind: 'state', name: state },
190
+ canConfigure,
191
+ );
192
+ const covered = new Set(
193
+ own?.groups.flatMap((group) =>
194
+ Object.keys(group.fields).map((field) =>
195
+ JSON.stringify([group.key, field]),
196
+ ),
197
+ ),
198
+ );
199
+ const outgoing = description.transitions.flatMap((transition) => {
200
+ if (!transition.from.includes(state)) return [];
201
+ const selection = lifecycleConfigurationSelection(
202
+ description,
203
+ { kind: 'transition', name: transition.name },
204
+ canConfigure,
205
+ );
206
+ // A dual state/transition binding already has an entry on this node. Keep
207
+ // distinct transitions distinct, even when they happen to share fields.
208
+ return selection?.groups.some((group) =>
209
+ Object.keys(group.fields).some(
210
+ (field) => !covered.has(JSON.stringify([group.key, field])),
211
+ ),
212
+ )
213
+ ? [selection]
214
+ : [];
215
+ });
216
+ return own ? [own, ...outgoing] : outgoing;
217
+ }