@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.
Files changed (52) hide show
  1. package/.agents/rules/build-and-deploy.md +4 -0
  2. package/.agents/rules/components.md +3 -1
  3. package/AGENTS.md +7 -1
  4. package/README.MD +3 -3
  5. package/cli/plugins.ts +2 -1
  6. package/client/components/data-table/data-table.tsx +2 -0
  7. package/client/components/data-table/pagination.tsx +6 -6
  8. package/client/components/flow-diagram.tsx +645 -83
  9. package/client/components/flow-layout.ts +117 -7
  10. package/client/components/lifecycle-diagram.tsx +175 -37
  11. package/client/components/record-link.tsx +20 -0
  12. package/client/components/required-role-setup.tsx +93 -0
  13. package/client/components/role-list.tsx +71 -31
  14. package/client/components/setup-readiness-state.ts +135 -0
  15. package/client/components/setup-readiness.tsx +91 -0
  16. package/client/components/ui/combobox.tsx +300 -0
  17. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  18. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
  19. package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
  20. package/client/hooks/use-table-url-state.ts +93 -0
  21. package/client/lib/navigation-transform.ts +2 -1
  22. package/client/lib/required-role-setup.ts +98 -0
  23. package/client/lib/return-to.ts +19 -0
  24. package/client/locales/system/en-US.ts +10 -0
  25. package/client/locales/system/settings-en-US.ts +38 -1
  26. package/client/locales/system/settings-zh-CN.ts +34 -2
  27. package/client/locales/system/zh-CN.ts +10 -0
  28. package/client/pages/settings/default-access-form.tsx +4 -1
  29. package/client/pages/settings/default-access.tsx +40 -8
  30. package/client/pages/settings/role-form.tsx +1 -1
  31. package/client/pages/settings/roles.tsx +47 -6
  32. package/client/pages/settings/shared.tsx +15 -13
  33. package/client/pages/settings/user-detail.tsx +15 -259
  34. package/client/pages/settings/user-form.tsx +299 -147
  35. package/client/pages/settings/users.tsx +211 -123
  36. package/client/plugins.ts +2 -0
  37. package/config.example.yml +6 -5
  38. package/package.json +16 -14
  39. package/server/plugins.ts +2 -0
  40. package/tests/components/default-access.test.tsx +141 -2
  41. package/tests/components/role-list.test.tsx +73 -8
  42. package/tests/components/roles.test.tsx +14 -7
  43. package/tests/components/settings-continuity.test.tsx +426 -0
  44. package/tests/components/settings-feedback.test.tsx +304 -0
  45. package/tests/components/settings.test.tsx +1 -1
  46. package/tests/components/setup-readiness.test.tsx +370 -0
  47. package/tests/components/table-url-state.test.tsx +95 -0
  48. package/tests/components/user-drawer.test.tsx +526 -0
  49. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  50. package/tests/logic/foundation-integration.test.ts +62 -0
  51. package/tests/logic/return-to.test.ts +19 -0
  52. 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
- 320,
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: 24,
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 { FlowDiagram, type FlowDiagramProps } from '#components/flow-diagram';
19
- import type { FlowDiagramNode } from '#components/flow-layout';
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
- {...graph}
136
- nodeActions={(node) => {
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
- if (!state) return undefined;
141
- const actions = stateActions?.(state);
142
- const selections =
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
- <div className='nodrag nopan flex items-center gap-1'>
152
- {actions}
153
- <NodeConfigurationActions
154
- configuration={configuration}
155
- selections={selections}
156
- state={{ kind: 'state', name: state.name }}
157
- title={(target) => {
158
- if (target.kind === 'state')
159
- return stateLabel?.(state) ?? state.title;
160
- const transition = description.transitions.find(
161
- (item) => item.name === target.name,
162
- )!;
163
- return transitionLabel?.(transition) ?? transition.title;
164
- }}
165
- />
166
- </div>
167
- );
168
- }}
169
- renderNode={(node) => {
170
- const state = description.stateInfo.find(
171
- (state) => state.name === node.id,
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 { ReactElement } from 'react';
2
- import { Link, type To } from 'react-router';
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
- /** A compact content list; the host owns authorization, loading and filtering. */
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
- return (
28
- <ul
29
- aria-label={ariaLabel}
30
- className='@container divide-y rounded-lg border bg-card [&>li:first-child]:rounded-t-lg [&>li:last-child]:rounded-b-lg'
31
- >
32
- {roles.map((role) => (
33
- <li
34
- key={role.key}
35
- className='group/role grid min-h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 px-3 py-2 transition-colors hover:bg-muted/50 @xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_6rem]'
36
- >
37
- <Link
38
- to={role.to}
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
- {role.title}
43
- </Link>
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='col-start-1 row-start-2 min-w-0 truncate text-xs leading-4 text-muted-foreground @xl:col-start-2 @xl:row-start-1'
46
- title={role.key}
70
+ className='inline-block max-w-64 truncate align-middle text-muted-foreground'
71
+ title={row.original.key}
47
72
  >
48
- {role.key}
73
+ {row.original.key}
49
74
  </span>
50
- <Badge
51
- variant='secondary'
52
- data-size='sm'
53
- className='col-start-2 row-span-2 row-start-1 shrink-0 justify-self-end @xl:col-start-3 @xl:row-span-1'
54
- >
55
- {role.protected ? labels.protected : labels.custom}
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
- </li>
58
- ))}
59
- </ul>
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
  }