@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
@@ -5,6 +5,12 @@
5
5
  */
6
6
  export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
7
7
 
8
+ /**
9
+ * The window event of a refresh nobody asked for (a timer, a feed): on a page
10
+ * declared with `live` it goes through the Direct / Pause gate.
11
+ */
12
+ export const LIVE_BLOCK_REFRESH_EVENT = "DmsComponent.Block.LiveRefresh";
13
+
8
14
  /** The watch functions every block can name (see interface-dms). */
9
15
  export const BlockFunctions = {
10
16
  REFRESH_PAGE: "DmsComponent.Block.RefreshPage",
@@ -12,17 +18,49 @@ export const BlockFunctions = {
12
18
 
13
19
  /**
14
20
  * Asks every data block mounted on the page to read its data again: what a
15
- * component calls once an action of its own changed the record the page
16
- * shows. The page header calls it after a button changed something.
21
+ * component calls once an action of the user changed the record the page
22
+ * shows. The page header calls it after a button changed something, a form
23
+ * through `BlockFunctions.REFRESH_PAGE` once it saved. A user action always
24
+ * refreshes: on a page declared with `live` it reads even while paused, and
25
+ * the blocks it reads no longer count what they had missed.
17
26
  */
18
27
  export function refreshPageBlocks(): void {
19
28
  if (typeof window === "undefined") return;
20
29
  window.dispatchEvent(new CustomEvent(BLOCK_REFRESH_EVENT));
21
30
  }
22
31
 
23
- /** Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. */
24
- export function onPageBlocksRefresh(handler: () => void): () => void {
32
+ /**
33
+ * Asks every data block mounted on the page to read its data again because
34
+ * time passed or a feed moved, not because the user did something: on a page
35
+ * declared with `live` it goes through the page's Direct / Pause gate like a
36
+ * realtime event — capped by `maxRate` in Direct, counted while paused and
37
+ * read once on resume. Without `live`, it is `refreshPageBlocks()`.
38
+ */
39
+ export function refreshLivePageBlocks(): void {
40
+ if (typeof window === "undefined") return;
41
+ window.dispatchEvent(new CustomEvent(LIVE_BLOCK_REFRESH_EVENT));
42
+ }
43
+
44
+ /**
45
+ * Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. Every
46
+ * handler receives the same event for one call, which tells one refresh
47
+ * reaching several blocks from several refreshes.
48
+ */
49
+ export function onPageBlocksRefresh(
50
+ handler: (event: Event) => void,
51
+ ): () => void {
52
+ return listen(BLOCK_REFRESH_EVENT, handler);
53
+ }
54
+
55
+ /** Calls `handler` on each `refreshLivePageBlocks()`; returns the unsubscribe. */
56
+ export function onLivePageBlocksRefresh(
57
+ handler: (event: Event) => void,
58
+ ): () => void {
59
+ return listen(LIVE_BLOCK_REFRESH_EVENT, handler);
60
+ }
61
+
62
+ function listen(name: string, handler: (event: Event) => void): () => void {
25
63
  if (typeof window === "undefined") return () => {};
26
- window.addEventListener(BLOCK_REFRESH_EVENT, handler);
27
- return () => window.removeEventListener(BLOCK_REFRESH_EVENT, handler);
64
+ window.addEventListener(name, handler);
65
+ return () => window.removeEventListener(name, handler);
28
66
  }
@@ -0,0 +1,21 @@
1
+ // A page's top-level block is identified by its key; a block nested in a
2
+ // layout block (a Grid, a GridRow) by its parent's id, this separator, then
3
+ // its own key.
4
+ const CHILD_SEPARATOR = "-child-";
5
+
6
+ /** The component id of the child `key` of the block `parentId`. */
7
+ export function childComponentId(parentId: string, key: string): string {
8
+ return `${parentId}${CHILD_SEPARATOR}${key}`;
9
+ }
10
+
11
+ /**
12
+ * The component id of the block `key` declared next to the block
13
+ * `componentId`, under the same parent: a custom block reaching the form
14
+ * beside it (its `FIELD_CHANGE` events) without parsing ids.
15
+ */
16
+ export function siblingComponentId(componentId: string, key: string): string {
17
+ const nested = componentId.lastIndexOf(CHILD_SEPARATOR);
18
+ return nested >= 0
19
+ ? childComponentId(componentId.slice(0, nested), key)
20
+ : key;
21
+ }
@@ -46,3 +46,15 @@ export const resolveRowClickAction = (
46
46
  if (isEnabledForRow(rowActions?.details, row)) return "details";
47
47
  return undefined;
48
48
  };
49
+
50
+ /**
51
+ * The custom row actions `row` offers: those whose `rule` accepts it, in
52
+ * their declared order (a card's own menu, `actions.customFor`).
53
+ */
54
+ export const rowCustomActions = (
55
+ custom: CustomRowAction[] | undefined,
56
+ row: Record<string, unknown>,
57
+ ): CustomRowAction[] =>
58
+ (custom ?? []).filter(
59
+ (action) => !action.rule || evaluateRowActionRule(action.rule, row),
60
+ );
@@ -469,6 +469,19 @@
469
469
  "delete": {
470
470
  "description": "Delete hovered row",
471
471
  "condition": "Only when enableRowDelete is enabled"
472
+ },
473
+ "j": {
474
+ "description": "Next row"
475
+ },
476
+ "k": {
477
+ "description": "Previous row"
478
+ },
479
+ "row_open": {
480
+ "condition": "While a row is open in a side panel, a drawer or a modal, outside a text field"
481
+ },
482
+ "panel_escape": {
483
+ "description": "Close the row side panel",
484
+ "condition": "While a row is open in a side panel, once the table has no search or selection to clear"
472
485
  }
473
486
  },
474
487
  "tab": {
@@ -492,6 +505,11 @@
492
505
  "escape": {
493
506
  "description": "Close dialogs, menus and the palette"
494
507
  }
508
+ },
509
+ "livetoggle": {
510
+ "l": {
511
+ "description": "Switch the page between Direct and Pause"
512
+ }
495
513
  }
496
514
  },
497
515
  "notifications": {
@@ -709,6 +727,16 @@
709
727
  "action_error": {
710
728
  "forbidden": "You don’t have permission to do this.",
711
729
  "generic": "Something went wrong and the action couldn’t be completed. Try again."
730
+ },
731
+ "live": {
732
+ "direct": "Direct",
733
+ "pause": "Pause",
734
+ "aria_label": "Live updates",
735
+ "updated_seconds": "Updated {count} s ago",
736
+ "updated_minutes": "Updated {count} min ago",
737
+ "updated_hours": "Updated {count} h ago",
738
+ "new_rows": "{count} new",
739
+ "show_new": "Show"
712
740
  }
713
741
  }
714
742
  }
