@antelopejs/dms 0.6.0 → 0.6.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 (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/implementations/dms/layout-banners.d.ts +4 -1
  3. package/dist/implementations/dms/layout-banners.js +33 -18
  4. package/dist/implementations/dms/layout-banners.js.map +1 -1
  5. package/dist/test/unit/implementations/dms/layout-banners.test.js +76 -0
  6. package/dist/test/unit/implementations/dms/layout-banners.test.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/column-display.test.js +70 -0
  8. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +38 -0
  10. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  11. package/frontend-vue/layers/dms-core/app/composables/translation/useComposedText.ts +25 -0
  12. package/frontend-vue/layers/dms-core/app/types/composed-text.ts +64 -0
  13. package/frontend-vue/layers/dms-core/app/utils/composedText.ts +227 -0
  14. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +49 -0
  15. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +17 -3
  16. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +5 -1
  17. package/frontend-vue/layers/dms-layout/app/types/page.ts +7 -1
  18. package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
  19. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/registerBlockFunctions.ts +7 -0
  20. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +11 -3
  21. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
  23. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
  26. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +1 -1
  27. package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
  28. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +63 -0
  29. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +1 -0
  30. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
  31. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -17
  32. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +1 -0
  33. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -22
  34. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +1 -0
  35. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +1 -0
  36. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +1 -0
  37. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +1 -0
  38. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +1 -0
  39. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +1 -0
  40. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +78 -16
  41. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +21 -53
  42. package/frontend-vue/layers/dms-ui/app/plugins/register.ts +2 -0
  43. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +28 -0
  44. package/frontend-vue/tests/block-page-refresh.test.ts +128 -0
  45. package/frontend-vue/tests/block-route-params.test.ts +190 -0
  46. package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
  47. package/frontend-vue/tests/composed-text.test.ts +265 -0
  48. package/frontend-vue/tests/layout-banners.test.ts +56 -0
  49. package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
  50. package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
  51. package/package.json +2 -2
  52. package/skills/dms/SKILL.md +10 -0
@@ -1,13 +1,33 @@
1
- import { onMounted, ref, watch, type Ref } from "vue";
1
+ import {
2
+ computed,
3
+ onBeforeUnmount,
4
+ onMounted,
5
+ ref,
6
+ watch,
7
+ type ComputedRef,
8
+ type Ref,
9
+ } from "vue";
2
10
  import {
3
11
  appendPeriodToUrl,
4
12
  usePeriodScope,
5
13
  } from "../../../../dms-core/app/composables/period/usePeriodScope";
6
14
  import type { PeriodState } from "../../../../dms-core/app/composables/period/types";
7
15
  import { useRealtimeTopic } from "../../../../dms-core/app/composables/realtime/useRealtimeTopic";
16
+ import {
17
+ hasUrlVariables,
18
+ resolveUrlVariables,
19
+ } from "../../build/utils/urlVariables";
20
+ import { onPageBlocksRefresh } from "../../utils/blockRefresh";
8
21
 
9
22
  export interface UseChartFetchOptions<T> {
23
+ /**
24
+ * Where the data is read from. `{{params.X}}` is filled from `routeParams`
25
+ * and `{{query.X}}` from the page URL's query, again when they change;
26
+ * nothing is requested while a token is left without a value.
27
+ */
10
28
  fetchUrl?: string;
29
+ /** The parameters of the page route (a block's `routeParams` prop). */
30
+ routeParams?: () => Record<string, string> | undefined;
11
31
  fetchUrlMethod?: string;
12
32
  periodScope?: string;
13
33
  realtimeTopic?: string | string[];
@@ -46,6 +66,13 @@ function buildFinalUrl(
46
66
  return appendPeriodToUrl(url, scopeState);
47
67
  }
48
68
 
69
+ /** What one fetch requests. */
70
+ interface FetchRequest {
71
+ url: string | undefined;
72
+ method: string | undefined;
73
+ periodScope: string | undefined;
74
+ }
75
+
49
76
  interface FetchRunner {
50
77
  hasFetchedOnce: boolean;
51
78
  /** Inputs the last fetch was started for, to collapse duplicate triggers. */
@@ -61,16 +88,16 @@ type AuthFetcher = <T>(
61
88
 
62
89
  async function runFetch<T>(
63
90
  authFetch: AuthFetcher,
64
- options: UseChartFetchOptions<T>,
91
+ request: FetchRequest,
65
92
  scopeState: PeriodState | null,
66
93
  runner: FetchRunner,
67
94
  data: Ref<T | null>,
68
95
  isLoading: Ref<boolean>,
69
96
  error: Ref<unknown>,
70
97
  ): Promise<void> {
71
- if (!options.fetchUrl) return;
72
- if (options.periodScope && !scopeState) return;
73
- const url = buildFinalUrl(options.fetchUrl, scopeState, options.periodScope);
98
+ if (!request.url) return;
99
+ if (request.periodScope && !scopeState) return;
100
+ const url = buildFinalUrl(request.url, scopeState, request.periodScope);
74
101
  if (!url) return;
75
102
  if (runner.activeAbort) runner.activeAbort.abort();
76
103
  const controller = new AbortController();
@@ -79,7 +106,7 @@ async function runFetch<T>(
79
106
  error.value = null;
80
107
  try {
81
108
  const response = await authFetch<T>(url, {
82
- method: options.fetchUrlMethod || DEFAULT_HTTP_METHOD,
109
+ method: request.method || DEFAULT_HTTP_METHOD,
83
110
  signal: controller.signal,
84
111
  });
85
112
  if (runner.activeAbort !== controller) return;
@@ -119,6 +146,34 @@ function subscribeRealtimeTopics(
119
146
  }
120
147
  }
121
148
 
149
+ /**
150
+ * The URL to request: `fetchUrl` with its tokens filled, or `undefined` while
151
+ * one has no value. Only a URL naming a token reads the route.
152
+ */
153
+ function useResolvedFetchUrl<T>(
154
+ options: UseChartFetchOptions<T>,
155
+ ): ComputedRef<string | undefined> {
156
+ const { fetchUrl } = options;
157
+ if (!fetchUrl || !hasUrlVariables(fetchUrl)) return computed(() => fetchUrl);
158
+ const route = useDmsRoute();
159
+ return computed(() =>
160
+ resolveUrlVariables(fetchUrl, {
161
+ routeParams: options.routeParams?.(),
162
+ routeQuery: route.query as Record<string, unknown>,
163
+ }),
164
+ );
165
+ }
166
+
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
+
122
177
  export function useChartFetch<T>(
123
178
  options: UseChartFetchOptions<T>,
124
179
  ): UseChartFetchReturn<T> {
@@ -130,7 +185,8 @@ export function useChartFetch<T>(
130
185
  // period-scoped card renders server-side with no scope and no data. Start
131
186
  // in the loading state so consumers show a skeleton instead of a hard zero
132
187
  // for the whole hydrate-then-fetch window.
133
- const isLoading = ref(Boolean(options.fetchUrl));
188
+ const resolvedUrl = useResolvedFetchUrl(options);
189
+ const isLoading = ref(Boolean(resolvedUrl.value));
134
190
  const error = ref<unknown>(null);
135
191
  const scopeState = usePeriodScope(options.periodScope);
136
192
  const { $authFetch } = useAuthFetch();
@@ -144,7 +200,11 @@ export function useChartFetch<T>(
144
200
  const performFetch = () =>
145
201
  runFetch(
146
202
  $authFetch as AuthFetcher,
147
- options,
203
+ {
204
+ url: resolvedUrl.value,
205
+ method: options.fetchUrlMethod,
206
+ periodScope: options.periodScope,
207
+ },
148
208
  scopeState.value,
149
209
  runner,
150
210
  data,
@@ -159,7 +219,7 @@ export function useChartFetch<T>(
159
219
  };
160
220
 
161
221
  const currentInputs = () =>
162
- `${scopeState.value?.key ?? ""}|${JSON.stringify(options.watchSource?.() ?? null)}`;
222
+ `${resolvedUrl.value}|${scopeState.value?.key ?? ""}|${JSON.stringify(options.watchSource?.() ?? null)}`;
163
223
 
164
224
  const fetchScheduledInputs = () => {
165
225
  runner.pendingTimer = null;
@@ -182,7 +242,7 @@ export function useChartFetch<T>(
182
242
  const hadPending = runner.pendingTimer !== null;
183
243
  if (runner.pendingTimer) clearTimeout(runner.pendingTimer);
184
244
  runner.pendingTimer = null;
185
- if (options.periodScope && !scopeState.value) {
245
+ if (!resolvedUrl.value || (options.periodScope && !scopeState.value)) {
186
246
  settleCancelledRefresh(hadPending);
187
247
  return;
188
248
  }
@@ -203,10 +263,11 @@ export function useChartFetch<T>(
203
263
  };
204
264
  const scheduleRefresh = () => scheduleRefreshWith(true);
205
265
 
206
- // A realtime event means the data behind unchanged inputs moved, so it has
207
- // to bypass the deduplication above. The inputs did not change, so the
208
- // values on screen stay current until the new answer replaces them.
209
- const refreshFromRealtime = () => {
266
+ // A realtime event or a page refresh (refreshPageBlocks) means the data
267
+ // behind unchanged inputs moved, so it has to bypass the deduplication
268
+ // above. The inputs did not change, so the values on screen stay current
269
+ // until the new answer replaces them.
270
+ const refreshSameInputs = () => {
210
271
  runner.lastInputs = null;
211
272
  scheduleRefreshWith(false);
212
273
  };
@@ -225,11 +286,12 @@ export function useChartFetch<T>(
225
286
 
226
287
  if (options.fetchUrl) {
227
288
  watch(
228
- () => [scopeState.value?.key, options.watchSource?.()],
289
+ () => [resolvedUrl.value, scopeState.value?.key, options.watchSource?.()],
229
290
  scheduleRefresh,
230
291
  );
231
292
  onMounted(fetchOnMount);
232
- subscribeRealtimeTopics(options.realtimeTopic, refreshFromRealtime);
293
+ subscribeRealtimeTopics(options.realtimeTopic, refreshSameInputs);
294
+ subscribePageRefresh(refreshSameInputs);
233
295
  } else if (typeof options.staticData === "function") {
234
296
  watch(
235
297
  () => readStaticData(options.staticData),
@@ -9,6 +9,17 @@ import type { FormField, FormFieldOrGroup } from "./types/field";
9
9
  import { isFieldGroup } from "./types/field";
10
10
  import { resolveResponseToast } from "../../build/utils/responseWarning";
11
11
  import { processFieldI18n } from "../../build/utils/fieldOptionsI18n";
12
+ import {
13
+ type ReplaceUrlVariablesContext,
14
+ hasUrlVariables,
15
+ replaceUrlVariables,
16
+ resolveUrlVariables,
17
+ } from "../../build/utils/urlVariables";
18
+
19
+ export {
20
+ type ReplaceUrlVariablesContext,
21
+ replaceUrlVariables,
22
+ } from "../../build/utils/urlVariables";
12
23
 
13
24
  /** A toast a submit response asks for, on top of the success one. */
14
25
  interface FormSubmitNotice {
@@ -135,47 +146,6 @@ function createBaseValidationSchema(
135
146
  }
136
147
  }
137
148
 
138
- // `{{params.id}}` takes the bare name, which on a route repeating a placeholder
139
- // is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
140
- // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
141
- // table view (see extractRouteParams).
142
- const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
143
-
144
- export interface ReplaceUrlVariablesContext {
145
- routeParams?: Record<string, string>;
146
- routeQuery: Record<string, unknown>;
147
- response?: Record<string, unknown>;
148
- }
149
-
150
- export function replaceUrlVariables(
151
- url: string,
152
- context: ReplaceUrlVariablesContext,
153
- ): string {
154
- let processedUrl = url.replace(
155
- PARAM_TOKEN,
156
- (match, key) => context.routeParams?.[key] || match,
157
- );
158
-
159
- processedUrl = processedUrl.replace(/\{\{query\.(\w+)\}\}/g, (match, key) => {
160
- const value = context.routeQuery[key];
161
- return typeof value === "string" ? value : match;
162
- });
163
-
164
- if (context.response) {
165
- processedUrl = processedUrl.replace(
166
- /\{\{response\.(\w+)\}\}/g,
167
- (match, key) => {
168
- const value = context.response?.[key];
169
- return typeof value === "string" || typeof value === "number"
170
- ? String(value)
171
- : match;
172
- },
173
- );
174
- }
175
-
176
- return processedUrl;
177
- }
178
-
179
149
  /** Where a submit goes, or what it lacks to go anywhere. */
180
150
  export type SubmitTarget = { url: string } | { missing: "url" | "token" };
181
151
 
@@ -191,8 +161,8 @@ export function resolveSubmitTarget(
191
161
  context: ReplaceUrlVariablesContext,
192
162
  ): SubmitTarget {
193
163
  if (!submitUrl) return { missing: "url" };
194
- const url = replaceUrlVariables(submitUrl, context);
195
- return url.includes("{{") ? { missing: "token" } : { url };
164
+ const url = resolveUrlVariables(submitUrl, context);
165
+ return url === undefined ? { missing: "token" } : { url };
196
166
  }
197
167
 
198
168
  function processBeforeStateMappers(
@@ -376,9 +346,9 @@ export function resolveSubmitDefaults(
376
346
 
377
347
  const resolved: Record<string, unknown> = {};
378
348
  for (const [key, value] of Object.entries(submitDefaults)) {
379
- if (typeof value === "string" && value.includes("{{")) {
380
- const replaced = replaceUrlVariables(value, context);
381
- if (replaced.includes("{{")) continue;
349
+ if (typeof value === "string" && hasUrlVariables(value)) {
350
+ const replaced = resolveUrlVariables(value, context);
351
+ if (replaced === undefined) continue;
382
352
  resolved[key] = replaced;
383
353
  } else {
384
354
  resolved[key] = value;
@@ -622,11 +592,9 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
622
592
  resolveSubmitDefaults(props.submitDefaults, buildUrlContext()),
623
593
  );
624
594
 
625
- const resolvedFetchUrl = computed(() => {
626
- if (!props.fetchUrl) return undefined;
627
- const url = replaceUrlVariables(props.fetchUrl, buildUrlContext());
628
- return url.includes("{{") ? undefined : url;
629
- });
595
+ const resolvedFetchUrl = computed(() =>
596
+ resolveUrlVariables(props.fetchUrl, buildUrlContext()),
597
+ );
630
598
 
631
599
  // A form that loaded a record and does not create one (a duplicate posts
632
600
  // the loaded values as a new row) updates it: only its changes are sent.
@@ -638,11 +606,11 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
638
606
  if (!props.fetchUrl) return undefined;
639
607
 
640
608
  try {
641
- const processedUrl = replaceUrlVariables(
609
+ const processedUrl = resolveUrlVariables(
642
610
  props.fetchUrl,
643
611
  buildUrlContext(),
644
612
  );
645
- if (processedUrl.includes("{{")) return undefined;
613
+ if (processedUrl === undefined) return undefined;
646
614
 
647
615
  const fetchedData = await $authFetch<FormFetchResponse>(processedUrl, {
648
616
  method: props.fetchUrlMethod || "GET",
@@ -1,9 +1,11 @@
1
1
  import { registerDefaultDataTypes } from "../build/composables/data-types/registerDefaults";
2
2
  import { registerDefaultFunctions } from "../build/composables/table-view/registerDefaultFunctions";
3
3
  import { registerFormFunctions } from "../build/composables/form/registerFormFunctions";
4
+ import { registerBlockFunctions } from "../build/composables/blocks/registerBlockFunctions";
4
5
 
5
6
  export default defineDmsPlugin(() => {
6
7
  registerDefaultDataTypes();
7
8
  registerDefaultFunctions();
8
9
  registerFormFunctions();
10
+ registerBlockFunctions();
9
11
  });
@@ -0,0 +1,28 @@
1
+ /**
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.
5
+ */
6
+ export const BLOCK_REFRESH_EVENT = "DmsComponent.Block.Refresh";
7
+
8
+ /** The watch functions every block can name (see interface-dms). */
9
+ export const BlockFunctions = {
10
+ REFRESH_PAGE: "DmsComponent.Block.RefreshPage",
11
+ } as const;
12
+
13
+ /**
14
+ * 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.
17
+ */
18
+ export function refreshPageBlocks(): void {
19
+ if (typeof window === "undefined") return;
20
+ window.dispatchEvent(new CustomEvent(BLOCK_REFRESH_EVENT));
21
+ }
22
+
23
+ /** Calls `handler` on each `refreshPageBlocks()`; returns the unsubscribe. */
24
+ export function onPageBlocksRefresh(handler: () => void): () => void {
25
+ if (typeof window === "undefined") return () => {};
26
+ window.addEventListener(BLOCK_REFRESH_EVENT, handler);
27
+ return () => window.removeEventListener(BLOCK_REFRESH_EVENT, handler);
28
+ }
@@ -0,0 +1,128 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Once an action changes the record a page shows, its data blocks read their
4
+ * data again in place: `refreshPageBlocks()` (or the `REFRESH_PAGE` watch
5
+ * function a form names) re-fetches every mounted block, keeps what it shows
6
+ * until the answer lands, and leaves an unmounted block alone.
7
+ */
8
+ import { createApp, defineComponent, h, ref } from "vue";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+ import {
11
+ BlockFunctions,
12
+ refreshPageBlocks,
13
+ } from "../layers/dms-ui/app/utils/blockRefresh";
14
+
15
+ const requests: string[] = [];
16
+ let answer = 1;
17
+
18
+ vi.stubGlobal("useAuthFetch", () => ({
19
+ $authFetch: async (url: string) => {
20
+ requests.push(url);
21
+ return { value: answer };
22
+ },
23
+ }));
24
+ vi.stubGlobal("useDmsRoute", () => ({ query: {} }));
25
+ vi.stubGlobal("ref", ref);
26
+
27
+ const { useChartFetch } = await import(
28
+ "../layers/dms-ui/app/composables/chart/useChartFetch"
29
+ );
30
+ const { useDefinedFunctions } = await import(
31
+ "../layers/dms-core/app/composables/functions/useDefinedFunction"
32
+ );
33
+ vi.stubGlobal("useDefinedFunctions", useDefinedFunctions);
34
+ const { registerBlockFunctions } = await import(
35
+ "../layers/dms-ui/app/build/composables/blocks/registerBlockFunctions"
36
+ );
37
+
38
+ // useChartFetch debounces every refresh past the first by 180ms.
39
+ const SETTLE_MS = 250;
40
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
41
+
42
+ interface Probe {
43
+ data?: { value: { value: number } | null };
44
+ isLoading?: { value: boolean };
45
+ }
46
+
47
+ function mountBlock(fetchUrl: string) {
48
+ const probe: Probe = {};
49
+ const Block = defineComponent({
50
+ setup() {
51
+ const { data, isLoading } = useChartFetch<{ value: number }>({
52
+ fetchUrl,
53
+ routeParams: () => ({ id: "ws-1" }),
54
+ });
55
+ probe.data = data;
56
+ probe.isLoading = isLoading;
57
+ return () => h("div");
58
+ },
59
+ });
60
+ const app = createApp(Block);
61
+ app.mount(document.createElement("div"));
62
+ return { probe, unmount: () => app.unmount() };
63
+ }
64
+
65
+ const apps: Array<() => void> = [];
66
+
67
+ beforeEach(() => {
68
+ requests.length = 0;
69
+ answer = 1;
70
+ });
71
+
72
+ afterEach(() => {
73
+ apps.splice(0).forEach((unmount) => unmount());
74
+ });
75
+
76
+ it("re-fetches every mounted block, keeping its values until the answer", async () => {
77
+ const facts = mountBlock("/api/ws/{{params.id}}/facts");
78
+ const counts = mountBlock("/api/ws/{{params.id}}/tab-counts");
79
+ apps.push(facts.unmount, counts.unmount);
80
+ await settle();
81
+ expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-1/tab-counts"]);
82
+
83
+ answer = 2;
84
+ refreshPageBlocks();
85
+ expect(facts.probe.isLoading?.value).toBe(false);
86
+ expect(facts.probe.data?.value).toEqual({ value: 1 });
87
+ await settle();
88
+
89
+ expect(requests).toEqual([
90
+ "/api/ws/ws-1/facts",
91
+ "/api/ws/ws-1/tab-counts",
92
+ "/api/ws/ws-1/facts",
93
+ "/api/ws/ws-1/tab-counts",
94
+ ]);
95
+ expect(facts.probe.data?.value).toEqual({ value: 2 });
96
+ });
97
+
98
+ it("leaves an unmounted block alone", async () => {
99
+ const block = mountBlock("/api/ws/{{params.id}}/facts");
100
+ await settle();
101
+ block.unmount();
102
+ refreshPageBlocks();
103
+ await settle();
104
+ expect(requests).toEqual(["/api/ws/ws-1/facts"]);
105
+ });
106
+
107
+ it("does not request a URL whose token is unresolved", async () => {
108
+ const block = mountBlock("/api/ws/{{params.workspace}}/facts");
109
+ apps.push(block.unmount);
110
+ refreshPageBlocks();
111
+ await settle();
112
+ expect(requests).toEqual([]);
113
+ });
114
+
115
+ it("runs from the REFRESH_PAGE watch function", async () => {
116
+ registerBlockFunctions();
117
+ const block = mountBlock("/api/ws/{{params.id}}/facts");
118
+ apps.push(block.unmount);
119
+ await settle();
120
+
121
+ const refresh = useDefinedFunctions().getFunction(
122
+ BlockFunctions.REFRESH_PAGE,
123
+ );
124
+ expect(refresh).toBeTypeOf("function");
125
+ refresh?.();
126
+ await settle();
127
+ expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-1/facts"]);
128
+ });
@@ -0,0 +1,190 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A data block on a detail page reads the record of that page: the
4
+ * `{{params.X}}` / `{{query.X}}` tokens of its `fetchUrl` are filled from the
5
+ * page URL, again when it changes, and a URL with a token left unresolved is
6
+ * never requested.
7
+ */
8
+ import {
9
+ type Component,
10
+ createApp,
11
+ defineComponent,
12
+ h,
13
+ nextTick,
14
+ reactive,
15
+ ref,
16
+ } from "vue";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+ import {
19
+ replaceUrlVariables,
20
+ resolveUrlVariables,
21
+ } from "../layers/dms-ui/app/build/utils/urlVariables";
22
+
23
+ const requests: string[] = [];
24
+ const route = reactive<{ query: Record<string, unknown> }>({ query: {} });
25
+
26
+ vi.stubGlobal("useAuthFetch", () => ({
27
+ $authFetch: async (url: string) => {
28
+ requests.push(url);
29
+ return { items: [{ label: url }] };
30
+ },
31
+ }));
32
+ vi.stubGlobal("useDmsRoute", () => route);
33
+
34
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
35
+ useWatch: () => ({ state: ref({}) }),
36
+ }));
37
+ vi.mock(
38
+ "../layers/dms-core/app/composables/components/useComponentEvent",
39
+ () => ({ useComponentEvent: () => ({}) }),
40
+ );
41
+
42
+ const { useChartFetch } = await import(
43
+ "../layers/dms-ui/app/composables/chart/useChartFetch"
44
+ );
45
+ const { useBlockItems } = await import(
46
+ "../layers/dms-ui/app/build/composables/blocks/useBlockItems"
47
+ );
48
+
49
+ // useChartFetch debounces every refresh past the first by 180ms.
50
+ const SETTLE_MS = 250;
51
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
52
+
53
+ const mounted: Array<{ unmount: () => void }> = [];
54
+
55
+ function mount(component: Component) {
56
+ const app = createApp(component);
57
+ app.mount(document.createElement("div"));
58
+ mounted.push(app);
59
+ }
60
+
61
+ beforeEach(() => {
62
+ requests.length = 0;
63
+ route.query = {};
64
+ });
65
+
66
+ afterEach(() => {
67
+ mounted.splice(0).forEach((app) => app.unmount());
68
+ });
69
+
70
+ describe("resolveUrlVariables", () => {
71
+ it("fills route parameters and query values", () => {
72
+ expect(
73
+ resolveUrlVariables("/api/ws/{{params.id}}/facts?tab={{query.tab}}", {
74
+ routeParams: { id: "ws-1" },
75
+ routeQuery: { tab: "billing" },
76
+ }),
77
+ ).toBe("/api/ws/ws-1/facts?tab=billing");
78
+ });
79
+
80
+ it("reaches one occurrence of a repeated parameter", () => {
81
+ expect(
82
+ resolveUrlVariables("/api/ws/{{params.id:1}}/rows/{{params.id}}", {
83
+ routeParams: { id: "row-7", "id:1": "ws-1", "id:2": "row-7" },
84
+ routeQuery: {},
85
+ }),
86
+ ).toBe("/api/ws/ws-1/rows/row-7");
87
+ });
88
+
89
+ it("gives nothing while a token has no value", () => {
90
+ const context = { routeParams: {}, routeQuery: {} };
91
+ expect(resolveUrlVariables("/api/ws/{{params.id}}", context)).toBe(
92
+ undefined,
93
+ );
94
+ expect(replaceUrlVariables("/api/ws/{{params.id}}", context)).toBe(
95
+ "/api/ws/{{params.id}}",
96
+ );
97
+ });
98
+
99
+ it("leaves a URL without tokens as it is", () => {
100
+ expect(resolveUrlVariables("/api/plain", { routeQuery: {} })).toBe(
101
+ "/api/plain",
102
+ );
103
+ });
104
+ });
105
+
106
+ describe("useChartFetch with a templated fetchUrl", () => {
107
+ function mountFetch(
108
+ fetchUrl: string,
109
+ routeParams: () => Record<string, string> | undefined,
110
+ ) {
111
+ const state: { isLoading?: { value: boolean } } = {};
112
+ const Probe = defineComponent({
113
+ setup() {
114
+ const { isLoading } = useChartFetch<unknown>({ fetchUrl, routeParams });
115
+ state.isLoading = isLoading;
116
+ return () => h("div");
117
+ },
118
+ });
119
+ mount(Probe);
120
+ return state;
121
+ }
122
+
123
+ it("requests the URL of the page's record", async () => {
124
+ mountFetch("/api/ws/{{params.id}}/facts", () => ({ id: "ws-1" }));
125
+ await settle();
126
+ expect(requests).toEqual(["/api/ws/ws-1/facts"]);
127
+ });
128
+
129
+ it("requests nothing, and shows no skeleton, while a token is unresolved", async () => {
130
+ const state = mountFetch("/api/ws/{{params.id}}/facts", () => ({}));
131
+ await settle();
132
+ expect(requests).toEqual([]);
133
+ expect(state.isLoading?.value).toBe(false);
134
+ });
135
+
136
+ it("follows the route to another record", async () => {
137
+ const params = ref<Record<string, string>>({ id: "ws-1" });
138
+ mountFetch("/api/ws/{{params.id}}/facts", () => params.value);
139
+ await settle();
140
+ params.value = { id: "ws-2" };
141
+ await nextTick();
142
+ await settle();
143
+ expect(requests).toEqual(["/api/ws/ws-1/facts", "/api/ws/ws-2/facts"]);
144
+ });
145
+
146
+ it("fetches once a query value it waited for appears", async () => {
147
+ mountFetch("/api/activity?actor={{query.actor}}", () => ({}));
148
+ await settle();
149
+ expect(requests).toEqual([]);
150
+ route.query = { actor: "u-1" };
151
+ await nextTick();
152
+ await settle();
153
+ expect(requests).toEqual(["/api/activity?actor=u-1"]);
154
+ });
155
+ });
156
+
157
+ describe("useBlockItems with a templated fetchUrl", () => {
158
+ function mountItems(routeParams: Record<string, string>) {
159
+ const state: { items?: { value: unknown[] } } = {};
160
+ const Probe = defineComponent({
161
+ setup() {
162
+ const { items } = useBlockItems<{ label: string }>({
163
+ items: () => [{ label: "static" }],
164
+ fetchUrl: "/api/ws/{{params.id}}/billing-info",
165
+ routeParams: () => routeParams,
166
+ });
167
+ state.items = items;
168
+ return () => h("div");
169
+ },
170
+ });
171
+ mount(Probe);
172
+ return state;
173
+ }
174
+
175
+ it("lists the items of the page's record", async () => {
176
+ const state = mountItems({ id: "ws-1" });
177
+ await settle();
178
+ expect(requests).toEqual(["/api/ws/ws-1/billing-info"]);
179
+ expect(state.items?.value).toEqual([
180
+ { label: "/api/ws/ws-1/billing-info" },
181
+ ]);
182
+ });
183
+
184
+ it("shows its empty state, not the static items, without the record", async () => {
185
+ const state = mountItems({});
186
+ await settle();
187
+ expect(requests).toEqual([]);
188
+ expect(state.items?.value).toEqual([]);
189
+ });
190
+ });