@antelopejs/dms 0.7.7 → 0.7.9

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 (85) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/test/attachment-host/capabilities.d.ts +25 -0
  3. package/dist/test/attachment-host/capabilities.js +53 -1
  4. package/dist/test/attachment-host/capabilities.js.map +1 -1
  5. package/dist/test/integration/table-view-capabilities.test.js +13 -0
  6. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  8. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  10. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  11. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +6 -1
  13. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  15. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  16. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  17. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  18. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  19. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  20. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  21. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  22. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  23. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  24. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  25. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  26. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  29. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  30. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  32. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  33. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  36. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  38. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  40. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  46. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  47. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
  48. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  49. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  50. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  51. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +99 -13
  52. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  53. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  54. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  55. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  56. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  57. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
  58. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  59. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  60. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +7 -2
  61. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  62. package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
  63. package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
  64. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  65. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  66. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  67. package/frontend-vue/tests/component-id.test.ts +18 -0
  68. package/frontend-vue/tests/field-row.test.ts +14 -0
  69. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  70. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  71. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  72. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  73. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  74. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  75. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  76. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  77. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  78. package/frontend-vue/tests/table-row-hover.test.ts +32 -0
  79. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  80. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  81. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  82. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  83. package/package.json +2 -2
  84. package/skills/dms/SKILL.md +9 -2
  85. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -0,0 +1,176 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A `duration` and a plain text (`string`) cell take the colour of the tone a
