@antelopejs/dms 0.7.21 → 0.8.0

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 (157) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/implementations/dms-auth/account-emails.d.ts +4 -0
  3. package/dist/implementations/dms-auth/account-emails.js +62 -0
  4. package/dist/implementations/dms-auth/account-emails.js.map +1 -0
  5. package/dist/implementations/dms-auth/index.d.ts +1 -3
  6. package/dist/implementations/dms-auth/index.js +15 -61
  7. package/dist/implementations/dms-auth/index.js.map +1 -1
  8. package/dist/pages/settings/users/email-change.js +3 -4
  9. package/dist/pages/settings/users/email-change.js.map +1 -1
  10. package/dist/pages/settings/users/invites.js +1 -1
  11. package/dist/pages/settings/users/invites.js.map +1 -1
  12. package/dist/pages/settings/users/members.js +1 -1
  13. package/dist/pages/settings/users/members.js.map +1 -1
  14. package/dist/pages/settings/users/notifications.js +1 -0
  15. package/dist/pages/settings/users/notifications.js.map +1 -1
  16. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.d.ts +1 -0
  17. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js +34 -0
  18. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms/hook-handler-results.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js +49 -0
  21. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-topics.test.js +17 -0
  23. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  24. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +2 -2
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -1
  26. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.d.ts +1 -0
  27. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js +29 -0
  28. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js.map +1 -0
  29. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +5 -0
  30. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
  31. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js +12 -2
  32. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js.map +1 -1
  33. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +29 -4
  34. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +14 -11
  36. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  37. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js +8 -5
  38. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js.map +1 -1
  39. package/dist/test/unit/utils/email-subjects.test.d.ts +1 -0
  40. package/dist/test/unit/utils/email-subjects.test.js +27 -0
  41. package/dist/test/unit/utils/email-subjects.test.js.map +1 -0
  42. package/dist/utils/admin-invite-email.js +2 -4
  43. package/dist/utils/admin-invite-email.js.map +1 -1
  44. package/dist/utils/email-change-mail.d.ts +1 -1
  45. package/dist/utils/email-change-mail.js +11 -6
  46. package/dist/utils/email-change-mail.js.map +1 -1
  47. package/dist/utils/email-subjects.d.ts +3 -0
  48. package/dist/utils/email-subjects.js +32 -0
  49. package/dist/utils/email-subjects.js.map +1 -0
  50. package/dist/utils/export-jobs.js +15 -10
  51. package/dist/utils/export-jobs.js.map +1 -1
  52. package/frontend-vue/layers/dms-core/app/utils/url-interpolation.ts +29 -11
  53. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +17 -0
  54. package/frontend-vue/layers/dms-layout/app/app.config.ts +5 -0
  55. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +18 -2
  56. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +16 -1
  57. package/frontend-vue/layers/dms-layout/app/build/composables/layout/useHeaderActionControls.ts +14 -0
  58. package/frontend-vue/layers/dms-layout/app/components/layout/HStack.vue +16 -0
  59. package/frontend-vue/layers/dms-layout/app/components/layout/VStack.vue +2 -0
  60. package/frontend-vue/layers/dms-layout/app/composables/useFooterLinks.ts +11 -1
  61. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +1 -1
  62. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +1 -1
  63. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeNotice.vue +13 -7
  64. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeVerification.vue +18 -7
  65. package/frontend-vue/layers/dms-layout/app/emails/EmailExportReady.vue +23 -12
  66. package/frontend-vue/layers/dms-layout/app/emails/EmailResetPassword.vue +18 -7
  67. package/frontend-vue/layers/dms-layout/app/emails/EmailTwoFactor.vue +23 -4
  68. package/frontend-vue/layers/dms-layout/app/emails/EmailUserValidation.vue +18 -6
  69. package/frontend-vue/layers/dms-layout/app/emails/components/EmailOTP.vue +8 -2
  70. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +40 -0
  71. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +40 -0
  72. package/frontend-vue/layers/dms-ui/app/build/components/form/FormFieldControl.vue +2 -1
  73. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +11 -3
  74. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +27 -36
  75. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +59 -2
  76. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +27 -4
  77. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +10 -2
  78. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +0 -5
  79. package/frontend-vue/layers/dms-ui/app/build/composables/form/formEntryOrder.ts +23 -0
  80. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +19 -15
  81. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +26 -2
  82. package/frontend-vue/layers/dms-ui/app/build/composables/page/pageUrlValues.ts +41 -0
  83. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +2 -2
  84. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +3 -5
  85. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +7 -12
  86. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -3
  87. package/frontend-vue/layers/dms-ui/app/build/utils/link.ts +7 -3
  88. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +32 -9
  89. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +8 -1
  90. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +9 -4
  91. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +5 -11
  92. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +2 -1
  93. package/frontend-vue/layers/dms-ui/app/components/chart/ApexChartHost.vue +32 -1
  94. package/frontend-vue/layers/dms-ui/app/components/check-list/CheckList.vue +1 -1
  95. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +12 -2
  96. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +7 -3
  97. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +7 -0
  98. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +4 -1
  99. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +4 -2
  100. package/frontend-vue/layers/dms-ui/app/components/form/components/InputSecret.vue +3 -2
  101. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -1
  102. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +15 -3
  103. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +2 -1
  104. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +9 -2
  105. package/frontend-vue/layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue +13 -5
  106. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +1 -1
  107. package/frontend-vue/layers/dms-ui/app/components/stat-group/StatGroup.vue +3 -2
  108. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +53 -31
  109. package/frontend-vue/layers/dms-ui/app/composables/chart/apexPlotOptions.ts +15 -4
  110. package/frontend-vue/layers/dms-ui/app/composables/chart/apexYRange.ts +123 -0
  111. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.ts +49 -49
  112. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartTheme.ts +20 -1
  113. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +1 -1
  114. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  115. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +8 -6
  116. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +1 -1
  117. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +3 -5
  118. package/frontend-vue/layers/dms-ui/app/utils/bannerToneIcons.ts +13 -0
  119. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +26 -0
  120. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -0
  121. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -0
  122. package/frontend-vue/tests/activity-feed-fill-height.test.ts +7 -0
  123. package/frontend-vue/tests/banner-tone-icon.test.ts +55 -0
  124. package/frontend-vue/tests/builtin-emails.test.ts +141 -0
  125. package/frontend-vue/tests/cascader.test.ts +27 -0
  126. package/frontend-vue/tests/chart-auto-y-range.test.ts +25 -9
  127. package/frontend-vue/tests/chart-colors.test.ts +8 -0
  128. package/frontend-vue/tests/chart-resize-after-animation.test.ts +100 -0
  129. package/frontend-vue/tests/chart-type-defaults.test.ts +128 -0
  130. package/frontend-vue/tests/check-list-marker.test.ts +58 -0
  131. package/frontend-vue/tests/confirm-from.test.ts +1 -1
  132. package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -2
  133. package/frontend-vue/tests/context-links.test.ts +2 -2
  134. package/frontend-vue/tests/custom-action-confirm-from.test.ts +2 -2
  135. package/frontend-vue/tests/download-action-target.test.ts +2 -2
  136. package/frontend-vue/tests/file-drop-prompt.test.ts +72 -0
  137. package/frontend-vue/tests/footer-config.test.ts +135 -0
  138. package/frontend-vue/tests/form-actions.test.ts +7 -0
  139. package/frontend-vue/tests/form-entry-order.test.ts +45 -0
  140. package/frontend-vue/tests/form-footer-kind.test.ts +11 -6
  141. package/frontend-vue/tests/form-load-error.test.ts +38 -1
  142. package/frontend-vue/tests/grid-responsive-columns.test.ts +9 -0
  143. package/frontend-vue/tests/header-actions.test.ts +55 -9
  144. package/frontend-vue/tests/instant-save-badge.test.ts +11 -4
  145. package/frontend-vue/tests/list-row-truncate.test.ts +46 -0
  146. package/frontend-vue/tests/roles-name-errors.test.ts +2 -3
  147. package/frontend-vue/tests/save-bar.test.ts +15 -3
  148. package/frontend-vue/tests/save-status-error.test.ts +2 -2
  149. package/frontend-vue/tests/table-row-action-errors.test.ts +5 -5
  150. package/frontend-vue/tests/table-row-hover.test.ts +2 -2
  151. package/frontend-vue/tests/table-view-sources.test.ts +1 -1
  152. package/frontend-vue/tests/template-tokens.test.ts +235 -0
  153. package/frontend-vue/tests/url-interpolation.test.ts +6 -6
  154. package/package.json +2 -2
  155. package/skills/dms/SKILL.md +2 -1
  156. package/skills/dms-pages/REFERENCE.md +2 -2
  157. package/skills/dms-project/SKILL.md +0 -1
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { type Component, createSSRApp, defineComponent, h } from "vue";
3
+ import { renderToString } from "vue/server-renderer";
4
+ import { createI18n } from "vue-i18n";
5
+ import en from "../layers/dms-layout/i18n/locales/layout-en-GB.json";
6
+ import fr from "../layers/dms-layout/i18n/locales/layout-fr-FR.json";
7
+
8
+ interface EmailModule {
9
+ default: Component;
10
+ }
11
+
12
+ const emailModules = import.meta.glob<EmailModule>(
13
+ "../layers/dms-layout/app/emails/*.vue",
14
+ { eager: true },
15
+ );
16
+
17
+ const PUBLIC_BASE = "https://admin.example.com";
18
+ const PUBLIC_PNG_LOGO = "/images/logo.png";
19
+ const SVG_LOGO = "/images/logo.svg";
20
+ const ELEMENTS: Record<string, string> = {
21
+ EHtml: "html",
22
+ EHead: "head",
23
+ EBody: "body",
24
+ EContainer: "div",
25
+ ESection: "section",
26
+ EHeading: "h1",
27
+ EText: "p",
28
+ EButton: "a",
29
+ EImg: "img",
30
+ EHr: "hr",
31
+ EPreview: "span",
32
+ };
33
+
34
+ function element(tag: string) {
35
+ return defineComponent({
36
+ inheritAttrs: false,
37
+ setup:
38
+ (_, { attrs, slots }) =>
39
+ () =>
40
+ h(tag, { src: attrs.src }, slots.default?.()),
41
+ });
42
+ }
43
+
44
+ function templateName(path: string): string {
45
+ return path.split("/").pop()?.replace(".vue", "") ?? path;
46
+ }
47
+
48
+ const EMAIL_PROPS = {
49
+ userName: "Ada",
50
+ newEmail: "ada@new.test",
51
+ expiresIn: "24 hours",
52
+ expiresInHours: 24,
53
+ partial: true,
54
+ failedSources: ["media"],
55
+ };
56
+
57
+ async function renderEmail(
58
+ component: Component,
59
+ logo: string,
60
+ locale = "en",
61
+ ): Promise<string> {
62
+ vi.stubGlobal("useDmsAppConfig", () => ({
63
+ branding: { logo: { default: { light: logo } } },
64
+ }));
65
+ vi.stubGlobal("useDmsRuntimeConfig", () => ({
66
+ public: { dms: { clientBaseUrl: PUBLIC_BASE } },
67
+ }));
68
+ const app = createSSRApp(component, EMAIL_PROPS);
69
+ app.use(
70
+ createI18n({
71
+ legacy: false,
72
+ locale,
73
+ fallbackLocale: "en",
74
+ messages: { en, fr },
75
+ }),
76
+ );
77
+ Object.entries(ELEMENTS).forEach(([name, tag]) =>
78
+ app.component(name, element(tag)),
79
+ );
80
+ const html = await renderToString(app);
81
+ vi.unstubAllGlobals();
82
+ return html;
83
+ }
84
+
85
+ const templates = Object.entries(emailModules).map(
86
+ ([path, module]) => [templateName(path), module.default] as const,
87
+ );
88
+
89
+ describe("built-in emails", () => {
90
+ it.each(templates)(
91
+ "%s leaves out a logo mail clients cannot load",
92
+ async (_name, component) => {
93
+ const html = await renderEmail(component, SVG_LOGO);
94
+ expect(html).not.toContain("<img");
95
+ },
96
+ );
97
+
98
+ it.each(templates)("%s draws a public PNG logo", async (_name, component) => {
99
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO);
100
+ expect(html).toContain(`<img src="${PUBLIC_BASE}${PUBLIC_PNG_LOGO}"`);
101
+ });
102
+
103
+ it.each(templates)("%s is written in French", async (_name, component) => {
104
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO, "fr");
105
+ expect(html).toContain("Bonjour");
106
+ expect(html).not.toMatch(/Hello|If you/);
107
+ });
108
+
109
+ it("words the code lifetime in the recipient's language", async () => {
110
+ const [, component] = templates.find(
111
+ ([name]) => name === "EmailResetPassword",
112
+ )!;
113
+ expect(await renderEmail(component, PUBLIC_PNG_LOGO)).toContain(
114
+ "This code expires in 24 hours.",
115
+ );
116
+ expect(await renderEmail(component, PUBLIC_PNG_LOGO, "fr")).toContain(
117
+ "Ce code expire dans 24 heures.",
118
+ );
119
+ });
120
+
121
+ it("marks a partial export in French", async () => {
122
+ const [, component] = templates.find(
123
+ ([name]) => name === "EmailExportReady",
124
+ )!;
125
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO, "fr");
126
+ expect(html).toContain("<strong>incomplet</strong>");
127
+ expect(html.replace(/<!--[^>]*-->/g, "")).toContain("— media.");
128
+ });
129
+
130
+ it("puts the two-factor disclaimer under the separator", async () => {
131
+ const [, component] = templates.find(
132
+ ([name]) => name === "EmailTwoFactor",
133
+ )!;
134
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO);
135
+ const separator = html.indexOf("<hr>");
136
+ expect(separator).toBeGreaterThan(-1);
137
+ expect(html.indexOf("If you did not attempt to sign in")).toBeGreaterThan(
138
+ separator,
139
+ );
140
+ });
141
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildCascaderPathLabels,
4
4
  buildCascaderTree,
