@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.
- package/CHANGELOG.md +33 -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/build/components/layout/PageHeaderActionBar.vue +5 -1
- 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/blocks/registerBlockFunctions.ts +7 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +11 -3
- 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/composables/table-view/registerDefaultFunctions.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/utils/relativeDate.ts +4 -35
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +63 -0
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +1 -0
- 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 -17
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +26 -22
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +78 -16
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +21 -53
- package/frontend-vue/layers/dms-ui/app/plugins/register.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +28 -0
- package/frontend-vue/tests/block-page-refresh.test.ts +128 -0
- package/frontend-vue/tests/block-route-params.test.ts +190 -0
- 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 +10 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A composed text is written in the reader's language from the raw values the
|
|
3
|
+
* server sends: amounts in minor units, dates, counts that pick the plural
|
|
4
|
+
* form. The same JSON reads in English or in French, and follows a switch.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { createI18n } from "vue-i18n";
|
|
8
|
+
import { useComposedText } from "../layers/dms-core/app/composables/translation/useComposedText";
|
|
9
|
+
import type { ComposedText } from "../layers/dms-core/app/types/composed-text";
|
|
10
|
+
import {
|
|
11
|
+
isComposedText,
|
|
12
|
+
resolveComposedText,
|
|
13
|
+
} from "../layers/dms-core/app/utils/composedText";
|
|
14
|
+
import { setRegionalPreferencesSource } from "../layers/dms-core/app/utils/regional";
|
|
15
|
+
|
|
16
|
+
const NOW = Date.UTC(2026, 9, 8, 12);
|
|
17
|
+
|
|
18
|
+
const MESSAGES = {
|
|
19
|
+
en: {
|
|
20
|
+
saas: {
|
|
21
|
+
stats: { mrr: "{amount} MRR" },
|
|
22
|
+
banner: { retry: "Payment failed, retry {date}" },
|
|
23
|
+
seats: "no seat | {count} seat | {count} seats",
|
|
24
|
+
plan_seats: "{plan} · {seats}",
|
|
25
|
+
plans: { business: "Business" },
|
|
26
|
+
only_english: "Renews {date}",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
fr: {
|
|
30
|
+
saas: {
|
|
31
|
+
stats: { mrr: "MRR de {amount}" },
|
|
32
|
+
banner: { retry: "Paiement refusé, nouvel essai {date}" },
|
|
33
|
+
seats: "aucun siège | {count} siège | {count} sièges",
|
|
34
|
+
plan_seats: "{plan} · {seats}",
|
|
35
|
+
plans: { business: "Business" },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function makeI18n(locale: "en" | "fr" = "en") {
|
|
41
|
+
return createI18n({
|
|
42
|
+
legacy: false,
|
|
43
|
+
locale,
|
|
44
|
+
fallbackLocale: "en",
|
|
45
|
+
missingWarn: false,
|
|
46
|
+
fallbackWarn: false,
|
|
47
|
+
messages: MESSAGES,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Intl separates a French amount from its symbol with a no-break space.
|
|
52
|
+
const plainSpaces = (text: string) => text.replace(/[\u00a0\u202f]/g, " ");
|
|
53
|
+
|
|
54
|
+
function write(
|
|
55
|
+
text: Parameters<typeof resolveComposedText>[0],
|
|
56
|
+
locale: "en" | "fr" = "en",
|
|
57
|
+
): string {
|
|
58
|
+
const { global } = makeI18n(locale);
|
|
59
|
+
const translate = (
|
|
60
|
+
key: string,
|
|
61
|
+
named: Record<string, unknown>,
|
|
62
|
+
plural?: number,
|
|
63
|
+
) =>
|
|
64
|
+
plural === undefined ? global.t(key, named) : global.t(key, named, plural);
|
|
65
|
+
return plainSpaces(
|
|
66
|
+
resolveComposedText(text, { translate, locale, now: NOW }),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const mrr = (value: number, currency = "EUR"): ComposedText => ({
|
|
71
|
+
key: "saas.stats.mrr",
|
|
72
|
+
params: { amount: { type: "money", value, currency } },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
setRegionalPreferencesSource(() => ({ timeZone: "UTC" }));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
setRegionalPreferencesSource(() => ({}));
|
|
81
|
+
vi.unstubAllGlobals();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("resolveComposedText — parameters", () => {
|
|
85
|
+
it("writes an amount from its minor units, in the reader's locale", () => {
|
|
86
|
+
expect(write(mrr(92200))).toBe("€922.00 MRR");
|
|
87
|
+
expect(write(mrr(92200), "fr")).toBe("MRR de 922,00 €");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reads the minor unit of the currency", () => {
|
|
91
|
+
expect(write(mrr(4500, "JPY"))).toBe("¥4,500 MRR");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("still shows an amount whose currency code is malformed", () => {
|
|
95
|
+
expect(write(mrr(92200, "euro"))).toBe("922 MRR");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("writes a date in the format asked, medium by default", () => {
|
|
99
|
+
const retry = (format?: "day" | "long"): ComposedText => ({
|
|
100
|
+
key: "$saas.banner.retry",
|
|
101
|
+
params: { date: { type: "date", value: "2026-10-10T12:00:00Z", format } },
|
|
102
|
+
});
|
|
103
|
+
expect(write(retry("day"))).toBe("Payment failed, retry Oct 10");
|
|
104
|
+
expect(write(retry())).toBe("Payment failed, retry Oct 10, 2026");
|
|
105
|
+
expect(write(retry("long"), "fr")).toBe(
|
|
106
|
+
"Paiement refusé, nouvel essai 10 octobre 2026",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("writes a date and its time", () => {
|
|
111
|
+
expect(
|
|
112
|
+
write({
|
|
113
|
+
key: "saas.banner.retry",
|
|
114
|
+
params: {
|
|
115
|
+
date: { type: "datetime", value: Date.UTC(2026, 9, 10, 14, 5) },
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
).toBe("Payment failed, retry Oct 10, 2026, 2:05 PM");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("writes a relative date from now, past or future", () => {
|
|
122
|
+
const retry = (value: string): ComposedText => ({
|
|
123
|
+
key: "saas.banner.retry",
|
|
124
|
+
params: { date: { type: "relative", value } },
|
|
125
|
+
});
|
|
126
|
+
expect(write(retry("2026-10-10T12:00:00Z"))).toBe(
|
|
127
|
+
"Payment failed, retry in 2 days",
|
|
128
|
+
);
|
|
129
|
+
expect(write(retry("2026-10-09T12:00:00Z"), "fr")).toBe(
|
|
130
|
+
"Paiement refusé, nouvel essai demain",
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("writes numbers for the locale, as a percentage or compact on request", () => {
|
|
135
|
+
const value = (amount: ComposedText["params"]): ComposedText => ({
|
|
136
|
+
key: "saas.stats.mrr",
|
|
137
|
+
params: amount,
|
|
138
|
+
});
|
|
139
|
+
expect(write(value({ amount: 1284.5 }), "fr")).toBe("MRR de 1 284,5");
|
|
140
|
+
expect(
|
|
141
|
+
write(
|
|
142
|
+
value({ amount: { type: "number", value: 0.25, format: "percent" } }),
|
|
143
|
+
),
|
|
144
|
+
).toBe("25% MRR");
|
|
145
|
+
expect(
|
|
146
|
+
write(
|
|
147
|
+
value({ amount: { type: "number", value: 1300, format: "compact" } }),
|
|
148
|
+
),
|
|
149
|
+
).toBe("1.3K MRR");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("inserts a string parameter as written, never as a key", () => {
|
|
153
|
+
expect(
|
|
154
|
+
write({ key: "saas.stats.mrr", params: { amount: "$not.a.key" } }),
|
|
155
|
+
).toBe("$not.a.key MRR");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("composes a nested text first", () => {
|
|
159
|
+
expect(
|
|
160
|
+
write({
|
|
161
|
+
key: "saas.plan_seats",
|
|
162
|
+
params: {
|
|
163
|
+
plan: { key: "$saas.plans.business" },
|
|
164
|
+
seats: {
|
|
165
|
+
key: "saas.seats",
|
|
166
|
+
params: { count: { type: "count", value: 3 } },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
}),
|
|
170
|
+
).toBe("Business · 3 seats");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("resolveComposedText — plurals", () => {
|
|
175
|
+
const seats = (value: number): ComposedText => ({
|
|
176
|
+
key: "saas.seats",
|
|
177
|
+
params: { count: { type: "count", value } },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("picks the form from the count parameter", () => {
|
|
181
|
+
expect([0, 1, 1200].map((value) => write(seats(value)))).toEqual([
|
|
182
|
+
"no seat",
|
|
183
|
+
"1 seat",
|
|
184
|
+
"1,200 seats",
|
|
185
|
+
]);
|
|
186
|
+
expect(write(seats(2), "fr")).toBe("2 sièges");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("picks the form from the parameter `plural` names", () => {
|
|
190
|
+
expect(
|
|
191
|
+
write({ key: "saas.seats", params: { count: 1 }, plural: "count" }),
|
|
192
|
+
).toBe("1 seat");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("resolveComposedText — fallbacks", () => {
|
|
197
|
+
it("falls back to the fallback language, parameters included", () => {
|
|
198
|
+
expect(
|
|
199
|
+
write(
|
|
200
|
+
{
|
|
201
|
+
key: "saas.only_english",
|
|
202
|
+
params: {
|
|
203
|
+
date: { type: "date", value: "2026-10-10", format: "day" },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
"fr",
|
|
207
|
+
),
|
|
208
|
+
).toBe("Renews 10 oct.");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("shows the bare key when no language has the message", () => {
|
|
212
|
+
expect(write({ key: "$saas.missing", params: { amount: 1 } })).toBe(
|
|
213
|
+
"saas.missing",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("shows the raw value of a date that is not one", () => {
|
|
218
|
+
const retry = (type: "date" | "relative"): ComposedText => ({
|
|
219
|
+
key: "saas.banner.retry",
|
|
220
|
+
params: { date: { type, value: "soon" } },
|
|
221
|
+
});
|
|
222
|
+
expect(write(retry("date"))).toBe("Payment failed, retry soon");
|
|
223
|
+
expect(write(retry("relative"))).toBe("Payment failed, retry soon");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("keeps a type from a newer backend readable", () => {
|
|
227
|
+
expect(
|
|
228
|
+
write({
|
|
229
|
+
key: "saas.stats.mrr",
|
|
230
|
+
params: { amount: { type: "duration", value: 90 } as never },
|
|
231
|
+
}),
|
|
232
|
+
).toBe("90 MRR");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("passes plain strings through, translates `$` keys, writes numbers", () => {
|
|
236
|
+
expect(write("Plain text")).toBe("Plain text");
|
|
237
|
+
expect(write("$saas.plans.business")).toBe("Business");
|
|
238
|
+
expect(write(1284, "fr")).toBe("1 284");
|
|
239
|
+
expect(write(undefined)).toBe("");
|
|
240
|
+
expect(write(null)).toBe("");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("tells a composed text from other values", () => {
|
|
244
|
+
expect(isComposedText(mrr(1))).toBe(true);
|
|
245
|
+
expect(isComposedText("saas.stats.mrr")).toBe(false);
|
|
246
|
+
expect(isComposedText({ type: "money", value: 1 })).toBe(false);
|
|
247
|
+
expect(isComposedText(null)).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("useComposedText", () => {
|
|
252
|
+
it("follows a language switch", () => {
|
|
253
|
+
const i18n = makeI18n("en");
|
|
254
|
+
vi.stubGlobal("useI18n", () => ({
|
|
255
|
+
t: i18n.global.t,
|
|
256
|
+
locale: i18n.global.locale,
|
|
257
|
+
}));
|
|
258
|
+
const { processText } = useComposedText();
|
|
259
|
+
const text = mrr(92200);
|
|
260
|
+
|
|
261
|
+
expect(plainSpaces(processText(text))).toBe("€922.00 MRR");
|
|
262
|
+
i18n.global.locale.value = "fr";
|
|
263
|
+
expect(plainSpaces(processText(text))).toBe("MRR de 922,00 €");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -56,12 +56,22 @@ const AlertStub = defineComponent({
|
|
|
56
56
|
() =>
|
|
57
57
|
h("div", { "data-color": props.color, "data-icon": props.icon }, [
|
|
58
58
|
slots.description?.(),
|
|
59
|
+
slots.actions?.(),
|
|
59
60
|
props.close
|
|
60
61
|
? h("button", { onClick: () => emit("update:open", false) }, "close")
|
|
61
62
|
: null,
|
|
62
63
|
]),
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
interface ButtonStubProps {
|
|
67
|
+
label?: string;
|
|
68
|
+
to?: string;
|
|
69
|
+
color?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ButtonStub = (props: ButtonStubProps) =>
|
|
73
|
+
h("a", { href: props.to, "data-action-color": props.color }, props.label);
|
|
74
|
+
|
|
65
75
|
const BannerContent = defineComponent({
|
|
66
76
|
props: { days: { type: Number, required: true } },
|
|
67
77
|
setup: (props) => () => h("strong", `${props.days} days left`),
|
|
@@ -69,6 +79,7 @@ const BannerContent = defineComponent({
|
|
|
69
79
|
|
|
70
80
|
function mountable(root: App): App {
|
|
71
81
|
root.component("UAlert", AlertStub);
|
|
82
|
+
root.component("UButton", ButtonStub);
|
|
72
83
|
root.component("BannerContent", BannerContent);
|
|
73
84
|
return root;
|
|
74
85
|
}
|
|
@@ -86,6 +97,13 @@ beforeEach(() => {
|
|
|
86
97
|
vi.stubGlobal("useTranslation", () => ({
|
|
87
98
|
processI18n: (key: string) => key.replace(/^\$/, "t:"),
|
|
88
99
|
}));
|
|
100
|
+
vi.stubGlobal("useI18n", () => ({
|
|
101
|
+
t: (key: string, named?: Record<string, unknown>) =>
|
|
102
|
+
named && Object.keys(named).length
|
|
103
|
+
? `t:${key}:${JSON.stringify(named)}`
|
|
104
|
+
: `t:${key}`,
|
|
105
|
+
locale: ref("en"),
|
|
106
|
+
}));
|
|
89
107
|
vi.stubGlobal("useLayoutBanners", useLayoutBanners);
|
|
90
108
|
vi.stubGlobal(
|
|
91
109
|
"resolveLayoutBannerPresentation",
|
|
@@ -187,6 +205,44 @@ describe("DashboardBanners", () => {
|
|
|
187
205
|
]);
|
|
188
206
|
});
|
|
189
207
|
|
|
208
|
+
it("composes a text from its parameters and draws the banner's buttons", async () => {
|
|
209
|
+
serveBanners([
|
|
210
|
+
banner({
|
|
211
|
+
key: "past-due",
|
|
212
|
+
variant: "error",
|
|
213
|
+
text: {
|
|
214
|
+
key: "saas.banner.past_due",
|
|
215
|
+
params: {
|
|
216
|
+
invoice: "INV-42",
|
|
217
|
+
amount: { type: "money", value: 2900, currency: "EUR" },
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
actions: [
|
|
221
|
+
{ label: "Invoices", to: "/billing/invoices" },
|
|
222
|
+
{ label: "$saas.banner.pay", to: "/billing?pay=1" },
|
|
223
|
+
],
|
|
224
|
+
}),
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const html = await renderToString(
|
|
228
|
+
mountable(createSSRApp(DashboardBanners)),
|
|
229
|
+
);
|
|
230
|
+
const document = new DOMParser().parseFromString(html, "text/html");
|
|
231
|
+
const links = [...document.querySelectorAll("a")].map((node) => ({
|
|
232
|
+
label: node.textContent,
|
|
233
|
+
href: node.getAttribute("href"),
|
|
234
|
+
color: node.getAttribute("data-action-color"),
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
expect(document.body.textContent).toContain(
|
|
238
|
+
't:saas.banner.past_due:{"invoice":"INV-42","amount":"€29.00"}',
|
|
239
|
+
);
|
|
240
|
+
expect(links).toEqual([
|
|
241
|
+
{ label: "Invoices", href: "/billing/invoices", color: "neutral" },
|
|
242
|
+
{ label: "t:saas.banner.pay", href: "/billing?pay=1", color: "error" },
|
|
243
|
+
]);
|
|
244
|
+
});
|
|
245
|
+
|
|
190
246
|
// The server reads the same cookie, so a dismissed banner never reaches the
|
|
191
247
|
// page only to be removed on hydration.
|
|
192
248
|
it("leaves a dismissed banner out of the server-rendered page", async () => {
|