4
+ * row field holds (`toneField`), as text and never as a pill; a row without
5
+ * one keeps the default look. A duration may also be written in milliseconds
6
+ * whatever its size (`format: "ms"`).
7
+ */
8
+ import { createApp, defineComponent, h, type App, type VNodeChild } from "vue";
9
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
10
+ import type { DataType } from "../layers/dms-core/app/composables/data-types/useDataType";
11
+ import { isObject } from "../layers/dms-core/app/utils/type-check";
12
+
13
+ const stub = { default: { render: () => null } };
14
+ vi.mock("@nuxt/ui/components/Avatar.vue", () => stub);
15
+ vi.mock("@nuxt/ui/components/Badge.vue", () => stub);
16
+ vi.mock("@nuxt/ui/components/Link.vue", () => stub);
17
+ vi.mock("@nuxt/ui/runtime/vue/components/Icon.vue", () => stub);
18
+ vi.mock("../layers/dms-ui/app/composables/useFileReadUrls", () => ({
19
+ useFileReadUrls: () => ({ getUrl: () => undefined, resolve: () => {} }),
20
+ }));
21
+
22
+ type Formatter = (
23
+ value: unknown,
24
+ locale: string,
25
+ options?: unknown,
26
+ row?: Record<string, unknown>,
27
+ ) => unknown;
28
+
29
+ let formatMilliseconds: (ms: number, locale: string) => string;
30
+
31
+ const formatters = new Map<string, Formatter>();
32
+ const register = (dataType: DataType) =>
33
+ formatters.set(dataType.id, dataType.formatter?.default as Formatter);
34
+
35
+ // Auto-imports of the layer the default types are registered with.
36
+ function stubLayerGlobals() {
37
+ vi.stubGlobal("h", h);
38
+ vi.stubGlobal("isObject", isObject);
39
+ for (const name of [
40
+ "formatNumber",
41
+ "formatPrice",
42
+ "formatPercentage",
43
+ "formatTimeSpan",
44
+ "formatDate",
45
+ "getBooleanLabel",
46
+ ]) {
47
+ vi.stubGlobal(name, () => "");
48
+ }
49
+ vi.stubGlobal("useDataTypes", () => ({ registerDataType: register }));
50
+ }
51
+
52
+ beforeAll(async () => {
53
+ stubLayerGlobals();
54
+ const { registerDefaultDataTypes } = await import(
55
+ "../layers/dms-ui/app/build/composables/data-types/registerDefaults"
56
+ );
57
+ registerDefaultDataTypes();
58
+ ({ formatMilliseconds } = await import(
59
+ "../layers/dms-ui/app/build/composables/data-types/metricCells"
60
+ ));
61
+ });
62
+
63
+ let app: App | undefined;
64
+
65
+ afterEach(() => {
66
+ app?.unmount();
67
+ app = undefined;
68
+ });
69
+
70
+ function render(content: unknown): HTMLElement {
71
+ const container = document.createElement("div");
72
+ app = createApp(
73
+ defineComponent({ setup: () => () => content as VNodeChild }),
74
+ );
75
+ app.mount(container);
76
+ return container;
77
+ }
78
+
79
+ const cell = (
80
+ id: string,
81
+ value: unknown,
82
+ options: unknown,
83
+ row: Record<string, unknown> = {},
84
+ ) => formatters.get(id)!(value, "en-GB", options, row);
85
+
86
+ describe("milliseconds format", () => {
87
+ it("writes every duration in milliseconds, its digits grouped", () => {
88
+ expect(formatMilliseconds(2149, "en-GB")).toBe("2,149 ms");
89
+ expect(formatMilliseconds(2149, "fr-FR")).toBe("2 149 ms");
90
+ expect(formatMilliseconds(2149, "es-ES")).toBe("2.149 ms");
91
+ expect(formatMilliseconds(910.4, "en-GB")).toBe("910 ms");
92
+ expect(formatMilliseconds(125_000, "en-GB")).toBe("125,000 ms");
93
+ });
94
+ });
95
+
96
+ describe("duration cell", () => {
97
+ it("keeps its most readable unit by default and writes milliseconds with format ms", () => {
98
+ expect(render(cell("duration", 2149, {})).textContent).toBe("2.1 s");
99
+ app?.unmount();
100
+ expect(render(cell("duration", 2149, { format: "ms" })).textContent).toBe(
101
+ "2,149 ms",
102
+ );
103
+ app?.unmount();
104
+ expect(
105
+ render(cell("duration", 2.149, { unit: "s", format: "ms" })).textContent,
106
+ ).toBe("2,149 ms");
107
+ });
108
+
109
+ it("colours the figure in the row's tone, without a pill", () => {
110
+ const figure = render(
111
+ cell(
112
+ "duration",
113
+ 2149,
114
+ { toneField: "durationTone" },
115
+ { durationTone: "warning" },
116
+ ),
117
+ ).firstElementChild!;
118
+ expect(figure.className).toContain("text-warning");
119
+ expect(figure.className).not.toContain("text-toned");
120
+ expect(figure.className).not.toMatch(/bg-|rounded/);
121
+ });
122
+
123
+ it("keeps today's colour for a row whose field holds no tone", () => {
124
+ for (const row of [{}, { durationTone: null }, { durationTone: "slow" }]) {
125
+ const figure = render(
126
+ cell("duration", 120, { toneField: "durationTone" }, row),
127
+ ).firstElementChild!;
128
+ expect(figure.className).toContain("text-toned");
129
+ expect(figure.className).not.toContain("text-warning");
130
+ app?.unmount();
131
+ }
132
+ });
133
+ });
134
+
135
+ describe("plain text cell", () => {
136
+ it("draws the bare value without a tone field, as a string column did", () => {
137
+ expect(cell("string", "Timeout", undefined)).toBe("Timeout");
138
+ expect(cell("string", "Timeout", {}, { messageTone: "error" })).toBe(
139
+ "Timeout",
140
+ );
141
+ });
142
+
143
+ it("keeps the bare value for a row whose field holds no tone", () => {
144
+ const options = { toneField: "messageTone" };
145
+ expect(cell("string", "OK", options, {})).toBe("OK");
146
+ expect(cell("string", "OK", options, { messageTone: "red" })).toBe("OK");
147
+ });
148
+
149
+ it("colours the text in the row's tone, without a pill", () => {
150
+ const text = render(
151
+ cell(
152
+ "string",
153
+ "ECONNRESET",
154
+ { toneField: "messageTone" },
155
+ { messageTone: "error" },
156
+ ),
157
+ ).firstElementChild!;
158
+ expect(text.textContent).toBe("ECONNRESET");
159
+ expect(text.className).toBe("text-error");
160
+ });
161
+
162
+ it("renders a non-string value as the untoned cell does, only adding the tone", () => {
163
+ const options = { toneField: "messageTone" };
164
+ for (const value of [2149, ["a", "b"], 0]) {
165
+ const plain = render(cell("string", value, options, {})).textContent;
166
+ app?.unmount();
167
+ const toned = render(
168
+ cell("string", value, options, { messageTone: "error" }),
169
+ ).firstElementChild!;
170
+ expect(toned.textContent).toBe(plain);
171
+ expect(toned.textContent).not.toContain("[object");
172
+ expect(toned.className).toBe("text-error");
173
+ app?.unmount();
174
+ }
175
+ });
176
+ });
@@ -0,0 +1,204 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A `liveHold` table on a paused live page keeps its rows: its events are
4
+ * counted into a "N new · Show" banner, a source route is told when the rows
5
+ * were frozen on every list it is asked for (the user's search, sort and
6
+ * pagination included), and Show reads the rows once without leaving the
7
+ * pause.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { createApp, defineComponent, h, nextTick, type App } from "vue";
12
+ import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
13
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
14
+ import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
15
+ import {
16
+ createPageLiveController,
17
+ type PageLiveController,
18
+ } from "../layers/dms-ui/app/build/composables/live/pageLiveController";
19
+ import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
20
+ import {
21
+ LIVE_HELD_AT_QUERY_KEY,
22
+ sourceRouteQuery,
23
+ } from "../layers/dms-ui/app/build/composables/table-view/useTableRows";
24
+ import type { TableLiveHold } from "../layers/dms-ui/app/build/composables/table-view/useTableLiveHold";
25
+
26
+ const topicHandlers = new Map<string, RealtimeTopicHandler>();
27
+
28
+ vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
29
+ useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
30
+ topicHandlers.set(topic, handler);
31
+ },
32
+ }));
33
+
34
+ const { useRefreshTriggers } = await import(
35
+ "../layers/dms-ui/app/build/composables/blocks/useRefreshTriggers"
36
+ );
37
+ const { useTableLiveHold } = await import(
38
+ "../layers/dms-ui/app/build/composables/table-view/useTableLiveHold"
39
+ );
40
+
41
+ const TOPIC = "api:requests";
42
+ const tableViewSource = readFileSync(
43
+ resolve(
44
+ __dirname,
45
+ "../layers/dms-ui/app/components/table-view/TableView.vue",
46
+ ),
47
+ "utf8",
48
+ );
49
+
50
+ let apps: App[] = [];
51
+ let live: PageLiveController;
52
+
53
+ const publish = () =>
54
+ topicHandlers.get(TOPIC)?.({ topic: TOPIC, type: "request", ts: 1 });
55
+
56
+ function mountTable(options: { liveHold: boolean; isSource?: boolean }) {
57
+ const refresh = vi.fn();
58
+ let hold: TableLiveHold | undefined;
59
+ const app = createApp(
60
+ defineComponent({
61
+ setup() {
62
+ const gate = useRefreshTriggers(TOPIC, refresh);
63
+ hold = useTableLiveHold({
64
+ isEnabled: options.liveHold,
65
+ isSource: options.isSource ?? true,
66
+ gate: () => gate,
67
+ });
68
+ return () => h("div");
69
+ },
70
+ }),
71
+ );
72
+ app.mount(document.createElement("div"));
73
+ apps.push(app);
74
+ return { refresh, hold: hold! };
75
+ }
76
+
77
+ beforeEach(() => {
78
+ vi.useFakeTimers();
79
+ topicHandlers.clear();
80
+ window.localStorage.clear();
81
+ live = createPageLiveController("api.logs", {});
82
+ setCurrentPageLive(live);
83
+ });
84
+
85
+ afterEach(() => {
86
+ apps.forEach((app) => app.unmount());
87
+ apps = [];
88
+ live.dispose();
89
+ setCurrentPageLive(null);
90
+ vi.unstubAllGlobals();
91
+ vi.useRealTimers();
92
+ });
93
+
94
+ it("holds the rows while paused and counts what came in", async () => {
95
+ const { refresh, hold } = mountTable({ liveHold: true });
96
+ expect(hold.isHolding.value).toBe(false);
97
+ expect(hold.heldQuery()).toEqual({});
98
+
99
+ live.pause();
100
+ await nextTick();
101
+ publish();
102
+ publish();
103
+
104
+ expect(refresh).not.toHaveBeenCalled();
105
+ expect(hold.isHolding.value).toBe(true);
106
+ expect(hold.newCount.value).toBe(2);
107
+ });
108
+
109
+ it("bounds a source table's lists at the moment it was frozen", async () => {
110
+ vi.setSystemTime(new Date("2026-10-11T08:00:00.000Z"));
111
+ const { hold } = mountTable({ liveHold: true });
112
+ live.pause();
113
+ await nextTick();
114
+
115
+ vi.setSystemTime(new Date("2026-10-11T08:05:00.000Z"));
116
+ // The user's search while paused still lists the rows as they stood.
117
+ expect(hold.heldQuery()).toEqual({
118
+ [LIVE_HELD_AT_QUERY_KEY]: "2026-10-11T08:00:00.000Z",
119
+ });
120
+
121
+ live.resume();
122
+ await nextTick();
123
+ expect(hold.heldQuery()).toEqual({});
124
+ });
125
+
126
+ it("sends no bound to a data controller's list", async () => {
127
+ const { hold } = mountTable({ liveHold: true, isSource: false });
128
+ live.pause();
129
+ await nextTick();
130
+ expect(hold.heldQuery()).toEqual({});
131
+ });
132
+
133
+ it("shows what is new once, staying paused, and holds from then", async () => {
134
+ vi.setSystemTime(new Date("2026-10-11T08:00:00.000Z"));
135
+ const { refresh, hold } = mountTable({ liveHold: true });
136
+ live.pause();
137
+ await nextTick();
138
+ publish();
139
+
140
+ vi.setSystemTime(new Date("2026-10-11T08:02:00.000Z"));
141
+ hold.showNew();
142
+
143
+ expect(refresh).toHaveBeenCalledTimes(1);
144
+ expect(live.isPaused.value).toBe(true);
145
+ expect(hold.newCount.value).toBe(0);
146
+ expect(hold.heldQuery()).toEqual({
147
+ [LIVE_HELD_AT_QUERY_KEY]: "2026-10-11T08:02:00.000Z",
148
+ });
149
+
150
+ // Nothing missed since Show: resuming has nothing to read again.
151
+ live.resume();
152
+ expect(refresh).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ it("shows no banner count without liveHold, though it still pauses", async () => {
156
+ const { refresh, hold } = mountTable({ liveHold: false });
157
+ live.pause();
158
+ await nextTick();
159
+ publish();
160
+ expect(refresh).not.toHaveBeenCalled();
161
+ expect(hold.newCount.value).toBe(0);
162
+ expect(hold.heldQuery()).toEqual({});
163
+
164
+ live.resume();
165
+ expect(refresh).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("lets the bound through to a source route whatever its capabilities", () => {
169
+ expect(
170
+ sourceRouteQuery(
171
+ { [LIVE_HELD_AT_QUERY_KEY]: "2026-10-11T08:00:00.000Z", search: "x" },
172
+ {},
173
+ ),
174
+ ).toEqual({ [LIVE_HELD_AT_QUERY_KEY]: "2026-10-11T08:00:00.000Z" });
175
+ });
176
+
177
+ it("reads on a user's refresh while held, and holds from then", async () => {
178
+ vi.setSystemTime(new Date("2026-10-11T08:00:00.000Z"));
179
+ const { refresh, hold } = mountTable({ liveHold: true });
180
+ live.pause();
181
+ await nextTick();
182
+ publish();
183
+
184
+ vi.setSystemTime(new Date("2026-10-11T08:03:00.000Z"));
185
+ refreshPageBlocks();
186
+
187
+ expect(refresh).toHaveBeenCalledTimes(1);
188
+ expect(live.isPaused.value).toBe(true);
189
+ expect(hold.newCount.value).toBe(0);
190
+ expect(hold.heldQuery()).toEqual({
191
+ [LIVE_HELD_AT_QUERY_KEY]: "2026-10-11T08:03:00.000Z",
192
+ });
193
+ });
194
+
195
+ it("wires the table view's rows, row events and banner to the hold", () => {
196
+ expect(tableViewSource).toMatch(/liveHold\?: boolean;/);
197
+ expect(tableViewSource).toContain("heldQuery: liveHold.heldQuery,");
198
+ expect(tableViewSource).toContain("refreshCallback: refreshByUser,");
199
+ expect(tableViewSource).toContain('@refresh="refreshByUser"');
200
+ expect(tableViewSource).toContain("gateRowEvent(payload.type, ids, event);");
201
+ expect(tableViewSource).toMatch(
202
+ /<LiveHoldBanner[\s\S]*@show="liveHold.showNew"/,
203
+ );
204
+ });
@@ -5,6 +5,7 @@ import { EYEBROW_CLASS } from "../layers/dms-ui/app/build/utils/eyebrow";
5
5
  import { REORDER_HANDLE_CLASS } from "../layers/dms-ui/app/build/composables/table-view/useReorderHandles";
6
6
  import {
7
7
  resolveRowClickAction,
8
+ rowCustomActions,
8
9
  type RowClickActionOptions,
9
10
  } from "../layers/dms-ui/app/utils/rowClickAction";
10
11
  import type { CustomRowAction } from "../layers/dms-ui/app/types/row-action";
@@ -158,3 +159,34 @@ describe("resolveRowClickAction", () => {
158
159
  expect(resolveRowClickAction(options, rowFor("sent"))).toBe("details");
159
160
  });
160
161
  });
162
+
163
+ describe("rowCustomActions", () => {
164
+ const stopSelling: CustomRowAction = {
165
+ id: "stop_selling",
166
+ label: "Stop selling",
167
+ target: { type: "api", url: "/plans/{_id}/stop", method: "POST" },
168
+ rule: { field: "isActive", equals: true },
169
+ };
170
+ const remove: CustomRowAction = {
171
+ id: "delete",
172
+ label: "Delete",
173
+ target: { type: "api", url: "/plans/{_id}", method: "DELETE" },
174
+ rule: { field: "workspaces", equals: 0 },
175
+ };
176
+
177
+ it("lists the actions whose rule accepts the row, in their order", () => {
178
+ const actions = [detailsAction, stopSelling, remove];
179
+ expect(
180
+ rowCustomActions(actions, { isActive: true, workspaces: 0 }),
181
+ ).toEqual(actions);
182
+ expect(
183
+ rowCustomActions(actions, { isActive: false, workspaces: 2 }).map(
184
+ (action) => action.label,
185
+ ),
186
+ ).toEqual(["Details"]);
187
+ });
188
+
189
+ it("lists nothing for a table without custom actions", () => {
190
+ expect(rowCustomActions(undefined, {})).toEqual([]);
191
+ });
192
+ });
@@ -0,0 +1,144 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A `TableView.fromSource` table with a `periodScope` follows its
4
+ * PeriodSelector like a chart: its route is sent the period, it lists again
5
+ * when the period changes, and it requests nothing (keeping its skeleton)
6
+ * until the selector has registered the scope.
7
+ */
8
+ import {
9
+ computed,
10
+ createApp,
11
+ defineComponent,
12
+ effectScope,
13
+ h,
14
+ nextTick,
15
+ type App,
16
+ } from "vue";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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 { useTableListData } = await import(
28
+ "../layers/dms-ui/app/build/composables/table-view/useTableListData"
29
+ );
30
+ const { usePeriod } = await import(
31
+ "../layers/dms-core/app/composables/period/usePeriod"
32
+ );
33
+ const { registerPeriodScope } = await import(
34
+ "../layers/dms-core/app/composables/period/usePeriodScope"
35
+ );
36
+
37
+ const SCOPE = "report";
38
+ const FETCH_URL = "/api/report";
39
+ const COLUMNS = [{ id: "name", accessorKey: "name" }] as TableViewColumn[];
40
+
41
+ type Api = Parameters<typeof useTableRows>[0]["api"];
42
+
43
+ const requests: string[] = [];
44
+ const api = (async (url: string) => {
45
+ requests.push(url);
46
+ return { results: [{ _id: "a", name: "row" }], total: 1 };
47
+ }) as unknown as Api;
48
+
49
+ const mounted: App[] = [];
50
+ const cleanups: Array<() => void> = [];
51
+ let listed: { value: unknown } | undefined;
52
+
53
+ const SourceTable = defineComponent({
54
+ setup() {
55
+ const rows = useTableRows({
56
+ api,
57
+ location: FETCH_URL,
58
+ source: { fetchUrl: FETCH_URL, capabilities: {}, periodScope: SCOPE },
59
+ columns: COLUMNS,
60
+ });
61
+ void useTableListData({
62
+ componentId: "report",
63
+ pageId: "reports",
64
+ rows,
65
+ query: computed(() => ({ offset: 0, limit: 10 })),
66
+ archiveQuery: computed(() => ({})),
67
+ isSelfManaged: computed(() => false),
68
+ }).then(({ data }) => {
69
+ listed = data;
70
+ });
71
+ return () => h("div");
72
+ },
73
+ });
74
+
75
+ function mountTable(): void {
76
+ const app = createApp(SourceTable);
77
+ engine.provideDmsFrontendRuntime(app, engine.createDmsFrontendRuntime());
78
+ app.mount(document.createElement("div"));
79
+ mounted.push(app);
80
+ }
81
+
82
+ function registerSelector() {
83
+ const scope = effectScope();
84
+ const period = scope.run(() => usePeriod({ defaultPreset: "last-7-days" }))!;
85
+ cleanups.push(registerPeriodScope(SCOPE, period.state), () => scope.stop());
86
+ return period;
87
+ }
88
+
89
+ const settle = async () => {
90
+ await nextTick();
91
+ await new Promise((resolve) => setTimeout(resolve, 0));
92
+ await nextTick();
93
+ };
94
+
95
+ const periodOf = (url: string) =>
96
+ Object.fromEntries(new URL(url, "http://dms.local").searchParams);
97
+
98
+ beforeEach(() => {
99
+ requests.length = 0;
100
+ listed = undefined;
101
+ });
102
+
103
+ afterEach(() => {
104
+ mounted.splice(0).forEach((app) => app.unmount());
105
+ cleanups.splice(0).forEach((cleanup) => cleanup());
106
+ });
107
+
108
+ describe("a source table bound to a period scope", () => {
109
+ it("requests nothing and keeps its skeleton until the scope registers", async () => {
110
+ mountTable();
111
+ await settle();
112
+ expect(requests).toEqual([]);
113
+ expect(listed).toBeDefined();
114
+ expect(listed!.value).toBeNull();
115
+ });
116
+
117
+ it("sends the period with its request once the scope registers", async () => {
118
+ const period = registerSelector();
119
+ mountTable();
120
+ await settle();
121
+ expect(requests).toHaveLength(1);
122
+ expect(listed!.value).toMatchObject({ total: 1 });
123
+ const { range } = period.state.value;
124
+ expect(periodOf(requests[0]!)).toEqual({
125
+ from: range.from.toISOString(),
126
+ to: range.to.toISOString(),
127
+ preset: "last-7-days",
128
+ comparison: "none",
129
+ });
130
+ });
131
+
132
+ it("lists again when the period changes", async () => {
133
+ const period = registerSelector();
134
+ mountTable();
135
+ await settle();
136
+ period.setPreset("last-30-days");
137
+ period.setComparison("previous-period");
138
+ await settle();
139
+ const last = periodOf(requests.at(-1)!);
140
+ expect(last.preset).toBe("last-30-days");
141
+ expect(last.comparison).toBe("previous-period");
142
+ expect(last.compareFrom).toBeDefined();
143
+ });
144
+ });
@@ -78,7 +78,7 @@ it("refreshes on each event of any of its topics, not on a snapshot", () => {
78
78
 
79
79
  it("wires the table view's realtimeTopic and page refresh to its refresh", () => {
80
80
  expect(tableViewSource).toMatch(/realtimeTopic\?: string \| string\[\];/);
81
- expect(tableViewSource).toContain(
82
- "useRefreshTriggers(props.realtimeTopic, () => void refreshAll());",
81
+ expect(tableViewSource).toMatch(
82
+ /const liveGate = useRefreshTriggers\(\s*props\.realtimeTopic,\s*\(\) => void refreshAll\(\),\s*\);/,
83
83
  );
84
84
  });
@@ -179,4 +179,44 @@ describe("source tables", () => {
179
179
  ]);
180
180
  });
181
181
  });
182
+
183
+ describe("reading one row (fetchRowUrl)", () => {
184
+ const FETCH_URL = "/api/ws/{{params.ws}}/requests";
185
+ const ROW_URL = "/api/ws/{{params.ws}}/requests/{id}";
186
+
187
+ function rowsWith(
188
+ api: (url: string) => Promise<unknown>,
189
+ fetchRowUrl?: string,
190
+ ) {
191
+ return useTableRows<LogRow>({
192
+ api: api as unknown as Api,
193
+ location: FETCH_URL,
194
+ source: { fetchUrl: FETCH_URL, fetchRowUrl, capabilities: {} },
195
+ columns: COLUMNS,
196
+ route: () => ({ routeParams: { ws: "ws-1" }, routeQuery: {} }),
197
+ });
198
+ }
199
+
200
+ it("reads a row by id, its id and the page's tokens filled", async () => {
201
+ const api = vi.fn(async () => ROWS[2]);
202
+ const rows = rowsWith(api, ROW_URL);
203
+ expect(await rows.getRow("c/42")).toBe(ROWS[2]);
204
+ expect(api).toHaveBeenCalledWith("/api/ws/ws-1/requests/c%2F42");
205
+ });
206
+
207
+ it("rejects as the route does on a missing row", async () => {
208
+ const api = vi.fn(async () => {
209
+ throw Object.assign(new Error("Not Found"), { statusCode: 404 });
210
+ });
211
+ await expect(rowsWith(api, ROW_URL).getRow("gone")).rejects.toThrow(
212
+ "Not Found",
213
+ );
214
+ });
215
+
216
+ it("requests nothing without fetchRowUrl", async () => {
217
+ const api = vi.fn(async () => ROWS[0]);
218
+ expect(await rowsWith(api).getRow("a")).toBeUndefined();
219
+ expect(api).not.toHaveBeenCalled();
220
+ });
221
+ });
182
222
  });
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
4
 
