@antelopejs/dms 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/hooks/tenant-instances.d.ts +1 -0
  3. package/dist/hooks/tenant-instances.js +13 -0
  4. package/dist/hooks/tenant-instances.js.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +67 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js +1 -0
  10. package/dist/test/unit/interfaces/dms-base/block-vocabulary.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.d.ts +1 -0
  12. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js +46 -0
  13. package/dist/test/unit/interfaces/dms-base/code-block-catalog.test.js.map +1 -0
  14. package/dist/test/unit/interfaces/dms-base/column-display.test.js +11 -0
  15. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +31 -0
  17. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  18. package/dist/test/unit/utils/tenant-instances.test.d.ts +1 -0
  19. package/dist/test/unit/utils/tenant-instances.test.js +53 -0
  20. package/dist/test/unit/utils/tenant-instances.test.js.map +1 -0
  21. package/dist/utils/index.d.ts +1 -0
  22. package/dist/utils/index.js +1 -0
  23. package/dist/utils/index.js.map +1 -1
  24. package/dist/utils/tenant-instances.d.ts +2 -0
  25. package/dist/utils/tenant-instances.js +38 -0
  26. package/dist/utils/tenant-instances.js.map +1 -0
  27. package/dist/validation/auth.schema.d.ts +2 -2
  28. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +4 -0
  29. package/frontend-vue/layers/dms-ui/app/build/components/activity-feed/activityFeedDays.ts +35 -23
  30. package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockActions.vue +34 -6
  31. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +26 -8
  32. package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +3 -1
  33. package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +1 -1
  34. package/frontend-vue/layers/dms-ui/app/build/components/table/MonoCell.vue +3 -2
  35. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +24 -13
  36. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +1 -1
  37. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +12 -5
  38. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +40 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +15 -4
  40. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +85 -22
  41. package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +10 -2
  42. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +79 -0
  43. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +94 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +41 -5
  45. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/tableQuery.ts +3 -0
  46. package/frontend-vue/layers/dms-ui/app/build/utils/codeEditor.ts +2 -13
  47. package/frontend-vue/layers/dms-ui/app/build/utils/codeHighlight.ts +59 -0
  48. package/frontend-vue/layers/dms-ui/app/build/utils/codeLanguages.ts +36 -0
  49. package/frontend-vue/layers/dms-ui/app/build/utils/codePalette.ts +32 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/shellParser.ts +55 -0
  51. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +77 -20
  52. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +15 -4
  53. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +111 -25
  54. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +95 -0
  55. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +18 -0
  56. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +8 -0
  57. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +21 -9
  58. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +8 -0
  59. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +97 -0
  60. package/frontend-vue/layers/dms-ui/app/components/empty-state/EmptyState.vue +13 -4
  61. package/frontend-vue/layers/dms-ui/app/components/flow-canvas/FlowCanvas.vue +14 -3
  62. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +2 -8
  63. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +19 -0
  64. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +54 -48
  65. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +24 -9
  66. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +4 -1
  67. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +66 -74
  68. package/frontend-vue/layers/dms-ui/app/components/top-list/internal/TopListRow.vue +4 -3
  69. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +5 -2
  70. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +2 -29
  71. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -1
  72. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +2 -0
  73. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +4 -0
  74. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +2 -2
  75. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  76. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  77. package/frontend-vue/package.json +2 -2
  78. package/frontend-vue/pnpm-lock.yaml +5 -5
  79. package/frontend-vue/tests/activity-feed-days.test.ts +26 -12
  80. package/frontend-vue/tests/banner-block-fetch.test.ts +162 -0
  81. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +100 -0
  82. package/frontend-vue/tests/block-items-period-realtime.test.ts +147 -0
  83. package/frontend-vue/tests/block-route-params.test.ts +48 -0
  84. package/frontend-vue/tests/code-block.test.ts +155 -0
  85. package/frontend-vue/tests/code-highlight.test.ts +72 -0
  86. package/frontend-vue/tests/composed-text-blocks.test.ts +5 -0
  87. package/frontend-vue/tests/composed-text-more-blocks.test.ts +391 -0
  88. package/frontend-vue/tests/drawer-target-direction.test.ts +205 -0
  89. package/frontend-vue/tests/empty-state-late-slots.test.ts +53 -0
  90. package/frontend-vue/tests/flow-canvas-delete-badge.test.ts +88 -0
  91. package/frontend-vue/tests/form-redirect-placeholders.test.ts +11 -0
  92. package/frontend-vue/tests/key-value-list-copy.test.ts +114 -0
  93. package/frontend-vue/tests/master-detail-layout.test.ts +53 -0
  94. package/frontend-vue/tests/mono-cell-truncate.test.ts +50 -0
  95. package/frontend-vue/tests/nav-card-grid-empty-category.test.ts +1 -0
  96. package/frontend-vue/tests/page-fill-height.test.ts +20 -3
  97. package/frontend-vue/tests/route-param-occurrences.test.ts +9 -0
  98. package/frontend-vue/tests/table-actions-search-label.test.ts +147 -0
  99. package/frontend-vue/tests/table-clear-filters.test.ts +22 -0
  100. package/frontend-vue/tests/table-empty-body-viewport.test.ts +25 -0
  101. package/frontend-vue/tests/table-metric-cells.test.ts +22 -0
  102. package/frontend-vue/tests/table-pills-cell.test.ts +126 -0
  103. package/frontend-vue/tests/table-source-navigation.test.ts +123 -0
  104. package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +137 -0
  105. package/frontend-vue/tests/table-view-refresh.test.ts +84 -0
  106. package/frontend-vue/tests/table-view-sources.test.ts +67 -0
  107. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -3
  110. package/skills/dms-pages/REFERENCE.md +17 -1
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  computed,
3
- onBeforeUnmount,
4
3
  onMounted,
