@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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- 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 +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- 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/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -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 +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- 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 {
|
|
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 {
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
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
|
-
'
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
146
|
-
expect(
|
|
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
|
+
});
|