@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.
- package/.agents/rules/components.md +4 -0
- package/AGENTS.md +3 -1
- package/client/components/flow-diagram-css.d.ts +2 -0
- package/client/components/flow-diagram.tsx +674 -0
- package/client/components/flow-layout.ts +250 -0
- package/client/components/lifecycle-diagram-model.ts +217 -0
- package/client/components/lifecycle-diagram.tsx +310 -0
- package/client/components/permission-editor.tsx +111 -73
- package/client/extensions/nocobase-workflow-management/README.md +70 -0
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +148 -0
- package/client/extensions/nocobase-workflow-management/execution-detail.tsx +230 -0
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +175 -0
- package/client/extensions/nocobase-workflow-management/layout.tsx +95 -0
- package/client/locales/system/en-US.ts +22 -0
- package/client/locales/system/settings-en-US.ts +2 -0
- package/client/locales/system/settings-zh-CN.ts +8 -6
- package/client/locales/system/zh-CN.ts +22 -0
- package/client/pages/settings/page-permission-titles.ts +7 -0
- package/client/pages/settings/page-permissions.ts +51 -0
- package/client/pages/settings/permissions-model.ts +60 -31
- package/client/pages/settings/role-form.tsx +8 -29
- package/package.json +11 -9
- package/tests/components/permission-editor.test.tsx +62 -0
- package/tests/logic/page-permission-titles.test.ts +303 -0
- package/tests/logic/settings-permissions.test.ts +77 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useMemo,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { ChevronDownIcon, Settings2Icon } from 'lucide-react';
|
|
9
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
10
|
+
import { Button } from '#components/ui/button';
|
|
11
|
+
import {
|
|
12
|
+
Popover,
|
|
13
|
+
PopoverContent,
|
|
14
|
+
PopoverTitle,
|
|
15
|
+
PopoverTrigger,
|
|
16
|
+
} from '#components/ui/popover';
|
|
17
|
+
import type { LifecycleDescription } from '@nocobase/lifecycle';
|
|
18
|
+
import { FlowDiagram, type FlowDiagramProps } from '#components/flow-diagram';
|
|
19
|
+
import type { FlowDiagramNode } from '#components/flow-layout';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
lifecycleDiagramGraph,
|
|
23
|
+
lifecycleConfigurationSelection,
|
|
24
|
+
lifecycleNodeConfigurationSelections,
|
|
25
|
+
type LifecycleConfigurationSelection,
|
|
26
|
+
type LifecycleConfigurationTarget,
|
|
27
|
+
type LifecycleDiagramConfiguration,
|
|
28
|
+
type LifecycleDiagramOptions,
|
|
29
|
+
} from './lifecycle-diagram-model.js';
|
|
30
|
+
|
|
31
|
+
export interface LifecycleDiagramProps
|
|
32
|
+
extends
|
|
33
|
+
LifecycleDiagramOptions,
|
|
34
|
+
Omit<
|
|
35
|
+
FlowDiagramProps,
|
|
36
|
+
| 'nodes'
|
|
37
|
+
| 'edges'
|
|
38
|
+
| 'renderNode'
|
|
39
|
+
| 'nodeActions'
|
|
40
|
+
| 'onNodeClick'
|
|
41
|
+
| 'onEdgeClick'
|
|
42
|
+
> {
|
|
43
|
+
readonly description: LifecycleDescription;
|
|
44
|
+
/** Opens the application-owned, authorized parameter editor for declared bindings. */
|
|
45
|
+
readonly configuration?: LifecycleDiagramConfiguration;
|
|
46
|
+
readonly stateActions?: (
|
|
47
|
+
state: LifecycleDescription['stateInfo'][number],
|
|
48
|
+
) => ReactNode;
|
|
49
|
+
readonly renderState?: (
|
|
50
|
+
state: LifecycleDescription['stateInfo'][number],
|
|
51
|
+
node: FlowDiagramNode,
|
|
52
|
+
) => ReactNode;
|
|
53
|
+
readonly onStateClick?: (
|
|
54
|
+
state: LifecycleDescription['stateInfo'][number],
|
|
55
|
+
) => void;
|
|
56
|
+
readonly onTransitionClick?: (
|
|
57
|
+
transition: LifecycleDescription['transitions'][number],
|
|
58
|
+
) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Definitions are source-owned; clicking opens consumer-owned details, never fires a transition. */
|
|
62
|
+
export function LifecycleDiagram({
|
|
63
|
+
description,
|
|
64
|
+
currentState,
|
|
65
|
+
endLabel,
|
|
66
|
+
history,
|
|
67
|
+
visitedStates,
|
|
68
|
+
availableTransitions,
|
|
69
|
+
stateLabel,
|
|
70
|
+
transitionLabel,
|
|
71
|
+
renderState,
|
|
72
|
+
stateActions,
|
|
73
|
+
configuration,
|
|
74
|
+
onStateClick,
|
|
75
|
+
onTransitionClick,
|
|
76
|
+
...props
|
|
77
|
+
}: LifecycleDiagramProps): ReactElement {
|
|
78
|
+
const sourceGraph = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
lifecycleDiagramGraph(description, {
|
|
81
|
+
currentState,
|
|
82
|
+
endLabel,
|
|
83
|
+
history,
|
|
84
|
+
visitedStates,
|
|
85
|
+
availableTransitions,
|
|
86
|
+
stateLabel,
|
|
87
|
+
transitionLabel,
|
|
88
|
+
}),
|
|
89
|
+
[
|
|
90
|
+
description,
|
|
91
|
+
currentState,
|
|
92
|
+
endLabel,
|
|
93
|
+
history,
|
|
94
|
+
visitedStates,
|
|
95
|
+
availableTransitions,
|
|
96
|
+
stateLabel,
|
|
97
|
+
transitionLabel,
|
|
98
|
+
],
|
|
99
|
+
);
|
|
100
|
+
const graph = configuration
|
|
101
|
+
? {
|
|
102
|
+
nodes: sourceGraph.nodes.map((node) => ({
|
|
103
|
+
...node,
|
|
104
|
+
configurable: Boolean(
|
|
105
|
+
lifecycleNodeConfigurationSelections(
|
|
106
|
+
description,
|
|
107
|
+
node.id,
|
|
108
|
+
configuration.canConfigure,
|
|
109
|
+
).length,
|
|
110
|
+
),
|
|
111
|
+
})),
|
|
112
|
+
edges: sourceGraph.edges.map((edge) => ({
|
|
113
|
+
...edge,
|
|
114
|
+
configurable:
|
|
115
|
+
!edge.decorative &&
|
|
116
|
+
Boolean(
|
|
117
|
+
lifecycleConfigurationSelection(
|
|
118
|
+
description,
|
|
119
|
+
{
|
|
120
|
+
kind: 'transition',
|
|
121
|
+
name: (JSON.parse(edge.id) as string[])[0],
|
|
122
|
+
},
|
|
123
|
+
configuration.canConfigure,
|
|
124
|
+
),
|
|
125
|
+
),
|
|
126
|
+
})),
|
|
127
|
+
}
|
|
128
|
+
: sourceGraph;
|
|
129
|
+
return (
|
|
130
|
+
<FlowDiagram
|
|
131
|
+
direction='DOWN'
|
|
132
|
+
initialMinZoom={0.7}
|
|
133
|
+
autoHeight
|
|
134
|
+
{...props}
|
|
135
|
+
{...graph}
|
|
136
|
+
nodeActions={(node) => {
|
|
137
|
+
const state = description.stateInfo.find(
|
|
138
|
+
(state) => state.name === node.id,
|
|
139
|
+
);
|
|
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;
|
|
150
|
+
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,
|
|
172
|
+
);
|
|
173
|
+
return state && renderState ? renderState(state, node) : node.label;
|
|
174
|
+
}}
|
|
175
|
+
onNodeClick={(node) => {
|
|
176
|
+
const state = description.stateInfo.find(
|
|
177
|
+
(state) => state.name === node.id,
|
|
178
|
+
);
|
|
179
|
+
if (state) onStateClick?.(state);
|
|
180
|
+
}}
|
|
181
|
+
onEdgeClick={(edge) => {
|
|
182
|
+
if (edge.decorative) return;
|
|
183
|
+
const [name] = JSON.parse(edge.id) as string[];
|
|
184
|
+
const transition = description.transitions.find(
|
|
185
|
+
(item) => item.name === name,
|
|
186
|
+
);
|
|
187
|
+
if (!transition) return;
|
|
188
|
+
const selection =
|
|
189
|
+
configuration &&
|
|
190
|
+
lifecycleConfigurationSelection(
|
|
191
|
+
description,
|
|
192
|
+
{ kind: 'transition', name: transition.name },
|
|
193
|
+
configuration.canConfigure,
|
|
194
|
+
);
|
|
195
|
+
if (selection && configuration) configuration.onConfigure(selection);
|
|
196
|
+
else onTransitionClick?.(transition);
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reuse the authoritative editor; this chooser never invents or saves values. */
|
|
203
|
+
function NodeConfigurationActions({
|
|
204
|
+
configuration,
|
|
205
|
+
selections,
|
|
206
|
+
state,
|
|
207
|
+
title,
|
|
208
|
+
}: {
|
|
209
|
+
readonly configuration: LifecycleDiagramConfiguration;
|
|
210
|
+
readonly selections: readonly LifecycleConfigurationSelection[];
|
|
211
|
+
readonly state: LifecycleConfigurationTarget;
|
|
212
|
+
readonly title: (target: LifecycleConfigurationTarget) => string;
|
|
213
|
+
}): ReactElement {
|
|
214
|
+
const { t } = useTranslation();
|
|
215
|
+
const [open, setOpen] = useState(false);
|
|
216
|
+
const pendingRef = useRef<LifecycleConfigurationSelection | undefined>(
|
|
217
|
+
undefined,
|
|
218
|
+
);
|
|
219
|
+
const first = selections[0];
|
|
220
|
+
const direct =
|
|
221
|
+
first.target.kind === 'state' || selections.length === 1
|
|
222
|
+
? first
|
|
223
|
+
: undefined;
|
|
224
|
+
const choices = direct ? selections.slice(1) : selections;
|
|
225
|
+
const label = t('flowDiagram.configure', { defaultValue: 'Configure' });
|
|
226
|
+
const className =
|
|
227
|
+
'h-6 border border-primary/15 bg-primary/5 px-1.5 text-xs text-primary hover:bg-primary/10';
|
|
228
|
+
return (
|
|
229
|
+
<>
|
|
230
|
+
{direct && (
|
|
231
|
+
<Button
|
|
232
|
+
type='button'
|
|
233
|
+
variant='ghost'
|
|
234
|
+
size='sm'
|
|
235
|
+
className={className}
|
|
236
|
+
onClick={(event) => {
|
|
237
|
+
event.stopPropagation();
|
|
238
|
+
configuration.onConfigure(direct);
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<Settings2Icon aria-hidden />
|
|
242
|
+
{configuration.label(direct.target)}
|
|
243
|
+
</Button>
|
|
244
|
+
)}
|
|
245
|
+
{choices.length > 0 && (
|
|
246
|
+
<Popover
|
|
247
|
+
open={open}
|
|
248
|
+
onOpenChange={(nextOpen) => {
|
|
249
|
+
if (nextOpen) pendingRef.current = undefined;
|
|
250
|
+
setOpen(nextOpen);
|
|
251
|
+
}}
|
|
252
|
+
onOpenChangeComplete={(nextOpen) => {
|
|
253
|
+
// Open the editor only after the chooser has closed and restored focus.
|
|
254
|
+
// Otherwise its focus restoration can steal focus from the editor.
|
|
255
|
+
if (!nextOpen && pendingRef.current) {
|
|
256
|
+
const selection = pendingRef.current;
|
|
257
|
+
pendingRef.current = undefined;
|
|
258
|
+
configuration.onConfigure(selection);
|
|
259
|
+
}
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<PopoverTrigger
|
|
263
|
+
render={
|
|
264
|
+
<Button
|
|
265
|
+
type='button'
|
|
266
|
+
variant='ghost'
|
|
267
|
+
size='sm'
|
|
268
|
+
className={className}
|
|
269
|
+
aria-label={`${label}: ${title(state)}`}
|
|
270
|
+
onClick={(event) => event.stopPropagation()}
|
|
271
|
+
/>
|
|
272
|
+
}
|
|
273
|
+
>
|
|
274
|
+
{!direct && (
|
|
275
|
+
<>
|
|
276
|
+
<Settings2Icon aria-hidden />
|
|
277
|
+
{label}
|
|
278
|
+
</>
|
|
279
|
+
)}
|
|
280
|
+
<ChevronDownIcon aria-hidden />
|
|
281
|
+
</PopoverTrigger>
|
|
282
|
+
<PopoverContent
|
|
283
|
+
align='end'
|
|
284
|
+
className='w-auto max-w-[min(var(--container-sm),var(--available-width))]'
|
|
285
|
+
onClick={(event) => event.stopPropagation()}
|
|
286
|
+
>
|
|
287
|
+
<PopoverTitle className='sr-only'>
|
|
288
|
+
{label}: {title(state)}
|
|
289
|
+
</PopoverTitle>
|
|
290
|
+
{choices.map((selection) => (
|
|
291
|
+
<Button
|
|
292
|
+
key={`${selection.target.kind}:${selection.target.name}`}
|
|
293
|
+
type='button'
|
|
294
|
+
variant='ghost'
|
|
295
|
+
className='h-auto justify-start whitespace-normal text-left'
|
|
296
|
+
onClick={() => {
|
|
297
|
+
pendingRef.current = selection;
|
|
298
|
+
setOpen(false);
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
{configuration.label(selection.target)} ·{' '}
|
|
302
|
+
{title(selection.target)}
|
|
303
|
+
</Button>
|
|
304
|
+
))}
|
|
305
|
+
</PopoverContent>
|
|
306
|
+
</Popover>
|
|
307
|
+
)}
|
|
308
|
+
</>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
@@ -126,7 +126,7 @@ function LevelControl({
|
|
|
126
126
|
if (levels.length === 0) return null;
|
|
127
127
|
if (levels.length === 1)
|
|
128
128
|
return (
|
|
129
|
-
<span className='
|
|
129
|
+
<span className='flex min-h-7 min-w-0 items-center px-2 text-xs text-muted-foreground'>
|
|
130
130
|
{levels[0]?.label}
|
|
131
131
|
</span>
|
|
132
132
|
);
|
|
@@ -141,7 +141,7 @@ function LevelControl({
|
|
|
141
141
|
>
|
|
142
142
|
<SelectTrigger
|
|
143
143
|
size='sm'
|
|
144
|
-
className='h-
|
|
144
|
+
className='h-7 w-full min-w-0 max-w-full flex-1 gap-1 border-0 bg-muted/50 px-2 text-xs shadow-none'
|
|
145
145
|
aria-label={label}
|
|
146
146
|
>
|
|
147
147
|
<SelectValue />
|
|
@@ -162,6 +162,7 @@ function LevelControl({
|
|
|
162
162
|
function PermissionRow({
|
|
163
163
|
row,
|
|
164
164
|
controlId,
|
|
165
|
+
showScopeLabels = false,
|
|
165
166
|
readOnly,
|
|
166
167
|
labels,
|
|
167
168
|
onEnabledChange,
|
|
@@ -169,6 +170,7 @@ function PermissionRow({
|
|
|
169
170
|
}: {
|
|
170
171
|
readonly row: PermissionEditorRow;
|
|
171
172
|
readonly controlId: string;
|
|
173
|
+
readonly showScopeLabels?: boolean;
|
|
172
174
|
readonly readOnly: boolean;
|
|
173
175
|
readonly labels: PermissionEditorLabels;
|
|
174
176
|
readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
|
|
@@ -179,7 +181,7 @@ function PermissionRow({
|
|
|
179
181
|
data-slot='permission-row'
|
|
180
182
|
className='flex min-w-0 flex-col gap-1 rounded-md px-2 py-1.5'
|
|
181
183
|
>
|
|
182
|
-
<div className='
|
|
184
|
+
<div className='grid min-h-6 min-w-0 grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-2 @2xl:grid-cols-[auto_minmax(0,1fr)] gap-y-1'>
|
|
183
185
|
<Checkbox
|
|
184
186
|
id={controlId}
|
|
185
187
|
checked={row.enabled}
|
|
@@ -189,35 +191,45 @@ function PermissionRow({
|
|
|
189
191
|
/>
|
|
190
192
|
<label
|
|
191
193
|
htmlFor={controlId}
|
|
192
|
-
className='min-w-0 cursor-pointer text-sm leading-
|
|
194
|
+
className='min-h-6 min-w-0 max-w-36 cursor-pointer text-sm leading-6 font-medium break-words @2xl:max-w-none'
|
|
193
195
|
>
|
|
194
196
|
{row.label}
|
|
195
197
|
</label>
|
|
196
198
|
{row.enabled && row.levels ? (
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
199
|
+
<div className='col-start-3 min-w-0 @2xl:col-start-2'>
|
|
200
|
+
<LevelControl
|
|
201
|
+
label={labels.levelFor(row.label)}
|
|
202
|
+
levels={row.levels}
|
|
203
|
+
value={row.level ?? ''}
|
|
204
|
+
disabled={readOnly}
|
|
205
|
+
onChange={(level) => onLevelChange(row.id, level)}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
204
208
|
) : null}
|
|
205
209
|
{row.enabled &&
|
|
206
210
|
row.scopes?.map((scope) => (
|
|
207
|
-
<div
|
|
208
|
-
{
|
|
209
|
-
|
|
211
|
+
<div
|
|
212
|
+
key={scope.id}
|
|
213
|
+
className={cn(
|
|
214
|
+
'col-start-3 grid min-w-0 gap-1 @2xl:col-start-2',
|
|
215
|
+
showScopeLabels && 'col-span-2 col-start-2 @2xl:col-span-1',
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
{(showScopeLabels || (row.scopes && row.scopes.length > 1)) && (
|
|
219
|
+
<span className='text-xs leading-5 text-muted-foreground'>
|
|
210
220
|
{scope.label}
|
|
211
221
|
</span>
|
|
212
222
|
)}
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
<div className='flex min-h-7 min-w-0 items-center gap-1'>
|
|
224
|
+
<LevelControl
|
|
225
|
+
label={labels.levelFor(`${row.label} · ${scope.label}`)}
|
|
226
|
+
levels={scope.levels}
|
|
227
|
+
value={scope.level ?? ''}
|
|
228
|
+
disabled={readOnly}
|
|
229
|
+
onChange={(level) => onLevelChange(scope.id, level)}
|
|
230
|
+
/>
|
|
231
|
+
{scope.configuration}
|
|
232
|
+
</div>
|
|
221
233
|
</div>
|
|
222
234
|
))}
|
|
223
235
|
</div>
|
|
@@ -280,7 +292,7 @@ export function PermissionEditor({
|
|
|
280
292
|
return (
|
|
281
293
|
<div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
|
|
282
294
|
<div className='flex flex-wrap items-center gap-3 border-b p-3'>
|
|
283
|
-
<div className='relative min-w-
|
|
295
|
+
<div className='relative min-w-48 flex-1'>
|
|
284
296
|
<SearchIcon
|
|
285
297
|
aria-hidden
|
|
286
298
|
className='pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground'
|
|
@@ -296,7 +308,7 @@ export function PermissionEditor({
|
|
|
296
308
|
</div>
|
|
297
309
|
<label
|
|
298
310
|
htmlFor={`${id}-enabled`}
|
|
299
|
-
className='flex items-center gap-2 text-sm'
|
|
311
|
+
className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
|
|
300
312
|
>
|
|
301
313
|
<Switch
|
|
302
314
|
id={`${id}-enabled`}
|
|
@@ -322,7 +334,7 @@ export function PermissionEditor({
|
|
|
322
334
|
<div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
|
|
323
335
|
<nav
|
|
324
336
|
aria-label={words.navigation}
|
|
325
|
-
className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-
|
|
337
|
+
className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-64 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
|
|
326
338
|
>
|
|
327
339
|
{sections.map((section) => {
|
|
328
340
|
const rows = section.groups.flatMap((group) => group.rows);
|
|
@@ -338,13 +350,18 @@ export function PermissionEditor({
|
|
|
338
350
|
setEnabledOnly(false);
|
|
339
351
|
}}
|
|
340
352
|
className={cn(
|
|
341
|
-
'h-auto shrink-0 justify-between gap-
|
|
353
|
+
'h-auto shrink-0 justify-between gap-3 px-3 py-2 text-left whitespace-nowrap @2xl:w-full @2xl:min-w-0',
|
|
342
354
|
section.id === active &&
|
|
343
355
|
'bg-primary/10 text-primary hover:bg-primary/15',
|
|
344
356
|
)}
|
|
345
357
|
>
|
|
346
|
-
<span
|
|
347
|
-
|
|
358
|
+
<span
|
|
359
|
+
className='min-w-0 @2xl:flex-1 @2xl:truncate'
|
|
360
|
+
title={section.title}
|
|
361
|
+
>
|
|
362
|
+
{section.title}
|
|
363
|
+
</span>
|
|
364
|
+
<span className='shrink-0 rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
|
|
348
365
|
{rows.filter((row) => row.enabled).length}/{rows.length}
|
|
349
366
|
</span>
|
|
350
367
|
</Button>
|
|
@@ -357,6 +374,9 @@ export function PermissionEditor({
|
|
|
357
374
|
const original =
|
|
358
375
|
sections.find((item) => item.id === section.id) ?? section;
|
|
359
376
|
const singleResource = original.groups.length === 1;
|
|
377
|
+
const showScopeLabels = original.groups.some((group) =>
|
|
378
|
+
group.rows.some((row) => (row.scopes?.length ?? 0) > 1),
|
|
379
|
+
);
|
|
360
380
|
const keys = [
|
|
361
381
|
...new Set(
|
|
362
382
|
original.groups
|
|
@@ -369,16 +389,30 @@ export function PermissionEditor({
|
|
|
369
389
|
...common.filter((key) => keys.includes(key)),
|
|
370
390
|
...keys.filter((key) => !common.includes(key)),
|
|
371
391
|
];
|
|
372
|
-
// Dense or unclassified catalogs keep the wrapping layout instead of hiding actions.
|
|
373
392
|
const aligned =
|
|
374
393
|
columns.length > 0 &&
|
|
375
|
-
columns.length <=
|
|
394
|
+
columns.length <= 4 &&
|
|
376
395
|
original.groups.every(
|
|
377
396
|
(group) =>
|
|
378
397
|
group.rows.every((row) => row.actionKey) &&
|
|
379
398
|
new Set(group.rows.map((row) => row.actionKey)).size ===
|
|
380
399
|
group.rows.length,
|
|
381
400
|
);
|
|
401
|
+
// Keep action columns only when each has room for its label and scope.
|
|
402
|
+
const alignment = [
|
|
403
|
+
'',
|
|
404
|
+
'@3xl:grid-cols-(--permission-columns)',
|
|
405
|
+
'@3xl:grid-cols-(--permission-columns)',
|
|
406
|
+
'@5xl:grid-cols-(--permission-columns)',
|
|
407
|
+
'@7xl:grid-cols-(--permission-columns)',
|
|
408
|
+
][columns.length];
|
|
409
|
+
const placement = [
|
|
410
|
+
'',
|
|
411
|
+
'@3xl:col-start-(--permission-column) @3xl:row-start-1',
|
|
412
|
+
'@3xl:col-start-(--permission-column) @3xl:row-start-1',
|
|
413
|
+
'@5xl:col-start-(--permission-column) @5xl:row-start-1',
|
|
414
|
+
'@7xl:col-start-(--permission-column) @7xl:row-start-1',
|
|
415
|
+
][columns.length];
|
|
382
416
|
return (
|
|
383
417
|
<section
|
|
384
418
|
key={section.id}
|
|
@@ -430,11 +464,11 @@ export function PermissionEditor({
|
|
|
430
464
|
return (
|
|
431
465
|
<div
|
|
432
466
|
key={group.id}
|
|
433
|
-
className='grid min-w-0 gap-2 p-3 @
|
|
467
|
+
className='grid min-w-0 gap-2 p-3 @3xl:grid-cols-[9rem_minmax(0,1fr)]'
|
|
434
468
|
data-slot='permission-resource'
|
|
435
469
|
>
|
|
436
|
-
<div className='flex min-w-0 flex-col
|
|
437
|
-
<h3 className='text-sm font-medium break-words'>
|
|
470
|
+
<div className='flex min-w-0 flex-col gap-1 py-1.5'>
|
|
471
|
+
<h3 className='min-h-6 text-sm leading-6 font-medium break-words'>
|
|
438
472
|
{group.title ?? section.title}
|
|
439
473
|
</h3>
|
|
440
474
|
{group.hint && (
|
|
@@ -442,7 +476,12 @@ export function PermissionEditor({
|
|
|
442
476
|
{group.hint}
|
|
443
477
|
</p>
|
|
444
478
|
)}
|
|
445
|
-
<div
|
|
479
|
+
<div
|
|
480
|
+
className={cn(
|
|
481
|
+
'flex min-h-7 items-center gap-2 text-xs text-muted-foreground',
|
|
482
|
+
showScopeLabels && '@3xl:mt-6',
|
|
483
|
+
)}
|
|
484
|
+
>
|
|
446
485
|
{onEnabledBatchChange &&
|
|
447
486
|
totalRows.length > 1 &&
|
|
448
487
|
selectable.length > 0 && (
|
|
@@ -514,9 +553,8 @@ export function PermissionEditor({
|
|
|
514
553
|
</div>
|
|
515
554
|
<div
|
|
516
555
|
className={cn(
|
|
517
|
-
'
|
|
518
|
-
aligned &&
|
|
519
|
-
'@4xl:grid @4xl:grid-cols-(--permission-columns) @4xl:gap-x-1',
|
|
556
|
+
'grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))] items-start gap-x-2 gap-y-1',
|
|
557
|
+
aligned && alignment,
|
|
520
558
|
)}
|
|
521
559
|
style={
|
|
522
560
|
{
|
|
@@ -524,42 +562,42 @@ export function PermissionEditor({
|
|
|
524
562
|
} as CSSProperties
|
|
525
563
|
}
|
|
526
564
|
>
|
|
527
|
-
{
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
565
|
+
{[...group.rows]
|
|
566
|
+
.sort(
|
|
567
|
+
(a, b) =>
|
|
568
|
+
columns.indexOf(a.actionKey ?? '') -
|
|
569
|
+
columns.indexOf(b.actionKey ?? ''),
|
|
570
|
+
)
|
|
571
|
+
.map((row) => (
|
|
572
|
+
<div
|
|
573
|
+
key={row.id}
|
|
574
|
+
className={cn(
|
|
575
|
+
'min-w-0',
|
|
576
|
+
aligned && placement,
|
|
577
|
+
)}
|
|
578
|
+
style={
|
|
579
|
+
{
|
|
580
|
+
'--permission-column':
|
|
581
|
+
columns.indexOf(row.actionKey ?? '') +
|
|
582
|
+
1,
|
|
583
|
+
} as CSSProperties
|
|
584
|
+
}
|
|
585
|
+
>
|
|
586
|
+
<PermissionRow
|
|
587
|
+
controlId={`${id}-${row.id}`}
|
|
588
|
+
row={row}
|
|
589
|
+
showScopeLabels={showScopeLabels}
|
|
590
|
+
readOnly={readOnly}
|
|
591
|
+
labels={words}
|
|
592
|
+
onEnabledChange={(rowId, on) => {
|
|
593
|
+
if (enabledOnly && !on)
|
|
594
|
+
searchRef.current?.focus();
|
|
595
|
+
onEnabledChange(rowId, on);
|
|
596
|
+
}}
|
|
597
|
+
onLevelChange={onLevelChange}
|
|
598
|
+
/>
|
|
599
|
+
</div>
|
|
600
|
+
))}
|
|
563
601
|
</div>
|
|
564
602
|
</div>
|
|
565
603
|
);
|