@antelopejs/dms 0.7.4 → 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 +37 -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/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- 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 +22 -40
- 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/CardsDisplay.vue +49 -9
- 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/useReorderHandles.ts +93 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableReorder.ts +8 -7
- 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/table-view/TableView.vue +8 -2
- 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/layers/dms-ui/app/composables/table-view/types/display.ts +51 -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 +27 -3
- package/frontend-vue/tests/table-row-action-errors.test.ts +26 -0
- package/frontend-vue/tests/table-row-hover.test.ts +3 -1
- package/frontend-vue/tests/table-scroll-area.test.ts +3 -1
- package/frontend-vue/tests/table-view-card-props.test.ts +8 -2
- package/frontend-vue/tests/table-view-cards-reorder.test.ts +250 -0
- package/frontend-vue/tests/table-view-reorder.test.ts +2 -2
- package/frontend-vue/tests/top-list-card-actions.test.ts +91 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +14 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* `fit` draws a horizontal pill list as wide as its tabs: the list stops
|
|
4
|
+
* taking the column's width and the triggers stop growing to share it. A
|
|
5
|
+
* link list, a vertical one and a list without `fit` keep their classes.
|
|
6
|
+
*/
|
|
7
|
+
import * as vue from "vue";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { AxeOrientation } from "../layers/dms-core/app/types/orientation";
|
|
10
|
+
import { Color } from "../layers/dms-core/app/types/color";
|
|
11
|
+
import { Size } from "../layers/dms-core/app/types/size";
|
|
12
|
+
import { TabVariant } from "../layers/dms-ui/app/composables/tab/types/props";
|
|
13
|
+
|
|
14
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
15
|
+
useChartFetch: () => ({ data: vue.ref(null) }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
interface TabsUi {
|
|
19
|
+
list?: unknown;
|
|
20
|
+
trigger?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let received: TabsUi = {};
|
|
24
|
+
let app: vue.App | undefined;
|
|
25
|
+
|
|
26
|
+
const Tabs = vue.defineComponent({
|
|
27
|
+
props: { ui: { type: Object, default: () => ({}) } },
|
|
28
|
+
setup: (props) => () => {
|
|
29
|
+
received = props.ui as TabsUi;
|
|
30
|
+
return vue.h("div");
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const classesOf = (value: unknown): string =>
|
|
35
|
+
[value].flat(Infinity).filter(Boolean).join(" ");
|
|
36
|
+
|
|
37
|
+
async function mount(props: Record<string, unknown>): Promise<TabsUi> {
|
|
38
|
+
const { default: Tab } = await import(
|
|
39
|
+
"../layers/dms-ui/app/components/tab/Tab.vue"
|
|
40
|
+
);
|
|
41
|
+
app = vue.createApp({
|
|
42
|
+
render: () =>
|
|
43
|
+
vue.h(Tab, {
|
|
44
|
+
items: [
|
|
45
|
+
{ label: "Activity", slot: "activity" },
|
|
46
|
+
{ label: "Logins", slot: "logins" },
|
|
47
|
+
],
|
|
48
|
+
componentId: "tabs",
|
|
49
|
+
pageId: "dashboard",
|
|
50
|
+
...props,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
app.component("UTabs", Tabs);
|
|
54
|
+
app.mount(document.createElement("div"));
|
|
55
|
+
await vue.nextTick();
|
|
56
|
+
return received;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
for (const name of ["computed", "ref", "useTemplateRef"] as const) {
|
|
61
|
+
vi.stubGlobal(name, vue[name]);
|
|
62
|
+
}
|
|
63
|
+
vi.stubGlobal("AxeOrientation", AxeOrientation);
|
|
64
|
+
vi.stubGlobal("Color", Color);
|
|
65
|
+
vi.stubGlobal("Size", Size);
|
|
66
|
+
vi.stubGlobal("useI18n", () => ({ locale: vue.ref("en-US"), t: String }));
|
|
67
|
+
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
68
|
+
vi.stubGlobal("useWatch", () => ({ state: vue.ref({}) }));
|
|
69
|
+
vi.stubGlobal("useTab", () => ({ activeTab: vue.ref("0") }));
|
|
70
|
+
vi.stubGlobal("useTabShortcuts", () => {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
app?.unmount();
|
|
75
|
+
vi.unstubAllGlobals();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("Tab fit", () => {
|
|
79
|
+
it("hugs a horizontal pill list to its tabs", async () => {
|
|
80
|
+
const ui = await mount({ fit: true });
|
|
81
|
+
expect(classesOf(ui.list)).toContain("w-fit");
|
|
82
|
+
expect(classesOf(ui.trigger)).toContain("grow-0");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("keeps the full width by default", async () => {
|
|
86
|
+
const ui = await mount({});
|
|
87
|
+
expect(classesOf(ui.list)).not.toContain("w-fit");
|
|
88
|
+
expect(classesOf(ui.trigger)).toBe("");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each([
|
|
92
|
+
{ variant: TabVariant.link },
|
|
93
|
+
{ orientation: AxeOrientation.vertical },
|
|
94
|
+
])("leaves %o as it was", async (props) => {
|
|
95
|
+
const ui = await mount({ fit: true, ...props });
|
|
96
|
+
expect(classesOf(ui.list)).not.toContain("w-fit");
|
|
97
|
+
expect(classesOf(ui.trigger)).toBe("");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -448,6 +448,21 @@ describe("identity sub-line", () => {
|
|
|
448
448
|
expect(sub?.className).toContain("text-muted");
|
|
449
449
|
});
|
|
450
450
|
|
|
451
|
+
it("reads each row's tile icon from iconField, falling back to icon", async () => {
|
|
452
|
+
const identity = await identityFormatter();
|
|
453
|
+
const options = { iconField: "icon", icon: "i-ph-puzzle-piece" };
|
|
454
|
+
const tileOf = (row: Record<string, unknown>) =>
|
|
455
|
+
render(() =>
|
|
456
|
+
(identity.default as Formatter)("Exports", "en", options, row),
|
|
457
|
+
)
|
|
458
|
+
.querySelector("[data-icon]")
|
|
459
|
+
?.getAttribute("data-icon");
|
|
460
|
+
|
|
461
|
+
expect(tileOf({ icon: "i-ph-export" })).toBe("i-ph-export");
|
|
462
|
+
expect(tileOf({ icon: "" })).toBe("i-ph-puzzle-piece");
|
|
463
|
+
expect(tileOf({})).toBe("i-ph-puzzle-piece");
|
|
464
|
+
});
|
|
465
|
+
|
|
451
466
|
it("draws an empty issuer as its translated empty label and icon", async () => {
|
|
452
467
|
const identity = await identityFormatter();
|
|
453
468
|
const cell = render(() =>
|
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
3
|
import { tv } from "tailwind-variants";
|
|
4
4
|
import { EYEBROW_CLASS } from "../layers/dms-ui/app/build/utils/eyebrow";
|
|
5
|
+
import { REORDER_HANDLE_CLASS } from "../layers/dms-ui/app/build/composables/table-view/useReorderHandles";
|
|
5
6
|
|
|
6
7
|
const tableSource = readFileSync(
|
|
7
8
|
new URL(
|
|
@@ -17,8 +18,9 @@ const themeSource = tableSource.slice(
|
|
|
17
18
|
const theme = new Function(
|
|
18
19
|
"tv",
|
|
19
20
|
"EYEBROW_CLASS",
|
|
21
|
+
"REORDER_HANDLE_CLASS",
|
|
20
22
|
`${themeSource}; return theme;`,
|
|
21
|
-
)(tv, EYEBROW_CLASS);
|
|
23
|
+
)(tv, EYEBROW_CLASS, REORDER_HANDLE_CLASS);
|
|
22
24
|
|
|
23
25
|
describe("Table grid cell layout theme", () => {
|
|
24
26
|
it.each([undefined, false])("preserves the default for %s", (cellWrap) => {
|
|
@@ -36,8 +38,30 @@ describe("Table grid cell layout theme", () => {
|
|
|
36
38
|
|
|
37
39
|
it("preserves pinned cell and table layout classes", () => {
|
|
38
40
|
expect(theme({ cellWrap: true }).table()).toBe(theme().table());
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
const pinned = theme().rowCell({ pinned: "left" }).split(" ");
|
|
42
|
+
const wrapped = theme()
|
|
43
|
+
.rowCell({ pinned: "left", cellWrap: true })
|
|
44
|
+
.split(" ");
|
|
45
|
+
expect(wrapped.filter((name: string) => !pinned.includes(name))).toEqual([
|
|
46
|
+
"py-2.5",
|
|
47
|
+
]);
|
|
48
|
+
expect(pinned.filter((name: string) => !wrapped.includes(name))).toEqual([
|
|
49
|
+
"py-0",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("pads only a wrapped cell, so its lines clear the row's hairlines", () => {
|
|
54
|
+
expect(theme().rowCell({ cellWrap: true })).toContain("py-2.5");
|
|
55
|
+
expect(theme().rowCell({ cellWrap: true })).toContain("h-11");
|
|
56
|
+
expect(theme().rowCell({ cellWrap: true })).not.toContain("py-0");
|
|
57
|
+
for (const cellWrap of [undefined, false]) {
|
|
58
|
+
expect(theme().rowCell({ cellWrap })).toContain("py-0");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads each column's policy for its cell too", () => {
|
|
63
|
+
expect(tableSource).toMatch(
|
|
64
|
+
/uiTable\.rowCell\(\{[^}]*cellWrap: \(cell\.column\.columnDef as TableColumn<T>\)\s*\.cellWrap,?\s*\}\)/,
|
|
41
65
|
);
|
|
42
66
|
});
|
|
43
67
|
|
|
@@ -73,6 +73,11 @@ function fetchError(status: number | undefined, data?: unknown) {
|
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
const shownRows = [
|
|
77
|
+
{ _id: "t1", name: "Write the report" },
|
|
78
|
+
{ _id: "t2", name: "Review the draft" },
|
|
79
|
+
];
|
|
80
|
+
|
|
76
81
|
async function rowActions() {
|
|
77
82
|
const { useTableRowActions } = await import(
|
|
78
83
|
"../layers/dms-ui/app/build/composables/table-view/useTableViewRowActions"
|
|
@@ -84,6 +89,7 @@ async function rowActions() {
|
|
|
84
89
|
pageId: "tasks",
|
|
85
90
|
formComponents: {},
|
|
86
91
|
refreshCallback: refresh,
|
|
92
|
+
rowNavigation: { rows: () => shownRows, rowIdKey: "_id" },
|
|
87
93
|
});
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -298,6 +304,26 @@ describe("Table row actions: refusals", () => {
|
|
|
298
304
|
});
|
|
299
305
|
});
|
|
300
306
|
|
|
307
|
+
it("words a fixed dialog with the fields of the one row it deletes", async () => {
|
|
308
|
+
api.mockResolvedValue(1);
|
|
309
|
+
const { deleteRows } = await rowActions();
|
|
310
|
+
|
|
311
|
+
await deleteRows(["t1"], { confirm: { title: "Delete {name}?" } });
|
|
312
|
+
|
|
313
|
+
expect(dialogs[0]!.options.title).toBe("Delete Write the report?");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("words a fixed dialog with the count of the rows it deletes", async () => {
|
|
317
|
+
api.mockResolvedValue(2);
|
|
318
|
+
const { deleteRows } = await rowActions();
|
|
319
|
+
|
|
320
|
+
await deleteRows(["t1", "t2"], {
|
|
321
|
+
confirm: { title: "Delete {count} tasks?" },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(dialogs[0]!.options.title).toBe("Delete 2 tasks?");
|
|
325
|
+
});
|
|
326
|
+
|
|
301
327
|
it("keeps the generic confirmation for several rows of a `from` dialog", async () => {
|
|
302
328
|
api.mockResolvedValue(2);
|
|
303
329
|
const { deleteRows } = await rowActions();
|