@nocobase/app-template-default 1.0.0-beta.84 → 1.0.0-beta.85

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 (42) hide show
  1. package/AGENTS.md +1 -1
  2. package/client/components/data-table/data-table.tsx +86 -32
  3. package/client/components/list-feedback.tsx +174 -0
  4. package/client/components/permission-editor.tsx +40 -33
  5. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  6. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  7. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  8. package/client/hooks/use-list-data.ts +1 -2
  9. package/client/locales/system/en-US.ts +14 -1
  10. package/client/locales/system/settings-en-US.ts +0 -10
  11. package/client/locales/system/settings-zh-CN.ts +0 -8
  12. package/client/locales/system/zh-CN.ts +14 -1
  13. package/client/pages/settings/default-access.tsx +50 -72
  14. package/client/pages/settings/permissions-model.ts +0 -1
  15. package/client/pages/settings/roles.tsx +63 -97
  16. package/client/pages/settings/users.tsx +72 -70
  17. package/client/runtime.ts +0 -4
  18. package/config.example.yml +24 -0
  19. package/package.json +19 -17
  20. package/server/app.ts +0 -2
  21. package/server/config/auth.ts +2 -0
  22. package/server/config/concurrency.ts +3 -0
  23. package/server/config/index.ts +3 -0
  24. package/server/config/jobs.ts +1 -13
  25. package/server/config/notification.ts +1 -1
  26. package/server/config/queue.ts +1 -7
  27. package/server/plugins.ts +2 -0
  28. package/tests/client/data-table.test.tsx +9 -4
  29. package/tests/client/default-access.test.tsx +43 -5
  30. package/tests/client/list-feedback.test.tsx +301 -0
  31. package/tests/client/manual-list-retry.test.tsx +190 -0
  32. package/tests/client/permission-workspace.test.ts +13 -0
  33. package/tests/client/settings-continuity.test.tsx +80 -1
  34. package/tests/client/settings-feedback.test.tsx +7 -15
  35. package/tests/fixtures/permission-alignment.html +16 -0
  36. package/tests/fixtures/permission-alignment.tsx +124 -0
  37. package/tests/playwright/permission-alignment.test.ts +109 -0
  38. package/tests/server/auth-email-config.test.ts +79 -0
  39. package/tests/server/concurrency.test.ts +61 -0
  40. package/tests/server/config.test.ts +34 -14
  41. package/client/route-overrides.ts +0 -9
  42. 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, a source extension or `client/route-overrides.ts`, 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).
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 { useTranslation } from '@nocobase/i18n/client';
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 place of the body when no row survives filtering. */
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
- {rows.length > 0 ? (
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
- colSpan={columns.length}
198
- className='h-24 text-center text-muted-foreground'
199
- >
200
- {emptyMessage ??
201
- t('dataTable.noResults', { defaultValue: 'No results.' })}
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={cn(
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
- <LevelControl
616
- label={labels.levelFor(row.label)}
617
- levels={row.levels}
618
- value={row.level ?? ''}
619
- disabled={readOnly}
620
- onChange={(level) => onLevelChange(row.id, level)}
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
- className='grid min-w-0 grid-cols-[minmax(4rem,auto)_minmax(0,1fr)] items-center gap-2'
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 className='max-w-36 text-xs break-words text-muted-foreground'>
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='flex min-w-0 items-center gap-1'>
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
- {scope.configuration}
640
- {scope.hint && (
641
- <ScopeHint
642
- label={labels.scopeHintFor(`${row.label} · ${scope.label}`)}
643
- >
644
- {scope.hint}
645
- </ScopeHint>
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 sm:col-span-full'
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. Every label and formatted value is supplied by the caller; the block imports no example plugin, API client, authorization provider or router.
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: ReactNode;
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
- {loaded && rows.length > 0 && (
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
  );