@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.
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 +33 -31
  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 +125 -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
@@ -9,7 +9,12 @@ import {
9
9
  import { DataTable, DataTableColumnHeader } from '#components/data-table';
10
10
  import type { ColumnDef } from '@tanstack/react-table';
11
11
  import { useSettingsData, useUserPermission } from './hooks.js';
12
- import { useApiClient, useSubmission, useToaster } from '@nocobase/app-client';
12
+ import {
13
+ resolveListState,
14
+ useApiClient,
15
+ useSubmission,
16
+ useToaster,
17
+ } from '@nocobase/app-client';
13
18
  import { useTranslation } from '@nocobase/i18n/client';
14
19
  import {
15
20
  UsersClient,
@@ -25,12 +30,13 @@ import {
25
30
  type ReactElement,
26
31
  } from 'react';
27
32
  import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
28
- import { PlusIcon, UsersIcon } from 'lucide-react';
33
+ import { PlusIcon } from 'lucide-react';
29
34
  import { PageContainer } from '#components/page-container';
30
35
  import { PageHeader } from '#components/page-header';
31
36
  import { Button } from '#components/ui/button';
32
37
  import { Input } from '#components/ui/input';
33
38
  import { StatusBadge } from '#components/status-badge';
39
+ import { Spinner } from '#components/ui/spinner';
34
40
  import {
35
41
  Select,
36
42
  SelectTrigger,
@@ -38,14 +44,26 @@ import {
38
44
  SelectContent,
39
45
  SelectItem,
40
46
  } from '#components/ui/select';
41
- import {
42
- Empty,
43
- EmptyMedia,
44
- EmptyTitle,
45
- EmptyDescription,
46
- EmptyContent,
47
- } from '#components/ui/empty';
48
- import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
47
+ import { ConfirmAction, SettingsError } from './shared.js';
48
+
49
+ function UsersRefreshing(): ReactElement | null {
50
+ const { t } = useTranslation();
51
+ const [visible, setVisible] = useState(false);
52
+ useEffect(() => {
53
+ // Fast background reads should not flash feedback on overlay close.
54
+ const timer = setTimeout(() => setVisible(true), 300);
55
+ return () => clearTimeout(timer);
56
+ }, []);
57
+ return visible ? (
58
+ <span
59
+ role='status'
60
+ className='flex items-center gap-2 text-sm text-muted-foreground'
61
+ >
62
+ <Spinner aria-hidden='true' className='size-4' />
63
+ {t('settings.loading')}
64
+ </span>
65
+ ) : null;
66
+ }
49
67
 
50
68
  function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
51
69
  const canUpdate = useUserPermission('update', user.id);
@@ -273,10 +291,12 @@ export default function UsersPage(): ReactElement {
273
291
  }
274
292
  : {}),
275
293
  });
