@nocobase/app-template-default 1.0.0-beta.83 → 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 (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -19,7 +19,7 @@ import {
19
19
  createTestI18nRuntime,
20
20
  } from '@nocobase/i18n/testing';
21
21
  import { act, fireEvent, render, screen } from '@testing-library/react';
22
- import { useEffect } from 'react';
22
+ import { useEffect, useState } from 'react';
23
23
  import { MemoryRouter, Outlet } from 'react-router';
24
24
  import { afterEach, expect, it, vi } from 'vitest';
25
25
  import { AppRouter } from '../../client/routing/app-router.js';
@@ -80,6 +80,7 @@ async function setup() {
80
80
  const accepted = vi.fn();
81
81
  const old = Promise.withResolvers<void>();
82
82
  function Page() {
83
+ const [privateFilter, setPrivateFilter] = useState('');
83
84
  useEffect(() => {
84
85
  let active = true;
85
86
  mounted();
@@ -96,6 +97,11 @@ async function setup() {
96
97
  <DialogContent>
97
98
  <DialogTitle>Protected dialog</DialogTitle>
98
99
  <DialogDescription>Protected content</DialogDescription>
100
+ <input
101
+ aria-label='Private filter'
102
+ value={privateFilter}
103
+ onChange={(event) => setPrivateFilter(event.target.value)}
104
+ />
99
105
  </DialogContent>
100
106
  </Dialog>
101
107
  );
@@ -170,6 +176,10 @@ async function setup() {
170
176
  session = null;
171
177
  await act(async () => refresh());
172
178
  },
179
+ switchUser: async () => {
180
+ session = { user: { id: 'other' }, session: { id: 'other-session' } };
181
+ await act(async () => refresh());
182
+ },
173
183
  };
174
184
  }
175
185
 
@@ -195,6 +205,19 @@ it('mounts only after the initial handshake and preserves runtime fail-closed po
195
205
  expect(await screen.findByText('Access denied')).toBeVisible();
196
206
  });
197
207
 
208
+ it('clears mounted private list memory when the signed-in identity changes', async () => {
209
+ const probe = await setup();
210
+ await probe.open();
211
+ const filter = await screen.findByRole('textbox', { name: 'Private filter' });
212
+ fireEvent.change(filter, { target: { value: 'private condition' } });
213
+ expect(filter).toHaveValue('private condition');
214
+ await probe.switchUser();
215
+ await probe.open();
216
+ expect(
217
+ await screen.findByRole('textbox', { name: 'Private filter' }),
218
+ ).toHaveValue('');
219
+ });
220
+
198
221
  it('keeps a timed-out session closed, supports retry, and reports sign-out failure', async () => {
199
222
  vi.useFakeTimers();
200
223
  const probe = await setup();
@@ -1,10 +1,17 @@
1
- import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
+ import { answerApi } from '@nocobase/app-testing/client';
2
3
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
4
  import {
4
5
  AuthorizationClient,
5
6
  authorizationClientToken,
6
7
  } from '@nocobase/app-plugin-authorization/client';
7
- import { screen, waitFor, within } from '@testing-library/react';
8
+ import {
9
+ act,
10
+ fireEvent,
11
+ screen,
12
+ waitFor,
13
+ within,
14
+ } from '@testing-library/react';
8
15
  import userEvent from '@testing-library/user-event';
9
16
  import { Routes, Route, useLocation } from 'react-router';
10
17
  import { expect, it, vi } from 'vitest';
@@ -58,6 +65,79 @@ function Location() {
58
65
  );
59
66
  }
60
67
 
68
+ it.each(['fast', 'slow'] as const)(
69
+ 'keeps the user table on drawer close and only indicates a slow refresh (%s)',
70
+ async (speed) => {
71
+ const user = userEvent.setup();
72
+ let refresh = false;
73
+ let complete!: () => void;
74
+ const response = new Promise<void>((resolve) => {
75
+ complete = resolve;
76
+ });
77
+ await renderWithApp(
78
+ <Routes>
79
+ <Route path='/settings/users' element={<UsersPage />}>
80
+ <Route path=':userId' element={<UserDetail />}>
81
+ <Route path='edit' element={<UserForm />} />
82
+ </Route>
83
+ </Route>
84
+ </Routes>,
85
+ {
86
+ route: '/settings/users',
87
+ namespace,
88
+ namespaces,
89
+ services,
90
+ fetch: answerApi(async ({ path }) => {
91
+ if (path === 'authorization/permissions')
92
+ return { data: { unrestricted: true, permissions: [] } };
93
+ if (path === 'users/options') return options;
94
+ if (path === 'users/alice') return { data: row };
95
+ if (refresh) await response;
96
+ return {
97
+ data: [{ ...row, name: refresh ? 'Alice refreshed' : row.name }],
98
+ meta: { total: 1 },
99
+ };
100
+ }),
101
+ },
102
+ );
103
+ await user.click(await screen.findByRole('link', { name: 'Alice' }));
104
+ await waitFor(() =>
105
+ expect(screen.getByRole('textbox', { name: 'Name *' })).toHaveValue(
106
+ 'Alice',
107
+ ),
108
+ );
109
+ refresh = true;
110
+ vi.useFakeTimers();
111
+ try {
112
+ await act(() => vi.advanceTimersByTimeAsync(1000));
113
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
114
+ await act(async () => {
115
+ fireEvent.click(
116
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
117
+ );
118
+ });
119
+ expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
120
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
121
+ if (speed === 'slow') {
122
+ await act(() => vi.advanceTimersByTimeAsync(1000));
123
+ expect(screen.getByText(enUS.settings.loading)).toBeVisible();
124
+ expect(screen.getByRole('link', { name: 'Alice' })).toBeVisible();
125
+ }
126
+ await act(async () => {
127
+ complete();
128
+ await response;
129
+ });
130
+ await act(() => vi.advanceTimersByTimeAsync(1000));
131
+ expect(
132
+ screen.getByRole('link', { name: 'Alice refreshed' }),
133
+ ).toBeVisible();
134
+ expect(screen.queryByText(enUS.settings.loading)).not.toBeInTheDocument();
135
+ } finally {
136
+ vi.useRealTimers();
137
+ }
138
+ },
139
+ );
140
+
61
141
  it('uses role labels and paired server filters, retaining the source and ordering when clearing filters', async () => {
62
142
  const calls = vi.fn(({ path }: { path: string }) =>
63
143
  path === 'authorization/permissions'
@@ -282,9 +362,9 @@ it.each(['/settings/users', '/users'])(
282
362
  `${base}${search}`,
283
363
  ),
284
364
  );
285
- expect(
286
- await screen.findByText('Alice updated', { exact: true }),
287
- ).toBeVisible();
365
+ await waitFor(() =>
366
+ expect(screen.getByText('Alice updated', { exact: true })).toBeVisible(),
367
+ );
288
368
  },
289
369
  );
290
370
 
@@ -10,6 +10,7 @@ import { describe, expect, it, vi } from 'vitest';
10
10
  import { Routes, Route } from 'react-router';
11
11
  import UsersPage from '../../client/pages/settings/users.js';
12
12
  import RolesPage from '../../client/pages/settings/roles.js';
13
+ import { ListRecoveryError } from '../../client/hooks/use-table-url-state.js';
13
14
  import { SettingsError } from '../../client/pages/settings/shared.js';
14
15
  import enUS from '../../client/locales/en-US.js';
15
16
  import zhCN from '../../client/locales/zh-CN.js';
@@ -40,7 +41,7 @@ describe.each([
40
41
  ['zh-CN', zhCN],
41
42
  ] as const)('%s', (language, copy) => {
42
43
  it.each(['users', 'roles'] as const)(
43
- 'explains the next step for a read-only empty %s list',
44
+ 'keeps the compact empty status and hides creation for a read-only %s list',
44
45
  async (kind) => {
45
46
  await renderWithApp(
46
47
  <Routes>
@@ -63,13 +64,9 @@ describe.each([
63
64
  }),
64
65
  },
65
66
  );
66
- expect(
67
- await screen.findByText(
68
- kind === 'users'
69
- ? copy.settings.usersReadOnlyEmpty
70
- : copy.settings.rolesReadOnlyEmpty,
71
- ),
72
- ).toBeVisible();
67
+ expect((await screen.findByRole('status')).textContent).toBe(
68
+ copy.listFeedback.empty,
69
+ );
73
70
  expect(
74
71
  screen.queryByRole('button', {
75
72
  name:
@@ -118,13 +115,9 @@ describe.each([
118
115
  kind === 'users' ? copy.settings.newUser : copy.settings.newRole,
119
116
  }),
120
117
  ).toHaveLength(1);
121
- expect(
122
- screen.queryByText(
123
- kind === 'users'
124
- ? copy.settings.usersReadOnlyEmpty
125
- : copy.settings.rolesReadOnlyEmpty,
126
- ),
127
- ).not.toBeInTheDocument();
118
+ expect(screen.getByRole('status').textContent).toBe(
119
+ copy.listFeedback.empty,
120
+ );
128
121
  },
129
122
  );
130
123
 
@@ -151,6 +144,30 @@ describe.each([
151
144
  expect(retry).not.toHaveBeenCalled();
152
145
  });
153
146
 
147
+ it.each([false, true])(
148
+ 'preserves bounded recovery feedback with inline=%s',
149
+ async (inline) => {
150
+ const retry = vi.fn();
151
+ const message = 'The list changed repeatedly. Refresh to try again.';
152
+ await renderWithApp(
153
+ <SettingsError
154
+ error={new ListRecoveryError(message)}
155
+ retry={retry}
156
+ inline={inline}
157
+ />,
158
+ { locale: language, namespace, namespaces },
159
+ );
160
+ expect(screen.getByText(message)).toBeVisible();
161
+ expect(screen.queryByText(copy.settings.failed)).not.toBeInTheDocument();
162
+ expect(screen.queryAllByRole('alert')).toHaveLength(inline ? 0 : 1);
163
+ expect(retry).not.toHaveBeenCalled();
164
+ await userEvent.click(
165
+ screen.getByRole('button', { name: copy.settings.retry }),
166
+ );
167
+ expect(retry).toHaveBeenCalledTimes(1);
168
+ },
169
+ );
170
+
154
171
  it('keeps a retry for temporary failures and performs it only on demand', async () => {
155
172
  const retry = vi.fn();
156
173
  await renderWithApp(
@@ -332,3 +332,38 @@ it('shows a shared plugin action explanation once without duplicating row hints'
332
332
  );
333
333
  expect(result[0]?.groups[0]?.rows.every((row) => !row.hint)).toBe(true);
334
334
  });
335
+
336
+ it('keeps scope metadata while ungranted without creating a grant or selecting its default', () => {
337
+ const catalog = {
338
+ sections: [
339
+ {
340
+ name: 'business',
341
+ title: 'Business',
342
+ subsections: [{ name: 'orders', resources: [resource] }],
343
+ },
344
+ ],
345
+ recordAccess: [
346
+ { key: 'mine', title: 'Own' },
347
+ { key: 'all', title: 'All' },
348
+ ],
349
+ } as unknown as Parameters<typeof permissionSections>[0];
350
+ const grants: Parameters<typeof permissionSections>[1] = [];
351
+ const row = permissionSections(catalog, grants, (value) =>
352
+ typeof value === 'string' ? value : value.key,
353
+ )[0]!.groups[0]!.rows[0]!;
354
+ expect(row.enabled).toBe(false);
355
+ expect(row.scopes?.[0]).toMatchObject({
356
+ id: JSON.stringify(['composite', 'orders', 'edit', 'records']),
357
+ level: '',
358
+ });
359
+ expect(grants).toEqual([]);
360
+ const enabled = permissionSections(
361
+ catalog,
362
+ setPermission(grants, resource, 'edit', true),
363
+ (value) => (typeof value === 'string' ? value : value.key),
364
+ )[0]!.groups[0]!.rows[0]!;
365
+ expect(enabled.scopes?.map((scope) => scope.id)).toEqual(
366
+ row.scopes?.map((scope) => scope.id),
367
+ );
368
+ expect(enabled.scopes?.[0]?.level).toBe('mine');
369
+ });
@@ -1,4 +1,5 @@
1
- import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
+ import { answerApi } from '@nocobase/app-testing/client';
2
3
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
4
  import {
4
5
  AuthorizationClient,
@@ -4,11 +4,24 @@ import userEvent from '@testing-library/user-event';
4
4
  import { useState } from 'react';
5
5
  import { useLocation, useNavigate } from 'react-router';
6
6
  import { expect, it } from 'vitest';
7
- import { useTableUrlState } from '../../client/hooks/use-table-url-state.js';
7
+ import {
8
+ readTableUrlState,
9
+ useTableUrlState,
10
+ } from '../../client/hooks/use-table-url-state.js';
8
11
 
9
- function Probe() {
12
+ function Probe({
13
+ sortFormat = 'sort-order',
14
+ }: {
15
+ sortFormat?: 'sort-order' | 'orderBy';
16
+ }) {
10
17
  const [count, setCount] = useState<number>();
11
- const state = useTableUrlState({ sortColumns: ['name'], rowCount: count });
18
+ const [active, setActive] = useState(true);
19
+ const state = useTableUrlState({
20
+ sortColumns: ['name', 'updatedAt'],
21
+ rowCount: count,
22
+ active,
23
+ sortFormat,
24
+ });
12
25
  const location = useLocation();
13
26
  const navigate = useNavigate();
14
27
  return (
@@ -20,7 +33,27 @@ function Probe() {
20
33
  </output>
21
34
  <output data-testid='url'>{location.search}</output>
22
35
  <button onClick={() => setCount(25)}>Loaded</button>
36
+ <button onClick={() => setActive((value) => !value)}>
37
+ Toggle active
38
+ </button>
23
39
  <button onClick={() => setCount(10)}>Removed</button>
40
+ <button
41
+ onClick={() =>
42
+ state.onSortingChange([
43
+ { id: 'name', desc: true },
44
+ { id: 'updatedAt', desc: false },
45
+ ])
46
+ }
47
+ >
48
+ Multi sort
49
+ </button>
50
+ <button
51
+ onClick={() =>
52
+ state.onPaginationChange((old) => ({ ...old, pageSize: 20 }))
53
+ }
54
+ >
55
+ Resize
56
+ </button>
24
57
  <button
25
58
  onClick={() => state.onSortingChange([{ id: 'name', desc: true }])}
26
59
  >
@@ -93,3 +126,84 @@ it('allows browser Back to restore the preceding page', async () => {
93
126
  expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2');
94
127
  expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
95
128
  });
129
+
130
+ it('starts at page one after resizing even when the table supplies an old page index', async () => {
131
+ await renderWithApp(<Probe />, {
132
+ route: '/?page=4&pageSize=10&sort=name&order=desc&q=kept',
133
+ });
134
+ await userEvent.click(screen.getByRole('button', { name: 'Resize' }));
135
+ expect(screen.getByTestId('state')).toHaveTextContent(
136
+ '"pageIndex":0,"pageSize":20',
137
+ );
138
+ expect(screen.getByTestId('url')).toHaveTextContent('order=desc');
139
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
140
+ });
141
+
142
+ it('preserves ordered multi-column orderBy and falls back on invalid columns and directions', () => {
143
+ const options = {
144
+ sortColumns: ['name', 'updatedAt'],
145
+ sortFormat: 'orderBy' as const,
146
+ defaultSorting: [{ id: 'updatedAt', desc: true }],
147
+ };
148
+ expect(
149
+ readTableUrlState(
150
+ new URLSearchParams('orderBy=name,updatedAt desc&page=2'),
151
+ options,
152
+ ),
153
+ ).toEqual({
154
+ paginationState: { pageIndex: 1, pageSize: 10 },
155
+ sortingState: [
156
+ { id: 'name', desc: false },
157
+ { id: 'updatedAt', desc: true },
158
+ ],
159
+ });
160
+ for (const value of [
161
+ 'name sideways',
162
+ 'deleted desc',
163
+ 'name,deleted',
164
+ 'name desc extra',
165
+ ]) {
166
+ expect(
167
+ readTableUrlState(new URLSearchParams({ orderBy: value }), options)
168
+ .sortingState,
169
+ ).toEqual(options.defaultSorting);
170
+ }
171
+ });
172
+
173
+ it('writes and restores the complete existing orderBy sequence', async () => {
174
+ await renderWithApp(<Probe sortFormat='orderBy' />, {
175
+ route: '/?page=3&pageSize=20&q=kept',
176
+ });
177
+ await userEvent.click(screen.getByRole('button', { name: 'Multi sort' }));
178
+ expect(screen.getByTestId('state')).toHaveTextContent(
179
+ '[{"id":"name","desc":true},{"id":"updatedAt","desc":false}]',
180
+ );
181
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":0');
182
+ await userEvent.click(screen.getByRole('button', { name: 'Next' }));
183
+ expect(screen.getByTestId('url')).toHaveTextContent(
184
+ 'orderBy=name+desc%2CupdatedAt',
185
+ );
186
+ expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
187
+ });
188
+
189
+ it('keeps ordered multi-sort while deferred deletion recovery corrects the reactivated list', async () => {
190
+ await renderWithApp(<Probe sortFormat='orderBy' />, {
191
+ route: '/?page=5&pageSize=10&orderBy=name%20desc,updatedAt&q=kept&tab=open',
192
+ });
193
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle active' }));
194
+ await userEvent.click(screen.getByRole('button', { name: 'Loaded' }));
195
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":4');
196
+ await userEvent.click(screen.getByRole('button', { name: 'Toggle active' }));
197
+ await waitFor(() =>
198
+ expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2'),
199
+ );
200
+ expect(screen.getByTestId('state')).toHaveTextContent(
201
+ '[{"id":"name","desc":true},{"id":"updatedAt","desc":false}]',
202
+ );
203
+ const params = new URLSearchParams(
204
+ screen.getByTestId('url').textContent ?? '',
205
+ );
206
+ expect(params.get('orderBy')).toBe('name desc,updatedAt');
207
+ expect(params.get('q')).toBe('kept');
208
+ expect(params.get('tab')).toBe('open');
209
+ });
@@ -1,4 +1,5 @@
1
- import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
+ import { answerApi } from '@nocobase/app-testing/client';
2
3
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
4
  import {
4
5
  AuthorizationClient,
@@ -1,4 +1,5 @@
1
- import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
1
+ import { renderSettingsClient as renderWithApp } from '../helpers/settings-client.js';
2
+ import { answerApi } from '@nocobase/app-testing/client';
2
3
  import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
3
4
  import {
4
5
  AuthorizationClient,
@@ -9,7 +10,7 @@ import {
9
10
  defineAppRoutes,
10
11
  defineClientPlugin,
11
12
  } from '@nocobase/app-client/plugins';
12
- import { screen, waitFor } from '@testing-library/react';
13
+ import { screen, waitFor, within } from '@testing-library/react';
13
14
  import userEvent from '@testing-library/user-event';
14
15
  import { Routes, Route } from 'react-router';
15
16
  import { describe, expect, it, vi } from 'vitest';
@@ -138,12 +139,36 @@ describe('user permission configuration', () => {
138
139
  expect(
139
140
  screen.getByRole('heading', { name: words.roleAssignment }),
140
141
  ).toBeVisible();
141
- expect(screen.getAllByRole('checkbox')).toHaveLength(8);
142
+ const permissionNames = [
143
+ words.readUsers,
144
+ words.createUsers,
145
+ words.updateUsers,
146
+ words.disable,
147
+ words.enable,
148
+ words.revokeSessions,
149
+ words.assignRole,
150
+ ];
151
+ const permissionCheckboxes = permissionNames.map((name) =>
152
+ screen.getByRole('checkbox', { name, exact: true }),
153
+ );
154
+ expect(screen.getAllByRole('checkbox')).toHaveLength(
155
+ permissionNames.length,
156
+ );
142
157
  expect(
143
158
  screen.queryByRole('checkbox', { name: words.inviteUsers }),
144
159
  ).not.toBeInTheDocument();
145
- const rowIds = screen.getAllByRole('checkbox').map((node) => node.id);
146
- expect(new Set(rowIds).size).toBe(8);
160
+ const rowIds = permissionCheckboxes.map((node) => node.id);
161
+ expect(rowIds).not.toContain('');
162
+ expect(new Set(rowIds).size).toBe(permissionNames.length);
163
+ const status = within(
164
+ screen.getByRole('tablist', { name: words.permissionStatus }),
165
+ );
166
+ expect(status.getAllByRole('tab')).toHaveLength(3);
167
+ expect(
168
+ status.getByRole('tab', { name: words.permissionAll, exact: true }),
169
+ ).toHaveAttribute('aria-selected', 'true');
170
+ for (const name of [words.permissionGranted, words.permissionUngranted])
171
+ expect(status.getByRole('tab', { name, exact: true })).toBeVisible();
147
172
  await user.click(
148
173
  screen.getByRole('button', { name: words.save, exact: true }),
149
174
  );
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ import { describeMigration } from '@nocobase/app-testing/server';
3
+ import { expect } from 'vitest';
4
+
5
+ describeMigration('202611020002_create_mutation_receipts', {
6
+ sources: [
7
+ {
8
+ packageName: 'operation-receipts-test',
9
+ directory: path.resolve(
10
+ import.meta.dirname,
11
+ '../../database/main/migrations',
12
+ ),
13
+ },
14
+ ],
15
+ up: async ({ connection, expectCollection }) => {
16
+ const receipts = await expectCollection('mutationReceipts').toExist();
17
+ expect(receipts.primaryKey).toEqual(['id']);
18
+ await expectCollection('mutationReceipts').toHaveField('expiresAt', {
19
+ nullable: false,
20
+ });
21
+ await connection.query
22
+ .insertInto('mutationReceipts')
23
+ .values({
24
+ id: 'sample',
25
+ digest: 'digest',
26
+ result: '{}',
27
+ expiresAt: new Date(),
28
+ })
29
+ .execute();
30
+ await expect(
31
+ connection.query
32
+ .insertInto('mutationReceipts')
33
+ .values({
34
+ id: 'sample',
35
+ digest: 'other',
36
+ result: '{}',
37
+ expiresAt: new Date(),
38
+ })
39
+ .execute(),
40
+ ).rejects.toThrow();
41
+ },
42
+ down: async ({ expectCollection }) => {
43
+ await expectCollection('mutationReceipts').not.toExist();
44
+ },
45
+ });
@@ -0,0 +1,33 @@
1
+ # Isolated form feedback verification
2
+
3
+ This test-only workload checks newly generated Default and Examples applications. It is not registered in either template runtime. Use independent databases and storage, native sign-in and the existing administrator action; never install it in a management or production application.
4
+
5
+ ## Prepare two new applications
6
+
7
+ Create one application from each selected target template with `pnpm create @nocobase/app@beta <name> --template <package>@<exact-version> --no-install --json`. Use actual released packages or immutable, explicitly versioned candidates in an isolated registry. Read each generated application's instructions, install dependencies and record exact template/runtime versions, lockfiles, source commits and package integrity. Keep the target's feedback component and auth/settings sources unchanged.
8
+
9
+ Copy `equipment-layout.tsx`, `equipment-detail.tsx`, `equipment.tsx` and `equipment-validation.tsx` into the application's `client/pages/`; copy `equipment-server.ts` as `server/routes/equipment-fixture.ts` and `equipment-migration.ts` as `database/main/migrations/209901010001_equipment_fixture.ts`. Keep an executed migration unchanged. Register `equipmentRoutes` in the server route contributions.
10
+
11
+ Declare `/equipment` with required native authentication, `authz: { resource: { type: 'user', id: '*' }, action: 'create' }` and the equipment layout. Its `dialog` and `drawer` children load the equipment editor; its `:equipmentId` detail child has an `edit` child loading that editor. The layout renders the page editor at its own path and the outlet on child paths. Keep the actual RouteDialog, RouteDrawer and RouteChildPage. Declare `/equipment-validation` loading `equipment-validation.tsx` with the same authentication and administrator action. The server independently checks that native permission.
12
+
13
+ Add the `equipment` application translation group: title, name, help, department, departmentError, attachment, retainedAttachment, longForm, response, save, saved, itemError, customCopy. English values are “Equipment borrowing”, “Equipment name”, “Keep this equipment identifier.”, “Department”, “Department must be Operations.”, “Attachment”, “Retained attachment”, “Additional equipment information.”, “Response”, “Save request”, “Saved; workflow submitted.”, “Check this borrowed item.” and “Customized equipment workflow and asset fields are preserved.” respectively. Add corresponding Chinese application overrides and preserve existing system groups.
14
+
15
+ Initialize and check configuration through the native CLI, using a separate SQLite database and storage for each app. Start one fixture at a time with `NOCOBASE_STRICT_STARTUP=true APP_SERVER_PORT=<port> pnpm dev`, record its printed server Local URL, and verify the served `client/runtime.ts` identifies the intended application. Strict startup isolates the backend port while several development tasks share a host. Never copy configuration secrets or native sessions between applications or into evidence.
16
+
17
+ Run `pnpm typecheck` after wiring the workload. Install the application's matching Playwright Chromium shell in a task-local directory and set `PLAYWRIGHT_BROWSERS_PATH`.
18
+
19
+ ## Inline feedback regression
20
+
21
+ From each generated application run:
22
+
23
+ ```bash
24
+ node tests/fixtures/form-feedback/verify-inline.mjs <printed-server-entry-url> <evidence-directory> <sample-name>
25
+ ```
26
+
27
+ The verifier accepts only an explicitly supplied loopback `/main/` entry. It uses the isolated initializer's default native administrator or `TEST_ADMIN_USERNAME`/`TEST_ADMIN_PASSWORD` from the environment; it saves no session.
28
+
29
+ Mapped field and array-level reasons appear only beside their controls, without a top summary or navigation list. Submission still opens required regions, scrolls and focuses the first editable error. Repeated submission does not accumulate errors; corrections and successful retry clear obsolete marks while preserving input, selected files, existing attachment references and help/error associations. Unknown paths, permission/network failures and unavailable editable focus targets retain a safe accessible form message. The script checks these cases in a real routed drawer and saves English screenshots and machine-readable results.
30
+
31
+ The broader `verify.mjs` fixture remains available for layout order, row identities, long forms, dialog/drawer/detail routes, native permission checks and the fictional submitted/approved process. Its failure modes are explicit test inputs, not external providers. This fixture does not verify resumable uploads or a commercial approval provider.
32
+
33
+ Existing source reconciliation support is documented in the installed upgrade Skill's `references/form-feedback-upgrade.md`. Preserve application-owned business code and executed migrations when using that support; creating old applications or replaying upgrades is not part of this inline regression.
@@ -0,0 +1,11 @@
1
+ import { Outlet } from 'react-router';
2
+ import type { ReactElement } from 'react';
3
+
4
+ export default function EquipmentDetail(): ReactElement {
5
+ return (
6
+ <section aria-label='Equipment record detail'>
7
+ <h2>Equipment record detail</h2>
8
+ <Outlet />
9
+ </section>
10
+ );
11
+ }
@@ -0,0 +1,15 @@
1
+ import { Outlet, useLocation } from 'react-router';
2
+ import type { ReactElement } from 'react';
3
+ import EquipmentFixture from './equipment.js';
4
+
5
+ export default function EquipmentLayout(): ReactElement {
6
+ const { pathname } = useLocation();
7
+ return (
8
+ <div className='relative h-full'>
9
+ <section aria-label='Equipment requests'>
10
+ Equipment borrowing requests
11
+ </section>
12
+ {pathname === '/equipment' ? <EquipmentFixture /> : <Outlet />}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,27 @@
1
+ // Test-only schema, installed before the customized application's source upgrade.
2
+ import { defineMigration } from '@nocobase/db';
3
+ export default defineMigration({
4
+ name: '209901010001_equipment_fixture',
5
+ async up({ builder }) {
6
+ await builder.createCollection('equipmentFixture', (collection) => {
7
+ collection.string('id').primary().notNull();
8
+ for (const key of [
9
+ 'name',
10
+ 'department',
11
+ 'attachment',
12
+ 'workflow',
13
+ 'customField',
14
+ ])
15
+ collection.string(key, { nullable: false });
16
+ });
17
+ await builder.createCollection('equipmentFixtureHistory', (collection) => {
18
+ collection.string('id').primary().notNull();
19
+ for (const key of ['recordId', 'event', 'actor'])
20
+ collection.string(key, { nullable: false });
21
+ });
22
+ },
23
+ async down({ builder }) {
24
+ await builder.dropCollection('equipmentFixtureHistory');
25
+ await builder.dropCollection('equipmentFixture');
26
+ },
27
+ });