5
4
  ref,
6
5
  watch,
@@ -12,12 +11,11 @@ import {
12
11
  usePeriodScope,
13
12
  } from "../../../../dms-core/app/composables/period/usePeriodScope";
14
13
  import type { PeriodState } from "../../../../dms-core/app/composables/period/types";
15
- import { useRealtimeTopic } from "../../../../dms-core/app/composables/realtime/useRealtimeTopic";
16
14
  import {
17
15
  hasUrlVariables,
18
16
  resolveUrlVariables,
19
17
  } from "../../build/utils/urlVariables";
20
- import { onPageBlocksRefresh } from "../../utils/blockRefresh";
18
+ import { useRefreshTriggers } from "../../build/composables/blocks/useRefreshTriggers";
21
19
 
22
20
  export interface UseChartFetchOptions<T> {
23
21
  /**
@@ -132,20 +130,6 @@ function readStaticData<T>(
132
130
  return source ?? null;
133
131
  }
134
132
 
135
- function subscribeRealtimeTopics(
136
- topic: string | string[] | undefined,
137
- onEvent: () => void,
138
- ): void {
139
- if (!topic) return;
140
- const topics = Array.isArray(topic) ? topic : [topic];
141
- for (const entry of topics) {
142
- useRealtimeTopic(entry, (event) => {
143
- if (!("type" in event)) return;
144
- onEvent();
145
- });
146
- }
147
- }
148
-
149
133
  /**
150
134
  * The URL to request: `fetchUrl` with its tokens filled, or `undefined` while
151
135
  * one has no value. Only a URL naming a token reads the route.
@@ -164,16 +148,6 @@ function useResolvedFetchUrl<T>(
164
148
  );
165
149
  }
166
150
 
167
- // Mounted only: the page refresh is a browser event, and a block the server
168
- // renders has nothing to refetch.
169
- function subscribePageRefresh(onRefresh: () => void): void {
170
- let unsubscribe: (() => void) | undefined;
171
- onMounted(() => {
172
- unsubscribe = onPageBlocksRefresh(onRefresh);
173
- });
174
- onBeforeUnmount(() => unsubscribe?.());
175
- }
176
-
177
151
  export function useChartFetch<T>(
178
152
  options: UseChartFetchOptions<T>,
179
153
  ): UseChartFetchReturn<T> {
@@ -290,8 +264,7 @@ export function useChartFetch<T>(
290
264
  scheduleRefresh,
291
265
  );
292
266
  onMounted(fetchOnMount);
293
- subscribeRealtimeTopics(options.realtimeTopic, refreshSameInputs);
294
- subscribePageRefresh(refreshSameInputs);
267
+ useRefreshTriggers(options.realtimeTopic, refreshSameInputs);
295
268
  } else if (typeof options.staticData === "function") {
296
269
  watch(
297
270
  () => readStaticData(options.staticData),
@@ -13,6 +13,7 @@ import {
13
13
  type ReplaceUrlVariablesContext,
14
14
  hasUrlVariables,
15
15
  replaceUrlVariables,
16
+ resolveTextVariables,
16
17
  resolveUrlVariables,
17
18
  } from "../../build/utils/urlVariables";
18
19
 
@@ -347,7 +348,7 @@ export function resolveSubmitDefaults(
347
348
  const resolved: Record<string, unknown> = {};
348
349
  for (const [key, value] of Object.entries(submitDefaults)) {
349
350
  if (typeof value === "string" && hasUrlVariables(value)) {
350
- const replaced = resolveUrlVariables(value, context);
351
+ const replaced = resolveTextVariables(value, context);
351
352
  if (replaced === undefined) continue;
352
353
  resolved[key] = replaced;
353
354
  } else {
@@ -29,6 +29,8 @@ export interface TabProps extends TabComponentProps {
29
29
  * names, read in one request and again when a watch action fires.
30
30
  */
31
31
  badgesUrl?: string;
32
+ /** Topics whose events make the tab set read `badgesUrl` again. */
33
+ realtimeTopic?: string | string[];
32
34
  }
