@nocobase/app-template-default 1.0.0-beta.84 → 1.0.0-beta.86
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.md +1 -1
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +40 -33
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +1 -2
- package/client/locales/system/en-US.ts +14 -1
- package/client/locales/system/settings-en-US.ts +0 -10
- package/client/locales/system/settings-zh-CN.ts +0 -8
- package/client/locales/system/zh-CN.ts +14 -1
- package/client/pages/settings/default-access.tsx +50 -72
- package/client/pages/settings/permissions-model.ts +0 -1
- package/client/pages/settings/roles.tsx +63 -97
- package/client/pages/settings/users.tsx +72 -70
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/package.json +33 -31
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +43 -5
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/settings-continuity.test.tsx +80 -1
- package/tests/client/settings-feedback.test.tsx +7 -15
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +125 -0
- package/tests/server/config.test.ts +34 -14
- package/client/route-overrides.ts +0 -9
- package/client/source-extensions.ts +0 -19
package/AGENTS.md
CHANGED
|
@@ -95,7 +95,7 @@ Put a package your **server** code (`server/`, `database/`, `cli/`) imports in `
|
|
|
95
95
|
|
|
96
96
|
### Plugins
|
|
97
97
|
|
|
98
|
-
**Read a plugin's Skill before building what it already does.** Building a permission system, a notification sender, or a job scheduler by hand when a registered plugin already provides one is the most expensive mistake available here. Register and update plugins with `pnpm nocobase plugin register` and `pnpm nocobase plugin update`; customize a plugin's page through its options
|
|
98
|
+
**Read a plugin's Skill before building what it already does.** Building a permission system, a notification sender, or a job scheduler by hand when a registered plugin already provides one is the most expensive mistake available here. Register and update plugins with `pnpm nocobase plugin register` and `pnpm nocobase plugin update`; customize a plugin's page through its options or an explicitly registered route component override, never by redeclaring its route. `.agents/skills/` is generated output; never edit a file there. See [plugins](.agents/skills/nocobase-app-development/references/plugins.md).
|
|
99
99
|
|
|
100
100
|
### Running the application
|
|
101
101
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ListState } from '@nocobase/app-client';
|
|
2
|
+
import { ListFeedback, ListFeedbackActions } from '#components/list-feedback';
|
|
2
3
|
import {
|
|
3
4
|
type ColumnDef,
|
|
4
5
|
type PaginationState,
|
|
@@ -41,13 +42,17 @@ export interface DataTableProps<TData, TValue = unknown> {
|
|
|
41
42
|
readonly onSortingChange?: OnChangeFn<SortingState>;
|
|
42
43
|
readonly showSelectedCount?: boolean;
|
|
43
44
|
readonly loading?: boolean;
|
|
45
|
+
readonly listState?: ListState;
|
|
46
|
+
readonly onRetry?: () => void;
|
|
47
|
+
readonly onClearFilters?: () => void;
|
|
48
|
+
readonly emptyAction?: ReactNode;
|
|
44
49
|
readonly className?: string;
|
|
45
50
|
/**
|
|
46
51
|
* Rendered above the table and handed the table instance, so filters and
|
|
47
52
|
* `DataTableViewOptions` can read and drive its state.
|
|
48
53
|
*/
|
|
49
54
|
readonly toolbar?: (table: TableInstance<TData>) => ReactNode;
|
|
50
|
-
/** Shown in
|
|
55
|
+
/** Shown in the table body when no row survives filtering. */
|
|
51
56
|
readonly emptyMessage?: ReactNode;
|
|
52
57
|
/** Set to `false` to render every row and hide the pagination footer. */
|
|
53
58
|
readonly pagination?: boolean;
|
|
@@ -91,8 +96,11 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
91
96
|
onSortingChange,
|
|
92
97
|
showSelectedCount = true,
|
|
93
98
|
loading = false,
|
|
99
|
+
listState,
|
|
100
|
+
onRetry,
|
|
101
|
+
onClearFilters,
|
|
102
|
+
emptyAction,
|
|
94
103
|
}: DataTableProps<TData, TValue>): ReactElement {
|
|
95
|
-
const { t } = useTranslation();
|
|
96
104
|
const tableData = useMemo(() => [...data], [data]);
|
|
97
105
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
98
106
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
@@ -134,12 +142,56 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
134
142
|
});
|
|
135
143
|
|
|
136
144
|
const rows = table.getRowModel().rows;
|
|
145
|
+
const requestedState =
|
|
146
|
+
(listState === 'ready' && rows.length === 0
|
|
147
|
+
? columnFilters.length > 0
|
|
148
|
+
? 'no-results'
|
|
149
|
+
: 'empty'
|
|
150
|
+
: listState) ??
|
|
151
|
+
(loading && data.length === 0
|
|
152
|
+
? 'loading'
|
|
153
|
+
: rows.length
|
|
154
|
+
? 'ready'
|
|
155
|
+
: columnFilters.length
|
|
156
|
+
? 'no-results'
|
|
157
|
+
: 'empty');
|
|
158
|
+
const state =
|
|
159
|
+
(requestedState === 'empty' || requestedState === 'no-results') &&
|
|
160
|
+
((manualPagination && columnFilters.length === 0 && (rowCount ?? 0) > 0) ||
|
|
161
|
+
(!manualPagination && table.getPrePaginationRowModel().rows.length > 0))
|
|
162
|
+
? 'loading'
|
|
163
|
+
: requestedState;
|
|
164
|
+
const clearFilters = () => {
|
|
165
|
+
table.resetColumnFilters();
|
|
166
|
+
table.setPageIndex(0);
|
|
167
|
+
onClearFilters?.();
|
|
168
|
+
};
|
|
169
|
+
const hasStateActions =
|
|
170
|
+
(state === 'no-results' &&
|
|
171
|
+
(columnFilters.length > 0 || onClearFilters !== undefined)) ||
|
|
172
|
+
((state === 'failed' || state === 'refresh-failed') &&
|
|
173
|
+
onRetry !== undefined) ||
|
|
174
|
+
state === 'unauthenticated';
|
|
175
|
+
const showRows =
|
|
176
|
+
rows.length > 0 && (state === 'ready' || state === 'refresh-failed');
|
|
177
|
+
const showFeedback = !showRows || state === 'refresh-failed';
|
|
178
|
+
const feedbackColumnCount = Math.max(1, table.getVisibleLeafColumns().length);
|
|
137
179
|
|
|
138
180
|
return (
|
|
139
181
|
<div className={cn('flex flex-col gap-4', className)}>
|
|
140
|
-
{toolbar ? (
|
|
182
|
+
{toolbar || hasStateActions ? (
|
|
141
183
|
<div className='flex flex-wrap items-center gap-2'>
|
|
142
|
-
{toolbar(table)}
|
|
184
|
+
{toolbar?.(table)}
|
|
185
|
+
<ListFeedbackActions
|
|
186
|
+
state={state}
|
|
187
|
+
onClear={
|
|
188
|
+
columnFilters.length > 0 || onClearFilters
|
|
189
|
+
? clearFilters
|
|
190
|
+
: undefined
|
|
191
|
+
}
|
|
192
|
+
onRetry={onRetry}
|
|
193
|
+
busy={loading}
|
|
194
|
+
/>
|
|
143
195
|
</div>
|
|
144
196
|
) : null}
|
|
145
197
|
<div className='overflow-hidden rounded-lg border'>
|
|
@@ -173,39 +225,41 @@ export function DataTable<TData, TValue = unknown>({
|
|
|
173
225
|
))}
|
|
174
226
|
</TableHeader>
|
|
175
227
|
<TableBody>
|
|
176
|
-
{
|
|
177
|
-
rows.map((row) => (
|
|
178
|
-
<TableRow
|
|
179
|
-
key={row.id}
|
|
180
|
-
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
181
|
-
className={onRowClick ? 'cursor-pointer' : undefined}
|
|
182
|
-
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
183
|
-
>
|
|
184
|
-
{row.getVisibleCells().map((cell) => (
|
|
185
|
-
<TableCell key={cell.id}>
|
|
186
|
-
{flexRender(
|
|
187
|
-
cell.column.columnDef.cell,
|
|
188
|
-
cell.getContext(),
|
|
189
|
-
)}
|
|
190
|
-
</TableCell>
|
|
191
|
-
))}
|
|
192
|
-
</TableRow>
|
|
193
|
-
))
|
|
194
|
-
) : (
|
|
228
|
+
{showFeedback ? (
|
|
195
229
|
<TableRow>
|
|
196
|
-
<TableCell
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
<TableCell colSpan={feedbackColumnCount} className='p-0'>
|
|
231
|
+
<ListFeedback
|
|
232
|
+
state={state}
|
|
233
|
+
empty={emptyMessage}
|
|
234
|
+
emptyAction={emptyAction}
|
|
235
|
+
/>
|
|
202
236
|
</TableCell>
|
|
203
237
|
</TableRow>
|
|
204
|
-
)}
|
|
238
|
+
) : null}
|
|
239
|
+
{showRows
|
|
240
|
+
? rows.map((row) => (
|
|
241
|
+
<TableRow
|
|
242
|
+
key={row.id}
|
|
243
|
+
data-state={row.getIsSelected() ? 'selected' : undefined}
|
|
244
|
+
className={onRowClick ? 'cursor-pointer' : undefined}
|
|
245
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
246
|
+
>
|
|
247
|
+
{row.getVisibleCells().map((cell) => (
|
|
248
|
+
<TableCell key={cell.id}>
|
|
249
|
+
{flexRender(
|
|
250
|
+
cell.column.columnDef.cell,
|
|
251
|
+
cell.getContext(),
|
|
252
|
+
)}
|
|
253
|
+
</TableCell>
|
|
254
|
+
))}
|
|
255
|
+
</TableRow>
|
|
256
|
+
))
|
|
257
|
+
: null}
|
|
205
258
|
</TableBody>
|
|
206
259
|
</Table>
|
|
207
260
|
</div>
|
|
208
|
-
{pagination
|
|
261
|
+
{pagination &&
|
|
262
|
+
(state === 'ready' || (state === 'refresh-failed' && rows.length > 0)) ? (
|
|
209
263
|
<DataTablePagination
|
|
210
264
|
table={table}
|
|
211
265
|
pageSizeOptions={pageSizeOptions}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ListState } from '@nocobase/app-client';
|
|
2
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
3
|
+
import {
|
|
4
|
+
CircleAlert,
|
|
5
|
+
Inbox,
|
|
6
|
+
LoaderCircle,
|
|
7
|
+
LockKeyhole,
|
|
8
|
+
LogIn,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
SearchX,
|
|
11
|
+
X,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
import { Fragment, type ReactElement, type ReactNode } from 'react';
|
|
14
|
+
import { Link } from 'react-router';
|
|
15
|
+
import { Button } from '#components/ui/button';
|
|
16
|
+
import { Alert } from '#components/ui/alert';
|
|
17
|
+
import {
|
|
18
|
+
Empty,
|
|
19
|
+
EmptyContent,
|
|
20
|
+
EmptyDescription,
|
|
21
|
+
EmptyMedia,
|
|
22
|
+
EmptyTitle,
|
|
23
|
+
} from '#components/ui/empty';
|
|
24
|
+
|
|
25
|
+
const feedback = {
|
|
26
|
+
loading: ['listFeedback.loading', 'Loading', LoaderCircle],
|
|
27
|
+
empty: ['listFeedback.empty', 'No data yet', Inbox],
|
|
28
|
+
'no-results': ['listFeedback.noResults', 'No results', SearchX],
|
|
29
|
+
forbidden: ['listFeedback.forbidden', 'Access denied', LockKeyhole],
|
|
30
|
+
unauthenticated: ['listFeedback.unauthenticated', 'Session expired', LogIn],
|
|
31
|
+
failed: ['listFeedback.failed', 'Unable to load list', CircleAlert],
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export interface ListFeedbackProps {
|
|
35
|
+
readonly state: ListState;
|
|
36
|
+
readonly children?: ReactNode;
|
|
37
|
+
/** Business empty content applies only after a successful unfiltered read. */
|
|
38
|
+
readonly empty?: ReactNode;
|
|
39
|
+
/** Optional business-specific action; the standard create action belongs in the page header. */
|
|
40
|
+
readonly emptyAction?: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ListFeedbackActionsProps {
|
|
44
|
+
readonly state: ListState;
|
|
45
|
+
readonly onClear?: () => void;
|
|
46
|
+
readonly onRetry?: () => void;
|
|
47
|
+
readonly busy?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render state-appropriate recovery controls inside the host's existing toolbar. */
|
|
51
|
+
export function ListFeedbackActions({
|
|
52
|
+
state,
|
|
53
|
+
onClear,
|
|
54
|
+
onRetry,
|
|
55
|
+
busy = false,
|
|
56
|
+
}: ListFeedbackActionsProps): ReactElement | null {
|
|
57
|
+
const { t } = useTranslation();
|
|
58
|
+
|
|
59
|
+
if (state === 'no-results' && onClear) {
|
|
60
|
+
return (
|
|
61
|
+
<Button variant='outline' onClick={onClear}>
|
|
62
|
+
<X aria-hidden='true' />
|
|
63
|
+
{t('listFeedback.clear', { defaultValue: 'Clear filters' })}
|
|
64
|
+
</Button>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if ((state === 'failed' || state === 'refresh-failed') && onRetry) {
|
|
69
|
+
return (
|
|
70
|
+
<Button variant='outline' disabled={busy} onClick={onRetry}>
|
|
71
|
+
{busy ? (
|
|
72
|
+
<LoaderCircle
|
|
73
|
+
aria-hidden='true'
|
|
74
|
+
className='motion-safe:animate-spin'
|
|
75
|
+
/>
|
|
76
|
+
) : (
|
|
77
|
+
<RefreshCw aria-hidden='true' />
|
|
78
|
+
)}
|
|
79
|
+
{busy
|
|
80
|
+
? t('listFeedback.retrying', { defaultValue: 'Retrying…' })
|
|
81
|
+
: t('listFeedback.retry', { defaultValue: 'Retry' })}
|
|
82
|
+
</Button>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (state === 'unauthenticated') {
|
|
87
|
+
return (
|
|
88
|
+
<Button
|
|
89
|
+
variant='outline'
|
|
90
|
+
nativeButton={false}
|
|
91
|
+
render={<Link to='/login' />}
|
|
92
|
+
>
|
|
93
|
+
<LogIn aria-hidden='true' />
|
|
94
|
+
{t('listFeedback.signIn', { defaultValue: 'Sign in' })}
|
|
95
|
+
</Button>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Shared by tables and card lists. Authorization is decided by the host. */
|
|
103
|
+
export function ListFeedback({
|
|
104
|
+
state,
|
|
105
|
+
children,
|
|
106
|
+
empty,
|
|
107
|
+
emptyAction,
|
|
108
|
+
}: ListFeedbackProps): ReactElement {
|
|
109
|
+
const { t } = useTranslation();
|
|
110
|
+
|
|
111
|
+
if (state === 'ready') {
|
|
112
|
+
return <Fragment key='list-feedback-content'>{children}</Fragment>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (state === 'refresh-failed') {
|
|
116
|
+
return (
|
|
117
|
+
<>
|
|
118
|
+
<Alert
|
|
119
|
+
className='flex min-w-0 items-center gap-2 border-border px-3 py-2 text-sm'
|
|
120
|
+
role='alert'
|
|
121
|
+
>
|
|
122
|
+
<CircleAlert
|
|
123
|
+
aria-hidden='true'
|
|
124
|
+
className='size-4 shrink-0 text-destructive'
|
|
125
|
+
/>
|
|
126
|
+
<p className='min-w-0 flex-1 wrap-anywhere'>
|
|
127
|
+
{t('listFeedback.refreshFailed', {
|
|
128
|
+
defaultValue: 'Refresh failed; showing previous data',
|
|
129
|
+
})}
|
|
130
|
+
</p>
|
|
131
|
+
</Alert>
|
|
132
|
+
<Fragment key='list-feedback-content'>{children}</Fragment>
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const [key, fallback, Icon] = feedback[state];
|
|
138
|
+
const isError =
|
|
139
|
+
state === 'failed' || state === 'forbidden' || state === 'unauthenticated';
|
|
140
|
+
const showCustomEmpty =
|
|
141
|
+
state === 'empty' && (empty !== undefined || emptyAction !== undefined);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<Empty
|
|
146
|
+
role={isError ? 'alert' : 'status'}
|
|
147
|
+
className='min-h-24 flex-row flex-none gap-2 rounded-none border-0 px-4 py-6 text-pretty text-card-foreground'
|
|
148
|
+
aria-busy={state === 'loading'}
|
|
149
|
+
>
|
|
150
|
+
<EmptyMedia className='mb-0 size-4 text-muted-foreground'>
|
|
151
|
+
<Icon
|
|
152
|
+
aria-hidden='true'
|
|
153
|
+
className={
|
|
154
|
+
state === 'loading' ? 'size-4 motion-safe:animate-spin' : 'size-4'
|
|
155
|
+
}
|
|
156
|
+
/>
|
|
157
|
+
</EmptyMedia>
|
|
158
|
+
<EmptyTitle className='text-sm font-medium text-muted-foreground'>
|
|
159
|
+
{t(key, { defaultValue: fallback })}
|
|
160
|
+
</EmptyTitle>
|
|
161
|
+
</Empty>
|
|
162
|
+
{showCustomEmpty ? (
|
|
163
|
+
<EmptyContent className='max-w-full px-4 pb-4 text-center'>
|
|
164
|
+
{empty !== undefined ? (
|
|
165
|
+
<EmptyDescription className='max-w-md wrap-anywhere'>
|
|
166
|
+
{empty}
|
|
167
|
+
</EmptyDescription>
|
|
168
|
+
) : null}
|
|
169
|
+
{emptyAction}
|
|
170
|
+
</EmptyContent>
|
|
171
|
+
) : null}
|
|
172
|
+
</>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -538,8 +538,6 @@ function PermissionRow({
|
|
|
538
538
|
}): ReactElement {
|
|
539
539
|
const hasControls =
|
|
540
540
|
row.enabled && Boolean(row.levels?.length || row.scopes?.length);
|
|
541
|
-
const singleRange =
|
|
542
|
-
hasControls && (row.scopes?.length ?? 0) + (row.levels ? 1 : 0) === 1;
|
|
543
541
|
const describedBy =
|
|
544
542
|
[
|
|
545
543
|
row.hint ? `${controlId}-hint` : undefined,
|
|
@@ -554,19 +552,14 @@ function PermissionRow({
|
|
|
554
552
|
className={cn(
|
|
555
553
|
'group/field grid min-w-0 gap-2 rounded-md px-2 py-2 hover:bg-hover',
|
|
556
554
|
presentation === 'inline' && 'inline-flex items-start',
|
|
557
|
-
singleRange && 'sm:flex sm:flex-wrap sm:items-start sm:gap-x-5',
|
|
558
555
|
)}
|
|
559
556
|
>
|
|
560
|
-
<div
|
|
561
|
-
className={cn(
|
|
562
|
-
'flex min-w-0 items-start gap-3',
|
|
563
|
-
singleRange && 'sm:max-w-lg',
|
|
564
|
-
)}
|
|
565
|
-
>
|
|
557
|
+
<div className='flex min-w-0 items-start gap-3'>
|
|
566
558
|
<Checkbox
|
|
567
559
|
id={controlId}
|
|
568
560
|
className='mt-0.5 shrink-0'
|
|
569
561
|
checked={row.enabled}
|
|
562
|
+
aria-label={row.label}
|
|
570
563
|
disabled={readOnly || row.toggleable === false}
|
|
571
564
|
onCheckedChange={(on: boolean) => onEnabledChange(row.id, on)}
|
|
572
565
|
aria-describedby={describedBy}
|
|
@@ -606,29 +599,35 @@ function PermissionRow({
|
|
|
606
599
|
{hasControls && (
|
|
607
600
|
<div
|
|
608
601
|
data-slot='permission-scopes'
|
|
609
|
-
className=
|
|
610
|
-
'grid min-w-0 gap-x-5 gap-y-2 pl-7 sm:grid-cols-2',
|
|
611
|
-
singleRange && 'w-full sm:w-80 sm:shrink-0 sm:grid-cols-1 sm:pl-0',
|
|
612
|
-
)}
|
|
602
|
+
className='grid min-w-0 max-w-[60rem] gap-x-6 gap-y-2 pl-7 @min-[48rem]/permissions:grid-cols-2'
|
|
613
603
|
>
|
|
614
|
-
{row.levels && (
|
|
615
|
-
<
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
604
|
+
{row.levels && row.levels.length > 0 && (
|
|
605
|
+
<div className='grid min-w-0 items-start gap-x-3 gap-y-1 @min-[26rem]/permissions:grid-cols-[8.5rem_minmax(0,1fr)]'>
|
|
606
|
+
<span aria-hidden />
|
|
607
|
+
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-x-1'>
|
|
608
|
+
<LevelControl
|
|
609
|
+
label={labels.levelFor(row.label)}
|
|
610
|
+
levels={row.levels}
|
|
611
|
+
value={row.level ?? ''}
|
|
612
|
+
disabled={readOnly}
|
|
613
|
+
onChange={(level) => onLevelChange(row.id, level)}
|
|
614
|
+
/>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
622
617
|
)}
|
|
623
618
|
{row.scopes?.map((scope) => (
|
|
624
619
|
<div
|
|
625
620
|
key={scope.id}
|
|
626
|
-
|
|
621
|
+
data-slot='permission-scope'
|
|
622
|
+
className='grid min-w-0 items-start gap-x-3 gap-y-1 @min-[26rem]/permissions:grid-cols-[8.5rem_minmax(0,1fr)]'
|
|
627
623
|
>
|
|
628
|
-
<span
|
|
624
|
+
<span
|
|
625
|
+
title={scope.label}
|
|
626
|
+
className='line-clamp-2 min-w-0 pt-1 text-xs break-words text-muted-foreground'
|
|
627
|
+
>
|
|
629
628
|
{scope.label}
|
|
630
629
|
</span>
|
|
631
|
-
<div className='
|
|
630
|
+
<div className='grid min-w-0 grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-x-1 gap-y-1'>
|
|
632
631
|
<LevelControl
|
|
633
632
|
label={labels.levelFor(`${row.label} · ${scope.label}`)}
|
|
634
633
|
levels={scope.levels}
|
|
@@ -636,13 +635,21 @@ function PermissionRow({
|
|
|
636
635
|
disabled={readOnly}
|
|
637
636
|
onChange={(level) => onLevelChange(scope.id, level)}
|
|
638
637
|
/>
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
638
|
+
<div className='col-start-2 row-start-1'>
|
|
639
|
+
{scope.hint && (
|
|
640
|
+
<ScopeHint
|
|
641
|
+
label={labels.scopeHintFor(
|
|
642
|
+
`${row.label} · ${scope.label}`,
|
|
643
|
+
)}
|
|
644
|
+
>
|
|
645
|
+
{scope.hint}
|
|
646
|
+
</ScopeHint>
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
{scope.configuration && (
|
|
650
|
+
<div className='col-start-1 min-w-0'>
|
|
651
|
+
{scope.configuration}
|
|
652
|
+
</div>
|
|
646
653
|
)}
|
|
647
654
|
</div>
|
|
648
655
|
</div>
|
|
@@ -652,7 +659,7 @@ function PermissionRow({
|
|
|
652
659
|
].map((hint) => (
|
|
653
660
|
<p
|
|
654
661
|
key={hint}
|
|
655
|
-
className='text-xs leading-5 text-muted-foreground
|
|
662
|
+
className='col-span-full text-xs leading-5 text-muted-foreground'
|
|
656
663
|
>
|
|
657
664
|
{hint}
|
|
658
665
|
</p>
|
|
@@ -797,7 +804,7 @@ export function PermissionEditor({
|
|
|
797
804
|
setQuery('');
|
|
798
805
|
setStatus('all');
|
|
799
806
|
}}
|
|
800
|
-
className='min-w-0 gap-3'
|
|
807
|
+
className='@container/permissions min-w-0 gap-3'
|
|
801
808
|
>
|
|
802
809
|
{available.length > 0 && (
|
|
803
810
|
<div className='overflow-x-auto pb-1'>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Workflow management
|
|
2
2
|
|
|
3
|
-
Install with `yes n | pnpm exec shadcn add @nocobase/workflow-management`. This block provides the workflow example's administration layout and interactions as presentational parts in `client/extensions/nocobase-workflow-management/`. Read `@nocobase/workflow-management-demo` for a standalone composition.
|
|
3
|
+
Install with `yes n | pnpm exec shadcn add @nocobase/workflow-management`. This block provides the workflow example's administration layout and interactions as presentational parts in `client/extensions/nocobase-workflow-management/`. Read `@nocobase/workflow-management-demo` for a standalone composition. Business labels and formatted values are supplied by the caller; the block imports no example plugin, API client, authorization provider or router.
|
|
4
4
|
|
|
5
5
|
| File | Exports and responsibility |
|
|
6
6
|
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -70,3 +70,5 @@ Connect `LifecycleDiagram.configuration` to the application's existing authorize
|
|
|
70
70
|
Omit `WorkflowDefinitionDetail.parameters` when the editor opens contextually so the diagram spans the available width; keep the slot when the same editor is visible beside the diagram. No editor, API, sample business registration or menu is installed by this block. Definition and execution lists use the same TanStack DataTable as the templates, with no second local pagination over a server page.
|
|
71
71
|
|
|
72
72
|
Ordering is optional: omit `onSort` when activity ordering is not supported so the header stays plain text. Omit a row's optional `name` when its linked `title` already supplies the complete display name. Hosts own filtering, pagination and their URL state; keep that state when opening and returning from a definition or execution.
|
|
73
|
+
|
|
74
|
+
List hosts pass `listState={resolveListState(request, rows.length, hasEffectiveFilters)}` and `onClearFilters`, using the current request error and data. The DataTable dependency supplies shared loading, denial, session and failure copy; merge the `listFeedback` English/Chinese keys from the components README into application translations. `onRefresh` is reused for retry and must preserve the current query. Keep `loaded`, `loading`, custom `empty` content and existing permission checks when reconciling installed copies. A successful empty filter is `no-results`; an empty unfiltered read is `empty`. Hosts that retain the legacy status slot without a listState still own their error rendering.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ListState } from '@nocobase/app-client';
|
|
1
2
|
import type { ReactElement, ReactNode } from 'react';
|
|
2
3
|
import type {
|
|
3
4
|
CellContext,
|
|
@@ -16,13 +17,15 @@ export interface WorkflowDefinitionRow {
|
|
|
16
17
|
activity: ReactNode;
|
|
17
18
|
}
|
|
18
19
|
export interface WorkflowDefinitionListProps {
|
|
20
|
+
listState?: ListState;
|
|
21
|
+
onClearFilters?: () => void;
|
|
19
22
|
rows: readonly WorkflowDefinitionRow[];
|
|
20
23
|
categories?: ReactNode;
|
|
21
24
|
search: ReactNode;
|
|
22
25
|
status?: ReactNode;
|
|
23
26
|
loaded: boolean;
|
|
24
27
|
loading: boolean;
|
|
25
|
-
empty
|
|
28
|
+
empty?: ReactNode;
|
|
26
29
|
labels: {
|
|
27
30
|
refresh: string;
|
|
28
31
|
workflow: string;
|
|
@@ -39,6 +42,8 @@ type DefinitionActivityMeta = ColumnMeta<WorkflowDefinitionRow, unknown> &
|
|
|
39
42
|
|
|
40
43
|
export function WorkflowDefinitionList({
|
|
41
44
|
rows,
|
|
45
|
+
listState,
|
|
46
|
+
onClearFilters,
|
|
42
47
|
categories,
|
|
43
48
|
search,
|
|
44
49
|
status,
|
|
@@ -71,10 +76,22 @@ export function WorkflowDefinitionList({
|
|
|
71
76
|
</Button>
|
|
72
77
|
</div>
|
|
73
78
|
{status}
|
|
74
|
-
{
|
|
79
|
+
{(listState !== undefined || loaded || loading) && (
|
|
75
80
|
<div className='bg-card'>
|
|
76
81
|
<DataTable
|
|
77
82
|
data={rows}
|
|
83
|
+
loading={loading}
|
|
84
|
+
listState={
|
|
85
|
+
listState ??
|
|
86
|
+
(!loaded ? 'loading' : rows.length ? 'ready' : 'empty')
|
|
87
|
+
}
|
|
88
|
+
onRetry={onRefresh}
|
|
89
|
+
onClearFilters={onClearFilters}
|
|
90
|
+
emptyMessage={
|
|
91
|
+
<Empty>
|
|
92
|
+
<EmptyDescription>{empty}</EmptyDescription>
|
|
93
|
+
</Empty>
|
|
94
|
+
}
|
|
78
95
|
getRowId={(row) => row.id}
|
|
79
96
|
pagination={false}
|
|
80
97
|
manualSorting
|
|
@@ -100,11 +117,6 @@ export function WorkflowDefinitionList({
|
|
|
100
117
|
/>
|
|
101
118
|
</div>
|
|
102
119
|
)}
|
|
103
|
-
{loaded && rows.length === 0 && (
|
|
104
|
-
<Empty>
|
|
105
|
-
<EmptyDescription>{empty}</EmptyDescription>
|
|
106
|
-
</Empty>
|
|
107
|
-
)}
|
|
108
120
|
</div>
|
|
109
121
|
</section>
|
|
110
122
|
);
|