@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,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 () => {