@antelopejs/dms 0.4.5 → 0.4.6

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 (158) hide show
  1. package/CHANGELOG.md +27 -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/saas-mode.test.d.ts +1 -0
  67. package/dist/test/unit/interfaces/dms/saas-mode.test.js +42 -0
  68. package/dist/test/unit/interfaces/dms/saas-mode.test.js.map +1 -0
  69. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.d.ts +1 -0
  70. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.js +53 -0
  71. package/dist/test/unit/interfaces/dms/tenant-invite-email.test.js.map +1 -0
  72. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.d.ts +1 -0
  73. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.js +80 -0
  74. package/dist/test/unit/interfaces/dms-base/table-view-form-slots.test.js.map +1 -0
  75. package/dist/test/unit/pages/settings/invite-member-quick-action.test.d.ts +1 -0
  76. package/dist/test/unit/pages/settings/invite-member-quick-action.test.js +104 -0
  77. package/dist/test/unit/pages/settings/invite-member-quick-action.test.js.map +1 -0
  78. package/dist/test/unit/pages/settings/notifications-access-gate.test.d.ts +1 -0
  79. package/dist/test/unit/pages/settings/notifications-access-gate.test.js +78 -0
  80. package/dist/test/unit/pages/settings/notifications-access-gate.test.js.map +1 -0
  81. package/dist/test/unit/signup-account-guard.test.js +20 -2
  82. package/dist/test/unit/signup-account-guard.test.js.map +1 -1
  83. package/dist/test/unit/utils/admin-invite-email.test.d.ts +1 -0
  84. package/dist/test/unit/utils/admin-invite-email.test.js +69 -0
  85. package/dist/test/unit/utils/admin-invite-email.test.js.map +1 -0
  86. package/dist/utils/admin-invite-email.d.ts +14 -0
  87. package/dist/utils/admin-invite-email.js +51 -0
  88. package/dist/utils/admin-invite-email.js.map +1 -0
  89. package/dist/utils/index.d.ts +0 -1
  90. package/dist/utils/index.js +0 -1
  91. package/dist/utils/index.js.map +1 -1
  92. package/dist/validation/auth.schema.d.ts +3 -3
  93. package/dist/validation/auth.schema.js +1 -1
  94. package/dist/validation/auth.schema.js.map +1 -1
  95. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/signup.vue +37 -8
  96. package/frontend-vue/layers/dms-auth/i18n/locales/auth-en-GB.json +3 -1
  97. package/frontend-vue/layers/dms-auth/i18n/locales/auth-fr-FR.json +3 -1
  98. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardBanners.vue +50 -0
  99. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +16 -13
  100. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +2 -2
  101. package/frontend-vue/layers/dms-layout/app/build/components/notification/NotificationPopover.vue +11 -5
  102. package/frontend-vue/layers/dms-layout/app/build/components/notification/widgetRequest.ts +20 -0
  103. package/frontend-vue/layers/dms-layout/app/composables/layout/useLayoutBanners.ts +59 -0
  104. package/frontend-vue/layers/dms-layout/app/composables/page/useQuickActions.ts +15 -2
  105. package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +2 -0
  106. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +54 -9
  107. package/frontend-vue/layers/dms-layout/app/error.vue +17 -2
  108. package/frontend-vue/layers/dms-layout/app/types/page.ts +22 -0
  109. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +48 -0
  110. package/frontend-vue/layers/dms-layout/app/utils/favorites.ts +94 -0
  111. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +21 -2
  112. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +21 -2
  113. package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/index.ts +1 -1
  114. package/frontend-vue/layers/dms-onboarding/app/composables/onboarding/{useOnboardingMiddleware.ts → useOnboarding.ts} +0 -12
  115. package/frontend-vue/layers/dms-onboarding/app/custom-pages/onboarding.vue +4 -1
  116. package/frontend-vue/layers/dms-onboarding/app/middleware/onboarding.global.ts +21 -4
  117. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +13 -3
  118. package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +13 -3
  119. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +2 -7
  120. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/columnVisibility.ts +13 -0
  121. package/frontend-vue/layers/dms-ui/app/components/PasswordStrength.vue +1 -1
  122. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +1 -1
  123. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +1 -1
  124. package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +2 -2
  125. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +6 -16
  126. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +2 -2
  127. package/frontend-vue/layers/dms-ui/app/components/chart/internal/TrendBadge.vue +1 -1
  128. package/frontend-vue/layers/dms-ui/app/components/copy/CopyButton.vue +1 -1
  129. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +3 -3
  130. package/frontend-vue/layers/dms-ui/app/components/master-detail/MasterDetail.vue +5 -1
  131. package/frontend-vue/layers/dms-ui/app/components/segmented/Segmented.vue +1 -1
  132. package/frontend-vue/layers/dms-ui/app/components/status/StatusSummary.vue +1 -1
  133. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +33 -10
  134. package/frontend-vue/layers/dms-ui/app/composables/chart/donutRecords.ts +26 -0
  135. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +4 -2
  136. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +3 -3
  137. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/column.ts +1 -1
  138. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/custom-button.ts +2 -0
  139. package/frontend-vue/layers/dms-ui/app/types/quick-actions.ts +38 -3
  140. package/frontend-vue/tests/admin-invite-email.test.ts +86 -0
  141. package/frontend-vue/tests/breadcrumb-target.test.ts +50 -0
  142. package/frontend-vue/tests/chart-nested-donut.test.ts +97 -0
  143. package/frontend-vue/tests/error-page-message.test.ts +83 -0
  144. package/frontend-vue/tests/favorite-page.test.ts +104 -0
  145. package/frontend-vue/tests/icon-aria-hidden.test.ts +29 -0
  146. package/frontend-vue/tests/layout-banners.test.ts +224 -0
  147. package/frontend-vue/tests/notification-popover-errors.test.ts +147 -0
  148. package/frontend-vue/tests/onboarding-middleware.test.ts +103 -0
  149. package/frontend-vue/tests/password-strength-meter.test.ts +33 -0
  150. package/frontend-vue/tests/quick-action-intent.test.ts +74 -0
  151. package/frontend-vue/tests/quick-actions.test.ts +23 -0
  152. package/frontend-vue/tests/signup-invitation-link.test.ts +126 -0
  153. package/frontend-vue/tests/skeleton-paragraph.test.ts +30 -0
  154. package/frontend-vue/tests/tab-without-component-id.test.ts +51 -0
  155. package/frontend-vue/tests/table-column-visibility.test.ts +20 -0
  156. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  157. package/package.json +2 -2
  158. package/skills/dms/REFERENCE.md +1 -1
