@antelopejs/dms 0.7.5 → 0.7.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.
- package/CHANGELOG.md +21 -0
- package/dist/implementations/dms-base/export-jobs.d.ts +4 -1
- package/dist/implementations/dms-base/export-jobs.js +10 -6
- package/dist/implementations/dms-base/export-jobs.js.map +1 -1
- package/dist/implementations/dms-base/search-route.js +20 -11
- package/dist/implementations/dms-base/search-route.js.map +1 -1
- package/dist/test/attachment-host/capabilities.d.ts +2 -0
- package/dist/test/attachment-host/capabilities.js +11 -0
- package/dist/test/attachment-host/capabilities.js.map +1 -1
- package/dist/test/integration/table-view-capabilities.test.js +23 -5
- package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +29 -0
- package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +14 -0
- package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js +49 -0
- package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js +105 -0
- package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js.map +1 -0
- package/dist/test/unit/request-tenant-guards.test.js +38 -0
- package/dist/test/unit/request-tenant-guards.test.js.map +1 -1
- package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +9 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +6 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +6 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/CommandPaletteAssistantAnswer.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageSkeleton.vue +13 -4
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RolePreviewBar.vue +9 -3
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +5 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSummaryRow.vue +4 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/RoleEditor.vue +1 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +4 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -1
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +19 -4
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/AppearanceDeveloper.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/CssVariablesList.vue +6 -3
- package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +20 -5
- package/frontend-vue/layers/dms-ui/app/build/components/chart/internal/ChartSkeleton.vue +4 -0
- package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +10 -2
- package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +7 -2
- package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +87 -40
- package/frontend-vue/layers/dms-ui/app/build/components/skeleton/RowSkeleton.vue +8 -1
- package/frontend-vue/layers/dms-ui/app/build/components/stat/StatCell.vue +54 -13
- package/frontend-vue/layers/dms-ui/app/build/components/table/Pagination.vue +9 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table/Tabs.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table/Views.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue +22 -5
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/ExpandedRowDetail.vue +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +3 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +3 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/form/formSections.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +20 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/characterCount.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +4 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +3 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +13 -5
- package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +7 -1
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +10 -3
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +8 -0
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +4 -3
- package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +6 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/Display.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +27 -3
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputText.vue +24 -2
- package/frontend-vue/layers/dms-ui/app/components/form/components/RichText.vue +13 -4
- package/frontend-vue/layers/dms-ui/app/components/form/components/Textarea.vue +27 -2
- package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +8 -4
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -4
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +15 -4
- package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +69 -42
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +15 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -3
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +45 -9
- package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +5 -0
- package/frontend-vue/tests/confirm-dialog-fields.test.ts +12 -0
- package/frontend-vue/tests/drawer-target-direction.test.ts +1 -0
- package/frontend-vue/tests/field-suffix-count.test.ts +120 -0
- package/frontend-vue/tests/form-section-nav-groups.test.ts +116 -0
- package/frontend-vue/tests/nav-card-badge.test.ts +5 -1
- package/frontend-vue/tests/nuxt-ui-aria-labels.test.ts +58 -0
- package/frontend-vue/tests/page-fill-height.test.ts +2 -2
- package/frontend-vue/tests/save-bar.test.ts +49 -0
- package/frontend-vue/tests/stat-group-detail-lines.test.ts +63 -0
- package/frontend-vue/tests/tab-fit.test.ts +99 -0
- package/frontend-vue/tests/table-cell-sublines.test.ts +15 -0
- package/frontend-vue/tests/table-cell-wrap.test.ts +24 -2
- package/frontend-vue/tests/table-row-action-errors.test.ts +26 -0
- package/frontend-vue/tests/top-list-card-actions.test.ts +91 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +11 -1
|
@@ -3,6 +3,9 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
|
|
|
3
3
|
import { useMounted } from "@vueuse/core";
|
|
4
4
|
import DmsEmptyState from "../empty-state/EmptyState.vue";
|
|
5
5
|
import DmsSectionHeader from "../section-header/SectionHeader.vue";
|
|
6
|
+
import DmsBlockActions, {
|
|
7
|
+
type BlockAction,
|
|
8
|
+
} from "../../build/components/blocks/BlockActions.vue";
|
|
6
9
|
// Imported rather than resolved from the registry: a registered component is
|
|
7
10
|
// a lazy chunk of its own, and a list whose rows only arrive with its data
|
|
8
11
|
// would draw an empty card for the moment that chunk takes.
|
|
@@ -42,6 +45,8 @@ interface Props extends DefaultComponentProps {
|
|
|
42
45
|
maxHeight?: string;
|
|
43
46
|
staticItems?: TopListItem[];
|
|
44
47
|
emptyLabel?: string;
|
|
48
|
+
/** Link buttons in the card head, after the period badge. */
|
|
49
|
+
actions?: BlockAction[];
|
|
45
50
|
/**
|
|
46
51
|
* Placeholder rows while `fetchUrl` loads: the "top N" it answers.
|
|
47
52
|
* Optional. Defaults to 10, enough to fill the default `maxHeight`.
|
|
@@ -90,6 +95,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
90
95
|
currencyCode: "EUR",
|
|
91
96
|
maxHeight: DEFAULT_MAX_HEIGHT,
|
|
92
97
|
skeletonCount: SKELETON_ROW_COUNT,
|
|
98
|
+
actions: () => [],
|
|
93
99
|
});
|
|
94
100
|
|
|
95
101
|
const { locale, t } = useI18n();
|
|
@@ -247,11 +253,33 @@ const gridStyle = computed(() => ({
|
|
|
247
253
|
:title="processI18n(title)"
|
|
248
254
|
:description="description ? processI18n(description) : undefined"
|
|
249
255
|
>
|
|
250
|
-
<template v-if="periodScope" #trailing>
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
256
|
+
<template v-if="periodScope || props.actions.length" #trailing>
|
|
257
|
+
<template v-if="periodScope">
|
|
258
|
+
<UBadge
|
|
259
|
+
v-if="badgeLabel"
|
|
260
|
+
:color="badgeColor"
|
|
261
|
+
variant="soft"
|
|
262
|
+
size="sm"
|
|
263
|
+
>
|
|
264
|
+
{{ badgeLabel }}
|
|
265
|
+
</UBadge>
|
|
266
|
+
<USkeleton
|
|
267
|
+
:aria-label="t('dms.a11y.loading')"
|
|
268
|
+
v-else
|
|
269
|
+
class="h-5 w-16 rounded-[5px]"
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
/>
|
|
272
|
+
</template>
|
|
273
|
+
<!-- Quiet links, as in an ActivityFeed head: the rows stay the
|
|
274
|
+
card's content. -->
|
|
275
|
+
<DmsBlockActions
|
|
276
|
+
v-if="props.actions.length"
|
|
277
|
+
:actions="props.actions"
|
|
278
|
+
size="xs"
|
|
279
|
+
lead-variant="ghost"
|
|
280
|
+
lead-color="neutral"
|
|
281
|
+
rest-variant="ghost"
|
|
282
|
+
/>
|
|
255
283
|
</template>
|
|
256
284
|
</DmsSectionHeader>
|
|
257
285
|
|
|
@@ -308,15 +336,23 @@ const gridStyle = computed(() => ({
|
|
|
308
336
|
class="flex h-14 items-center gap-3 px-[18px]"
|
|
309
337
|
>
|
|
310
338
|
<USkeleton
|
|
339
|
+
:aria-label="t('dms.a11y.loading')"
|
|
311
340
|
v-if="showRank"
|
|
312
341
|
class="size-[26px] shrink-0 rounded-[7px]"
|
|
313
342
|
/>
|
|
314
343
|
<div class="grid flex-1 gap-1.5">
|
|
315
|
-
<USkeleton class="h-3 w-2/5" />
|
|
316
|
-
<USkeleton
|
|
344
|
+
<USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-2/5" />
|
|
345
|
+
<USkeleton
|
|
346
|
+
:aria-label="t('dms.a11y.loading')"
|
|
347
|
+
class="h-2.5 w-1/4"
|
|
348
|
+
/>
|
|
317
349
|
</div>
|
|
318
|
-
<USkeleton class="h-3 w-14" />
|
|
319
|
-
<USkeleton
|
|
350
|
+
<USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-14" />
|
|
351
|
+
<USkeleton
|
|
352
|
+
:aria-label="t('dms.a11y.loading')"
|
|
353
|
+
v-if="showDelta"
|
|
354
|
+
class="h-3 w-[46px]"
|
|
355
|
+
/>
|
|
320
356
|
</div>
|
|
321
357
|
</div>
|
|
322
358
|
</div>
|
|
@@ -31,6 +31,11 @@ export interface TabProps extends TabComponentProps {
|
|
|
31
31
|
badgesUrl?: string;
|
|
32
32
|
/** Topics whose events make the tab set read `badgesUrl` again. */
|
|
33
33
|
realtimeTopic?: string | string[];
|
|
34
|
+
/**
|
|
35
|
+
* A horizontal `pill` list as wide as its tabs, not the full width; a
|
|
36
|
+
* `link` list already sizes its tabs to their labels.
|
|
37
|
+
*/
|
|
38
|
+
fit?: boolean;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** The badges `badgesUrl` answers with, by tab slot. */
|
|
@@ -231,6 +231,18 @@ describe("ConfirmModal fields", () => {
|
|
|
231
231
|
expect(closed).toHaveBeenCalledWith(true);
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
it("says what the required mark means under the fields", async () => {
|
|
235
|
+
mount({ title: "Delete?", fields: [reassign] });
|
|
236
|
+
await flush();
|
|
237
|
+
expect(host.textContent).toContain("dms.form.required_legend");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("draws no legend when no field is required", async () => {
|
|
241
|
+
mount({ title: "Delete?", fields: [{ ...reassign, required: false }] });
|
|
242
|
+
await flush();
|
|
243
|
+
expect(host.textContent).not.toContain("dms.form.required_legend");
|
|
244
|
+
});
|
|
245
|
+
|
|
234
246
|
it("flags a required field left empty instead of confirming", async () => {
|
|
235
247
|
const onConfirm = vi.fn();
|
|
236
248
|
mount({ title: "Delete?", fields: [reassign], onConfirm });
|
|
@@ -184,6 +184,7 @@ describe("the drawer container", () => {
|
|
|
184
184
|
clearGuards: vi.fn(),
|
|
185
185
|
}));
|
|
186
186
|
vi.stubGlobal("useDmsRoute", () => reactive({ path: "/" }));
|
|
187
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
187
188
|
});
|
|
188
189
|
|
|
189
190
|
it("slides in from the bottom, full width, by default", async () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A number field writes its unit after the box ("30 days"), and a text field
|
|
4
|
+
* or a text area asked for it counts the characters typed, out of its
|
|
5
|
+
* `maxLength` ("4 / 60"). Without either option, the control is unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
type App,
|
|
10
|
+
type Component,
|
|
11
|
+
createApp,
|
|
12
|
+
defineComponent,
|
|
13
|
+
h,
|
|
14
|
+
nextTick,
|
|
15
|
+
} from "vue";
|
|
16
|
+
import { characterCountText } from "../layers/dms-ui/app/build/utils/characterCount";
|
|
17
|
+
|
|
18
|
+
let app: App | undefined;
|
|
19
|
+
let host: HTMLDivElement;
|
|
20
|
+
|
|
21
|
+
const Control = (tag: string) =>
|
|
22
|
+
defineComponent({
|
|
23
|
+
inheritAttrs: false,
|
|
24
|
+
setup:
|
|
25
|
+
(_, { slots, attrs }) =>
|
|
26
|
+
() =>
|
|
27
|
+
h("div", { "data-control": tag }, [
|
|
28
|
+
h(tag, { ...attrs }),
|
|
29
|
+
slots.trailing?.(),
|
|
30
|
+
]),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function mount(component: Component, props: Record<string, unknown>) {
|
|
34
|
+
app = createApp({ render: () => h(component, props) });
|
|
35
|
+
app.component("UInput", Control("input"));
|
|
36
|
+
app.component("UTextarea", Control("textarea"));
|
|
37
|
+
app.component("UInputNumber", Control("input"));
|
|
38
|
+
app.mount(host);
|
|
39
|
+
await nextTick();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const load = async (name: string) =>
|
|
43
|
+
(await import(`../layers/dms-ui/app/components/form/components/${name}.vue`))
|
|
44
|
+
.default as Component;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
48
|
+
processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
|
|
49
|
+
}));
|
|
50
|
+
host = document.createElement("div");
|
|
51
|
+
document.body.append(host);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
app?.unmount();
|
|
56
|
+
host.remove();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("characterCountText", () => {
|
|
61
|
+
it("counts out of the limit, or alone", () => {
|
|
62
|
+
expect(characterCountText("Acme", 60)).toBe("4 / 60");
|
|
63
|
+
expect(characterCountText("Acme")).toBe("4");
|
|
64
|
+
expect(characterCountText(undefined, 60)).toBe("0 / 60");
|
|
65
|
+
expect(characterCountText("👋🏽", 10)).toBe("2 / 10");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("a text field's counter", () => {
|
|
70
|
+
it("shows the count in the field when asked", async () => {
|
|
71
|
+
await mount(await load("InputText"), {
|
|
72
|
+
modelValue: "Acme",
|
|
73
|
+
maxLength: 60,
|
|
74
|
+
showCount: true,
|
|
75
|
+
});
|
|
76
|
+
expect(
|
|
77
|
+
host.querySelector("[data-character-count]")?.textContent?.trim(),
|
|
78
|
+
).toBe("4 / 60");
|
|
79
|
+
const input = host.querySelector("input")!;
|
|
80
|
+
expect(input.getAttribute("maxlength")).toBe("60");
|
|
81
|
+
expect(input.hasAttribute("showcount")).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("draws no counter by default", async () => {
|
|
85
|
+
await mount(await load("InputText"), { modelValue: "Acme", maxLength: 60 });
|
|
86
|
+
expect(host.querySelector("[data-character-count]")).toBeNull();
|
|
87
|
+
expect(host.querySelector("input")!.getAttribute("maxlength")).toBe("60");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("counts under a text area", async () => {
|
|
91
|
+
await mount(await load("Textarea"), {
|
|
92
|
+
modelValue: "Hello",
|
|
93
|
+
maxLength: 280,
|
|
94
|
+
showCount: true,
|
|
95
|
+
});
|
|
96
|
+
expect(
|
|
97
|
+
host.querySelector("[data-character-count]")?.textContent?.trim(),
|
|
98
|
+
).toBe("5 / 280");
|
|
99
|
+
expect(host.querySelector("textarea")!.hasAttribute("showcount")).toBe(
|
|
100
|
+
false,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("a number field's unit", () => {
|
|
106
|
+
it("writes the unit after the field, translated", async () => {
|
|
107
|
+
await mount(await load("InputNumber"), {
|
|
108
|
+
modelValue: 30,
|
|
109
|
+
suffix: "$billing.days",
|
|
110
|
+
});
|
|
111
|
+
const suffix = host.querySelector("[data-field-suffix]");
|
|
112
|
+
expect(suffix?.textContent?.trim()).toBe("t($billing.days)");
|
|
113
|
+
expect(host.querySelector("input")!.hasAttribute("suffix")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("draws no unit by default", async () => {
|
|
117
|
+
await mount(await load("InputNumber"), { modelValue: 30 });
|
|
118
|
+
expect(host.querySelector("[data-field-suffix]")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A long sectioned form groups its side navigation under headings: the
|
|
4
|
+
* sections naming the same `navGroup` one after the other share one. The
|
|
5
|
+
* row of chips lists every section without them.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { type App, createApp, defineComponent, h, nextTick } from "vue";
|
|
9
|
+
import {
|
|
10
|
+
groupSectionsForNav,
|
|
11
|
+
layoutSections,
|
|
12
|
+
} from "../layers/dms-ui/app/build/composables/form/formSections";
|
|
13
|
+
import FormSectionNav from "../layers/dms-ui/app/build/components/form/FormSectionNav.vue";
|
|
14
|
+
|
|
15
|
+
const field = (id: string) => ({
|
|
16
|
+
id,
|
|
17
|
+
component: { componentName: "dms-input-text" },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const SECTIONS = [
|
|
21
|
+
{ id: "plan", label: "Plan", fieldIds: ["plan"] },
|
|
22
|
+
{
|
|
23
|
+
id: "dunning",
|
|
24
|
+
label: "Dunning",
|
|
25
|
+
navGroup: "$billing",
|
|
26
|
+
fieldIds: ["dunning"],
|
|
27
|
+
},
|
|
28
|
+
{ id: "tax", label: "Tax", navGroup: "$billing", fieldIds: ["tax"] },
|
|
29
|
+
{ id: "terms", label: "Terms", navGroup: "Legal", fieldIds: ["terms"] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const layout = layoutSections(
|
|
33
|
+
["plan", "dunning", "tax", "terms"].map(field),
|
|
34
|
+
SECTIONS,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
let app: App | undefined;
|
|
38
|
+
let host: HTMLDivElement;
|
|
39
|
+
|
|
40
|
+
async function mount(variant: "list" | "chips") {
|
|
41
|
+
app = createApp({
|
|
42
|
+
render: () =>
|
|
43
|
+
h(FormSectionNav, {
|
|
44
|
+
variant,
|
|
45
|
+
sections: layout.sections,
|
|
46
|
+
states: {},
|
|
47
|
+
anchorId: (id: string) => `form-${id}`,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
app.component("UIcon", defineComponent({ render: () => h("i") }));
|
|
51
|
+
app.mount(host);
|
|
52
|
+
await nextTick();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
57
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
58
|
+
processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
|
|
59
|
+
}));
|
|
60
|
+
host = document.createElement("div");
|
|
61
|
+
document.body.append(host);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
app?.unmount();
|
|
66
|
+
host.remove();
|
|
67
|
+
vi.unstubAllGlobals();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("groupSectionsForNav", () => {
|
|
71
|
+
it("cuts the sections wherever their heading changes", () => {
|
|
72
|
+
expect(
|
|
73
|
+
groupSectionsForNav(layout.sections).map((group) => [
|
|
74
|
+
group.label,
|
|
75
|
+
group.sections.map((section) => section.id),
|
|
76
|
+
]),
|
|
77
|
+
).toEqual([
|
|
78
|
+
[undefined, ["plan"]],
|
|
79
|
+
["$billing", ["dunning", "tax"]],
|
|
80
|
+
["Legal", ["terms"]],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("keeps a form without groups in one run", () => {
|
|
85
|
+
const plain = layoutSections(
|
|
86
|
+
[field("a"), field("b")],
|
|
87
|
+
[
|
|
88
|
+
{ id: "a", label: "A", fieldIds: ["a"] },
|
|
89
|
+
{ id: "b", label: "B", fieldIds: ["b"] },
|
|
90
|
+
],
|
|
91
|
+
);
|
|
92
|
+
expect(groupSectionsForNav(plain.sections)).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("the side navigation", () => {
|
|
97
|
+
it("titles each run, and names its list by it", async () => {
|
|
98
|
+
await mount("list");
|
|
99
|
+
const lists = [...host.querySelectorAll("ul")];
|
|
100
|
+
expect(lists).toHaveLength(3);
|
|
101
|
+
expect(lists[0]!.hasAttribute("aria-labelledby")).toBe(false);
|
|
102
|
+
const headings = lists.slice(1).map((list) => {
|
|
103
|
+
const id = list.getAttribute("aria-labelledby")!;
|
|
104
|
+
return document.getElementById(id)?.textContent?.trim();
|
|
105
|
+
});
|
|
106
|
+
expect(headings).toEqual(["t($billing)", "Legal"]);
|
|
107
|
+
expect(lists[1]!.querySelectorAll("li")).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("lists the chips in one row, without headings", async () => {
|
|
111
|
+
await mount("chips");
|
|
112
|
+
expect(host.querySelectorAll("ul")).toHaveLength(1);
|
|
113
|
+
expect(host.querySelectorAll("li")).toHaveLength(4);
|
|
114
|
+
expect(host.querySelector("[aria-labelledby]")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { createApp, defineComponent, h, type App } from "vue";
|
|
3
|
-
import { afterEach, expect, it } from "vitest";
|
|
3
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
4
4
|
import NavCard from "../layers/dms-ui/app/components/card/NavCard.vue";
|
|
5
5
|
|
|
6
6
|
// A nav card of the settings overview carries its page's navigation badge in
|
|
@@ -39,6 +39,10 @@ function mountCard(props: Record<string, unknown>): HTMLElement {
|
|
|
39
39
|
const headRow = (container: HTMLElement) =>
|
|
40
40
|
container.querySelector("h3")?.parentElement as HTMLElement;
|
|
41
41
|
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
|
|
44
|
+
});
|
|
45
|
+
|
|
42
46
|
afterEach(() => {
|
|
43
47
|
app?.unmount();
|
|
44
48
|
app = undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import coreEn from "../layers/dms-core/i18n/locales/core-en-GB.json";
|
|
5
|
+
import coreFr from "../layers/dms-core/i18n/locales/core-fr-FR.json";
|
|
6
|
+
|
|
7
|
+
// Nuxt UI hard-codes these labels in English, and its locales do not carry
|
|
8
|
+
// them: each use has to bind its own, translated.
|
|
9
|
+
const LABELLED_TAGS = {
|
|
10
|
+
UBreadcrumb: "dms.a11y.breadcrumb",
|
|
11
|
+
USkeleton: "dms.a11y.loading",
|
|
12
|
+
} as const;
|
|
13
|
+
const LOCALES = { "en-GB": coreEn, "fr-FR": coreFr };
|
|
14
|
+
|
|
15
|
+
function walk(directory: string): string[] {
|
|
16
|
+
return readdirSync(directory).flatMap((name) => {
|
|
17
|
+
const path = join(directory, name);
|
|
18
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function lookup(messages: unknown, key: string): unknown {
|
|
23
|
+
return key
|
|
24
|
+
.split(".")
|
|
25
|
+
.reduce<unknown>(
|
|
26
|
+
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
|
|
27
|
+
messages,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("Nuxt UI hard-coded aria-labels", () => {
|
|
32
|
+
const sources = walk(join(process.cwd(), "layers"))
|
|
33
|
+
.filter((path) => path.endsWith(".vue"))
|
|
34
|
+
.map((path) => ({
|
|
35
|
+
path: relative(process.cwd(), path),
|
|
36
|
+
text: readFileSync(path, "utf8"),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
for (const [tag, key] of Object.entries(LABELLED_TAGS)) {
|
|
40
|
+
it(`binds a translated aria-label on every ${tag}`, () => {
|
|
41
|
+
const tagPattern = new RegExp(`<${tag}\\b[^>]*?>`, "gs");
|
|
42
|
+
const offenders = sources.flatMap(({ path, text }) =>
|
|
43
|
+
[...text.matchAll(tagPattern)]
|
|
44
|
+
.filter(([found]) => !found.includes(`:aria-label="t('${key}')"`))
|
|
45
|
+
.map(() => path),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(offenders).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it(`translates ${key} in every locale`, () => {
|
|
52
|
+
for (const messages of Object.values(LOCALES)) {
|
|
53
|
+
expect(lookup(messages, key)).toBeTypeOf("string");
|
|
54
|
+
}
|
|
55
|
+
expect(lookup(coreFr, key)).not.toBe(lookup(coreEn, key));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -68,11 +68,11 @@ const pageLayout = ref<PageLayoutFixture | null>(null);
|
|
|
68
68
|
// is pending after a client navigation, once a short delay passes).
|
|
69
69
|
const PAGE_SKELETON_MARKUP =
|
|
70
70
|
'<div aria-hidden="true" class="dms-page-skeleton space-y-6 hidden [html[data-dms-role-preview=pending]_&]:block"><!--v-if-->' +
|
|
71
|
-
'<div class="dms-card space-y-4 p-[18px]"><USkeleton class="h-3.5 w-40"></USkeleton><!--[-->' +
|
|
71
|
+
'<div class="dms-card space-y-4 p-[18px]"><USkeleton aria-label="dms.a11y.loading" class="h-3.5 w-40"></USkeleton><!--[-->' +
|
|
72
72
|
["72%", "58%", "66%", "44%"]
|
|
73
73
|
.map(
|
|
74
74
|
(width) =>
|
|
75
|
-
`<USkeleton class="h-2.5" style="width:${width};"></USkeleton>`,
|
|
75
|
+
`<USkeleton aria-label="dms.a11y.loading" class="h-2.5" style="width:${width};"></USkeleton>`,
|
|
76
76
|
)
|
|
77
77
|
.join("") +
|
|
78
78
|
"<!--]--></div></div>";
|
|
@@ -104,3 +104,52 @@ it("lists a record form's unsaved changes beside Discard and Save", async () =>
|
|
|
104
104
|
"dms.save_bar.save",
|
|
105
105
|
]);
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
const classesOf = (element: Element | null | undefined) =>
|
|
109
|
+
(element?.className ?? "").split(/\s+/);
|
|
110
|
+
const anchor = () => host.firstElementChild!;
|
|
111
|
+
const bar = () => anchor().firstElementChild!;
|
|
112
|
+
|
|
113
|
+
it("takes no space while a clean page form's bar hides", async () => {
|
|
114
|
+
await mount({ variant: "floating", dirty: false });
|
|
115
|
+
|
|
116
|
+
expect(anchor().getAttribute("aria-hidden")).toBe("true");
|
|
117
|
+
expect(classesOf(anchor())).toEqual(
|
|
118
|
+
expect.arrayContaining(["sticky", "h-0"]),
|
|
119
|
+
);
|
|
120
|
+
expect(classesOf(bar())).toEqual(
|
|
121
|
+
expect.arrayContaining(["absolute", "invisible"]),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("floats over the form when it shows, making room under the last field", async () => {
|
|
126
|
+
await mount({ variant: "floating", dirty: true, changes: ["Name"] });
|
|
127
|
+
|
|
128
|
+
expect(anchor().getAttribute("aria-hidden")).toBeNull();
|
|
129
|
+
expect(classesOf(anchor())).not.toContain("h-0");
|
|
130
|
+
expect(classesOf(anchor())).toContain("h-[calc(var(--dms-save-bar-h)+2rem)]");
|
|
131
|
+
expect(classesOf(bar())).toEqual(
|
|
132
|
+
expect.arrayContaining(["absolute", "h-(--dms-save-bar-h)"]),
|
|
133
|
+
);
|
|
134
|
+
expect(classesOf(bar())).not.toContain("invisible");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("gives Cancel and Unsaved changes the same fixed height", async () => {
|
|
138
|
+
await mount({ variant: "floating", dirty: false, cancellable: true });
|
|
139
|
+
const cancelBar = classesOf(bar());
|
|
140
|
+
expect(buttons()).toEqual([{ label: "dms.button.cancel", disabled: false }]);
|
|
141
|
+
app?.unmount();
|
|
142
|
+
|
|
143
|
+
await mount({ variant: "floating", dirty: true, changes: ["Name"] });
|
|
144
|
+
expect(cancelBar).toContain("h-(--dms-save-bar-h)");
|
|
145
|
+
expect(classesOf(bar())).toContain("h-(--dms-save-bar-h)");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps a hidden band's place in its footer", async () => {
|
|
149
|
+
await mount({ variant: "band", kind: "action", dirty: false });
|
|
150
|
+
|
|
151
|
+
expect(anchor().getAttribute("aria-hidden")).toBe("true");
|
|
152
|
+
expect(classesOf(anchor())).toContain("invisible");
|
|
153
|
+
expect(classesOf(anchor())).not.toContain("h-0");
|
|
154
|
+
expect(classesOf(anchor())).not.toContain("absolute");
|
|
155
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createSSRApp, defineComponent, h, ref, type Component } from "vue";
|
|
2
|
+
import { renderToString } from "vue/server-renderer";
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import StatGroup from "../layers/dms-ui/app/components/stat-group/StatGroup.vue";
|
|
5
|
+
import NavCard from "../layers/dms-ui/app/components/card/NavCard.vue";
|
|
6
|
+
|
|
7
|
+
// A detail or a title cut on its first line loses what it says: a stat
|
|
8
|
+
// group's details and a nav card's title take two lines before the ellipsis.
|
|
9
|
+
|
|
10
|
+
const Slot = defineComponent({
|
|
11
|
+
setup:
|
|
12
|
+
(_, { slots }) =>
|
|
13
|
+
() =>
|
|
14
|
+
h("div", slots.default?.()),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function render(component: Component, props: Record<string, unknown>) {
|
|
18
|
+
const app = createSSRApp({ render: () => h(component, props) });
|
|
19
|
+
for (const name of ["UIcon", "USkeleton", "DmsLink"]) {
|
|
20
|
+
app.component(name, Slot);
|
|
21
|
+
}
|
|
22
|
+
return renderToString(app);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const DETAIL = "Next invoice on Nov 1 for the three seats left";
|
|
30
|
+
|
|
31
|
+
function detailClasses(html: string): string {
|
|
32
|
+
const element = new RegExp(
|
|
33
|
+
`<(?:p|small) class="([^"]*)"[^>]*>\\s*${DETAIL}`,
|
|
34
|
+
).exec(html);
|
|
35
|
+
return element?.[1] ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("StatGroup detail", () => {
|
|
39
|
+
it.each(["joined", "cards"])(
|
|
40
|
+
"gives a %s cell's detail two lines before its ellipsis",
|
|
41
|
+
async (layout) => {
|
|
42
|
+
const html = await render(StatGroup, {
|
|
43
|
+
layout,
|
|
44
|
+
items: [{ eyebrow: "Billing", value: "On", detail: DETAIL }],
|
|
45
|
+
});
|
|
46
|
+
expect(detailClasses(html)).toContain("line-clamp-2");
|
|
47
|
+
expect(detailClasses(html)).not.toContain("truncate");
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("NavCard title", () => {
|
|
53
|
+
it("takes two lines before its ellipsis", async () => {
|
|
54
|
+
const html = await render(NavCard, {
|
|
55
|
+
to: "/workspaces?view=past-due",
|
|
56
|
+
icon: "i-ph-warning",
|
|
57
|
+
title: "Workspaces past due for more than seven days",
|
|
58
|
+
});
|
|
59
|
+
const title = /<h3 class="([^"]*)"/.exec(html)?.[1] ?? "";
|
|
60
|
+
expect(title).toContain("line-clamp-2");
|
|
61
|
+
expect(title).not.toContain("truncate");
|
|
62
|
+
});
|
|
63
|
+
});
|