@antelopejs/dms 0.7.0 → 0.7.2

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 (110) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +2 -2
  78. package/frontend-vue/pnpm-lock.yaml +5 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createApp,
5
+ defineComponent,
6
+ h,
7
+ nextTick,
8
+ provide,
9
+ ref,
10
+ shallowRef,
11
+ watch,
12
+ type App,
13
+ } from "vue";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import TableActions from "../layers/dms-ui/app/build/components/table/Actions.vue";
16
+ import { FULL_TABLE_CHROME } from "../layers/dms-ui/app/build/composables/table-view/utils/chrome";
17
+
18
+ vi.mock("../layers/dms-ui/app/build/components/table/Menu.vue", () => ({
19
+ default: { render: () => null },
20
+ }));
21
+
22
+ vi.mock("../layers/dms-ui/app/build/components/form/SearchInput.vue", () => ({
23
+ default: { render: () => null },
24
+ }));
25
+
26
+ const PassThrough = defineComponent({
27
+ setup:
28
+ (_, { slots }) =>
29
+ () =>
30
+ h("div", slots.default?.()),
31
+ });
32
+
33
+ const ButtonStub = defineComponent({
34
+ setup:
35
+ (_, { slots }) =>
36
+ () =>
37
+ h("button", slots.default?.()),
38
+ });
39
+
40
+ const InputStub = defineComponent({
41
+ setup: () => () => h("input"),
42
+ });
43
+
44
+ let app: App | undefined;
45
+
46
+ function mountActions(searchPlaceholder: string | undefined, search = "") {
47
+ const Host = defineComponent({
48
+ setup() {
49
+ provide(
50
+ "tableSharedData",
51
+ shallowRef({
52
+ activeCapabilities: ref({
53
+ search: true,
54
+ filters: false,
55
+ sorting: false,
56
+ }),
57
+ labeledColumns: ref([]),
58
+ globalFilterState: ref(search),
59
+ columnFiltersState: ref([]),
60
+ paginationState: ref({ pageIndex: 0, pageSize: 10 }),
61
+ filtersRowOpenState: ref(false),
62
+ hasCustomSort: ref(false),
63
+ displays: [],
64
+ activeDisplayState: ref("table"),
65
+ kanbanGroupByOptions: [],
66
+ kanbanGroupByState: ref(undefined),
67
+ onFiltersCleared: () => undefined,
68
+ emits: () => undefined,
69
+ }),
70
+ );
71
+ return () =>
72
+ h(TableActions, {
73
+ searchPlaceholder,
74
+ chrome: { ...FULL_TABLE_CHROME, refresh: false, menu: false },
75
+ });
76
+ },
77
+ });
78
+ app = createApp(Host);
79
+ for (const [name, stub] of Object.entries({
80
+ UButton: ButtonStub,
81
+ UInput: InputStub,
82
+ UKbd: PassThrough,
83
+ UPopover: PassThrough,
84
+ UDropdownMenu: PassThrough,
85
+ UTooltip: PassThrough,
86
+ UIcon: PassThrough,
87
+ })) {
88
+ app.component(name, stub);
89
+ }
90
+ const container = document.createElement("div");
91
+ app.mount(container);
92
+ return container;
93
+ }
94
+
95
+ beforeEach(() => {
96
+ vi.stubGlobal("computed", computed);
97
+ vi.stubGlobal("ref", ref);
98
+ vi.stubGlobal("watch", watch);
99
+ vi.stubGlobal("defineShortcuts", () => undefined);
100
+ vi.stubGlobal("useDmsAppConfig", () => ({
101
+ ui: { icons: { close: "close", plus: "plus", ellipsis: "ellipsis" } },
102
+ }));
103
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
104
+ vi.stubGlobal("useTranslation", () => ({
105
+ processI18n: (value: string) =>
106
+ value.startsWith("$") ? `translated:${value.slice(1)}` : value,
107
+ }));
108
+ });
109
+
110
+ afterEach(() => {
111
+ app?.unmount();
112
+ app = undefined;
113
+ vi.unstubAllGlobals();
114
+ });
115
+
116
+ describe("table toolbar search", () => {
117
+ it("names the button opening the search", () => {
118
+ const container = mountActions(undefined);
119
+ expect(container.querySelector("button")?.getAttribute("aria-label")).toBe(
120
+ "dms.table.search_open",
121
+ );
122
+ });
123
+
124
+ it("uses the configured placeholder, which also names the field", async () => {
125
+ const container = mountActions("$page.members.search", "ada");
126
+ await nextTick();
127
+ const input = container.querySelector("input");
128
+ expect(input?.getAttribute("placeholder")).toBe(
129
+ "translated:page.members.search",
130
+ );
131
+ expect(input?.getAttribute("aria-label")).toBe(
132
+ "translated:page.members.search",
133
+ );
134
+ });
135
+
136
+ it("falls back to the default placeholder as the field's name", async () => {
137
+ const container = mountActions(undefined, "ada");
138
+ await nextTick();
139
+ const input = container.querySelector("input");
140
+ expect(input?.getAttribute("placeholder")).toBe(
141
+ "dms.table.search_placeholder",
142
+ );
143
+ expect(input?.getAttribute("aria-label")).toBe(
144
+ "dms.table.search_placeholder",
145
+ );
146
+ });
147
+ });
@@ -148,6 +148,18 @@ describe("clearTableFilters", () => {
148
148
  ).toBe(true);
149
149
  });
