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.
- package/.cabloy-version +1 -1
- package/CHANGELOG.md +6 -0
- package/package.json +1 -1
- package/repo-docs/frontend/column-configuration-guide.md +2 -0
- package/repo-docs/frontend/table-guide.md +24 -0
- package/repo-e2e/specs/cabloy-basic.spec.ts +350 -28
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/component/dateRange/controller.tsx +4 -3
- package/zova/src/suite/cabloy-basic/modules/basic-date/src/component/formFieldDateRange/controller.tsx +13 -1
- package/zova/src/suite/cabloy-basic/modules/basic-details/src/component/blockTable/controller.tsx +1 -1
- package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx +1 -1
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/component/rowOperations.ts +31 -0
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/index.ts +13 -0
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionDelete.tsx +6 -2
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionOperationsRow.tsx +32 -4
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/bean/tableCell.actionUpdate.tsx +6 -2
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/rowOperations/controller.tsx +233 -0
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/controller.tsx +75 -2
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/component/table/render.tsx +40 -22
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/en-us.ts +4 -0
- package/zova/src/suite/cabloy-basic/modules/basic-table/src/config/locale/zh-cn.ts +4 -0
- package/zova/src/suite-vendor/a-zova/modules/a-table/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-table/src/component/table/controller.tsx +19 -11
- package/zova/src/suite-vendor/a-zova/package.json +2 -2
package/.cabloy-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.1.
|
|
1
|
+
5.1.247
|
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
1306
|
+
const failedDeleteErrors: unknown[] = [];
|
|
1307
|
+
const pendingFailedDeletes: Promise<void>[] = [];
|
|
1308
|
+
const failedDeleteHandler = (route: Route) => {
|
|
1062
1309
|
failureRequestCount++;
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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.
|
|
1079
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"zova-suite-a-zova": "^5.1.177"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"clean-package": "^2.2.0",
|
package/zova/src/suite/cabloy-basic/modules/basic-date/src/component/dateRange/controller.tsx
CHANGED
|
@@ -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({
|
|
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() {
|
package/zova/src/suite/cabloy-basic/modules/basic-details/src/component/blockTable/controller.tsx
CHANGED
|
@@ -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: '
|
|
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 };
|
package/zova/src/suite/cabloy-basic/modules/basic-page/src/component/blockTable/controller.tsx
CHANGED
|
@@ -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: '
|
|
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 };
|
package/zova/src/suite/cabloy-basic/modules/basic-table/src/.metadata/component/rowOperations.ts
ADDED
|
@@ -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
|
+
}
|