cabloy 5.1.246 → 5.1.247

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 (24) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/package.json +1 -1
  4. package/repo-docs/frontend/column-configuration-guide.md +2 -0
  5. package/repo-docs/frontend/table-guide.md +24 -0
  6. package/repo-e2e/specs/cabloy-basic.spec.ts +350 -28
  7. package/zova/packages-zova/zova/package.json +2 -2
  8. package/zova/src/suite/cabloy-basic/modules/basic-date/src/component/dateRange/controller.tsx +4 -3
  9. package/zova/src/suite/cabloy-basic/modules/basic-date/src/component/formFieldDateRange/controller.tsx +13 -1
  10. package/zova/src/suite/cabloy-basic/modules/basic-details/src/component/blockTable/controller.tsx +1 -1
  11. package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +1 -1
  12. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/component/rowOperations.ts +31 -0
  13. package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/index.ts +13 -0
  14. package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionDelete.tsx +6 -2
  15. package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionOperationsRow.tsx +32 -4
  16. package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionUpdate.tsx +6 -2
  17. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/rowOperations/controller.tsx +233 -0
  18. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/controller.tsx +75 -2
  19. package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +40 -22
  20. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/en-us.ts +4 -0
  21. package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/zh-cn.ts +4 -0
  22. package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
  23. package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +19 -11
  24. package/zova/src/suite-vendor/a-zova/package.json +2 -2
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.246
1
+ 5.1.247
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.247
4
+
5
+ ### Improvements
6
+
7
+ - Improve fixed table column behavior on mobile devices.
8
+
3
9
  ## 5.1.246
4
10
 
5
11
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.246",
3
+ "version": "5.1.247",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -29,6 +29,8 @@ A schema can pin a column to the `left`, `center`, or `right` region. The Move U
29
29
 
30
30
  A user cannot change a column's fixed region through Column Configuration. The row-selection column is also a runtime-generated column and is never saved in a personal layout.
31
31
 
32
+ In Cabloy Basic, a table container narrower than `768px` temporarily disables actual column pinning and presents row operations through a compact popover. This does **not** change a column's schema-defined region or the reorder boundaries in Column Configuration. Saved widths, visibility, and order remain canonical; widening the container restores them. Responsive transitions do not save or reset the profile. Direct Basic `ZTable` consumers can set `compactBreakpoint` to another numeric threshold or `false` to disable this automatic presentation. See [Narrow-container behavior in Cabloy Basic](/frontend/table-guide#narrow-container-behavior-in-cabloy-basic).
33
+
32
34
  ## User workflow
33
35
 
34
36
  1. Sign in and open a resource list page.
