cabloy 5.1.163 → 5.1.165

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/.cabloy-version +1 -1
  2. package/.claude/scheduled_tasks.lock +1 -0
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +2 -0
  5. package/package.json +1 -1
  6. package/repo-docs/backend/shared-rbac-architecture.md +26 -0
  7. package/repo-docs/blogs/ai-spec-driven-development/cover-en-v1.png +0 -0
  8. package/repo-docs/blogs/ai-spec-driven-development/implementation-burndown.png +0 -0
  9. package/repo-docs/blogs/ai-spec-driven-development/implementation-gantt.png +0 -0
  10. package/repo-docs/blogs/ai-spec-driven-development/index.md +217 -0
  11. package/repo-docs/blogs/index.md +15 -0
  12. package/repo-docs/editions/overview.md +2 -0
  13. package/repo-docs/frontend/environment-config-guide.md +43 -10
  14. package/repo-docs/frontend/resource-list-page-deep-dive.md +14 -1
  15. package/repo-docs/frontend/table-action-visibility-permission-flow-guide.md +20 -0
  16. package/repo-docs/frontend/table-guide.md +42 -0
  17. package/repo-docs/frontend/table-resource-crud-cookbook.md +25 -1
  18. package/repo-docs/fullstack/backend-metadata-to-frontend-table-actions.md +36 -1
  19. package/repo-e2e/docs/specs/blogs-index.spec.ts +45 -9
  20. package/repo-e2e/specs/cabloy-basic.spec.ts +248 -1
  21. package/vona/packages-cli/cli/package.json +1 -1
  22. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/controller/{{resourceName}}.ts_ +17 -6
  23. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}DeleteBulk.tsx_ +14 -0
  24. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +7 -1
  25. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/boilerplate/src/service/{{resourceName}}.ts_ +6 -0
  26. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/3-en-us.ts +12 -1
  27. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/4-zh-cn.ts +12 -1
  28. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +21 -0
  29. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}DeleteBulk.tsx_ +14 -0
  30. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/dto/{{resourceName}}SelectResItem.tsx_ +7 -1
  31. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/service/{{resourceName}}.ts_ +6 -0
  32. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/3-en-us.ts +1 -0
  33. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/4-zh-cn.ts +1 -0
  34. package/vona/packages-cli/cli-set-api/package.json +1 -1
  35. package/vona/packages-vona/vona/package.json +1 -1
  36. package/vona/pnpm-lock.yaml +91 -11
  37. package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +9 -0
  38. package/vona/src/suite/a-training/modules/training-student/src/config/locale/en-us.ts +1 -0
  39. package/vona/src/suite/a-training/modules/training-student/src/config/locale/zh-cn.ts +1 -0
  40. package/vona/src/suite/a-training/modules/training-student/src/controller/student.ts +9 -0
  41. package/vona/src/suite/a-training/modules/training-student/src/dto/studentDeleteBulk.tsx +14 -0
  42. package/vona/src/suite/a-training/modules/training-student/src/dto/studentSelectResItem.tsx +7 -1
  43. package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +6 -0
  44. package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +114 -2
  45. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  46. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/index.ts +24 -3
  47. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/.metadata/locales.ts +18 -0
  48. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/bean.rbacResourceBulk.ts +50 -0
  49. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/bean/index.ts +1 -0
  50. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/errors.ts +18 -0
  51. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/locale/en-us.ts +6 -0
  52. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/config/locale/zh-cn.ts +6 -0
  53. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/src/index.ts +1 -0
  54. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/test/resourceBulk.test.ts +128 -0
  55. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  56. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  57. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  58. package/zova/packages-cli/cli/package.json +2 -2
  59. package/zova/packages-cli/cli-set-front/cli/templates/create/component/boilerplateTableActionBulk/controller.tsx_ +2 -2
  60. package/zova/packages-cli/cli-set-front/package.json +1 -1
  61. package/zova/packages-utils/zova-jsx/package.json +2 -2
  62. package/zova/packages-zova/zova/package.json +3 -3
  63. package/zova/packages-zova/zova-core/package.json +1 -1
  64. package/zova/packages-zova/zova-core/src/types/interface/component.ts +26 -20
  65. package/zova/pnpm-lock.yaml +6 -6
  66. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +16 -12
  67. package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +66 -7
  68. package/zova/src/suite/a-training/modules/training-student/src/api/trainingStudent.ts +18 -0
  69. package/zova/src/suite/a-training/modules/training-student/src/apiSchema/trainingStudent.ts +5 -0
  70. package/zova/src/suite/cabloy-basic/modules/basic-commands/src/.metadata/index.ts +16 -0
  71. package/zova/src/suite/cabloy-basic/modules/basic-commands/src/bean/command.deleteBulk.tsx +35 -0
  72. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockPage/controller.tsx +143 -7
  73. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +3 -2
  74. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockToolbarBulk/controller.tsx +76 -4
  75. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/en-us.ts +7 -0
  76. package/zova/src/suite/cabloy-basic/modules/basic-page/src/config/locale/zh-cn.ts +6 -0
  77. package/zova/src/suite/cabloy-basic/modules/basic-page/src/lib/selection.ts +84 -0
  78. package/zova/src/suite/cabloy-basic/modules/basic-page/test/lib/selection.test.ts +99 -0
  79. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/component/actionDeleteBulk.ts +31 -0
  80. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/index.ts +13 -0
  81. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/actionCreate/controller.tsx +5 -2
  82. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/actionDeleteBulk/controller.tsx +57 -0
  83. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +35 -7
  84. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/en-us.ts +4 -0
  85. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/zh-cn.ts +4 -0
  86. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
  87. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/lib/selectedIds.ts +15 -0
  88. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +57 -0
  89. package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/lib/selectedIds.test.ts +20 -0
  90. package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
  91. package/zova/src/suite-vendor/a-zova/modules/a-command/package.json +1 -1
  92. package/zova/src/suite-vendor/a-zova/modules/a-command/src/lib/beanCommandSelectedBase.ts +33 -0
  93. package/zova/src/suite-vendor/a-zova/modules/a-command/src/lib/index.ts +1 -0
  94. package/zova/src/suite-vendor/a-zova/modules/a-command/src/types/command.ts +5 -0
  95. package/zova/src/suite-vendor/a-zova/modules/a-command/test/lib/beanCommandSelectedBase.test.ts +47 -0
  96. package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
  97. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/action.ts +3 -0
  98. package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/resource/tableActionBulk.ts +19 -0
  99. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  100. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +86 -26
  101. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/render.tsx +30 -6
  102. package/zova/src/suite-vendor/a-zova/modules/a-table/src/types/table.ts +2 -0
  103. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +2 -2
  104. package/zova/src/suite-vendor/a-zova/package.json +5 -5
