@antelopejs/dms 0.4.2 → 0.4.4

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 (51) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/pages/settings/users/invites.js +6 -5
  3. package/dist/pages/settings/users/invites.js.map +1 -1
  4. package/dist/pages/settings/users/members.d.ts +1 -0
  5. package/dist/pages/settings/users/members.js +12 -3
  6. package/dist/pages/settings/users/members.js.map +1 -1
  7. package/dist/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
  8. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
  9. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
  10. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
  11. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
  12. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
  13. package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
  14. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
  15. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
  16. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
  17. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
  18. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
  19. package/frontend-vue/dms.frontend.ts +2 -0
  20. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +4 -2
  21. package/frontend-vue/layers/dms-layout/app/composables/general/types/index.ts +7 -0
  22. package/frontend-vue/layers/dms-layout/app/composables/general/useColorModePreference.ts +17 -0
  23. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/appearance.vue +6 -6
  24. package/frontend-vue/layers/dms-layout/app/plugins/color-mode.ts +100 -0
  25. package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
  26. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
  27. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +49 -6
  28. package/frontend-vue/layers/dms-ui/app/components/form/components/Calendar.vue +49 -26
  29. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +77 -32
  30. package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
  31. package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
  32. package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
  33. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
  34. package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
  35. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
  36. package/frontend-vue/layers/dms-ui/app/composables/chart/useThemeRevision.ts +0 -22
  37. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +76 -4
  39. package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
  40. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +2 -0
  41. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +2 -0
  42. package/frontend-vue/package.json +2 -2
  43. package/frontend-vue/pnpm-lock.yaml +7 -33
  44. package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
  45. package/frontend-vue/tests/color-mode-server-render.test.ts +84 -0
  46. package/frontend-vue/tests/color-mode-sync.test.ts +320 -0
  47. package/frontend-vue/tests/form-actions.test.ts +27 -0
  48. package/frontend-vue/tests/form-required-fields.test.ts +44 -0
  49. package/frontend-vue/tests/form-submit-target.test.ts +28 -0
  50. package/frontend-vue/tests/tree-without-source.test.ts +79 -0
  51. package/package.json +1 -1
@@ -11,10 +11,13 @@ const props = defineProps<GridRowProps>();
11
11
 
12
12
  const gridContext = inject<GridContext>(GRID_CONTEXT);
13
13
 
14
- const columnCount = props.childCount || 0;
15
- if (gridContext) {
16
- gridContext.registerRowColumnCount(columnCount);
17
- }
14
+ /** The identity this row counts under, so its width dies with it. */
15
+ const rowId = Symbol("grid-row");
16
+
17
+ const columnCount = computed(() => props.childCount || 0);
18
+
19
+ watchEffect(() => gridContext?.setRowColumnCount(rowId, columnCount.value));
20
+ onUnmounted(() => gridContext?.dropRow(rowId));
18
21
 
