@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,72 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ computed,
4
+ createApp,
5
+ defineComponent,
6
+ h,
7
+ inject,
8
+ onScopeDispose,
9
+ ref,
10
+ watch,
11
+ type App,
12
+ } from "vue";
13
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
14
+ import File from "../layers/dms-ui/app/components/form/components/File.vue";
15
+
16
+ const dropZone = defineComponent({
17
+ inheritAttrs: false,
18
+ setup:
19
+ (_, { attrs }) =>
20
+ () =>
21
+ h("span", String(attrs.label ?? "")),
22
+ });
23
+
24
+ function mountFile(multiple: boolean): { app: App; container: HTMLElement } {
25
+ const app = createApp({
26
+ render: () => h(File, { modelValue: null, multiple }),
27
+ });
28
+ app.config.globalProperties.$t = (key: string) => key;
29
+ app.component("UFileUpload", dropZone);
30
+ const container = document.createElement("div");
31
+ app.mount(container);
32
+ return { app, container };
33
+ }
34
+
35
+ beforeEach(() => {
36
+ vi.stubGlobal("ref", ref);
37
+ vi.stubGlobal("computed", computed);
38
+ vi.stubGlobal("watch", watch);
39
+ vi.stubGlobal("inject", inject);
40
+ vi.stubGlobal("onScopeDispose", onScopeDispose);
41
+ vi.stubGlobal("FORM_FIELD_LOADING_KEY", Symbol());
42
+ vi.stubGlobal("FORM_CONTENT_LANGUAGE_KEY", Symbol());
43
+ vi.stubGlobal("CONTENT_LANGUAGE_HEADER", "x-content-language");
44
+ vi.stubGlobal("useAuthFetch", () => ({ $authFetch: vi.fn() }));
45
+ vi.stubGlobal("useUploadWithProgress", () => ({
46
+ uploadWithProgress: vi.fn(),
47
+ }));
48
+ vi.stubGlobal("useFormField", () => ({
49
+ emitFormChange: vi.fn(),
50
+ color: ref(undefined),
51
+ highlight: ref(undefined),
52
+ ariaAttrs: ref(undefined),
53
+ }));
54
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
55
+ vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
56
+ });
57
+
58
+ afterEach(() => {
59
+ vi.unstubAllGlobals();
60
+ });
61
+
62
+ it("asks for a file in its drop zone", () => {
63
+ const { app, container } = mountFile(false);
64
+ expect(container.textContent).toBe("dms.form.file.drop_prompt");
65
+ app.unmount();
66
+ });
67
+
68
+ it("asks for files in the drop zone of a multiple field", () => {
69
+ const { app, container } = mountFile(true);
70
+ expect(container.textContent).toBe("dms.form.file.drop_prompt_multiple");
71
+ app.unmount();
72
+ });
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ computed,
5
+ createApp,
6
+ defineComponent,
7
+ h,
8
+ nextTick,
9
+ onMounted,
10
+ ref,
11
+ type App,
12
+ } from "vue";
13
+ import type { DmsFooterConfig } from "../layers/dms-core/shared/types/app-config";
14
+
15
+ let app: App | undefined;
16
+ let host: HTMLDivElement;
17
+ let footerConfig: DmsFooterConfig | undefined;
18
+
19
+ const SelectStub = defineComponent({
20
+ setup: () => () => h("select", { "data-locale-switcher": "" }),
21
+ });
22
+
23
+ async function mountFooter(): Promise<void> {
24
+ const { default: Footer } = await import(
25
+ "../layers/dms-layout/app/build/components/layout/Footer.vue"
26
+ );
27
+ host = document.createElement("div");
28
+ document.body.append(host);
29
+ app = createApp(Footer);
30
+ app.component("DmsLink", {
31
+ props: ["to"],
32
+ setup:
33
+ (props: { to: string }, { slots }) =>
34
+ () =>
35
+ h("a", { href: props.to }, slots.default?.()),
36
+ });
37
+ app.component("UIcon", { render: () => null });
38
+ app.component("USkeleton", { render: () => null });
39
+ app.component("USelect", SelectStub);
40
+ app.mount(host);
41
+ await nextTick();
42
+ }
43
+
44
+ function linkTexts(): string[] {
45
+ return [...host.querySelectorAll("a")]
46
+ .map((link) => link.textContent?.trim() ?? "")
47
+ .filter((text) => text.length > 0);
48
+ }
49
+
50
+ beforeEach(async () => {
51
+ vi.resetModules();
52
+ footerConfig = undefined;
53
+ vi.stubGlobal("ref", ref);
54
+ vi.stubGlobal("computed", computed);
55
+ vi.stubGlobal("onMounted", onMounted);
56
+ vi.stubGlobal(
57
+ "fetch",
58
+ vi.fn(async () => ({ ok: false })),
59
+ );
60
+ vi.stubGlobal("useI18n", () => ({
61
+ locale: ref("en"),
62
+ setLocale: vi.fn(),
63
+ t: (key: string) => key,
64
+ }));
65
+ vi.stubGlobal("useUniqueLocales", () => ({ uniqueLocales: ref([]) }));
66
+ vi.stubGlobal("useTranslation", () => ({
67
+ processI18n: (text: string) =>
68
+ text.startsWith("$") ? `t(${text.slice(1)})` : text,
69
+ }));
70
+ vi.stubGlobal("useDmsAppConfig", () => ({
71
+ branding: { footer: footerConfig },
72
+ }));
73
+ const footerLinks = await import(
74
+ "../layers/dms-layout/app/composables/useFooterLinks"
75
+ );
76
+ vi.stubGlobal("useFooterLinks", footerLinks.useFooterLinks);
77
+ vi.stubGlobal("registerFooterLink", footerLinks.registerFooterLink);
78
+ vi.stubGlobal("unregisterFooterLink", footerLinks.unregisterFooterLink);
79
+ });
80
+
81
+ afterEach(() => {
82
+ app?.unmount();
83
+ host?.remove();
84
+ vi.unstubAllGlobals();
85
+ });
86
+
87
+ describe("footer", () => {
88
+ it("shows the copyright, the Antelope links, the star and the language picker by default", async () => {
89
+ await mountFooter();
90
+ expect(host.textContent).toContain("© Antelope");
91
+ expect(linkTexts()).toEqual([
92
+ "empty_layout.footer.documentation",
93
+ "empty_layout.footer.website",
94
+ "empty_layout.footer.github_star",
95
+ ]);
96
+ expect(host.querySelector("[data-locale-switcher]")).not.toBeNull();
97
+ });
98
+
99
+ it("drops a link a layer unregisters, the Antelope ones included", async () => {
100
+ const { registerFooterLink, unregisterFooterLink } = await import(
101
+ "../layers/dms-layout/app/composables/useFooterLinks"
102
+ );
103
+ registerFooterLink({
104
+ id: "acme:status",
105
+ label: "Status",
106
+ to: "/status",
107
+ order: 30,
108
+ });
109
+ unregisterFooterLink("dms:documentation");
110
+ unregisterFooterLink("dms:website");
111
+ unregisterFooterLink("never-registered");
112
+ await mountFooter();
113
+ expect(linkTexts()).toEqual(["Status", "empty_layout.footer.github_star"]);
114
+ });
115
+
116
+ it("names the copyright holder, or drops it, the star and the picker", async () => {
117
+ footerConfig = { copyright: "$acme.name" };
118
+ await mountFooter();
119
+ expect(host.textContent).toContain("© t(acme.name)");
120
+ app?.unmount();
121
+ host.remove();
122
+ vi.mocked(fetch).mockClear();
123
+
124
+ footerConfig = {
125
+ copyright: false,
126
+ githubStar: false,
127
+ localeSwitcher: false,
128
+ };
129
+ await mountFooter();
130
+ expect(host.textContent).not.toContain("©");
131
+ expect(linkTexts()).not.toContain("empty_layout.footer.github_star");
132
+ expect(host.querySelector("[data-locale-switcher]")).toBeNull();
133
+ expect(fetch).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -13,6 +13,13 @@ describe("formShowsActions", () => {
13
13
  ).toBe(false);
14
14
  });
15
15
 
16
+ it("shows the submit of an action form with nothing to fill in", () => {
17
+ const action = { kind: "action", submitUrl: "/api/jobs/run" } as const;
18
+ expect(formShowsActions(action, [])).toBe(true);
19
+ expect(formShowsActions(action, [{ disabled: true }])).toBe(true);
20
+ expect(formShowsActions({ ...action, saveMode: "none" }, [])).toBe(false);
21
+ });
22
+
16
23
  it("hides them with saveMode none, whatever the form could submit", () => {
17
24
  expect(
18
25
  formShowsActions({ saveMode: "none", submitUrl: "/api/order/new" }, [{}]),
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { orderFormEntries } from "../layers/dms-ui/app/build/composables/form/formEntryOrder";
3
+ import type { FormFieldOrGroup } from "../layers/dms-ui/app/composables/form/types/field";
4
+
5
+ const field = (id: string) => ({
6
+ id,
7
+ component: { componentName: "dms-input-text" },
8
+ });
9
+
10
+ const group = (id: string, order?: number) => ({
11
+ id,
12
+ order,
13
+ fields: [field(`${id}Member`)],
14
+ });
15
+
16
+ const ids = (entries: FormFieldOrGroup[]) => entries.map((entry) => entry.id);
17
+
18
+ describe("orderFormEntries", () => {
19
+ it("moves the groups giving an order into their places, lowest first", () => {
20
+ const entries: FormFieldOrGroup[] = [
21
+ field("username"),
22
+ group("contact"),
23
+ field("bio"),
24
+ group("social", 2),
25
+ group("address", 1),
26
+ ];
27
+ expect(ids(orderFormEntries(entries))).toEqual([
28
+ "username",
29
+ "contact",
30
+ "bio",
31
+ "address",
32
+ "social",
33
+ ]);
34
+ });
35
+
36
+ it("keeps the declaration order of equal orders and of no order at all", () => {
37
+ const entries: FormFieldOrGroup[] = [
38
+ group("first", 1),
39
+ field("name"),
40
+ group("second", 1),
41
+ group("loose"),
42
+ ];
43
+ expect(ids(orderFormEntries(entries))).toEqual(ids(entries));
44
+ });
45
+ });
@@ -69,23 +69,28 @@ describe("saveBarState of an action form", () => {
69
69
  });
70
70
  });
71
71
 
72
- it("shows nothing on a page while untouched", () => {
73
- expect(action({}).isHidden).toBe(true);
72
+ it("shows its submit alone, ready, on a page while untouched", () => {
73
+ // A pre-filled form sends its defaults as they are.
74
+ expect(action({})).toEqual({
75
+ showsStatus: false,
76
+ showsSubmit: true,
77
+ isSubmitHeld: false,
78
+ isHidden: false,
79
+ });
74
80
  });
75
81
 
76
- it("offers Cancel beside its held submit in a drawer or a modal", () => {
77
- // The invite modal: empty, it still shows the way out and its "Invite".
82
+ it("offers Cancel beside its ready submit in a drawer or a modal", () => {
78
83
  expect(action({ cancellable: true })).toEqual({
79
84
  showsStatus: false,
80
85
  secondary: "cancel",
81
86
  showsSubmit: true,
82
- isSubmitHeld: true,
87
+ isSubmitHeld: false,
83
88
  isHidden: false,
84
89
  });
85
90
  });
86
91
 
87
92
  it("shows its submit alone, ready, when nothing in it can be changed", () => {
88
- const bar = action({ resettable: false });
93
+ const bar = action({ resettable: false, dirty: true });
89
94
  expect(bar.secondary).toBeUndefined();
90
95
  expect(bar).toMatchObject({
91
96
  showsSubmit: true,
@@ -4,7 +4,9 @@ import { renderToString } from "vue/server-renderer";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import FormLoadError from "../layers/dms-ui/app/build/components/form/FormLoadError.vue";
6
6
  import {
7
+ type FormLoadFailure,
7
8
  type FormLoadSource,
9
+ missingRecordFailure,
8
10
  trackFormRecordLoad,
9
11
  } from "../layers/dms-ui/app/build/composables/form/formRecordLoad";
10
12
 
@@ -72,6 +74,32 @@ describe("a form's record load", () => {
72
74
  });
73
75
  });
74
76
 
77
+ describe("a form about a record its address names none of", () => {
78
+ const FETCH_URL = "/api/tasks/get?id={{query.id}}";
79
+
80
+ it("reports the record missing while the id is not in the address", () => {
81
+ expect(
82
+ missingRecordFailure({ requiresRecord: true, fetchUrl: FETCH_URL }),
83
+ ).toBe("missing");
84
+ });
85
+
86
+ it("reports nothing once the address names the record", () => {
87
+ expect(
88
+ missingRecordFailure({
89
+ requiresRecord: true,
90
+ fetchUrl: FETCH_URL,
91
+ resolvedFetchUrl: "/api/tasks/get?id=1",
92
+ }),
93
+ ).toBe(null);
94
+ });
95
+
96
+ it("leaves a form that may open without a record to its defaults", () => {
97
+ // A create form seeded by `?duplicate=` when there is one.
98
+ expect(missingRecordFailure({ fetchUrl: FETCH_URL })).toBe(null);
99
+ expect(missingRecordFailure({ requiresRecord: true })).toBe(null);
100
+ });
101
+ });
102
+
75
103
  describe("the load error a form shows", () => {
76
104
  const Slot = defineComponent({
77
105
  props: {
@@ -91,7 +119,7 @@ describe("the load error a form shows", () => {
91
119
  ]),
92
120
  });
93
121
 
94
- async function render(failure: "forbidden" | "error"): Promise<string> {
122
+ async function render(failure: FormLoadFailure): Promise<string> {
95
123
  const app = createSSRApp({
96
124
  render: () => h(FormLoadError, { failure }),
97
125
  });
@@ -118,6 +146,13 @@ describe("the load error a form shows", () => {
118
146
  expect(html).toContain("dms.form.load_error.retry");
119
147
  });
120
148
 
149
+ it("says no entry is selected, with nothing to retry", async () => {
150
+ const html = await render("missing");
151
+ expect(html).toContain("dms.form.load_error.missing_title");
152
+ expect(html).toContain("dms.form.load_error.missing_description");
153
+ expect(html).not.toContain("dms.form.load_error.retry");
154
+ });
155
+
121
156
  it("has its texts in English and French", () => {
122
157
  for (const file of ["ui-en-GB.json", "ui-fr-FR.json"]) {
123
158
  const locale = JSON.parse(
@@ -131,6 +166,8 @@ describe("the load error a form shows", () => {
131
166
  "error_title",
132
167
  "forbidden_description",
133
168
  "forbidden_title",
169
+ "missing_description",
170
+ "missing_title",
134
171
  "retry",
135
172
  ]);
136
173
  }
@@ -87,6 +87,15 @@ describe("grid column template", () => {
87
87
  expect(gridColumnsTemplate(1, GAP, MIN)).toContain("(100% - 0 * 1rem)");
88
88
  });
89
89
 
90
+ it("reads a unitless gap or minimum as pixels, which calc() requires", () => {
91
+ const template = gridColumnsTemplate(2, "0", "160");
92
+ expect(template).toContain("(100% - 1 * 0px)");
93
+ expect(template).toContain("max(160px,");
94
+ expect(resolveColumnCount(template, 553, 0)).toBe(2);
95
+ const css = gridResponsiveStyles(gridScopeId("nested"), 2, "0", MIN);
96
+ expect(css).toContain(`calc(2 * ${MIN} + 1 * 0px)`);
97
+ });
98
+
90
99
  it("honours a caller-supplied minimum", () => {
91
100
  expect(gridColumnsTemplate(4, GAP, "120px")).toContain("120px");
92
101
  expect(
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  computed,
4
4
  createApp,
5
+ createSSRApp,
5
6
  defineComponent,
6
7
  h,
7
8
  nextTick,
@@ -10,6 +11,7 @@ import {
10
11
  type App,
11
12
  type Ref,
12
13
  } from "vue";
14
+ import { renderToString } from "vue/server-renderer";
13
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
16
  import DashboardHeader from "../layers/dms-layout/app/build/components/layout/DashboardHeader.vue";
15
17
  import {
@@ -97,18 +99,44 @@ function installRuntime(): void {
97
99
  );
98
100
  }
99
101
 
100
- function mountHeader(): HTMLElement {
101
- const host = document.createElement("div");
102
- document.body.append(host);
103
- app = createApp(DashboardHeader);
104
- app.component("UButton", ButtonStub);
102
+ function createHeaderApp(create: typeof createApp): App {
103
+ const header = create(DashboardHeader);
104
+ header.component("UButton", ButtonStub);
105
105
  ["UDashboardNavbar", "UTooltip", "UBreadcrumb", "UDropdownMenu"].forEach(
106
- (name) => app?.component(name, SlotsStub),
106
+ (name) => header.component(name, SlotsStub),
107
107
  );
108
+ return header;
109
+ }
110
+
111
+ async function mountHeader(): Promise<HTMLElement> {
112
+ const host = document.createElement("div");
113
+ document.body.append(host);
114
+ app = createHeaderApp(createApp);
115
+ app.mount(host);
116
+ await nextTick();
117
+ return host;
118
+ }
119
+
120
+ /**
121
+ * Renders the header on the "server", runs what only the client runs (a
122
+ * `.client` plugin), then hydrates the server markup.
123
+ */
124
+ async function hydrateHeader(runOnClient: () => void): Promise<HTMLElement> {
125
+ const host = document.createElement("div");
126
+ host.innerHTML = await renderToString(createHeaderApp(createSSRApp));
127
+ document.body.append(host);
128
+ runOnClient();
129
+ app = createHeaderApp(createSSRApp);
108
130
  app.mount(host);
109
131
  return host;
110
132
  }
111
133
 
134
+ function hydrationWarnings(spies: ReturnType<typeof vi.spyOn>[]): string[] {
135
+ return spies
136
+ .flatMap((spy) => spy.mock.calls.map((call) => String(call[0])))
137
+ .filter((message) => message.includes("Hydration"));
138
+ }
139
+
112
140
  function button(host: HTMLElement, label: string): HTMLButtonElement | null {
113
141
  return host.querySelector(`button[aria-label="${label}"]`);
114
142
  }
@@ -145,7 +173,7 @@ describe("header action buttons", () => {
145
173
  action("toggle", { onSelect: vi.fn(), isActive: () => isOn.value }),
146
174
  );
147
175
  registerHeaderAction(action("plain", { onSelect: vi.fn() }));
148
- const host = mountHeader();
176
+ const host = await mountHeader();
149
177
  const toggle = button(host, "toggle");
150
178
  expect(toggle?.dataset.color).toBe(IDLE_COLOR);
151
179
  expect(toggle?.getAttribute("aria-pressed")).toBe("false");
@@ -160,7 +188,7 @@ describe("header action buttons", () => {
160
188
  registerHeaderAction(
161
189
  action("panel", { label: "$panel.toggle", sidePanelId: PANEL_ID }),
162
190
  );
163
- const host = mountHeader();
191
+ const host = await mountHeader();
164
192
  const toggle = button(host, "t:panel.toggle");
165
193
  const { isOpen } = useSidePanel(PANEL_ID);
166
194
  toggle?.click();
@@ -182,10 +210,28 @@ describe("header action buttons", () => {
182
210
  isActive: () => true,
183
211
  }),
184
212
  );
185
- const host = mountHeader();
213
+ const host = await mountHeader();
186
214
  const builder = button(host, "Edit");
187
215
  builder?.click();
188
216
  expect(onSelect).toHaveBeenCalledOnce();
189
217
  expect(builder?.dataset.color).toBe(ACTIVE_COLOR);
190
218
  });
191
219
  });
220
+
221
+ describe("header action hydration", () => {
222
+ it("draws a client-only action once mounted, not over the server markup", async () => {
223
+ const spies = [
224
+ vi.spyOn(console, "warn").mockImplementation(() => undefined),
225
+ vi.spyOn(console, "error").mockImplementation(() => undefined),
226
+ ];
227
+ registerHeaderAction(action("panel", { sidePanelId: PANEL_ID }));
228
+ const host = await hydrateHeader(() =>
229
+ registerHeaderAction(action("client", { onSelect: vi.fn() })),
230
+ );
231
+ expect(hydrationWarnings(spies)).toEqual([]);
232
+ expect(button(host, "panel")).not.toBeNull();
233
+ await nextTick();
234
+ expect(button(host, "client")).not.toBeNull();
235
+ spies.forEach((spy) => spy.mockRestore());
236
+ });
237
+ });
@@ -51,13 +51,20 @@ afterEach(() => {
51
51
  });
52
52
 
53
53
  describe("DmsInstantSaveBadge", () => {
54
- it("reads exactly 'Saved instantly' in en-GB, whatever the save state", async () => {
54
+ it("says what the save state is, in en-GB", async () => {
55
55
  const { default: InstantSaveBadge } = await import(
56
56
  "../layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue"
57
57
  );
58
- for (const state of [undefined, "idle", "saving", "saved"] as const) {
58
+ const labels = [
59
+ [undefined, "Saved instantly"],
60
+ ["idle", "Saved instantly"],
61
+ ["saving", "Saving"],
62
+ ["saved", "Saved instantly"],
63
+ ["error", "Not saved"],
64
+ ] as const;
65
+ for (const [state, label] of labels) {
59
66
  await mount({ render: () => h(InstantSaveBadge, { state }) });
60
- expect(badge()?.textContent?.trim()).toBe("Saved instantly");
67
+ expect(badge()?.textContent?.trim()).toBe(label);
61
68
  expect(badge()?.dataset.state).toBe(state ?? "idle");
62
69
  expect(badge()?.title).toBeTruthy();
63
70
  app?.unmount();
@@ -82,7 +89,7 @@ describe("DmsInstantSaveBadge", () => {
82
89
  }
83
90
  });
84
91
 
85
- it("uses one key, translated in French", async () => {
92
+ it("is translated in French", async () => {
86
93
  vi.stubGlobal("useI18n", () => ({ t: translator(uiFr) }));
87
94
  const { default: InstantSaveBadge } = await import(
88
95
  "../layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue"
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createApp, h, nextTick, type App } from "vue";
4
+ import ListRow from "../layers/dms-ui/app/components/list-row/ListRow.vue";
5
+
6
+ const LONG_TITLE = "Safari on iPhone 15 with a very long device name";
7
+
8
+ let app: App | undefined;
9
+ let host: HTMLDivElement;
10
+
11
+ async function mount(truncate: boolean) {
12
+ app = createApp({ render: () => h(ListRow, { title: LONG_TITLE, truncate }) });
13
+ app.mount(host);
14
+ await nextTick();
15
+ }
16
+
17
+ const titleText = () =>
18
+ [...host.querySelectorAll("span")].find(
19
+ (span) => span.textContent?.trim() === LONG_TITLE,
20
+ );
21
+
22
+ beforeEach(() => {
23
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
24
+ host = document.createElement("div");
25
+ document.body.append(host);
26
+ });
27
+
28
+ afterEach(() => {
29
+ app?.unmount();
30
+ app = undefined;
31
+ host.remove();
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ describe("DmsListRow truncate", () => {
36
+ it("puts the ellipsis on the title text, not on its flex container", async () => {
37
+ await mount(true);
38
+ expect(titleText()?.classList.contains("truncate")).toBe(true);
39
+ expect(titleText()?.parentElement?.title).toBe(LONG_TITLE);
40
+ });
41
+
42
+ it("leaves the title text alone without truncate", async () => {
43
+ await mount(false);
44
+ expect(titleText()?.classList.contains("truncate")).toBe(false);
45
+ });
46
+ });
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ import { interpolateUrl } from "../layers/dms-core/app/utils/url-interpolation";
2
3
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
3
4
  import {
4
5
  type App,
@@ -203,9 +204,7 @@ beforeEach(() => {
203
204
  vi.stubGlobal("useToast", () => ({ add: addToast }));
204
205
  vi.stubGlobal("useConfirm", () => ({ confirm }));
205
206
  vi.stubGlobal("useAuthFetch", () => ({ $authFetch: authFetch }));
206
- vi.stubGlobal("interpolateUrl", (url: string, data: Record<string, string>) =>
207
- url.replace(/\{(\w+)\}/g, (_, key: string) => data[key] ?? ""),
208
- );
207
+ vi.stubGlobal("interpolateUrl", interpolateUrl);
209
208
  vi.stubGlobal("useTranslation", () => ({
210
209
  processApiMessage: (message: string) => `t(${message})`,
211
210
  }));
@@ -61,7 +61,7 @@ afterEach(() => {
61
61
  vi.unstubAllGlobals();
62
62
  });
63
63
 
64
- it("shows an empty modal action form's way out and its held submit", async () => {
64
+ it("shows a modal action form's way out and its ready submit", async () => {
65
65
  const { onCancel } = await mount({
66
66
  variant: "band",
67
67
  kind: "action",
@@ -73,12 +73,24 @@ it("shows an empty modal action form's way out and its held submit", async () =>
73
73
  expect(host.firstElementChild!.getAttribute("aria-hidden")).toBeNull();
74
74
  expect(buttons()).toEqual([
75
75
  { label: "dms.button.cancel", disabled: false },
76
- { label: "Invite", disabled: true },
76
+ { label: "Invite", disabled: false },
77
77
  ]);
78
78
  host.querySelector("button")!.click();
79
79
  expect(onCancel).toHaveBeenCalledOnce();
80
80
  });
81
81
 
82
+ it("sends an untouched page action form as it is, its submit alone", async () => {
83
+ await mount({
84
+ variant: "band",
85
+ kind: "action",
86
+ dirty: false,
87
+ saveLabel: "Send",
88
+ });
89
+
90
+ expect(host.firstElementChild!.getAttribute("aria-hidden")).toBeNull();
91
+ expect(buttons()).toEqual([{ label: "Send", disabled: false }]);
92
+ });
93
+
82
94
  it("resets an action form once a value changed, and sends it", async () => {
83
95
  await mount({
84
96
  variant: "band",
@@ -146,7 +158,7 @@ it("gives Cancel and Unsaved changes the same fixed height", async () => {
146
158
  });
147
159
 
148
160
  it("keeps a hidden band's place in its footer", async () => {
149
- await mount({ variant: "band", kind: "action", dirty: false });
161
+ await mount({ variant: "band", kind: "record", dirty: false });
150
162
 
151
163
  expect(anchor().getAttribute("aria-hidden")).toBe("true");
152
164
  expect(classesOf(anchor())).toContain("invisible");
@@ -56,11 +56,11 @@ describe("DmsSaveStatus: a failed save", () => {
56
56
  expect(onRetry).toHaveBeenCalledOnce();
57
57
  });
58
58
 
59
- it("turns the header pill red, its wording unchanged", async () => {
59
+ it("turns the header pill red and says it was not saved", async () => {
60
60
  await mount(() => h(InstantSaveBadge, { state: "error" }));
61
61
  const pill = host.querySelector<HTMLElement>("[data-instant-save-badge]")!;
62
62
 
63
- expect(pill.textContent?.trim()).toBe("Saved instantly");
63
+ expect(pill.textContent?.trim()).toBe("Not saved");
64
64
  expect(pill.className).toContain("text-error");
65
65
  });
66
66
  });