5
- const EXPECTED_VUE_FILES = 273;
5
+ const EXPECTED_VUE_FILES = 276;
6
6
 
7
7
  function walk(directory: string): string[] {
8
8
  return readdirSync(directory).flatMap((name) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.5.3 <0.6.0",
40
+ "@antelopejs/interface-dms": ">=0.5.5 <0.6.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -35,8 +35,13 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
35
35
  A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL, percent-encoded;
36
36
  nothing is fetched while one is unresolved), and `.watch(FormEvents.SUBMIT_SUCCESS,
37
37
  BlockFunctions.REFRESH_PAGE)` makes the page's data blocks (table views included) re-fetch after a form saves;
38
- `periodScope` binds a card, chart, list block or `Meter` to a `PeriodSelector`, and
38
+ `periodScope` binds a card, chart, list block, `Meter` or `TableView.fromSource` to a `PeriodSelector`
39
+ (which keeps its period in the page URL, `?<id>.period=`), and
39
40
  `realtimeTopic` re-fetches a chart, list block, `Meter`, `Tab` badges or `TableView` on each `PublishMessage`.
41
+ A monitoring page that must not refetch on every event declares `live: { maxRate }` in its page options
42
+ and places a `LiveToggle()` (Direct / Pause, L key); `liveHold: true` keeps a table's rows while paused.
43
+ A custom component follows that state with `usePageLive()`; `refreshPageBlocks()` (user actions) always reads,
44
+ `refreshLivePageBlocks()` (timers, feeds) is held while paused.
40
45
  A status the backend computes (health, outage) is a `Banner({ fetchUrl })`: the route answers
41
46
  a `BannerContent` (its buttons may run `api` / modal / quick-action targets with `confirm`)
42
47
  or `null` for no banner — the block then draws nothing.
@@ -50,7 +55,9 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
50
55
  `StatusPillDisplay({ subField })`, `IdentityDisplay({ subtitleField })` or
51
56
  `TwoLineDisplay({ subField })`; `IdentityDisplay({ emptyLabel, emptyIcon })` draws an empty row,
52
57
  and `IdentityDisplay({ iconField })` reads each row's tile icon from a row field.
53
- `StatusPillDisplay({ toneField })` reads the pill's tone from a row field (an HTTP status class),
58
+ `StatusPillDisplay({ toneField })` reads the pill's tone from a row field (an HTTP status class);
59
+ `DurationDisplay({ toneField })` and `TextDisplay({ toneField })` colour their text with it (a slow
60
+ duration, an error message), and `DurationDisplay({ format: "ms" })` writes "2,149 ms" on every row;
54
61
  and a `PillsDisplay` item may be `{ label, tone?, variant: "soft" | "outline" }`, styled per row.
55
62
  A `StatGroup` detail and a `NavCardGrid` card title take up to two lines before their ellipsis.
56
63
  A unit after a number field is `NumberType({ suffix: "$…days" })`, a character counter on a