@antelopejs/dms 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/test/unit/interfaces/dms-base/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-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/form/FormSectionNav.vue +30 -15
- 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/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 +90 -12
- package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
- 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/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 +5 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- 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/field-row.test.ts +14 -0
- package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
- 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-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 +11 -3
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -74,6 +74,20 @@ describe("FieldRow", () => {
|
|
|
74
74
|
expect(body().className).toContain("py-4");
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
it("spans both form columns with its control when it has no label", async () => {
|
|
78
|
+
await mount({ layout: "form" }, { default: () => h("input") });
|
|
79
|
+
expect(body().children).toHaveLength(1);
|
|
80
|
+
expect(body().firstElementChild!.className).toContain("col-span-full");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps a labelled control in the form's control column", async () => {
|
|
84
|
+
await mount(
|
|
85
|
+
{ label: "Name", layout: "form" },
|
|
86
|
+
{ default: () => h("input") },
|
|
87
|
+
);
|
|
88
|
+
expect(body().lastElementChild!.className).not.toContain("col-span-full");
|
|
89
|
+
});
|
|
90
|
+
|
|
77
91
|
it("leaves the spacing to the list of a stacked form", async () => {
|
|
78
92
|
await mount({
|
|
79
93
|
label: "Name",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
/**
|
|
3
|
-
* A long sectioned form groups its
|
|
4
|
-
*
|
|
5
|
-
* row of chips
|
|
3
|
+
* A long sectioned form groups its navigation under headings: the sections
|
|
4
|
+
* naming the same `navGroup` one after the other share one, in the side list
|
|
5
|
+
* and in the row of chips alike. Without a group, the chips stay one row.
|
|
6
6
|
*/
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
8
|
import { type App, createApp, defineComponent, h, nextTick } from "vue";
|
|
@@ -37,7 +37,17 @@ const layout = layoutSections(
|
|
|
37
37
|
let app: App | undefined;
|
|
38
38
|
let host: HTMLDivElement;
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
const plainLayout = layoutSections(
|
|
41
|
+
[field("a"), field("b")],
|
|
42
|
+
[
|
|
43
|
+
{ id: "a", label: "A", fieldIds: ["a"] },
|
|
44
|
+
{ id: "b", label: "B", fieldIds: ["b"] },
|
|
45
|
+
],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
type NavProps = Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
async function mount(variant: "list" | "chips", props: NavProps = {}) {
|
|
41
51
|
app = createApp({
|
|
42
52
|
render: () =>
|
|
43
53
|
h(FormSectionNav, {
|
|
@@ -45,6 +55,7 @@ async function mount(variant: "list" | "chips") {
|
|
|
45
55
|
sections: layout.sections,
|
|
46
56
|
states: {},
|
|
47
57
|
anchorId: (id: string) => `form-${id}`,
|
|
58
|
+
...props,
|
|
48
59
|
}),
|
|
49
60
|
});
|
|
50
61
|
app.component("UIcon", defineComponent({ render: () => h("i") }));
|
|
@@ -82,14 +93,7 @@ describe("groupSectionsForNav", () => {
|
|
|
82
93
|
});
|
|
83
94
|
|
|
84
95
|
it("keeps a form without groups in one run", () => {
|
|
85
|
-
|
|
86
|
-
[field("a"), field("b")],
|
|
87
|
-
[
|
|
88
|
-
{ id: "a", label: "A", fieldIds: ["a"] },
|
|
89
|
-
{ id: "b", label: "B", fieldIds: ["b"] },
|
|
90
|
-
],
|
|
91
|
-
);
|
|
92
|
-
expect(groupSectionsForNav(plain.sections)).toHaveLength(1);
|
|
96
|
+
expect(groupSectionsForNav(plainLayout.sections)).toHaveLength(1);
|
|
93
97
|
});
|
|
94
98
|
});
|
|
95
99
|
|
|
@@ -106,11 +110,68 @@ describe("the side navigation", () => {
|
|
|
106
110
|
expect(headings).toEqual(["t($billing)", "Legal"]);
|
|
107
111
|
expect(lists[1]!.querySelectorAll("li")).toHaveLength(2);
|
|
108
112
|
});
|
|
113
|
+
});
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
function labelledRuns(): [string | undefined, string[]][] {
|
|
116
|
+
return [...host.querySelectorAll("ul")].map((list) => {
|
|
117
|
+
const id = list.getAttribute("aria-labelledby");
|
|
118
|
+
const label = id
|
|
119
|
+
? document.getElementById(id)?.textContent?.trim()
|
|
120
|
+
: undefined;
|
|
121
|
+
const chips = [...list.querySelectorAll("a")].map(
|
|
122
|
+
(chip) => chip.textContent?.trim() ?? "",
|
|
123
|
+
);
|
|
124
|
+
return [label, chips];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("the row of chips", () => {
|
|
129
|
+
it("leads each run of chips with its group's label", async () => {
|
|
111
130
|
await mount("chips");
|
|
131
|
+
expect(labelledRuns()).toEqual([
|
|
132
|
+
[undefined, ["Plan"]],
|
|
133
|
+
["t($billing)", ["Dunning", "Tax"]],
|
|
134
|
+
["Legal", ["Terms"]],
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("wraps the runs instead of scrolling one line", async () => {
|
|
139
|
+
await mount("chips");
|
|
140
|
+
const nav = host.querySelector("nav")!;
|
|
141
|
+
expect(nav.classList.contains("flex-wrap")).toBe(true);
|
|
142
|
+
for (const list of host.querySelectorAll("ul")) {
|
|
143
|
+
expect(list.classList.contains("overflow-x-auto")).toBe(false);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps a form without groups in one scrolling row", async () => {
|
|
148
|
+
await mount("chips", { sections: plainLayout.sections });
|
|
112
149
|
expect(host.querySelectorAll("ul")).toHaveLength(1);
|
|
113
|
-
expect(host.querySelectorAll("li")).toHaveLength(
|
|
150
|
+
expect(host.querySelectorAll("li")).toHaveLength(2);
|
|
114
151
|
expect(host.querySelector("[aria-labelledby]")).toBeNull();
|
|
152
|
+
expect(host.querySelector("p")).toBeNull();
|
|
153
|
+
expect(host.querySelector("ul")!.className).toBe(
|
|
154
|
+
"flex gap-1.5 overflow-x-auto pb-1",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("links each chip to its section and reports the one chosen", async () => {
|
|
159
|
+
const selected: string[] = [];
|
|
160
|
+
await mount("chips", {
|
|
161
|
+
activeId: "tax",
|
|
162
|
+
onSelect: (id: string) => selected.push(id),
|
|
163
|
+
});
|
|
164
|
+
const chips = [...host.querySelectorAll("a")];
|
|
165
|
+
expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([
|
|
166
|
+
"#form-plan",
|
|
167
|
+
"#form-dunning",
|
|
168
|
+
"#form-tax",
|
|
169
|
+
"#form-terms",
|
|
170
|
+
]);
|
|
171
|
+
expect(
|
|
172
|
+
host.querySelector("[aria-current='location']")?.textContent?.trim(),
|
|
173
|
+
).toBe("Tax");
|
|
174
|
+
chips[3]!.click();
|
|
175
|
+
expect(selected).toEqual(["terms"]);
|
|
115
176
|
});
|
|
116
177
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The strip of a held table: "N new · Show", Show reading the rows again.
|
|
4
|
+
*/
|
|
5
|
+
import { createApp, h, type App } from "vue";
|
|
6
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
let app: App | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
app?.unmount();
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("draws the banner and reads the rows on Show", async () => {
|
|
16
|
+
vi.stubGlobal("useI18n", () => ({
|
|
17
|
+
t: (key: string, params?: { count?: number }) =>
|
|
18
|
+
params ? `${key}:${params.count}` : key,
|
|
19
|
+
}));
|
|
20
|
+
const { default: LiveHoldBanner } = await import(
|
|
21
|
+
"../layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue"
|
|
22
|
+
);
|
|
23
|
+
const onShow = vi.fn();
|
|
24
|
+
const host = document.createElement("div");
|
|
25
|
+
app = createApp({
|
|
26
|
+
render: () => h(LiveHoldBanner, { count: 3, onShow }),
|
|
27
|
+
});
|
|
28
|
+
app.component("UIcon", () => h("i"));
|
|
29
|
+
app.component("UButton", (props: { label: string }, { attrs }) =>
|
|
30
|
+
h("button", attrs, props.label),
|
|
31
|
+
);
|
|
32
|
+
app.mount(host);
|
|
33
|
+
|
|
34
|
+
expect(host.textContent).toContain("dms.live.new_rows:3");
|
|
35
|
+
host.querySelector("button")?.click();
|
|
36
|
+
expect(onShow).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* The LiveToggle block: Direct / Pause as a two-segment toggle, "updated N s
|
|
4
|
+
* ago" counted every second in the browser, L switching the page outside a
|
|
5
|
+
* text field, and nothing at all on a page without `live`.
|
|
6
|
+
*/
|
|
7
|
+
import { createApp, h, nextTick, type App } from "vue";
|
|
8
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
9
|
+
import { defineShortcuts } from "@nuxt/ui/runtime/composables/defineShortcuts.js";
|
|
10
|
+
import {
|
|
11
|
+
createPageLiveController,
|
|
12
|
+
type PageLiveController,
|
|
13
|
+
} from "../layers/dms-ui/app/build/composables/live/pageLiveController";
|
|
14
|
+
import { setCurrentPageLive } from "../layers/dms-ui/app/build/composables/live/currentPageLive";
|
|
15
|
+
import { formatElapsed } from "../layers/dms-ui/app/build/composables/live/formatElapsed";
|
|
16
|
+
|
|
17
|
+
let app: App | undefined;
|
|
18
|
+
let host: HTMLDivElement;
|
|
19
|
+
let live: PageLiveController | undefined;
|
|
20
|
+
|
|
21
|
+
const t = (key: string, params?: { count?: number }) =>
|
|
22
|
+
params?.count === undefined ? key : `${key}:${params.count}`;
|
|
23
|
+
|
|
24
|
+
async function createToggle(
|
|
25
|
+
target: HTMLElement,
|
|
26
|
+
props: Record<string, unknown> = {},
|
|
27
|
+
): Promise<App> {
|
|
28
|
+
const { default: LiveToggleBlock } = await import(
|
|
29
|
+
"../layers/dms-ui/app/components/blocks/LiveToggleBlock.vue"
|
|
30
|
+
);
|
|
31
|
+
const toggle = createApp({
|
|
32
|
+
render: () =>
|
|
33
|
+
h(LiveToggleBlock, { componentId: "live", pageId: "api.logs", ...props }),
|
|
34
|
+
});
|
|
35
|
+
toggle.component("UIcon", () => h("i"));
|
|
36
|
+
toggle.mount(target);
|
|
37
|
+
await nextTick();
|
|
38
|
+
return toggle;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function mountToggle(props: Record<string, unknown> = {}) {
|
|
42
|
+
app = await createToggle(host, props);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const segment = (value: string) =>
|
|
46
|
+
host.querySelector<HTMLButtonElement>(
|
|
47
|
+
`[role="radio"][data-value="${value}"]`,
|
|
48
|
+
) ??
|
|
49
|
+
[...host.querySelectorAll<HTMLButtonElement>('[role="radio"]')].find(
|
|
50
|
+
(button) => button.textContent?.includes(`dms.live.${value}`),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const pressL = (target: EventTarget = document) =>
|
|
54
|
+
target.dispatchEvent(
|
|
55
|
+
new KeyboardEvent("keydown", { key: "l", bubbles: true }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
vi.stubGlobal("useI18n", () => ({ t }));
|
|
61
|
+
vi.stubGlobal("defineShortcuts", defineShortcuts);
|
|
62
|
+
host = document.createElement("div");
|
|
63
|
+
document.body.append(host);
|
|
64
|
+
window.localStorage.clear();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
app?.unmount();
|
|
69
|
+
app = undefined;
|
|
70
|
+
host.remove();
|
|
71
|
+
live?.dispose();
|
|
72
|
+
live = undefined;
|
|
73
|
+
setCurrentPageLive(null);
|
|
74
|
+
vi.unstubAllGlobals();
|
|
75
|
+
vi.useRealTimers();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function installLive() {
|
|
79
|
+
live = createPageLiveController("api.logs", { maxRate: 2 });
|
|
80
|
+
setCurrentPageLive(live);
|
|
81
|
+
return live;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("renders nothing on a page without live", async () => {
|
|
85
|
+
await mountToggle();
|
|
86
|
+
expect(host.querySelector(".dms-live-toggle")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("shows the page's state and switches it", async () => {
|
|
90
|
+
const controller = installLive();
|
|
91
|
+
await mountToggle();
|
|
92
|
+
expect(segment("direct")?.getAttribute("aria-checked")).toBe("true");
|
|
93
|
+
|
|
94
|
+
segment("pause")?.click();
|
|
95
|
+
await nextTick();
|
|
96
|
+
expect(controller.isPaused.value).toBe(true);
|
|
97
|
+
expect(segment("pause")?.getAttribute("aria-checked")).toBe("true");
|
|
98
|
+
|
|
99
|
+
segment("direct")?.click();
|
|
100
|
+
await nextTick();
|
|
101
|
+
expect(controller.isPaused.value).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("counts the seconds since the last update, every second", async () => {
|
|
105
|
+
installLive();
|
|
106
|
+
await mountToggle();
|
|
107
|
+
expect(host.textContent).toContain("dms.live.updated_seconds:0");
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(3000);
|
|
110
|
+
await nextTick();
|
|
111
|
+
expect(host.textContent).toContain("dms.live.updated_seconds:3");
|
|
112
|
+
|
|
113
|
+
vi.advanceTimersByTime(60_000);
|
|
114
|
+
await nextTick();
|
|
115
|
+
expect(host.textContent).toContain("dms.live.updated_minutes:1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("hides the time when asked to", async () => {
|
|
119
|
+
installLive();
|
|
120
|
+
await mountToggle({ showUpdatedAt: false });
|
|
121
|
+
expect(host.textContent).not.toContain("dms.live.updated");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("switches on L, but not while typing", async () => {
|
|
125
|
+
const controller = installLive();
|
|
126
|
+
await mountToggle();
|
|
127
|
+
|
|
128
|
+
pressL();
|
|
129
|
+
expect(controller.isPaused.value).toBe(true);
|
|
130
|
+
pressL();
|
|
131
|
+
expect(controller.isPaused.value).toBe(false);
|
|
132
|
+
|
|
133
|
+
const field = document.createElement("input");
|
|
134
|
+
document.body.append(field);
|
|
135
|
+
field.focus();
|
|
136
|
+
await nextTick();
|
|
137
|
+
pressL(field);
|
|
138
|
+
expect(controller.isPaused.value).toBe(false);
|
|
139
|
+
field.remove();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("reads elapsed times in seconds, minutes, then hours", () => {
|
|
143
|
+
expect(formatElapsed(-5)).toEqual({ unit: "seconds", count: 0 });
|
|
144
|
+
expect(formatElapsed(59_999)).toEqual({ unit: "seconds", count: 59 });
|
|
145
|
+
expect(formatElapsed(60_000)).toEqual({ unit: "minutes", count: 1 });
|
|
146
|
+
expect(formatElapsed(2 * 3_600_000 + 1)).toEqual({ unit: "hours", count: 2 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("switches once per L with two toggles on the page", async () => {
|
|
150
|
+
const controller = installLive();
|
|
151
|
+
await mountToggle();
|
|
152
|
+
const second = await createToggle(document.createElement("div"));
|
|
153
|
+
|
|
154
|
+
pressL();
|
|
155
|
+
expect(controller.isPaused.value).toBe(true);
|
|
156
|
+
|
|
157
|
+
app?.unmount();
|
|
158
|
+
app = undefined;
|
|
159
|
+
pressL();
|
|
160
|
+
expect(controller.isPaused.value).toBe(false);
|
|
161
|
+
second.unmount();
|
|
162
|
+
});
|
|
@@ -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
|
+
});
|