@antelopejs/dms 0.7.11 → 0.7.13
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 +33 -0
- package/dist/implementations/dms/page.js +14 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
- package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
- package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
- package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +23 -2
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
- package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
- package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
- package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
- package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
- package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +7 -1
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
- package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
- package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
- package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
- package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
- package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
- package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
- package/frontend-vue/tests/chart-legend.test.ts +133 -0
- package/frontend-vue/tests/code-block.test.ts +28 -0
- package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
- package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
- package/frontend-vue/tests/header-actions.test.ts +1 -1
- package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
- package/frontend-vue/tests/module-routing.test.ts +158 -0
- package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
- package/frontend-vue/tests/page-record-label.test.ts +53 -0
- package/frontend-vue/tests/regional-format.test.ts +69 -2
- package/frontend-vue/tests/scroll-to-block.test.ts +171 -0
- package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
- package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
- package/frontend-vue/tests/table-live-hold.test.ts +90 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +28 -4
- package/skills/dms-module/SKILL.md +2 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A chart names its series in a legend drawn in the dashboard's colours, at
|
|
3
|
+
* the top of an axis chart or under a circular one unless `legendPosition`
|
|
4
|
+
* says otherwise. Inside a ChartCard the chart's legend stays off, the card
|
|
5
|
+
* heading its single series, until the card asks for it with
|
|
6
|
+
* `showSeriesLegend`: no raw Apex `legend` option is needed for either.
|
|
7
|
+
*/
|
|
8
|
+
import { createSSRApp, defineComponent, h, ref, type PropType } from "vue";
|
|
9
|
+
import { renderToString } from "vue/server-renderer";
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import ChartCard from "../layers/dms-ui/app/components/chart/ChartCard.vue";
|
|
12
|
+
import Chart from "../layers/dms-ui/app/components/chart/Chart.vue";
|
|
13
|
+
import { useApexChart } from "../layers/dms-ui/app/composables/chart/useApexChart";
|
|
14
|
+
import type { UseApexChartInput } from "../layers/dms-ui/app/composables/chart/useApexChart.types";
|
|
15
|
+
import type { ChartSeries } from "../layers/dms-ui/app/composables/chart/types";
|
|
16
|
+
|
|
17
|
+
const SERIES: ChartSeries[] = [
|
|
18
|
+
{ name: "Average", data: [{ x: "10:00", y: 42 }] },
|
|
19
|
+
{ name: "p95", data: [{ x: "10:00", y: 96 }] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
|
|
23
|
+
useChartFetch: () => ({
|
|
24
|
+
data: ref({ value: 42, series: SERIES }),
|
|
25
|
+
isLoading: ref(false),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
interface LegendOptions {
|
|
30
|
+
show: boolean;
|
|
31
|
+
position: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LegendProbe = defineComponent({
|
|
35
|
+
props: {
|
|
36
|
+
options: {
|
|
37
|
+
type: Object as PropType<{ legend: LegendOptions }>,
|
|
38
|
+
required: true,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
setup: (props) => () =>
|
|
42
|
+
h("output", {
|
|
43
|
+
"data-show": String(props.options.legend.show),
|
|
44
|
+
"data-position": props.options.legend.position,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const Slot = defineComponent({
|
|
49
|
+
setup:
|
|
50
|
+
(_, { slots }) =>
|
|
51
|
+
() =>
|
|
52
|
+
h("div", slots.default?.()),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function legendOf(input: Partial<UseApexChartInput>) {
|
|
56
|
+
const chart = useApexChart(() => ({
|
|
57
|
+
type: "line",
|
|
58
|
+
series: SERIES,
|
|
59
|
+
...input,
|
|
60
|
+
}));
|
|
61
|
+
return chart.options.value.legend as Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function renderCard(cardProps: Record<string, unknown>) {
|
|
65
|
+
vi.stubGlobal("useI18n", () => ({
|
|
66
|
+
locale: ref("en"),
|
|
67
|
+
t: (key: string) => key,
|
|
68
|
+
}));
|
|
69
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
70
|
+
processI18n: (text: string) => text,
|
|
71
|
+
}));
|
|
72
|
+
vi.stubGlobal("useComponentEvent", () => ({}));
|
|
73
|
+
vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
|
|
74
|
+
const app = createSSRApp({
|
|
75
|
+
render: () =>
|
|
76
|
+
h(
|
|
77
|
+
ChartCard,
|
|
78
|
+
{
|
|
79
|
+
title: "Latency",
|
|
80
|
+
componentId: "card",
|
|
81
|
+
pageId: "routes",
|
|
82
|
+
...cardProps,
|
|
83
|
+
},
|
|
84
|
+
() =>
|
|
85
|
+
h(Chart, {
|
|
86
|
+
type: "line",
|
|
87
|
+
componentId: "plot",
|
|
88
|
+
pageId: "routes",
|
|
89
|
+
legendPosition: "bottom",
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
});
|
|
93
|
+
for (const name of ["DmsCard", "DmsClientOnly", "UIcon", "USkeleton"])
|
|
94
|
+
app.component(name, Slot);
|
|
95
|
+
app.component("DmsApexChartHost", LegendProbe);
|
|
96
|
+
return renderToString(app);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
100
|
+
|
|
101
|
+
describe("chart legend", () => {
|
|
102
|
+
it("sits above an axis chart and under a circular one by default", () => {
|
|
103
|
+
expect(legendOf({}).position).toBe("top");
|
|
104
|
+
expect(legendOf({ type: "donut", series: [] }).position).toBe("bottom");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("follows legendPosition", () => {
|
|
108
|
+
expect(legendOf({ legendPosition: "bottom" }).position).toBe("bottom");
|
|
109
|
+
expect(
|
|
110
|
+
legendOf({ type: "donut", series: [], legendPosition: "top" }).position,
|
|
111
|
+
).toBe("top");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("is drawn in the dashboard's theme", () => {
|
|
115
|
+
const legend = legendOf({});
|
|
116
|
+
expect(legend.show).toBe(true);
|
|
117
|
+
expect(legend.markers).toMatchObject({ shape: "square", strokeWidth: 0 });
|
|
118
|
+
expect(legend.labels).toHaveProperty("colors");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("series legend of a ChartCard", () => {
|
|
123
|
+
it("stays off in a card by default", async () => {
|
|
124
|
+
const html = await renderCard({});
|
|
125
|
+
expect(html).toContain('data-show="false"');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("names the series where the chart places it when the card asks", async () => {
|
|
129
|
+
const html = await renderCard({ showSeriesLegend: true });
|
|
130
|
+
expect(html).toContain('data-show="true"');
|
|
131
|
+
expect(html).toContain('data-position="bottom"');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -94,6 +94,34 @@ describe("DmsCodeSnippet", () => {
|
|
|
94
94
|
expect(pre().previousElementSibling).toBeNull();
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
it("floats the copy button in the code's corner without a title or a language", async () => {
|
|
98
|
+
await mountSnippet({ code: '{"ok":true}' });
|
|
99
|
+
expect(host.querySelector("[data-floating-copy]")).toBe(copyButton());
|
|
100
|
+
expect(host.textContent).not.toContain("Text");
|
|
101
|
+
expect(pre().className).toContain("pe-10");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps the head of a snippet naming its language", async () => {
|
|
105
|
+
await mountSnippet({ code: "plain words", language: "text" });
|
|
106
|
+
expect(host.querySelector("[data-floating-copy]")).toBeNull();
|
|
107
|
+
expect(copyButton()).not.toBeNull();
|
|
108
|
+
expect(pre().className).not.toContain("pe-10");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("breaks a word too long for the line with wrap anywhere", async () => {
|
|
112
|
+
await mountSnippet({ code: "x".repeat(400), wrap: "anywhere" });
|
|
113
|
+
expect(pre().className).toContain("[overflow-wrap:anywhere]");
|
|
114
|
+
expect(pre().className).toContain("whitespace-pre-wrap");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("caps the height with a CSS length when no line count is set", async () => {
|
|
118
|
+
await mountSnippet({ code: "a", maxHeight: "20rem" });
|
|
119
|
+
expect(pre().style.maxHeight).toBe("20rem");
|
|
120
|
+
app?.unmount();
|
|
121
|
+
await mountSnippet({ code: "a\nb", maxHeight: "20rem", maxLines: 1 });
|
|
122
|
+
expect(pre().style.maxHeight).toBe("35px");
|
|
123
|
+
});
|
|
124
|
+
|
|
97
125
|
it("wraps long lines or scrolls them, and caps the height in lines", async () => {
|
|
98
126
|
await mountSnippet({ code: "a\nb\nc", wrap: true, maxLines: 2 });
|
|
99
127
|
expect(pre().className).toContain("whitespace-pre-wrap");
|
|
@@ -44,6 +44,7 @@ const load = async (name: string) =>
|
|
|
44
44
|
.default as Component;
|
|
45
45
|
|
|
46
46
|
beforeEach(() => {
|
|
47
|
+
vi.stubGlobal("useI18n", () => ({ locale: { value: "en" } }));
|
|
47
48
|
vi.stubGlobal("useTranslation", () => ({
|
|
48
49
|
processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
|
|
49
50
|
}));
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What a `Form` offers a custom field through `useEnclosingForm`: submitting
|
|
4
|
+
* the form as its save button does, a validator that can stop a submit and
|
|
5
|
+
* leaves with its component, and focusing a field by id. A page opened on a
|
|
6
|
+
* field's anchor focuses that field.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { createApp, defineComponent, h } from "vue";
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
FORM_HANDLE_KEY,
|
|
14
|
+
type FormHandle,
|
|
15
|
+
linkedFieldId,
|
|
16
|
+
} from "../layers/dms-ui/app/build/composables/form/enclosingForm";
|
|
17
|
+
import {
|
|
18
|
+
type EnclosingForm,
|
|
19
|
+
useEnclosingForm,
|
|
20
|
+
} from "../layers/dms-ui/app/composables/form/useEnclosingForm";
|
|
21
|
+
import type { FormValidator } from "../layers/dms-ui/app/composables/form/types/validation";
|
|
22
|
+
|
|
23
|
+
const FIELD_IDS = ["retentionDays", "requestLogMaxBodyKb"];
|
|
24
|
+
const mounted: Array<{ unmount: () => void }> = [];
|
|
25
|
+
|
|
26
|
+
afterEach(() => mounted.splice(0).forEach((app) => app.unmount()));
|
|
27
|
+
|
|
28
|
+
interface FakeForm {
|
|
29
|
+
handle: FormHandle;
|
|
30
|
+
validators: Set<FormValidator>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fakeForm(): FakeForm {
|
|
34
|
+
const validators = new Set<FormValidator>();
|
|
35
|
+
return {
|
|
36
|
+
validators,
|
|
37
|
+
handle: {
|
|
38
|
+
submit: vi.fn(async () => {}),
|
|
39
|
+
registerValidator: (validator) => {
|
|
40
|
+
validators.add(validator);
|
|
41
|
+
return () => validators.delete(validator);
|
|
42
|
+
},
|
|
43
|
+
revealField: vi.fn(async () => {}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mountField(
|
|
49
|
+
handle: FormHandle | null,
|
|
50
|
+
setup: (form: EnclosingForm | null) => void,
|
|
51
|
+
) {
|
|
52
|
+
const app = createApp(
|
|
53
|
+
defineComponent({
|
|
54
|
+
setup() {
|
|
55
|
+
setup(useEnclosingForm());
|
|
56
|
+
return () => h("div");
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
if (handle) app.provide(FORM_HANDLE_KEY, handle);
|
|
61
|
+
app.mount(document.createElement("div"));
|
|
62
|
+
mounted.push(app);
|
|
63
|
+
return app;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("useEnclosingForm", () => {
|
|
67
|
+
it("is null outside a Form", () => {
|
|
68
|
+
let form: EnclosingForm | null | undefined;
|
|
69
|
+
mountField(null, (enclosing) => (form = enclosing));
|
|
70
|
+
expect(form).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("submits the form and focuses a field through the form", async () => {
|
|
74
|
+
const { handle } = fakeForm();
|
|
75
|
+
let form: EnclosingForm | null = null;
|
|
76
|
+
mountField(handle, (enclosing) => (form = enclosing));
|
|
77
|
+
|
|
78
|
+
await form!.submit();
|
|
79
|
+
await form!.focusField("retentionDays");
|
|
80
|
+
|
|
81
|
+
expect(handle.submit).toHaveBeenCalledOnce();
|
|
82
|
+
expect(handle.revealField).toHaveBeenCalledWith("retentionDays");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("adds a validator that leaves with its component", () => {
|
|
86
|
+
const { handle, validators } = fakeForm();
|
|
87
|
+
const validator = () => false;
|
|
88
|
+
const app = mountField(handle, (form) => form?.addValidator(validator));
|
|
89
|
+
|
|
90
|
+
expect([...validators]).toEqual([validator]);
|
|
91
|
+
|
|
92
|
+
app.unmount();
|
|
93
|
+
mounted.splice(mounted.indexOf(app), 1);
|
|
94
|
+
expect(validators.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("removes a validator on request", () => {
|
|
98
|
+
const { handle, validators } = fakeForm();
|
|
99
|
+
let remove: (() => void) | undefined;
|
|
100
|
+
mountField(handle, (form) => (remove = form?.addValidator(() => true)));
|
|
101
|
+
|
|
102
|
+
remove?.();
|
|
103
|
+
expect(validators.size).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("a field's anchor", () => {
|
|
108
|
+
it("names a field of the form by its id", () => {
|
|
109
|
+
expect(linkedFieldId("#retentionDays", FIELD_IDS)).toBe("retentionDays");
|
|
110
|
+
expect(
|
|
111
|
+
linkedFieldId("#request%4CogMaxBodyKb", ["requestLogMaxBodyKb"]),
|
|
112
|
+
).toBe("requestLogMaxBodyKb");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("names nothing for another anchor, none, or a broken one", () => {
|
|
116
|
+
expect(linkedFieldId("#dms-form-settings-section-logs", FIELD_IDS)).toBe(
|
|
117
|
+
undefined,
|
|
118
|
+
);
|
|
119
|
+
expect(linkedFieldId("", FIELD_IDS)).toBe(undefined);
|
|
120
|
+
expect(linkedFieldId("#%E0%A4%A", FIELD_IDS)).toBe(undefined);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("the Form", () => {
|
|
125
|
+
const formSource = readFileSync(
|
|
126
|
+
resolve(__dirname, "../layers/dms-ui/app/components/form/Form.vue"),
|
|
127
|
+
"utf8",
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
it("offers its fields the submit, its validators and the focus", () => {
|
|
131
|
+
expect(formSource).toMatch(
|
|
132
|
+
/provide\(FORM_HANDLE_KEY, \{\s*submit: async \(\) => \{\s*await form\.value\?\.submit\(\);\s*\},\s*registerValidator,\s*revealField,\s*\}\);/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("follows the anchor of a field once mounted, a page form only", () => {
|
|
137
|
+
expect(formSource).toMatch(
|
|
138
|
+
/if \(!inFormContainer && !container\) \{\s*const followFieldAnchor = \(\) => \{\s*const fieldId = linkedFieldId\(window\.location\.hash, allFieldIds\.value\);/,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -28,7 +28,7 @@ vi.mock(
|
|
|
28
28
|
"../layers/dms-layout/app/build/components/layout/QuickActionsPopover.vue",
|
|
29
29
|
() => ({ default: () => null }),
|
|
30
30
|
);
|
|
31
|
-
vi.mock("#dms-core/app/
|
|
31
|
+
vi.mock("#dms-core/app/composables/page/usePageRecordLabel", () => ({
|
|
32
32
|
usePageRecordLabel: () => ({ label: ref(null) }),
|
|
33
33
|
}));
|
|
34
34
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* A number field keeps a typed value within its bounds and on its steps when
|
|
4
|
+
* it is left or on Enter. It says so with `adjust` — 1020 typed in a field of
|
|
5
|
+
* steps of 50 kept as 1,000 — and draws the kept value again, so a module
|
|
6
|
+
* shows "1020 → 1,000" without remounting the field.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
type App,
|
|
11
|
+
type Component,
|
|
12
|
+
createApp,
|
|
13
|
+
defineComponent,
|
|
14
|
+
h,
|
|
15
|
+
nextTick,
|
|
16
|
+
ref,
|
|
17
|
+
} from "vue";
|
|
18
|
+
import {
|
|
19
|
+
numberAdjustment,
|
|
20
|
+
parseTypedNumber,
|
|
21
|
+
} from "../layers/dms-ui/app/build/utils/numberAdjustment";
|
|
22
|
+
import type { InputNumberAdjustment } from "../layers/dms-ui/app/types/input-number";
|
|
23
|
+
|
|
24
|
+
let app: App | undefined;
|
|
25
|
+
let host: HTMLDivElement;
|
|
26
|
+
|
|
27
|
+
// The Nuxt UI field as far as the wrapper sees it: it applies the typed text
|
|
28
|
+
// on blur or Enter, kept within `min` / `max` and on `step`, then emits blur.
|
|
29
|
+
const NumberField = defineComponent({
|
|
30
|
+
inheritAttrs: false,
|
|
31
|
+
props: {
|
|
32
|
+
modelValue: { type: Number, default: undefined },
|
|
33
|
+
min: { type: Number, default: undefined },
|
|
34
|
+
max: { type: Number, default: undefined },
|
|
35
|
+
step: { type: Number, default: 1 },
|
|
36
|
+
},
|
|
37
|
+
emits: ["update:modelValue", "blur"],
|
|
38
|
+
setup(props, { emit }) {
|
|
39
|
+
const apply = (input: HTMLInputElement) => {
|
|
40
|
+
const typed = Number(input.value.replace(/,/g, ""));
|
|
41
|
+
const bounded = Math.min(
|
|
42
|
+
props.max ?? Infinity,
|
|
43
|
+
Math.max(props.min ?? -Infinity, typed),
|
|
44
|
+
);
|
|
45
|
+
emit("update:modelValue", Math.round(bounded / props.step) * props.step);
|
|
46
|
+
};
|
|
47
|
+
return () =>
|
|
48
|
+
h("input", {
|
|
49
|
+
onBlur: (event: FocusEvent) => {
|
|
50
|
+
apply(event.target as HTMLInputElement);
|
|
51
|
+
emit("blur", event);
|
|
52
|
+
},
|
|
53
|
+
onKeydown: (event: KeyboardEvent) => {
|
|
54
|
+
if (event.key === "Enter") apply(event.target as HTMLInputElement);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
async function mount(props: Record<string, unknown>) {
|
|
61
|
+
const value = ref<number | undefined>(props.modelValue as number);
|
|
62
|
+
const adjustments: InputNumberAdjustment[] = [];
|
|
63
|
+
const field = (
|
|
64
|
+
await import(
|
|
65
|
+
"../layers/dms-ui/app/components/form/components/InputNumber.vue"
|
|
66
|
+
)
|
|
67
|
+
).default as Component;
|
|
68
|
+
app = createApp({
|
|
69
|
+
render: () =>
|
|
70
|
+
h(field, {
|
|
71
|
+
...props,
|
|
72
|
+
modelValue: value.value,
|
|
73
|
+
"onUpdate:modelValue": (next: number) => (value.value = next),
|
|
74
|
+
onAdjust: (adjustment: InputNumberAdjustment) =>
|
|
75
|
+
adjustments.push(adjustment),
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
app.component("UInputNumber", NumberField);
|
|
79
|
+
app.mount(host);
|
|
80
|
+
await nextTick();
|
|
81
|
+
return { value, adjustments, input: host.querySelector("input")! };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function type(input: HTMLInputElement, text: string) {
|
|
85
|
+
input.value = text;
|
|
86
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function settled() {
|
|
90
|
+
await nextTick();
|
|
91
|
+
await nextTick();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.stubGlobal("useTranslation", () => ({
|
|
96
|
+
processI18n: (text: string) => text,
|
|
97
|
+
}));
|
|
98
|
+
vi.stubGlobal("useI18n", () => ({ locale: ref("en") }));
|
|
99
|
+
host = document.createElement("div");
|
|
100
|
+
document.body.append(host);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
app?.unmount();
|
|
105
|
+
host.remove();
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("parseTypedNumber", () => {
|
|
110
|
+
it("reads a number as the locale writes it", () => {
|
|
111
|
+
expect(parseTypedNumber("1,020", "en")).toBe(1020);
|
|
112
|
+
expect(parseTypedNumber(" -100 ", "en")).toBe(-100);
|
|
113
|
+
expect(parseTypedNumber("1 020,5", "fr")).toBe(1020.5);
|
|
114
|
+
expect(parseTypedNumber("", "en")).toBeNull();
|
|
115
|
+
expect(parseTypedNumber("abc", "en")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("numberAdjustment", () => {
|
|
120
|
+
it("names the bound or the step the value was kept on", () => {
|
|
121
|
+
const bounds = { min: 0, max: 5000 };
|
|
122
|
+
expect(numberAdjustment(9000, 5000, bounds)?.reason).toBe("max");
|
|
123
|
+
expect(numberAdjustment(-3, 0, bounds)?.reason).toBe("min");
|
|
124
|
+
expect(numberAdjustment(1020, 1000, bounds)).toEqual({
|
|
125
|
+
typed: 1020,
|
|
126
|
+
value: 1000,
|
|
127
|
+
reason: "step",
|
|
128
|
+
});
|
|
129
|
+
expect(numberAdjustment(1000, 1000, bounds)).toBeNull();
|
|
130
|
+
expect(numberAdjustment(null, 1000, bounds)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("DmsInputNumber adjust", () => {
|
|
135
|
+
it("says the field kept a typed value on its steps when it is left", async () => {
|
|
136
|
+
const { input, adjustments, value } = await mount({
|
|
137
|
+
modelValue: 1000,
|
|
138
|
+
step: 50,
|
|
139
|
+
min: 0,
|
|
140
|
+
});
|
|
141
|
+
await type(input, "1020");
|
|
142
|
+
input.dispatchEvent(new FocusEvent("blur"));
|
|
143
|
+
await settled();
|
|
144
|
+
expect(value.value).toBe(1000);
|
|
145
|
+
expect(adjustments).toEqual([{ typed: 1020, value: 1000, reason: "step" }]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("says the field kept a typed value within its maximum on Enter", async () => {
|
|
149
|
+
const { input, adjustments } = await mount({ modelValue: 10, max: 100 });
|
|
150
|
+
await type(input, "250");
|
|
151
|
+
input.dispatchEvent(
|
|
152
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
153
|
+
);
|
|
154
|
+
await settled();
|
|
155
|
+
expect(adjustments).toEqual([{ typed: 250, value: 100, reason: "max" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("redraws the kept value through the field's change handler", async () => {
|
|
159
|
+
const { input } = await mount({ modelValue: 1000, step: 50 });
|
|
160
|
+
const changes = vi.fn();
|
|
161
|
+
input.addEventListener("change", changes);
|
|
162
|
+
await type(input, "1020");
|
|
163
|
+
input.dispatchEvent(new FocusEvent("blur"));
|
|
164
|
+
await settled();
|
|
165
|
+
expect(changes).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("stays quiet when the field keeps what was typed, or nothing was typed", async () => {
|
|
169
|
+
const { input, adjustments } = await mount({ modelValue: 10, step: 5 });
|
|
170
|
+
input.dispatchEvent(new FocusEvent("blur"));
|
|
171
|
+
await type(input, "15");
|
|
172
|
+
input.dispatchEvent(new FocusEvent("blur"));
|
|
173
|
+
await settled();
|
|
174
|
+
expect(adjustments).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
/**
|
|
3
|
-
* The strip of a held table: "N new · Show", Show reading the rows again
|
|
3
|
+
* The strip of a held table: "N new · Show", Show reading the rows again,
|
|
4
|
+
* led by why the table holds by itself ("Table on hold · search active").
|
|
4
5
|
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
5
8
|
import { createApp, h, type App } from "vue";
|
|
6
9
|
import { afterEach, expect, it, vi } from "vitest";
|
|
7
10
|
|
|
@@ -12,7 +15,7 @@ afterEach(() => {
|
|
|
12
15
|
vi.unstubAllGlobals();
|
|
13
16
|
});
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
async function mountBanner(props: Record<string, unknown>) {
|
|
16
19
|
vi.stubGlobal("useI18n", () => ({
|
|
17
20
|
t: (key: string, params?: { count?: number }) =>
|
|
18
21
|
params ? `${key}:${params.count}` : key,
|
|
@@ -20,18 +23,73 @@ it("draws the banner and reads the rows on Show", async () => {
|
|
|
20
23
|
const { default: LiveHoldBanner } = await import(
|
|
21
24
|
"../layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue"
|
|
22
25
|
);
|
|
23
|
-
const onShow = vi.fn();
|
|
24
26
|
const host = document.createElement("div");
|
|
25
|
-
app = createApp({
|
|
26
|
-
render: () => h(LiveHoldBanner, { count: 3, onShow }),
|
|
27
|
-
});
|
|
27
|
+
app = createApp({ render: () => h(LiveHoldBanner, props) });
|
|
28
28
|
app.component("UIcon", () => h("i"));
|
|
29
|
-
app.component("UButton", (
|
|
30
|
-
h("button", attrs,
|
|
29
|
+
app.component("UButton", (buttonProps: { label: string }, { attrs }) =>
|
|
30
|
+
h("button", attrs, buttonProps.label),
|
|
31
31
|
);
|
|
32
32
|
app.mount(host);
|
|
33
|
+
return host;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("draws the banner and reads the rows on Show", async () => {
|
|
37
|
+
const onShow = vi.fn();
|
|
38
|
+
const host = await mountBanner({ count: 3, onShow });
|
|
33
39
|
|
|
34
40
|
expect(host.textContent).toContain("dms.live.new_rows:3");
|
|
35
41
|
host.querySelector("button")?.click();
|
|
36
42
|
expect(onShow).toHaveBeenCalledTimes(1);
|
|
37
43
|
});
|
|
44
|
+
|
|
45
|
+
it("says why the table holds, with no count nor Show before anything came in", async () => {
|
|
46
|
+
const host = await mountBanner({ count: 0, reason: "searching" });
|
|
47
|
+
|
|
48
|
+
expect(host.textContent).toContain(
|
|
49
|
+
"dms.live.table_held · dms.live.held_searching",
|
|
50
|
+
);
|
|
51
|
+
expect(host.textContent).not.toContain("dms.live.new_rows");
|
|
52
|
+
expect(host.querySelector("button")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("adds the count and Show after the reason", async () => {
|
|
56
|
+
const host = await mountBanner({ count: 2, reason: "rowOpen" });
|
|
57
|
+
|
|
58
|
+
expect(host.textContent).toContain("dms.live.held_row_open");
|
|
59
|
+
expect(host.textContent).toContain("dms.live.new_rows:2");
|
|
60
|
+
expect(host.querySelector("button")).not.toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("has every reason in English and French", () => {
|
|
64
|
+
for (const file of ["ui-en-GB.json", "ui-fr-FR.json"]) {
|
|
65
|
+
const live = JSON.parse(
|
|
66
|
+
readFileSync(
|
|
67
|
+
resolve(__dirname, `../layers/dms-ui/i18n/locales/${file}`),
|
|
68
|
+
"utf8",
|
|
69
|
+
),
|
|
70
|
+
).dms.live;
|
|
71
|
+
for (const key of [
|
|
72
|
+
"table_held",
|
|
73
|
+
"held_row_open",
|
|
74
|
+
"held_searching",
|
|
75
|
+
"held_sorted",
|
|
76
|
+
"held_scrolled",
|
|
77
|
+
]) {
|
|
78
|
+
expect(live[key], `${file} ${key}`).toBeTruthy();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("words the count with missedLabel and adds the paused page's breakdown", async () => {
|
|
84
|
+
const host = await mountBanner({
|
|
85
|
+
count: 12,
|
|
86
|
+
missedLabel: "$api.live.new_requests",
|
|
87
|
+
detail: { text: "$api.live.in_5xx", params: { count: 2 }, tone: "error" },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(host.textContent).toContain("api.live.new_requests:12");
|
|
91
|
+
expect(host.textContent).not.toContain("dms.live.new_rows");
|
|
92
|
+
const detail = host.querySelector(".dms-live-hold-banner__detail");
|
|
93
|
+
expect(detail?.textContent?.trim()).toBe("api.live.in_5xx:2");
|
|
94
|
+
expect(detail?.classList.contains("text-error")).toBe(true);
|
|
95
|
+
});
|