@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
@@ -0,0 +1,466 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The secondary lines of the table cells — under a status pill, under an
4
+ * identity's name, under a `two_line` primary — draw a composed text from the
5
+ * row in the reader's language and in the tone the row gives it; an identity
6
+ * without a value stands as its empty label and icon.
7
+ */
8
+ import {
9
+ createApp,
10
+ defineComponent,
11
+ h,
12
+ ref,
13
+ type App,
14
+ type VNodeChild,
15
+ } from "vue";
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17
+ import { createI18n } from "vue-i18n";
18
+ import type { DataType } from "../layers/dms-core/app/composables/data-types/useDataType";
19
+ import { setRegionalPreferencesSource } from "../layers/dms-core/app/utils/regional";
20
+ import { readSubline } from "../layers/dms-ui/app/build/composables/data-types/cellHelpers";
21
+ import { registerMetricCellTypes } from "../layers/dms-ui/app/build/composables/data-types/metricCells";
22
+ import { registerTwoLineCellType } from "../layers/dms-ui/app/build/composables/data-types/twoLineCell";
23
+
24
+ // The renderers registerDefaults imports beside the identity's.
25
+ const stub = { default: { render: () => null } };
26
+ vi.mock("@nuxt/ui/components/Avatar.vue", () => stub);
27
+ vi.mock("@nuxt/ui/components/Badge.vue", () => stub);
28
+ vi.mock("@nuxt/ui/components/Link.vue", () => stub);
29
+ vi.mock("@nuxt/ui/runtime/vue/components/Icon.vue", () => stub);
30
+
31
+ vi.mock("../layers/dms-ui/app/composables/useFileReadUrls", () => ({
32
+ useFileReadUrls: () => ({ getUrl: () => undefined, resolve: () => {} }),
33
+ }));
34
+
35
+ const IdentityCell = (
36
+ await import(
37
+ "../layers/dms-ui/app/build/components/table-view/IdentityCell.vue"
38
+ )
39
+ ).default;
40
+
41
+ const i18n = createI18n({
42
+ legacy: false,
43
+ locale: "en",
44
+ fallbackLocale: "en",
45
+ missingWarn: false,
46
+ fallbackWarn: false,
47
+ messages: {
48
+ en: {
49
+ saas: {
50
+ invoice: {
51
+ open: "Open",
52
+ retry: "Payment failed · retry {date}",
53
+ due: "Due {date}",
54
+ },
55
+ plan_seats:
56
+ "{price} × {count} seat / {interval} | {price} × {count} seats / {interval}",
57
+ month: "month",
58
+ at_risk: "At risk",
59
+ renews_in: "Renews tomorrow | Renews in {count} days",
60
+ automatic: "Automatic",
61
+ joined: "Joined",
62
+ },
63
+ },
64
+ fr: {
65
+ saas: {
66
+ invoice: { retry: "Paiement refusé · nouvel essai le {date}" },
67
+ },
68
+ },
69
+ },
70
+ });
71
+
72
+ type Formatter = (
73
+ value: unknown,
74
+ locale: string,
75
+ options?: unknown,
76
+ row?: Record<string, unknown>,
77
+ ) => VNodeChild;
78
+
79
+ const formatters = new Map<string, DataType["formatter"]>();
80
+ const register = (dataType: DataType) =>
81
+ formatters.set(dataType.id, dataType.formatter);
82
+ registerMetricCellTypes(register);
83
+ registerTwoLineCellType(register);
84
+ // The column types a two-line primary is written by.
85
+ register({
86
+ id: "price",
87
+ formatter: {
88
+ default: (value) => `€${Number(value).toFixed(2)}`,
89
+ },
90
+ });
91
+ register({
92
+ id: "select",
93
+ formatter: {
94
+ default: (value, _locale, options) =>
95
+ (
96
+ options as { items: Array<{ value: string; label: string }> }
97
+ ).items.find((item) => item.value === value)?.label,
98
+ },
99
+ });
100
+
101
+ const format = (id: string, kind: "default" | "empty" = "default") =>
102
+ formatters.get(id)![kind] as Formatter;
103
+
104
+ let app: App | undefined;
105
+
106
+ const render = (content: () => VNodeChild): HTMLElement => {
107
+ const container = document.createElement("div");
108
+ app = createApp(defineComponent({ setup: () => content }));
109
+ app.component(
110
+ "UIcon",
111
+ defineComponent({
112
+ props: { name: { type: String, default: "" } },
113
+ setup: (props) => () => h("i", { "data-icon": props.name }),
114
+ }),
115
+ );
116
+ app.component(
117
+ "UAvatar",
118
+ defineComponent({
119
+ props: { text: { type: String, default: "" } },
120
+ setup: (props) => () => h("span", { "data-avatar": "" }, props.text),
121
+ }),
122
+ );
123
+ app.component("UBadge", defineComponent({ setup: () => () => h("b") }));
124
+ app.mount(container);
125
+ return container;
126
+ };
127
+
128
+ const text = (element: Element | null | undefined) =>
129
+ element?.textContent?.replace(/[\u00a0\u202f]/g, " ");
130
+
131
+ const retry = {
132
+ key: "$saas.invoice.retry",
133
+ params: { date: { type: "date", value: "2026-10-10", format: "day" } },
134
+ };
135
+
136
+ beforeEach(() => {
137
+ i18n.global.locale.value = "en";
138
+ setRegionalPreferencesSource(() => ({ timeZone: "UTC" }));
139
+ vi.stubGlobal("useI18n", () => ({
140
+ t: i18n.global.t,
141
+ locale: i18n.global.locale,
142
+ }));
143
+ vi.stubGlobal("useTranslation", () => ({
144
+ processI18n: (value: string) =>
145
+ value.startsWith("$") ? i18n.global.t(value.slice(1)) : value,
146
+ }));
147
+ vi.stubGlobal("useDataTypes", () => ({
148
+ getDataType: (id: string) => ({ id, formatter: formatters.get(id) }),
149
+ }));
150
+ vi.stubGlobal("useCurrentUser", () => ({ user: ref(null) }));
151
+ });
152
+
153
+ afterEach(() => {
154
+ app?.unmount();
155
+ app = undefined;
156
+ vi.unstubAllGlobals();
157
+ setRegionalPreferencesSource(() => ({}));
158
+ });
159
+
160
+ describe("readSubline", () => {
161
+ const processText = (value: unknown) =>
162
+ typeof value === "string" ? value.replace(/^\$/, "i18n:") : "composed";
163
+
164
+ it("reads a text with its tone, a composed text, a string, or nothing", () => {
165
+ const reading = { processText, isStringTranslated: true };
166
+ expect(readSubline({ text: retry, tone: "error" }, reading)).toEqual({
167
+ text: "composed",
168
+ tone: "error",
169
+ });
170
+ expect(readSubline(retry, reading)).toEqual({ text: "composed" });
171
+ expect(readSubline("$saas.due", reading)).toEqual({
172
+ text: "i18n:saas.due",
173
+ });
174
+ expect(readSubline(null, reading)).toBeUndefined();
175
+ expect(readSubline({ text: "" }, reading)).toBeUndefined();
176
+ });
177
+
178
+ it("keeps a data string as written when strings are not texts", () => {
179
+ expect(
180
+ readSubline("$29 a month", { processText, isStringTranslated: false }),
181
+ ).toEqual({ text: "$29 a month" });
182
+ });
183
+ });
184
+
185
+ describe("status_pill sub-line", () => {
186
+ const options = {
187
+ tones: { open: "info" },
188
+ subField: "statusDetail",
189
+ typeOptions: { items: [{ value: "open", label: "$saas.invoice.open" }] },
190
+ };
191
+
192
+ it("composes the row's sub-line in the reader's language, in the row's tone", () => {
193
+ const cell = render(() =>
194
+ format("status_pill")("open", "en", options, {
195
+ statusDetail: { text: retry, tone: "error" },
196
+ }),
197
+ );
198
+ const sub = cell.querySelector("span.text-xs");
199
+ expect(text(sub)).toBe("Payment failed · retry Oct 10");
200
+ expect(sub?.className).toContain("text-error");
201
+ });
202
+
203
+ it("follows a language switch", () => {
204
+ i18n.global.locale.value = "fr";
205
+ const cell = render(() =>
206
+ format("status_pill")("open", "fr", options, { statusDetail: retry }),
207
+ );
208
+ expect(text(cell.querySelector("span.text-xs"))).toBe(
209
+ "Paiement refusé · nouvel essai le 10 oct.",
210
+ );
211
+ });
212
+
213
+ it("draws a sub-line without a tone in subTone, else in the pill's", () => {
214
+ const due = {
215
+ key: "saas.invoice.due",
216
+ params: { date: { type: "date", value: "2026-10-28", format: "day" } },
217
+ };
218
+ const muted = render(() =>
219
+ format("status_pill")(
220
+ "open",
221
+ "en",
222
+ { ...options, subTone: "muted" },
223
+ {
224
+ statusDetail: due,
225
+ },
226
+ ),
227
+ );
228
+ expect(muted.querySelector("span.text-xs")?.className).toContain(
229
+ "text-muted",
230
+ );
231
+ app?.unmount();
232
+ const pillTone = render(() =>
233
+ format("status_pill")("open", "en", options, { statusDetail: due }),
234
+ );
235
+ expect(text(pillTone.querySelector("span.text-xs"))).toBe("Due Oct 28");
236
+ expect(pillTone.querySelector("span.text-xs")?.className).toContain(
237
+ "text-info",
238
+ );
239
+ });
240
+ });
241
+
242
+ describe("two_line", () => {
243
+ it("writes the primary line with the column's type, the plan's price under it", () => {
244
+ const cell = render(() =>
245
+ format("two_line")(
246
+ "pro",
247
+ "en",
248
+ {
249
+ subField: "planDetail",
250
+ columnType: "select",
251
+ typeOptions: { items: [{ value: "pro", label: "Business" }] },
252
+ },
253
+ {
254
+ planDetail: {
255
+ key: "saas.plan_seats",
256
+ params: {
257
+ price: { type: "money", value: 2900, currency: "EUR" },
258
+ count: { type: "count", value: 23 },
259
+ interval: { key: "saas.month" },
260
+ },
261
+ },
262
+ },
263
+ ),
264
+ );
265
+ const [primary, sub] = cell.querySelectorAll("span > span");
266
+ expect(text(primary)).toBe("Business");
267
+ expect(text(sub)).toBe("€29.00 × 23 seats / month");
268
+ expect(sub?.className).toContain("text-muted");
269
+ });
270
+
271
+ it("keeps an amount in its column format, what it means in the row's tone", () => {
272
+ const cell = render(() =>
273
+ format("two_line")(
274
+ 49,
275
+ "en",
276
+ { subField: "mrrNote", columnType: "price" },
277
+ { mrrNote: { text: "$saas.at_risk", tone: "error" } },
278
+ ),
279
+ );
280
+ const [primary, sub] = cell.querySelectorAll("span > span");
281
+ expect(text(primary)).toBe("€49.00");
282
+ expect(text(sub)).toBe("At risk");
283
+ expect(sub?.className).toContain("text-error");
284
+ });
285
+
286
+ it("reads its primary line off another field, even when the value is empty", () => {
287
+ const cell = render(() =>
288
+ format("two_line", "empty")(
289
+ null,
290
+ "en",
291
+ {
292
+ primaryField: "renewal",
293
+ subField: "renewalDate",
294
+ subTone: "warning",
295
+ },
296
+ {
297
+ renewal: {
298
+ key: "saas.renews_in",
299
+ params: { count: { type: "count", value: 3 } },
300
+ },
301
+ renewalDate: {
302
+ key: "saas.invoice.due",
303
+ params: { date: { type: "date", value: "2026-10-12" } },
304
+ },
305
+ },
306
+ ),
307
+ );
308
+ const [primary, sub] = cell.querySelectorAll("span > span");
309
+ expect(text(primary)).toBe("Renews in 3 days");
310
+ expect(text(sub)).toBe("Due Oct 12, 2026");
311
+ expect(sub?.className).toContain("text-warning");
312
+ });
313
+
314
+ it("draws its empty label for an empty primary line, and no sub-line for none", () => {
315
+ const cell = render(() =>
316
+ format("two_line", "empty")(
317
+ undefined,
318
+ "en",
319
+ { subField: "note", emptyLabel: "$saas.automatic" },
320
+ {},
321
+ ),
322
+ );
323
+ expect(text(cell)).toBe("Automatic");
324
+ expect(cell.querySelectorAll("span > span")).toHaveLength(1);
325
+ app?.unmount();
326
+ expect(
327
+ text(render(() => format("two_line", "empty")(null, "en", {}, {}))),
328
+ ).toBe("—");
329
+ });
330
+
331
+ it("composes a value that is itself a composed text", () => {
332
+ const cell = render(() =>
333
+ format("two_line")(
334
+ {
335
+ key: "saas.renews_in",
336
+ params: { count: { type: "count", value: 1 } },
337
+ },
338
+ "en",
339
+ {},
340
+ {},
341
+ ),
342
+ );
343
+ expect(text(cell)).toBe("Renews tomorrow");
344
+ });
345
+ });
346
+
347
+ describe("IdentityCell", () => {
348
+ const mountIdentity = (props: Record<string, unknown>) =>
349
+ render(() => h(IdentityCell, props));
350
+
351
+ it("stands an empty row as its muted label and icon tile", () => {
352
+ const cell = mountIdentity({
353
+ title: "",
354
+ emptyLabel: "Automatic",
355
+ emptyIcon: "i-ph-robot",
356
+ });
357
+ expect(text(cell)).toBe("Automatic");
358
+ expect(cell.querySelector("[data-icon]")?.getAttribute("data-icon")).toBe(
359
+ "i-ph-robot",
360
+ );
361
+ expect(cell.querySelector("[data-avatar]")).toBeNull();
362
+ expect(cell.querySelector(".text-muted")).not.toBeNull();
363
+ });
364
+
365
+ it("keeps the avatar and the name of a row with a value", () => {
366
+ const cell = mountIdentity({
367
+ title: "Camille Martin",
368
+ emptyLabel: "Automatic",
369
+ emptyIcon: "i-ph-robot",
370
+ });
371
+ expect(text(cell)).toContain("Camille Martin");
372
+ expect(text(cell.querySelector("[data-avatar]"))).toBe("CM");
373
+ expect(cell.querySelector("[data-icon]")).toBeNull();
374
+ });
375
+
376
+ it("draws the subtitle under the empty label, in its tone", () => {
377
+ const cell = mountIdentity({
378
+ title: "",
379
+ subtitle: "Refund · duplicate charge",
380
+ subtitleClass: "text-warning",
381
+ emptyLabel: "Automatic",
382
+ });
383
+ const sub = cell.querySelector(".text-xs");
384
+ expect(text(sub)).toBe("Refund · duplicate charge");
385
+ expect(sub?.className).toContain("text-warning");
386
+ });
387
+
388
+ it("lets the subtitle stand as the name without an empty label, as before", () => {
389
+ const cell = mountIdentity({ title: "", subtitle: "ada@example.com" });
390
+ expect(text(cell.querySelector(".truncate"))).toBe("ada@example.com");
391
+ expect(cell.querySelector(".text-xs")).toBeNull();
392
+ });
393
+ });
394
+
395
+ describe("identity sub-line", () => {
396
+ const identityFormatter = async () => {
397
+ const registered = new Map<string, DataType["formatter"]>();
398
+ // Auto-imports of the layer the other default types are registered with.
399
+ vi.stubGlobal("h", h);
400
+ for (const name of [
401
+ "formatNumber",
402
+ "formatPrice",
403
+ "formatPercentage",
404
+ "formatTimeSpan",
405
+ "formatDate",
406
+ "getBooleanLabel",
407
+ ]) {
408
+ vi.stubGlobal(name, () => "");
409
+ }
410
+ vi.stubGlobal("useDataTypes", () => ({
411
+ registerDataType: (dataType: DataType) =>
412
+ registered.set(dataType.id, dataType.formatter),
413
+ }));
414
+ const { registerDefaultDataTypes } = await import(
415
+ "../layers/dms-ui/app/build/composables/data-types/registerDefaults"
416
+ );
417
+ registerDefaultDataTypes();
418
+ return registered.get("identity")!;
419
+ };
420
+
421
+ it("composes a subtitle the row gives as a composed text, in its tone", async () => {
422
+ const identity = await identityFormatter();
423
+ const cell = render(() =>
424
+ (identity.default as Formatter)(
425
+ "Camille Martin",
426
+ "en",
427
+ { subtitleField: "ownerState" },
428
+ { ownerState: { text: "$saas.joined", tone: "success" } },
429
+ ),
430
+ );
431
+ const sub = cell.querySelector(".text-xs");
432
+ expect(text(sub)).toBe("Joined");
433
+ expect(sub?.className).toContain("text-success");
434
+ });
435
+
436
+ it("keeps a plain subtitle as written, in subtitleTone", async () => {
437
+ const identity = await identityFormatter();
438
+ const cell = render(() =>
439
+ (identity.default as Formatter)(
440
+ "Acme",
441
+ "en",
442
+ { subtitleField: "price", subtitleTone: "muted" },
443
+ { price: "$29 a month" },
444
+ ),
445
+ );
446
+ const sub = cell.querySelector(".text-xs");
447
+ expect(text(sub)).toBe("$29 a month");
448
+ expect(sub?.className).toContain("text-muted");
449
+ });
450
+
451
+ it("draws an empty issuer as its translated empty label and icon", async () => {
452
+ const identity = await identityFormatter();
453
+ const cell = render(() =>
454
+ (identity.empty as Formatter)(
455
+ null,
456
+ "en",
457
+ { emptyLabel: "$saas.automatic", emptyIcon: "i-ph-robot" },
458
+ {},
459
+ ),
460
+ );
461
+ expect(text(cell)).toBe("Automatic");
462
+ expect(cell.querySelector("[data-icon]")?.getAttribute("data-icon")).toBe(
463
+ "i-ph-robot",
464
+ );
465
+ });
466
+ });
@@ -71,6 +71,10 @@ describe("metric cells", () => {
71
71
  vi.stubGlobal("useTranslation", () => ({
72
72
  processI18n: (text: string) => text.replace(/^\$/, "i18n:"),
73
73
  }));
74
+ vi.stubGlobal("useI18n", () => ({
75
+ t: (key: string) => `i18n:${key}`,
76
+ locale: { value: "en-GB" },
77
+ }));
74
78
  });