@@ -9,9 +9,11 @@ import type {
9
9
  } from "@tanstack/vue-table";
10
10
  import { refDebounced, watchDebounced } from "@vueuse/core";
11
11
  import {
12
- QUICK_ACTION_ADD,
12
+ QUICK_ACTION_BUTTON_KEY,
13
13
  QUICK_ACTION_COMPONENT_KEY,
14
14
  QUICK_ACTION_QUERY_KEY,
15
+ type QuickActionIntent,
16
+ readQuickActionIntent,
15
17
  } from "../../types/quick-actions";
16
18
  import type {
17
19
  KanbanConfig,
@@ -957,24 +959,45 @@ async function clearQuickActionQuery() {
957
959
  const {
958
960
  [QUICK_ACTION_QUERY_KEY]: _discarded,
959
961
  [QUICK_ACTION_COMPONENT_KEY]: _discardedComponent,
962
+ [QUICK_ACTION_BUTTON_KEY]: _discardedButton,
960
963
  ...rest
961
964
  } = route.query;
962
965
  await router.replace({ query: rest });
963
966
  }
964
967
 
965
- // A page may mount several table views, so the quick action always names the
966
- // one it means — resolved server-side — and only that one answers.
967
- function isQuickActionTarget(): boolean {
968
- return route.query[QUICK_ACTION_COMPONENT_KEY] === componentId;
968
+ // The button is looked up among the ones the server kept for this caller: a
969
+ // button it stripped cannot be pressed through the URL either.
970
+ function pressCustomButton(id: string) {
971
+ const button = customButtons?.find((candidate) => candidate.id === id);
972
+ if (button) handleCustomButton(button);
969
973
  }
970
974
 
975
+ const quickActionHandlers: {
976
+ [K in QuickActionIntent["kind"]]: (
977
+ intent: Extract<QuickActionIntent, { kind: K }>,
978
+ ) => void;
979
+ } = {
980
+ add: () => handleRowAdd(),
981
+ button: (intent) => pressCustomButton(intent.button),
982
+ };
983
+
984
+ // Watched key by key rather than through the parsed intent: a fresh intent
985
+ // object on every unrelated query change would press the button twice.
971
986
  watch(
972
- () => route.query[QUICK_ACTION_QUERY_KEY],
973
- async (value) => {
974
- if (value !== QUICK_ACTION_ADD) return;
975
- if (!isQuickActionTarget()) return;
987
+ [
988
+ QUICK_ACTION_QUERY_KEY,
989
+ QUICK_ACTION_COMPONENT_KEY,
990
+ QUICK_ACTION_BUTTON_KEY,
991
+ ].map((key) => () => route.query[key]),
992
+ async () => {
993
+ const intent = readQuickActionIntent(route.query, componentId);
994
+ if (!intent) return;
976
995
  await clearQuickActionQuery();
977
- handleRowAdd();
996
+ // Keyed by the intent's own discriminant, so the handler always matches.
997
+ const handler = quickActionHandlers[intent.kind] as (
998
+ value: QuickActionIntent,
999
+ ) => void;
1000
+ handler(intent);
978
1001
  },
979
1002
  { immediate: true },
980
1003
  );
@@ -0,0 +1,26 @@
1
+ import type { ChartSeries, DonutRecord } from "./types";
2
+
3
+ type ChartSeriesEntry = ChartSeries["data"][number];
4
+
5
+ interface LabelledPoint {
6
+ label?: string;
7
+ x?: unknown;
8
+ y?: unknown;
9
+ }
10
+
11
+ function toDonutRecord(entry: ChartSeriesEntry): DonutRecord {
12
+ if (typeof entry === "number") {
13
+ return { label: String(entry), value: entry };
14
+ }
15
+ const point = entry as LabelledPoint;
16
+ return {
17
+ label: point.label ?? String(point.x ?? ""),
18
+ value: Number(point.y ?? 0),
19
+ };
20
+ }
21
+
22
+ /** Turns one chart series into the slices of a circular chart. */
23
+ export function toDonutRecords(series: ChartSeries | undefined): DonutRecord[] {
24
+ if (!series || !Array.isArray(series.data)) return [];
25
+ return series.data.map(toDonutRecord);
26
+ }
@@ -1,8 +1,10 @@
1
1
  import type { TabItem } from "./item";
2
2
 
3
+ // A tab set is also used directly in hand-written pages, outside any
4
+ // registered component: the ids exist only for server-driven ones.
3
5
  interface TabComponentProps {
4
- componentId: string;
5
- pageId: string;
6
+ componentId?: string;
7
+ pageId?: string;
6
8
  routeParams?: Record<string, string>;
7
9
  watchActions?: WatchAction[];
8
10
  childCount?: number;
@@ -80,14 +80,14 @@ export function useTab(
80
80
  };
81
81
 
82
82
  watch(activeTab, async (newTab, oldTab) => {
83
- if (newTab !== oldTab) {
83
+ if (newTab === oldTab) return;
84
+ if (props.componentId) {
84
85
  sendComponentEvent(TabEvents.TAB_CHANGE, props.componentId, {
85
86
  previousTab: Number.parseInt(oldTab, 10),
86
87
  currentTab: Number.parseInt(newTab, 10),
87
88
  });
88
-
89
- await updateUrl(newTab);
90
89
  }
90
+ await updateUrl(newTab);
91
91
  });
92
92
 
93
93
  watch(
@@ -19,7 +19,7 @@ export interface ReadonlyBehavior {
19
19
  export interface TableViewColumn {
20
20
  id: string;
21
21
  header: string;
22
- visible?: boolean;
22
+ isVisible?: boolean;
23
23
  order?: number;
24
24
  accessorKey: string;
25
25
  listable: boolean;
@@ -3,6 +3,8 @@ import type { ActionTarget } from "./action-target";
3
3
  export type CustomButtonVariant = "solid" | "outline" | "ghost" | "link";
4
4
 
5
5
  export interface CustomButton {
6
+ /** Set when a quick action can press the button. */
7
+ id?: string;
6
8
  label: string;
7
9
  icon?: string;
8
10
  variant?: CustomButtonVariant;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * How an `openForm` quick action reaches its table view: it routes to the
3
- * action's page with these query parameters, and the table view opens its
4
- * creation form when it mounts. Carried by the URL rather than a window event
2
+ * How an `openForm` or `button` quick action reaches its table view: it routes
3
+ * to the action's page with these query parameters, and the table view opens
4
+ * its creation form, or presses the named button, when it mounts. Carried by the URL rather than a window event
5
5
  * so the action works from any page — the listener would otherwise have to
6
6
  * exist before the navigation that creates it.
7
7
  */
@@ -9,3 +9,38 @@ export const QUICK_ACTION_QUERY_KEY = "quickAction";
9
9
  export const QUICK_ACTION_ADD = "add";
10
10
  /** Which table view answers, when the page mounts more than one. */
11
11
  export const QUICK_ACTION_COMPONENT_KEY = "quickActionComponent";
12
+ export const QUICK_ACTION_BUTTON = "button";
13
+ /** Which of the table view's custom buttons a `button` quick action presses. */
14
+ export const QUICK_ACTION_BUTTON_KEY = "quickActionButton";
15
+
16
+ export type QuickActionIntent =
17
+ | { kind: "add" }
18
+ | { kind: "button"; button: string };
19
+
20
+ type QuickActionQuery = Record<string, unknown>;
21
+
22
+ const intentReaders: Record<
23
+ string,
24
+ (query: QuickActionQuery) => QuickActionIntent | undefined
25
+ > = {
26
+ [QUICK_ACTION_ADD]: () => ({ kind: "add" }),
27
+ [QUICK_ACTION_BUTTON]: (query) => {
28
+ const button = query[QUICK_ACTION_BUTTON_KEY];
29
+ return typeof button === "string" ? { kind: "button", button } : undefined;
30
+ },
31
+ };
32
+
33
+ /**
34
+ * What the route asks the table view `componentId` to run. A page may mount
35
+ * several table views, so the quick action always names the one it means —
36
+ * resolved server-side — and only that one answers.
37
+ */
38
+ export function readQuickActionIntent(
39
+ query: QuickActionQuery,
40
+ componentId: string | undefined,
41
+ ): QuickActionIntent | undefined {
42
+ if (query[QUICK_ACTION_COMPONENT_KEY] !== componentId) return undefined;
43
+ const action = query[QUICK_ACTION_QUERY_KEY];
44
+ if (typeof action !== "string") return undefined;
45
+ return intentReaders[action]?.(query);
46
+ }
@@ -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
+ });