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

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 (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +9 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -1,11 +1,15 @@
1
+ import { useListLocation } from '#hooks/use-list-location';
1
2
  import { RecordLink } from '#components/record-link';
2
3
  import { BackButton } from '#components/back-button';
3
4
  import { safeReturnTo } from '../../lib/return-to.js';
4
- import { useTableUrlState } from '#hooks/use-table-url-state';
5
+ import {
6
+ readTableUrlState,
7
+ useTableUrlState,
8
+ } from '#hooks/use-table-url-state';
5
9
  import { DataTable, DataTableColumnHeader } from '#components/data-table';
6
10
  import type { ColumnDef } from '@tanstack/react-table';
7
11
  import { useSettingsData, useUserPermission } from './hooks.js';
8
- import { useApiClient, useToaster } from '@nocobase/app-client';
12
+ import { useApiClient, useSubmission, useToaster } from '@nocobase/app-client';
9
13
  import { useTranslation } from '@nocobase/i18n/client';
10
14
  import {
11
15
  UsersClient,
@@ -20,13 +24,7 @@ import {
20
24
  useEffect,
21
25
  type ReactElement,
22
26
  } from 'react';
23
- import {
24
- Link,
25
- Outlet,
26
- useLocation,
27
- useSearchParams,
28
- useResolvedPath,
29
- } from 'react-router';
27
+ import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
30
28
  import { PlusIcon, UsersIcon } from 'lucide-react';
31
29
  import { PageContainer } from '#components/page-container';
32
30
  import { PageHeader } from '#components/page-header';
@@ -79,14 +77,21 @@ function UserActions({
79
77
  const [action, setAction] = useState<
80
78
  'disable' | 'enable' | 'revokeSessions'
81
79
  >();
82
- const [busy, setBusy] = useState(false);
80
+ const [requestBusy, setRequestBusy] = useState(false);
83
81
  const [error, setError] = useState<unknown>();
82
+ const submission = useSubmission();
83
+ const busy = requestBusy || submission.submitting;
84
84
  const run = async () => {
85
85
  if (!action) return;
86
- setBusy(true);
86
+ setRequestBusy(true);
87
87
  setError(undefined);
88
88
  try {
89
- const updated = await client[action](user.id);
89
+ const outcome = await submission.run(
90
+ { action, id: user.id },
91
+ async (key) => ({ updated: await client[action](user.id, key) }),
92
+ );
93
+ if (!outcome) return;
94
+ const updated = outcome.updated;
90
95
  toaster.show({
91
96
  type: 'success',
92
97
  title: t(`settings.userActionSaved.${action}`, { name: user.name }),
@@ -96,7 +101,7 @@ function UserActions({
96
101
  } catch (next) {
97
102
  setError(next);
98
103
  } finally {
99
- setBusy(false);
104
+ setRequestBusy(false);
100
105
  }
101
106
  };
102
107
  if (!canRevoke && !(user.disabledAt ? canEnable : canDisable)) return <></>;
@@ -156,7 +161,7 @@ function UserActions({
156
161
 
157
162
  export default function UsersPage(): ReactElement {
158
163
  const { t } = useTranslation();
159
- const [params, setParams] = useSearchParams();
164
+ const { params, setParams, active } = useListLocation();
160
165
  const location = useLocation();
161
166
  const listPath = useResolvedPath('.').pathname;
162
167
  const searchRef = useRef<HTMLInputElement>(null);
@@ -218,16 +223,14 @@ export default function UsersPage(): ReactElement {
218
223
  !roleChoices.some((option) => option.value === selectedRole)
219
224
  )
220
225
  roleChoices.push({ value: selectedRole, label: `${roleScope} · ${role}` });
221
- const page = Math.max(
222
- 1,
223
- Number.isSafeInteger(Number(params.get('page')))
224
- ? Number(params.get('page'))
225
- : 1,
226
- );
226
+ const { paginationState, sortingState } = readTableUrlState(params, {
227
+ defaultPageSize: 20,
228
+ pageSizeOptions: [20, 50, 100],
229
+ sortColumns: ['name'],
230
+ });
231
+ const page = paginationState.pageIndex + 1;
227
232
  const status = params.get('status') ?? 'all';
228
- const pageSize = [20, 50, 100].includes(Number(params.get('pageSize')))
229
- ? Number(params.get('pageSize'))
230
- : 20;
233
+ const pageSize = paginationState.pageSize;
231
234
  const update = (key: string, value: string) => {
232
235
  setParams(
233
236
  (old) => {
@@ -263,15 +266,20 @@ export default function UsersPage(): ReactElement {
263
266
  ...(status !== 'all' ? { status } : {}),
264
267
  ...(roleScope ? { roleScope } : {}),
265
268
  ...(role ? { role } : {}),
266
- ...(params.get('order')
267
- ? { sort: 'name', order: params.get('order')! }
269
+ ...(sortingState[0]
270
+ ? {
271
+ sort: sortingState[0].id,
272
+ order: sortingState[0].desc ? 'desc' : 'asc',
273
+ }
268
274
  : {}),
269
275
  });
270
276
  const request = useSettingsData<{
271
277
  data: readonly ManagedUser[];
272
278
  meta: { total: number };
273
- }>(`users?${remote}`, true);
279
+ }>(`users?${remote}`, { keepPreviousData: true, active });
274
280
  const tableState = useTableUrlState({
281
+ active,
282
+ revision: request.revision,
275
283
  defaultPageSize: 20,
276
284
  pageSizeOptions: [20, 50, 100],
277
285
  sortColumns: ['name'],
@@ -481,10 +489,14 @@ export default function UsersPage(): ReactElement {
481
489
  </p>
482
490
  )}
483
491
  <SettingsError error={options.error} retry={options.reload} />
484
- <SettingsError error={request.error} retry={request.reload} />
492
+ <SettingsError
493
+ error={tableState.error ?? request.error}
494
+ retry={request.reload}
495
+ />
485
496
  {request.loading && !request.data ? (
486
497
  <SettingsLoading />
487
498
  ) : (
499
+ !tableState.error &&
488
500
  !request.error &&
489
501
  (rows.length ? (
490
502
  <DataTable<ManagedUser>
@@ -1,6 +1,11 @@
1
1
  import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
2
2
  import { useTranslation } from '@nocobase/i18n/client';
3
- import { useState, type PropsWithChildren, type ReactElement } from 'react';
3
+ import {
4
+ Fragment,
5
+ useState,
6
+ type PropsWithChildren,
7
+ type ReactElement,
8
+ } from 'react';
4
9
 
5
10
  import { Loading } from '#components/loading';
6
11
  import { Button } from '#components/ui/button';
@@ -9,13 +14,15 @@ import { Button } from '#components/ui/button';
9
14
  export function SessionPreparation({
10
15
  children,
11
16
  }: PropsWithChildren): ReactElement {
12
- const { preparation, retryPreparation, client, refresh } =
17
+ const { preparation, retryPreparation, client, refresh, session } =
13
18
  useAuthentication();
14
19
  const { t } = useTranslation();
15
20
  const [signingOut, setSigningOut] = useState(false);
16
21
  const [signOutFailed, setSignOutFailed] = useState(false);
17
22
 
18
- if (preparation === 'ready') return <>{children}</>;
23
+ // List memory and ancestor queries belong to one signed-in identity only.
24
+ if (preparation === 'ready')
25
+ return <Fragment key={session?.user.id}>{children}</Fragment>;
19
26
  if (preparation === 'preparing') {
20
27
  return <Loading className='min-h-svh' label={t('auth.preparingSession')} />;
21
28
  }
@@ -0,0 +1,19 @@
1
+ import { defineMigration, type MigrationDefinition } from '@nocobase/db';
2
+
3
+ const migration: MigrationDefinition = defineMigration({
4
+ name: '202611020002_create_mutation_receipts',
5
+ async up({ builder }) {
6
+ await builder.createCollection('mutationReceipts', (collection) => {
7
+ collection.string('id', { length: 64 }).notNull();
8
+ collection.string('digest', { length: 64 }).notNull();
9
+ collection.json('result').nullable();
10
+ collection.datetime('expiresAt').notNull();
11
+ collection.primary('id', { name: 'pk_mutation_receipts' });
12
+ collection.index('expiresAt', { name: 'ix_mutation_receipts_expiry' });
13
+ });
14
+ },
15
+ async down({ builder }) {
16
+ await builder.dropCollection('mutationReceipts');
17
+ },
18
+ });
19
+ export default migration;
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.82",
4
+ "version": "1.0.0-beta.84",
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.82"
12
+ "defaultTemplateVersion": "1.0.0-beta.84"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -73,9 +73,10 @@
73
73
  "@xyflow/react": "^12.11.5",
74
74
  "elkjs": "^0.10.2",
75
75
  "@types/jsdom": "^30.0.0",
76
- "@nocobase/app-client": "^4.0.0-beta.3",
76
+ "@noble/hashes": "2.3.0",
77
+ "@nocobase/app-client": "^4.0.0-beta.4",
77
78
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
78
- "@nocobase/app-skills": "0.1.0-beta.52",
79
+ "@nocobase/app-skills": "0.1.0-beta.54",
79
80
  "@nocobase/app-testing": "0.1.0-beta.6",
80
81
  "@nocobase/app-websocket": "^0.1.0-beta.1",
81
82
  "@nocobase/dev-config": "0.1.0-beta.25",
@@ -141,28 +142,28 @@
141
142
  "@oclif/core": "^4.14.0",
142
143
  "better-auth": "1.7.5",
143
144
  "hono": "^4.13.2",
144
- "@nocobase/app-plugin-agents": "^1.0.0-beta.27",
145
+ "@nocobase/app-plugin-agents": "^1.0.0-beta.28",
145
146
  "@nocobase/app-plugin-api-keys": "^1.0.0-beta.19",
146
147
  "@nocobase/app-plugin-authentication": "^2.0.0-beta.15",
147
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.32",
148
+ "@nocobase/app-plugin-authorization": "^1.0.0-beta.33",
148
149
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.17",
149
150
  "@nocobase/app-plugin-file": "^1.0.0-beta.27",
150
151
  "@nocobase/app-plugin-foundation": "^0.1.0-beta.9",
151
152
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
152
- "@nocobase/app-plugin-knowledge": "^0.1.0-beta.13",
153
+ "@nocobase/app-plugin-knowledge": "^0.1.0-beta.14",
153
154
  "@nocobase/app-plugin-mail": "^1.0.0-beta.17",
154
155
  "@nocobase/app-plugin-notification": "^2.0.0-beta.1",
155
156
  "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
156
157
  "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
157
158
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.20",
158
- "@nocobase/app-plugin-users": "^2.0.0-beta.10",
159
- "@nocobase/app-server": "^3.0.0-beta.3",
159
+ "@nocobase/app-plugin-users": "^2.0.0-beta.11",
160
+ "@nocobase/app-server": "^3.0.0-beta.4",
160
161
  "@nocobase/config": "^0.1.0-beta.3",
161
- "@nocobase/db": "^1.0.0-beta.22",
162
+ "@nocobase/db": "^1.0.0-beta.23",
162
163
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
163
164
  "@nocobase/service-provider": "^0.0.2-beta.2",
164
165
  "@nocobase/snowflake": "^1.0.0-beta.4",
165
- "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.16",
166
+ "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.17",
166
167
  "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.16",
167
168
  "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.15",
168
169
  "@nocobase/authorization": "^1.0.0-beta.15",
@@ -172,7 +173,7 @@
172
173
  "@nocobase/i18n": "^2.0.0-beta.1",
173
174
  "@nocobase/caching": "^0.1.0-beta.3",
174
175
  "@nocobase/repository-input": "^0.1.0-beta.3",
175
- "@nocobase/app-cli": "^1.0.0-beta.28",
176
+ "@nocobase/app-cli": "^1.0.0-beta.30",
176
177
  "@nocobase/realtime": "^0.1.0-beta.3"
177
178
  },
178
179
  "description": "NocoBase 3 application template with the standard product capabilities.",
@@ -1,5 +1,8 @@
1
+ import SubmissionProvider from './submission.js';
1
2
  import type { ApplicationServiceProviderConstructor } from '@nocobase/app-server/application';
2
3
 
3
- const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [];
4
+ const serviceProviders: readonly ApplicationServiceProviderConstructor[] = [
5
+ SubmissionProvider,
6
+ ];
4
7
 
5
8
  export default serviceProviders;
@@ -0,0 +1,61 @@
1
+ import type { Application } from '@nocobase/app-server/application';
2
+ import {
3
+ authenticationToken,
4
+ type AuthEnv,
5
+ } from '@nocobase/app-plugin-authentication';
6
+ import {
7
+ repositoryIdempotencyToken,
8
+ pruneMutationReceipts,
9
+ } from '@nocobase/app-server/router';
10
+ import { jobExecutorServiceToken } from '@nocobase/app-server/jobs';
11
+ import { databaseManagerToken } from '@nocobase/db';
12
+ import { ServiceProvider } from '@nocobase/service-provider';
13
+ import type { Context } from 'hono';
14
+ import type { ScheduleExecutor } from '@nocobase/jobs';
15
+
16
+ /** Standard data writes require an operation key; custom HTTP actions opt in explicitly. */
17
+ export default class SubmissionProvider extends ServiceProvider<Application> {
18
+ readonly name = 'app/submission';
19
+ private cleanup?: ScheduleExecutor;
20
+ override register(): void {
21
+ this.app.container.singleton(repositoryIdempotencyToken, () => ({
22
+ required: true,
23
+ principal: async (context) => {
24
+ const auth =
25
+ (context as Context<AuthEnv>).get('auth') ??
26
+ (await this.app.container
27
+ .resolve(authenticationToken)
28
+ .getSession(context.req.raw.headers, {
29
+ disableRefresh: true,
30
+ disableCookieCache: true,
31
+ }));
32
+ return auth && !auth.credential
33
+ ? `${this.app.appName}:${auth.user.id}`
34
+ : undefined;
35
+ },
36
+ }));
37
+ }
38
+ override async boot(): Promise<void> {
39
+ if (
40
+ !this.app.container.has(databaseManagerToken) ||
41
+ !this.app.container.has(jobExecutorServiceToken)
42
+ )
43
+ return;
44
+ const database = this.app.container.resolve(databaseManagerToken);
45
+ this.cleanup = this.app.container
46
+ .resolve(jobExecutorServiceToken)
47
+ .getScheduleExecutor('app.mutation-receipts');
48
+ await this.cleanup.addJob({
49
+ name: 'prune',
50
+ options: { every: 60 * 60 * 1000 },
51
+ payload: {},
52
+ execute: async () => {
53
+ await pruneMutationReceipts(database.connection());
54
+ },
55
+ });
56
+ await this.cleanup.setup();
57
+ }
58
+ override async shutdown(): Promise<void> {
59
+ await this.cleanup?.shutdown();
60
+ }
61
+ }
@@ -116,6 +116,15 @@ describe('authentication translations', () => {
116
116
  it('retranslates an existing password mismatch', async () => {
117
117
  const value = await runtime();
118
118
  const { container } = renderPage(value, <RegisterPage />);
119
+ for (const [label, text] of [
120
+ ['Name', 'Test'],
121
+ ['Username', 'tester'],
122
+ ['Email', 'tester@example.test'],
123
+ ]) {
124
+ fireEvent.change(screen.getByLabelText(label!), {
125
+ target: { value: text },
126
+ });
127
+ }
119
128
  fireEvent.change(screen.getByLabelText('Password'), {
120
129
  target: { value: 'one' },
121
130
  });
@@ -46,15 +46,15 @@ function renderAt(url: string, child: ReactElement) {
46
46
  }
47
47
 
48
48
  describe('BackButton', () => {
49
- it('leads to the parent route and keeps the query string', async () => {
49
+ it('uses the default parent list when no source visit exists', async () => {
50
50
  const router = renderAt('/projects/12?q=alpha&page=2', <BackButton />);
51
51
 
52
52
  const link = screen.getByRole('link', { name: enUS.navigation.back });
53
- expect(link).toHaveAttribute('href', '/projects?q=alpha&page=2');
53
+ expect(link).toHaveAttribute('href', '/projects');
54
54
 
55
55
  await userEvent.click(link);
56
56
  expect(router.state.location.pathname).toBe('/projects');
57
- expect(router.state.location.search).toBe('?q=alpha&page=2');
57
+ expect(router.state.location.search).toBe('');
58
58
  // Leaving the child page replaces its entry, as closing an overlay does.
59
59
  expect(router.state.historyAction).toBe('REPLACE');
60
60
  });
@@ -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,
@@ -207,7 +208,7 @@ it('supports read-only inspection and does not show write or delete actions with
207
208
  });
208
209
  it('shows unknown rules read-only and refuses overwriting a rule changed after loading', async () => {
209
210
  const user = userEvent.setup();
210
- let count = 0;
211
+ let changed = false;
211
212
  let writes = 0;
212
213
  await renderWithApp(tree(), {
213
214
  route: '/default-access/project-default/edit',
@@ -219,14 +220,14 @@ it('shows unknown rules read-only and refuses overwriting a rule changed after l
219
220
  return { data: { unrestricted: true, permissions: [] } };
220
221
  if (path.endsWith('/options')) return { data: catalog };
221
222
  if (method === 'PATCH') writes++;
222
- count++;
223
- return { data: [count > 2 ? { ...existing, actions: [] } : existing] };
223
+ return { data: [changed ? { ...existing, actions: [] } : existing] };
224
224
  }),
225
225
  });
226
226
  await user.click(
227
227
  await screen.findByRole('combobox', { name: 'Edit · Projects' }),
228
228
  );
229
229
  await user.click(await screen.findByRole('option', { name: 'All records' }));
230
+ changed = true;
230
231
  await user.click(screen.getByRole('button', { name: 'Save' }));
231
232
  expect(
232
233
  await screen.findByText(enUS.settings.defaultAccess.changed),
@@ -691,3 +692,58 @@ it('replaces the saved editor history entry and keeps the list query when going
691
692
  ).not.toBeInTheDocument();
692
693
  expect(patches).toBe(1);
693
694
  });
695
+
696
+ it.each(['transport', 'server'])(
697
+ 'verifies an uncertain %s delete without replaying the mutation',
698
+ async (failure) => {
699
+ let deleted = false;
700
+ let writes = 0;
701
+ await renderWithApp(tree(), {
702
+ route: '/default-access',
703
+ namespace,
704
+ namespaces,
705
+ services,
706
+ fetch: answerApi(({ path, method }) => {
707
+ if (path === 'authorization/permissions')
708
+ return { data: { unrestricted: true, permissions: [] } };
709
+ if (path.endsWith('/options')) return { data: catalog };
710
+ if (method === 'DELETE') {
711
+ deleted = true;
712
+ writes++;
713
+ if (failure === 'transport') throw new TypeError('Response lost');
714
+ return Response.json(
715
+ {
716
+ error: {
717
+ code: 'INTERNAL_SERVER_ERROR',
718
+ message: 'Response lost',
719
+ },
720
+ },
721
+ { status: 500 },
722
+ );
723
+ }
724
+ return { data: deleted ? [] : [existing] };
725
+ }),
726
+ });
727
+ await userEvent.click(
728
+ await screen.findByRole('button', { name: 'Actions for Projects' }),
729
+ );
730
+ await userEvent.click(
731
+ await screen.findByRole('menuitem', { name: 'Delete' }),
732
+ );
733
+ await userEvent.click(
734
+ screen.getByRole('button', { name: 'Delete', exact: true }),
735
+ );
736
+ expect(
737
+ await screen.findByText(enUS.settings.defaultAccess.empty),
738
+ ).toBeVisible();
739
+ expect(screen.getByRole('alertdialog')).toBeVisible();
740
+ expect(screen.getByText(enUS.settings.failed)).toBeVisible();
741
+ await userEvent.click(
742
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
743
+ );
744
+ await waitFor(() =>
745
+ expect(screen.getByText(enUS.settings.defaultAccess.empty)).toBeVisible(),
746
+ );
747
+ expect(writes).toBe(1);
748
+ },
749
+ );