@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
@@ -10,11 +10,14 @@ import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
10
10
  import {
11
11
  isSet,
12
12
  readRowField,
13
+ readSubline,
13
14
  type Row,
14
15
  stringOf,
15
16
  toneClass,
16
17
  } from "./cellHelpers";
17
18
  import { registerMetricCellTypes } from "./metricCells";
19
+ import { registerTwoLineCellType } from "./twoLineCell";
20
+ import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
18
21
  import StatusPill from "../../../components/status-pill/StatusPill.vue";
19
22
  import IdentityCell from "../../components/table-view/IdentityCell.vue";
20
23
  import {
@@ -381,10 +384,15 @@ interface IdentityOptions {
381
384
  avatarField?: string;
382
385
  icon?: string;
383
386
  subtitleField?: string;
387
+ subtitleTone?: string;
384
388
  selfField?: string;
385
389
  selfLabel?: string;
386
390
  badges?: IdentityBadgeOption[];
387
391
  storage?: string;
392
+ /** Name drawn, muted, for a row without a value. */
393
+ emptyLabel?: string;
394
+ /** Icon tile of a row without a value. */
395
+ emptyIcon?: string;
388
396
  }
389
397
 
390
398
  const matchesBadge = (badge: IdentityBadgeOption, value: unknown): boolean =>
@@ -394,17 +402,28 @@ const matchesBadge = (badge: IdentityBadgeOption, value: unknown): boolean =>
394
402
 
395
403
  /**
396
404
  * `identity`: avatar or icon tile, the value as the name, a "You" tag on the
397
- * signed-in user's row, badges and a secondary line, all read off the row.
405
+ * signed-in user's row, badges and a secondary line (an address as written,
406
+ * or a composed text in its tone), all read off the row; `emptyLabel` and
407
+ * `emptyIcon` stand for a row without a value.
398
408
  */
399
409
  function renderIdentity(value: unknown, options: unknown, row: Row) {
400
410
  const { processI18n } = useTranslation();
411
+ const { processText } = useComposedText();
401
412
  const opts = (options ?? {}) as IdentityOptions;
402
413
  const badges = (opts.badges ?? [])
403
414
  .filter((badge) => matchesBadge(badge, readRowField(row, badge.field)))
404
415
  .map((badge) => ({ label: processI18n(badge.label), color: badge.tone }));
416
+ // An address or an id is data: only a composed text is translated.
417
+ const subtitle = readSubline(readRowField(row, opts.subtitleField), {
418
+ processText,
419
+ isStringTranslated: false,
420
+ });
405
421
  return h(IdentityCell, {
406
422
  title: stringOf(value) ?? "",
407
- subtitle: stringOf(readRowField(row, opts.subtitleField)) ?? "",
423
+ subtitle: subtitle?.text ?? "",
424
+ subtitleClass: toneClass(subtitle?.tone ?? opts.subtitleTone, "dimmed"),
425
+ emptyLabel: opts.emptyLabel ? processI18n(opts.emptyLabel) : undefined,
426
+ emptyIcon: opts.emptyIcon,
408
427
  avatar: readRowField(row, opts.avatarField) as
409
428
  | { key: string }
410
429
  | string
@@ -843,5 +862,6 @@ export function registerDefaultDataTypes() {
843
862
  registerDisplayOnlyTypes(registerDataType);
844
863
  registerCellTypes(registerDataType);
845
864
  registerMetricCellTypes(registerDataType);
865
+ registerTwoLineCellType(registerDataType);
846
866
  registerFieldTypes(registerDataType);
847
867
  }
@@ -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,5 +1,5 @@
1
1
  import type { LocationQueryValue } from "#dms/frontend-module";
2
- import { replaceUrlVariables } from "../../../composables/form/useForm";
2
+ import { replaceUrlVariables } from "../../utils/urlVariables";
3
3
 
4
4
  type RouteQuery = Record<string, LocationQueryValue | LocationQueryValue[]>;
5
5
 
@@ -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. */
@@ -0,0 +1,63 @@
1
+ // `{{params.id}}` takes the bare name, which on a route repeating a placeholder
2
+ // is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
3
+ // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
4
+ // table view (see extractRouteParams).
5
+ const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
6
+ const QUERY_TOKEN = /\{\{query\.(\w+)\}\}/g;
7
+ const RESPONSE_TOKEN = /\{\{response\.(\w+)\}\}/g;
8
+ const TOKEN_OPENING = "{{";
9
+
10
+ export interface ReplaceUrlVariablesContext {
11
+ routeParams?: Record<string, string>;
12
+ routeQuery: Record<string, unknown>;
13
+ response?: Record<string, unknown>;
14
+ }
15
+
16
+ /**
17
+ * Fills the `{{params.X}}`, `{{query.X}}` and (with a response)
18
+ * `{{response.X}}` tokens of a URL. A token with no value is left as written.
19
+ */
20
+ export function replaceUrlVariables(
21
+ url: string,
22
+ context: ReplaceUrlVariablesContext,
23
+ ): string {
24
+ let processedUrl = url.replace(
25
+ PARAM_TOKEN,
26
+ (match, key) => context.routeParams?.[key] || match,
27
+ );
28
+
29
+ processedUrl = processedUrl.replace(QUERY_TOKEN, (match, key) => {
30
+ const value = context.routeQuery[key];
31
+ return typeof value === "string" ? value : match;
32
+ });
33
+
34
+ if (context.response) {
35
+ processedUrl = processedUrl.replace(RESPONSE_TOKEN, (match, key) => {
36
+ const value = context.response?.[key];
37
+ return typeof value === "string" || typeof value === "number"
38
+ ? String(value)
39
+ : match;
40
+ });
41
+ }
42
+
43
+ return processedUrl;
44
+ }
45
+
46
+ /** Whether a URL still names a token: written so, or left unresolved. */
47
+ export function hasUrlVariables(url: string): boolean {
48
+ return url.includes(TOKEN_OPENING);
49
+ }
50
+
51
+ /**
52
+ * The URL with its tokens filled, or `undefined` while one is left: a URL
53
+ * naming a value the page does not carry is not a route, and must not be
54
+ * requested as one.
55
+ */
56
+ export function resolveUrlVariables(
57
+ url: string | undefined,
58
+ context: ReplaceUrlVariablesContext,
59
+ ): string | undefined {
60
+ if (!url) return undefined;
61
+ const resolved = replaceUrlVariables(url, context);
62
+ return hasUrlVariables(resolved) ? undefined : resolved;
63
+ }
@@ -83,6 +83,7 @@ const {
83
83
  items: () => props.items,
84
84
  fetchUrl: props.fetchUrl,
85
85
  fetchUrlMethod: props.fetchUrlMethod,
86
+ routeParams: () => props.routeParams,
86
87
  watchActions: props.watchActions,
87
88
  componentId: props.componentId,
88
89
  });
@@ -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,29 +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
- watchActions: props.watchActions,
60
- componentId: props.componentId,
61
- });
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
+ }
62
86
 