@@ -25,6 +25,7 @@ interface IArticleGeometry {
25
25
  }
26
26
 
27
27
  const blogArticlePaths = [
28
+ '/blogs/ai-spec-driven-development/',
28
29
  '/blogs/ai-react-nextjs-enterprise-architecture-cabloy/',
29
30
  '/blogs/cabloy-fullstack-resource-addressing/',
30
31
  '/blogs/nextjs-integrated-fullstack-cabloy-contract-loop/',
@@ -118,29 +119,46 @@ test(
118
119
  await expect(page.locator('.VPDoc.has-aside')).toHaveCount(0);
119
120
 
120
121
  const cards = page.locator('.cabloy-blog-card');
121
- await expect(cards).toHaveCount(4);
122
- for (let index = 0; index < 4; index++) {
122
+ await expect(cards).toHaveCount(5);
123
+ for (let index = 0; index < 5; index++) {
123
124
  await expect(cards.nth(index)).toBeVisible();
124
125
  await expect(cards.nth(index).locator('.cabloy-blog-card__cover img')).toBeVisible();
125
126
  }
127
+ await expect(
128
+ cards.nth(0).getByRole('link', {
129
+ name: 'CabloyJS AI Spec-Driven Development Automatically Generates Gantt and Burndown Charts',
130
+ exact: true,
131
+ }),
132
+ ).toBeVisible();
133
+ await expect(cards.nth(0).locator('.cabloy-blog-card__date')).toHaveText('September 11, 2026');
134
+ await expect(cards.nth(0).locator('.cabloy-blog-card__cover')).toHaveAttribute(
135
+ 'href',
136
+ '/blogs/ai-spec-driven-development/',
137
+ );
126
138
 
127
139
  const geometry = await getBlogsGeometry(page);
128
140
  expect(geometry.grid.display).toBe('grid');
129
- expect(geometry.cards).toHaveLength(4);
141
+ expect(geometry.cards).toHaveLength(5);
130
142
 
131
- const [firstCard, ...remainingCards] = geometry.cards;
143
+ const [firstCard, secondCard, thirdCard, fourthCard, fifthCard] = geometry.cards;
144
+ const firstRow = [firstCard, secondCard, thirdCard, fourthCard];
132
145
  expect(firstCard.width).toBeGreaterThanOrEqual(279);
133
- for (const card of remainingCards) {
146
+ for (const card of firstRow) {
134
147
  expect(Math.abs(card.top - firstCard.top)).toBeLessThanOrEqual(1);
135
148
  expect(Math.abs(card.width - firstCard.width)).toBeLessThanOrEqual(1);
136
- expect(card.left).toBeGreaterThan(firstCard.left);
137
149
  expect(card.left).toBeGreaterThanOrEqual(geometry.grid.left - 1);
138
150
  expect(card.right).toBeLessThanOrEqual(geometry.grid.right + 1);
139
151
  expect(card.right).toBeLessThanOrEqual(1441);
140
152
  }
141
- expect(firstCard.left).toBeGreaterThanOrEqual(geometry.grid.left - 1);
142
- expect(firstCard.right).toBeLessThanOrEqual(geometry.grid.right + 1);
143
- expect(firstCard.right).toBeLessThanOrEqual(1441);
153
+ expect(secondCard.left).toBeGreaterThan(firstCard.left);
154
+ expect(thirdCard.left).toBeGreaterThan(secondCard.left);
155
+ expect(fourthCard.left).toBeGreaterThan(thirdCard.left);
156
+ expect(fifthCard.top).toBeGreaterThan(firstCard.bottom);
157
+ expect(fifthCard.left).toBeCloseTo(firstCard.left, 0);
158
+ expect(fifthCard.width).toBeCloseTo(firstCard.width, 0);
159
+ expect(fifthCard.left).toBeGreaterThanOrEqual(geometry.grid.left - 1);
160
+ expect(fifthCard.right).toBeLessThanOrEqual(geometry.grid.right + 1);
161
+ expect(fifthCard.right).toBeLessThanOrEqual(1441);
144
162
 
145
163
  await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
146
164
  expect(pageErrors).toEqual([]);
@@ -163,6 +181,24 @@ test(
163
181
  await expect(page.locator('.VPDoc.has-sidebar')).toHaveCount(0);
164
182
  await expect(page.locator('.VPDoc.has-aside')).toHaveCount(1);
165
183
  await expect(page.locator('.aside')).toBeVisible();
184
+ if (path === '/blogs/ai-spec-driven-development/') {
185
+ await expect(
186
+ page.getByRole('heading', {
187
+ name: 'CabloyJS AI Spec-Driven Development Automatically Generates Gantt and Burndown Charts',
188
+ level: 1,
189
+ }),
190
+ ).toBeVisible();
191
+ await expect(
192
+ page.getByAltText(
193
+ 'A-Commerce implementation roadmap: WBS phases, dependencies, and derived status',
194
+ ),
195
+ ).toBeVisible();
196
+ await expect(
197
+ page.getByAltText(
198
+ 'A-Commerce WBS scope-count burndown: approved scope and remaining-item counts',
199
+ ),
200
+ ).toBeVisible();
201
+ }
166
202
 
167
203
  const geometry = await getArticleGeometry(page);
168
204
  expect(geometry.container.width).toBeCloseTo(1216, 0);
@@ -1,4 +1,4 @@
1
- import type { Locator, Page, Route } from '@playwright/test';
1
+ import type { Locator, Page, Request, Route } from '@playwright/test';
2
2
 
3
3
  import { expect, test } from '@playwright/test';
4
4
 
@@ -518,6 +518,253 @@ test(
518
518
  },
519
519
  );
520
520
 
521
+ test(
522
+ 'ATP-BASIC-TABLE-02: Training Student selection mode preserves page state and bulk deletes selected rows',
523
+ { tag: ['@admin', '@flow'] },
524
+ async ({ page }, testInfo) => {
525
+ testInfo.setTimeout(120_000);
526
+ page.setDefaultTimeout(10_000);
527
+ await page.setViewportSize({ width: 1440, height: 900 });
528
+ const pageErrors = collectPageErrors(page);
529
+ await loginAsAdmin(page);
530
+
531
+ const fixturePrefix = `Selection E2E ${Date.now()}`;
532
+ const fixtureNames = Array.from(
533
+ { length: 21 },
534
+ (_, index) => `${fixturePrefix} ${String(index + 1).padStart(2, '0')}`,
535
+ );
536
+ const visibleFixtureNames = fixtureNames.toReversed();
537
+ const fixtureRecords: Array<{ id: string | number; name: string }> = [];
538
+ const accessToken = (await page.context().cookies()).find(
539
+ cookie => cookie.name === 'token',
540
+ )?.value;
541
+ expect(accessToken).toBeTruthy();
542
+ const headers = { Authorization: `Bearer ${accessToken}` };
543
+
544
+ const allPageCheckbox = page.getByRole('checkbox', {
545
+ name: 'Select all rows on this page',
546
+ exact: true,
547
+ });
548
+ const selectionStatus = page
549
+ .getByRole('status')
550
+ .filter({ hasText: /^Selected (?:\d+ items|one item)$/ });
551
+ const select = page.getByRole('button', { name: 'Select', exact: true });
552
+ const done = page.getByRole('button', { name: 'Done', exact: true });
553
+ const create = page.getByRole('button', { name: 'Create', exact: true });
554
+ const deleteBulk = page.getByRole('button', { name: 'Bulk Delete', exact: true });
555
+ const rowFor = (name: string) => page.locator('tbody tr').filter({ hasText: name });
556
+ const rowCheckboxFor = (name: string) =>
557
+ rowFor(name).getByRole('checkbox', { name: /^Select row / });
558
+ const selectPage = async () => {
559
+ const response = waitForStudentSelect(page);
560
+ await page.getByRole('button', { name: 'Search', exact: true }).click();
561
+ return await response;
562
+ };
563
+
564
+ try {
565
+ for (const name of fixtureNames) {
566
+ const createResponse = await page.request.post('/api/training/student', {
567
+ data: { name, mobile: '13812345678', level: 1 },
568
+ headers,
569
+ });
570
+ expect(createResponse.ok()).toBeTruthy();
571
+ const id = (await createResponse.json()).data;
572
+ expect(['string', 'number']).toContain(typeof id);
573
+ fixtureRecords.unshift({ id, name });
574
+ }
575
+
576
+ const initialSelect = waitForStudentSelect(page);
577
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
578
+ await expect(page).toHaveURL(studentResourceUrl);
579
+ await initialSelect;
580
+
581
+ await page.getByLabel('Student Name').fill(fixturePrefix);
582
+ const filteredResponse = await selectPage();
583
+ const filteredUrl = new URL(filteredResponse.url());
584
+ expect(filteredUrl.searchParams.get('name')).toBe(fixturePrefix);
585
+ await expect(rowFor(visibleFixtureNames[0])).toHaveCount(1);
586
+ await expect(rowFor(visibleFixtureNames[20])).toHaveCount(0);
587
+
588
+ await expect(create).toBeVisible();
589
+ await expect(create).toBeEnabled();
590
+ await expect(select).toBeVisible();
591
+ await expect(allPageCheckbox).toHaveCount(0);
592
+ await expect(deleteBulk).toBeDisabled();
593
+
594
+ await select.click();
595
+ await expect(done).toBeVisible();
596
+ await expect(selectionStatus).toHaveText('Selected 0 items');
597
+ await expect(allPageCheckbox).toBeVisible();
598
+ await expect(rowCheckboxFor(visibleFixtureNames[0])).toBeVisible();
599
+
600
+ await rowCheckboxFor(visibleFixtureNames[0]).check();
601
+ await expect(rowFor(visibleFixtureNames[0])).toHaveAttribute('aria-selected', 'true');
602
+ await expect(selectionStatus).toHaveText('Selected one item');
603
+ await expect(allPageCheckbox).toHaveAttribute('aria-checked', 'mixed');
604
+ await expect(allPageCheckbox).toHaveJSProperty('indeterminate', true);
605
+
606
+ await done.click();
607
+ await expect(select).toBeVisible();
608
+ await expect(allPageCheckbox).toHaveCount(0);
609
+ await expect(create).toBeEnabled();
610
+
611
+ await select.click();
612
+ await rowCheckboxFor(visibleFixtureNames[0]).check();
613
+ await allPageCheckbox.check();
614
+ await expect(selectionStatus).toHaveText('Selected 20 items');
615
+ await expect(allPageCheckbox).toBeChecked();
616
+ await expect(allPageCheckbox).toHaveJSProperty('indeterminate', false);
617
+
618
+ const pageTwoResponse = waitForStudentSelect(page);
619
+ await page.getByRole('button', { name: '»', exact: true }).click();
620
+ const pageTwoUrl = new URL((await pageTwoResponse).url());
621
+ expect(pageTwoUrl.searchParams.get('pageNo')).toBe('2');
622
+ await expect(selectionStatus).toHaveText('Selected 20 items');
623
+ await expect(rowFor(visibleFixtureNames[20])).toHaveCount(1);
624
+ await expect(rowCheckboxFor(visibleFixtureNames[20])).not.toBeChecked();
625
+
626
+ await rowCheckboxFor(visibleFixtureNames[20]).check();
627
+ await expect(rowFor(visibleFixtureNames[20])).toHaveAttribute('aria-selected', 'true');
628
+ await expect(selectionStatus).toHaveText('Selected 21 items');
629
+
630
+ await page.getByRole('button', { name: '«', exact: true }).click();
631
+ await expect(rowFor(visibleFixtureNames[0])).toHaveCount(1);
632
+ await expect(allPageCheckbox).toBeChecked();
633
+ await allPageCheckbox.uncheck();
634
+ await expect(selectionStatus).toHaveText('Selected one item');
635
+
636
+ await rowCheckboxFor(visibleFixtureNames[9]).check();
637
+ await expect(selectionStatus).toHaveText('Selected 2 items');
638
+ await page.getByLabel('Student Name').fill(`${fixturePrefix} 1`);
639
+ await selectPage();
640
+ await expect(selectionStatus).toHaveText('Selected 0 items');
641
+ await expect(rowFor(visibleFixtureNames[9])).toHaveAttribute('aria-selected', 'false');
642
+
643
+ await rowCheckboxFor(visibleFixtureNames[9]).check();
644
+ await expect(selectionStatus).toHaveText('Selected one item');
645
+ await page.getByRole('button', { name: 'Reset', exact: true }).click();
646
+ await expect(page.getByLabel('Student Name')).toHaveValue('');
647
+ await expect(selectionStatus).toHaveText('Selected 0 items');
648
+ await page.getByLabel('Student Name').fill(fixturePrefix);
649
+ await page.getByRole('button', { name: 'Search', exact: true }).click();
650
+ await expect(rowFor(visibleFixtureNames[0])).toHaveCount(1);
651
+
652
+ await rowCheckboxFor(visibleFixtureNames[0]).check();
653
+ await expect(selectionStatus).toHaveText('Selected one item');
654
+ const nameSort = page.getByRole('button', { name: 'Sort by name', exact: true });
655
+ const sortResponse = waitForStudentSelect(page);
656
+ await nameSort.click();
657
+ await sortResponse;
658
+ await expect(selectionStatus).toHaveText('Selected 0 items');
659
+
660
+ await nameSort.click();
661
+ const selectedRows = page.locator('tbody tr').filter({ hasText: fixturePrefix });
662
+ await expect(selectedRows).toHaveCount(20);
663
+ const selectedNames = await selectedRows.evaluateAll(rows =>
664
+ rows.slice(0, 2).map(row => row.querySelector('td:nth-child(3) a')?.textContent?.trim()),
665
+ );
666
+ const selectedIds = await selectedRows.evaluateAll(rows =>
667
+ rows
668
+ .slice(0, 2)
669
+ .map(row => Number(row.querySelector('td:nth-child(2)')?.textContent?.trim())),
670
+ );
671
+ expect(selectedNames.every((name): name is string => Boolean(name))).toBeTruthy();
672
+ expect(selectedIds.every(Number.isInteger)).toBeTruthy();
673
+ await rowCheckboxFor(selectedNames[0]).check();
674
+ await rowCheckboxFor(selectedNames[1]).check();
675
+ await expect(selectionStatus).toHaveText('Selected 2 items');
676
+ await expect(deleteBulk).toBeEnabled();
677
+
678
+ let cancelledRequestCount = 0;
679
+ const cancelledRequestListener = (request: Request) => {
680
+ if (
681
+ request.method() === 'POST' &&
682
+ new URL(request.url()).pathname === '/api/training/student/bulk/delete'
683
+ ) {
684
+ cancelledRequestCount++;
685
+ }
686
+ };
687
+ page.on('request', cancelledRequestListener);
688
+ try {
689
+ await deleteBulk.click();
690
+ await expect(page.getByRole('dialog')).toContainText('2 selected items');
691
+ await page.getByRole('button', { name: 'No', exact: true }).click();
692
+ await expect(selectionStatus).toHaveText('Selected 2 items');
693
+ expect(cancelledRequestCount).toBe(0);
694
+ } finally {
695
+ page.off('request', cancelledRequestListener);
696
+ }
697
+
698
+ let failureRequestCount = 0;
699
+ const bulkDeleteRoute = (url: URL) => url.pathname === '/api/training/student/bulk/delete';
700
+ const bulkDeleteFailureHandler = async (route: Route) => {
701
+ failureRequestCount++;
702
+ await route.fulfill({
703
+ status: 500,
704
+ contentType: 'application/json',
705
+ body: JSON.stringify({ code: 500, message: 'Bulk deletion temporarily unavailable' }),
706
+ });
707
+ };
708
+ await page.route(bulkDeleteRoute, bulkDeleteFailureHandler);
709
+ try {
710
+ await deleteBulk.click();
711
+ await page.getByRole('button', { name: 'Yes', exact: true }).click();
712
+ await expect.poll(() => failureRequestCount).toBe(1);
713
+ await expect(selectionStatus).toHaveText('Selected 2 items');
714
+ await expect(rowFor(selectedNames[0])).toHaveAttribute('aria-selected', 'true');
715
+ await expect(rowFor(selectedNames[1])).toHaveAttribute('aria-selected', 'true');
716
+ } finally {
717
+ await page.unroute(bulkDeleteRoute, bulkDeleteFailureHandler);
718
+ }
719
+
720
+ const bulkDeleteRequest = page.waitForRequest(request => {
721
+ const url = new URL(request.url());
722
+ return request.method() === 'POST' && url.pathname === '/api/training/student/bulk/delete';
723
+ });
724
+ const bulkDeleteResponse = page.waitForResponse(response => {
725
+ const url = new URL(response.url());
726
+ return (
727
+ response.request().method() === 'POST' &&
728
+ response.ok() &&
729
+ url.pathname === '/api/training/student/bulk/delete'
730
+ );
731
+ });
732
+ const refetchResponse = waitForStudentSelect(page);
733
+ await deleteBulk.click();
734
+ await page.getByRole('button', { name: 'Yes', exact: true }).click();
735
+ const [request, response, refetch] = await Promise.all([
736
+ bulkDeleteRequest,
737
+ bulkDeleteResponse,
738
+ refetchResponse,
739
+ ]);
740
+ expect(response.ok()).toBeTruthy();
741
+ expect(request.postDataJSON()).toEqual({ ids: selectedIds });
742
+ expect(refetch.ok()).toBeTruthy();
743
+
744
+ await expect(rowFor(selectedNames[0])).toHaveCount(0);
745
+ await expect(rowFor(selectedNames[1])).toHaveCount(0);
746
+ await expect(selectionStatus).toHaveText('Selected 0 items');
747
+ await expect(page.locator('tbody tr[aria-selected="true"]')).toHaveCount(0);
748
+ await expect(deleteBulk).toBeDisabled();
749
+ expect(pageErrors).toEqual([]);
750
+ } finally {
751
+ for (const { id, name } of fixtureRecords) {
752
+ const deleteResponse = await page.request.delete(
753
+ `/api/training/student/deleteForce/${id}`,
754
+ {
755
+ headers,
756
+ timeout: 10_000,
757
+ },
758
+ );
759
+ expect(
760
+ deleteResponse.ok() || deleteResponse.status() === 404,
761
+ `Failed to clean up Training Student ${name} (${id}): ${deleteResponse.status()}`,
762
+ ).toBeTruthy();
763
+ }
764
+ }
765
+ },
766
+ );
767
+
521
768
  test(
522
769
  'ATP-BASIC-FORM-01: Training Student content remains in the Basic Information tab',
523
770
  { tag: ['@admin', '@flow'] },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.145",
3
+ "version": "1.1.147",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -10,7 +10,9 @@ import { Arg, Controller, Web } from 'vona-module-a-web';
10
10
 
11
11
  import type { Model<%=argv.resourceNameCapitalize%> } from '../model/<%=argv.resourceName%>.ts';
12
12
 
13
+ import { $locale } from '../.metadata/locales.ts';
13
14
  import { Dto<%=argv.resourceNameCapitalize%>Create } from '../dto/<%=argv.resourceName%>Create.tsx';
15
+ import { Dto<%=argv.resourceNameCapitalize%>DeleteBulk } from '../dto/<%=argv.resourceName%>DeleteBulk.tsx';
14
16
  import { Dto<%=argv.resourceNameCapitalize%>SelectReq } from '../dto/<%=argv.resourceName%>SelectReq.tsx';
15
17
  import { Dto<%=argv.resourceNameCapitalize%>SelectRes } from '../dto/<%=argv.resourceName%>SelectRes.tsx';
16
18
  import { Dto<%=argv.resourceNameCapitalize%>Update } from '../dto/<%=argv.resourceName%>Update.tsx';
@@ -18,41 +20,50 @@ import { Dto<%=argv.resourceNameCapitalize%>View } from '../dto/<%=argv.resource
18
20
 
19
21
  export interface IControllerOptions<%=argv.resourceNameCapitalize%> extends IDecoratorControllerOptions {}
20
22
 
21
- @Controller<IControllerOptions<%=argv.resourceNameCapitalize%>>('<%=argv.resourceName%>')
23
+ @Controller<IControllerOptions<%=argv.resourceNameCapitalize%>>('<%=argv.resourceName%>', {
24
+ summary: $locale('<%=argv.resourceNameCapitalize%>Controller'),
25
+ })
22
26
  @Resource()
23
27
  export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
24
- @Web.post()
28
+ @Web.post('', { summary: $locale('<%=argv.resourceNameCapitalize%>Create') })
25
29
  @Api.body(v.tableIdentity())
26
30
  @Passport.systemAdmin()
27
31
  async create(@Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Create): Promise<TableIdentity> {
28
32
  return (await this.scope.service.<%=argv.resourceName%>.create(<%=argv.resourceName%>)).id;
29
33
  }
30
34
 
31
- @Web.get()
35
+ @Web.get('', { summary: $locale('<%=argv.resourceNameCapitalize%>Select') })
32
36
  @Api.body(Dto<%=argv.resourceNameCapitalize%>SelectRes)
33
37
  @Passport.systemAdmin()
34
38
  async select(@Arg.filter(Dto<%=argv.resourceNameCapitalize%>SelectReq) params: IQueryParams<Model<%=argv.resourceNameCapitalize%>>): Promise<Dto<%=argv.resourceNameCapitalize%>SelectRes> {
35
39
  return await this.scope.service.<%=argv.resourceName%>.select(params);
36
40
  }
37
41
 
38
- @Web.get(':id')
42
+ @Web.get(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>View') })
39
43
  @Api.body(v.optional(), v.object(Dto<%=argv.resourceNameCapitalize%>View))
40
44
  @Passport.systemAdmin()
41
45
  async view(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<Dto<%=argv.resourceNameCapitalize%>View | undefined> {
42
46
  return await this.scope.service.<%=argv.resourceName%>.view(id);
43
47
  }
44
48
 
45
- @Web.patch(':id')
49
+ @Web.patch(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Update') })
46
50
  @Api.body(z.null())
47
51
  @Passport.systemAdmin()
48
52
  async update(@Arg.param('id', v.tableIdentity()) id: TableIdentity, @Arg.body() <%=argv.resourceName%>: Dto<%=argv.resourceNameCapitalize%>Update): Promise<void> {
49
53
  await this.scope.service.<%=argv.resourceName%>.update(id, <%=argv.resourceName%>);
50
54
  }
51
55
 
52
- @Web.delete(':id')
56
+ @Web.delete(':id', { summary: $locale('<%=argv.resourceNameCapitalize%>Delete') })
53
57
  @Api.body(z.null())
54
58
  @Passport.systemAdmin()
55
59
  async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
56
60
  await this.scope.service.<%=argv.resourceName%>.delete(id);
57
61
  }
62
+
63
+ @Web.post('bulk/delete', { summary: $locale('BulkDelete') })
64
+ @Api.body(z.null())
65
+ @Passport.systemAdmin()
66
+ async deleteBulk(@Arg.body() command: Dto<%=argv.resourceNameCapitalize%>DeleteBulk): Promise<void> {
67
+ await this.scope.service.<%=argv.resourceName%>.deleteBulk(command.ids);
68
+ }
58
69
  }
@@ -0,0 +1,14 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
3
+
4
+ import { Api, v } from 'vona-module-a-openapiutils';
5
+ import { Dto } from 'vona-module-a-web';
6
+ import { z } from 'zod';
7
+
8
+ export interface IDtoOptions<%=argv.resourceNameCapitalize%>DeleteBulk extends IDecoratorDtoOptions {}
9
+
10
+ @Dto<IDtoOptions<%=argv.resourceNameCapitalize%>DeleteBulk>()
11
+ export class Dto<%=argv.resourceNameCapitalize%>DeleteBulk {
12
+ @Api.field(v.required(), z.array(v.tableIdentity()()).min(1).max(100))
13
+ ids: TableIdentity[];
14
+ }
@@ -38,7 +38,13 @@ export interface IDtoOptions<%=argv.resourceNameCapitalize%>SelectResItem extend
38
38
  ],
39
39
  }),
40
40
  ZovaRender.block('basic-page:blockToolbarBulk', {
41
- actions: [ZovaRender.tableActionBulk('basic-table:actionCreate')],
41
+ actions: [
42
+ ZovaRender.tableActionBulk('basic-table:actionCreate'),
43
+ ZovaRender.tableActionBulk('basic-table:actionDeleteBulk', {
44
+ requiresSelection: true,
45
+ selectedMaxIds: 100,
46
+ }),
47
+ ],
42
48
  }),
43
49
  ZovaRender.block('basic-page:blockTable'),
44
50
  ZovaRender.block('basic-page:blockPager'),
@@ -3,6 +3,7 @@ import type { IQueryParams } from 'vona-module-a-orm';
3
3
 
4
4
  import { BeanBase } from 'vona';
5
5
  import { Service } from 'vona-module-a-bean';
6
+ import { Core } from 'vona-module-a-core';
6
7
 
7
8
  import type { Dto<%=argv.resourceNameCapitalize%>Create } from '../dto/<%=argv.resourceName%>Create.tsx';
8
9
  import type { Dto<%=argv.resourceNameCapitalize%>SelectRes } from '../dto/<%=argv.resourceName%>SelectRes.tsx';
@@ -32,4 +33,9 @@ export class Service<%=argv.resourceNameCapitalize%> extends BeanBase {
32
33
  async delete(id: TableIdentity) {
33
34
  return await this.scope.model.<%=argv.resourceName%>.deleteById(id);
34
35
  }
36
+
37
+ @Core.transaction()
38
+ async deleteBulk(ids: TableIdentity[]): Promise<void> {
39
+ await this.scope.model.<%=argv.resourceName%>.deleteBulk(ids);
40
+ }
35
41
  }
@@ -9,6 +9,7 @@ const __resources = {
9
9
  Name: 'Name',
10
10
  Description: 'Description',
11
11
  Operations: 'Operations',
12
+ BulkDelete: 'Bulk Delete',
12
13
  };
13
14
 
14
15
  declare module '@cabloy/cli' {
@@ -30,6 +31,16 @@ export default metadataCustomSnippet({
30
31
  return fs.readFileSync(targetFile).toString('utf8');
31
32
  },
32
33
  async transform({ ast, argv }) {
33
- return locale_transform({ ast, argv, resources: __resources });
34
+ const resource = argv.resourceNameCapitalize;
35
+ const resources = {
36
+ ...__resources,
37
+ [`${resource}Controller`]: `${resource} Management`,
38
+ [`${resource}Create`]: `Create ${resource}`,
39
+ [`${resource}Select`]: `Query ${resource} List`,
40
+ [`${resource}View`]: `View ${resource}`,
41
+ [`${resource}Update`]: `Update ${resource}`,
42
+ [`${resource}Delete`]: `Delete ${resource}`,
43
+ };
44
+ return locale_transform({ ast, argv, resources });
34
45
  },
35
46
  });
@@ -7,12 +7,23 @@ const __resources = {
7
7
  Name: '名称',
8
8
  Description: '描述',
9
9
  Operations: '操作',
10
+ BulkDelete: '批量删除',
10
11
  };
11
12
 
12
13
  export default metadataCustomSnippet({
13
14
  file: 'src/config/locale/zh-cn.ts',
14
15
  language: 'plain',
15
16
  async transform({ ast, argv }) {
16
- return locale_transform({ ast, argv, resources: __resources });
17
+ const resource = argv.resourceNameCapitalize;
18
+ const resources = {
19
+ ...__resources,
20
+ [`${resource}Controller`]: `${resource}管理`,
21
+ [`${resource}Create`]: `创建${resource}`,
22
+ [`${resource}Select`]: `查询${resource}列表`,
23
+ [`${resource}View`]: `查看${resource}`,
24
+ [`${resource}Update`]: `更新${resource}`,
25
+ [`${resource}Delete`]: `删除${resource}`,
26
+ };
27
+ return locale_transform({ ast, argv, resources });
17
28
  },
18
29
  });
@@ -1,5 +1,6 @@
1
1
  import type { TableIdentity } from 'table-identity';
2
2
  import type { IQueryParams } from 'vona-module-a-orm';
3
+ import type { IRbacScopeAccess } from 'vona-module-a-rbac';
3
4
  import type { IDecoratorControllerOptions } from 'vona-module-a-web';
4
5
 
5
6
  import { BeanBase } from 'vona';
@@ -12,6 +13,7 @@ import type { Model<%=argv.resourceNameCapitalize%> } from '../model/<%=argv.res
12
13
 
13
14
  import { $locale } from '../.metadata/locales.ts';
14
15
  import { Dto<%=argv.resourceNameCapitalize%>Create } from '../dto/<%=argv.resourceName%>Create.tsx';
16
+ import { Dto<%=argv.resourceNameCapitalize%>DeleteBulk } from '../dto/<%=argv.resourceName%>DeleteBulk.tsx';
15
17
  import { Dto<%=argv.resourceNameCapitalize%>SelectReq } from '../dto/<%=argv.resourceName%>SelectReq.tsx';
16
18
  import { Dto<%=argv.resourceNameCapitalize%>SelectRes } from '../dto/<%=argv.resourceName%>SelectRes.tsx';
17
19
  import { Dto<%=argv.resourceNameCapitalize%>Update } from '../dto/<%=argv.resourceName%>Update.tsx';
@@ -58,4 +60,23 @@ export class Controller<%=argv.resourceNameCapitalize%> extends BeanBase {
58
60
  async delete(@Arg.param('id', v.tableIdentity()) id: TableIdentity): Promise<void> {
59
61
  await this.scope.service.<%=argv.resourceName%>.delete(id);
60
62
  }
63
+
64
+ @Web.post('bulk/delete', { summary: $locale('BulkDelete') })
65
+ @Api.body(z.null())
66
+ @Passport.rbac({ actionInherit: 'delete' })
67
+ async deleteBulk(
68
+ @Arg.body() command: Dto<%=argv.resourceNameCapitalize%>DeleteBulk,
69
+ @Arg.rbacScopeCurrent() rbacScopeCurrent: IRbacScopeAccess,
70
+ ): Promise<void> {
71
+ const entries = await this.bean.rbacResourceBulk.entries(
72
+ command.ids,
73
+ async ids => {
74
+ return await this.scope.model.<%=argv.resourceName%>.select({
75
+ where: rbacScopeCurrent.where({ id: ids }),
76
+ });
77
+ },
78
+ rbacScopeCurrent,
79
+ );
80
+ await this.scope.service.<%=argv.resourceName%>.deleteBulk(entries.map(entry => entry.id));
81
+ }
61
82
  }
@@ -0,0 +1,14 @@
1
+ import type { TableIdentity } from 'table-identity';
2
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
3
+
4
+ import { Api, v } from 'vona-module-a-openapiutils';
5
+ import { Dto } from 'vona-module-a-web';
6
+ import { z } from 'zod';
7
+
8
+ export interface IDtoOptions<%=argv.resourceNameCapitalize%>DeleteBulk extends IDecoratorDtoOptions {}
9
+
10
+ @Dto<IDtoOptions<%=argv.resourceNameCapitalize%>DeleteBulk>()
11
+ export class Dto<%=argv.resourceNameCapitalize%>DeleteBulk {
12
+ @Api.field(v.required(), z.array(v.tableIdentity()()).min(1).max(100))
13
+ ids: TableIdentity[];
14
+ }
@@ -38,7 +38,13 @@ export interface IDtoOptions<%=argv.resourceNameCapitalize%>SelectResItem extend
38
38
  ],
39
39
  }),