75
79
 
76
80
  afterEach(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.4.0 <0.5.0",
40
+ "@antelopejs/interface-dms": ">=0.4.2 <0.5.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -32,6 +32,16 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
32
32
  `Grid`, `KpiCard`, … — fluent builders producing a **serializable description**, not live UI: the
33
33
  frontend renders it. **Components fetch their own data** via URLs (`fetchUrl`, `submitUrl`, …) —
34
34
  routes often declared `@Get`/`@Post` on the same page class; the backend stays the source of truth.
35
+ A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL;
36
+ nothing is fetched while one is unresolved), and `.watch(FormEvents.SUBMIT_SUCCESS,
37
+ BlockFunctions.REFRESH_PAGE)` makes the page's data blocks re-fetch after a form saves.
38
+ A text naming values (an amount, a date, a count) is sent as a `ComposedText` —
39
+ `{ key, params: { amount: { type: "money", value: 92200, currency: "EUR" } } }` — that the
40
+ browser writes in the reader's language: StatGroup / KeyValueList / Banner texts and layout
41
+ banners (`RegisterLayoutBanner({ resolve })` → `{ text, actions }`) take it; never format text server-side.
42
+ A table cell's secondary line is a row field holding one (or `{ text, tone }`), named by
43
+ `StatusPillDisplay({ subField })`, `IdentityDisplay({ subtitleField })` or
44
+ `TwoLineDisplay({ subField })`; `IdentityDisplay({ emptyLabel, emptyIcon })` draws an empty row.
35
45
  - **It's interfaces all the way down.** Everything you consume from the DMS is an AntelopeJS
36
46
  interface imported as an `@antelopejs/interface-dms/...` subpath (see the imports below);
37
47
  a distributable module exposes its own from a companion `@antelopejs/interface-<name>` package listed in `antelopeJs.implements`.