63
87
  const resolvedItems = computed<KeyValueItem[]>(() =>
64
88
  items.value.map((item) => ({
65
89
  ...item,
66
- label: processI18n(item.label ?? ""),
67
- detail: item.detail ? processI18n(item.detail) : undefined,
68
- value:
69
- typeof item.value === "string" && TRANSLATED_TYPES.has(item.type)
70
- ? processI18n(item.value)
71
- : item.value,
90
+ label: processText(item.label),
91
+ detail: item.detail ? processText(item.detail) : undefined,
92
+ value: resolveValue(item),
72
93
  })),
73
94
  );
74
95
  const title = computed(() =>
75
- props.title ? processI18n(props.title) : undefined,
96
+ props.title ? processText(props.title) : undefined,
76
97
  );
77
98
  const isEmpty = computed(
78
99
  () => !isPending.value && !hasError.value && resolvedItems.value.length === 0,
@@ -87,6 +87,7 @@ const {
87
87
  items: () => props.items,
88
88
  fetchUrl: props.fetchUrl,
89
89
  fetchUrlMethod: props.fetchUrlMethod,
90
+ routeParams: () => props.routeParams,
90
91
  watchActions: props.watchActions,
91
92
  componentId: props.componentId,
92
93
  });
@@ -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,30 +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
- watchActions: props.watchActions,
54
- componentId: props.componentId,
55
- });
56
-
57
- function formatValue(value: StatGroupItem["value"]): string {
58
- if (typeof value === "number") {
59
- return new Intl.NumberFormat(locale.value).format(value);
60
- }
61
- return processI18n(String(value ?? ""));
62
- }
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
+ });
63
67
 
