@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.
- package/CHANGELOG.md +39 -0
- package/dist/test/attachment-host/capabilities.d.ts +25 -0
- package/dist/test/attachment-host/capabilities.js +53 -1
- package/dist/test/attachment-host/capabilities.js.map +1 -1
- package/dist/test/integration/table-view-capabilities.test.js +13 -0
- package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +6 -1
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +99 -13
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +7 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
- package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/component-id.test.ts +18 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-row-hover.test.ts +32 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +9 -2
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The Direct / Pause valve of a page declared with `live`: in Direct a block
|
|
4
|
+
* refetches on its events at most once per `maxRate` (the last event of a
|
|
5
|
+
* burst included), paused it requests nothing and counts what it missed, and
|
|
6
|
+
* resuming refetches once each block that missed something. The choice is
|
|
7
|
+
* kept per page in the browser, and a page without `live` is untouched.
|
|
8
|
+
*/
|
|
9
|
+
import { createApp, defineComponent, h } from "vue";
|
|
10
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
11
|
+
import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
|
|
12
|
+
import {
|
|
13
|
+
refreshLivePageBlocks,
|
|
14
|
+
refreshPageBlocks,
|
|
15
|
+
} from "../layers/dms-ui/app/utils/blockRefresh";
|
|
16
|
+
import {
|
|
17
|
+
createPageLiveController,
|
|
18
|
+
type PageLiveController,
|
|
19
|
+
} from "../layers/dms-ui/app/build/composables/live/pageLiveController";
|
|
20
|
+
import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
|
|
21
|
+
import { usePageLive } from "../layers/dms-ui/app/composables/live/usePageLive";
|
|
22
|
+
|
|
23
|
+
const topicHandlers = new Map<string, RealtimeTopicHandler[]>();
|
|
24
|
+
|
|
25
|
+
vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
|
|
26
|
+
useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
|
|
27
|
+
topicHandlers.set(topic, [...(topicHandlers.get(topic) ?? []), handler]);
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const { useRefreshTriggers } = await import(
|
|
32
|
+
"../layers/dms-ui/app/build/composables/blocks/useRefreshTriggers"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const PAGE = "monitoring.logs";
|
|
36
|
+
const TOPIC = "api:requests";
|
|
37
|
+
const MAX_RATE_SECONDS = 5;
|
|
38
|
+
const MAX_RATE_MS = MAX_RATE_SECONDS * 1000;
|
|
39
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
40
|
+
const controllers: PageLiveController[] = [];
|
|
41
|
+
|
|
42
|
+
function publish(topic = TOPIC) {
|
|
43
|
+
const event = { topic, type: "request", ts: Date.now() };
|
|
44
|
+
for (const handler of topicHandlers.get(topic) ?? []) handler(event);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mountBlock(topic: string | undefined = TOPIC) {
|
|
48
|
+
const onRefresh = vi.fn();
|
|
49
|
+
const app = createApp(
|
|
50
|
+
defineComponent({
|
|
51
|
+
setup() {
|
|
52
|
+
useRefreshTriggers(topic, onRefresh);
|
|
53
|
+
return () => h("div");
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
app.mount(document.createElement("div"));
|
|
58
|
+
mounted.push(app);
|
|
59
|
+
return onRefresh;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function installLive(maxRate?: number): PageLiveController {
|
|
63
|
+
const controller = createPageLiveController(PAGE, { maxRate });
|
|
64
|
+
controllers.push(controller);
|
|
65
|
+
setCurrentPageLive(controller);
|
|
66
|
+
return controller;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
topicHandlers.clear();
|
|
72
|
+
window.localStorage.clear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
77
|
+
controllers.splice(0).forEach((controller) => controller.dispose());
|
|
78
|
+
setCurrentPageLive(null);
|
|
79
|
+
vi.useRealTimers();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("leaves a page without live as it was: every event refetches", () => {
|
|
83
|
+
const onRefresh = mountBlock();
|
|
84
|
+
publish();
|
|
85
|
+
publish();
|
|
86
|
+
refreshPageBlocks();
|
|
87
|
+
refreshLivePageBlocks();
|
|
88
|
+
expect(onRefresh).toHaveBeenCalledTimes(4);
|
|
89
|
+
expect(usePageLive().isEnabled.value).toBe(false);
|
|
90
|
+
expect(usePageLive().isLive.value).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refetches on each event in Direct when no maxRate is set", () => {
|
|
94
|
+
installLive();
|
|
95
|
+
const onRefresh = mountBlock();
|
|
96
|
+
publish();
|
|
97
|
+
publish();
|
|
98
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("in Direct, refetches at most once per maxRate, the last event trailing", () => {
|
|
102
|
+
installLive(MAX_RATE_SECONDS);
|
|
103
|
+
const onRefresh = mountBlock();
|
|
104
|
+
|
|
105
|
+
publish();
|
|
106
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
107
|
+
|
|
108
|
+
vi.advanceTimersByTime(1000);
|
|
109
|
+
publish();
|
|
110
|
+
vi.advanceTimersByTime(1000);
|
|
111
|
+
publish();
|
|
112
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
113
|
+
|
|
114
|
+
vi.advanceTimersByTime(MAX_RATE_MS - 2000);
|
|
115
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
116
|
+
|
|
117
|
+
vi.advanceTimersByTime(MAX_RATE_MS * 2);
|
|
118
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("caps each block on its own", () => {
|
|
122
|
+
installLive(MAX_RATE_SECONDS);
|
|
123
|
+
const first = mountBlock();
|
|
124
|
+
const second = mountBlock("api:routes");
|
|
125
|
+
publish();
|
|
126
|
+
publish("api:routes");
|
|
127
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("in Pause, requests nothing and counts the events per block", () => {
|
|
132
|
+
const live = installLive();
|
|
133
|
+
const logs = mountBlock();
|
|
134
|
+
const chart = mountBlock();
|
|
135
|
+
const routes = mountBlock("api:routes");
|
|
136
|
+
live.pause();
|
|
137
|
+
|
|
138
|
+
publish();
|
|
139
|
+
publish();
|
|
140
|
+
refreshLivePageBlocks();
|
|
141
|
+
vi.advanceTimersByTime(60_000);
|
|
142
|
+
|
|
143
|
+
expect(logs).not.toHaveBeenCalled();
|
|
144
|
+
expect(chart).not.toHaveBeenCalled();
|
|
145
|
+
expect(routes).not.toHaveBeenCalled();
|
|
146
|
+
// Two events and a live refresh, each counted once for the page although
|
|
147
|
+
// two blocks (three for the refresh) received them.
|
|
148
|
+
expect(live.missedCount.value).toBe(3);
|
|
149
|
+
expect(usePageLive().isPaused.value).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("refreshes on a user's refreshPageBlocks even while paused", () => {
|
|
153
|
+
const live = installLive(MAX_RATE_SECONDS);
|
|
154
|
+
const onRefresh = mountBlock();
|
|
155
|
+
live.pause();
|
|
156
|
+
publish();
|
|
157
|
+
expect(live.missedCount.value).toBe(1);
|
|
158
|
+
|
|
159
|
+
refreshPageBlocks();
|
|
160
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(live.isPaused.value).toBe(true);
|
|
162
|
+
|
|
163
|
+
// Read by the user, the block has nothing left to read on resume.
|
|
164
|
+
live.resume();
|
|
165
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("caps a live refresh like a realtime event in Direct", () => {
|
|
169
|
+
installLive(MAX_RATE_SECONDS);
|
|
170
|
+
const onRefresh = mountBlock(undefined);
|
|
171
|
+
refreshLivePageBlocks();
|
|
172
|
+
refreshLivePageBlocks();
|
|
173
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
174
|
+
vi.advanceTimersByTime(MAX_RATE_MS);
|
|
175
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("on resume, refetches once each block that missed an event", () => {
|
|
179
|
+
const live = installLive(MAX_RATE_SECONDS);
|
|
180
|
+
const logs = mountBlock();
|
|
181
|
+
const routes = mountBlock("api:routes");
|
|
182
|
+
publish("api:routes");
|
|
183
|
+
routes.mockClear();
|
|
184
|
+
live.pause();
|
|
185
|
+
|
|
186
|
+
publish();
|
|
187
|
+
publish();
|
|
188
|
+
publish();
|
|
189
|
+
live.resume();
|
|
190
|
+
|
|
191
|
+
expect(logs).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(routes).not.toHaveBeenCalled();
|
|
193
|
+
expect(live.missedCount.value).toBe(0);
|
|
194
|
+
vi.advanceTimersByTime(MAX_RATE_MS);
|
|
195
|
+
expect(logs).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps a trailing refetch the pause interrupted for the resume", () => {
|
|
199
|
+
const live = installLive(MAX_RATE_SECONDS);
|
|
200
|
+
const onRefresh = mountBlock();
|
|
201
|
+
publish();
|
|
202
|
+
publish();
|
|
203
|
+
live.pause();
|
|
204
|
+
vi.advanceTimersByTime(MAX_RATE_MS);
|
|
205
|
+
expect(onRefresh).toHaveBeenCalledTimes(1);
|
|
206
|
+
expect(live.missedCount.value).toBe(1);
|
|
207
|
+
|
|
208
|
+
live.resume();
|
|
209
|
+
expect(onRefresh).toHaveBeenCalledTimes(2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("dates the page's last update from its refetches", () => {
|
|
213
|
+
const live = installLive();
|
|
214
|
+
mountBlock();
|
|
215
|
+
const before = live.lastUpdatedAt.value;
|
|
216
|
+
vi.advanceTimersByTime(4000);
|
|
217
|
+
publish();
|
|
218
|
+
expect(live.lastUpdatedAt.value).toBe(before + 4000);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("remembers the choice per page in the browser", () => {
|
|
222
|
+
installLive().pause();
|
|
223
|
+
expect(createPageLiveController(PAGE, {}).isPaused.value).toBe(true);
|
|
224
|
+
expect(createPageLiveController("monitoring.routes", {}).isPaused.value).toBe(
|
|
225
|
+
false,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
createPageLiveController(PAGE, {}).resume();
|
|
229
|
+
expect(createPageLiveController(PAGE, {}).isPaused.value).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("follows the page through usePageLive", () => {
|
|
233
|
+
installLive();
|
|
234
|
+
const state = usePageLive();
|
|
235
|
+
expect(state.isEnabled.value).toBe(true);
|
|
236
|
+
state.pause();
|
|
237
|
+
expect(state.isPaused.value).toBe(true);
|
|
238
|
+
expect(state.isLive.value).toBe(false);
|
|
239
|
+
state.toggle();
|
|
240
|
+
expect(state.isPaused.value).toBe(false);
|
|
241
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A rolling period (`last-hour`, `last-24h`) slides every minute, and every
|
|
4
|
+
* block reading it refetches. On a paused live page the period holds still,
|
|
5
|
+
* so nothing refetches on the clock; a period the user picks still applies at
|
|
6
|
+
* once, and resuming catches the skipped minutes up in one read.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createApp, defineComponent, h, ref } from "vue";
|
|
11
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
12
|
+
import { createPageLiveController } from "../layers/dms-ui/app/build/composables/live/pageLiveController";
|
|
13
|
+
import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
|
|
14
|
+
import { usePageLive } from "../layers/dms-ui/app/composables/live/usePageLive";
|
|
15
|
+
import {
|
|
16
|
+
usePeriod,
|
|
17
|
+
type UsePeriodReturn,
|
|
18
|
+
} from "../layers/dms-core/app/composables/period/usePeriod";
|
|
19
|
+
import { registerPeriodScope } from "../layers/dms-core/app/composables/period/usePeriodScope";
|
|
20
|
+
|
|
21
|
+
const requests: string[] = [];
|
|
22
|
+
|
|
23
|
+
vi.stubGlobal("useAuthFetch", () => ({
|
|
24
|
+
$authFetch: async (url: string) => {
|
|
25
|
+
requests.push(url);
|
|
26
|
+
return { value: requests.length };
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
vi.stubGlobal("useDmsRoute", () => ({ query: {} }));
|
|
30
|
+
vi.stubGlobal("ref", ref);
|
|
31
|
+
|
|
32
|
+
const { useChartFetch } = await import(
|
|
33
|
+
"../layers/dms-ui/app/composables/chart/useChartFetch"
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const SCOPE = "live-period";
|
|
37
|
+
const MINUTE_MS = 60_000;
|
|
38
|
+
const apps: Array<() => void> = [];
|
|
39
|
+
|
|
40
|
+
function mountPageWithChart(): UsePeriodReturn {
|
|
41
|
+
let period: UsePeriodReturn | undefined;
|
|
42
|
+
const app = createApp(
|
|
43
|
+
defineComponent({
|
|
44
|
+
setup() {
|
|
45
|
+
const { isPaused } = usePageLive();
|
|
46
|
+
// As the PeriodSelector wires it.
|
|
47
|
+
period = usePeriod({
|
|
48
|
+
defaultPreset: "last-hour",
|
|
49
|
+
holdClock: () => isPaused.value,
|
|
50
|
+
});
|
|
51
|
+
registerPeriodScope(SCOPE, period.state);
|
|
52
|
+
useChartFetch({ fetchUrl: "/api/chart", periodScope: SCOPE });
|
|
53
|
+
return () => h("div");
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
app.mount(document.createElement("div"));
|
|
58
|
+
apps.push(() => app.unmount());
|
|
59
|
+
return period!;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.useFakeTimers();
|
|
64
|
+
requests.length = 0;
|
|
65
|
+
window.localStorage.clear();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
apps.splice(0).forEach((unmount) => unmount());
|
|
70
|
+
setCurrentPageLive(null);
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("slides and refetches every minute in Direct", async () => {
|
|
75
|
+
setCurrentPageLive(createPageLiveController("api.overview", {}));
|
|
76
|
+
mountPageWithChart();
|
|
77
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
78
|
+
expect(requests).toHaveLength(1);
|
|
79
|
+
|
|
80
|
+
await vi.advanceTimersByTimeAsync(2 * MINUTE_MS);
|
|
81
|
+
expect(requests).toHaveLength(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("holds the period while paused, but follows the user's pick", async () => {
|
|
85
|
+
const live = createPageLiveController("api.overview", {});
|
|
86
|
+
setCurrentPageLive(live);
|
|
87
|
+
const period = mountPageWithChart();
|
|
88
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
89
|
+
expect(requests).toHaveLength(1);
|
|
90
|
+
|
|
91
|
+
live.pause();
|
|
92
|
+
const heldKey = period.state.value.key;
|
|
93
|
+
await vi.advanceTimersByTimeAsync(3 * MINUTE_MS);
|
|
94
|
+
expect(requests).toHaveLength(1);
|
|
95
|
+
expect(period.state.value.key).toBe(heldKey);
|
|
96
|
+
|
|
97
|
+
period.setPreset("last-24h");
|
|
98
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
99
|
+
expect(requests).toHaveLength(2);
|
|
100
|
+
expect(requests[1]).toContain("preset=last-24h");
|
|
101
|
+
|
|
102
|
+
await vi.advanceTimersByTimeAsync(2 * MINUTE_MS);
|
|
103
|
+
expect(requests).toHaveLength(2);
|
|
104
|
+
|
|
105
|
+
live.resume();
|
|
106
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
107
|
+
expect(requests).toHaveLength(3);
|
|
108
|
+
expect(period.state.value.key).not.toBe(heldKey);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("holds the selector's period on the page's pause", () => {
|
|
112
|
+
const source = readFileSync(
|
|
113
|
+
resolve(
|
|
114
|
+
__dirname,
|
|
115
|
+
"../layers/dms-ui/app/components/period/PeriodSelector.vue",
|
|
116
|
+
),
|
|
117
|
+
"utf8",
|
|
118
|
+
);
|
|
119
|
+
expect(source).toContain("holdClock: () => isPagePaused.value,");
|
|
120
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A PeriodSelector keeps its period in the page URL: the period it starts
|
|
4
|
+
* from (the URL, then the user's last choice, then its default), the server
|
|
5
|
+
* render (no flash of the default), the in-place write on a change (no new
|
|
6
|
+
* history entry), and the period following a route changed from outside.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createApp,
|
|
10
|
+
createSSRApp,
|
|
11
|
+
defineComponent,
|
|
12
|
+
h,
|
|
13
|
+
nextTick,
|
|
14
|
+
reactive,
|
|
15
|
+
type App,
|
|
16
|
+
} from "vue";
|
|
17
|
+
import { renderToString } from "vue/server-renderer";
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
import type { PeriodPreset } from "../layers/dms-core/app/composables/period/types";
|
|
20
|
+
|
|
21
|
+
const route = reactive<{ query: Record<string, string> }>({ query: {} });
|
|
22
|
+
const preferences = new Map<string, unknown>();
|
|
23
|
+
|
|
24
|
+
vi.stubGlobal("useDmsRoute", () => route);
|
|
25
|
+
// Inertia's client-side replace moves its page URL, which the DMS route
|
|
26
|
+
// follows: the route then holds the query the browser shows.
|
|
27
|
+
const { inertiaReplace } = vi.hoisted(() => ({
|
|
28
|
+
inertiaReplace: vi.fn(({ url }: { url: string }) => {
|
|
29
|
+
route.query = Object.fromEntries(
|
|
30
|
+
new URL(url, "http://dms.local").searchParams,
|
|
31
|
+
);
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("@inertiajs/vue3", () => ({ router: { replace: inertiaReplace } }));
|
|
35
|
+
vi.stubGlobal("usePreferences", () => ({
|
|
36
|
+
getPreference: <T>(key: string, fallback?: T) =>
|
|
37
|
+
(preferences.get(key) as T | undefined) ?? fallback,
|
|
38
|
+
setPreference: (key: string, value: unknown) => preferences.set(key, value),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const { usePeriod } = await import(
|
|
42
|
+
"../layers/dms-core/app/composables/period/usePeriod"
|
|
43
|
+
);
|
|
44
|
+
const { usePeriodLocation, PERIOD_PREFERENCE_KEY } = await import(
|
|
45
|
+
"../layers/dms-ui/app/build/composables/period/usePeriodLocation"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
type Period = ReturnType<typeof usePeriod>;
|
|
49
|
+
|
|
50
|
+
const PAGE_ID = "dashboard";
|
|
51
|
+
const PAGE_PATH = "/dashboard";
|
|
52
|
+
const handles: Record<string, Period> = {};
|
|
53
|
+
|
|
54
|
+
/** The selector's state wiring, as PeriodSelector.vue does it in setup. */
|
|
55
|
+
function selector(id: string, defaultPreset: PeriodPreset = "this-month") {
|
|
56
|
+
return defineComponent({
|
|
57
|
+
setup() {
|
|
58
|
+
const period = usePeriod({
|
|
59
|
+
defaultPreset,
|
|
60
|
+
presets: ["last-7-days", "last-30-days", "this-month", "custom"],
|
|
61
|
+
});
|
|
62
|
+
usePeriodLocation(period, {
|
|
63
|
+
id,
|
|
64
|
+
pageId: PAGE_ID,
|
|
65
|
+
fallback: {
|
|
66
|
+
preset: period.preset.value,
|
|
67
|
+
comparison: period.comparison.value,
|
|
68
|
+
customRange: null,
|
|
69
|
+
customCompareRange: null,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
handles[id] = period;
|
|
73
|
+
return () =>
|
|
74
|
+
h("span", `${period.preset.value}|${period.comparison.value}`);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const mounted: App[] = [];
|
|
80
|
+
|
|
81
|
+
function mount(...ids: string[]): void {
|
|
82
|
+
const app = createApp({
|
|
83
|
+
setup: () => () =>
|
|
84
|
+
h(
|
|
85
|
+
"div",
|
|
86
|
+
ids.map((id) => h(selector(id))),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
app.mount(document.createElement("div"));
|
|
90
|
+
mounted.push(app);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const search = () => new URLSearchParams(window.location.search);
|
|
94
|
+
|
|
95
|
+
const remember = (query: Record<string, string>) =>
|
|
96
|
+
preferences.set(PERIOD_PREFERENCE_KEY, { [`${PAGE_ID}#page`]: query });
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
route.query = {};
|
|
100
|
+
preferences.clear();
|
|
101
|
+
inertiaReplace.mockClear();
|
|
102
|
+
window.history.replaceState(null, "", PAGE_PATH);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
mounted.splice(0).forEach((app) => app.unmount());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("the period a selector starts from", () => {
|
|
110
|
+
it("is the URL's", () => {
|
|
111
|
+
route.query = {
|
|
112
|
+
"page.period": "last-7-days",
|
|
113
|
+
"page.compare": "previous-year",
|
|
114
|
+
};
|
|
115
|
+
remember({
|
|
116
|
+
period: "last-30-days",
|
|
117
|
+
});
|
|
118
|
+
mount("page");
|
|
119
|
+
expect(handles.page!.preset.value).toBe("last-7-days");
|
|
120
|
+
expect(handles.page!.comparison.value).toBe("previous-year");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("is the user's last choice when the URL names none", () => {
|
|
124
|
+
remember({
|
|
125
|
+
period: "last-30-days",
|
|
126
|
+
compare: "previous-period",
|
|
127
|
+
});
|
|
128
|
+
mount("page");
|
|
129
|
+
expect(handles.page!.preset.value).toBe("last-30-days");
|
|
130
|
+
expect(handles.page!.comparison.value).toBe("previous-period");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is the default when neither names a valid one", () => {
|
|
134
|
+
route.query = { "page.period": "next-century" };
|
|
135
|
+
remember({
|
|
136
|
+
period: "ytd",
|
|
137
|
+
});
|
|
138
|
+
mount("page");
|
|
139
|
+
expect(handles.page!.preset.value).toBe("this-month");
|
|
140
|
+
expect(handles.page!.comparison.value).toBe("none");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("is a custom range read from the URL", () => {
|
|
144
|
+
route.query = { "page.from": "2026-10-01", "page.to": "2026-10-15" };
|
|
145
|
+
mount("page");
|
|
146
|
+
expect(handles.page!.preset.value).toBe("custom");
|
|
147
|
+
expect(handles.page!.state.value.range).toEqual({
|
|
148
|
+
from: new Date(2026, 9, 1),
|
|
149
|
+
to: new Date(2026, 9, 15, 23, 59, 59, 999),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is rendered by the server, so the default never flashes", async () => {
|
|
154
|
+
route.query = { "page.period": "last-7-days" };
|
|
155
|
+
const html = await renderToString(createSSRApp(selector("page")));
|
|
156
|
+
expect(html).toContain("last-7-days|none");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("the URL on load", () => {
|
|
161
|
+
it("carries the period the selector starts from, the default included", () => {
|
|
162
|
+
window.history.replaceState(null, "", `${PAGE_PATH}?requests.tab=get`);
|
|
163
|
+
mount("page");
|
|
164
|
+
expect(Object.fromEntries(search())).toEqual({
|
|
165
|
+
"requests.tab": "get",
|
|
166
|
+
"page.period": "this-month",
|
|
167
|
+
"page.compare": "none",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("is left alone when it already names the period", () => {
|
|
172
|
+
window.history.replaceState(
|
|
173
|
+
null,
|
|
174
|
+
"",
|
|
175
|
+
`${PAGE_PATH}?page.period=this-month&page.compare=none`,
|
|
176
|
+
);
|
|
177
|
+
route.query = { "page.period": "this-month", "page.compare": "none" };
|
|
178
|
+
mount("page");
|
|
179
|
+
expect(inertiaReplace).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("a change of period", () => {
|
|
184
|
+
it("reaches the route, so a tab written from it keeps the new period", async () => {
|
|
185
|
+
mount("page");
|
|
186
|
+
handles.page!.setPreset("last-7-days");
|
|
187
|
+
await nextTick();
|
|
188
|
+
// What useTab does: the route's query, plus its own key.
|
|
189
|
+
const query = { ...route.query, tab: "2" };
|
|
190
|
+
window.history.replaceState(
|
|
191
|
+
null,
|
|
192
|
+
"",
|
|
193
|
+
`${PAGE_PATH}?${new URLSearchParams(query)}`,
|
|
194
|
+
);
|
|
195
|
+
expect(search().get("tab")).toBe("2");
|
|
196
|
+
expect(search().get("page.period")).toBe("last-7-days");
|
|
197
|
+
expect(handles.page!.preset.value).toBe("last-7-days");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("replaces the URL in place and remembers the choice", async () => {
|
|
201
|
+
mount("page");
|
|
202
|
+
const entries = window.history.length;
|
|
203
|
+
handles.page!.setPreset("last-7-days");
|
|
204
|
+
handles.page!.setComparison("previous-period");
|
|
205
|
+
await nextTick();
|
|
206
|
+
expect(search().get("page.period")).toBe("last-7-days");
|
|
207
|
+
expect(search().get("page.compare")).toBe("previous-period");
|
|
208
|
+
expect(window.history.length).toBe(entries);
|
|
209
|
+
expect(
|
|
210
|
+
(preferences.get(PERIOD_PREFERENCE_KEY) as Record<string, unknown>)[
|
|
211
|
+
`${PAGE_ID}#page`
|
|
212
|
+
],
|
|
213
|
+
).toEqual({ period: "last-7-days", compare: "previous-period" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("swaps the preset key for the custom range keys", async () => {
|
|
217
|
+
window.history.replaceState(
|
|
218
|
+
null,
|
|
219
|
+
"",
|
|
220
|
+
`${PAGE_PATH}?page.period=last-7-days`,
|
|
221
|
+
);
|
|
222
|
+
mount("page");
|
|
223
|
+
handles.page!.setCustomRange({
|
|
224
|
+
from: new Date(2026, 9, 1),
|
|
225
|
+
to: new Date(2026, 9, 2, 23, 59, 59, 999),
|
|
226
|
+
});
|
|
227
|
+
await nextTick();
|
|
228
|
+
expect(search().get("page.period")).toBeNull();
|
|
229
|
+
expect(search().get("page.from")).toBe("2026-10-01");
|
|
230
|
+
expect(search().get("page.to")).toBe("2026-10-02");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("leaves another selector's keys and the other parameters alone", async () => {
|
|
234
|
+
window.history.replaceState(null, "", `${PAGE_PATH}?requests.tab=get`);
|
|
235
|
+
mount("sales", "traffic");
|
|
236
|
+
handles.sales!.setPreset("last-7-days");
|
|
237
|
+
await nextTick();
|
|
238
|
+
handles.traffic!.setPreset("last-30-days");
|
|
239
|
+
await nextTick();
|
|
240
|
+
expect(Object.fromEntries(search())).toEqual({
|
|
241
|
+
"requests.tab": "get",
|
|
242
|
+
"sales.period": "last-7-days",
|
|
243
|
+
"sales.compare": "none",
|
|
244
|
+
"traffic.period": "last-30-days",
|
|
245
|
+
"traffic.compare": "none",
|
|
246
|
+
});
|
|
247
|
+
expect(handles.sales!.preset.value).toBe("last-7-days");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("a route changed from outside", () => {
|
|
252
|
+
it("moves the period of the selector it names", async () => {
|
|
253
|
+
mount("sales", "traffic");
|
|
254
|
+
route.query = { "sales.period": "last-30-days" };
|
|
255
|
+
await nextTick();
|
|
256
|
+
expect(handles.sales!.preset.value).toBe("last-30-days");
|
|
257
|
+
expect(handles.traffic!.preset.value).toBe("this-month");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps the period when it names an invalid one", async () => {
|
|
261
|
+
mount("page");
|
|
262
|
+
handles.page!.setPreset("last-7-days");
|
|
263
|
+
await nextTick();
|
|
264
|
+
route.query = { "page.period": "last-decade" };
|
|
265
|
+
await nextTick();
|
|
266
|
+
expect(handles.page!.preset.value).toBe("last-7-days");
|
|
267
|
+
});
|
|
268
|
+
});
|