@antelopejs/dms 0.6.1 → 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 (33) hide show
  1. package/CHANGELOG.md +17 -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/types/page.ts +7 -1
  17. package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
  18. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
  19. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
  20. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
  21. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
  23. package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
  24. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
  25. package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -18
  26. package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -23
  27. package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
  28. package/frontend-vue/tests/composed-text.test.ts +265 -0
  29. package/frontend-vue/tests/layout-banners.test.ts +56 -0
  30. package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
  31. package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
  32. package/package.json +2 -2
  33. package/skills/dms/SKILL.md +7 -0
@@ -0,0 +1,118 @@
1
+ import { h, type VNodeChild } from "vue";
2
+ import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
3
+ import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
4
+ import {
5
+ type ComposableText,
6
+ isComposedText,
7
+ } from "../../../../../dms-core/app/utils/composedText";
8
+ import { readRowField, readSubline, toneClass, type Row } from "./cellHelpers";
9
+
10
+ const EMPTY = "—";
11
+ const PRIMARY_CLASS = "text-highlighted truncate text-[13px] font-medium";
12
+ const EMPTY_PRIMARY_CLASS = "text-dimmed truncate text-[13px]";
13
+ const SUB_CLASS = "truncate text-xs";
14
+
15
+ interface TwoLineOptions {
16
+ /** Row field holding the primary line, in place of the column's value. */
17
+ primaryField?: string;
18
+ /** Row field holding the secondary line. */
19
+ subField?: string;
20
+ /** Tone of the secondary line when the row gives none. */
21
+ subTone?: string;
22
+ /** Text of an empty primary line. */
23
+ emptyLabel?: string;
24
+ /** The column's own data type, which writes its value. */
25
+ columnType?: string;
26
+ /** The column's own type options (a select's items). */
27
+ typeOptions?: unknown;
28
+ }
29
+
30
+ const isBlank = (value: unknown): boolean =>
31
+ value === null || value === undefined || value === "";
32
+
33
+ /** The column's value written by the column's own data type. */
34
+ function formatColumnValue(
35
+ value: unknown,
36
+ locale: string,
37
+ opts: TwoLineOptions,
38
+ row: Row,
39
+ ): VNodeChild {
40
+ const { getDataType } = useDataTypes();
41
+ const formatter = opts.columnType
42
+ ? getDataType(opts.columnType)?.formatter
43
+ : undefined;
44
+ const format = formatter?.cell ?? formatter?.default;
45
+ if (!format) return String(value);
46
+ return format(value, locale, opts.typeOptions, row) as VNodeChild;
47
+ }
48
+
49
+ /** The primary line, or nothing when the row has none to show. */
50
+ function readPrimary(
51
+ value: unknown,
52
+ locale: string,
53
+ opts: TwoLineOptions,
54
+ row: Row,
55
+ ): VNodeChild | undefined {
56
+ const { processText } = useComposedText();
57
+ if (opts.primaryField) {
58
+ const text = processText(
59
+ readRowField(row, opts.primaryField) as ComposableText,
60
+ );
61
+ return text || undefined;
62
+ }
63
+ if (isBlank(value)) return undefined;
64
+ if (isComposedText(value)) return processText(value);
65
+ return formatColumnValue(value, locale, opts, row);
66
+ }
67
+
68
+ /**
69
+ * `two_line`: a primary line — the column's value in its own format, or a row
70
+ * field's text — over a secondary line read off the row, in the row's tone,
71
+ * else `subTone`, else muted. No avatar.
72
+ */
73
+ function renderTwoLine(
74
+ value: unknown,
75
+ locale: string,
76
+ options: unknown,
77
+ row: Row,
78
+ ) {
79
+ const { processText } = useComposedText();
80
+ const opts = (options ?? {}) as TwoLineOptions;
81
+ const primary = readPrimary(value, locale, opts, row);
82
+ const sub = readSubline(readRowField(row, opts.subField), {
83
+ processText,
84
+ isStringTranslated: true,
85
+ });
86
+ return h("span", { class: "flex min-w-0 flex-col leading-tight" }, [
87
+ primary === undefined
88
+ ? h(
89
+ "span",
90
+ { class: EMPTY_PRIMARY_CLASS },
91
+ opts.emptyLabel ? processText(opts.emptyLabel) : EMPTY,
92
+ )
93
+ : h("span", { class: PRIMARY_CLASS }, [primary]),
94
+ sub
95
+ ? h(
96
+ "span",
97
+ {
98
+ class: [SUB_CLASS, toneClass(sub.tone ?? opts.subTone, "muted")],
99
+ title: sub.text,
100
+ },
101
+ sub.text,
102
+ )
103
+ : null,
104
+ ]);
105
+ }
106
+
107
+ /**
108
+ * The `two_line` cell data type. It draws an empty value too: its primary
109
+ * line may come from another field, and its secondary line always does.
110
+ */
111
+ export const registerTwoLineCellType = (
112
+ registerDataType: (dataType: DataType) => void,
113
+ ) => {
114
+ registerDataType({
115
+ id: "two_line",
116
+ formatter: { default: renderTwoLine, empty: renderTwoLine },
117
+ });
118
+ };
@@ -45,8 +45,10 @@ export function useColumnValueRenderer() {
45
45
  const { locale } = useI18n();
46
46
 
47
47
  // A display also reads the column's own type options (`typeOptions`): a
48
- // select's items name the values it draws. A display declared without
49
- // options reads them at the top level, as it always did.
48
+ // select's items name the values it draws; and the column's own type id
49
+ // (`columnType`), so it can write the value as the column would (an amount
50
+ // under `two_line`). A display declared without options reads the type
51
+ // options at the top level, as it always did.
50
52
  const resolveRenderer = (column: RenderableColumn) => {
51
53
  const typeOptions = column.type?.inputComponent?.options as
52
54
  | Record<string, unknown>
@@ -56,7 +58,7 @@ export function useColumnValueRenderer() {
56
58
  const displayOptions = column.display.options ?? typeOptions;
57
59
  return {
58
60
  typeId: column.display.type,
59
- options: { ...displayOptions, typeOptions },
61
+ options: { ...displayOptions, typeOptions, columnType: column.type?.id },
60
62
  };
61
63
  };
62
64
 
@@ -1,29 +1,6 @@
1
+ import { formatRelativeDistance } from "#dms-core/app/utils/formatter";
1
2
  import { regionalDateTimeFormat } from "#dms-core/app/utils/regional";
2
3
 
3
- const SECOND_MS = 1000;
4
- const MINUTE_MS = 60 * SECOND_MS;
5
- const HOUR_MS = 60 * MINUTE_MS;
6
- const DAY_MS = 24 * HOUR_MS;
7
- const WEEK_MS = 7 * DAY_MS;
8
- const MONTH_MS = 30 * DAY_MS;
9
- const YEAR_MS = 365 * DAY_MS;
10
-
11
- interface RelativeUnit {
12
- unit: Intl.RelativeTimeFormatUnit;
13
- ms: number;
14
- /** The unit applies below this elapsed time. */
15
- below: number;
16
- }
17
-
18
- const RELATIVE_UNITS: RelativeUnit[] = [
19
- { unit: "minute", ms: MINUTE_MS, below: HOUR_MS },
20
- { unit: "hour", ms: HOUR_MS, below: DAY_MS },
21
- { unit: "day", ms: DAY_MS, below: WEEK_MS },
22
- { unit: "week", ms: WEEK_MS, below: MONTH_MS },
23
- { unit: "month", ms: MONTH_MS, below: YEAR_MS },
24
- { unit: "year", ms: YEAR_MS, below: Number.POSITIVE_INFINITY },
25
- ];
26
-
27
4
  function capitalize(text: string): string {
28
5
  return text ? `${text[0]!.toLocaleUpperCase()}${text.slice(1)}` : text;
29
6
  }
@@ -37,17 +14,9 @@ export function formatRelativeDate(
37
14
  locale: string,
38
15
  now: number = Date.now(),
39
16
  ): string {
40
- const deltaMs = new Date(date).getTime() - now;
41
- const distance = Math.abs(deltaMs);
42
- const unit =
43
- RELATIVE_UNITS.find((candidate) => distance < candidate.below) ??
44
- RELATIVE_UNITS[RELATIVE_UNITS.length - 1]!;
45
- const value = Math.round(deltaMs / unit.ms);
46
- const format = new Intl.RelativeTimeFormat(locale, {
47
- numeric: "auto",
48
- style: "short",
49
- });
50
- return capitalize(format.format(value, unit.unit));
17
+ return capitalize(
18
+ formatRelativeDistance(date, locale, { style: "short", now }),
19
+ );
51
20
  }
52
21
 
53
22
  /** "Sep 27": day and short month. */
@@ -5,7 +5,9 @@ import DmsBlockActions, {
5
5
  type BlockAction,
6
6
  } from "../../build/components/blocks/BlockActions.vue";
7
7
  import { useWatch } from "../../../../dms-core/app/composables/watch/useWatch";
8
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
8
9
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
10
+ import type { BlockText } from "../../../../dms-core/app/types/composed-text";
9
11
 
10
12
  type BannerTone = "info" | "success" | "warning" | "error" | "primary";
11
13
 
@@ -14,8 +16,9 @@ type BannerTone = "info" | "success" | "warning" | "error" | "primary";
14
16
  // Dismissals live in a cookie (like the dashboard banners) so the server
15
17
  // render already leaves a dismissed banner out instead of flashing it.
16
18
  interface BannerBlockProps extends Partial<DefaultComponentProps> {
17
- title?: string;
18
- description?: string;
19
+ /** A string (`$` for an i18n key) or a composed text. */
20
+ title?: BlockText;
21
+ description?: BlockText;
19
22
  tone?: BannerTone;
20
23
  /** Overrides the tone's icon. */
21
24
  icon?: string;
@@ -56,7 +59,7 @@ const TONE_ICONS: Record<BannerTone, string> = {
56
59
 
57
60
  useWatch(props.watchActions || [], props.componentId);
58
61
 
59
- const { processI18n } = useTranslation();
62
+ const { processText } = useComposedText();
60
63
  const dismissed = useDmsCookie<string[]>(DISMISSED_COOKIE, {
61
64
  default: () => [],
62
65
  maxAge: DISMISSED_MAX_AGE_S,
@@ -91,9 +94,9 @@ function dismiss(): void {
91
94
  <template>
92
95
  <DmsBanner
93
96
  v-if="isVisible"
94
- :title="props.title ? processI18n(props.title) : undefined"
97
+ :title="props.title ? processText(props.title) : undefined"
95
98
  :description="
96
- props.description ? processI18n(props.description) : undefined
99
+ props.description ? processText(props.description) : undefined
97
100
  "
98
101
  :icon="props.icon ?? TONE_ICONS[props.tone]"
99
102
  :tone="props.tone"
@@ -8,13 +8,24 @@ import DmsBlockStatus, {
8
8
  type BlockEmptyText,
9
9
  } from "../../build/components/blocks/BlockStatus.vue";
10
10
  import { useBlockItems } from "../../build/composables/blocks/useBlockItems";
11
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
11
12
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
13
+ import type { BlockText } from "../../../../dms-core/app/types/composed-text";
14
+ import { isComposedText } from "../../../../dms-core/app/utils/composedText";
12
15
 
13
16
  // `KeyValueList` block (interface-dms `base/key-value-list`): label / value
14
17
  // rows (v2 .c-dl), static or from `fetchUrl` (`{ items }`), in a card by
15
- // default. Labels, details and text values follow the `$` i18n convention.
18
+ // default. Labels, details and text values follow the `$` i18n convention or
19
+ // are composed texts.
20
+ interface KeyValueListBlockItem
21
+ extends Omit<KeyValueItem, "label" | "value" | "detail"> {
22
+ label: BlockText;
23
+ value?: BlockText | number | null;
24
+ detail?: BlockText;
25
+ }
26
+
16
27
  interface KeyValueListBlockProps extends DefaultComponentProps {
17
- items?: KeyValueItem[];
28
+ items?: KeyValueListBlockItem[];
18
29
  dense?: boolean;
19
30
  columns?: number;
20
31
  currency?: string;
@@ -50,30 +61,39 @@ const props = withDefaults(defineProps<KeyValueListBlockProps>(), {
50
61
 
51
62
  const TRANSLATED_TYPES = new Set([undefined, "text", "status", "link"]);
52
63
 
53
- const { processI18n } = useTranslation();
64
+ const { processText } = useComposedText();
54
65
 
55
- const { items, isPending, hasError, refresh } = useBlockItems<KeyValueItem>({
56
- items: () => props.items,
57
- fetchUrl: props.fetchUrl,
58
- fetchUrlMethod: props.fetchUrlMethod,
59
- routeParams: () => props.routeParams,
60
- watchActions: props.watchActions,
61
- componentId: props.componentId,
62
- });
66
+ const { items, isPending, hasError, refresh } =
67
+ useBlockItems<KeyValueListBlockItem>({
68
+ items: () => props.items,
69
+ fetchUrl: props.fetchUrl,
70
+ fetchUrlMethod: props.fetchUrlMethod,
71
+ routeParams: () => props.routeParams,
72
+ watchActions: props.watchActions,
73
+ componentId: props.componentId,
74
+ });
75
+
76
+ // A composed value already says how each of its parameters is written, so it
77
+ // is composed whatever the row's type; a plain one is translated only where
78
+ // the type draws text.
79
+ function resolveValue(item: KeyValueListBlockItem): KeyValueItem["value"] {
80
+ if (isComposedText(item.value)) return processText(item.value);
81
+ if (typeof item.value === "string" && TRANSLATED_TYPES.has(item.type)) {
82
+ return processText(item.value);
83
+ }
84
+ return item.value as KeyValueItem["value"];
85
+ }
63
86
 
64
87
  const resolvedItems = computed<KeyValueItem[]>(() =>
65
88
  items.value.map((item) => ({
66
89
  ...item,
67
- label: processI18n(item.label ?? ""),
68
- detail: item.detail ? processI18n(item.detail) : undefined,
69
- value:
70
- typeof item.value === "string" && TRANSLATED_TYPES.has(item.type)
71
- ? processI18n(item.value)
72
- : item.value,
90
+ label: processText(item.label),
91
+ detail: item.detail ? processText(item.detail) : undefined,
92
+ value: resolveValue(item),
73
93
  })),
74
94
  );
75
95
  const title = computed(() =>
76
- props.title ? processI18n(props.title) : undefined,
96
+ props.title ? processText(props.title) : undefined,
77
97
  );
78
98
  const isEmpty = computed(
79
99
  () => !isPending.value && !hasError.value && resolvedItems.value.length === 0,
@@ -8,13 +8,23 @@ import DmsBlockStatus, {
8
8
  type BlockEmptyText,
9
9
  } from "../../build/components/blocks/BlockStatus.vue";
10
10
  import { useBlockItems } from "../../build/composables/blocks/useBlockItems";
11
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
11
12
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
13
+ import type { BlockText } from "../../../../dms-core/app/types/composed-text";
12
14
 
13
15
  // `StatGroup` block (interface-dms `base/stat-group`): the generic StatGroup
14
16
  // fed from the block options or from `fetchUrl` (`{ items }`). Texts follow
15
- // the `$` i18n-key convention; numeric values are formatted for the locale.
17
+ // the `$` i18n-key convention or are composed texts; numeric values are
18
+ // formatted for the locale.
19
+ interface StatGroupBlockItem
20
+ extends Omit<StatGroupItem, "eyebrow" | "value" | "detail"> {
21
+ eyebrow: BlockText;
22
+ value: BlockText | number;
23
+ detail?: BlockText;
24
+ }
25
+
16
26
  interface StatGroupBlockProps extends DefaultComponentProps {
17
- items?: StatGroupItem[];
27
+ items?: StatGroupBlockItem[];
18
28
  layout?: StatGroupLayout;
19
29
  columns?: number;
20
30
  /** Accessible name of the group. */
@@ -43,31 +53,24 @@ const props = withDefaults(defineProps<StatGroupBlockProps>(), {
43
53
 
44
54
  const DEFAULT_SKELETON_COUNT = 4;
45
55
 
46
- const { locale } = useI18n();
47
- const { processI18n } = useTranslation();
56
+ const { processText } = useComposedText();
48
57
 
49
- const { items, isPending, hasError, refresh } = useBlockItems<StatGroupItem>({
50
- items: () => props.items,
51
- fetchUrl: props.fetchUrl,
52
- fetchUrlMethod: props.fetchUrlMethod,
53
- routeParams: () => props.routeParams,
54
- watchActions: props.watchActions,
55
- componentId: props.componentId,
56
- });
57
-
58
- function formatValue(value: StatGroupItem["value"]): string {
59
- if (typeof value === "number") {
60
- return new Intl.NumberFormat(locale.value).format(value);
61
- }
62
- return processI18n(String(value ?? ""));
63
- }
58
+ const { items, isPending, hasError, refresh } =
59
+ useBlockItems<StatGroupBlockItem>({
60
+ items: () => props.items,
61
+ fetchUrl: props.fetchUrl,
62
+ fetchUrlMethod: props.fetchUrlMethod,
63
+ routeParams: () => props.routeParams,
64
+ watchActions: props.watchActions,
65
+ componentId: props.componentId,
66
+ });
64
67
 
65
68
  const resolvedItems = computed<StatGroupItem[]>(() =>
66
69
  items.value.map((item) => ({
67
70
  ...item,
68
- eyebrow: processI18n(item.eyebrow ?? ""),
69
- value: formatValue(item.value),
70
- detail: item.detail ? processI18n(item.detail) : undefined,
71
+ eyebrow: processText(item.eyebrow),
72
+ value: processText(item.value),
73
+ detail: item.detail ? processText(item.detail) : undefined,
71
74
  })),
72
75
  );
73
76
  </script>
@@ -88,6 +91,6 @@ const resolvedItems = computed<StatGroupItem[]>(() =>
88
91
  :skeleton-count="
89
92
  props.skeletonCount ?? props.columns ?? DEFAULT_SKELETON_COUNT
90
93
  "
91
- :label="props.label ? processI18n(props.label) : undefined"
94
+ :label="props.label ? processText(props.label) : undefined"
92
95
  />
93
96
  </template>
@@ -0,0 +1,205 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * StatGroup, KeyValueList and Banner draw a composed text wherever they draw
4
+ * a text — from their options or from the JSON their route answers — in the
5
+ * reader's language, and keep drawing plain strings as before.
6
+ */
7
+ import {
8
+ createApp,
9
+ defineComponent,
10
+ h,
11
+ reactive,
12
+ ref,
13
+ type Component,
14
+ } from "vue";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import { createI18n } from "vue-i18n";
17
+ import { setRegionalPreferencesSource } from "../layers/dms-core/app/utils/regional";
18
+
19
+ const i18n = createI18n({
20
+ legacy: false,
21
+ locale: "en",
22
+ fallbackLocale: "en",
23
+ missingWarn: false,
24
+ fallbackWarn: false,
25
+ messages: {
26
+ en: {
27
+ saas: {
28
+ stats: { mrr: "MRR", mrr_value: "{amount}", workspaces: "Workspaces" },
29
+ seats: "no seat | {count} seat | {count} seats",
30
+ retry: "Payment failed, retry {date}",
31
+ failed: "Payment failed",
32
+ },
33
+ },
34
+ fr: {
35
+ saas: {
36
+ stats: { mrr: "MRR", mrr_value: "{amount}", workspaces: "Espaces" },
37
+ seats: "aucun siège | {count} siège | {count} sièges",
38
+ retry: "Paiement refusé, nouvel essai {date}",
39
+ failed: "Paiement refusé",
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ let fetchedItems: unknown[] = [];
46
+
47
+ vi.stubGlobal("useAuthFetch", () => ({
48
+ $authFetch: async () => ({ items: fetchedItems }),
49
+ }));
50
+ vi.stubGlobal("useDmsRoute", () => reactive({ query: {} }));
51
+
52
+ vi.mock("../layers/dms-core/app/composables/watch/useWatch", () => ({
53
+ useWatch: () => ({ state: ref({}) }),
54
+ }));
55
+ vi.mock(
56
+ "../layers/dms-core/app/composables/components/useComponentEvent",
57
+ () => ({ useComponentEvent: () => ({}) }),
58
+ );
59
+
60
+ // The generic components only lay out what the block resolved: their props
61
+ // are what is checked here.
62
+ const propsProbe = (name: string, keys: string[]) => ({
63
+ default: defineComponent({
64
+ props: keys,
65
+ setup: (props) => () =>
66
+ h("pre", { "data-probe": name }, JSON.stringify(props)),
67
+ }),
68
+ });
69
+ vi.mock("../layers/dms-ui/app/components/stat-group/StatGroup.vue", () =>
70
+ propsProbe("stat-group", ["items"]),
71
+ );
72
+ vi.mock("../layers/dms-ui/app/components/key-value-list/KeyValueList.vue", () =>
73
+ propsProbe("key-value-list", ["items"]),
74
+ );
75
+ vi.mock("../layers/dms-ui/app/components/banner/Banner.vue", () =>
76
+ propsProbe("banner", ["title", "description"]),
77
+ );
78
+
79
+ const StatGroupBlock = (
80
+ await import("../layers/dms-ui/app/components/blocks/StatGroupBlock.vue")
81
+ ).default;
82
+ const KeyValueListBlock = (
83
+ await import("../layers/dms-ui/app/components/blocks/KeyValueListBlock.vue")
84
+ ).default;
85
+ const BannerBlock = (
86
+ await import("../layers/dms-ui/app/components/blocks/BannerBlock.vue")
87
+ ).default;
88
+
89
+ const SETTLE_MS = 50;
90
+ const settle = () => new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
91
+ const mounted: Array<{ unmount: () => void }> = [];
92
+
93
+ function render(component: Component, props: Record<string, unknown>) {
94
+ const root = document.createElement("div");
95
+ const app = createApp(component, props);
96
+ app.mount(root);
97
+ mounted.push(app);
98
+ return () =>
99
+ JSON.parse(
100
+ (root.querySelector("[data-probe]")?.textContent ?? "{}").replace(
101
+ /[\u00a0\u202f]/g,
102
+ " ",
103
+ ),
104
+ );
105
+ }
106
+
107
+ const mrr = {
108
+ key: "$saas.stats.mrr_value",
109
+ params: { amount: { type: "money", value: 92200, currency: "EUR" } },
110
+ };
111
+ const seats = (value: number) => ({
112
+ key: "saas.seats",
113
+ params: { count: { type: "count", value } },
114
+ });
115
+
116
+ beforeEach(() => {
117
+ i18n.global.locale.value = "en";
118
+ fetchedItems = [];
119
+ setRegionalPreferencesSource(() => ({ timeZone: "UTC" }));
120
+ vi.stubGlobal("useI18n", () => ({
121
+ t: i18n.global.t,
122
+ locale: i18n.global.locale,
123
+ }));
124
+ vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
125
+ });
126
+
127
+ afterEach(() => {
128
+ mounted.splice(0).forEach((app) => app.unmount());
129
+ setRegionalPreferencesSource(() => ({}));
130
+ });
131
+
132
+ describe("StatGroupBlock", () => {
133
+ it("composes the label, the value and the detail of a cell", () => {
134
+ const read = render(StatGroupBlock, {
135
+ items: [
136
+ { eyebrow: "$saas.stats.mrr", value: mrr, detail: seats(3) },
137
+ { eyebrow: "Plain", value: 1284, detail: "As written" },
138
+ ],
139
+ });
140
+
141
+ expect(read().items).toEqual([
142
+ { eyebrow: "MRR", value: "€922.00", detail: "3 seats" },
143
+ { eyebrow: "Plain", value: "1,284", detail: "As written" },
144
+ ]);
145
+ });
146
+
147
+ it("composes the cells its route answers, in the reader's language", async () => {
148
+ i18n.global.locale.value = "fr";
149
+ fetchedItems = [
150
+ {
151
+ eyebrow: { key: "saas.stats.workspaces" },
152
+ value: mrr,
153
+ detail: seats(1),
154
+ },
155
+ ];
156
+ const read = render(StatGroupBlock, { fetchUrl: "/api/billing-stats" });
157
+ await settle();
158
+
159
+ expect(read().items).toEqual([
160
+ { eyebrow: "Espaces", value: "922,00 €", detail: "1 siège" },
161
+ ]);
162
+ });
163
+ });
164
+
165
+ describe("KeyValueListBlock", () => {
166
+ it("composes labels, details and values, whatever the row's type", () => {
167
+ const read = render(KeyValueListBlock, {
168
+ card: false,
169
+ items: [
170
+ { label: { key: "saas.stats.mrr" }, value: mrr, type: "money" },
171
+ { label: "Seats", value: seats(0), detail: { key: "saas.failed" } },
172
+ { label: "Next invoice", value: 588, type: "money" },
173
+ { label: "Plan", value: "$saas.stats.workspaces" },
174
+ ],
175
+ });
176
+
177
+ expect(read().items).toEqual([
178
+ { label: "MRR", value: "€922.00", type: "money" },
179
+ { label: "Seats", value: "no seat", detail: "Payment failed" },
180
+ { label: "Next invoice", value: 588, type: "money" },
181
+ { label: "Plan", value: "Workspaces" },
182
+ ]);
183
+ });
184
+ });
185
+
186
+ describe("BannerBlock", () => {
187
+ it("composes its title and description", () => {
188
+ const read = render(BannerBlock, {
189
+ title: { key: "saas.failed" },
190
+ description: {
191
+ key: "saas.retry",
192
+ params: { date: { type: "date", value: "2026-10-10", format: "day" } },
193
+ },
194
+ });
195
+
196
+ expect(read()).toEqual({
197
+ title: "Payment failed",
198
+ description: "Payment failed, retry Oct 10",
199
+ });
200
+ });
201
+
202
+ it("keeps a plain title as written", () => {
203
+ expect(render(BannerBlock, { title: "Beta" })().title).toBe("Beta");
204
+ });
205
+ });