@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.
- package/CHANGELOG.md +17 -0
- package/dist/implementations/dms/layout-banners.d.ts +4 -1
- package/dist/implementations/dms/layout-banners.js +33 -18
- package/dist/implementations/dms/layout-banners.js.map +1 -1
- package/dist/test/unit/implementations/dms/layout-banners.test.js +76 -0
- package/dist/test/unit/implementations/dms/layout-banners.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +70 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +38 -0
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/translation/useComposedText.ts +25 -0
- package/frontend-vue/layers/dms-core/app/types/composed-text.ts +64 -0
- package/frontend-vue/layers/dms-core/app/utils/composedText.ts +227 -0
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +49 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +17 -3
- package/frontend-vue/layers/dms-layout/app/types/page.ts +7 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/IdentityCell.vue +36 -7
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +57 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +19 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +22 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/twoLineCell.ts +118 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts +5 -3
- package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
- package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +8 -5
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +38 -18
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -23
- package/frontend-vue/tests/composed-text-blocks.test.ts +205 -0
- package/frontend-vue/tests/composed-text.test.ts +265 -0
- package/frontend-vue/tests/layout-banners.test.ts +56 -0
- package/frontend-vue/tests/table-cell-sublines.test.ts +466 -0
- package/frontend-vue/tests/table-metric-cells.test.ts +4 -0
- package/package.json +2 -2
- 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
|
+
};
|
package/frontend-vue/layers/dms-ui/app/build/composables/data-types/useColumnValueRenderer.ts
CHANGED
|
@@ -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
|
|
49
|
-
//
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 {
|
|
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 ?
|
|
97
|
+
:title="props.title ? processText(props.title) : undefined"
|
|
95
98
|
:description="
|
|
96
|
-
props.description ?
|
|
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?:
|
|
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 {
|
|
64
|
+
const { processText } = useComposedText();
|
|
54
65
|
|
|
55
|
-
const { items, isPending, hasError, refresh } =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
68
|
-
detail: item.detail ?
|
|
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 ?
|
|
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
|
|
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?:
|
|
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 {
|
|
47
|
-
const { processI18n } = useTranslation();
|
|
56
|
+
const { processText } = useComposedText();
|
|
48
57
|
|
|
49
|
-
const { items, isPending, hasError, refresh } =
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
69
|
-
value:
|
|
70
|
-
detail: item.detail ?
|
|
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 ?
|
|
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
|
+
});
|