@antelopejs/dms 0.4.5 → 0.4.7

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 (178) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/hooks/invite-extensions.js +2 -2
  3. package/dist/hooks/invite-extensions.js.map +1 -1
  4. package/dist/implementations/dms/layout-banners.d.ts +18 -0
  5. package/dist/implementations/dms/layout-banners.js +60 -0
  6. package/dist/implementations/dms/layout-banners.js.map +1 -0
  7. package/dist/implementations/dms/page.d.ts +2 -0
  8. package/dist/implementations/dms/page.js +82 -27
  9. package/dist/implementations/dms/page.js.map +1 -1
  10. package/dist/implementations/dms/quick-actions.d.ts +5 -0
  11. package/dist/implementations/dms/quick-actions.js.map +1 -1
  12. package/dist/implementations/dms/resolver-timeout.d.ts +1 -0
  13. package/dist/implementations/dms/resolver-timeout.js +13 -0
  14. package/dist/implementations/dms/resolver-timeout.js.map +1 -0
  15. package/dist/implementations/dms/saas-mode.d.ts +8 -0
  16. package/dist/implementations/dms/saas-mode.js +20 -0
  17. package/dist/implementations/dms/saas-mode.js.map +1 -0
  18. package/dist/implementations/dms-auth/index.d.ts +2 -1
  19. package/dist/implementations/dms-auth/index.js +19 -8
  20. package/dist/implementations/dms-auth/index.js.map +1 -1
  21. package/dist/implementations/dms-html-render/service-jwt.js +2 -1
  22. package/dist/implementations/dms-html-render/service-jwt.js.map +1 -1
  23. package/dist/index.js +2 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/pages/settings/users/index.d.ts +1 -0
  26. package/dist/pages/settings/users/index.js +1 -0
  27. package/dist/pages/settings/users/index.js.map +1 -1
  28. package/dist/pages/settings/users/invite-extension-routes.d.ts +4 -0
  29. package/dist/pages/settings/users/invite-extension-routes.js +57 -0
  30. package/dist/pages/settings/users/invite-extension-routes.js.map +1 -0
  31. package/dist/pages/settings/users/invites.d.ts +2 -1
  32. package/dist/pages/settings/users/invites.js +17 -5
  33. package/dist/pages/settings/users/invites.js.map +1 -1
  34. package/dist/pages/settings/users/members.d.ts +4 -1
  35. package/dist/pages/settings/users/members.js +10 -6
  36. package/dist/pages/settings/users/members.js.map +1 -1
  37. package/dist/pages/settings/users/notifications.js +1 -1
  38. package/dist/pages/settings/users/notifications.js.map +1 -1
  39. package/dist/pages/settings/users/quick-actions.d.ts +1 -0
  40. package/dist/pages/settings/users/quick-actions.js +22 -0
  41. package/dist/pages/settings/users/quick-actions.js.map +1 -0
  42. package/dist/routes/auth/signup.d.ts +2 -0
  43. package/dist/routes/auth/signup.js +7 -2
  44. package/dist/routes/auth/signup.js.map +1 -1
  45. package/dist/test/integration/invite-extension-edit.test.d.ts +1 -0
  46. package/dist/test/integration/invite-extension-edit.test.js +122 -0
  47. package/dist/test/integration/invite-extension-edit.test.js.map +1 -0
  48. package/dist/test/integration/saas-mode-ownership.test.d.ts +1 -0
  49. package/dist/test/integration/saas-mode-ownership.test.js +81 -0
  50. package/dist/test/integration/saas-mode-ownership.test.js.map +1 -0
  51. package/dist/test/integration/tenant-lifecycle.test.js +38 -0
  52. package/dist/test/integration/tenant-lifecycle.test.js.map +1 -1
  53. package/dist/test/unit/implementations/dms/layout-banners.test.d.ts +1 -0
  54. package/dist/test/unit/implementations/dms/layout-banners.test.js +217 -0
  55. package/dist/test/unit/implementations/dms/layout-banners.test.js.map +1 -0
  56. package/dist/test/unit/implementations/dms/quick-actions-button.test.d.ts +1 -0
  57. package/dist/test/unit/implementations/dms/quick-actions-button.test.js +220 -0
  58. package/dist/test/unit/implementations/dms/quick-actions-button.test.js.map +1 -0
  59. package/dist/test/unit/implementations/dms-html-render/generate-html.test.js +1 -1
  60. package/dist/test/unit/implementations/dms-html-render/generate-html.test.js.map +1 -1
  61. package/dist/test/unit/implementations/dms-html-render/service-jwt.test.d.ts +1 -0
  62. package/dist/test/unit/implementations/dms-html-render/service-jwt.test.js +37 -0
  63. package/dist/test/unit/implementations/dms-html-render/service-jwt.test.js.map +1 -0
  64. package/dist/test/unit/interfaces/dms/invite-extension.test.js +138 -12
  65. package/dist/test/unit/interfaces/dms/invite-extension.test.js.map +1 -1
  66. package/dist/test/unit/interfaces/dms/password-policy.test.d.ts +1 -0
  67. package/dist/test/unit/interfaces/dms/password-policy.test.js +43 -0
  68. package/dist/test/unit/interfaces/dms/password-policy.test.js.map +1 -0
  69. package/dist/test/unit/interfaces/dms/saas-mode.test.d.ts +1 -0
  70. package/dist/test/unit/interfaces/dms/saas-mode.test.js +42 -0
  71. package/dist/test/unit/interfaces/dms/saas-mode.test.js.map +1 -0
  72. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.d.ts +1 -0
  73. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.js +53 -0
  74. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.js.map +1 -0
  75. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.d.ts +1 -0
  76. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.js +80 -0
  77. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.js.map +1 -0
  78. package/dist/test/unit/pages/settings/invite-member-quick-action.test.d.ts +1 -0
  79. package/dist/test/unit/pages/settings/invite-member-quick-action.test.js +104 -0
  80. package/dist/test/unit/pages/settings/invite-member-quick-action.test.js.map +1 -0
  81. package/dist/test/unit/pages/settings/notifications-access-gate.test.d.ts +1 -0
  82. package/dist/test/unit/pages/settings/notifications-access-gate.test.js +78 -0
  83. package/dist/test/unit/pages/settings/notifications-access-gate.test.js.map +1 -0
  84. package/dist/test/unit/signup-account-guard.test.js +20 -2
  85. package/dist/test/unit/signup-account-guard.test.js.map +1 -1
  86. package/dist/test/unit/utils/admin-invite-email.test.d.ts +1 -0
  87. package/dist/test/unit/utils/admin-invite-email.test.js +69 -0
  88. package/dist/test/unit/utils/admin-invite-email.test.js.map +1 -0
  89. package/dist/utils/admin-invite-email.d.ts +14 -0
  90. package/dist/utils/admin-invite-email.js +51 -0
  91. package/dist/utils/admin-invite-email.js.map +1 -0
  92. package/dist/utils/index.d.ts +0 -1
  93. package/dist/utils/index.js +0 -1
  94. package/dist/utils/index.js.map +1 -1
  95. package/dist/validation/auth.schema.d.ts +3 -3
  96. package/dist/validation/auth.schema.js +1 -1
  97. package/dist/validation/auth.schema.js.map +1 -1
  98. package/dist/validation/password.d.ts +0 -1
  99. package/dist/validation/password.js +6 -3
  100. package/dist/validation/password.js.map +1 -1
  101. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/signup.vue +37 -8
  102. package/frontend-vue/layers/dms-auth/i18n/locales/auth-en-GB.json +3 -1
  103. package/frontend-vue/layers/dms-auth/i18n/locales/auth-fr-FR.json +3 -1
  104. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +4 -0
  105. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +4 -0
  106. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +50 -0
  107. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +16 -13
  108. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +2 -2
  109. package/frontend-vue/layers/dms-layout/app/build/components/notification/NotificationPopover.vue +11 -5
  110. package/frontend-vue/layers/dms-layout/app/build/components/notification/widgetRequest.ts +20 -0
  111. package/frontend-vue/layers/dms-layout/app/composables/layout/useLayoutBanners.ts +59 -0
  112. package/frontend-vue/layers/dms-layout/app/composables/page/useQuickActions.ts +15 -2
  113. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -0
  114. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +54 -9
  115. package/frontend-vue/layers/dms-layout/app/error.vue +17 -2
  116. package/frontend-vue/layers/dms-layout/app/types/page.ts +22 -0
  117. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +48 -0
  118. package/frontend-vue/layers/dms-layout/app/utils/favorites.ts +94 -0
  119. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +21 -2
  120. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +21 -2
  121. package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/index.ts +1 -1
  122. package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/{useOnboardingMiddleware.ts → useOnboarding.ts} +0 -12
  123. package/frontend-vue/layers/dms-onboarding/app/custom-pages/onboarding.vue +4 -1
  124. package/frontend-vue/layers/dms-onboarding/app/middleware/onboarding.global.ts +21 -4
  125. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +13 -3
  126. package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +13 -3
  127. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +2 -7
  128. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/columnVisibility.ts +13 -0
  129. package/frontend-vue/layers/dms-ui/app/components/PasswordStrength.vue +1 -1
  130. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +1 -1
  131. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +1 -1
  132. package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +2 -2
  133. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +6 -16
  134. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +2 -2
  135. package/frontend-vue/layers/dms-ui/app/components/chart/internal/TrendBadge.vue +1 -1
  136. package/frontend-vue/layers/dms-ui/app/components/copy/CopyButton.vue +1 -1
  137. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +3 -3
  138. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +5 -1
  139. package/frontend-vue/layers/dms-ui/app/components/segmented/Segmented.vue +1 -1
  140. package/frontend-vue/layers/dms-ui/app/components/status/StatusSummary.vue +1 -1
  141. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +35 -11
  142. package/frontend-vue/layers/dms-ui/app/components/tree/Tree.vue +10 -9
  143. package/frontend-vue/layers/dms-ui/app/composables/chart/donutRecords.ts +26 -0
  144. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +4 -2
  145. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +3 -3
  146. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +17 -4
  147. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewEscape.ts +22 -0
  148. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/column.ts +1 -1
  149. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/custom-button.ts +2 -0
  150. package/frontend-vue/layers/dms-ui/app/composables/tree/shortcuts/index.ts +19 -4
  151. package/frontend-vue/layers/dms-ui/app/composables/tree/shortcuts/treeEscape.ts +21 -0
  152. package/frontend-vue/layers/dms-ui/app/types/quick-actions.ts +38 -3
  153. package/frontend-vue/layers/dms-ui/app/utils/overlayFocus.ts +16 -0
  154. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -2
  155. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -2
  156. package/frontend-vue/package.json +1 -1
  157. package/frontend-vue/pnpm-lock.yaml +5 -5
  158. package/frontend-vue/tests/admin-invite-email.test.ts +86 -0
  159. package/frontend-vue/tests/breadcrumb-target.test.ts +50 -0
  160. package/frontend-vue/tests/chart-nested-donut.test.ts +97 -0
  161. package/frontend-vue/tests/error-page-message.test.ts +83 -0
  162. package/frontend-vue/tests/favorite-page.test.ts +104 -0
  163. package/frontend-vue/tests/icon-aria-hidden.test.ts +29 -0
  164. package/frontend-vue/tests/layout-banners.test.ts +224 -0
  165. package/frontend-vue/tests/notification-popover-errors.test.ts +147 -0
  166. package/frontend-vue/tests/onboarding-middleware.test.ts +103 -0
  167. package/frontend-vue/tests/password-strength-meter.test.ts +33 -0
  168. package/frontend-vue/tests/quick-action-intent.test.ts +74 -0
  169. package/frontend-vue/tests/quick-actions.test.ts +23 -0
  170. package/frontend-vue/tests/signup-invitation-link.test.ts +126 -0
  171. package/frontend-vue/tests/skeleton-paragraph.test.ts +30 -0
  172. package/frontend-vue/tests/tab-without-component-id.test.ts +51 -0
  173. package/frontend-vue/tests/table-column-visibility.test.ts +20 -0
  174. package/frontend-vue/tests/table-view-escape-shortcut.test.ts +141 -0
  175. package/frontend-vue/tests/tree-escape-shortcut.test.ts +138 -0
  176. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  177. package/package.json +2 -2
  178. package/skills/dms/REFERENCE.md +1 -1
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createSSRApp, defineComponent, h } from "vue";
3
+ import { renderToString } from "vue/server-renderer";
4
+ import { createI18n } from "vue-i18n";
5
+ import AdminInvite from "../layers/dms-layout/app/emails/EmailAdminInvite.vue";
6
+ import en from "../layers/dms-layout/i18n/locales/layout-en-GB.json";
7
+ import fr from "../layers/dms-layout/i18n/locales/layout-fr-FR.json";
8
+
9
+ const EMAIL_ELEMENTS = ["EHtml", "EHead", "EBody", "EContainer", "ESection"];
10
+ const LEAF_ELEMENTS = ["EText", "EHeading", "EButton"];
11
+
12
+ function passthrough(tag: string) {
13
+ return defineComponent({
14
+ setup:
15
+ (_, { slots }) =>
16
+ () =>
17
+ h(tag, slots.default?.()),
18
+ });
19
+ }
20
+
21
+ async function renderInvite(
22
+ props: Record<string, unknown>,
23
+ locale = "en",
24
+ ): Promise<string> {
25
+ vi.stubGlobal("useDmsAppConfig", () => ({}));
26
+ vi.stubGlobal("useDmsRuntimeConfig", () => ({ public: {} }));
27
+ const app = createSSRApp(AdminInvite, {
28
+ email: "invitee@local.test",
29
+ signupLink: "https://app.local/auth/signup?token=t",
30
+ expiresInDays: 7,
31
+ ...props,
32
+ });
33
+ app.use(
34
+ createI18n({
35
+ legacy: false,
36
+ locale,
37
+ fallbackLocale: "en",
38
+ messages: { en, fr },
39
+ }),
40
+ );
41
+ EMAIL_ELEMENTS.forEach((name) => app.component(name, passthrough("div")));
42
+ LEAF_ELEMENTS.forEach((name) => app.component(name, passthrough("p")));
43
+ app.component("EImg", passthrough("img"));
44
+ app.component("EHr", passthrough("hr"));
45
+ const html = await renderToString(app);
46
+ vi.unstubAllGlobals();
47
+ return html.replace(/<!--[^>]*-->/g, "").replace(/&#39;/g, "'");
48
+ }
49
+
50
+ describe("EmailAdminInvite", () => {
51
+ it("keeps today's generic wording without any name", async () => {
52
+ const html = await renderInvite({});
53
+ expect(html).toContain("You're Invited!");
54
+ expect(html).toContain(
55
+ "You've been invited to join our platform. Click the button below to create your account:",
56
+ );
57
+ expect(html).toContain("This invitation expires in 7 days.");
58
+ });
59
+
60
+ it("names the workspace, the platform and the inviter", async () => {
61
+ const html = await renderInvite({
62
+ workspaceName: "Storefront",
63
+ platformName: "Acme",
64
+ inviterName: "Ada",
65
+ });
66
+ expect(html).toContain("You're invited to Storefront");
67
+ expect(html).toContain("Ada invited you to join Storefront on Acme.");
68
+ });
69
+
70
+ it("names the platform when the workspace is unknown", async () => {
71
+ const html = await renderInvite({ platformName: "Acme" });
72
+ expect(html).toContain("You've been invited to join Acme.");
73
+ });
74
+
75
+ it("writes the email in French", async () => {
76
+ const html = await renderInvite(
77
+ { workspaceName: "Storefront", platformName: "Acme" },
78
+ "fr",
79
+ );
80
+ expect(html).toContain("Vous êtes invité à rejoindre Storefront");
81
+ expect(html).toContain(
82
+ "Vous avez été invité à rejoindre Storefront sur Acme.",
83
+ );
84
+ expect(html).toContain("Cette invitation expire dans 7 jours.");
85
+ });
86
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildBreadcrumbTarget } from "../layers/dms-layout/app/utils/breadcrumb";
3
+
4
+ const PAGE = { layoutUrl: "/projects/project/pagelayout" };
5
+ const PROJECT_PAGE = {
6
+ ...PAGE,
7
+ validation: { requiredQueryParams: ["project"] },
8
+ };
9
+
10
+ describe("buildBreadcrumbTarget", () => {
11
+ it("links a page without query requirements to its bare path", () => {
12
+ expect(
13
+ buildBreadcrumbTarget("/projects", PAGE, { project: "p1" }),
14
+ ).to.equal("/projects");
15
+ });
16
+
17
+ it("gives a category without a page no link", () => {
18
+ expect(buildBreadcrumbTarget("/projects", {}, {})).to.equal(undefined);
19
+ });
20
+
21
+ it("carries only the query values an ancestor page requires", () => {
22
+ expect(
23
+ buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {
24
+ project: "p 1&x",
25
+ service: "s1",
26
+ }),
27
+ ).to.equal("/projects/project?project=p+1%26x");
28
+ });
29
+
30
+ it("gives no link when a required query value is missing", () => {
31
+ expect(
32
+ buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {
33
+ service: "s1",
34
+ }),
35
+ ).to.equal(undefined);
36
+ });
37
+
38
+ it("gives no link when a required query value is empty or repeated", () => {
39
+ expect(
40
+ buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {
41
+ project: "",
42
+ }),
43
+ ).to.equal(undefined);
44
+ expect(
45
+ buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {
46
+ project: ["a", "b"],
47
+ }),
48
+ ).to.equal(undefined);
49
+ });
50
+ });
@@ -0,0 +1,97 @@
1
+ import { createSSRApp, defineComponent, h, ref, type PropType } from "vue";
2
+ import { renderToString } from "vue/server-renderer";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import ChartCard from "../layers/dms-ui/app/components/chart/ChartCard.vue";
5
+ import Chart from "../layers/dms-ui/app/components/chart/Chart.vue";
6
+ import type { ChartType } from "../layers/dms-ui/app/composables/chart/types";
7
+
8
+ const CARD_SERIES = [
9
+ {
10
+ name: "Plans",
11
+ data: [
12
+ { x: "Starter", y: 3 },
13
+ { x: "Pro", y: 5, label: "Pro plan" },
14
+ ],
15
+ },
16
+ ];
17
+
18
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
19
+ useChartFetch: (options: { fetchUrl?: string }) => ({
20
+ data: ref(options.fetchUrl ? { value: 8, series: CARD_SERIES } : null),
21
+ isLoading: ref(false),
22
+ }),
23
+ }));
24
+
25
+ interface CircularOptions {
26
+ labels?: string[];
27
+ }
28
+
29
+ const PlotProbe = defineComponent({
30
+ props: {
31
+ options: { type: Object as PropType<CircularOptions>, required: true },
32
+ series: { type: Array as PropType<unknown[]>, required: true },
33
+ },
34
+ setup: (props) => () =>
35
+ h("output", [
36
+ h("span", { class: "labels" }, JSON.stringify(props.options.labels)),
37
+ h("span", { class: "series" }, JSON.stringify(props.series)),
38
+ ]),
39
+ });
40
+ const Slot = defineComponent({
41
+ setup:
42
+ (_, { slots }) =>
43
+ () =>
44
+ h("div", slots.default?.()),
45
+ });
46
+
47
+ const SLOT_COMPONENTS = [
48
+ "DmsCard",
49
+ "DmsClientOnly",
50
+ "UIcon",
51
+ "USkeleton",
52
+ "DmsTrendBadge",
53
+ "DmsSparkline",
54
+ "UBadge",
55
+ ];
56
+
57
+ async function renderNested(type: ChartType) {
58
+ const app = createSSRApp({
59
+ render: () =>
60
+ h(
61
+ ChartCard,
62
+ {
63
+ title: "Plans",
64
+ componentId: "card",
65
+ pageId: "donut",
66
+ fetchUrl: "/plans",
67
+ },
68
+ () => h(Chart, { type, componentId: "plot", pageId: "donut" }),
69
+ ),
70
+ });
71
+ for (const name of SLOT_COMPONENTS) app.component(name, Slot);
72
+ app.component("DmsApexChartHost", PlotProbe);
73
+ return renderToString(app);
74
+ }
75
+
76
+ beforeEach(() => {
77
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-US"), t: String }));
78
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
79
+ vi.stubGlobal("useComponentEvent", () => ({}));
80
+ vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
81
+ });
82
+
83
+ afterEach(() => vi.unstubAllGlobals());
84
+
85
+ describe("circular chart nested in a ChartCard", () => {
86
+ it.each<ChartType>(["donut", "pie"])(
87
+ "draws the card's first series as %s slices",
88
+ async (type) => {
89
+ const html = await renderNested(type);
90
+ expect(html).not.toContain("dms.chart.no_data");
91
+ expect(html).toContain(
92
+ 'class="labels">[&quot;Starter&quot;,&quot;Pro plan&quot;]',
93
+ );
94
+ expect(html).toContain(`class="series">[3,5]`);
95
+ },
96
+ );
97
+ });
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
3
+ import {
4
+ computed,
5
+ createApp,
6
+ defineComponent,
7
+ h,
8
+ onMounted,
9
+ ref,
10
+ type App,
11
+ } from "vue";
12
+ import ErrorPage from "../layers/dms-layout/app/error.vue";
13
+ import {
14
+ HTTP_FORBIDDEN,
15
+ HTTP_NOT_FOUND,
16
+ HTTP_UNAUTHORIZED,
17
+ } from "../layers/dms-core/app/utils/http-status";
18
+
19
+ const TECHNICAL_MESSAGE = "DMS backend request failed";
20
+
21
+ let app: App;
22
+ let host: HTMLDivElement;
23
+
24
+ const Passthrough = defineComponent({
25
+ setup:
26
+ (_, { slots }) =>
27
+ () =>
28
+ h("div", slots.default?.()),
29
+ });
30
+
31
+ function installRuntime() {
32
+ Object.entries({
33
+ computed,
34
+ ref,
35
+ onMounted,
36
+ HTTP_FORBIDDEN,
37
+ HTTP_NOT_FOUND,
38
+ HTTP_UNAUTHORIZED,
39
+ }).forEach(([key, value]) => vi.stubGlobal(key, value));
40
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
41
+ vi.stubGlobal("useDmsRouter", () => ({ back: vi.fn() }));
42
+ vi.stubGlobal("useHomepage", () => "/");
43
+ vi.stubGlobal("useSessionRecovery", () => ({
44
+ loggedIn: ref(true),
45
+ reconcileSession: vi.fn(),
46
+ redirectToAuth: vi.fn(),
47
+ }));
48
+ }
49
+
50
+ function mountError(statusCode: number) {
51
+ app = createApp(ErrorPage, {
52
+ error: { statusCode, message: TECHNICAL_MESSAGE },
53
+ });
54
+ app.config.globalProperties.$t = (key: string) => key;
55
+ app.component("DmsAppLogo", Passthrough);
56
+ app.component("DmsCard", Passthrough);
57
+ app.component("Icon", Passthrough);
58
+ app.component("UButton", Passthrough);
59
+ app.mount(host);
60
+ return host.textContent ?? "";
61
+ }
62
+
63
+ beforeEach(() => {
64
+ installRuntime();
65
+ host = document.createElement("div");
66
+ });
67
+ afterEach(() => {
68
+ app?.unmount();
69
+ vi.unstubAllGlobals();
70
+ });
71
+
72
+ it.each([HTTP_NOT_FOUND, HTTP_FORBIDDEN])(
73
+ "keeps the technical cause off the %i card",
74
+ (statusCode) => {
75
+ const text = mountError(statusCode);
76
+ expect(text).toContain(`error.${statusCode}.description`);
77
+ expect(text).not.toContain(TECHNICAL_MESSAGE);
78
+ },
79
+ );
80
+
81
+ it("still shows the message of an unexpected error", () => {
82
+ expect(mountError(500)).toContain(TECHNICAL_MESSAGE);
83
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildFavoritePage,
4
+ isFavoritePathValid,
5
+ } from "../layers/dms-layout/app/utils/favorites";
6
+
7
+ const translate = (text: string) =>
8
+ text.startsWith("$") ? `t(${text.slice(1)})` : text;
9
+
10
+ const SETTINGS_PAGE = {
11
+ id: "settings",
12
+ displayName: "$page.settings.title",
13
+ icon: "i-ph-gear",
14
+ };
15
+ const PROJECT_PAGE = {
16
+ id: "project",
17
+ displayName: "$cloud.project.title",
18
+ icon: "i-ph-cube",
19
+ validation: { requiredQueryParams: ["project"] },
20
+ };
21
+
22
+ const ROUTES: Record<string, typeof SETTINGS_PAGE | typeof PROJECT_PAGE> = {
23
+ "/settings": SETTINGS_PAGE,
24
+ "/projects/project": PROJECT_PAGE,
25
+ };
26
+ const resolveRoute = (path: string) =>
27
+ ROUTES[path] ? { metadata: ROUTES[path] } : null;
28
+
29
+ describe("buildFavoritePage", () => {
30
+ it("keeps a plain page on its path and its translatable display name", () => {
31
+ expect(
32
+ buildFavoritePage({
33
+ path: "/settings",
34
+ metadata: SETTINGS_PAGE,
35
+ query: { tab: "general" },
36
+ translate,
37
+ }),
38
+ ).toEqual({
39
+ id: "settings",
40
+ path: "/settings",
41
+ title: "$page.settings.title",
42
+ icon: "i-ph-gear",
43
+ });
44
+ });
45
+
46
+ it("keeps the query a page requires and names it after the page and its values", () => {
47
+ expect(
48
+ buildFavoritePage({
49
+ path: "/projects/project",
50
+ metadata: PROJECT_PAGE,
51
+ query: { project: "p 1", service: "s1" },
52
+ translate,
53
+ }),
54
+ ).toEqual({
55
+ id: "/projects/project?project=p+1",
56
+ path: "/projects/project?project=p+1",
57
+ title: "t(cloud.project.title) · p 1",
58
+ icon: "i-ph-cube",
59
+ });
60
+ });
61
+
62
+ it("joins every required query value in the title", () => {
63
+ expect(
64
+ buildFavoritePage({
65
+ path: "/projects/project/service",
66
+ metadata: {
67
+ ...PROJECT_PAGE,
68
+ validation: { requiredQueryParams: ["project", "service"] },
69
+ },
70
+ query: { project: "p1", service: "s1" },
71
+ translate,
72
+ })?.title,
73
+ ).toBe("t(cloud.project.title) · p1 · s1");
74
+ });
75
+
76
+ it("gives no favorite when a required query value is missing", () => {
77
+ expect(
78
+ buildFavoritePage({
79
+ path: "/projects/project",
80
+ metadata: PROJECT_PAGE,
81
+ query: {},
82
+ translate,
83
+ }),
84
+ ).toBe(null);
85
+ });
86
+ });
87
+
88
+ describe("isFavoritePathValid", () => {
89
+ it("accepts favorites of plain pages, with or without a query", () => {
90
+ expect(isFavoritePathValid("/settings", resolveRoute)).toBe(true);
91
+ expect(isFavoritePathValid("/settings?tab=x", resolveRoute)).toBe(true);
92
+ });
93
+
94
+ it("accepts a favorite carrying the query its page requires", () => {
95
+ expect(
96
+ isFavoritePathValid("/projects/project?project=p+1", resolveRoute),
97
+ ).toBe(true);
98
+ });
99
+
100
+ it("drops favorites that match no page or lack a required query value", () => {
101
+ expect(isFavoritePathValid("/unknown", resolveRoute)).toBe(false);
102
+ expect(isFavoritePathValid("/projects/project", resolveRoute)).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,29 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const ICON_TAG = /<(?:UIcon|Icon)\b[^>]*?>/gs;
6
+ const STATIC_ARIA_HIDDEN = /(?<![:\w-])aria-hidden=/;
7
+
8
+ function walk(directory: string): string[] {
9
+ return readdirSync(directory).flatMap((name) => {
10
+ const path = join(directory, name);
11
+ return statSync(path).isDirectory() ? walk(path) : [path];
12
+ });
13
+ }
14
+
15
+ describe("icon aria-hidden bindings", () => {
16
+ // The icon hands its attributes to Iconify's component, whose `ariaHidden`
17
+ // prop is a Boolean: a static attribute reaches it as the string "true".
18
+ it("binds aria-hidden on icons as a boolean", () => {
19
+ const offenders = walk(join(process.cwd(), "layers"))
20
+ .filter((path) => path.endsWith(".vue"))
21
+ .flatMap((path) =>
22
+ [...readFileSync(path, "utf8").matchAll(ICON_TAG)]
23
+ .filter(([tag]) => STATIC_ARIA_HIDDEN.test(tag))
24
+ .map(() => relative(process.cwd(), path)),
25
+ );
26
+
27
+ expect(offenders).toEqual([]);
28
+ });
29
+ });
@@ -0,0 +1,224 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createApp,
5
+ createSSRApp,
6
+ defineComponent,
7
+ h,
8
+ nextTick,
9
+ ref,
10
+ type App,
11
+ type Ref,
12
+ } from "vue";
13
+ import { renderToString } from "vue/server-renderer";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import DashboardBanners from "../layers/dms-layout/app/build/components/layout/DashboardBanners.vue";
16
+ import {
17
+ resolveLayoutBannerPresentation,
18
+ useLayoutBanners,
19
+ } from "../layers/dms-layout/app/composables/layout/useLayoutBanners";
20
+ import type {
21
+ LayoutBanner,
22
+ LayoutBannerVariant,
23
+ SiteLayout,
24
+ } from "../layers/dms-layout/app/types/page";
25
+
26
+ const DISMISSED_BANNERS_COOKIE = "dms-dismissed-banners";
27
+
28
+ const siteLayout = ref<SiteLayout | undefined>();
29
+ let dismissedCookie: Ref<string[]>;
30
+ const cookieNames: string[] = [];
31
+
32
+ const banner = (extra: Partial<LayoutBanner> = {}): LayoutBanner => ({
33
+ key: "banner",
34
+ variant: "info",
35
+ order: 0,
36
+ dismissible: false,
37
+ text: "Heads up",
38
+ ...extra,
39
+ });
40
+
41
+ function serveBanners(banners: LayoutBanner[] | undefined): void {
42
+ siteLayout.value = { pages: {}, categories: {}, banners };
43
+ }
44
+
45
+ // Renders what the real alert exposes to the banner: its description slot and
46
+ // a close button that reports the alert closed.
47
+ const AlertStub = defineComponent({
48
+ props: {
49
+ color: { type: String, default: undefined },
50
+ icon: { type: String, default: undefined },
51
+ close: { type: Boolean, default: false },
52
+ },
53
+ emits: ["update:open"],
54
+ setup:
55
+ (props, { slots, emit }) =>
56
+ () =>
57
+ h("div", { "data-color": props.color, "data-icon": props.icon }, [
58
+ slots.description?.(),
59
+ props.close
60
+ ? h("button", { onClick: () => emit("update:open", false) }, "close")
61
+ : null,
62
+ ]),
63
+ });
64
+
65
+ const BannerContent = defineComponent({
66
+ props: { days: { type: Number, required: true } },
67
+ setup: (props) => () => h("strong", `${props.days} days left`),
68
+ });
69
+
70
+ function mountable(root: App): App {
71
+ root.component("UAlert", AlertStub);
72
+ root.component("BannerContent", BannerContent);
73
+ return root;
74
+ }
75
+
76
+ beforeEach(() => {
77
+ siteLayout.value = undefined;
78
+ cookieNames.length = 0;
79
+ dismissedCookie = ref<string[]>([]);
80
+ vi.stubGlobal("computed", computed);
81
+ vi.stubGlobal("useSiteLayout", () => ({ siteLayout }));
82
+ vi.stubGlobal("useDmsCookie", (name: string) => {
83
+ cookieNames.push(name);
84
+ return dismissedCookie;
85
+ });
86
+ vi.stubGlobal("useTranslation", () => ({
87
+ processI18n: (key: string) => key.replace(/^\$/, "t:"),
88
+ }));
89
+ vi.stubGlobal("useLayoutBanners", useLayoutBanners);
90
+ vi.stubGlobal(
91
+ "resolveLayoutBannerPresentation",
92
+ resolveLayoutBannerPresentation,
93
+ );
94
+ });
95
+
96
+ afterEach(() => {
97
+ vi.unstubAllGlobals();
98
+ });
99
+
100
+ describe("resolveLayoutBannerPresentation", () => {
101
+ it.each([
102
+ ["info", "status"],
103
+ ["warning", "status"],
104
+ ["error", "alert"],
105
+ ] as const)("announces a %s banner as %s", (variant, role) => {
106
+ const presentation = resolveLayoutBannerPresentation(banner({ variant }));
107
+ expect(presentation.role).toBe(role);
108
+ expect(presentation.color).toBe(variant);
109
+ });
110
+
111
+ it("renders an unknown variant as info rather than unstyled", () => {
112
+ const presentation = resolveLayoutBannerPresentation(
113
+ banner({ variant: "success" as LayoutBannerVariant }),
114
+ );
115
+ expect(presentation).toEqual(
116
+ resolveLayoutBannerPresentation(banner({ variant: "info" })),
117
+ );
118
+ });
119
+
120
+ it("keeps the banner's own icon over the variant's", () => {
121
+ expect(
122
+ resolveLayoutBannerPresentation(banner({ icon: "i-ph-clock" })).icon,
123
+ ).toBe("i-ph-clock");
124
+ });
125
+ });
126
+
127
+ describe("useLayoutBanners", () => {
128
+ it("serves nothing for a backend that predates banners", () => {
129
+ serveBanners(undefined);
130
+ expect(useLayoutBanners().banners.value).toEqual([]);
131
+ });
132
+
133
+ it("drops a dismissed banner, and remembers the dismissal in the cookie", () => {
134
+ serveBanners([
135
+ banner({ key: "a", dismissible: true }),
136
+ banner({ key: "b", dismissible: true }),
137
+ ]);
138
+ const { banners, dismiss } = useLayoutBanners();
139
+
140
+ dismiss("a");
141
+ dismiss("a");
142
+
143
+ expect(cookieNames).toEqual([DISMISSED_BANNERS_COOKIE]);
144
+ expect(dismissedCookie.value).toEqual(["a"]);
145
+ expect(banners.value.map((entry) => entry.key)).toEqual(["b"]);
146
+ });
147
+
148
+ // A banner that turned non-dismissible (an overdue invoice) must show again
149
+ // even to a user who closed its dismissible predecessor under the same key.
150
+ it("ignores a dismissal for a banner that is not dismissible", () => {
151
+ dismissedCookie.value = ["a"];
152
+ serveBanners([banner({ key: "a", dismissible: false })]);
153
+ expect(useLayoutBanners().banners.value).toHaveLength(1);
154
+ });
155
+ });
156
+
157
+ describe("DashboardBanners", () => {
158
+ it("server-renders the banners in order, each with its variant's role", async () => {
159
+ serveBanners([
160
+ banner({ key: "billing", variant: "error", text: "$billing.overdue" }),
161
+ banner({
162
+ key: "trial",
163
+ variant: "warning",
164
+ component: "BannerContent",
165
+ props: { days: 3 },
166
+ text: undefined,
167
+ }),
168
+ banner({ key: "news", variant: "info", text: "New release" }),
169
+ ]);
170
+
171
+ const html = await renderToString(
172
+ mountable(createSSRApp(DashboardBanners)),
173
+ );
174
+ const document = new DOMParser().parseFromString(html, "text/html");
175
+ const rendered = [
176
+ ...document.querySelectorAll("[data-dms-layout-banner]"),
177
+ ].map((node) => ({
178
+ key: node.getAttribute("data-dms-layout-banner"),
179
+ role: node.getAttribute("role"),
180
+ text: node.textContent,
181
+ }));
182
+
183
+ expect(rendered).toEqual([
184
+ { key: "billing", role: "alert", text: "t:billing.overdue" },
185
+ { key: "trial", role: "status", text: "3 days left" },
186
+ { key: "news", role: "status", text: "New release" },
187
+ ]);
188
+ });
189
+
190
+ // The server reads the same cookie, so a dismissed banner never reaches the
191
+ // page only to be removed on hydration.
192
+ it("leaves a dismissed banner out of the server-rendered page", async () => {
193
+ dismissedCookie.value = ["news"];
194
+ serveBanners([banner({ key: "news", dismissible: true })]);
195
+
196
+ const html = await renderToString(
197
+ mountable(createSSRApp(DashboardBanners)),
198
+ );
199
+ expect(html).not.toContain("data-dms-layout-banners");
200
+ });
201
+
202
+ it("offers a close button only on a dismissible banner, and closes it", async () => {
203
+ serveBanners([
204
+ banner({ key: "sticky" }),
205
+ banner({ key: "closable", dismissible: true }),
206
+ ]);
207
+ const host = document.createElement("div");
208
+ const app = mountable(createApp(DashboardBanners));
209
+ app.mount(host);
210
+
211
+ const buttons = host.querySelectorAll("button");
212
+ expect(buttons).toHaveLength(1);
213
+ buttons[0]!.click();
214
+ await nextTick();
215
+
216
+ expect(dismissedCookie.value).toEqual(["closable"]);
217
+ expect(
218
+ [...host.querySelectorAll("[data-dms-layout-banner]")].map((node) =>
219
+ node.getAttribute("data-dms-layout-banner"),
220
+ ),
221
+ ).toEqual(["sticky"]);
222
+ app.unmount();
223
+ });
224
+ });