@@ -470,6 +470,19 @@
470
470
  "delete": {
471
471
  "description": "Supprimer la ligne survolée",
472
472
  "condition": "Uniquement quand enableRowDelete est activé"
473
+ },
474
+ "j": {
475
+ "description": "Ligne suivante"
476
+ },
477
+ "k": {
478
+ "description": "Ligne précédente"
479
+ },
480
+ "row_open": {
481
+ "condition": "Quand une ligne est ouverte dans un panneau latéral, un tiroir ou une fenêtre, hors d'un champ de saisie"
482
+ },
483
+ "panel_escape": {
484
+ "description": "Fermer le panneau latéral de la ligne",
485
+ "condition": "Quand une ligne est ouverte dans un panneau latéral, une fois que la table n'a plus de recherche ni de sélection à effacer"
473
486
  }
474
487
  },
475
488
  "tab": {
@@ -493,6 +506,11 @@
493
506
  "escape": {
494
507
  "description": "Fermer les dialogues, les menus et la palette"
495
508
  }
509
+ },
510
+ "livetoggle": {
511
+ "l": {
512
+ "description": "Basculer la page entre Direct et Pause"
513
+ }
496
514
  }
497
515
  },
498
516
  "notifications": {
@@ -710,6 +728,16 @@
710
728
  "action_error": {
711
729
  "forbidden": "Vous n’avez pas l’autorisation d’effectuer cette action.",
712
730
  "generic": "Une erreur est survenue et l’action n’a pas pu aboutir. Réessayez."
731
+ },
732
+ "live": {
733
+ "direct": "Direct",
734
+ "pause": "Pause",
735
+ "aria_label": "Mises à jour en direct",
736
+ "updated_seconds": "Mis à jour il y a {count} s",
737
+ "updated_minutes": "Mis à jour il y a {count} min",
738
+ "updated_hours": "Mis à jour il y a {count} h",
739
+ "new_rows": "{count} nouvelle | {count} nouvelles",
740
+ "show_new": "Afficher"
713
741
  }
714
742
  }