150
150
 
151
+ it("counts the hidden filters of the page URL as filters", () => {
152
+ const state = { ...createState(), hiddenFiltersApply: ref(true) };
153
+ expect(isTableNarrowed(state)).toBe(true);
154
+ // Nothing the user could clear: no clear action is offered.
155
+ expect(clearableTableFilters(state)).toEqual({
156
+ filters: false,
157
+ search: false,
158
+ });
159
+ state.hiddenFiltersApply.value = false;
160
+ expect(isTableNarrowed(state)).toBe(false);
161
+ });
162
+
151
163
  it("ignores a search the active display does not apply", () => {
152
164
  const state = {
153
165
  ...createState({ globalFilter: "a" }),
@@ -244,6 +256,16 @@ describe("table empty state", () => {
244
256
  expect(state.globalFilter.value).toBe("");
245
257
  });
246
258
 
259
+ it("says no row matches, not that there are none yet, under hidden filters", () => {
260
+ const container = mountEmpty({
261
+ ...createState(),
262
+ hiddenFiltersApply: ref(true),
263
+ });
264
+ expect(container.textContent).toContain("dms.table.no_results_title");
265
+ expect(container.textContent).not.toContain("dms.table.empty_title");
266
+ expect(container.querySelector("button")).toBeNull();
267
+ });
268
+
247
269
  it("offers no clear action when only default filters narrow the rows", () => {
248
270
  const container = mountEmpty(
249
271
  createState({ columnFilters: [pinnedStatus("open")] }),
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const tableSource = readFileSync(
6
+ resolve(__dirname, "../layers/dms-ui/app/build/components/table/Table.vue"),
7
+ "utf8",
8
+ );
9
+
10
+ describe("table empty body", () => {
11
+ it("centres on the visible part of a table wider than its card", () => {
12
+ expect(tableSource).toMatch(
13
+ /emptyBody:\s*"sticky start-0 w-\[var\(--dms-table-viewport,auto\)\]"/,
14
+ );
15
+ expect(tableSource).toMatch(
16
+ /<div :class="uiTable\.emptyBody\(\)">\s*<TableEmpty/,
17
+ );
18
+ });
19
+
20
+ it("knows the visible width of every table, not only expandable ones", () => {
21
+ expect(tableSource).toMatch(
22
+ /"--dms-table-viewport":\s*tableViewportWidth\.value > 0/,
23
+ );
24
+ });
25
+ });
@@ -103,6 +103,28 @@ describe("metric cells", () => {
103
103
  expect(cell.innerHTML).toContain("text-error");
104
104
  });
105
105
 
106
+ it("tones a status pill by the row's tone field before the column's map", () => {
107
+ const options = { tones: { "404": "info" }, toneField: "statusTone" };
108
+ const pill = (row: Record<string, unknown>) =>
109
+ render(formatters.get("status_pill")!(404, "en-GB", options, row))
110
+ .innerHTML;
111
+
112
+ expect(pill({ statusTone: "warning" })).toContain("text-warning");
113
+ app?.unmount();
114
+ expect(pill({ statusTone: "not-a-tone" })).toContain("text-info");
115
+ app?.unmount();
116
+ expect(
117
+ render(
118
+ formatters.get("status_pill")!(
119
+ 503,
120
+ "en-GB",
121
+ { toneField: "statusTone" },
122
+ { statusTone: "error" },
123
+ ),
124
+ ).innerHTML,
125
+ ).toContain("text-error");
126
+ });
127
+
106
128
  it("draws a progress bar with its figures", () => {
107
129
  const cell = render(
108
130
  formatters.get("progress")!(
@@ -0,0 +1,126 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The `pills` cell draws a list of strings and related rows as outline role
4
+ * pills, and the pill items a server styles per row in their own tone and
5
+ * variant.
6
+ */
7
+ import { createApp, defineComponent, h, type App, type VNodeChild } from "vue";
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
9
+ import type { DataType } from "../layers/dms-core/app/composables/data-types/useDataType";
10
+ import { isObject } from "../layers/dms-core/app/utils/type-check";
11
+
12
+ const stub = { default: { render: () => null } };
13
+ vi.mock("@nuxt/ui/components/Avatar.vue", () => stub);
14
+ vi.mock("@nuxt/ui/components/Badge.vue", () => stub);
15
+ vi.mock("@nuxt/ui/components/Link.vue", () => stub);
16
+ vi.mock("@nuxt/ui/runtime/vue/components/Icon.vue", () => stub);
17
+ vi.mock("../layers/dms-ui/app/composables/useFileReadUrls", () => ({
18
+ useFileReadUrls: () => ({ getUrl: () => undefined, resolve: () => {} }),
19
+ }));
20
+
21
+ type Formatter = (
22
+ value: unknown,
23
+ locale: string,
24
+ options?: unknown,
25
+ row?: Record<string, unknown>,
26
+ ) => VNodeChild;
27
+
28
+ const registered = new Map<string, DataType["formatter"]>();
29
+
30
+ // Auto-imports of the layer the default types are registered with.
31
+ function stubLayerGlobals() {
32
+ vi.stubGlobal("h", h);
33
+ vi.stubGlobal("isObject", isObject);
34
+ for (const name of [
35
+ "formatNumber",
36
+ "formatPrice",
37
+ "formatPercentage",
38
+ "formatTimeSpan",
39
+ "formatDate",
40
+ "getBooleanLabel",
41
+ ]) {
42
+ vi.stubGlobal(name, () => "");
43
+ }
44
+ vi.stubGlobal("useTranslation", () => ({
45
+ processI18n: (text: string) => text.replace(/^\$/, "i18n:"),
46
+ }));
47
+ vi.stubGlobal("useDataTypes", () => ({
48
+ registerDataType: (dataType: DataType) =>
49
+ registered.set(dataType.id, dataType.formatter),
50
+ }));
51
+ }
52
+
53
+ let app: App | undefined;
54
+
55
+ function renderPills(value: unknown, options: unknown = {}): HTMLElement {
56
+ const content = (registered.get("pills")!.default as Formatter)(
57
+ value,
58
+ "en",
59
+ options,
60
+ {},
61
+ );
62
+ const container = document.createElement("div");
63
+ app = createApp(defineComponent({ setup: () => () => content }));
64
+ app.component("UIcon", defineComponent({ render: () => h("i") }));
65
+ app.mount(container);
66
+ return container;
67
+ }
68
+
69
+ const pillsOf = (cell: HTMLElement) =>
70
+ [...cell.firstElementChild!.children].map((pill) => ({
71
+ label: pill.textContent,
72
+ className: pill.className,
73
+ }));
74
+
75
+ beforeAll(async () => {
76
+ stubLayerGlobals();
77
+ const { registerDefaultDataTypes } = await import(
78
+ "../layers/dms-ui/app/build/composables/data-types/registerDefaults"
79
+ );
80
+ registerDefaultDataTypes();
81
+ });
82
+
83
+ afterEach(() => {
84
+ app?.unmount();
85
+ app = undefined;
86
+ });
87
+
88
+ describe("pills cell", () => {
89
+ it("draws strings and related rows as outline role pills, as before", () => {
90
+ const strings = pillsOf(renderPills(["$roles.admin", "Billing"]));
91
+ expect(strings.map((pill) => pill.label)).toEqual([
92
+ "i18n:roles.admin",
93
+ "Billing",
94
+ ]);
95
+ expect(strings[0]!.className).toContain("border-accented");
96
+ app?.unmount();
97
+
98
+ const rows = pillsOf(renderPills([{ _id: "r1", name: "Editor" }, "r2"]));
99
+ expect(rows.map((pill) => pill.label)).toEqual(["Editor"]);
100
+ });
101
+
102
+ it("draws a pill item in its tone and variant, beside plain strings", () => {
103
+ const pills = pillsOf(
104
+ renderPills([
105
+ { label: "EN", tone: "primary", variant: "soft" },
106
+ { label: "DE", variant: "outline" },
107
+ { label: "IT", tone: "success" },
108
+ "ES",
109
+ ]),
110
+ );
111
+
112
+ expect(pills.map((pill) => pill.label)).toEqual(["EN", "DE", "IT", "ES"]);
113
+ expect(pills[0]!.className).toContain("text-primary");
114
+ expect(pills[0]!.className).toContain("bg-(--dms-accent-tint)");
115
+ expect(pills[1]!.className).toContain("border");
116
+ expect(pills[1]!.className).toContain("bg-transparent");
117
+ expect(pills[2]!.className).toContain("bg-success/12");
118
+ expect(pills[3]!.className).toContain("border-accented");
119
+ });
120
+
121
+ it("draws a pill item without a tone nor a variant as a plain string", () => {
122
+ const [pill] = pillsOf(renderPills([{ label: "FR" }]));
123
+ expect(pill!.label).toBe("FR");
124
+ expect(pill!.className).toContain("border-accented");
125
+ });
126
+ });
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A `TableView.fromSource` table whose `fetchUrl` reads the page query lists
4
+ * again when a link changes that query, through the runtime's real async data
5
+ * cache: whether the link keeps the page mounted (the watch) or remounts it
6
+ * (a fresh instance binding to the cached entry of its key).
7
+ */
8
+ import {
9
+ computed,
10
+ createApp,
11
+ defineComponent,
12
+ h,
13
+ nextTick,
14
+ type App,
15
+ } from "vue";
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+ import type { DmsFrontendRuntime } from "#dms/frontend-module";
18
+ import type { TableViewColumn } from "../layers/dms-ui/app/composables/table-view/types";
19
+
20
+ const engine = await import("#dms/frontend-module");
21
+ vi.stubGlobal("useDmsAsyncData", engine.useDmsAsyncData);
22
+ vi.stubGlobal("useDmsLazyAsyncData", engine.useDmsLazyAsyncData);
23
+
24
+ const { useTableRows } = await import(
25
+ "../layers/dms-ui/app/build/composables/table-view/useTableRows"
26
+ );
27
+ const { useTableCountsData, useTableListData } = await import(
28
+ "../layers/dms-ui/app/build/composables/table-view/useTableListData"
29
+ );
30
+
31
+ const FETCH_URL = "/api/log/route?route={{query.route}}";
32
+ const COLUMNS = [{ id: "path", accessorKey: "path" }] as TableViewColumn[];
33
+ const TAB_QUERIES = [{ id: "get", query: { filter_method: "is:GET" } }];
34
+ const ROUTE_VALUE = "GET /api/search?q=shoes&page=2";
35
+ const ENCODED_URL =
36
+ "/api/log/route?route=GET%20%2Fapi%2Fsearch%3Fq%3Dshoes%26page%3D2";
37
+
38
+ type Api = Parameters<typeof useTableRows>[0]["api"];
39
+
40
+ const requests: string[] = [];
41
+ const api = (async (url: string) => {
42
+ requests.push(url);
43
+ return { results: [{ _id: "a", path: "/api/search" }], total: 1 };
44
+ }) as unknown as Api;
45
+
46
+ let runtime: DmsFrontendRuntime;
47
+ const mounted: App[] = [];
48
+
49
+ const SourceTable = defineComponent({
50
+ setup() {
51
+ const route = runtime.route;
52
+ const rows = useTableRows({
53
+ api,
54
+ location: FETCH_URL,
55
+ source: { fetchUrl: FETCH_URL, capabilities: { filter: true } },
56
+ columns: COLUMNS,
57
+ route: () => ({ routeParams: {}, routeQuery: route.query }),
58
+ });
59
+ const archiveQuery = computed(() => ({}));
60
+ const keys = { componentId: "requests", pageId: "sources", rows };
61
+ void useTableListData({
62
+ ...keys,
63
+ query: computed(() => ({ offset: 0, limit: 10 })),
64
+ archiveQuery,
65
+ isSelfManaged: computed(() => false),
66
+ });
67
+ void useTableCountsData({
68
+ ...keys,
69
+ queries: computed(() => TAB_QUERIES),
70
+ archiveQuery,
71
+ });
72
+ return () => h("div");
73
+ },
74
+ });
75
+
76
+ function mountTable(): void {
77
+ const app = createApp(SourceTable);
78
+ engine.provideDmsFrontendRuntime(app, runtime);
79
+ app.mount(document.createElement("div"));
80
+ mounted.push(app);
81
+ }
82
+
83
+ const settle = async () => {
84
+ await nextTick();
85
+ await new Promise((resolve) => setTimeout(resolve, 0));
86
+ };
87
+
88
+ beforeEach(() => {
89
+ requests.length = 0;
90
+ runtime = engine.createDmsFrontendRuntime();
91
+ });
92
+
93
+ afterEach(() => {
94
+ mounted.splice(0).forEach((app) => app.unmount());
95
+ });
96
+
97
+ describe("a source table following the page query", () => {
98
+ it("requests nothing while the query value is missing", async () => {
99
+ mountTable();
100
+ await settle();
101
+ expect(requests).toEqual([]);
102
+ });
103
+
104
+ it("lists again when the page stays mounted", async () => {
105
+ mountTable();
106
+ await settle();
107
+ runtime.route.query = { route: ROUTE_VALUE };
108
+ await settle();
109
+ expect(requests).toEqual([ENCODED_URL, ENCODED_URL]);
110
+ });
111
+
112
+ // An Inertia link without preserveState remounts the page: the new table
113
+ // reads the new query from its first render, so its URL never changes.
114
+ it("lists again when the link remounts the page", async () => {
115
+ mountTable();
116
+ await settle();
117
+ mounted.pop()?.unmount();
118
+ runtime.route.query = { route: ROUTE_VALUE };
119
+ mountTable();
120
+ await settle();
121
+ expect(requests).toEqual([ENCODED_URL, ENCODED_URL]);
122
+ });
123
+ });
@@ -0,0 +1,137 @@
1
+ import { computed, nextTick, readonly, ref, watch, type Ref } from "vue";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { LocationQuery } from "vue-router";
4
+ import { useAccumulatedPages } from "../layers/dms-ui/app/build/composables/table-view/useAccumulatedPages";
5
+ import { usePersistedPagination } from "../layers/dms-ui/app/build/composables/table-view/usePersistedPagination";
6
+ import { usePreferences } from "../layers/dms-core/app/composables/user/usePreferences";
7
+
8
+ const PAGE_SIZE = 10;
9
+ const THIRD_PAGE = 2;
10
+
11
+ // What a table view derives from `queryParamFilters: { status: { field: "status" } }`.
12
+ const hiddenFiltersOf = (query: () => LocationQuery) =>
13
+ computed(() =>
14
+ query().status === undefined
15
+ ? []
16
+ : [{ accessorKey: "status", mode: "is", value: query().status }],
17
+ );
18
+
19
+ const TABLE_KEY = "tables.tasks.query-filters";
20
+ const preferenceKey = (suffix: string) => `${TABLE_KEY}.${suffix}`;
21
+
22
+ // The `user-preferences` cookie, surviving the remounts of a page.
23
+ let cookie: Ref<Record<string, unknown>>;
24
+
25
+ // What a table view does on mount: its page, read back from the cookie,
26
+ // under the hidden filters the URL sets.
27
+ const mountTable = (query: Ref<LocationQuery>) => {
28
+ const hiddenFilters = hiddenFiltersOf(() => query.value);
29
+ const pagination = usePersistedPagination({
30
+ preferences: usePreferences(),
31
+ preferenceKey,
32
+ defaults: { pageIndex: 0, pageSize: PAGE_SIZE },
33
+ scope: () => [hiddenFilters.value, []],
34
+ });
35
+ return { hiddenFilters, pagination };
36
+ };
37
+
38
+ beforeEach(() => {
39
+ cookie = ref({});
40
+ vi.stubGlobal("useDmsCookie", () => cookie);
41
+ vi.stubGlobal("ref", ref);
42
+ vi.stubGlobal("readonly", readonly);
43
+ vi.stubGlobal("computed", computed);
44
+ vi.stubGlobal("watch", watch);
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.unstubAllGlobals();
49
+ });
50
+
51
+ describe("a hidden filter changed by the URL", () => {
52
+ it("sends the pager back to page 1", () => {
53
+ const query = ref<LocationQuery>({ status: "pending" });
54
+ const { pagination } = mountTable(query);
55
+ pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
56
+
57
+ query.value = { status: "cancelled" };
58
+
59
+ expect(pagination.value).toEqual({ pageIndex: 0, pageSize: PAGE_SIZE });
60
+ });
61
+
62
+ it("keeps the page when another query parameter changes", () => {
63
+ const query = ref<LocationQuery>({ status: "pending" });
64
+ const { pagination } = mountTable(query);
65
+ pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
66
+
67
+ query.value = { status: "pending", record: "42" };
68
+
69
+ expect(pagination.value.pageIndex).toBe(THIRD_PAGE);
70
+ });
71
+
72
+ it("sends the pager back to page 1 when the filter goes away", () => {
73
+ const query = ref<LocationQuery>({ status: "pending" });
74
+ const { pagination } = mountTable(query);
75
+ pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
76
+
77
+ query.value = {};
78
+
79
+ expect(pagination.value.pageIndex).toBe(0);
80
+ });
81
+
82
+ it("starts at page 1 when a link remounts the page under another value", async () => {
83
+ const first = mountTable(ref<LocationQuery>({ status: "pending" }));
84
+ first.pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
85
+ await nextTick();
86
+
87
+ const other = mountTable(ref<LocationQuery>({ status: "completed" }));
88
+ expect(other.pagination.value).toEqual({
89
+ pageIndex: 0,
90
+ pageSize: PAGE_SIZE,
91
+ });
92
+ });
93
+
94
+ it("restores the page of a remount under the same value", async () => {
95
+ const first = mountTable(ref<LocationQuery>({ status: "pending" }));
96
+ first.pagination.value = { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE };
97
+ await nextTick();
98
+
99
+ const again = mountTable(ref<LocationQuery>({ status: "pending" }));
100
+ expect(again.pagination.value.pageIndex).toBe(THIRD_PAGE);
101
+ });
102
+
103
+ it("keeps a page stored before its hidden filters were", () => {
104
+ cookie.value = {
105
+ tables: {
106
+ tasks: {
107
+ "query-filters": {
108
+ pagination: { pageIndex: THIRD_PAGE, pageSize: PAGE_SIZE },
109
+ },
110
+ },
111
+ },
112
+ };
113
+ const table = mountTable(ref<LocationQuery>({}));
114
+ expect(table.pagination.value.pageIndex).toBe(THIRD_PAGE);
115
+ });
116
+
117
+ it("starts a load-more list over from one page", async () => {
118
+ const query = ref<LocationQuery>({ status: "pending" });
119
+ const hiddenFilters = hiddenFiltersOf(() => query.value);
120
+ const pagination = ref({ pageIndex: 0, pageSize: PAGE_SIZE });
121
+ const pages = useAccumulatedPages({
122
+ mode: "loadMore",
123
+ pagination,
124
+ resetOn: [hiddenFilters],
125
+ });
126
+ pages.loadNextPage();
127
+ expect(pages.requestedPagination.value.pageSize).toBe(PAGE_SIZE * 2);
128
+
129
+ query.value = { status: "cancelled" };
130
+ await nextTick();
131
+
132
+ expect(pages.requestedPagination.value).toEqual({
133
+ pageIndex: 0,
134
+ pageSize: PAGE_SIZE,
135
+ });
136
+ });
137
+ });