@@ -162,6 +162,30 @@ Schema metadata remains declarative: author `requiresSelection`, `selectedMaxIds
162
162
 
163
163
  Selection persists while paging the same result universe and during ordinary refetches. It clears on a material fixed-query change, filter submit/reset, sorting, page-size change, resource change, or a successful selection-consuming mutation. Page-size changes also return to page 1.
164
164
 
165
+ ## Narrow-container behavior in Cabloy Basic
166
+
167
+ Cabloy Basic's `ZTable` owns a horizontal scroll container and observes its **available container width**, not the browser viewport or the table's scrollable content width. By default, a container narrower than `768px` uses compact presentation:
168
+
169
+ - all columns, including row selection, temporarily stop being pinned;
170
+ - `basic-table:actionOperationsRow` uses a compact column and a **More actions** popover instead of parallel buttons;
171
+ - row actions retain their permission checks, confirmations, loading, duplicate-click protection, and error handling;
172
+ - widening the container restores schema-defined pinning and the schema or personal-layout widths.
173
+
174
+ This is a presentation override, not a schema or personal-layout mutation. Column visibility, order, and saved widths remain unchanged; resizing does not save or reset a layout. Sorting and selection also remain active.
175
+
176
+ For direct Basic table consumers, configure the threshold or disable automatic compact presentation:
177
+
178
+ ```tsx
179
+ import { ZTable } from 'zova-module-basic-table';
180
+
181
+ <ZTable data={this.students} schema={this.schemaRow} compactBreakpoint={640} />;
182
+ <ZTable data={this.students} schema={this.schemaRow} compactBreakpoint={false} />;
183
+ ```
184
+
185
+ The default resource-page and details-table blocks inherit this behavior. DOM measurement starts after mounting and initial SSR hydration, so server HTML and the hydration-time initial render use the same presentation. The popover uses the browser's native Popover API to escape table overflow clipping; browsers without that API retain the existing inline actions rather than showing an unusable trigger.
186
+
187
+ This automatic container policy belongs to the **Cabloy Basic UI adapter**. Do not assume Cabloy Start has the same presentation or breakpoint merely because the editions share the table engine.
188
+
165
189
  ## Step 3: Let schema metadata drive the default columns
166
190
 
167
191
  In the default path, `ZTable` reads table-scene metadata from the row schema.
@@ -482,6 +482,13 @@ function assertVisualOrder(geometry: IFieldGeometry[]) {
482
482
  }
483
483
  }
484
484
 
485
+ function basicTableWrapper(page: Page) {
486
+ return page
487
+ .locator('div[data-table-compact]')
488
+ .filter({ has: page.locator('table') })
489
+ .first();
490
+ }
491
+
485
492
  async function getDocumentHorizontalOverflow(page: Page) {
486
493
  return page.evaluate(() => {
487
494
  return (
@@ -877,7 +884,15 @@ test(
877
884
  await page.getByRole('link', { name: 'Student', exact: true }).click();
878
885
  await expect(page).toHaveURL(studentResourceUrl);
879
886
  await initialSelect;
880
- await page.setViewportSize({ width: 800, height: 900 });
887
+ // Keep the container above the compact breakpoint while forcing real horizontal overflow.
888
+ const tableWrapper = basicTableWrapper(page);
889
+ await tableWrapper.evaluate(element => {
890
+ element.style.width = '800px';
891
+ });
892
+ await expect(tableWrapper).toHaveAttribute('data-table-compact', 'false');
893
+ expect(await tableWrapper.evaluate(element => element.clientWidth)).toBeGreaterThanOrEqual(
894
+ 768,
895
+ );
881
896
 
882
897
  const nameSort = page.getByRole('button', { name: 'Sort by name', exact: true });
883
898
  const nameHeader = nameSort.locator('xpath=ancestor::th');
@@ -916,10 +931,6 @@ test(
916
931
  const ascendingUrl = new URL(ascending.url());
917
932
  expect(JSON.parse(ascendingUrl.searchParams.get('orders')!)).toEqual([['name', 'asc']]);
918
933
 
919
- const tableWrapper = page
920
- .locator('div.overflow-x-auto')
921
- .filter({ has: page.locator('table') })
922
- .first();
923
934
  await expect(tableWrapper).toBeVisible();
924
935
  const overflow = await tableWrapper.evaluate(element => {
925
936
  return element.scrollWidth > element.clientWidth;
@@ -984,15 +995,228 @@ test(
984
995
  );
985
996
 
986
997
  test(
987
- 'ATP-BASIC-TABLE-03: Training Student row delete handles cancellation, local failures, and retry',
988
- { tag: ['@admin', '@flow'] },
998
+ 'ATP-BASIC-TABLE-04: container resizing compacts row actions and narrow SSR reloads hydrate cleanly',
999
+ { tag: ['@admin', '@flow', '@ssr'] },
989
1000
  async ({ page }) => {
1001
+ test.setTimeout(120_000);
1002
+ await page.setViewportSize({ width: 1440, height: 900 });
1003
+ const pageErrors = collectPageErrors(page);
1004
+ const consoleErrors = collectConsoleErrors(page);
1005
+ const consoleWarnings: string[] = [];
1006
+ page.on('console', message => {
1007
+ if (
1008
+ message.type() === 'warning' ||
1009
+ /hydration.*mismatch|mismatch.*hydration/i.test(message.text())
1010
+ ) {
1011
+ consoleWarnings.push(message.text());
1012
+ }
1013
+ });
1014
+ await loginAsAdmin(page);
1015
+ await withDraftStudents(page, 1, async ([student]) => {
1016
+ const initialSelect = waitForStudentSelect(page);
1017
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
1018
+ await expect(page).toHaveURL(studentResourceUrl);
1019
+ await initialSelect;
1020
+ await page.getByLabel('Student Name').fill(student.name);
1021
+ const filteredResponse = waitForStudentSelect(page);
1022
+ await page.getByRole('button', { name: 'Search', exact: true }).click();
1023
+ await filteredResponse;
1024
+
1025
+ const wrapper = basicTableWrapper(page);
1026
+ const row = wrapper.locator('tbody tr').filter({
1027
+ has: page.getByRole('link', { name: student.name, exact: true }),
1028
+ });
1029
+ const nameHeader = wrapper
1030
+ .getByRole('button', { name: 'Sort by name', exact: true })
1031
+ .locator('xpath=ancestor::th');
1032
+ const operationsHeader = wrapper.getByRole('columnheader', {
1033
+ name: 'Operations',
1034
+ exact: true,
1035
+ });
1036
+ const operationsCell = row.locator('td').last();
1037
+ const more = row.getByRole('button', { name: 'More actions', exact: true });
1038
+ // CSS locators also see the mounted action group while the native popover is closed.
1039
+ const actions = row.locator('[popover="auto"][role="group"][aria-label="Row actions"]');
1040
+ await expect(row).toHaveCount(1);
1041
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'false');
1042
+ await expect(nameHeader).toHaveCSS('position', 'sticky');
1043
+ await expect(operationsHeader).toHaveCSS('min-width', '360px');
1044
+
1045
+ const profileWrites: string[] = [];
1046
+ const observeProfileWrites = (request: Request) => {
1047
+ const path = new URL(request.url()).pathname;
1048
+ if (
1049
+ (path === '/api/layoutprofile/save' || path === '/api/layoutprofile/reset') &&
1050
+ !request.headers()['x-vona-openapi-schema']
1051
+ ) {
1052
+ profileWrites.push(`${request.method()} ${path}`);
1053
+ }
1054
+ };
1055
+ page.on('request', observeProfileWrites);
1056
+ try {
1057
+ // The viewport stays desktop-sized: only the Basic table's own container changes.
1058
+ for (const width of [360, 320]) {
1059
+ await wrapper.evaluate((element, width) => {
1060
+ element.style.width = `${width}px`;
1061
+ }, width);
1062
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'true');
1063
+ await expect.poll(() => wrapper.evaluate(element => element.clientWidth)).toBe(width);
1064
+ expect(page.viewportSize()?.width).toBe(1440);
1065
+ await expect
1066
+ .poll(() =>
1067
+ wrapper
1068
+ .locator('th, td')
1069
+ .evaluateAll(cells =>
1070
+ cells.every(cell => getComputedStyle(cell).position !== 'sticky'),
1071
+ ),
1072
+ )
1073
+ .toBe(true);
1074
+ await expect(operationsHeader).toHaveCSS('min-width', '80px');
1075
+ await expect(operationsCell).toHaveCSS('min-width', '80px');
1076
+ await expect
1077
+ .poll(() =>
1078
+ operationsCell.evaluate(element =>
1079
+ Math.abs(element.getBoundingClientRect().width - 80),
1080
+ ),
1081
+ )
1082
+ .toBeLessThanOrEqual(1);
1083
+ await expect(more).toHaveCount(1);
1084
+ await expect(row.getByRole('button')).toHaveCount(1);
1085
+ await expect(actions).toHaveCount(1);
1086
+ await expect(actions).toBeHidden();
1087
+ await expect(actions.locator('button')).toHaveCount(4);
1088
+ await wrapper.evaluate(element => {
1089
+ element.scrollLeft = element.scrollWidth;
1090
+ });
1091
+ const reach = await wrapper.evaluate(element => ({
1092
+ overflow: element.scrollWidth - element.clientWidth,
1093
+ remaining: element.scrollWidth - element.clientWidth - element.scrollLeft,
1094
+ }));
1095
+ expect(reach.overflow).toBeGreaterThan(0);
1096
+ expect(Math.abs(reach.remaining)).toBeLessThanOrEqual(1);
1097
+ await expect(more).toBeInViewport();
1098
+ }
1099
+
1100
+ // Clip below the first row so a normal descendant menu cannot pass this check.
1101
+ await wrapper.evaluate(element => {
1102
+ const table = element.querySelector('table')!;
1103
+ const row = table.querySelector('tbody tr')!;
1104
+ const height = row.getBoundingClientRect().bottom - table.getBoundingClientRect().top;
1105
+ element.style.height = `${Math.ceil(height)}px`;
1106
+ element.style.overflowY = 'hidden';
1107
+ });
1108
+ await more.click();
1109
+ await expect(actions).toBeVisible();
1110
+ await expect(more).toHaveAttribute('aria-expanded', 'true');
1111
+ for (const label of ['Edit', 'Delete', 'Force Delete', 'Summary']) {
1112
+ const action = actions.getByRole('button', { name: label, exact: true });
1113
+ await expect(action).toBeVisible();
1114
+ await expect(action).toHaveText(label);
1115
+ }
1116
+ const deleteAction = actions.getByRole('button', { name: 'Delete', exact: true });
1117
+ const clipping = await deleteAction.evaluate(element => {
1118
+ const wrapper = element.closest('[data-table-compact]')!;
1119
+ const rect = element.getBoundingClientRect();
1120
+ const clip = wrapper.getBoundingClientRect();
1121
+ const x = rect.left + rect.width / 2;
1122
+ const y = rect.top + rect.height / 2;
1123
+ return {
1124
+ outside: y > clip.bottom || y < clip.top || x < clip.left || x > clip.right,
1125
+ hit: element.contains(document.elementFromPoint(x, y)),
1126
+ };
1127
+ });
1128
+ expect(clipping).toEqual({ outside: true, hit: true });
1129
+ await deleteAction.focus();
1130
+ await page.keyboard.press('Escape');
1131
+ await expect(actions).toBeHidden();
1132
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1133
+ await expect(more).toBeFocused();
1134
+ await more.click();
1135
+ await deleteAction.click();
1136
+ await expect(actions).toBeHidden();
1137
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1138
+ const confirmDialog = page.getByRole('dialog').filter({
1139
+ hasText: 'Are you sure you want to delete this item?',
1140
+ });
1141
+ await expect(confirmDialog).toBeVisible();
1142
+ await confirmDialog.getByRole('button', { name: 'No', exact: true }).click();
1143
+ await expect(confirmDialog).toHaveCount(0);
1144
+ await expect(row).toHaveCount(1);
1145
+
1146
+ await more.click();
1147
+ await actions.getByRole('button', { name: 'Summary', exact: true }).press('Enter');
1148
+ await expect(actions).toBeHidden();
1149
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1150
+ const summaryDialog = page.getByRole('dialog').filter({
1151
+ has: page.getByRole('heading', { name: 'Summary', exact: true }),
1152
+ });
1153
+ await expect(summaryDialog).toBeVisible();
1154
+ await page.keyboard.press('Escape');
1155
+ await expect(summaryDialog).toHaveCount(0);
1156
+
1157
+ await wrapper.evaluate(element => {
1158
+ element.style.width = '800px';
1159
+ element.style.removeProperty('height');
1160
+ element.style.removeProperty('overflow-y');
1161
+ });
1162
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'false');
1163
+ await expect(nameHeader).toHaveCSS('position', 'sticky');
1164
+ await expect(operationsHeader).toHaveCSS('position', 'sticky');
1165
+ await expect(operationsHeader).toHaveCSS('min-width', '360px');
1166
+ await expect(operationsCell).toHaveCSS('min-width', '360px');
1167
+ await expect(more).toHaveCount(0);
1168
+ await expect(row.getByRole('button')).toHaveCount(4);
1169
+ expect(profileWrites).toEqual([]);
1170
+
1171
+ for (const width of [320, 390]) {
1172
+ await page.setViewportSize({ width, height: 900 });
1173
+ const response = await page.reload({ waitUntil: 'load' });
1174
+ expect(response?.ok()).toBeTruthy();
1175
+ await expect(page.locator('html')).toHaveAttribute('data-zova-hydrated', 'admin');
1176
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'true');
1177
+ await expect.poll(() => getDocumentHorizontalOverflow(page)).toBeLessThanOrEqual(1);
1178
+ await expect(
1179
+ wrapper
1180
+ .locator('tbody tr')
1181
+ .first()
1182
+ .getByRole('button', { name: 'More actions', exact: true }),
1183
+ ).toHaveCount(1);
1184
+ }
1185
+ // Closing the cached Admin tab must unmount the table without ref teardown errors.
1186
+ await page.setViewportSize({ width: 1440, height: 900 });
1187
+ const studentTab = page.getByRole('tab', { name: 'Student', exact: true });
1188
+ await studentTab.hover();
1189
+ await studentTab.locator('.tab-close').click();
1190
+ await expect(studentTab).toHaveCount(0);
1191
+ await expect(wrapper).toHaveCount(0);
1192
+ const reopenedSelect = waitForStudentSelect(page);
1193
+ await page.getByRole('link', { name: 'Student', exact: true }).click();
1194
+ await reopenedSelect;
1195
+ await expect(wrapper).toBeVisible();
1196
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'false');
1197
+ expect(profileWrites).toEqual([]);
1198
+ expect(consoleWarnings).toEqual([]);
1199
+ expect(consoleErrors).toEqual([]);
1200
+ expect(pageErrors).toEqual([]);
1201
+ } finally {
1202
+ page.off('request', observeProfileWrites);
1203
+ }
1204
+ });
1205
+ },
1206
+ );
1207
+
1208
+ test(
1209
+ 'ATP-BASIC-TABLE-03: compact row delete handles cancellation, local failures, and retry',
1210
+ { tag: ['@admin', '@flow'] },
1211
+ async ({ page, request }) => {
990
1212
  test.setTimeout(120_000);
991
1213
  page.setDefaultTimeout(10_000);
992
1214
  await page.setViewportSize({ width: 1440, height: 900 });
993
1215
  const pageErrors = collectPageErrors(page);
994
1216
  await loginAsAdmin(page);
995
1217
 
1218
+ let scenarioError: unknown;
1219
+ let cleanupError: unknown;
996
1220
  const studentName = `Row Delete E2E ${Date.now()}`;
997
1221
  const accessToken = (await page.context().cookies()).find(
998
1222
  cookie => cookie.name === 'token',
@@ -1019,14 +1243,30 @@ test(
1019
1243
  await page.getByRole('button', { name: 'Search', exact: true }).click();
1020
1244
  await filteredResponse;
1021
1245
 
1022
- const row = page.locator('tbody tr').filter({
1246
+ await page.setViewportSize({ width: 390, height: 900 });
1247
+ const wrapper = basicTableWrapper(page);
1248
+ await expect(wrapper).toHaveAttribute('data-table-compact', 'true');
1249
+ const row = wrapper.locator('tbody tr').filter({
1023
1250
  has: page.getByRole('link', { name: studentName, exact: true }),
1024
1251
  });
1025
- const deleteButton = row.locator('button.btn-outline.btn-error').first();
1252
+ const more = row.getByRole('button', { name: 'More actions', exact: true });
1253
+ const actions = row.locator('[popover="auto"][role="group"][aria-label="Row actions"]');
1254
+ const deleteButton = actions.getByRole('button', {
1255
+ name: 'Delete',
1256
+ exact: true,
1257
+ includeHidden: true,
1258
+ });
1259
+ const openActions = async () => {
1260
+ if (!(await actions.isVisible())) await more.click();
1261
+ await expect(actions).toBeVisible();
1262
+ await expect(actions.locator('button')).toHaveCount(4);
1263
+ };
1026
1264
  const confirmDialog = page.getByRole('dialog').filter({
1027
1265
  hasText: 'Are you sure you want to delete this item?',
1028
1266
  });
1029
1267
  await expect(row).toHaveCount(1);
1268
+ await expect(actions).toBeHidden();
1269
+ await openActions();
1030
1270
  await expect(deleteButton).toBeVisible();
1031
1271
 
1032
1272
  let cancelledRequestCount = 0;
@@ -1043,10 +1283,15 @@ test(
1043
1283
  page.on('request', cancelledRequestListener);
1044
1284
  try {
1045
1285
  await deleteButton.click();
1286
+ await expect(actions).toBeHidden();
1287
+ await expect(more).toHaveAttribute('aria-expanded', 'false');
1046
1288
  await expect(confirmDialog).toBeVisible();
1047
1289
  await confirmDialog.getByRole('button', { name: 'No', exact: true }).click();
1048
1290
  await expect(confirmDialog).toHaveCount(0);
1049
1291
  await expect(row).toHaveCount(1);
1292
+ await openActions();
1293
+ await expect(deleteButton).toBeEnabled();
1294
+ await expect(deleteButton).not.toHaveAttribute('aria-busy', 'true');
1050
1295
  expect(cancelledRequestCount).toBe(0);
1051
1296
  } finally {
1052
1297
  page.off('request', cancelledRequestListener);
@@ -1058,25 +1303,59 @@ test(
1058
1303
  releaseFailedDelete = resolve;
1059
1304
  });
1060
1305
  const deleteRoute = (url: URL) => url.pathname === `/api/training/student/${studentId}`;
1061
- const failedDeleteHandler = async (route: Route) => {
1306
+ const failedDeleteErrors: unknown[] = [];
1307
+ const pendingFailedDeletes: Promise<void>[] = [];
1308
+ const failedDeleteHandler = (route: Route) => {
1062
1309
  failureRequestCount++;
1063
- await failedDeleteHeld;
1064
- await route.fulfill({
1065
- status: 500,
1066
- contentType: 'application/json',
1067
- body: JSON.stringify({ code: 500, message: 'Row deletion temporarily unavailable' }),
1310
+ const handled = (async () => {
1311
+ await failedDeleteHeld;
1312
+ await route.fulfill({
1313
+ status: 500,
1314
+ contentType: 'application/json',
1315
+ body: JSON.stringify({ code: 500, message: 'Row deletion temporarily unavailable' }),
1316
+ });
1317
+ })().catch(error => {
1318
+ // Report owned route failures after draining, not as detached Playwright errors.
1319
+ failedDeleteErrors.push(error);
1068
1320
  });
1321
+ pendingFailedDeletes.push(handled);
1322
+ return handled;
1069
1323
  };
1070
1324
  await page.route(deleteRoute, failedDeleteHandler);
1325
+ let failureScenarioError: unknown;
1071
1326
  try {
1072
1327
  await deleteButton.click();
1073
1328
  await confirmDialog.getByRole('button', { name: 'Yes', exact: true }).click();
1074
1329
  await expect.poll(() => failureRequestCount).toBe(1);
1330
+ await expect(confirmDialog).toHaveCount(0);
1331
+ await openActions();
1075
1332
  await expect(deleteButton).toBeDisabled();
1076
1333
  await expect(deleteButton).toHaveAttribute('aria-busy', 'true');
1077
1334
  await expect(deleteButton.locator('.loading.loading-spinner')).toHaveCount(1);
1078
- await deleteButton.click({ force: true });
1079
- await expect.poll(() => failureRequestCount).toBe(1);
1335
+ const pendingDelete = await deleteButton.elementHandle();
1336
+ expect(pendingDelete).not.toBeNull();
1337
+ try {
1338
+ await page.keyboard.press('Escape');
1339
+ await expect(actions).toBeHidden();
1340
+ await expect(more).toBeFocused();
1341
+ // Closed native popovers hide, rather than replace, the running action component.
1342
+ expect(await pendingDelete!.evaluate(element => element.isConnected)).toBe(true);
1343
+ await expect(deleteButton).toBeDisabled();
1344
+ await expect(deleteButton).toHaveAttribute('aria-busy', 'true');
1345
+ await openActions();
1346
+ expect(
1347
+ await deleteButton.evaluate((element, pending) => element === pending, pendingDelete),
1348
+ ).toBe(true);
1349
+ await expect(deleteButton).toBeDisabled();
1350
+ await expect(deleteButton.locator('.loading.loading-spinner')).toHaveCount(1);
1351
+ await deleteButton.click({ force: true });
1352
+ expect(failureRequestCount).toBe(1);
1353
+ await page.keyboard.press('Escape');
1354
+ await expect(actions).toBeHidden();
1355
+ await expect(more).toBeFocused();
1356
+ } finally {
1357
+ await pendingDelete?.dispose();
1358
+ }
1080
1359
 
1081
1360
  releaseFailedDelete?.();
1082
1361
  const errorDialog = page.getByRole('dialog').filter({
@@ -1090,10 +1369,37 @@ test(
1090
1369
  await expect(deleteButton).toBeEnabled();
1091
1370
  await expect(deleteButton).not.toHaveAttribute('aria-busy', 'true');
1092
1371
  await expect(deleteButton.locator('.loading.loading-spinner')).toHaveCount(0);
1372
+ await openActions();
1373
+ await expect(deleteButton).toBeEnabled();
1374
+ await expect(deleteButton).not.toHaveAttribute('aria-busy', 'true');
1375
+ await page.keyboard.press('Escape');
1376
+ await expect(actions).toBeHidden();
1377
+ await openActions();
1378
+ await expect(deleteButton).toBeEnabled();
1379
+ expect(failureRequestCount).toBe(1);
1380
+ } catch (error) {
1381
+ failureScenarioError = error;
1093
1382
  } finally {
1094
1383
  releaseFailedDelete?.();
1095
- await page.unroute(deleteRoute, failedDeleteHandler);
1384
+ // A held route must finish before unroute can let that request continue.
1385
+ for (let index = 0; index < pendingFailedDeletes.length; index++) {
1386
+ await pendingFailedDeletes[index];
1387
+ }
1388
+ try {
1389
+ await page.unroute(deleteRoute, failedDeleteHandler);
1390
+ } catch (error) {
1391
+ failedDeleteErrors.push(error);
1392
+ }
1096
1393
  }
1394
+ if (failedDeleteErrors.length) {
1395
+ throw new AggregateError(
1396
+ failureScenarioError === undefined
1397
+ ? failedDeleteErrors
1398
+ : [failureScenarioError, ...failedDeleteErrors],
1399
+ 'Compact row delete assertion or route cleanup failed',
1400
+ );
1401
+ }
1402
+ if (failureScenarioError !== undefined) throw failureScenarioError;
1097
1403
 
1098
1404
  const deleteRequest = page.waitForRequest(request => {
1099
1405
  const url = new URL(request.url());
@@ -1123,21 +1429,37 @@ test(
1123
1429
  expect(refetch.ok()).toBeTruthy();
1124
1430
  await expect(row).toHaveCount(0);
1125
1431
  expect(pageErrors).toEqual([]);
1432
+ } catch (error) {
1433
+ scenarioError = error;
1126
1434
  } finally {
1127
1435
  if (studentId !== undefined) {
1128
- const deleteResponse = await page.request.delete(
1129
- `/api/training/student/deleteForce/${studentId}`,
1130
- {
1131
- headers,
1132
- timeout: 10_000,
1133
- },
1436
+ try {
1437
+ const deleteResponse = await request.delete(
1438
+ `/api/training/student/deleteForce/${studentId}`,
1439
+ {
1440
+ headers,
1441
+ timeout: 10_000,
1442
+ },
1443
+ );
1444
+ expect(
1445
+ deleteResponse.ok() || deleteResponse.status() === 404,
1446
+ `Failed to clean up Training Student ${studentName} (${studentId}): ${deleteResponse.status()}`,
1447
+ ).toBeTruthy();
1448
+ } catch (error) {
1449
+ cleanupError = error;
1450
+ }
1451
+ }
1452
+ }
1453
+ if (cleanupError !== undefined) {
1454
+ if (scenarioError !== undefined) {
1455
+ throw new AggregateError(
1456
+ [scenarioError, cleanupError],
1457
+ 'Compact row delete assertion and owned fixture cleanup failed',
1134
1458
  );
1135
- expect(
1136
- deleteResponse.ok() || deleteResponse.status() === 404,
1137
- `Failed to clean up Training Student ${studentName} (${studentId}): ${deleteResponse.status()}`,
1138
- ).toBeTruthy();
1139
1459
  }
1460
+ throw cleanupError;
1140
1461
  }
1462
+ if (scenarioError !== undefined) throw scenarioError;
1141
1463
  },
1142
1464
  );
1143
1465
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zova",
3
- "version": "5.1.177",
3
+ "version": "5.1.178",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A vue3 framework with ioc",
6
6
  "keywords": [
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "zova-core": "^5.1.95",
49
- "zova-suite-a-zova": "^5.1.176"
49
+ "zova-suite-a-zova": "^5.1.177"
50
50
  },
51
51
  "devDependencies": {
52
52
  "clean-package": "^2.2.0",
@@ -21,6 +21,7 @@ export class ControllerDateRange extends BeanControllerBase {
21
21
  this.modelValue = this.$useModel('modelValue');
22
22
  this.cSeparator = this.$style({
23
23
  width: '20px',
24
+ flexShrink: 0,
24
25
  display: 'inline-block',
25
26
  textAlign: 'center',
26
27
  });
@@ -29,9 +30,9 @@ export class ControllerDateRange extends BeanControllerBase {
29
30
  protected render() {
30
31
  const [dateStartStr, dateEndStr] = this._parseValue(this.modelValue);
31
32
  return (
32
- <div>
33
+ <div class="inline-flex min-w-0 max-w-full items-center">
33
34
  <input
34
- style={{ width: '130px' }}
35
+ style={{ width: '130px', minWidth: 0 }}
35
36
  type="date"
36
37
  value={dateStartStr}
37
38
  onInput={e => {
@@ -41,7 +42,7 @@ export class ControllerDateRange extends BeanControllerBase {
41
42
  />
42
43
  <div class={this.cSeparator}>{this.$props.separator}</div>
43
44
  <input
44
- style={{ width: '130px' }}
45
+ style={{ width: '130px', minWidth: 0 }}
45
46
  type="date"
46
47
  value={dateEndStr}
47
48
  onInput={e => {
@@ -30,7 +30,19 @@ export class ControllerFormFieldDateRange extends BeanControllerBase {
30
30
  cContainer: string;
31
31
 
32
32
  protected async __init__() {
33
- this.cContainer = this.$style({ width: 'auto' });
33
+ this.cContainer = this.$style({
34
+ width: 'auto',
35
+ maxWidth: '100%',
36
+ $nest: {
37
+ '&.input': {
38
+ height: 'auto',
39
+ minHeight: 'var(--size)',
40
+ flexWrap: 'wrap',
41
+ paddingTop: '0.5rem',
42
+ paddingBottom: '0.5rem',
43
+ },
44
+ },
45
+ });
34
46
  }
35
47
 
36
48
  protected render() {
@@ -17,7 +17,7 @@ export interface ControllerBlockTableProps extends IResourceBlockOptionsBase {}
17
17
  @Controller()
18
18
  export class ControllerBlockTable<TData extends {} = {}> extends BeanControllerBase {
19
19
  static $propsDefault = {
20
- class: 'overflow-x-auto rounded-box border border-base-content/15 bg-base-100',
20
+ class: 'min-w-0 rounded-box border border-base-content/15 bg-base-100',
21
21
  };
22
22
 
23
23
  static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
@@ -17,7 +17,7 @@ export interface ControllerBlockTableProps extends IResourceBlockOptionsBase {}
17
17
  @Controller()
18
18
  export class ControllerBlockTable<TData extends {} = {}> extends BeanControllerBase {
19
19
  static $propsDefault = {
20
- class: 'overflow-x-auto rounded-box border border-base-content/15 bg-base-100',
20
+ class: 'min-w-0 rounded-box border border-base-content/15 bg-base-100',
21
21
  };
22
22
 
23
23
  static $componentOptions: IComponentOptions = { inheritAttrs: false, deepExtendDefault: true };
@@ -0,0 +1,31 @@
1
+ import type { TypeControllerInnerProps } from 'zova';
2
+
3
+ import { defineComponent } from 'vue';
4
+ import { prepareComponentOptions, useController } from 'zova';
5
+
6
+ import type { ControllerRowOperationsProps } from '../../component/rowOperations/controller.tsx';
7
+
8
+ import { ControllerRowOperations } from '../../component/rowOperations/controller.tsx';
9
+ export type ZRowOperationsProps = {
10
+ controllerRef?: (ref: ControllerRowOperations) => void;
11
+ } & ControllerRowOperationsProps;
12
+
13
+ type ControllerInnerProps = TypeControllerInnerProps<
14
+ ControllerRowOperationsProps,
15
+ keyof typeof ControllerRowOperations.$propsDefault
16
+ >;
17
+ declare module 'zova-module-basic-table' {
18
+ export interface ControllerRowOperations {
19
+ $props: ControllerInnerProps;
20
+ }
21
+ }
22
+
23
+ export const ZRowOperations = defineComponent((_props: ZRowOperationsProps) => {
24
+ useController(ControllerRowOperations, undefined, undefined);
25
+ return () => {};
26
+ }, prepareComponentOptions());
27
+ declare module 'zova-module-a-bean' {
28
+ export interface IVonaComponentRecord {
29
+ 'basic-table:rowOperations': ControllerRowOperationsProps;
30
+ }
31
+ }