33
35
 
34
36
  /** The badges `badgesUrl` answers with, by tab slot. */
@@ -1,9 +1,13 @@
1
+ import type { DrawerDirection } from "../../containers/types";
2
+
1
3
  export type ActionTarget =
2
4
  | {
3
5
  type: "drawer";
4
6
  component: ComponentInfo;
5
7
  title?: string;
6
8
  description?: string;
9
+ /** Edge the drawer slides in from (`bottom` by default). */
10
+ direction?: DrawerDirection;
7
11
  }
8
12
  | {
9
13
  type: "modal";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The window event a data block (StatGroup, KeyValueList, NavCardGrid,
3
- * ActivityFeed, KPI, top list, meter, chart, a tab set's badges) re-fetches
4
- * on, keeping its values on screen until the new answer lands.
3
+ * ActivityFeed, KPI, top list, meter, chart, a tab set's badges, table view)
4
+ * re-fetches on, keeping its values on screen until the new answer lands.
5
5
  */
6
6
  export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
7
7
 
@@ -25,6 +25,7 @@
25
25
  "edit_item_description": "Change its fields, then save.",
26
26
  "edit_item_in": "Changes are saved to {caption}.",
27
27
  "search_placeholder": "Search...",
28
+ "search_open": "Open search",
28
29
  "options_title": "Table options",
29
30
  "filters_title": "Filters",
30
31
  "columns_title": "Columns",
@@ -25,6 +25,7 @@
25
25
  "edit_item_description": "Modifiez ses champs, puis enregistrez.",
26
26
  "edit_item_in": "Les modifications sont enregistrées dans « {caption} ».",
27
27
  "search_placeholder": "Rechercher...",
28
+ "search_open": "Ouvrir la recherche",
28
29
  "options_title": "Options de la table",
29
30
  "filters_title": "Filtres",
30
31
  "columns_title": "Colonnes",
@@ -11,7 +11,7 @@
11
11
  "license": "Apache-2.0",
12
12
  "packageManager": "pnpm@10.6.5",
13
13
  "engines": {
14
- "@antelopejs/dms-frontend": ">=0.5.1 <0.6.0"
14
+ "@antelopejs/dms-frontend": ">=0.5.2 <0.6.0"
15
15
  },
16
16
  "scripts": {
17
17
  "typecheck": "ajs dms verify-source --layer \"$PWD\" --local-package @antelopejs/dms=.. --local-package @antelopejs/interface-dms=../../interface-dms",
@@ -73,7 +73,7 @@
73
73
  },
74
74
  "devDependencies": {
75
75
  "@antelopejs/core": ">=1.13.4 <2",
76
- "@antelopejs/dms-frontend": ">=0.5.1 <0.6.0",
76
+ "@antelopejs/dms-frontend": ">=0.5.2 <0.6.0",
77
77
  "@eslint/js": "^8.57.0",
78
78
  "@inertiajs/vue3": "^3.7.0",
79
79
  "@nuxt/ui": "4.10.0",
@@ -157,8 +157,8 @@ importers:
157
157
  specifier: '>=1.13.4 <2'
158
158
  version: 1.13.5
159
159
  '@antelopejs/dms-frontend':
160
- specifier: '>=0.5.1 <0.6.0'
161
- version: 0.5.1(@antelopejs/core@1.13.5)
160
+ specifier: '>=0.5.2 <0.6.0'
161
+ version: 0.5.2(@antelopejs/core@1.13.5)
162
162
  '@eslint/js':
163
163
  specifier: ^8.57.0
164
164
  version: 8.57.0
@@ -234,8 +234,8 @@ packages:
234
234
  engines: {node: '>=20.19'}
235
235
  hasBin: true
236
236
 
237
- '@antelopejs/dms-frontend@0.5.1':
238
- resolution: {integrity: sha512-ykrqlHfYF+BqNy+h4FuZBbqAW6UjviauCiI8cQCW1M55MzIxk2Ar3ZnMH1VKH8QsfDBBNapDik5rd/AMLQ33dw==}
237
+ '@antelopejs/dms-frontend@0.5.2':
238
+ resolution: {integrity: sha512-0kChPedSTpKES7aN/gLj5tAe4WRaU+FrwaRKpOyvo3fvvRPfDTvlTYjjdUpabsF150d3+V/AksXstZ5eb36QwQ==}
239
239
  engines: {node: '>=20.12.0'}
240
240
  hasBin: true
241
241
  peerDependencies:
@@ -4205,7 +4205,7 @@ snapshots:
4205
4205
  semver: 7.8.5
4206
4206
  typescript: 5.9.3
4207
4207
 
4208
- '@antelopejs/dms-frontend@0.5.1(@antelopejs/core@1.13.5)':
4208
+ '@antelopejs/dms-frontend@0.5.2(@antelopejs/core@1.13.5)':
4209
4209
  dependencies:
4210
4210
  '@antelopejs/core': 1.13.5
4211
4211
  chokidar: 4.0.3
@@ -3,6 +3,7 @@ import {
3
3
  formatActivityTime,
4
4
  formatShortDate,
5
5
  groupActivityByDay,
6
+ activityText,
6
7
  resolveActivityParams,
7
8
  relativeDayBucket,
8
9
  } from "../layers/dms-ui/app/build/components/activity-feed/activityFeedDays";
@@ -87,25 +88,38 @@ describe("activity feed day groups", () => {
87
88
  });
88
89
 
89
90
  describe("resolveActivityParams", () => {
90
- // Echoes the key and its values, so assertions read the whole text.
91
- const translate = (key: string, params?: Record<string, string> | null) =>
92
- `${key}(${JSON.stringify(params ?? {})})`;
93
-
94
- it("translates a $ value with the entry's literal values", () => {
91
+ it("makes a $ value a text of its own, with the entry's other values", () => {
92
+ const count = { type: "count", value: 2 } as const;
95
93
  expect(
96
- resolveActivityParams(
97
- { device: "$activity.device", browser: "Chrome", os: "Windows" },
98
- translate,
99
- ),
94
+ resolveActivityParams({
95
+ device: "$activity.device",
96
+ browser: "Chrome",
97
+ count,
98
+ }),
100
99
  ).toEqual({
101
- device: '$activity.device({"browser":"Chrome","os":"Windows"})',
100
+ device: {
101
+ key: "$activity.device",
102
+ params: { browser: "Chrome", count },
103
+ },
102
104
  browser: "Chrome",
103
- os: "Windows",
105
+ count,
104
106
  });
105
107
  });
106
108
 
107
109
  it("gives nothing for an entry without values", () => {
108
- expect(resolveActivityParams(undefined, translate)).toBeNull();
110
+ expect(resolveActivityParams(undefined)).toBeUndefined();
111
+ });
112
+ });
113
+
114
+ describe("activityText", () => {
115
+ it("gives a $ title the entry's values and leaves other texts alone", () => {
116
+ expect(activityText("$a.title", { name: "Léa" })).toEqual({
117
+ key: "$a.title",
118
+ params: { name: "Léa" },
119
+ });
120
+ expect(activityText("Plain", { name: "Léa" })).toBe("Plain");
121
+ const composed = { key: "a.composed" };
122
+ expect(activityText(composed, { name: "Léa" })).toBe(composed);
109
123
  });
110
124
  });
111
125
 
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A Banner with a `fetchUrl` draws what its route answers, over its static
4
+ * options field by field; draws nothing while the first answer is on its way
5
+ * or when the route answers no banner (`null`, `{}`, a 204); reads its route
6
+ * again on a page refresh; and runs the button targets the route answers.
7
+ */
8
+ import { createApp, defineComponent, h, reactive, ref } from "vue";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+ import { refreshPageBlocks } from "../layers/dms-ui/app/utils/blockRefresh";
11
+
12
+ const requests: string[] = [];
13
+ let answer: unknown = null;
14
+ const pressed: unknown[] = [];
15
+
16
+ vi.stubGlobal("useAuthFetch", () => ({
17
+ $authFetch: async (url: string) => {
18
+ requests.push(url);
19
+ return answer;
20
+ },
21
+ }));
22
+ vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
23
+ vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
24
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
25
+ vi.stubGlobal("useApiError", () => {});
26
+ vi.stubGlobal("useI18n", () => ({ t: String, locale: ref("en") }));
27
+
28
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
29
+ useWatch: () => ({ state: ref({}) }),
30
+ }));
31
+ vi.mock(
32
+ "../layers/dms-ui/app/build/composables/actions/useActionTargets",
33
+ () => ({
34
+ useActionTargets: () => ({
35
+ handleCustomButton: (button: unknown) => pressed.push(button),
36
+ }),
37
+ }),
38
+ );
39
+ vi.mock("../layers/dms-ui/app/components/banner/Banner.vue", () => ({
40
+ default: defineComponent({
41
+ props: {
42
+ title: { type: String, default: undefined },
43
+ description: { type: String, default: undefined },
44
+ tone: { type: String, default: undefined },
45
+ icon: { type: String, default: undefined },
46
+ },
47
+ setup:
48
+ (props, { slots }) =>
49
+ () =>
50
+ h("section", { "data-banner": JSON.stringify(props) }, [
51
+ slots.actions?.(),
52
+ ]),
53
+ }),
54
+ }));
55
+
56
+ const { default: BannerBlock } = await import(
57
+ "../layers/dms-ui/app/components/blocks/BannerBlock.vue"
58
+ );
59
+
60
+ // useChartFetch debounces every refresh past the first by 180ms.
61
+ const SETTLE_MS = 250;
62
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
63
+ const FETCH_URL = "/api/ws/{{params.id}}/health";
64
+
65
+ const UButton = defineComponent({
66
+ props: { label: { type: String, default: undefined } },
67
+ setup:
68
+ (props, { attrs }) =>
69
+ () =>
70
+ h("button", { onClick: attrs.onClick }, props.label),
71
+ });
72
+
73
+ const mounted: Array<{ unmount: () => void }> = [];
74
+
75
+ function mountBanner(props: Record<string, unknown>) {
76
+ const root = document.createElement("div");
77
+ const app = createApp(BannerBlock, {
78
+ componentId: "health",
79
+ pageId: "ws",
80
+ routeParams: { id: "ws-1" },
81
+ fetchUrl: FETCH_URL,
82
+ ...props,
83
+ });
84
+ app.component("UButton", UButton);
85
+ app.mount(root);
86
+ mounted.push(app);
87
+ return {
88
+ root,
89
+ read: () => {
90
+ const banner = root.querySelector("[data-banner]");
91
+ return banner ? JSON.parse(banner.getAttribute("data-banner")!) : null;
92
+ },
93
+ };
94
+ }
95
+
96
+ beforeEach(() => {
97
+ requests.length = 0;
98
+ pressed.length = 0;
99
+ answer = null;
100
+ });
101
+
102
+ afterEach(() => {
103
+ mounted.splice(0).forEach((app) => app.unmount());
104
+ });
105
+
106
+ it("draws the fetched props over the static options", async () => {
107
+ answer = { tone: "error", title: "Provider down" };
108
+ const banner = mountBanner({ title: "Static", description: "Kept" });
109
+ expect(banner.read()).toBeNull();
110
+ await settle();
111
+
112
+ expect(requests).toEqual(["/api/ws/ws-1/health"]);
113
+ expect(banner.read()).toMatchObject({
114
+ tone: "error",
115
+ title: "Provider down",
116
+ description: "Kept",
117
+ icon: "i-ph-warning-circle",
118
+ });
119
+ });
120
+
121
+ it.each([
122
+ ["null", null],
123
+ ["an empty object", {}],
124
+ ["no content (204)", undefined],
125
+ ])("draws nothing when the route answers %s", async (_name, response) => {
126
+ answer = response;
127
+ const banner = mountBanner({ title: "Static" });
128
+ await settle();
129
+ expect(requests).toHaveLength(1);
130
+ expect(banner.root.innerHTML).toBe("<!--v-if-->");
131
+ });
132
+
133
+ it("follows a page refresh, appearing and going away", async () => {
134
+ const banner = mountBanner({});
135
+ await settle();
136
+ expect(banner.read()).toBeNull();
137
+
138
+ answer = { title: "Degraded" };
139
+ refreshPageBlocks();
140
+ await settle();
141
+ expect(banner.read()).toMatchObject({ title: "Degraded" });
142
+
143
+ answer = null;
144
+ refreshPageBlocks();
145
+ await settle();
146
+ expect(banner.read()).toBeNull();
147
+ expect(requests).toHaveLength(3);
148
+ });
149
+
150
+ it("runs a button target the route answers", async () => {
151
+ const replay = {
152
+ label: "Replay",
153
+ target: { type: "api", url: "/api/replay", successMessage: "Replayed" },
154
+ confirm: { title: "Replay the queue?" },
155
+ };
156
+ answer = { title: "Outage", actions: [replay] };
157
+ const banner = mountBanner({});
158
+ await settle();
159
+
160
+ banner.root.querySelector("button")!.click();
161
+ expect(pressed).toEqual([replay]);
162
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Every block reading its data through useChartFetch hands it the period
3
+ * scope and the realtime topics of its options: a block that dropped them
4
+ * would silently stop following its PeriodSelector or its topics.
5
+ */
6
+ import { type Component, createSSRApp, defineComponent, h, ref } from "vue";
7
+ import { renderToString } from "vue/server-renderer";
8
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
9
+ import type { UseChartFetchOptions } from "../layers/dms-ui/app/composables/chart/useChartFetch";
10
+
11
+ const fetchOptions: Array<UseChartFetchOptions<unknown>> = [];
12
+
13
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
14
+ useChartFetch: (options: UseChartFetchOptions<unknown>) => {
15
+ fetchOptions.push(options);
16
+ return {
17
+ data: ref(null),
18
+ isLoading: ref(false),
19
+ error: ref(null),
20
+ refresh: async () => {},
21
+ };
22
+ },
23
+ }));
24
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
25
+ useWatch: () => ({ state: ref({}) }),
26
+ }));
27
+ vi.mock(
28
+ "../layers/dms-core/app/composables/components/useComponentEvent",
29
+ () => ({ useComponentEvent: () => ({}) }),
30
+ );
31
+ // The frames around the blocks are not under test here.
32
+ vi.mock("../layers/dms-ui/app/components/card/Card.vue", () => ({
33
+ default: defineComponent({ setup: () => () => h("div") }),
34
+ }));
35
+ vi.mock(
36
+ "#dms-layout/app/build/composables/navigation/useCategoryNavCards",
37
+ () => ({
38
+ useCategoryNavCards: () => ({ cards: ref([]) }),
39
+ usePreviewEntryVeil: () => ({}),
40
+ }),
41
+ );
42
+
43
+ const BLOCKS: Record<string, string> = {
44
+ StatGroupBlock: "../layers/dms-ui/app/components/blocks/StatGroupBlock.vue",
45
+ KeyValueListBlock:
46
+ "../layers/dms-ui/app/components/blocks/KeyValueListBlock.vue",
47
+ NavCardGridBlock:
48
+ "../layers/dms-ui/app/components/blocks/NavCardGridBlock.vue",
49
+ ActivityFeed:
50
+ "../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue",
51
+ MeterBlock: "../layers/dms-ui/app/components/meter/MeterBlock.vue",
52
+ };
53
+ const GLOBAL_COMPONENTS = ["DmsCard", "UIcon", "USkeleton", "UButton", "ULink"];
54
+ const SCOPE = "dashboard";
55
+ const TOPICS = ["stats:first", "stats:second"];
56
+
57
+ const Slot = defineComponent({
58
+ setup:
59
+ (_, { slots }) =>
60
+ () =>
61
+ h("div", slots.default?.()),
62
+ });
63
+
64
+ async function render(block: Component): Promise<void> {
65
+ const app = createSSRApp({
66
+ render: () =>
67
+ h(block, {
68
+ componentId: "block",
69
+ pageId: "page",
70
+ fetchUrl: "/api/data",
71
+ periodScope: SCOPE,
72
+ realtimeTopic: TOPICS,
73
+ }),
74
+ });
75
+ for (const name of GLOBAL_COMPONENTS) app.component(name, Slot);
76
+ await renderToString(app);
77
+ }
78
+
79
+ beforeEach(() => {
80
+ fetchOptions.length = 0;
81
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
82
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
83
+ vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
84
+ });
85
+
86
+ afterEach(() => vi.unstubAllGlobals());
87
+
88
+ it.each(Object.keys(BLOCKS))(
89
+ "%s forwards periodScope and realtimeTopic",
90
+ async (name) => {
91
+ const block = (await import(BLOCKS[name])).default as Component;
92
+ await render(block);
93
+ expect(fetchOptions).toHaveLength(1);
94
+ expect(fetchOptions[0]).toMatchObject({
95
+ fetchUrl: "/api/data",
96
+ periodScope: SCOPE,
97
+ realtimeTopic: TOPICS,
98
+ });
99
+ },
100
+ );
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A list block reading its items from a route follows a PeriodSelector like
4
+ * a KPI card (the period goes with every request, and a new period reads the
5
+ * route again) and reads the route again on each event of its realtime
6
+ * topics, keeping its items on screen until the answer lands.
7
+ */
8
+ import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+ import type { RealtimeTopicHandler } from "../layers/dms-core/app/composables/realtime/useUserRealtime";
11
+
12
+ const requests: string[] = [];
13
+ let revision = 1;
14
+ const topicHandlers = new Map<string, RealtimeTopicHandler>();
15
+
16
+ vi.stubGlobal("useAuthFetch", () => ({
17
+ $authFetch: async (url: string) => {
18
+ requests.push(url);
19
+ return { items: [{ revision }] };
20
+ },
21
+ }));
22
+ vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
23
+
24
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
25
+ useWatch: () => ({ state: ref({}) }),
26
+ }));
27
+ vi.mock(
28
+ "../layers/dms-core/app/composables/components/useComponentEvent",
29
+ () => ({ useComponentEvent: () => ({}) }),
30
+ );
31
+ vi.mock("../layers/dms-core/app/composables/realtime/useRealtimeTopic", () => ({
32
+ useRealtimeTopic: (topic: string, handler: RealtimeTopicHandler) => {
33
+ topicHandlers.set(topic, handler);
34
+ },
35
+ }));
36
+
37
+ const { usePeriod } = await import(
38
+ "../layers/dms-core/app/composables/period/usePeriod"
39
+ );
40
+ const { registerPeriodScope } = await import(
41
+ "../layers/dms-core/app/composables/period/usePeriodScope"
42
+ );
43
+ const { useBlockItems } = await import(
44
+ "../layers/dms-ui/app/build/composables/blocks/useBlockItems"
45
+ );
46
+
47
+ const SCOPE = "block-items-period";
48
+ const STATS_URL = "/api/stats";
49
+ const FACTS_TOPICS = ["facts:first", "facts:second"];
50
+ // useChartFetch debounces every refresh past the first by 180ms.
51
+ const SETTLE_MS = 250;
52
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
53
+
54
+ interface ItemsProbe {
55
+ items?: { value: unknown[] };
56
+ isPending?: { value: boolean };
57
+ }
58
+
59
+ interface SelectedPeriod {
60
+ period?: ReturnType<typeof usePeriod>;
61
+ }
62
+
63
+ const mounted: Array<{ unmount: () => void }> = [];
64
+
65
+ function mountPeriodPage() {
66
+ const selected: SelectedPeriod = {};
67
+ const Selector = defineComponent({
68
+ setup() {
69
+ const period = usePeriod({ defaultPreset: "last-7-days" });
70
+ registerPeriodScope(SCOPE, period.state);
71
+ selected.period = period;
72
+ return () => h("div");
73
+ },
74
+ });
75
+ const Block = defineComponent({
76
+ setup() {
77
+ useBlockItems({
78
+ items: () => [],
79
+ fetchUrl: STATS_URL,
80
+ periodScope: SCOPE,
81
+ });
82
+ return () => h("div");
83
+ },
84
+ });
85
+ const app = createApp({ render: () => h("main", [h(Selector), h(Block)]) });
86
+ app.mount(document.createElement("div"));
87
+ mounted.push(app);
88
+ return selected;
89
+ }
90
+
91
+ function mountRealtimeBlock(probe: ItemsProbe) {
92
+ const Block = defineComponent({
93
+ setup() {
94
+ const { items, isPending } = useBlockItems({
95
+ items: () => [],
96
+ fetchUrl: STATS_URL,
97
+ realtimeTopic: FACTS_TOPICS,
98
+ });
99
+ probe.items = items;
100
+ probe.isPending = isPending;
101
+ return () => h("div");
102
+ },
103
+ });
104
+ const app = createApp(Block);
105
+ app.mount(document.createElement("div"));
106
+ mounted.push(app);
107
+ }
108
+
109
+ beforeEach(() => {
110
+ requests.length = 0;
111
+ revision = 1;
112
+ topicHandlers.clear();
113
+ });
114
+
115
+ afterEach(() => {
116
+ mounted.splice(0).forEach((app) => app.unmount());
117
+ });
118
+
119
+ it("requests the items with the period of its scope, again when it changes", async () => {
120
+ const selected = mountPeriodPage();
121
+ await settle();
122
+ expect(requests).toHaveLength(1);
123
+ expect(requests[0]).toMatch(/^\/api\/stats\?from=.*&preset=last-7-days/);
124
+
125
+ selected.period?.setPreset("last-30-days");
126
+ await nextTick();
127
+ await settle();
128
+ expect(requests).toHaveLength(2);
129
+ expect(requests[1]).toContain("preset=last-30-days");
130
+ });
131
+
132
+ it("reads the route again on each event of any of its topics", async () => {
133
+ const probe: ItemsProbe = {};
134
+ mountRealtimeBlock(probe);
135
+ await settle();
136
+ expect([...topicHandlers.keys()]).toEqual(FACTS_TOPICS);
137
+ expect(probe.items?.value).toEqual([{ revision: 1 }]);
138
+
139
+ revision = 2;
140
+ topicHandlers.get("facts:second")?.({ type: "facts.update" } as never);
141
+ expect(probe.isPending?.value).toBe(false);
142
+ expect(probe.items?.value).toEqual([{ revision: 1 }]);
143
+ await settle();
144
+
145
+ expect(requests).toEqual([STATS_URL, STATS_URL]);
146
+ expect(probe.items?.value).toEqual([{ revision: 2 }]);
147
+ });