@antelopejs/dms 0.7.19 → 0.7.21
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/CHANGELOG.md +35 -0
- package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
- package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +11 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +70 -8
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +93 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +3 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +18 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +43 -54
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
- package/frontend-vue/tests/row-side-panel.test.ts +93 -2
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +117 -0
- package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
- package/frontend-vue/tests/table-view-record-link.test.ts +207 -0
- package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
- package/package.json +2 -2
|
@@ -27,3 +27,21 @@ export function withoutQueryParam<Query extends Record<string, unknown>>(
|
|
|
27
27
|
const { [param]: _removed, ...rest } = query;
|
|
28
28
|
return rest as Query;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** The query of a URL, absolute or relative to the page, one value per key. */
|
|
32
|
+
export function queryOfUrl(url: string): Record<string, string> {
|
|
33
|
+
return Object.fromEntries(new URL(url, "http://dms.invalid").searchParams);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The query the browser shows, else the route's on a server render. A table
|
|
38
|
+
* writes some keys in place (the open `?record=`, removed on close), so the
|
|
39
|
+
* route, which follows the last visit, can still hold a key the browser no
|
|
40
|
+
* longer shows: a visit rebuilt from it would bring the key back.
|
|
41
|
+
*/
|
|
42
|
+
export function currentQuery(
|
|
43
|
+
routeQuery: Record<string, unknown>,
|
|
44
|
+
): Record<string, unknown> {
|
|
45
|
+
if (typeof window === "undefined") return routeQuery;
|
|
46
|
+
return queryOfUrl(window.location.href);
|
|
47
|
+
}
|
|
@@ -183,6 +183,35 @@ export interface UrlKeyReading {
|
|
|
183
183
|
ignoredShortKey?: boolean;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The key this table writes `key` (`tab`, `record`) under: the short one on
|
|
188
|
+
* its page's only table, else prefixed with its id (`content.tab`).
|
|
189
|
+
*/
|
|
190
|
+
export function tableUrlKey(scope: TableUrlScope, key: string): string {
|
|
191
|
+
return scope.isSoleTableView || !scope.tableId
|
|
192
|
+
? key
|
|
193
|
+
: `${scope.tableId}.${key}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Writes (or, without a value, removes) `key` for this table in the browser's
|
|
198
|
+
* URL in place: no visit, no history entry. Returns the URL written, or
|
|
199
|
+
* `undefined` on a server render.
|
|
200
|
+
*/
|
|
201
|
+
export function writeTableUrlKey(
|
|
202
|
+
scope: TableUrlScope,
|
|
203
|
+
key: string,
|
|
204
|
+
value?: string,
|
|
205
|
+
): URL | undefined {
|
|
206
|
+
if (typeof window === "undefined") return undefined;
|
|
207
|
+
const url = new URL(window.location.href);
|
|
208
|
+
const scopedKey = tableUrlKey(scope, key);
|
|
209
|
+
if (value) url.searchParams.set(scopedKey, value);
|
|
210
|
+
else url.searchParams.delete(scopedKey);
|
|
211
|
+
window.history.replaceState(window.history.state, "", url);
|
|
212
|
+
return url;
|
|
213
|
+
}
|
|
214
|
+
|
|
186
215
|
/**
|
|
187
216
|
* The value the URL gives `key` (`view`, `tab`) for this table: under its
|
|
188
217
|
* prefixed key (`content.view`) or, when it is its page's only table, the
|
|
@@ -78,12 +78,8 @@ import { selectedRowIds } from "../../build/composables/table-view/utils/bulkAct
|
|
|
78
78
|
import { isBulkAction } from "../../build/composables/actions/bulkSelection";
|
|
79
79
|
import { selectionScopeKey } from "../../build/utils/selectionScope";
|
|
80
80
|
import {
|
|
81
|
-
type DeepLinkOutcome,
|
|
82
|
-
deepLinkOutcome,
|
|
83
81
|
isDeepLinkAction,
|
|
84
|
-
readRecordId,
|
|
85
82
|
resolveDeepLinkedRow,
|
|
86
|
-
writeRecordId,
|
|
87
83
|
} from "../../build/composables/table-view/utils/recordLink";
|
|
88
84
|
import {
|
|
89
85
|
rowSidePanelOwnerKey,
|
|
@@ -119,6 +115,7 @@ import type {
|
|
|
119
115
|
TableViewSwitcherItem,
|
|
120
116
|
} from "../../build/components/table/Table.vue";
|
|
121
117
|
import {
|
|
118
|
+
currentQuery,
|
|
122
119
|
visibleUrlFilters,
|
|
123
120
|
withoutQueryParam,
|
|
124
121
|
} from "../../build/composables/table-view/utils/urlFilters";
|
|
@@ -155,6 +152,7 @@ import {
|
|
|
155
152
|
readTableUrlTab,
|
|
156
153
|
useTableUrlTab,
|
|
157
154
|
} from "../../build/composables/table-view/useTableUrlTab";
|
|
155
|
+
import { useTableRecordLink } from "../../build/composables/table-view/useTableRecordLink";
|
|
158
156
|
import TableViews, {
|
|
159
157
|
type TableViewItem,
|
|
160
158
|
} from "../../build/components/table/Views.vue";
|
|
@@ -269,6 +267,11 @@ const kanbanGroupByOptions = kanbanEligibleColumns.map((column) => ({
|
|
|
269
267
|
const { displays: registeredDisplays, getById } = useTableViewDisplays();
|
|
270
268
|
|
|
271
269
|
const offeredDisplayIds = new Set(resolvedDisplays.map((d) => d.id));
|
|
270
|
+
// Where a stale or unavailable choice lands: the grid, unless the table view
|
|
271
|
+
// drops it, then its default display.
|
|
272
|
+
const fallbackDisplayId = offeredDisplayIds.has(TABLE_DISPLAY_ID)
|
|
273
|
+
? TABLE_DISPLAY_ID
|
|
274
|
+
: defaultDisplay;
|
|
272
275
|
const resolvedDisplay = (id: string): TableViewDisplayConfig | undefined =>
|
|
273
276
|
resolvedDisplays.find((d) => d.id === id);
|
|
274
277
|
const optionsForDisplay = (id: string): Record<string, unknown> | undefined =>
|
|
@@ -295,7 +298,7 @@ const activeDisplayId = ref<string>(
|
|
|
295
298
|
? defaultDisplay
|
|
296
299
|
: offeredDisplayIds.has(persistedDisplay)
|
|
297
300
|
? persistedDisplay
|
|
298
|
-
:
|
|
301
|
+
: fallbackDisplayId,
|
|
299
302
|
);
|
|
300
303
|
|
|
301
304
|
/** A display the backend config renders by itself, with no client plugin. */
|
|
@@ -411,9 +414,11 @@ const shownUrlFilters = computed<TableUrlFilter[]>(() =>
|
|
|
411
414
|
visibleUrlFilters(queryParamFilters, route.query as Record<string, unknown>),
|
|
412
415
|
);
|
|
413
416
|
|
|
417
|
+
// Rebuilt from the URL the browser shows: a `?record=` a closed drawer
|
|
418
|
+
// removed in place must not come back with the visit.
|
|
414
419
|
const removeUrlFilter = (param: string) =>
|
|
415
420
|
router.replace({
|
|
416
|
-
query: withoutQueryParam(route.query, param),
|
|
421
|
+
query: withoutQueryParam(currentQuery(route.query), param),
|
|
417
422
|
});
|
|
418
423
|
|
|
419
424
|
const routeParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
@@ -587,7 +592,7 @@ const {
|
|
|
587
592
|
),
|
|
588
593
|
display: offeredDisplayIds.has(defaultDisplay)
|
|
589
594
|
? defaultDisplay
|
|
590
|
-
:
|
|
595
|
+
: fallbackDisplayId,
|
|
591
596
|
density: props.density ?? DEFAULT_DENSITY,
|
|
592
597
|
displays: offeredDisplayIds,
|
|
593
598
|
},
|
|
@@ -1296,8 +1301,10 @@ const {
|
|
|
1296
1301
|
rowNavigation: {
|
|
1297
1302
|
rows: () => (shownResults.value ?? []) as Data[],
|
|
1298
1303
|
rowIdKey: props.rowIdKey ?? ROW_ID_DEFAULT_KEY,
|
|
1304
|
+
getRow: (id) => tableRows.getRow(id) as Promise<Data | undefined>,
|
|
1299
1305
|
},
|
|
1300
1306
|
recordScope: urlScope,
|
|
1307
|
+
onRecordShown: (id) => recordLink?.shown(id),
|
|
1301
1308
|
});
|
|
1302
1309
|
|
|
1303
1310
|
// Custom actions offered on the selection bar. "Select all N matching"
|
|
@@ -1704,59 +1711,40 @@ watch(rowSelect, (selection) => {
|
|
|
1704
1711
|
const deepLinkAction = (tableProps.value.rowActions?.custom ?? []).find(
|
|
1705
1712
|
isDeepLinkAction,
|
|
1706
1713
|
);
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
};
|
|
1714
|
+
const recordLink = deepLinkAction
|
|
1715
|
+
? useTableRecordLink<T>({
|
|
1716
|
+
route,
|
|
1717
|
+
urlScope,
|
|
1718
|
+
action: deepLinkAction,
|
|
1719
|
+
resolve: async (id) => {
|
|
1720
|
+
await until(isListLoading).toBe(false);
|
|
1721
|
+
return resolveDeepLinkedRow(id, {
|
|
1722
|
+
listed: shownResults.value ?? undefined,
|
|
1723
|
+
idOf: getRowId,
|
|
1724
|
+
getRow: tableRows.getRow,
|
|
1725
|
+
});
|
|
1726
|
+
},
|
|
1727
|
+
open: (row) => handleCustomRowAction(deepLinkAction, row as Data),
|
|
1728
|
+
showMissing: (id) => handleMissingRow(deepLinkAction, id),
|
|
1729
|
+
notify: (outcome) => toast.add(recordLinkToasts[outcome]()),
|
|
1730
|
+
})
|
|
1731
|
+
: undefined;
|
|
1719
1732
|
|
|
1720
|
-
const
|
|
1721
|
-
|
|
1722
|
-
toast.add({
|
|
1733
|
+
const recordLinkToasts = {
|
|
1734
|
+
notify: () => ({
|
|
1723
1735
|
title: t("dms.record_link.not_found_title"),
|
|
1724
1736
|
description: t("dms.record_link.not_found_description"),
|
|
1725
1737
|
color: Color.warning,
|
|
1726
1738
|
icon: "i-ph-link-break",
|
|
1727
|
-
})
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
const notifyFailedRecordLink = () => {
|
|
1731
|
-
writeRecordId(urlScope);
|
|
1732
|
-
toast.add({
|
|
1739
|
+
}),
|
|
1740
|
+
notifyFailed: () => ({
|
|
1733
1741
|
title: t("dms.record_link.failed_title"),
|
|
1734
1742
|
description: t("dms.record_link.failed_description"),
|
|
1735
1743
|
color: Color.error,
|
|
1736
1744
|
icon: "i-ph-warning-circle",
|
|
1737
|
-
})
|
|
1738
|
-
};
|
|
1739
|
-
|
|
1740
|
-
const deepLinkOutcomes: Record<
|
|
1741
|
-
DeepLinkOutcome,
|
|
1742
|
-
(action: CustomRowAction, id: string, row: Data) => void
|
|
1743
|
-
> = {
|
|
1744
|
-
open: (action, _id, row) => handleCustomRowAction(action, row),
|
|
1745
|
-
missingPanel: (action, id) => handleMissingRow(action, id),
|
|
1746
|
-
notify: notifyUnresolvedRecordLink,
|
|
1747
|
-
notifyFailed: notifyFailedRecordLink,
|
|
1745
|
+
}),
|
|
1748
1746
|
};
|
|
1749
1747
|
|
|
1750
|
-
// A visit that keeps the page mounted (a link to the same page with another
|
|
1751
|
-
// `?record=`) opens the row it names, as a fresh load would. The table's own
|
|
1752
|
-
// writes replace the URL in place and never reach the route.
|
|
1753
|
-
watch(
|
|
1754
|
-
() => readRecordId(route.query, urlScope),
|
|
1755
|
-
(id, previous) => {
|
|
1756
|
-
if (id && id !== previous) void openRecordFromUrl();
|
|
1757
|
-
},
|
|
1758
|
-
);
|
|
1759
|
-
|
|
1760
1748
|
const selectedIds = computed(() => selectedRowIds(rowSelect.value));
|
|
1761
1749
|
|
|
1762
1750
|
const isRowActionEnabled = (
|
|
@@ -1872,6 +1860,7 @@ const displayContext = computed(
|
|
|
1872
1860
|
items: shownResults.value ?? EMPTY_ITEMS,
|
|
1873
1861
|
columns: props.columns,
|
|
1874
1862
|
loading: isListLoading.value,
|
|
1863
|
+
firstPageLoading: isFirstPageLoading.value,
|
|
1875
1864
|
selection: { ids: selectedIds.value, ...displaySelection },
|
|
1876
1865
|
pagination: {
|
|
1877
1866
|
pageIndex: paginationState.value.pageIndex,
|
|
@@ -2086,17 +2075,16 @@ const checkUrlParameter = () => {
|
|
|
2086
2075
|
|
|
2087
2076
|
onMounted(() => {
|
|
2088
2077
|
checkUrlParameter();
|
|
2089
|
-
void openRecordFromUrl();
|
|
2090
2078
|
void loadRelationQuickFilters();
|
|
2091
2079
|
void refreshLinkTabCounts();
|
|
2092
|
-
// The registry is client-only; once hydrated, degrade to the
|
|
2093
|
-
// the active id is no longer available (e.g. kanban with no eligible column).
|
|
2080
|
+
// The registry is client-only; once hydrated, degrade to the fallback display
|
|
2081
|
+
// if the active id is no longer available (e.g. kanban with no eligible column).
|
|
2094
2082
|
if (
|
|
2095
|
-
activeDisplayId.value !==
|
|
2083
|
+
activeDisplayId.value !== fallbackDisplayId &&
|
|
2096
2084
|
!isConfigRenderedDisplay(activeDisplayId.value) &&
|
|
2097
2085
|
!availableDisplays.value.some((d) => d.id === activeDisplayId.value)
|
|
2098
2086
|
) {
|
|
2099
|
-
activeDisplayId.value =
|
|
2087
|
+
activeDisplayId.value = fallbackDisplayId;
|
|
2100
2088
|
}
|
|
2101
2089
|
});
|
|
2102
2090
|
</script>
|
|
@@ -2142,6 +2130,7 @@ onMounted(() => {
|
|
|
2142
2130
|
:tabs="tabsWithCount"
|
|
2143
2131
|
:initial-column-visibility="initialVisibility"
|
|
2144
2132
|
:loading="isListLoading"
|
|
2133
|
+
:first-page-loading="isFirstPageLoading"
|
|
2145
2134
|
:load-error="listLoadError"
|
|
2146
2135
|
:data="shownResults || []"
|
|
2147
2136
|
:pagination-options="{ manualPagination: true, rowCount: data?.total }"
|
|
@@ -296,6 +296,8 @@ export interface TableViewConfig<T extends Data>
|
|
|
296
296
|
tabs?: TableViewTab[];
|
|
297
297
|
/** Displays this table view offers, beyond the implicit built-in `table`. */
|
|
298
298
|
displays?: TableViewDisplayConfig[];
|
|
299
|
+
/** Whether the built-in `table` display is offered. Defaults to `true`. */
|
|
300
|
+
tableDisplay?: boolean;
|
|
299
301
|
/** Display shown by default when the user has no saved preference. Defaults to "table". */
|
|
300
302
|
defaultDisplay?: string;
|
|
301
303
|
/** Expandable rows: caret column + detail band. */
|
|
@@ -214,7 +214,13 @@ export interface TableViewDisplayContext<T = Record<string, unknown>> {
|
|
|
214
214
|
items: T[];
|
|
215
215
|
/** Full column metadata (types, labels, accessor keys) — for field rendering. */
|
|
216
216
|
columns: TableViewColumn[];
|
|
217
|
+
/** The shared query is in flight: a first read or any re-read. */
|
|
217
218
|
loading: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* No answer has arrived yet: draw a skeleton. A re-read keeps the rows,
|
|
221
|
+
* or the empty state, on screen; swapping either for a skeleton flashes.
|
|
222
|
+
*/
|
|
223
|
+
firstPageLoading: boolean;
|
|
218
224
|
|
|
219
225
|
selection: TableViewDisplaySelection;
|
|
220
226
|
pagination: TableViewDisplayPagination;
|
|
@@ -188,7 +188,12 @@ interface Mounted {
|
|
|
188
188
|
showTable: Ref<boolean>;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
type ReadRow = (id: string) => Promise<Row | undefined>;
|
|
192
|
+
|
|
193
|
+
function mountPanel(
|
|
194
|
+
shownRows: Ref<Row[]> = ref(rows),
|
|
195
|
+
getRow?: ReadRow,
|
|
196
|
+
): Mounted {
|
|
192
197
|
let targets: ReturnType<typeof useActionTargets> | undefined;
|
|
193
198
|
const showTable = ref(true);
|
|
194
199
|
const Table = defineComponent({
|
|
@@ -201,7 +206,11 @@ function mountPanel(shownRows: Ref<Row[]> = ref(rows)): Mounted {
|
|
|
201
206
|
refreshCallback: () => {
|
|
202
207
|
refreshes += 1;
|
|
203
208
|
},
|
|
204
|
-
rowNavigation: {
|
|
209
|
+
rowNavigation: {
|
|
210
|
+
rows: () => shownRows.value,
|
|
211
|
+
rowIdKey: "_id",
|
|
212
|
+
getRow,
|
|
213
|
+
},
|
|
205
214
|
recordScope: { tableId: "requests", isSoleTableView: true },
|
|
206
215
|
});
|
|
207
216
|
return () => h("table");
|
|
@@ -480,6 +489,88 @@ describe("deep link to a row the table does not list", () => {
|
|
|
480
489
|
});
|
|
481
490
|
});
|
|
482
491
|
|
|
492
|
+
describe("showing another row from the panel", () => {
|
|
493
|
+
const older: Row = { _id: "r9", name: "Older" };
|
|
494
|
+
const notFoundError = () =>
|
|
495
|
+
Object.assign(new Error("Not Found"), { statusCode: 404 });
|
|
496
|
+
const showRow = (rowOrId: Row | string) =>
|
|
497
|
+
(
|
|
498
|
+
useRowSidePanelContent().value?.componentOptions.showRow as (
|
|
499
|
+
rowOrId: Row | string,
|
|
500
|
+
) => Promise<void>
|
|
501
|
+
)(rowOrId);
|
|
502
|
+
|
|
503
|
+
it("moves the highlight, the position and ?record= onto a listed row", async () => {
|
|
504
|
+
const { host, targets } = mountPanel();
|
|
505
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
506
|
+
await settle();
|
|
507
|
+
await showRow("r3");
|
|
508
|
+
await settle();
|
|
509
|
+
expect(shownName(host)).toBe("Third");
|
|
510
|
+
expect(position(host)).toBe("side_panel.position(3/3)");
|
|
511
|
+
expect(useRowSidePanelContent().value?.rowKey).toBe("r3");
|
|
512
|
+
expect(window.location.search).toBe("?record=r3");
|
|
513
|
+
await showRow(rows[1]!);
|
|
514
|
+
await settle();
|
|
515
|
+
expect(position(host)).toBe("side_panel.position(2/3)");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("reads an unlisted row by id, shown without a position", async () => {
|
|
519
|
+
const getRow = vi.fn(async () => older);
|
|
520
|
+
const { host, targets } = mountPanel(ref(rows), getRow);
|
|
521
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
522
|
+
await settle();
|
|
523
|
+
await showRow("r9");
|
|
524
|
+
await settle();
|
|
525
|
+
expect(getRow).toHaveBeenCalledWith("r9");
|
|
526
|
+
expect(shownName(host)).toBe("Older");
|
|
527
|
+
expect(position(host)).toBeUndefined();
|
|
528
|
+
expect(useRowSidePanelContent().value?.rowKey).toBe("r9");
|
|
529
|
+
expect(window.location.search).toBe("?record=r9");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("says a row its route answers 404 for is gone", async () => {
|
|
533
|
+
const { host, targets } = mountPanel(ref(rows), async () => {
|
|
534
|
+
throw notFoundError();
|
|
535
|
+
});
|
|
536
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
537
|
+
await settle();
|
|
538
|
+
await showRow("gone");
|
|
539
|
+
await settle();
|
|
540
|
+
expect(query(host, "[data-dms-row-side-panel-not-found]")).not.toBeNull();
|
|
541
|
+
expect(useRowSidePanelContent().value?.rowKey).toBeUndefined();
|
|
542
|
+
expect(window.location.search).toBe("?record=gone");
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("rejects a row it cannot read, keeping the row it shows", async () => {
|
|
546
|
+
const { host, targets } = mountPanel();
|
|
547
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
548
|
+
await settle();
|
|
549
|
+
await expect(showRow("r9")).rejects.toThrow("r9");
|
|
550
|
+
await settle();
|
|
551
|
+
expect(shownName(host)).toBe("First");
|
|
552
|
+
expect(window.location.search).toBe("?record=r1");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("leaves the panel shut when it closed while the row was read", async () => {
|
|
556
|
+
let answer!: (row: Row) => void;
|
|
557
|
+
const { host, targets } = mountPanel(
|
|
558
|
+
ref(rows),
|
|
559
|
+
() => new Promise((resolve) => (answer = resolve)),
|
|
560
|
+
);
|
|
561
|
+
targets.handleCustomRowAction(panelAction(), rows[0]);
|
|
562
|
+
await settle();
|
|
563
|
+
const shown = showRow("r9");
|
|
564
|
+
closeRowSidePanel();
|
|
565
|
+
answer(older);
|
|
566
|
+
await shown;
|
|
567
|
+
await settle();
|
|
568
|
+
expect(useRowSidePanelContent().value).toBeNull();
|
|
569
|
+
expect(query(host, "[data-dms-side-panel]")).toBeNull();
|
|
570
|
+
expect(window.location.search).toBe("");
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
483
574
|
describe("side panel on a missing row", () => {
|
|
484
575
|
const notFound = (host: ParentNode) =>
|
|
485
576
|
query(host, "[data-dms-row-side-panel-not-found]");
|
|
@@ -271,9 +271,11 @@ describe("action targets on a selection and on a listed row", () => {
|
|
|
271
271
|
it("steps a row's drawer through the rows shown and keeps it in the URL", async () => {
|
|
272
272
|
window.history.replaceState(null, "", "/runs");
|
|
273
273
|
const rows = [{ _id: "r1" }, { _id: "r2" }];
|
|
274
|
+
const onRecordShown = vi.fn();
|
|
274
275
|
const targets = mountTargets({
|
|
275
276
|
rowNavigation: { rows: () => rows, rowIdKey: "_id" },
|
|
276
277
|
recordScope: { tableId: "runs", isSoleTableView: true },
|
|
278
|
+
onRecordShown,
|
|
277
279
|
});
|
|
278
280
|
targets.handleCustomRowAction(
|
|
279
281
|
{
|
|
@@ -306,10 +308,125 @@ describe("action targets on a selection and on a listed row", () => {
|
|
|
306
308
|
await nextTick();
|
|
307
309
|
await Promise.resolve();
|
|
308
310
|
expect(window.location.search).toBe("");
|
|
311
|
+
expect(onRecordShown.mock.calls).toEqual([["r1"], ["r2"], [undefined]]);
|
|
309
312
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k" }));
|
|
310
313
|
expect(drawer!.patches).toHaveLength(1);
|
|
311
314
|
});
|
|
312
315
|
|
|
316
|
+
it("shows another row in a row's drawer by id, read when unlisted", async () => {
|
|
317
|
+
window.history.replaceState(null, "", "/runs");
|
|
318
|
+
const rows = [{ _id: "r1" }, { _id: "r2" }];
|
|
319
|
+
const older = { _id: "r9" };
|
|
320
|
+
const getRow = vi.fn(async (id: string) =>
|
|
321
|
+
id === "r9" ? older : undefined,
|
|
322
|
+
);
|
|
323
|
+
const targets = mountTargets({
|
|
324
|
+
rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
|
|
325
|
+
recordScope: { tableId: "runs", isSoleTableView: true },
|
|
326
|
+
});
|
|
327
|
+
targets.handleCustomRowAction(
|
|
328
|
+
{
|
|
329
|
+
label: "Details",
|
|
330
|
+
deepLink: true,
|
|
331
|
+
target: { type: "drawer", component: { componentName: "RunDrawer" } },
|
|
332
|
+
},
|
|
333
|
+
rows[0],
|
|
334
|
+
);
|
|
335
|
+
await vi.waitFor(() => expect(drawers).toHaveLength(1));
|
|
336
|
+
const [drawer] = drawers;
|
|
337
|
+
const options = drawer!.options.componentOptions as Record<string, unknown>;
|
|
338
|
+
const showRow = options.showRow as (row: object | string) => Promise<void>;
|
|
339
|
+
|
|
340
|
+
await showRow("r9");
|
|
341
|
+
expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
|
|
342
|
+
const patched = drawer!.patches[0]!.componentOptions as Record<
|
|
343
|
+
string,
|
|
344
|
+
unknown
|
|
345
|
+
>;
|
|
346
|
+
expect(patched.rowData).toBe(older);
|
|
347
|
+
expect(patched.navigation).toBeUndefined();
|
|
348
|
+
expect(window.location.search).toBe("?record=r9");
|
|
349
|
+
|
|
350
|
+
await showRow("r2");
|
|
351
|
+
expect(getRow).toHaveBeenCalledTimes(1);
|
|
352
|
+
expect(drawer!.patches[1]).toMatchObject({ componentKey: "r2" });
|
|
353
|
+
expect(window.location.search).toBe("?record=r2");
|
|
354
|
+
|
|
355
|
+
await expect(showRow("r404")).rejects.toThrow("r404");
|
|
356
|
+
expect(drawer!.patches).toHaveLength(2);
|
|
357
|
+
drawer!.settle();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const deferredRows = () => {
|
|
361
|
+
const pending = new Map<string, (row: object) => void>();
|
|
362
|
+
const getRow = vi.fn(
|
|
363
|
+
(id: string) =>
|
|
364
|
+
new Promise<object | undefined>((resolve) => pending.set(id, resolve)),
|
|
365
|
+
);
|
|
366
|
+
return { getRow, resolve: (id: string) => pending.get(id)!({ _id: id }) };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const openDeepLinkedDrawer = (getRow: (id: string) => Promise<unknown>) => {
|
|
370
|
+
window.history.replaceState(null, "", "/runs");
|
|
371
|
+
const rows = [{ _id: "r1" }];
|
|
372
|
+
const targets = mountTargets({
|
|
373
|
+
rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
|
|
374
|
+
recordScope: { tableId: "runs", isSoleTableView: true },
|
|
375
|
+
onRecordShown: vi.fn(),
|
|
376
|
+
});
|
|
377
|
+
targets.handleCustomRowAction(
|
|
378
|
+
{
|
|
379
|
+
label: "Details",
|
|
380
|
+
deepLink: true,
|
|
381
|
+
target: { type: "drawer", component: { componentName: "RunDrawer" } },
|
|
382
|
+
},
|
|
383
|
+
rows[0],
|
|
384
|
+
);
|
|
385
|
+
return targets;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const showRowOf = (drawer: (typeof drawers)[number]) =>
|
|
389
|
+
(drawer.options.componentOptions as Record<string, unknown>).showRow as (
|
|
390
|
+
row: object | string,
|
|
391
|
+
) => Promise<void>;
|
|
392
|
+
|
|
393
|
+
it("drops a row read that resolves after its drawer closed", async () => {
|
|
394
|
+
const rows = deferredRows();
|
|
395
|
+
openDeepLinkedDrawer(rows.getRow);
|
|
396
|
+
await vi.waitFor(() => expect(drawers).toHaveLength(1));
|
|
397
|
+
const [drawer] = drawers;
|
|
398
|
+
const shown = showRowOf(drawer!)("r9");
|
|
399
|
+
|
|
400
|
+
drawer!.settle();
|
|
401
|
+
await vi.waitFor(() => expect(window.location.search).toBe(""));
|
|
402
|
+
rows.resolve("r9");
|
|
403
|
+
await shown;
|
|
404
|
+
|
|
405
|
+
expect(window.location.search).toBe("");
|
|
406
|
+
expect(drawer!.patches).toHaveLength(0);
|
|
407
|
+
expect(drawers).toHaveLength(1);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("applies only the latest of two rows read at once", async () => {
|
|
411
|
+
const rows = deferredRows();
|
|
412
|
+
openDeepLinkedDrawer(rows.getRow);
|
|
413
|
+
await vi.waitFor(() => expect(drawers).toHaveLength(1));
|
|
414
|
+
const [drawer] = drawers;
|
|
415
|
+
const showRow = showRowOf(drawer!);
|
|
416
|
+
const first = showRow("r8");
|
|
417
|
+
const second = showRow("r9");
|
|
418
|
+
|
|
419
|
+
rows.resolve("r9");
|
|
420
|
+
await second;
|
|
421
|
+
rows.resolve("r8");
|
|
422
|
+
await first;
|
|
423
|
+
|
|
424
|
+
expect(drawer!.patches).toHaveLength(1);
|
|
425
|
+
expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
|
|
426
|
+
expect(window.location.search).toBe("?record=r9");
|
|
427
|
+
drawer!.settle();
|
|
428
|
+
});
|
|
429
|
+
|
|
313
430
|
it("hands a bulk drawer the selection instead of a row", async () => {
|
|
314
431
|
const targets = mountTargets();
|
|
315
432
|
targets.handleBulkCustomAction(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { computed, createApp, h, reactive, ref, type App } from "vue";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import CardsDisplay from "../layers/dms-ui/app/build/components/table-view/CardsDisplay.vue";
|
|
5
|
+
import type { TableViewDisplayContext } from "../layers/dms-ui/app/composables/table-view/types/display";
|
|
6
|
+
|
|
7
|
+
vi.mock("../layers/dms-ui/app/build/components/table/Pagination.vue", () => ({
|
|
8
|
+
default: { render: () => null },
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("../layers/dms-ui/app/build/components/table/Empty.vue", () => ({
|
|
11
|
+
default: { render: () => h("p", { "data-test": "empty" }) },
|
|
12
|
+
}));
|
|
13
|
+
vi.mock(
|
|
14
|
+
"../layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue",
|
|
15
|
+
() => ({ default: { render: () => h("p", { "data-test": "skeleton" }) } }),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
interface ReadState {
|
|
19
|
+
loading: boolean;
|
|
20
|
+
firstPageLoading: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createContext(state: ReadState) {
|
|
24
|
+
return reactive({
|
|
25
|
+
items: [],
|
|
26
|
+
columns: [],
|
|
27
|
+
...state,
|
|
28
|
+
rowIdKey: "_id",
|
|
29
|
+
labelKey: "name",
|
|
30
|
+
options: {},
|
|
31
|
+
pagination: { pageIndex: 0, pageSize: 10, total: 0 },
|
|
32
|
+
selection: { isSelected: () => false, toggle: () => {} },
|
|
33
|
+
actions: { canAdd: false, open: () => {} },
|
|
34
|
+
}) as unknown as TableViewDisplayContext;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("the cards display of an empty list", () => {
|
|
38
|
+
let app: App | undefined;
|
|
39
|
+
let container: HTMLElement;
|
|
40
|
+
|
|
41
|
+
const mount = (context: TableViewDisplayContext) => {
|
|
42
|
+
app = createApp({ render: () => h(CardsDisplay, { context }) });
|
|
43
|
+
container = document.createElement("div");
|
|
44
|
+
document.body.append(container);
|
|
45
|
+
app.mount(container);
|
|
46
|
+
};
|
|
47
|
+
const shown = (test: string) =>
|
|
48
|
+
container.querySelector(`[data-test="${test}"]`) !== null;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.stubGlobal("computed", computed);
|
|
52
|
+
vi.stubGlobal("h", h);
|
|
53
|
+
vi.stubGlobal("useI18n", () => ({ locale: ref("en"), t: (t: string) => t }));
|
|
54
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: (t: string) => t }));
|
|
55
|
+
vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
|
|
56
|
+
vi.stubGlobal("resolveDmsComponent", () => undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
app?.unmount();
|
|
61
|
+
app = undefined;
|
|
62
|
+
container.remove();
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("draws the skeleton until the first answer arrives", () => {
|
|
67
|
+
mount(createContext({ loading: true, firstPageLoading: true }));
|
|
68
|
+
expect(shown("skeleton")).toBe(true);
|
|
69
|
+
expect(shown("empty")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps the empty state on screen while the list is read again", () => {
|
|
73
|
+
mount(createContext({ loading: true, firstPageLoading: false }));
|
|
74
|
+
expect(shown("empty")).toBe(true);
|
|
75
|
+
expect(shown("skeleton")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|