715
743
  }
@@ -0,0 +1,74 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A chart on a paused live page sends no request on its events or on a page
4
+ * refresh, and reads once when the page resumes.
5
+ */
6
+ import { createApp, defineComponent, h, ref } from "vue";
7
+ import { afterEach, expect, it, vi } from "vitest";
8
+ import {
9
+ refreshLivePageBlocks,
10
+ refreshPageBlocks,
11
+ } from "../layers/dms-ui/app/utils/blockRefresh";
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
+
15
+ const requests: string[] = [];
16
+
17
+ vi.stubGlobal("useAuthFetch", () => ({
18
+ $authFetch: async (url: string) => {
19
+ requests.push(url);
20
+ return { value: requests.length };
21
+ },
22
+ }));
23
+ vi.stubGlobal("useDmsRoute", () => ({ query: {} }));
24
+ vi.stubGlobal("ref", ref);
25
+
26
+ const { useChartFetch } = await import(
27
+ "../layers/dms-ui/app/composables/chart/useChartFetch"
28
+ );
29
+
30
+ // useChartFetch debounces every refresh past the first by 180ms.
31
+ const SETTLE_MS = 250;
32
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
33
+
34
+ const apps: Array<() => void> = [];
35
+
36
+ afterEach(() => {
37
+ apps.splice(0).forEach((unmount) => unmount());
38
+ setCurrentPageLive(null);
39
+ window.localStorage.clear();
40
+ });
41
+
42
+ it("requests nothing while paused and reads once on resume", async () => {
43
+ const live = createPageLiveController("api.overview", {});
44
+ setCurrentPageLive(live);
45
+ const app = createApp(
46
+ defineComponent({
47
+ setup() {
48
+ useChartFetch<{ value: number }>({ fetchUrl: "/api/overview/chart" });
49
+ return () => h("div");
50
+ },
51
+ }),
52
+ );
53
+ app.mount(document.createElement("div"));
54
+ apps.push(() => app.unmount());
55
+ await settle();
56
+ expect(requests).toHaveLength(1);
57
+
58
+ live.pause();
59
+ refreshLivePageBlocks();
60
+ refreshLivePageBlocks();
61
+ await settle();
62
+ expect(requests).toHaveLength(1);
63
+
64
+ live.resume();
65
+ await settle();
66
+ expect(requests).toHaveLength(2);
67
+
68
+ // An action of the user refreshes even while paused.
69
+ live.pause();
70
+ refreshPageBlocks();
71
+ await settle();
72
+ expect(requests).toHaveLength(3);
73
+ live.dispose();
74
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ childComponentId,
4
+ siblingComponentId,
5
+ } from "../layers/dms-ui/app/utils/componentId";
6
+
7
+ describe("siblingComponentId", () => {
8
+ it("names a top-level block's neighbour by its key", () => {
9
+ expect(siblingComponentId("preview", "form")).toBe("form");
10
+ });
11
+
12
+ it("names a nested block's neighbour under the same parent", () => {
13
+ const row = childComponentId("editor", "row");
14
+ expect(siblingComponentId(childComponentId(row, "preview"), "form")).toBe(
15
+ "editor-child-row-child-form",
16
+ );
17
+ });
18
+ });
@@ -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",
@@ -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
+ });