64
68
  const resolvedItems = computed<StatGroupItem[]>(() =>
65
69
  items.value.map((item) => ({
66
70
  ...item,
67
- eyebrow: processI18n(item.eyebrow ?? ""),
68
- value: formatValue(item.value),
69
- 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,
70
74
  })),
71
75
  );
72
76
  </script>
@@ -87,6 +91,6 @@ const resolvedItems = computed<StatGroupItem[]>(() =>
87
91
  :skeleton-count="
88
92
  props.skeletonCount ?? props.columns ?? DEFAULT_SKELETON_COUNT
89
93
  "
90
- :label="props.label ? processI18n(props.label) : undefined"
94
+ :label="props.label ? processText(props.label) : undefined"
91
95
  />
92
96
  </template>
@@ -132,6 +132,7 @@ const isNested = computed(() => !!nestedContext);
132
132
  const { data, isLoading } = useChartFetch<ChartResponse>({
133
133
  fetchUrl: isNested.value ? undefined : props.fetchUrl,
134
134
  fetchUrlMethod: props.fetchUrlMethod,
135
+ routeParams: () => props.routeParams,
135
136
  periodScope: props.periodScope,
136
137
  realtimeTopic: props.realtimeTopic,
137
138
  staticData: null,
@@ -69,6 +69,7 @@ const watchKey = computed(() => JSON.stringify(watchState.value));
69
69
  const { data, isLoading } = useChartFetch<ChartCardResponse>({
70
70
  fetchUrl: props.fetchUrl,
71
71
  fetchUrlMethod: props.fetchUrlMethod,
72
+ routeParams: () => props.routeParams,
72
73
  periodScope: props.periodScope,
73
74
  watchSource: () => watchKey.value,
74
75
  });
@@ -79,6 +79,7 @@ const staticData = computed<KpiCardResponse | null>(() => {
79
79
  const { data, isLoading, error, refresh } = useChartFetch<KpiCardResponse>({
80
80
  fetchUrl: props.fetchUrl,
81
81
  fetchUrlMethod: props.fetchUrlMethod,
82
+ routeParams: () => props.routeParams,
82
83
  periodScope: props.periodScope,
83
84
  staticData: () => staticData.value,
84
85
  watchSource: () => watchKey.value,
@@ -76,6 +76,7 @@ const { state: watchState } = useWatch(
76
76
  const { data, isLoading, error, refresh } = useChartFetch<MeterResponse>({
77
77
  fetchUrl: props.fetchUrl,
78
78
  fetchUrlMethod: props.fetchUrlMethod,
79
+ routeParams: () => props.routeParams,
79
80
  watchSource: () => JSON.stringify(watchState.value),
80
81
  });
81
82
 
@@ -30,6 +30,7 @@ const { state: watchState } = useWatch(
30
30
  );
31
31
  const { data: fetchedBadges } = useChartFetch<TabBadges>({
32
32
  fetchUrl: props.badgesUrl,
33
+ routeParams: () => props.routeParams,
33
34
  watchSource: () => JSON.stringify(watchState.value),
34
35
  });
35
36
  const { locale } = useI18n();
@@ -108,6 +108,7 @@ const staticData = computed<TopListCardResponse | null>(() =>
108
108
  const { data, isLoading } = useChartFetch<TopListCardResponse>({
109
109
  fetchUrl: props.fetchUrl,
110
110
  fetchUrlMethod: props.fetchUrlMethod,
111
+ routeParams: () => props.routeParams,
111
112
  periodScope: props.periodScope,
112
113
  staticData: () => staticData.value,
113
114
  watchSource: () => watchKey.value,