40
40
  ZovaRender.block('start-page:blockToolbarBulk', {
41
- actions: [ZovaRender.tableActionBulk('start-table:actionCreate')],
41
+ actions: [
42
+ ZovaRender.tableActionBulk('start-table:actionCreate'),
43
+ ZovaRender.tableActionBulk('start-table:actionDeleteBulk', {
44
+ requiresSelection: true,
45
+ selectedMaxIds: 100,
46
+ }),
47
+ ],
42
48
  }),
43
49
  ZovaRender.block('start-page:blockTable'),
44
50
  ],
@@ -3,6 +3,7 @@ import type { IQueryParams } from 'vona-module-a-orm';
3
3
 
4
4
  import { BeanBase } from 'vona';
5
5
  import { Service } from 'vona-module-a-bean';
6
+ import { Core } from 'vona-module-a-core';
6
7
 
7
8
  import type { Dto<%=argv.resourceNameCapitalize%>Create } from '../dto/<%=argv.resourceName%>Create.tsx';
8
9
  import type { Dto<%=argv.resourceNameCapitalize%>SelectRes } from '../dto/<%=argv.resourceName%>SelectRes.tsx';
@@ -32,4 +33,9 @@ export class Service<%=argv.resourceNameCapitalize%> extends BeanBase {
32
33
  async delete(id: TableIdentity) {
33
34
  return await this.scope.model.<%=argv.resourceName%>.deleteById(id);
34
35
  }
36
+
37
+ @Core.transaction()
38
+ async deleteBulk(ids: TableIdentity[]): Promise<void> {
39
+ await this.scope.model.<%=argv.resourceName%>.deleteBulk(ids);
40
+ }
35
41
  }
@@ -9,6 +9,7 @@ const __resources = {
9
9
  Name: 'Name',
10
10
  Description: 'Description',
11
11
  Operations: 'Operations',
12
+ BulkDelete: 'Bulk Delete',
12
13
  };
13
14
 
14
15
  declare module '@cabloy/cli' {
@@ -7,6 +7,7 @@ const __resources = {
7
7
  Name: '名称',
8
8
  Description: '描述',
9
9
  Operations: '操作',
10
+ BulkDelete: '批量删除',
10
11
  };
11
12
 
12
13
  export default metadataCustomSnippet({
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli-set-api",
3
- "version": "1.1.143",
3
+ "version": "1.1.145",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli-set-api",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona",
3
- "version": "5.1.92",
3
+ "version": "5.1.93",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
6
6
  "keywords": [