19
22
  const rowStyle = computed(() => {
20
23
  const gap = gridContext?.gap.value ?? DEFAULT_GAP;
@@ -1,7 +1,14 @@
1
1
  export const GRID_CONTEXT = Symbol("grid-context");
2
2
 
3
3
  export interface GridContext {
4
- registerRowColumnCount: (count: number) => void;
4
+ /**
5
+ * A row states how many columns it holds, and says so again whenever that
6
+ * changes: a row built by hand never moves, but one an editor fills gains and
7
+ * loses children while the page is open.
8
+ */
9
+ setRowColumnCount: (row: symbol, count: number) => void;
10
+ /** A row that unmounts stops counting, so a stale width cannot outlive it. */
11
+ dropRow: (row: symbol) => void;
5
12
  maxColumns: ComputedRef<number>;
6
13
  gap: ComputedRef<string>;
7
14
  minColumnWidth: ComputedRef<string>;
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
3
  import { useChartFetch } from "../../composables/chart/useChartFetch";
4
- import { formatValueParts } from "../../composables/chart/formatValue";
4
+ import {
5
+ ABSENT_VALUE_PARTS,
6
+ formatValueParts,
7
+ } from "../../composables/chart/formatValue";
5
8
  import { resolveSparklineAccent } from "../../composables/chart/resolveSparklineAccent";
6
9
  import type {
7
10
  KpiCardResponse,
@@ -69,18 +72,20 @@ const { data, isLoading } = useChartFetch<KpiCardResponse>({
69
72
  watchSource: () => watchKey.value,
70
73
  });
71
74
 
72
- const value = computed(() => data.value?.value ?? 0);
75
+ const value = computed(() => data.value?.value);
73
76
  const delta = computed(() => data.value?.delta ?? null);
74
77
  const sparkline = computed(() => data.value?.sparkline ?? []);
75
78
 
76
79
  const formattedParts = computed(() =>
77
- formatValueParts(
78
- value.value,
79
- props.valueFormat,
80
- locale.value,
81
- props.currencyCode,
82
- props.valuePrecision,
83
- ),
80
+ value.value === undefined
81
+ ? ABSENT_VALUE_PARTS
82
+ : formatValueParts(
83
+ value.value,
84
+ props.valueFormat,
85
+ locale.value,
86
+ props.currencyCode,
87
+ props.valuePrecision,
88
+ ),
84
89
  );
85
90
 
86
91
  const showTrend = computed(() => props.showDelta && delta.value !== null);
@@ -112,6 +112,21 @@ export interface FormattedValueParts {
112
112
  unitIsPrefix: boolean;
113
113
  }
114
114
 
115
+ /**
116
+ * What a card prints where its figure would go when the query measured nothing.
117
+ *
118
+ * An em dash and not a zero: a zero is itself a measurement, so a card printing
119
+ * one for a period with no rows in it states a result nobody computed. No unit
120
+ * either — there is no quantity for it to qualify.
121
+ */
122
+ export const ABSENT_VALUE_TEXT = "—";
123
+
124
+ export const ABSENT_VALUE_PARTS: FormattedValueParts = {
125
+ value: ABSENT_VALUE_TEXT,
126
+ unit: "",
127
+ unitIsPrefix: false,
128
+ };
129
+
115
130
  /** Splits the same formatted value into numeric and unit parts for KPI cards. */
116
131
  export function formatValueParts(
117
132
  value: number,
@@ -82,7 +82,8 @@ export interface DonutRecord {
82
82
  }
83
83
 
84
84
  export interface ChartCardResponse {
85
- value: number;
85
+ /** Absent when the query measured no group; the card shows no figure. */
86
+ value?: number;
86
87
  delta?: number;
87
88
  previousValue?: number;
88
89
  series: ChartSeries[];
@@ -90,7 +91,8 @@ export interface ChartCardResponse {
90
91
  }
91
92
 
92
93
  export interface KpiCardResponse {
93
- value: number;
94
+ /** Absent when the query measured no group; the card shows no figure. */
95
+ value?: number;
94
96
  delta?: number;
95
97
  previousValue?: number;
96
98
  sparkline?: number[];
@@ -1,29 +1,8 @@
1
1
  import { onBeforeUnmount, onMounted, ref, type Ref } from "vue";
2
2
  import { clearChartColorCache } from "./useChartTheme";
3
3
 
4
- interface ColorModeLike {
5
- preference?: string;
6
- value?: string;
7
- $subscribe?: (cb: () => void) => () => void;
8
- }
9
-
10
- type ColorModeFactory = () => ColorModeLike;
11
-
12
4
  const THEME_OBSERVED_ATTRIBUTES = ["class", "data-theme", "style"];
13
5
 
14
- function getColorModeFactory(): ColorModeFactory | undefined {
15
- const fn = (globalThis as unknown as { useColorMode?: ColorModeFactory })
16
- .useColorMode;
17
- return typeof fn === "function" ? fn : undefined;
18
- }
19
-
20
- function trySubscribeColorMode(bump: () => void) {
21
- const factory = getColorModeFactory();
22
- if (!factory) return;
23
- const mode = factory();
24
- mode?.$subscribe?.(bump);
25
- }
26
-
27
6
  function createThemeObserver(bump: () => void): MutationObserver {
28
7
  const observer = new MutationObserver(bump);
29
8
  observer.observe(document.documentElement, {
@@ -47,7 +26,6 @@ export function useThemeRevision(): Ref<number> {
47
26
 
48
27
  onMounted(() => {
49
28
  observer = createThemeObserver(bump);
50
- trySubscribeColorMode(bump);
51
29
  });
52
30
 
53
31
  onBeforeUnmount(() => {
@@ -17,6 +17,8 @@ export interface FormProps extends FormComponentProps {
17
17
  submitUrl?: string;
18
18
  submitUrlMethod?: HttpMethod;
19
19
  submitLabel?: string;
20
+ /** Whether the buttons show; left out, once there is somewhere to submit to. */
21
+ showActions?: boolean;
20
22
  successMessage?: string;
21
23
  errorMessage?: string;
22
24
  schema?: Record<string, unknown>;
@@ -28,6 +28,23 @@ export function cloneFormValue(
28
28
  return JSON.parse(JSON.stringify(value));
29
29
  }
30
30
 
31
+ /**
32
+ * Whether a form shows its reset and submit buttons.
33
+ *
34
+ * Asked for, they show whatever else holds — a form placed in the builder shows
35
+ * them before it has somewhere to submit to. Left to the form, they show once
36
+ * it has an address and a field someone can fill in.
37
+ */
38
+ export function formShowsActions(
39
+ options: { showActions?: boolean; submitUrl?: string },
40
+ fields: ReadonlyArray<{ disabled?: boolean }>,
41
+ ): boolean {
42
+ return (
43
+ options.showActions ??
44
+ (!!options.submitUrl && !fields.every((field) => field.disabled))
45
+ );
46
+ }
47
+
31
48
  export function computeResetState(
32
49
  state: Record<string, unknown>,
33
50
  initialValues: FormData,
@@ -137,6 +154,25 @@ export function replaceUrlVariables(
137
154
  return processedUrl;
138
155
  }
139
156
 
157
+ /** Where a submit goes, or what it lacks to go anywhere. */
158
+ export type SubmitTarget = { url: string } | { missing: "url" | "token" };
159
+
160
+ /**
161
+ * Where a submit is sent. A form with no URL has nowhere to send it — it is a
162
+ * read-only one — and a URL naming a token the page does not carry, such as an
163
+ * edit form opened without the id of its row, is not a route either: loading
164
+ * already skips such a URL, and submitting to it wrote to a path that was never
165
+ * meant to exist.
166
+ */
167
+ export function resolveSubmitTarget(
168
+ submitUrl: string | undefined,
169
+ context: ReplaceUrlVariablesContext,
170
+ ): SubmitTarget {
171
+ if (!submitUrl) return { missing: "url" };
172
+ const url = replaceUrlVariables(submitUrl, context);
173
+ return url.includes("{{") ? { missing: "token" } : { url };
174
+ }
175
+
140
176
  function processBeforeStateMappers(
141
177
  data: FormData,
142
178
  fields: FormFieldOrGroup[],
@@ -258,6 +294,28 @@ export function buildValidationSchema(
258
294
  return z.object(newShape).passthrough();
259
295
  }
260
296
 
297
+ // A switch always holds a value (off is `false`): it can never be left
298
+ // missing, so marking it required would only suggest an action that does not
299
+ // exist.
300
+ const ALWAYS_FILLED_FIELD_TYPES = new Set(["boolean"]);
301
+
302
+ /**
303
+ * Whether the form marks a field as required: declared so, or made so by a
304
+ * watch action, and neither disabled nor hidden, since `buildValidationSchema`
305
+ * does not validate inactive fields, nor a type that always holds a value.
306
+ */
307
+ export function isFieldMarkedRequired(
308
+ field: Pick<FormField, "id" | "required" | "disabled" | "type">,
309
+ disabled: Set<string> | undefined,
310
+ hidden: Set<string> | undefined,
311
+ required: Set<string> | undefined,
312
+ ): boolean {
313
+ if (field.disabled || disabled?.has(field.id) || hidden?.has(field.id))
314
+ return false;
315
+ if (field.type && ALWAYS_FILLED_FIELD_TYPES.has(field.type)) return false;
316
+ return !!field.required || (required?.has(field.id) ?? false);
317
+ }
318
+
261
319
  function watchFieldChanges(
262
320
  state: Ref<Record<string, unknown>>,
263
321
  componentId: string | undefined,
@@ -474,16 +532,29 @@ export const useForm = (props: FormProps) => {
474
532
  }
475
533
  };
476
534
 
535
+ const showUnresolvedTargetToast = () => {
536
+ toast.add({
537
+ title: processI18n("$dms.form.error_title"),
538
+ description: processI18n("$dms.form.error_no_target"),
539
+ color: Color.error,
540
+ });
541
+ };
542
+
477
543
  const onSubmit = async (event: FormSubmitEvent<FormData>) => {
544
+ const target = resolveSubmitTarget(props.submitUrl, buildUrlContext());
545
+ if ("missing" in target) {
546
+ // A read-only form renders no submit button, but Enter in one of its
547
+ // fields still submits it: that does nothing. Falling back on `/` sent
548
+ // the values to the site root with a PUT and reported nothing.
549
+ if (target.missing === "token") showUnresolvedTargetToast();
550
+ return;
551
+ }
552
+ const submitUrl = target.url;
478
553
  const plainData = collectSubmitData(
479
554
  event,
480
555
  allFields.value,
481
556
  effectiveSubmitDefaults.value,
482
557
  );
483
- const submitUrl = replaceUrlVariables(
484
- props.submitUrl || "/",
485
- buildUrlContext(),
486
- );
487
558
 
488
559
  loading.value = true;
489
560
  try {
@@ -546,6 +617,7 @@ export const useForm = (props: FormProps) => {
546
617
  allFields,
547
618
  disabledFields,
548
619
  hiddenFields,
620
+ requiredFields,
549
621
  isFieldGroup,
550
622
  submitSucceeded,
551
623
  resolvedFetchUrl,
@@ -106,12 +106,6 @@ export async function useTree<T = unknown>(props: TreeProps) {
106
106
  },
107
107
  });
108
108
 
109
- if (!props.fetchUrl && !props.staticNodes) {
110
- throw new Error(
111
- "Either fetchUrl or staticNodes must be provided to useTree",
112
- );
113
- }
114
-
115
109
  const loadingNodes = ref<Set<string>>(new Set());
116
110
  // Shallow on purpose: `TreeItem` carries a string index signature and
117
111
  // recurses through `children`, so Vue's deep `UnwrapRef` on a tree node
@@ -194,12 +188,16 @@ export async function useTree<T = unknown>(props: TreeProps) {
194
188
  }
195
189
  }
196
190
 
191
+ // A tree with neither a URL nor nodes of its own holds nothing yet, which is
192
+ // what a block just placed on a page looks like: it is configured after it is
193
+ // placed, and the editor says what is still missing. Refusing to render at
194
+ // all would answer that gesture with a crash.
197
195
  const dataLoader = props.fetchUrl
198
196
  ? () =>
199
197
  $authFetch<TreeNode<T>[]>(props.fetchUrl!, {
200
198
  method: props.fetchUrlMethod || "GET",
201
199
  })
202
- : () => Promise.resolve(props.staticNodes as TreeNode<T>[]);
200
+ : () => Promise.resolve((props.staticNodes ?? []) as TreeNode<T>[]);
203
201
 
204
202
  const { data, status, refresh } = await useDmsAsyncData(
205
203
  `tree-${props.componentId}-${props.pageId}-${props.componentId}`,
@@ -161,6 +161,7 @@
161
161
  "success_message": "Data has been successfully saved",
162
162
  "error_title": "Error",
163
163
  "error_unknown": "An unknown error occurred",
164
+ "error_no_target": "This form does not know which item to save. Open it from a link that names one.",
164
165
  "no_data": "No data available",
165
166
  "empty_value": "—",
166
167
  "actions": "Actions",
@@ -169,6 +170,7 @@
169
170
  "row_added_success": "The item has been successfully added",
170
171
  "error_missing_fields": "Missing required fields",
171
172
  "error_fill_all_fields": "Please fill in all required fields",
173
+ "required_legend": "Required fields",
172
174
  "fetch_error_title": "Failed to fetch data",
173
175
  "fetch_error_unknown": "An unexpected error occurred. Please try again later.",
174
176
  "select_date": "Select date",
@@ -161,6 +161,7 @@
161
161
  "success_message": "Les données ont été enregistrées avec succès",
162
162
  "error_title": "Erreur",
163
163
  "error_unknown": "Une erreur inconnue s'est produite",
164
+ "error_no_target": "Ce formulaire ne sait pas quel élément enregistrer. Ouvrez-le depuis un lien qui en désigne un.",
164
165
  "no_data": "Aucune donnée disponible",
165
166
  "empty_value": "—",
166
167
  "actions": "Actions",
@@ -169,6 +170,7 @@
169
170
  "row_added_success": "L'élément a été ajouté avec succès",
170
171
  "error_missing_fields": "Champs requis manquants",
171
172
  "error_fill_all_fields": "Veuillez remplir tous les champs requis",
173
+ "required_legend": "Champs requis",
172
174
  "error_not_submitted": "Le formulaire n'a pas été soumis",
173
175
  "fetch_error_title": "Échec de la récupération des données",
174
176
  "fetch_error_unknown": "Une erreur inattendue s'est produite. Veuillez réessayer plus tard.",
@@ -39,7 +39,7 @@
39
39
  "@vue-flow/controls": "^1.1.3",
40
40
  "@vue-flow/core": "^1.48.2",
41
41
  "@vue-flow/minimap": "^1.5.4",
42
- "@vueuse/core": "^13.0.0",
42
+ "@vueuse/core": "^14.3.0",
43
43
  "@vueuse/integrations": "^14.1.0",
44
44
  "apexcharts": "^4.4.0",
45
45
  "defu": "^6.1.4",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@antelopejs/core": ">=1.9.1 <2",
62
- "@antelopejs/dms-frontend": ">=0.2.6 <1.0.0",
62
+ "@antelopejs/dms-frontend": ">=0.2.8 <1.0.0",
63
63
  "@eslint/js": "^8.57.0",
64
64
  "@inertiajs/vue3": "^3.7.0",
65
65
  "@nuxt/ui": "4.10.0",
@@ -66,8 +66,8 @@ importers:
66
66
  specifier: ^1.5.4
67
67
  version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
68
68
  '@vueuse/core':
69
- specifier: ^13.0.0
70
- version: 13.9.0(vue@3.5.25(typescript@5.9.3))
69
+ specifier: ^14.3.0
70
+ version: 14.4.0(vue@3.5.25(typescript@5.9.3))
71
71
  '@vueuse/integrations':
72
72
  specifier: ^14.1.0
73
73
  version: 14.1.0(focus-trap@7.8.0)(fuse.js@7.5.0)(sortablejs@1.14.0)(vue@3.5.25(typescript@5.9.3))
@@ -124,8 +124,8 @@ importers:
124
124
  specifier: '>=1.9.1 <2'
125
125
  version: 1.9.1(@types/node@24.8.1)
126
126
  '@antelopejs/dms-frontend':
127
- specifier: '>=0.2.6 <1.0.0'
128
- version: 0.2.6(@antelopejs/core@1.9.1(@types/node@24.8.1))
127
+ specifier: '>=0.2.8 <1.0.0'
128
+ version: 0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))
129
129
  '@eslint/js':
130
130
  specifier: ^8.57.0
131
131
  version: 8.57.0
@@ -200,8 +200,8 @@ packages:
200
200
  resolution: {integrity: sha512-MPLJak0rPluV4lgsdsO5ba03sgTxIGSncajBcoaoppuppQmksdpaemojIHiVLJ4LiG0iiH21Roaed8XXy1XkYQ==}
201
201
  hasBin: true
202
202
 
203
- '@antelopejs/dms-frontend@0.2.6':
204
- resolution: {integrity: sha512-dCR9EWkpW4CekV5M87igyCZyRNXt9fVr5M2t9neATj1JqN/r6MltYJd4ShKnilSN4zQZ4ykkuFL/b9b0k79rDw==}
203
+ '@antelopejs/dms-frontend@0.2.8':
204
+ resolution: {integrity: sha512-7qrlf2M3J26DZxGZVvM/3qjPDeDfoRMrE7HEPFxWav8Pe1QFct7LtgY7NmKup9XdrRyUw4nqckxvW8UZDSjpeg==}
205
205
  engines: {node: '>=20.12.0'}
206
206
  hasBin: true
207
207
  peerDependencies:
@@ -1753,11 +1753,6 @@ packages:
1753
1753
  '@vueuse/core@12.8.2':
1754
1754
  resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
1755
1755
 
1756
- '@vueuse/core@13.9.0':
1757
- resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
1758
- peerDependencies:
1759
- vue: ^3.5.0
1760
-
1761
1756
  '@vueuse/core@14.1.0':
1762
1757
  resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==}
1763
1758
  peerDependencies:
@@ -1858,9 +1853,6 @@ packages:
1858
1853
  '@vueuse/metadata@12.8.2':
1859
1854
  resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
1860
1855
 
1861
- '@vueuse/metadata@13.9.0':
1862
- resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
1863
-
1864
1856
  '@vueuse/metadata@14.1.0':
1865
1857
  resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
1866
1858
 
@@ -1873,11 +1865,6 @@ packages:
1873
1865
  '@vueuse/shared@12.8.2':
1874
1866
  resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
1875
1867
 
1876
- '@vueuse/shared@13.9.0':
1877
- resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
1878
- peerDependencies:
1879
- vue: ^3.5.0
1880
-
1881
1868
  '@vueuse/shared@14.1.0':
1882
1869
  resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==}
1883
1870
  peerDependencies:
@@ -4344,7 +4331,7 @@ snapshots:
4344
4331
  transitivePeerDependencies:
4345
4332
  - '@types/node'
4346
4333
 
4347
- '@antelopejs/dms-frontend@0.2.6(@antelopejs/core@1.9.1(@types/node@24.8.1))':
4334
+ '@antelopejs/dms-frontend@0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))':
4348
4335
  dependencies:
4349
4336
  boxen: 5.1.2
4350
4337
  chalk: 4.1.2
@@ -5944,13 +5931,6 @@ snapshots:
5944
5931
  transitivePeerDependencies:
5945
5932
  - typescript
5946
5933
 
5947
- '@vueuse/core@13.9.0(vue@3.5.25(typescript@5.9.3))':
5948
- dependencies:
5949
- '@types/web-bluetooth': 0.0.21
5950
- '@vueuse/metadata': 13.9.0
5951
- '@vueuse/shared': 13.9.0(vue@3.5.25(typescript@5.9.3))
5952
- vue: 3.5.25(typescript@5.9.3)
5953
-
5954
5934
  '@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3))':
5955
5935
  dependencies:
5956
5936
  '@types/web-bluetooth': 0.0.21
@@ -5989,8 +5969,6 @@ snapshots:
5989
5969
 
5990
5970
  '@vueuse/metadata@12.8.2': {}
5991
5971
 
5992
- '@vueuse/metadata@13.9.0': {}
5993
-
5994
5972
  '@vueuse/metadata@14.1.0': {}
5995
5973
 
5996
5974
  '@vueuse/metadata@14.4.0': {}
@@ -6008,10 +5986,6 @@ snapshots:
6008
5986
  transitivePeerDependencies:
6009
5987
  - typescript
6010
5988
 
6011
- '@vueuse/shared@13.9.0(vue@3.5.25(typescript@5.9.3))':
6012
- dependencies:
6013
- vue: 3.5.25(typescript@5.9.3)
6014
-
6015
5989
  '@vueuse/shared@14.1.0(vue@3.5.25(typescript@5.9.3))':
6016
5990
  dependencies:
6017
5991
  vue: 3.5.25(typescript@5.9.3)
@@ -0,0 +1,78 @@
1
+ import { createSSRApp, defineComponent, h, ref } from "vue";
2
+ import { renderToString } from "vue/server-renderer";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import ChartCard from "../layers/dms-ui/app/components/chart/ChartCard.vue";
5
+ import KpiCard from "../layers/dms-ui/app/components/kpi/KpiCard.vue";
6
+ import { ABSENT_VALUE_TEXT } from "../layers/dms-ui/app/composables/chart/formatValue";
7
+
8
+ // A period the query measured nothing in: the response carries its series and no
9
+ // figure at all, which is what the contract answers rather than a zero.
10
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
11
+ useChartFetch: () => ({
12
+ data: ref({ series: [{ name: "Revenue", data: [] }] }),
13
+ isLoading: ref(false),
14
+ }),
15
+ }));
16
+
17
+ const Slot = defineComponent({
18
+ setup:
19
+ (_, { slots }) =>
20
+ () =>
21
+ h("div", slots.default?.()),
22
+ });
23
+
24
+ const SLOT_COMPONENTS = [
25
+ "DmsCard",
26
+ "DmsClientOnly",
27
+ "UIcon",
28
+ "USkeleton",
29
+ "DmsTrendBadge",
30
+ "DmsSparkline",
31
+ "UBadge",
32
+ ];
33
+
34
+ function stubNuxt() {
35
+ vi.stubGlobal("useI18n", () => ({
36
+ locale: ref("en-US"),
37
+ t: (key: string) => key,
38
+ }));
39
+ vi.stubGlobal("useTranslation", () => ({
40
+ processI18n: (text: string) => text,
41
+ }));
42
+ vi.stubGlobal("useComponentEvent", () => ({}));
43
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
44
+ }
45
+
46
+ type CardProps = InstanceType<typeof ChartCard>["$props"];
47
+
48
+ async function renderCards() {
49
+ stubNuxt();
50
+ const props: CardProps = {
51
+ title: "Revenue",
52
+ componentId: "card",
53
+ pageId: "absent",
54
+ valueFormat: "currency",
55
+ };
56
+ const app = createSSRApp({
57
+ render: () => h("main", [h(ChartCard, props), h(KpiCard, props)]),
58
+ });
59
+ for (const name of SLOT_COMPONENTS) app.component(name, Slot);
60
+ return renderToString(app);
61
+ }
62
+
63
+ afterEach(() => vi.unstubAllGlobals());
64
+
65
+ describe("a card whose query measured nothing", () => {
66
+ it("shows an absence where the figure goes, on both cards", async () => {
67
+ const html = await renderCards();
68
+ expect(html.split(ABSENT_VALUE_TEXT)).toHaveLength(3);
69
+ });
70
+
71
+ it("prints no zero, and no unit for a quantity there is none of", async () => {
72
+ const html = await renderCards();
73
+ expect(html, "a zero reads exactly like a measured figure").not.toContain(
74
+ "<span>0</span>",
75
+ );
76
+ expect(html).not.toContain("€");
77
+ });
78
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The color-mode plugin as the server runs it: the `<html>` tag of the rendered
3
+ * document already carries an explicit preference, so the first paint is right
4
+ * without waiting for JavaScript, and `system` is left to the pre-paint script.
5
+ */
6
+ import { useHead } from "@unhead/vue";
7
+ import { createHead, renderSSRHead } from "@unhead/vue/server";
8
+ import { createSSRApp, ref } from "vue";
9
+ import { beforeEach, expect, it, vi } from "vitest";
10
+
11
+ interface CookieOptions {
12
+ default: () => unknown;
13
+ maxAge?: number;
14
+ }
15
+
16
+ const COLOR_MODE_COOKIE = "dms-color-mode";
17
+ const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365;
18
+ const requestCookies = new Map<string, unknown>();
19
+ const cookieReads = vi.fn();
20
+
21
+ vi.stubGlobal("defineDmsPlugin", (setup: unknown) => setup);
22
+ vi.stubGlobal("useHead", useHead);
23
+ vi.stubGlobal("useDmsCookie", (name: string, options: CookieOptions) => {
24
+ cookieReads(name, options);
25
+ return ref(
26
+ requestCookies.has(name) ? requestCookies.get(name) : options.default(),
27
+ );
28
+ });
29
+
30
+ const { default: colorModePlugin } = await import(
31
+ "../layers/dms-layout/app/plugins/color-mode"
32
+ );
33
+
34
+ async function renderHead(preference?: string) {
35
+ if (preference !== undefined)
36
+ requestCookies.set(COLOR_MODE_COOKIE, preference);
37
+ const app = createSSRApp({ render: () => null });
38
+ const head = createHead();
39
+ app.use(head);
40
+ await app.runWithContext(() => colorModePlugin({} as never));
41
+ return renderSSRHead(head);
42
+ }
43
+
44
+ beforeEach(() => {
45
+ requestCookies.clear();
46
+ cookieReads.mockClear();
47
+ });
48
+
49
+ it.each(["light", "dark"])(
50
+ "renders the %s class on <html> for an explicit preference",
51
+ async (preference) => {
52
+ const { htmlAttrs } = await renderHead(preference);
53
+
54
+ expect(htmlAttrs).toContain(` class="${preference}"`);
55
+ },
56
+ );
57
+
58
+ it.each([undefined, "system", "sepia"])(
59
+ "leaves the class to the browser for a %s preference",
60
+ async (preference) => {
61
+ const { htmlAttrs } = await renderHead(preference);
62
+
63
+ expect(htmlAttrs).not.toContain("class");
64
+ },
65
+ );
66
+
67
+ it("reads the dms-color-mode cookie, kept a year and defaulting to system", async () => {
68
+ await renderHead();
69
+
70
+ expect(cookieReads).toHaveBeenCalledOnce();
71
+ const [name, options] = cookieReads.mock.calls[0] as [string, CookieOptions];
72
+ expect(name).toBe(COLOR_MODE_COOKIE);
73
+ expect(options.maxAge).toBe(ONE_YEAR_IN_SECONDS);
74
+ expect(options.default()).toBe("system");
75
+ });
76
+
77
+ it("ships the pre-paint script as a classic inline script in <head>", async () => {
78
+ // No src, type, defer or async: the browser runs it while it parses <head>,
79
+ // before the deferred entry module that installs Nuxt UI.
80
+ const { headTags, bodyTags, bodyTagsOpen } = await renderHead("dark");
81
+
82
+ expect(headTags).toMatch(/<script id="dms-color-mode">\(\(\) => \{/);
83
+ expect(bodyTags + bodyTagsOpen).toBe("");
84
+ });