294
+ const requestPath = `users?${remote}`;
276
295
  const request = useSettingsData<{
277
296
  data: readonly ManagedUser[];
278
297
  meta: { total: number };
279
- }>(`users?${remote}`, { keepPreviousData: true, active });
298
+ }>(requestPath, { keepPreviousData: true, active });
299
+ const hasCurrentResponse = request.path === requestPath;
280
300
  const tableState = useTableUrlState({
281
301
  active,
282
302
  revision: request.revision,
@@ -284,7 +304,9 @@ export default function UsersPage(): ReactElement {
284
304
  pageSizeOptions: [20, 50, 100],
285
305
  sortColumns: ['name'],
286
306
  rowCount:
287
- !request.loading && !request.error ? request.data?.meta.total : undefined,
307
+ hasCurrentResponse && !request.loading && !request.error
308
+ ? request.data?.meta.total
309
+ : undefined,
288
310
  });
289
311
  const clearFilters = () => {
290
312
  setQuery('');
@@ -317,6 +339,24 @@ export default function UsersPage(): ReactElement {
317
339
  const canCreate = useUserPermission('create');
318
340
  const canAssign = useUserPermission('assign-role');
319
341
  const rows = request.data?.data ?? [];
342
+ const filtered =
343
+ Boolean(params.get('q') || roleScope || role) || status !== 'all';
344
+ const listState = resolveListState(
345
+ {
346
+ data: hasCurrentResponse ? request.data : undefined,
347
+ error: tableState.error ?? request.error,
348
+ isPending: request.loading && !hasCurrentResponse,
349
+ },
350
+ hasCurrentResponse ? (request.data?.meta.total ?? rows.length) : 0,
351
+ filtered,
352
+ );
353
+ const canShowCreate =
354
+ canCreate &&
355
+ canAssign &&
356
+ (listState === 'empty' ||
357
+ listState === 'ready' ||
358
+ listState === 'no-results' ||
359
+ listState === 'refresh-failed');
320
360
  const columns = useMemo<ColumnDef<ManagedUser>[]>(
321
361
  () => [
322
362
  {
@@ -389,8 +429,6 @@ export default function UsersPage(): ReactElement {
389
429
  {t('settings.newUser')}
390
430
  </Button>
391
431
  ) : null;
392
- const filtered =
393
- Boolean(params.get('q') || roleScope || role) || status !== 'all';
394
432
  return (
395
433
  <>
396
434
  <PageContainer>
@@ -400,7 +438,7 @@ export default function UsersPage(): ReactElement {
400
438
  <PageHeader
401
439
  title={t('settings.users')}
402
440
  description={t('settings.usersDescription')}
403
- actions={rows.length || filtered ? create : undefined}
441
+ actions={canShowCreate ? create : undefined}
404
442
  />
405
443
  <div className='flex flex-wrap items-center gap-3'>
406
444
  <Input
@@ -472,73 +510,37 @@ export default function UsersPage(): ReactElement {
472
510
  ))}
473
511
  </SelectContent>
474
512
  </Select>
475
- {filtered && (
513
+ {filtered && listState !== 'no-results' && (
476
514
  <Button variant='ghost' onClick={clearFilters}>
477
515
  {t('settings.clear')}
478
516
  </Button>
479
517
  )}
518
+ <div className='ml-auto flex h-8 w-28 items-center justify-end'>
519
+ {active && hasCurrentResponse && request.loading && request.data ? (
520
+ <UsersRefreshing />
521
+ ) : null}
522
+ </div>
480
523
  </div>
481
- {request.data && request.loading && (
482
- <span role='status' className='text-sm text-muted-foreground'>
483
- {t('settings.loading')}
484
- </span>
485
- )}
486
524
  {Boolean(roleScope) !== Boolean(role) && (
487
525
  <p role='alert' className='text-sm text-destructive'>
488
526
  {t('settings.roleFilterIncomplete')}
489
527
  </p>
490
528
  )}
491
529
  <SettingsError error={options.error} retry={options.reload} />
492
- <SettingsError
493
- error={tableState.error ?? request.error}
494
- retry={request.reload}
530
+ <DataTable<ManagedUser>
531
+ columns={columns}
532
+ data={rows}
533
+ getRowId={(row) => row.id}
534
+ manualPagination
535
+ rowCount={request.data?.meta.total ?? 0}
536
+ {...tableState}
537
+ manualSorting
538
+ showSelectedCount={false}
539
+ loading={request.loading}
540
+ listState={listState}
541
+ onRetry={request.reload}
542
+ onClearFilters={clearFilters}
495
543
  />
496
- {request.loading && !request.data ? (
497
- <SettingsLoading />
498
- ) : (
499
- !tableState.error &&
500
- !request.error &&
501
- (rows.length ? (
502
- <DataTable<ManagedUser>
503
- columns={columns}
504
- data={rows}
505
- getRowId={(row) => row.id}
506
- manualPagination
507
- rowCount={request.data?.meta.total ?? 0}
508
- {...tableState}
509
- manualSorting
510
- showSelectedCount={false}
511
- loading={request.loading}
512
- />
513
- ) : (
514
- <Empty className='min-h-48 border border-dashed'>
515
- <EmptyMedia variant='icon'>
516
- <UsersIcon />
517
- </EmptyMedia>
518
- <EmptyTitle>
519
- {t(filtered ? 'settings.noResults' : 'settings.noUsers')}
520
- </EmptyTitle>
521
- <EmptyDescription>
522
- {t(
523
- filtered
524
- ? 'settings.clearHint'
525
- : canCreate && canAssign
526
- ? 'settings.usersDescription'
527
- : 'settings.usersReadOnlyEmpty',
528
- )}
529
- </EmptyDescription>
530
- <EmptyContent>
531
- {filtered ? (
532
- <Button variant='outline' onClick={clearFilters}>
533
- {t('settings.clear')}
534
- </Button>
535
- ) : (
536
- create
537
- )}
538
- </EmptyContent>
539
- </Empty>
540
- ))
541
- )}
542
544
  {request.data && (
543
545
  <p className='text-sm text-muted-foreground'>
544
546
  {t('settings.total', { count: request.data.meta.total })}
package/client/runtime.ts CHANGED
@@ -5,10 +5,8 @@ import { defineAppRuntime } from '@nocobase/app-client/runtime';
5
5
  import locales from './locales/index.js';
6
6
  import plugins from './plugins.js';
7
7
  import reactProviders from './react-providers.js';
8
- import routeComponentOverrides from './route-overrides.js';
9
8
  import routes from './routes.js';
10
9
  import serviceProviders from './service-provider.js';
11
- import sourceExtensions from './source-extensions.js';
12
10
 
13
11
  const appRuntime = defineAppRuntime({
14
12
  packageName: '@nocobase/app-template-default',
@@ -19,8 +17,6 @@ const appRuntime = defineAppRuntime({
19
17
  reactProviders,
20
18
  routes,
21
19
  plugins,
22
- routeComponentOverrides,
23
- sourceExtensions,
24
20
  });
25
21
 
26
22
  export default appRuntime;
@@ -70,6 +70,26 @@ secrets:
70
70
  key: replace-with-a-unique-secret
71
71
 
72
72
  auth:
73
+ # Authentication owns this sender; notification and mailbox configuration are independent.
74
+ # Set app.publicOrigin (or APP_PUBLIC_ORIGIN) before enabling automatic recovery.
75
+ # passwordReset:
76
+ # enabled: true
77
+ # email:
78
+ # provider: smtp
79
+ # host: smtp.example.com
80
+ # port: 587
81
+ # secure: false
82
+ # auth:
83
+ # user: noreply@example.com
84
+ # pass: replace-with-the-smtp-password
85
+ # from: App <noreply@example.com>
86
+ # replyTo: support@example.com
87
+ # For Resend, replace the entire email block with these fields; do not mix SMTP fields:
88
+ # email:
89
+ # provider: resend
90
+ # apiKey: replace-with-the-resend-api-key
91
+ # from: App <noreply@example.com>
92
+ # AUTH_EMAIL_PROVIDER/HOST/PORT/SECURE/USER/PASSWORD/FROM/REPLY_TO/API_KEY override auth.email.
73
93
  emailAndPassword:
74
94
  enabled: true
75
95
  autoSignIn: false
@@ -146,9 +166,13 @@ snowflake:
146
166
  # process that is killed loses what changed since it started. It serves one process: every other process or instance
147
167
  # would run its own copy. Set default to memory to keep that without the startup warning, or to redis to run several.
148
168
  # Redis must persist its data (AOF or RDB) and use maxmemory-policy noeviction, or schedules can be lost.
169
+ # Install the commercial @nocobase/app-plugin-jobs-redis package and register its Server plugin
170
+ # from @nocobase/app-plugin-jobs-redis/server in server/plugins.ts on every producer and consumer.
171
+ # Upgrading deployments must set adapter: redis explicitly; templates no longer supply Redis defaults.
149
172
  # jobs:
150
173
  # default: redis
151
174
  # redis:
175
+ # adapter: redis
152
176
  # connection:
153
177
  # host: 127.0.0.1
154
178
  # port: 6379
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.84",
4
+ "version": "1.0.0-beta.86",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.84"
12
+ "defaultTemplateVersion": "1.0.0-beta.86"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -74,10 +74,10 @@
74
74
  "elkjs": "^0.10.2",
75
75
  "@types/jsdom": "^30.0.0",
76
76
  "@noble/hashes": "2.3.0",
77
- "@nocobase/app-client": "^4.0.0-beta.4",
77
+ "@nocobase/app-client": "^4.0.0-beta.6",
78
78
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
79
- "@nocobase/app-skills": "0.1.0-beta.54",
80
- "@nocobase/app-testing": "0.1.0-beta.6",
79
+ "@nocobase/app-skills": "0.1.0-beta.56",
80
+ "@nocobase/app-testing": "0.1.0-beta.7",
81
81
  "@nocobase/app-websocket": "^0.1.0-beta.1",
82
82
  "@nocobase/dev-config": "0.1.0-beta.25",
83
83
  "@nocobase/drive": "^0.1.0-beta.7",
@@ -142,39 +142,41 @@
142
142
  "@oclif/core": "^4.14.0",
143
143
  "better-auth": "1.7.5",
144
144
  "hono": "^4.13.2",
145
- "@nocobase/app-plugin-agents": "^1.0.0-beta.28",
146
- "@nocobase/app-plugin-api-keys": "^1.0.0-beta.19",
147
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.15",
148
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.33",
149
- "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.17",
150
- "@nocobase/app-plugin-file": "^1.0.0-beta.27",
151
- "@nocobase/app-plugin-foundation": "^0.1.0-beta.9",
152
- "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
153
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.14",
154
- "@nocobase/app-plugin-mail": "^1.0.0-beta.17",
155
- "@nocobase/app-plugin-notification": "^2.0.0-beta.1",
156
- "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
157
- "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
158
- "@nocobase/app-plugin-scheduler": "^1.0.0-beta.20",
159
- "@nocobase/app-plugin-users": "^2.0.0-beta.11",
160
- "@nocobase/app-server": "^3.0.0-beta.4",
145
+ "@nocobase/concurrency": "^0.1.0-beta.1",
146
+ "@nocobase/app-plugin-concurrency": "^0.1.0-beta.1",
147
+ "@nocobase/app-plugin-agents": "^2.0.0-beta.1",
148
+ "@nocobase/app-plugin-api-keys": "^1.0.0-beta.20",
149
+ "@nocobase/app-plugin-authentication": "^2.0.0-beta.17",
150
+ "@nocobase/app-plugin-authorization": "^1.0.0-beta.34",
151
+ "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.18",
152
+ "@nocobase/app-plugin-file": "^1.0.0-beta.28",
153
+ "@nocobase/app-plugin-foundation": "^0.1.0-beta.11",
154
+ "@nocobase/app-plugin-i18n": "^1.0.0-beta.19",
155
+ "@nocobase/app-plugin-knowledge": "^0.1.0-beta.16",
156
+ "@nocobase/app-plugin-mail": "^1.0.0-beta.19",
157
+ "@nocobase/app-plugin-notification": "^2.0.0-beta.3",
158
+ "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.1",
159
+ "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.17",
160
+ "@nocobase/app-plugin-scheduler": "^1.0.0-beta.22",
161
+ "@nocobase/app-plugin-users": "^2.0.0-beta.12",
162
+ "@nocobase/app-server": "^4.0.0-beta.0",
161
163
  "@nocobase/config": "^0.1.0-beta.3",
162
- "@nocobase/db": "^1.0.0-beta.23",
164
+ "@nocobase/db": "^1.0.0-beta.24",
163
165
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
164
166
  "@nocobase/service-provider": "^0.0.2-beta.2",
165
167
  "@nocobase/snowflake": "^1.0.0-beta.4",
166
- "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.17",
167
- "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.16",
168
- "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.15",
169
- "@nocobase/authorization": "^1.0.0-beta.15",
170
- "@nocobase/queue": "^0.1.0-beta.9",
171
- "@nocobase/jobs": "^0.1.0-beta.4",
172
- "@nocobase/lifecycle": "^0.1.0-beta.3",
168
+ "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.18",
169
+ "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.17",
170
+ "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.16",
171
+ "@nocobase/authorization": "^1.0.0-beta.16",
172
+ "@nocobase/queue": "^1.0.0-beta.10",
173
+ "@nocobase/jobs": "^1.0.0-beta.5",
174
+ "@nocobase/lifecycle": "^0.1.0-beta.4",
173
175
  "@nocobase/i18n": "^2.0.0-beta.1",
174
176
  "@nocobase/caching": "^0.1.0-beta.3",
175
177
  "@nocobase/repository-input": "^0.1.0-beta.3",
176
- "@nocobase/app-cli": "^1.0.0-beta.30",
177
- "@nocobase/realtime": "^0.1.0-beta.3"
178
+ "@nocobase/app-cli": "^1.0.0-beta.31",
179
+ "@nocobase/realtime": "^1.0.0-beta.0"
178
180
  },
179
181
  "description": "NocoBase 3 application template with the standard product capabilities.",
180
182
  "imports": {
package/server/app.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { MailerProvider } from '@nocobase/app-server/mailer';
2
1
  import type { Application } from '@nocobase/app-server/application';
3
2
  import { DatabaseProvider } from '@nocobase/app-server/database';
4
3
  import { I18nProvider, i18nHttpMiddleware } from '@nocobase/app-server/i18n';
@@ -34,7 +33,6 @@ export function createApp(runtime: AppRuntimeContext): Application {
34
33
  app.addServiceProvider(SecretsProvider);
35
34
  app.addServiceProvider(SessionProvider);
36
35
  app.addServiceProvider(DriveProvider);
37
- app.addServiceProvider(MailerProvider);
38
36
  app.addServiceProvider(QueueServiceProvider, {
39
37
  nodeEnv: runtime.env.NODE_ENV,
40
38
  });
@@ -12,6 +12,8 @@ import { bearer, deviceAuthorization, username } from 'better-auth/plugins';
12
12
  */
13
13
  const CLI_CLIENT_IDS: readonly string[] = ['nocobase-cli'];
14
14
 
15
+ // defineAuthConfig supplies AUTH_EMAIL_* mappings and validates the private auth.email sender.
16
+ // Automatic recovery requires passwordReset.enabled: true; leave the switch absent for custom callback compatibility.
15
17
  const auth: AppConfigFactory<AuthConfig> = defineAuthConfig({
16
18
  defaults: {
17
19
  plugins: [
@@ -0,0 +1,3 @@
1
+ import { defineConcurrencyConfig } from '@nocobase/app-plugin-concurrency/config';
2
+
3
+ export default defineConcurrencyConfig();
@@ -12,6 +12,7 @@ import server from './server.js';
12
12
  import spa from './spa.js';
13
13
  import logging from './logging.js';
14
14
  import drive from './drive.js';
15
+ import concurrency from './concurrency.js';
15
16
  import queue from './queue.js';
16
17
  import jobs from './jobs.js';
17
18
  import scheduler from './scheduler.js';
@@ -35,6 +36,7 @@ const defaultConfigs: AppConfigFactory<{
35
36
  spa: ReturnType<typeof spa>;
36
37
  logging: ReturnType<typeof logging>;
37
38
  drive: ReturnType<typeof drive>;
39
+ concurrency: ReturnType<typeof concurrency>;
38
40
  queue: ReturnType<typeof queue>;
39
41
  jobs: ReturnType<typeof jobs>;
40
42
  scheduler: ReturnType<typeof scheduler>;
@@ -57,6 +59,7 @@ const defaultConfigs: AppConfigFactory<{
57
59
  spa,
58
60
  logging,
59
61
  drive,
62
+ concurrency,
60
63
  queue,
61
64
  jobs,
62
65
  scheduler,
@@ -4,24 +4,12 @@ import {
4
4
  } from '@nocobase/app-server/config';
5
5
  import type { AppJobsConfig } from '@nocobase/app-server/jobs';
6
6
 
7
- /**
8
- * Configurations the jobs service can run on. None is the default: until `jobs.default` names one, executors
9
- * run on the built-in memory adapter, which serves one process and writes its state under storage/jobs when the
10
- * application stops, and which is reported at startup outside development. Name `memory` to keep that choice without
11
- * the report, or `redis` to run any number of instances with each firing executed once.
12
- */
7
+ /** Single-process persistence. Distributed jobs require an explicitly registered backend plugin. */
13
8
  const jobs: AppConfigFactory<AppJobsConfig> = defineAppConfig(({ paths }) => ({
14
9
  memory: {
15
10
  adapter: 'memory',
16
11
  persistence: { path: paths.storage('jobs') },
17
12
  },
18
- redis: {
19
- adapter: 'redis',
20
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
21
- // Every firing leaves a finished job behind; keep the history bounded.
22
- removeOnComplete: { count: 1000 },
23
- removeOnFail: { age: 604_800 },
24
- },
25
13
  }));
26
14
 
27
15
  export default jobs;
@@ -1,4 +1,4 @@
1
- import { defineMailChannelsConfig } from '@nocobase/app-server/mailer';
1
+ import { defineMailChannelsConfig } from '@nocobase/app-plugin-notification-providers/config';
2
2
  import { type AppConfigFactory } from '@nocobase/app-server/config';
3
3
  import type { NotificationConfig } from '@nocobase/app-plugin-notification/server';
4
4
 
@@ -8,7 +8,7 @@ import type { AppQueueConfig } from '@nocobase/app-server/queue';
8
8
  * Configurations queues can run on. None is the default: until `queue.default` names one, queues run on the
9
9
  * built-in memory configuration, which serves one process and writes its pending jobs under storage/queue when the
10
10
  * application stops, and which is reported at startup outside development. Name `memory` to keep that choice without
11
- * the report, or `redis` to share queues across instances.
11
+ * the report. Shared Redis queues require explicit registration of @nocobase/app-plugin-queue-redis/server.
12
12
  */
13
13
  const queue: AppConfigFactory<AppQueueConfig> = defineAppConfig(
14
14
  ({ paths }) => ({
@@ -16,12 +16,6 @@ const queue: AppConfigFactory<AppQueueConfig> = defineAppConfig(
16
16
  adapter: 'inMemory',
17
17
  persistence: { path: paths.storage('queue') },
18
18
  },
19
- redis: {
20
- adapter: 'redis',
21
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
22
- removeOnComplete: { count: 1000 },
23
- removeOnFail: { age: 604_800 },
24
- },
25
19
  }),
26
20
  );
27
21
 
package/server/plugins.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import concurrency from '@nocobase/app-plugin-concurrency/server';
1
2
  import foundation from '@nocobase/app-plugin-foundation/server';
2
3
  import defaultAccess from '@nocobase/app-plugin-authz-default-access/server';
3
4
  import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/server';
@@ -20,6 +21,7 @@ import file from '@nocobase/app-plugin-file/server';
20
21
  import mail from '@nocobase/app-plugin-mail/server';
21
22
 
22
23
  const serverPlugins: AppServerPlugins = defineServerPlugins([
24
+ concurrency,
23
25
  authentication,
24
26
  authorization,
25
27
  defaultAccess,
@@ -114,14 +114,19 @@ describe('standard DataTable', () => {
114
114
  expect(screen.getByText('Page 4 of 6')).toBeVisible();
115
115
  expect(screen.getByText('row-0')).toBeVisible();
116
116
  });
117
- it('keeps empty-state and paging controls localized in Chinese', async () => {
117
+ it('localizes successful emptiness and keeps the table headers without paging controls', async () => {
118
118
  await renderWithApp(
119
119
  <DataTable columns={columns} data={[]} showSelectedCount={false} />,
120
120
  { namespace, namespaces, locale: 'zh-CN' },
121
121
  );
122
- expect(screen.getByText(zhCN.dataTable.noResults)).toBeVisible();
122
+ expect(screen.getByText(zhCN.listFeedback.empty)).toBeVisible();
123
+ expect(screen.getByRole('table')).toBeVisible();
124
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
123
125
  expect(
124
- screen.getByRole('button', { name: zhCN.dataTable.nextPage }),
125
- ).toBeDisabled();
126
+ screen.queryByText(zhCN.dataTable.noResults),
127
+ ).not.toBeInTheDocument();
128
+ expect(
129
+ screen.queryByRole('button', { name: zhCN.dataTable.nextPage }),
130
+ ).not.toBeInTheDocument();
126
131
  });
127
132
  });
@@ -343,7 +343,7 @@ it('keeps failed deletion in its confirmation and deletes only after explicit co
343
343
  expect(screen.getByRole('alertdialog')).toBeVisible();
344
344
  expect(screen.queryByText('Private server detail')).not.toBeInTheDocument();
345
345
  await user.click(screen.getByRole('button', { name: 'Delete', exact: true }));
346
- await screen.findByText(enUS.settings.defaultAccess.empty);
346
+ await screen.findByText(enUS.listFeedback.empty);
347
347
  expect(deletes).toBe(2);
348
348
  });
349
349
  it('retains the create draft when the server rejects a duplicate and shows a contextual error', async () => {
@@ -485,6 +485,46 @@ it('refuses deleting a changed rule and refreshes the list for explicit re-confi
485
485
  expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
486
486
  });
487
487
 
488
+ it('keeps one authorized create entry in the header for an empty list', async () => {
489
+ await renderWithApp(tree(), {
490
+ route: '/default-access',
491
+ namespace,
492
+ namespaces,
493
+ services,
494
+ fetch: answerApi(({ path }) =>
495
+ path === 'authorization/permissions'
496
+ ? { data: { unrestricted: true, permissions: [] } }
497
+ : path.endsWith('/options')
498
+ ? { data: catalog }
499
+ : { data: [] },
500
+ ),
501
+ });
502
+ const label = enUS.settings.defaultAccess.new;
503
+ const create = await screen.findByRole('button', { name: label });
504
+ expect(create).toHaveAttribute('href', '/default-access/create');
505
+ expect(screen.getAllByRole('button', { name: label })).toHaveLength(1);
506
+ });
507
+
508
+ it('hides the create entry for a read-only empty list', async () => {
509
+ await renderWithApp(tree(), {
510
+ route: '/default-access',
511
+ namespace,
512
+ namespaces,
513
+ services,
514
+ fetch: answerApi(({ path }) =>
515
+ path === 'authorization/permissions'
516
+ ? { data: { unrestricted: false, permissions: [] } }
517
+ : path.endsWith('/options')
518
+ ? { data: catalog }
519
+ : { data: [] },
520
+ ),
521
+ });
522
+ const label = enUS.settings.defaultAccess.new;
523
+ expect(await screen.findByText(enUS.listFeedback.empty)).toBeVisible();
524
+ expect(screen.queryByRole('link', { name: label })).not.toBeInTheDocument();
525
+ expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument();
526
+ });
527
+
488
528
  it('disables New rule with a reason when every available resource is already configured', async () => {
489
529
  await renderWithApp(tree(), {
490
530
  route: '/default-access',
@@ -733,16 +773,14 @@ it.each(['transport', 'server'])(
733
773
  await userEvent.click(
734
774
  screen.getByRole('button', { name: 'Delete', exact: true }),
735
775
  );
736
- expect(
737
- await screen.findByText(enUS.settings.defaultAccess.empty),
738
- ).toBeVisible();
776
+ expect(await screen.findByText(enUS.listFeedback.empty)).toBeVisible();
739
777
  expect(screen.getByRole('alertdialog')).toBeVisible();
740
778
  expect(screen.getByText(enUS.settings.failed)).toBeVisible();
741
779
  await userEvent.click(
742
780
  screen.getByRole('button', { name: 'Cancel', exact: true }),
743
781
  );
744
782
  await waitFor(() =>
745
- expect(screen.getByText(enUS.settings.defaultAccess.empty)).toBeVisible(),
783
+ expect(screen.getByText(enUS.listFeedback.empty)).toBeVisible(),
746
784
  );
747
785
  expect(writes).toBe(1);
748
786
  },