@nocobase/app-template-default 1.0.0-beta.74 → 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/AGENTS.md +7 -1
- package/README.MD +3 -3
- package/cli/plugins.ts +2 -1
- 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 +71 -31
- 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/navigation-transform.ts +2 -1
- 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 +38 -1
- package/client/locales/system/settings-zh-CN.ts +34 -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 +47 -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/client/plugins.ts +2 -0
- package/config.example.yml +6 -5
- package/package.json +16 -14
- package/server/plugins.ts +2 -0
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/role-list.test.tsx +73 -8
- package/tests/components/roles.test.tsx +14 -7
- 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/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
- package/tests/logic/return-to.test.ts +19 -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,20 @@
|
|
|
1
|
+
import type { ComponentProps, ReactElement } from 'react';
|
|
2
|
+
import { Link } from 'react-router';
|
|
3
|
+
import { cn } from 'cn';
|
|
4
|
+
|
|
5
|
+
/** A record name or identifier that opens its route from a list or table. */
|
|
6
|
+
export function RecordLink({
|
|
7
|
+
className,
|
|
8
|
+
...props
|
|
9
|
+
}: ComponentProps<typeof Link>): ReactElement {
|
|
10
|
+
return (
|
|
11
|
+
<Link
|
|
12
|
+
{...props}
|
|
13
|
+
data-slot='record-link'
|
|
14
|
+
className={cn(
|
|
15
|
+
'cursor-pointer font-medium text-foreground no-underline decoration-1 underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useMemo, type ReactElement } from 'react';
|
|
2
|
+
import { Link, useLocation } from 'react-router';
|
|
3
|
+
import { useApiClient } from '@nocobase/app-client';
|
|
4
|
+
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
5
|
+
import { UsersClient } from '@nocobase/app-plugin-users/client/user-client';
|
|
6
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
7
|
+
import { Button } from '#components/ui/button';
|
|
8
|
+
import { SetupReadiness } from './setup-readiness.js';
|
|
9
|
+
import { useSetupReadiness } from './setup-readiness-state.js';
|
|
10
|
+
import {
|
|
11
|
+
inspectRequiredRoles,
|
|
12
|
+
requiredRoleSetupId,
|
|
13
|
+
requiredRoleSetupLink,
|
|
14
|
+
type RequiredSetupRole,
|
|
15
|
+
} from '../lib/required-role-setup.js';
|
|
16
|
+
|
|
17
|
+
/** Mount on the dependent feature with its actual required roles, memoized by the host. */
|
|
18
|
+
export function RequiredRoleSetup({
|
|
19
|
+
roles,
|
|
20
|
+
refreshKey = '',
|
|
21
|
+
}: {
|
|
22
|
+
readonly roles: readonly RequiredSetupRole[];
|
|
23
|
+
readonly refreshKey?: string;
|
|
24
|
+
}): ReactElement | null {
|
|
25
|
+
const { t } = useTranslation();
|
|
26
|
+
const api = useApiClient();
|
|
27
|
+
const client = useMemo(() => new UsersClient(api), [api]);
|
|
28
|
+
const location = useLocation();
|
|
29
|
+
const enabled = roles.length > 0;
|
|
30
|
+
const read = useCan(
|
|
31
|
+
{ resource: { type: 'user', id: '*' }, action: 'read' },
|
|
32
|
+
{ enabled },
|
|
33
|
+
);
|
|
34
|
+
const assign = useCan(
|
|
35
|
+
{ resource: { type: 'user', id: '*' }, action: 'assign-role' },
|
|
36
|
+
{ enabled },
|
|
37
|
+
);
|
|
38
|
+
const source = `${location.pathname}${location.search}${location.hash}`;
|
|
39
|
+
const checks = useMemo(
|
|
40
|
+
() => [
|
|
41
|
+
...new Map(
|
|
42
|
+
roles.map((role) => [
|
|
43
|
+
requiredRoleSetupId(role),
|
|
44
|
+
{
|
|
45
|
+
id: requiredRoleSetupId(role),
|
|
46
|
+
title: role.title,
|
|
47
|
+
description: role.description,
|
|
48
|
+
action: assign.can ? (
|
|
49
|
+
<Button
|
|
50
|
+
variant='outline'
|
|
51
|
+
size='sm'
|
|
52
|
+
aria-label={t('settings.setup.manageMembersFor', {
|
|
53
|
+
role: role.title,
|
|
54
|
+
})}
|
|
55
|
+
render={<Link to={requiredRoleSetupLink(role, source)} />}
|
|
56
|
+
>
|
|
57
|
+
{t('settings.setup.manageMembers')}
|
|
58
|
+
</Button>
|
|
59
|
+
) : undefined,
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
).values(),
|
|
63
|
+
],
|
|
64
|
+
[roles, assign.can, source, t],
|
|
65
|
+
);
|
|
66
|
+
const inspect = useCallback(
|
|
67
|
+
() => inspectRequiredRoles(client, roles),
|
|
68
|
+
[client, roles],
|
|
69
|
+
);
|
|
70
|
+
const state = useSetupReadiness(checks, inspect, {
|
|
71
|
+
enabled: read.can,
|
|
72
|
+
refreshKey: `${location.key}:${refreshKey}`,
|
|
73
|
+
});
|
|
74
|
+
if (!read.can) return null;
|
|
75
|
+
return (
|
|
76
|
+
<SetupReadiness
|
|
77
|
+
state={state}
|
|
78
|
+
labels={{
|
|
79
|
+
title: t('settings.setup.title'),
|
|
80
|
+
checking: t('settings.setup.checking'),
|
|
81
|
+
checkAgain: t('settings.setup.checkAgain'),
|
|
82
|
+
ready: t('settings.setup.ready'),
|
|
83
|
+
missing: t('settings.setup.missing'),
|
|
84
|
+
unknown: {
|
|
85
|
+
unavailable: t('settings.setup.unavailable'),
|
|
86
|
+
signIn: t('settings.setup.signIn'),
|
|
87
|
+
forbidden: t('settings.setup.forbidden'),
|
|
88
|
+
invalid: t('settings.setup.invalid'),
|
|
89
|
+
},
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { useMemo, type ReactElement } from 'react';
|
|
2
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
3
|
+
import type { To } from 'react-router';
|
|
4
|
+
import { RecordLink } from '#components/record-link';
|
|
5
|
+
import {
|
|
6
|
+
DataTable,
|
|
7
|
+
DataTableColumnHeader,
|
|
8
|
+
type DataTableProps,
|
|
9
|
+
} from '#components/data-table';
|
|
3
10
|
import { Badge } from '#components/ui/badge';
|
|
4
11
|
|
|
5
12
|
export interface RoleListRole {
|
|
@@ -12,50 +19,83 @@ export interface RoleListRole {
|
|
|
12
19
|
export interface RoleListProps {
|
|
13
20
|
readonly roles: readonly RoleListRole[];
|
|
14
21
|
readonly labels: {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly type: string;
|
|
15
25
|
readonly protected: string;
|
|
16
26
|
readonly custom: string;
|
|
17
27
|
};
|
|
18
28
|
readonly ariaLabel?: string;
|
|
29
|
+
readonly tableState?: Pick<
|
|
30
|
+
DataTableProps<RoleListRole>,
|
|
31
|
+
| 'paginationState'
|
|
32
|
+
| 'onPaginationChange'
|
|
33
|
+
| 'sortingState'
|
|
34
|
+
| 'onSortingChange'
|
|
35
|
+
| 'pageSizeOptions'
|
|
36
|
+
>;
|
|
19
37
|
}
|
|
20
38
|
|
|
21
|
-
/**
|
|
39
|
+
/** Standard role table; the host owns authorization, loading and filtering. */
|
|
22
40
|
export function RoleList({
|
|
23
41
|
roles,
|
|
24
42
|
labels,
|
|
25
43
|
ariaLabel,
|
|
44
|
+
tableState,
|
|
26
45
|
}: RoleListProps): ReactElement {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
className='-my-1 w-fit max-w-full min-w-0 truncate py-1 text-sm leading-5 font-medium text-foreground underline-offset-4 group-hover/role:underline'
|
|
40
|
-
title={role.title}
|
|
46
|
+
const columns = useMemo<ColumnDef<RoleListRole>[]>(
|
|
47
|
+
() => [
|
|
48
|
+
{
|
|
49
|
+
accessorKey: 'title',
|
|
50
|
+
header: ({ column }) => (
|
|
51
|
+
<DataTableColumnHeader column={column} title={labels.name} />
|
|
52
|
+
),
|
|
53
|
+
cell: ({ row }) => (
|
|
54
|
+
<RecordLink
|
|
55
|
+
to={row.original.to}
|
|
56
|
+
className='inline-block max-w-72 truncate align-middle'
|
|
57
|
+
title={row.original.title}
|
|
41
58
|
>
|
|
42
|
-
{
|
|
43
|
-
</
|
|
59
|
+
{row.original.title}
|
|
60
|
+
</RecordLink>
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
accessorKey: 'key',
|
|
65
|
+
header: ({ column }) => (
|
|
66
|
+
<DataTableColumnHeader column={column} title={labels.key} />
|
|
67
|
+
),
|
|
68
|
+
cell: ({ row }) => (
|
|
44
69
|
<span
|
|
45
|
-
className='
|
|
46
|
-
title={
|
|
70
|
+
className='inline-block max-w-64 truncate align-middle text-muted-foreground'
|
|
71
|
+
title={row.original.key}
|
|
47
72
|
>
|
|
48
|
-
{
|
|
73
|
+
{row.original.key}
|
|
49
74
|
</span>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
accessorKey: 'protected',
|
|
79
|
+
enableSorting: false,
|
|
80
|
+
header: labels.type,
|
|
81
|
+
cell: ({ row }) => (
|
|
82
|
+
<Badge variant='secondary'>
|
|
83
|
+
{row.original.protected ? labels.protected : labels.custom}
|
|
56
84
|
</Badge>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
[labels],
|
|
89
|
+
);
|
|
90
|
+
return (
|
|
91
|
+
<div role='region' aria-label={ariaLabel}>
|
|
92
|
+
<DataTable
|
|
93
|
+
{...tableState}
|
|
94
|
+
columns={columns}
|
|
95
|
+
data={roles}
|
|
96
|
+
getRowId={(role) => role.key}
|
|
97
|
+
showSelectedCount={false}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
60
100
|
);
|
|
61
101
|
}
|