5
+ cascaderValuePath,
5
6
  findCascaderNode,
6
7
  flattenCascaderTree,
7
8
  formatCascaderPath,
@@ -177,6 +178,32 @@ describe("buildCascaderPathLabels", () => {
177
178
  });
178
179
  });
179
180
 
181
+ describe("cascaderValuePath", () => {
182
+ it("resolves a picked id, or a picked row, to its path", () => {
183
+ expect(cascaderValuePath("speakers", keyMapping, categories)).toEqual([
184
+ "Electronics",
185
+ "Audio",
186
+ "Speakers",
187
+ ]);
188
+ expect(
189
+ cascaderValuePath({ _id: "tv", name: "TV" }, keyMapping, categories),
190
+ ).toEqual(["Electronics", "Video", "TV"]);
191
+ });
192
+
193
+ it("shows a picked row's own label, or its id, until the rows are there", () => {
194
+ expect(cascaderValuePath({ _id: "tv", name: "TV" }, keyMapping)).toEqual([
195
+ "TV",
196
+ ]);
197
+ expect(cascaderValuePath("tv", keyMapping)).toEqual(["tv"]);
198
+ expect(cascaderValuePath("nope", keyMapping, categories)).toEqual(["nope"]);
199
+ });
200
+
201
+ it("has no path for nothing picked", () => {
202
+ expect(cascaderValuePath(null, keyMapping, categories)).toEqual([]);
203
+ expect(cascaderValuePath({ name: "Loose" }, keyMapping)).toEqual(["Loose"]);
204
+ });
205
+ });
206
+
180
207
  describe("resolveCascaderKeyMapping", () => {
181
208
  it("falls back to default keys", () => {
182
209
  expect(resolveCascaderKeyMapping()).toEqual({
@@ -34,20 +34,20 @@ describe("annotation-aware auto Y range", () => {
34
34
 
35
35
  it("expands both ends for annotations above and below native data bounds", () => {
36
36
  const bounds = boundsFor({ annotations: [{ y: 200 }, { y: -50 }] });
37
- expect(bounds.min(10)).toBe(-54);
38
- expect(bounds.max(30)).toBe(216);
37
+ expect(bounds.min(10)).toBe(-100);
38
+ expect(bounds.max(30)).toBe(250);
39
39
  });
40
40
 
41
41
  it("includes both ends of a reversed band and all native series extrema", () => {
42
42
  const bounds = boundsFor({ annotations: [{ y: 200, y2: -50 }] });
43
- expect(bounds.min(-100)).toBe(-108);
44
- expect(bounds.max(300)).toBe(324);
43
+ expect(bounds.min(-100)).toBe(-150);
44
+ expect(bounds.max(300)).toBe(350);
45
45
  });
46
46
 
47
47
  it("pads negative bounds outwards rather than clipping negative data", () => {
48
48
  const bounds = boundsFor();
49
- expect(bounds.min(-100)).toBe(-108);
50
- expect(bounds.max(-10)).toBe(-9.2);
49
+ expect(bounds.min(-100)).toBe(-120);
50
+ expect(bounds.max(-10)).toBe(-9);
51
51
  });
52
52
 
53
53
  it("gives zero-only data a nonzero range without changing the series", () => {
@@ -75,15 +75,15 @@ describe("annotation-aware auto Y range", () => {
75
75
  });
76
76
 
77
77
  it.each([-1, NaN, Infinity])("defaults invalid padding %s", (padding) => {
78
- expect(boundsFor({ autoYRange: { padding } }).max(100)).toBe(108);
78
+ expect(boundsFor({ autoYRange: { padding } }).max(100)).toBe(120);
79
79
  });
80
80
 
81
81
  it("ignores nonfinite annotation coordinates when determining bounds", () => {
82
82
  const bounds = boundsFor({
83
83
  annotations: [{ y: NaN }, { y: Infinity }, { y: 20 }],
84
84
  });
85
- expect(bounds.min(10)).toBe(9.2);
86
- expect(bounds.max(10)).toBe(21.6);
85
+ expect(bounds.min(10)).toBe(9);
86
+ expect(bounds.max(10)).toBe(22);
87
87
  });
88
88
 
89
89
  it("recomputes when annotations or padding change", () => {
@@ -109,6 +109,22 @@ describe("annotation-aware auto Y range", () => {
109
109
  ).toEqual({ min: 0, max: 5 });
110
110
  });
111
111
 
112
+ it("rounds the padded bounds to a step the axis can tick evenly", () => {
113
+ const series = [{ name: "value", data: [104.4, 420, 770] }];
114
+ const bounds = boundsFor({ series, annotations: [{ y: 300, y2: 500 }] });
115
+ expect(bounds.min(104.4)).toBe(0);
116
+ expect(bounds.max(770)).toBe(900);
117
+ });
118
+
119
+ it("writes unformatted axis labels without float noise", () => {
120
+ const labels = chartFor().options.value.yaxis as {
121
+ labels: { formatter: (value: number) => string };
122
+ };
123
+ expect(labels.labels.formatter(160.00000000000003)).toBe("160");
124
+ expect(labels.labels.formatter(0.1 + 0.2)).toBe("0.3");
125
+ expect(labels.labels.formatter(603.6)).toBe("603.6");
126
+ });
127
+
112
128
  it("does not scale circular charts", () => {
113
129
  expect(boundsFor({ type: "donut" })).not.toHaveProperty("min");
114
130
  });
@@ -71,6 +71,14 @@ describe("chart color tokens", () => {
71
71
  expect(resolveChartColor(color)).toBe(color);
72
72
  });
73
73
 
74
+ it.each([
75
+ ["rgb(14 165 233)", "rgb(14, 165, 233)"],
76
+ ["rgb(14 165 233 / 0.5)", "rgba(14, 165, 233, 0.5)"],
77
+ ["rgba(14 165 233 / 40%)", "rgba(14, 165, 233, 0.4)"],
78
+ ])("rewrites space-separated %s in the comma form Apex reads", (color, expected) => {
79
+ expect(resolveChartColor(color)).toBe(expected);
80
+ });
81
+
74
82
  it.each(["", "transparent", "rgba(0, 0, 0, 0)"])(
75
83
  "falls back for unusable computed color %s",
76
84
  (color) => {
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Apex drops its container's resizes while an animation runs: a sidebar
4
+ * toggled during the intro animation left the chart at its old width, past
5
+ * its card, until the next window resize. The host redraws such a chart once
6
+ * the animation ends.
7
+ */
8
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+ import ApexChartHost from "../layers/dms-ui/app/components/chart/ApexChartHost.vue";
11
+
12
+ const HOST_WIDTH = 1047;
13
+ const STALE_WIDTH = 1227;
14
+
15
+ const windowResizeHandler = vi.fn();
16
+ const drawnWidth = ref(HOST_WIDTH);
17
+ let endAnimation: () => void = () => {};
18
+
19
+ vi.mock("apexcharts", () => ({ default: {} }));
20
+ vi.mock("../layers/dms-ui/app/composables/chart/apexLocales", () => ({
21
+ ensureApexLocale: async () => undefined,
22
+ }));
23
+ vi.mock("vue3-apexcharts", () => ({
24
+ default: defineComponent({
25
+ emits: ["animationEnd"],
26
+ setup(_, { emit, expose }) {
27
+ expose({
28
+ chart: { windowResizeHandler },
29
+ updateOptions: async () => undefined,
30
+ });
31
+ endAnimation = () => emit("animationEnd");
32
+ return () =>
33
+ h("div", [
34
+ h("svg", {
35
+ class: "apexcharts-svg",
36
+ "data-width": drawnWidth.value,
37
+ }),
38
+ ]);
39
+ },
40
+ }),
41
+ }));
42
+
43
+ function widthOf(element: Element): number {
44
+ if (element.classList.contains("dms-apex")) return HOST_WIDTH;
45
+ return Number(element.getAttribute("data-width") ?? 0);
46
+ }
47
+
48
+ let app: ReturnType<typeof createApp> | undefined;
49
+
50
+ async function mountHost(): Promise<HTMLElement> {
51
+ const root = document.createElement("div");
52
+ app = createApp(() =>
53
+ h(ApexChartHost, {
54
+ apexType: "line",
55
+ height: 300,
56
+ options: {},
57
+ series: [],
58
+ }),
59
+ );
60
+ app.mount(root);
61
+ await vi.waitFor(() => {
62
+ expect(root.querySelector("svg.apexcharts-svg")).not.toBeNull();
63
+ });
64
+ return root;
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en") }));
69
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
70
+ function (this: Element) {
71
+ return { width: widthOf(this) } as DOMRect;
72
+ },
73
+ );
74
+ });
75
+
76
+ afterEach(() => {
77
+ app?.unmount();
78
+ windowResizeHandler.mockClear();
79
+ drawnWidth.value = HOST_WIDTH;
80
+ vi.restoreAllMocks();
81
+ vi.unstubAllGlobals();
82
+ });
83
+
84
+ it("redraws a chart left at a stale width when its animation ends", async () => {
85
+ await mountHost();
86
+ drawnWidth.value = STALE_WIDTH;
87
+ await nextTick();
88
+
89
+ endAnimation();
90
+
91
+ expect(windowResizeHandler).toHaveBeenCalledOnce();
92
+ });
93
+
94
+ it("leaves a chart drawn at its host's width alone", async () => {
95
+ await mountHost();
96
+
97
+ endAnimation();
98
+
99
+ expect(windowResizeHandler).not.toHaveBeenCalled();
100
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { useApexChart } from "../layers/dms-ui/app/composables/chart/useApexChart";
3
+ import type { UseApexChartInput } from "../layers/dms-ui/app/composables/chart/useApexChart.types";
4
+ import type { ChartSeries } from "../layers/dms-ui/app/composables/chart/types";
5
+
6
+ const DEALS: ChartSeries[] = [
7
+ {
8
+ name: "Deals",
9
+ data: [
10
+ { x: 3, y: 120, label: "Acme" },
11
+ { x: 8, y: 340 },
12
+ ],
13
+ },
14
+ ];
15
+
16
+ type Section = Record<string, unknown>;
17
+ type LabelFormatter = (value: unknown, context: unknown) => unknown;
18
+
19
+ function optionsFor(input: Partial<UseApexChartInput>): Section {
20
+ return useApexChart(() => ({ type: "line", series: [], ...input })).options
21
+ .value;
22
+ }
23
+
24
+ function sectionOf(options: Section, key: string): Section {
25
+ return options[key] as Section;
26
+ }
27
+
28
+ function labelContext(dataPointIndex: number) {
29
+ return { seriesIndex: 0, dataPointIndex, w: { config: { series: DEALS } } };
30
+ }
31
+
32
+ describe("radar grid", () => {
33
+ it("leaves the grid to the radar's own polygons", () => {
34
+ expect(sectionOf(optionsFor({ type: "radar" }), "grid").show).toBe(false);
35
+ });
36
+
37
+ it("keeps the cartesian grid on axis charts", () => {
38
+ expect(sectionOf(optionsFor({ type: "line" }), "grid").show).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe("donut slice labels", () => {
43
+ it("hides them on the default thin ring they overflow", () => {
44
+ const labels = sectionOf(optionsFor({ type: "donut" }), "dataLabels");
45
+ expect(labels.enabled).toBe(false);
46
+ });
47
+
48
+ it("shows them on a ring wide enough to hold them", () => {
49
+ const labels = sectionOf(
50
+ optionsFor({ type: "donut", arcWidth: 45 }),
51
+ "dataLabels",
52
+ );
53
+ expect(labels.enabled).toBe(true);
54
+ });
55
+
56
+ it("keeps them on a pie, and Apex's own percent formatter", () => {
57
+ const labels = sectionOf(optionsFor({ type: "pie" }), "dataLabels");
58
+ expect(labels.enabled).toBe(true);
59
+ expect(labels.formatter).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe("scatter points", () => {
64
+ it("draws its points without a pointSize", () => {
65
+ const markers = sectionOf(optionsFor({ type: "scatter" }), "markers");
66
+ expect(markers.size).toBeGreaterThan(0);
67
+ });
68
+
69
+ it("honours an explicit pointSize", () => {
70
+ const markers = sectionOf(
71
+ optionsFor({ type: "scatter", pointSize: 9 }),
72
+ "markers",
73
+ );
74
+ expect(markers.size).toBe(9);
75
+ });
76
+
77
+ it("keeps line markers hidden", () => {
78
+ expect(sectionOf(optionsFor({ type: "line" }), "markers").size).toBe(0);
79
+ });
80
+
81
+ it("labels a point with its own label, else its value", () => {
82
+ const labels = sectionOf(
83
+ optionsFor({ type: "scatter", series: DEALS, showLabels: true }),
84
+ "dataLabels",
85
+ );
86
+ const format = labels.formatter as LabelFormatter;
87
+ expect(format(120, labelContext(0))).toBe("Acme");
88
+ expect(format(340, labelContext(1))).toBe(340);
89
+ });
90
+
91
+ it("lifts a point's label off the point it names", () => {
92
+ const labels = sectionOf(
93
+ optionsFor({ type: "scatter", showLabels: true }),
94
+ "dataLabels",
95
+ );
96
+ expect(labels.offsetY).toBeLessThan(0);
97
+ const line = sectionOf(optionsFor({ type: "line" }), "dataLabels");
98
+ expect(line.offsetY).toBe(0);
99
+ });
100
+ });
101
+
102
+ describe("rawOptions paths", () => {
103
+ it("sets a dotted key inside its section", () => {
104
+ const markers = sectionOf(
105
+ optionsFor({
106
+ type: "scatter",
107
+ rawOptions: [{ key: "markers.size", value: "5" }],
108
+ }),
109
+ "markers",
110
+ );
111
+ expect(markers.size).toBe(5);
112
+ expect(markers.strokeWidth).toBe(0);
113
+ });
114
+
115
+ it("creates the sections a dotted key names", () => {
116
+ const options = optionsFor({
117
+ rawOptions: [{ key: "states.hover.filter", value: '{"type":"none"}' }],
118
+ });
119
+ expect(options.states).toEqual({ hover: { filter: { type: "none" } } });
120
+ });
121
+
122
+ it("still replaces a whole section from a plain key", () => {
123
+ const options = optionsFor({
124
+ rawOptions: [{ key: "markers", value: '{"size":5}' }],
125
+ });
126
+ expect(options.markers).toEqual({ size: 5 });
127
+ });
128
+ });
@@ -0,0 +1,58 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createApp, defineComponent, h, nextTick, type App } from "vue";
4
+ import CheckList, {
5
+ type CheckListItem,
6
+ type CheckListMarker,
7
+ } from "../layers/dms-ui/app/components/check-list/CheckList.vue";
8
+
9
+ const IconStub = defineComponent({
10
+ props: { name: String },
11
+ setup: (props) => () => h("i", { "data-icon": props.name }),
12
+ });
13
+
14
+ const ITEMS: CheckListItem[] = [
15
+ { label: "Ok", state: "ok" },
16
+ { label: "Failed", state: "error" },
17
+ { label: "Waiting", state: "pending" },
18
+ ];
19
+
20
+ let app: App | undefined;
21
+ let host: HTMLDivElement;
22
+
23
+ async function mount(marker: CheckListMarker) {
24
+ app = createApp({ render: () => h(CheckList, { items: ITEMS, marker }) });
25
+ app.component("UIcon", IconStub);
26
+ app.config.globalProperties.$t = (key: string) => key;
27
+ app.mount(host);
28
+ await nextTick();
29
+ }
30
+
31
+ const iconOf = (label: string) =>
32
+ [...host.querySelectorAll("li")]
33
+ .find((item) => item.textContent?.includes(label))
34
+ ?.querySelector("i")?.dataset.icon;
35
+
36
+ beforeEach(() => {
37
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
38
+ host = document.createElement("div");
39
+ document.body.append(host);
40
+ });
41
+
42
+ afterEach(() => {
43
+ app?.unmount();
44
+ app = undefined;
45
+ host.remove();
46
+ vi.unstubAllGlobals();
47
+ });
48
+
49
+ describe("DmsCheckList markers", () => {
50
+ it.each<CheckListMarker>(["icon", "plain"])(
51
+ "never draws a pending line with the error icon (%s)",
52
+ async (marker) => {
53
+ await mount(marker);
54
+ expect(iconOf("Waiting")).toBeTruthy();
55
+ expect(iconOf("Waiting")).not.toBe(iconOf("Failed"));
56
+ },
57
+ );
58
+ });
@@ -59,7 +59,7 @@ describe("confirmFrom", () => {
59
59
  });
60
60
  const onConfirm = vi.fn(async () => undefined);
61
61
  const confirmed = await mountConfirmFrom().confirmFrom(
62
- "/api/campaigns/{id}/delete-confirm",
62
+ "/api/campaigns/{{row.id}}/delete-confirm",
63
63
  {
64
64
  params: { id: "c1", name: "Spring" },
65
65
  fallback: { icon: "i-ph-trash" },
@@ -49,8 +49,8 @@ describe("a confirmation's secondary action, resolved", () => {
49
49
  icon: "i-ph-download-simple",
50
50
  target: {
51
51
  type: "download",
52
- url: "/legacy/lang/{workspace}/{code}.json",
53
- filename: "{workspace}-{file}",
52
+ url: "/legacy/lang/{{row.workspace}}/{{row.code}}.json",
53
+ filename: "{{row.workspace}}-{{row.file}}",
54
54
  },
55
55
  },
56
56
  },
@@ -198,7 +198,7 @@ describe("a link row action", () => {
198
198
  label: "Report",
199
199
  target: {
200
200
  type: "page",
201
- url: "/modules/marketing/funnel?id={_id}&website={{query.website}}&mk.period={{query.mk.period}}&mk.to={{query.mk.to}}&order={{params.id}}",
201
+ url: "/modules/marketing/funnel?id={{row._id}}&website={{query.website}}&mk.period={{query.mk.period}}&mk.to={{query.mk.to}}&order={{params.id}}",
202
202
  },
203
203
  },
204
204
  { _id: "f 1" },
@@ -211,7 +211,7 @@ describe("a link row action", () => {
211
211
 
212
212
  it("leaves a URL without page tokens as before", async () => {
213
213
  mountTargets().handleCustomRowAction(
214
- { label: "Open", target: { type: "page", url: "/orders/{_id}" } },
214
+ { label: "Open", target: { type: "page", url: "/orders/{{row._id}}" } },
215
215
  { _id: "o2" },
216
216
  );
217
217
  await vi.waitFor(() => expect(navigateDms).toHaveBeenCalledTimes(1));
@@ -32,7 +32,7 @@ function mountTargets(rowIdKey?: string) {
32
32
  const REFUND: CustomRowAction = {
33
33
  label: "Refund",
34
34
  target: { type: "page", url: "/orders" },
35
- confirm: { from: "/api/orders/{id}/refund-confirm?customer={customer}" },
35
+ confirm: { from: "/api/orders/{{row.id}}/refund-confirm?customer={{row.customer}}" },
36
36
  };
37
37
 
38
38
  beforeEach(() => {
@@ -60,7 +60,7 @@ afterEach(() => {
60
60
  });
61
61
 
62
62
  describe("a custom row action's confirmation worded by the server", () => {
63
- it("fills {id} with the row's id, like a built-in action, besides its fields", async () => {
63
+ it("fills {{row.id}} with the row's id, like a built-in action, besides its fields", async () => {
64
64
  mountTargets().handleCustomRowAction(REFUND, {
65
65
  _id: "o1",
66
66
  customer: "Ada",
@@ -146,8 +146,8 @@ describe("a download action target", () => {
146
146
  label: "Download",
147
147
  target: {
148
148
  type: "download",
149
- url: "/api/files/{_id}?workspace={{query.workspace}}",
150
- filename: "{name}",
149
+ url: "/api/files/{{row._id}}?workspace={{query.workspace}}",
150
+ filename: "{{row.name}}",
151
151
  },
152
152
  };
153
153
  mountTargets().handleCustomRowAction(action, {