@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
@@ -12,6 +12,8 @@ import { bearer, deviceAuthorization, username } from 'better-auth/plugins';
12
12
  */
13
13
  const CLI_CLIENT_IDS: readonly string[] = ['nocobase-cli'];
14
14
 
15
+ // defineAuthConfig supplies AUTH_EMAIL_* mappings and validates the private auth.email sender.
16
+ // Automatic recovery requires passwordReset.enabled: true; leave the switch absent for custom callback compatibility.
15
17
  const auth: AppConfigFactory<AuthConfig> = defineAuthConfig({
16
18
  defaults: {
17
19
  plugins: [
@@ -0,0 +1,3 @@
1
+ import { defineConcurrencyConfig } from '@nocobase/app-plugin-concurrency/config';
2
+
3
+ export default defineConcurrencyConfig();
@@ -12,6 +12,7 @@ import server from './server.js';
12
12
  import spa from './spa.js';
13
13
  import logging from './logging.js';
14
14
  import drive from './drive.js';
15
+ import concurrency from './concurrency.js';
15
16
  import queue from './queue.js';
16
17
  import jobs from './jobs.js';
17
18
  import scheduler from './scheduler.js';
@@ -35,6 +36,7 @@ const defaultConfigs: AppConfigFactory<{
35
36
  spa: ReturnType<typeof spa>;
36
37
  logging: ReturnType<typeof logging>;
37
38
  drive: ReturnType<typeof drive>;
39
+ concurrency: ReturnType<typeof concurrency>;
38
40
  queue: ReturnType<typeof queue>;
39
41
  jobs: ReturnType<typeof jobs>;
40
42
  scheduler: ReturnType<typeof scheduler>;
@@ -57,6 +59,7 @@ const defaultConfigs: AppConfigFactory<{
57
59
  spa,
58
60
  logging,
59
61
  drive,
62
+ concurrency,
60
63
  queue,
61
64
  jobs,
62
65
  scheduler,
@@ -4,24 +4,12 @@ import {
4
4
  } from '@nocobase/app-server/config';
5
5
  import type { AppJobsConfig } from '@nocobase/app-server/jobs';
6
6
 
7
- /**
8
- * Configurations the jobs service can run on. None is the default: until `jobs.default` names one, executors
9
- * run on the built-in memory adapter, which serves one process and writes its state under storage/jobs when the
10
- * application stops, and which is reported at startup outside development. Name `memory` to keep that choice without
11
- * the report, or `redis` to run any number of instances with each firing executed once.
12
- */
7
+ /** Single-process persistence. Distributed jobs require an explicitly registered backend plugin. */
13
8
  const jobs: AppConfigFactory<AppJobsConfig> = defineAppConfig(({ paths }) => ({
14
9
  memory: {
15
10
  adapter: 'memory',
16
11
  persistence: { path: paths.storage('jobs') },
17
12
  },
18
- redis: {
19
- adapter: 'redis',
20
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
21
- // Every firing leaves a finished job behind; keep the history bounded.
22
- removeOnComplete: { count: 1000 },
23
- removeOnFail: { age: 604_800 },
24
- },
25
13
  }));
26
14
 
27
15
  export default jobs;
@@ -1,4 +1,4 @@
1
- import { defineMailChannelsConfig } from '@nocobase/app-server/mailer';
1
+ import { defineMailChannelsConfig } from '@nocobase/app-plugin-notification-providers/config';
2
2
  import { type AppConfigFactory } from '@nocobase/app-server/config';
3
3
  import type { NotificationConfig } from '@nocobase/app-plugin-notification/server';
4
4
 
@@ -8,7 +8,7 @@ import type { AppQueueConfig } from '@nocobase/app-server/queue';
8
8
  * Configurations queues can run on. None is the default: until `queue.default` names one, queues run on the
9
9
  * built-in memory configuration, which serves one process and writes its pending jobs under storage/queue when the
10
10
  * application stops, and which is reported at startup outside development. Name `memory` to keep that choice without
11
- * the report, or `redis` to share queues across instances.
11
+ * the report. Shared Redis queues require explicit registration of @nocobase/app-plugin-queue-redis/server.
12
12
  */
13
13
  const queue: AppConfigFactory<AppQueueConfig> = defineAppConfig(
14
14
  ({ paths }) => ({
@@ -16,12 +16,6 @@ const queue: AppConfigFactory<AppQueueConfig> = defineAppConfig(
16
16
  adapter: 'inMemory',
17
17
  persistence: { path: paths.storage('queue') },
18
18
  },
19
- redis: {
20
- adapter: 'redis',
21
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
22
- removeOnComplete: { count: 1000 },
23
- removeOnFail: { age: 604_800 },
24
- },
25
19
  }),
26
20
  );
27
21
 
package/server/plugins.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import concurrency from '@nocobase/app-plugin-concurrency/server';
1
2
  import foundation from '@nocobase/app-plugin-foundation/server';
2
3
  import defaultAccess from '@nocobase/app-plugin-authz-default-access/server';
3
4
  import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/server';
@@ -20,6 +21,7 @@ import file from '@nocobase/app-plugin-file/server';
20
21
  import mail from '@nocobase/app-plugin-mail/server';
21
22
 
22
23
  const serverPlugins: AppServerPlugins = defineServerPlugins([
24
+ concurrency,
23
25
  authentication,
24
26
  authorization,
25
27
  defaultAccess,
@@ -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
  });
@@ -114,14 +114,19 @@ describe('standard DataTable', () => {
114
114
  expect(screen.getByText('Page 4 of 6')).toBeVisible();
115
115
  expect(screen.getByText('row-0')).toBeVisible();
116
116
  });
117
- it('keeps empty-state and paging controls localized in Chinese', async () => {
117
+ it('localizes successful emptiness and keeps the table headers without paging controls', async () => {
118
118
  await renderWithApp(
119
119
  <DataTable columns={columns} data={[]} showSelectedCount={false} />,
120
120
  { namespace, namespaces, locale: 'zh-CN' },
121
121
  );
122
- expect(screen.getByText(zhCN.dataTable.noResults)).toBeVisible();
122
+ expect(screen.getByText(zhCN.listFeedback.empty)).toBeVisible();
123
+ expect(screen.getByRole('table')).toBeVisible();
124
+ expect(screen.getByRole('columnheader', { name: 'ID' })).toBeVisible();
123
125
  expect(
124
- screen.getByRole('button', { name: zhCN.dataTable.nextPage }),
125
- ).toBeDisabled();
126
+ screen.queryByText(zhCN.dataTable.noResults),
127
+ ).not.toBeInTheDocument();
128
+ expect(
129
+ screen.queryByRole('button', { name: zhCN.dataTable.nextPage }),
130
+ ).not.toBeInTheDocument();
126
131
  });
127
132
  });
@@ -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),
@@ -342,7 +343,7 @@ it('keeps failed deletion in its confirmation and deletes only after explicit co
342
343
  expect(screen.getByRole('alertdialog')).toBeVisible();
343
344
  expect(screen.queryByText('Private server detail')).not.toBeInTheDocument();
344
345
  await user.click(screen.getByRole('button', { name: 'Delete', exact: true }));
345
- await screen.findByText(enUS.settings.defaultAccess.empty);
346
+ await screen.findByText(enUS.listFeedback.empty);
346
347
  expect(deletes).toBe(2);
347
348
  });
348
349
  it('retains the create draft when the server rejects a duplicate and shows a contextual error', async () => {
@@ -484,6 +485,46 @@ it('refuses deleting a changed rule and refreshes the list for explicit re-confi
484
485
  expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
485
486
  });
486
487
 
488
+ it('keeps one authorized create entry in the header for an empty list', async () => {
489
+ await renderWithApp(tree(), {
490
+ route: '/default-access',
491
+ namespace,
492
+ namespaces,
493
+ services,
494
+ fetch: answerApi(({ path }) =>
495
+ path === 'authorization/permissions'
496
+ ? { data: { unrestricted: true, permissions: [] } }
497
+ : path.endsWith('/options')
498
+ ? { data: catalog }
499
+ : { data: [] },
500
+ ),
501
+ });
502
+ const label = enUS.settings.defaultAccess.new;
503
+ const create = await screen.findByRole('button', { name: label });
504
+ expect(create).toHaveAttribute('href', '/default-access/create');
505
+ expect(screen.getAllByRole('button', { name: label })).toHaveLength(1);
506
+ });
507
+
508
+ it('hides the create entry for a read-only empty list', async () => {
509
+ await renderWithApp(tree(), {
510
+ route: '/default-access',
511
+ namespace,
512
+ namespaces,
513
+ services,
514
+ fetch: answerApi(({ path }) =>
515
+ path === 'authorization/permissions'
516
+ ? { data: { unrestricted: false, permissions: [] } }
517
+ : path.endsWith('/options')
518
+ ? { data: catalog }
519
+ : { data: [] },
520
+ ),
521
+ });
522
+ const label = enUS.settings.defaultAccess.new;
523
+ expect(await screen.findByText(enUS.listFeedback.empty)).toBeVisible();
524
+ expect(screen.queryByRole('link', { name: label })).not.toBeInTheDocument();
525
+ expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument();
526
+ });
527
+
487
528
  it('disables New rule with a reason when every available resource is already configured', async () => {
488
529
  await renderWithApp(tree(), {
489
530
  route: '/default-access',
@@ -691,3 +732,56 @@ it('replaces the saved editor history entry and keeps the list query when going
691
732
  ).not.toBeInTheDocument();
692
733
  expect(patches).toBe(1);
693
734
  });
735
+
736
+ it.each(['transport', 'server'])(
737
+ 'verifies an uncertain %s delete without replaying the mutation',
738
+ async (failure) => {
739
+ let deleted = false;
740
+ let writes = 0;
741
+ await renderWithApp(tree(), {
742
+ route: '/default-access',
743
+ namespace,
744
+ namespaces,
745
+ services,
746
+ fetch: answerApi(({ path, method }) => {
747
+ if (path === 'authorization/permissions')
748
+ return { data: { unrestricted: true, permissions: [] } };
749
+ if (path.endsWith('/options')) return { data: catalog };
750
+ if (method === 'DELETE') {
751
+ deleted = true;
752
+ writes++;
753
+ if (failure === 'transport') throw new TypeError('Response lost');
754
+ return Response.json(
755
+ {
756
+ error: {
757
+ code: 'INTERNAL_SERVER_ERROR',
758
+ message: 'Response lost',
759
+ },
760
+ },
761
+ { status: 500 },
762
+ );
763
+ }
764
+ return { data: deleted ? [] : [existing] };
765
+ }),
766
+ });
767
+ await userEvent.click(
768
+ await screen.findByRole('button', { name: 'Actions for Projects' }),
769
+ );
770
+ await userEvent.click(
771
+ await screen.findByRole('menuitem', { name: 'Delete' }),
772
+ );
773
+ await userEvent.click(
774
+ screen.getByRole('button', { name: 'Delete', exact: true }),
775
+ );
776
+ expect(await screen.findByText(enUS.listFeedback.empty)).toBeVisible();
777
+ expect(screen.getByRole('alertdialog')).toBeVisible();
778
+ expect(screen.getByText(enUS.settings.failed)).toBeVisible();
779
+ await userEvent.click(
780
+ screen.getByRole('button', { name: 'Cancel', exact: true }),
781
+ );
782
+ await waitFor(() =>
783
+ expect(screen.getByText(enUS.listFeedback.empty)).toBeVisible(),
784
+ );
785
+ expect(writes).toBe(1);
786
+ },
787
+ );