@antelopejs/dms 0.7.12 → 0.7.14
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 +36 -0
- package/dist/implementations/dms/client-ip.d.ts +2 -0
- package/dist/implementations/dms/client-ip.js +9 -0
- package/dist/implementations/dms/client-ip.js.map +1 -0
- package/dist/implementations/dms/page.js +14 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/auth/client-ip.d.ts +1 -4
- package/dist/routes/auth/client-ip.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/client-ip.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/client-ip.test.js +61 -0
- package/dist/test/unit/interfaces/dms/client-ip.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/frontend-vue/layers/dms-core/app/composables/period/periodLink.ts +60 -0
- package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +9 -4
- package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +34 -4
- package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +22 -1
- package/frontend-vue/layers/dms-layout/app/build/utils/pageValidationRedirect.ts +32 -0
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +27 -56
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +29 -7
- package/frontend-vue/layers/dms-layout/app/types/page.ts +2 -0
- 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/composables/actions/useActionTargets.ts +28 -7
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmTextMatch.ts +16 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +2 -0
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +46 -7
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +23 -1
- package/frontend-vue/layers/dms-ui/app/composables/confirm/index.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +20 -0
- package/frontend-vue/layers/dms-ui/app/composables/confirm/useConfirmFrom.ts +32 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
- 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/confirm-from.test.ts +101 -0
- package/frontend-vue/tests/confirm-typed-text.test.ts +35 -0
- package/frontend-vue/tests/context-links.test.ts +220 -0
- package/frontend-vue/tests/field-errors.test.ts +35 -0
- package/frontend-vue/tests/form-required-legend.test.ts +34 -0
- package/frontend-vue/tests/form-server-field-errors.test.ts +18 -1
- package/frontend-vue/tests/input-number-range.test.ts +135 -0
- package/frontend-vue/tests/module-routing.test.ts +23 -4
- package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
- package/frontend-vue/tests/scroll-to-block.test.ts +30 -1
- package/package.json +2 -2
- package/skills/dms-module/SKILL.md +3 -0
- /package/frontend-vue/layers/dms-ui/app/{build/components → components}/form/FormRequiredLegend.vue +0 -0
|
@@ -48,10 +48,36 @@ import {
|
|
|
48
48
|
dispatchQuickActionTarget,
|
|
49
49
|
findServedQuickAction,
|
|
50
50
|
} from "../../utils/dispatchQuickActionTarget";
|
|
51
|
+
import {
|
|
52
|
+
hasUrlVariables,
|
|
53
|
+
resolveLinkVariables,
|
|
54
|
+
} from "../../utils/urlVariables";
|
|
51
55
|
|
|
52
56
|
/** Runs a confirmed action from inside its dialog (see `onConfirm`). */
|
|
53
57
|
type ConfirmedRun = NonNullable<ConfirmOptions["onConfirm"]>;
|
|
54
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A link target's URL with the page's `{{params.X}}` / `{{query.X}}` filled
|
|
61
|
+
* (a query pair without a value left out), then the row's `{field}`: a row
|
|
62
|
+
* action keeps the page's context (`?website={{query.website}}`) as a page
|
|
63
|
+
* header button does. Read when the link is followed, so it carries the URL
|
|
64
|
+
* as it is then.
|
|
65
|
+
*/
|
|
66
|
+
function linkTargetUrl(url: string, rowData?: Data): string {
|
|
67
|
+
const withPageValues = hasUrlVariables(url)
|
|
68
|
+
? resolveLinkVariables(url, currentPageUrlValues())
|
|
69
|
+
: url;
|
|
70
|
+
return rowData ? interpolateUrl(withPageValues, rowData) : withPageValues;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function currentPageUrlValues() {
|
|
74
|
+
const route = useDmsRoute();
|
|
75
|
+
return {
|
|
76
|
+
routeParams: useSiteLayout().findMatchingRoute(route.path)?.params,
|
|
77
|
+
routeQuery: route.query as Record<string, unknown>,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
/** Where the buttons run: the requests they send, what they refresh. */
|
|
56
82
|
export interface ActionTargetsConfig {
|
|
57
83
|
api: ReturnType<typeof $fetch.create>;
|
|
@@ -410,16 +436,11 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
410
436
|
const targetHandlers: Record<string, TargetHandler> = {
|
|
411
437
|
page: (target, _label, rowData) => {
|
|
412
438
|
const pageTarget = target as ActionTarget & { type: "page" };
|
|
413
|
-
|
|
414
|
-
? interpolateUrl(pageTarget.url, rowData)
|
|
415
|
-
: pageTarget.url;
|
|
416
|
-
navigateDms(url);
|
|
439
|
+
navigateDms(linkTargetUrl(pageTarget.url, rowData));
|
|
417
440
|
},
|
|
418
441
|
external: (target, _label, rowData) => {
|
|
419
442
|
const externalTarget = target as ActionTarget & { type: "external" };
|
|
420
|
-
const url = rowData
|
|
421
|
-
? interpolateUrl(externalTarget.url, rowData)
|
|
422
|
-
: externalTarget.url;
|
|
443
|
+
const url = linkTargetUrl(externalTarget.url, rowData);
|
|
423
444
|
if (typeof window === "undefined" || !url) return;
|
|
424
445
|
if (externalTarget.newTab) {
|
|
425
446
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether what the user typed confirms a type-to-confirm dialog: the text,
|
|
3
|
+
* trimmed, equal to `confirmText` — ignoring case when asked, in the
|
|
4
|
+
* reader's locale rules (`acme` matches `ACME`).
|
|
5
|
+
*/
|
|
6
|
+
export function isConfirmTextMatch(
|
|
7
|
+
typed: string,
|
|
8
|
+
confirmText: string,
|
|
9
|
+
isCaseInsensitive = false,
|
|
10
|
+
): boolean {
|
|
11
|
+
const text = typed.trim();
|
|
12
|
+
if (!isCaseInsensitive) return text === confirmText;
|
|
13
|
+
return (
|
|
14
|
+
text.localeCompare(confirmText, undefined, { sensitivity: "accent" }) === 0
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The bounds a number field takes. */
|
|
2
|
+
export interface NumberBounds {
|
|
3
|
+
min?: number;
|
|
4
|
+
max?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Whether a number field's steppers can still move its value. */
|
|
8
|
+
export interface NumberStepperState {
|
|
9
|
+
incrementDisabled: boolean;
|
|
10
|
+
decrementDisabled: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const BELOW_MIN_MESSAGE = "$dms.field_errors.too_small";
|
|
14
|
+
export const ABOVE_MAX_MESSAGE = "$dms.field_errors.too_large";
|
|
15
|
+
|
|
16
|
+
function isNumberValue(value: unknown): value is number {
|
|
17
|
+
return typeof value === "number" && !Number.isNaN(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The error of a value outside its bounds, for a field that keeps what was
|
|
22
|
+
* typed instead of clamping it; `undefined` when it is in range or empty.
|
|
23
|
+
*/
|
|
24
|
+
export function numberRangeError(
|
|
25
|
+
value: unknown,
|
|
26
|
+
bounds: NumberBounds,
|
|
27
|
+
): string | undefined {
|
|
28
|
+
if (!isNumberValue(value)) return undefined;
|
|
29
|
+
if (bounds.min !== undefined && value < bounds.min) return BELOW_MIN_MESSAGE;
|
|
30
|
+
if (bounds.max !== undefined && value > bounds.max) return ABOVE_MAX_MESSAGE;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The steppers of a field that does not hand its bounds to the input: they
|
|
36
|
+
* stop at the bounds the input no longer knows of.
|
|
37
|
+
*/
|
|
38
|
+
export function numberStepperState(
|
|
39
|
+
value: unknown,
|
|
40
|
+
bounds: NumberBounds,
|
|
41
|
+
): NumberStepperState {
|
|
42
|
+
const current = isNumberValue(value) ? value : undefined;
|
|
43
|
+
return {
|
|
44
|
+
incrementDisabled:
|
|
45
|
+
current !== undefined &&
|
|
46
|
+
bounds.max !== undefined &&
|
|
47
|
+
current >= bounds.max,
|
|
48
|
+
decrementDisabled:
|
|
49
|
+
current !== undefined &&
|
|
50
|
+
bounds.min !== undefined &&
|
|
51
|
+
current <= bounds.min,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
type PeriodPreset,
|
|
8
8
|
type PeriodRange,
|
|
9
9
|
} from "#dms-core/app/composables/period/types";
|
|
10
|
+
import type { PeriodQueryKeys } from "#dms-core/app/composables/period/periodLink";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type PeriodQueryKeys,
|
|
14
|
+
periodQueryKeys,
|
|
15
|
+
} from "#dms-core/app/composables/period/periodLink";
|
|
10
16
|
|
|
11
17
|
/** The range half of a selector's choice: a preset, or a custom range. */
|
|
12
18
|
export interface PeriodRangeChoice {
|
|
@@ -34,16 +40,6 @@ export interface PeriodChoices {
|
|
|
34
40
|
/** A page URL query, or a selection kept in the preferences. */
|
|
35
41
|
export type PeriodQuery = Record<string, unknown>;
|
|
36
42
|
|
|
37
|
-
/** The keys one selector reads and writes. */
|
|
38
|
-
export interface PeriodQueryKeys {
|
|
39
|
-
period: string;
|
|
40
|
-
compare: string;
|
|
41
|
-
from: string;
|
|
42
|
-
to: string;
|
|
43
|
-
compareFrom: string;
|
|
44
|
-
compareTo: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
43
|
/** A source the halves of a selection are read from, in order. */
|
|
48
44
|
export interface PeriodSource {
|
|
49
45
|
query: PeriodQuery;
|
|
@@ -57,31 +53,12 @@ export interface PeriodSource {
|
|
|
57
53
|
|
|
58
54
|
const CUSTOM_PRESET: PeriodPreset = "custom";
|
|
59
55
|
const CUSTOM_COMPARISON: PeriodComparison = "custom";
|
|
60
|
-
const KEY_NAMES: Array<keyof PeriodQueryKeys> = [
|
|
61
|
-
"period",
|
|
62
|
-
"compare",
|
|
63
|
-
"from",
|
|
64
|
-
"to",
|
|
65
|
-
"compareFrom",
|
|
66
|
-
"compareTo",
|
|
67
|
-
];
|
|
68
56
|
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
69
57
|
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
70
58
|
const MONTH_OFFSET = 1;
|
|
71
59
|
const DATE_PART_LENGTH = 2;
|
|
72
60
|
const YEAR_PART_LENGTH = 4;
|
|
73
61
|
|
|
74
|
-
/**
|
|
75
|
-
* The keys of selector `id` in the page URL (`<id>.period`), or the bare keys
|
|
76
|
-
* (`period`) of a selection kept on its own.
|
|
77
|
-
*/
|
|
78
|
-
export function periodQueryKeys(id?: string): PeriodQueryKeys {
|
|
79
|
-
const prefix = id ? `${id}.` : "";
|
|
80
|
-
return Object.fromEntries(
|
|
81
|
-
KEY_NAMES.map((name) => [name, `${prefix}${name}`]),
|
|
82
|
-
) as unknown as PeriodQueryKeys;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
62
|
function readText(query: PeriodQuery, key: string): string | undefined {
|
|
86
63
|
const raw = query[key];
|
|
87
64
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
@@ -120,9 +120,12 @@ export function resolveTextVariables(
|
|
|
120
120
|
return hasUrlVariables(resolved) ? undefined : resolved;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
// A route token left without a value: `{{params.id}}`, `{{query.workspace}}
|
|
124
|
-
|
|
125
|
-
const
|
|
123
|
+
// A route token left without a value: `{{params.id}}`, `{{query.workspace}}`,
|
|
124
|
+
// `{{query.sales.period}}` (a query key may carry dots and dashes, as above).
|
|
125
|
+
const UNRESOLVED_ROUTE_TOKEN =
|
|
126
|
+
/\{\{(?:params\.\w+(?::\d+)?|query\.[\w.-]+)\}\}/g;
|
|
127
|
+
const UNRESOLVED_ROUTE_VALUE =
|
|
128
|
+
/^\{\{(?:params\.\w+(?::\d+)?|query\.[\w.-]+)\}\}$/;
|
|
126
129
|
const QUERY_SEPARATOR = "?";
|
|
127
130
|
const FRAGMENT_SEPARATOR = "#";
|
|
128
131
|
const PAIR_SEPARATOR = "&";
|
|
@@ -29,6 +29,7 @@ interface Props extends DefaultComponentProps {
|
|
|
29
29
|
fetchUrl?: string;
|
|
30
30
|
fetchUrlMethod?: string;
|
|
31
31
|
periodScope?: string;
|
|
32
|
+
realtimeTopic?: string | string[];
|
|
32
33
|
valueFormat?: ValueFormat;
|
|
33
34
|
currencyCode?: string;
|
|
34
35
|
valuePrecision?: ValuePrecision;
|
|
@@ -74,6 +75,7 @@ const { data, isLoading } = useChartFetch<ChartCardResponse>({
|
|
|
74
75
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
75
76
|
routeParams: () => props.routeParams,
|
|
76
77
|
periodScope: props.periodScope,
|
|
78
|
+
realtimeTopic: props.realtimeTopic,
|
|
77
79
|
watchSource: () => watchKey.value,
|
|
78
80
|
});
|
|
79
81
|
|
|
@@ -3,7 +3,7 @@ import { nextTick, useTemplateRef, watch, type FunctionalComponent } from "vue";
|
|
|
3
3
|
import { DialogDescription, DialogTitle } from "reka-ui";
|
|
4
4
|
import DmsIconWell from "../icon-well/IconWell.vue";
|
|
5
5
|
import DmsModalEscHint from "../../build/components/modal/ModalEscHint.vue";
|
|
6
|
-
import DmsFormRequiredLegend from "
|
|
6
|
+
import DmsFormRequiredLegend from "../form/FormRequiredLegend.vue";
|
|
7
7
|
import {
|
|
8
8
|
ConfirmActionError,
|
|
9
9
|
type ConfirmBodyRender,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ConfirmValues,
|
|
18
18
|
} from "../../composables/confirm/types";
|
|
19
19
|
import { resolveActionError } from "../../build/composables/confirm/actionError";
|
|
20
|
+
import { isConfirmTextMatch } from "../../build/composables/confirm/confirmTextMatch";
|
|
20
21
|
import type { ConfirmDialogField } from "#dms-core/app/types/confirm-dialog";
|
|
21
22
|
import { resolveFieldErrors } from "#dms-core/app/composables/useFieldErrors";
|
|
22
23
|
|
|
@@ -46,6 +47,8 @@ interface ConfirmModalProps {
|
|
|
46
47
|
* empty or different flags it under the field instead.
|
|
47
48
|
*/
|
|
48
49
|
confirmText?: string;
|
|
50
|
+
/** Compares the typed text with `confirmText` ignoring case. */
|
|
51
|
+
confirmTextCaseInsensitive?: boolean;
|
|
49
52
|
/**
|
|
50
53
|
* Fields filled in before confirming (labels resolved); their values reach
|
|
51
54
|
* `onConfirm`, and a field error it rejects with shows under its field.
|
|
@@ -92,6 +95,7 @@ const props = withDefaults(defineProps<ConfirmModalProps>(), {
|
|
|
92
95
|
impact: () => [],
|
|
93
96
|
body: undefined,
|
|
94
97
|
confirmText: undefined,
|
|
98
|
+
confirmTextCaseInsensitive: false,
|
|
95
99
|
fields: () => [],
|
|
96
100
|
component: undefined,
|
|
97
101
|
secondaryAction: undefined,
|
|
@@ -225,7 +229,13 @@ const modalContent = computed(() =>
|
|
|
225
229
|
);
|
|
226
230
|
|
|
227
231
|
const isTypedMatch = computed(
|
|
228
|
-
() =>
|
|
232
|
+
() =>
|
|
233
|
+
!props.confirmText ||
|
|
234
|
+
isConfirmTextMatch(
|
|
235
|
+
typedText.value,
|
|
236
|
+
props.confirmText,
|
|
237
|
+
props.confirmTextCaseInsensitive,
|
|
238
|
+
),
|
|
229
239
|
);
|
|
230
240
|
|
|
231
241
|
function close(value: boolean) {
|
|
@@ -66,7 +66,7 @@ import {
|
|
|
66
66
|
type FormRecordLoad,
|
|
67
67
|
trackFormRecordLoad,
|
|
68
68
|
} from "../../build/composables/form/formRecordLoad";
|
|
69
|
-
import DmsFormRequiredLegend from "
|
|
69
|
+
import DmsFormRequiredLegend from "./FormRequiredLegend.vue";
|
|
70
70
|
import DmsFormSections from "../../build/components/form/FormSections.vue";
|
|
71
71
|
|
|
72
72
|
const REALTIME_PRESENCE_FLAG = "_presence=1";
|
|
@@ -4,9 +4,14 @@ import type {
|
|
|
4
4
|
InputNumberEmits,
|
|
5
5
|
InputNumberSlots,
|
|
6
6
|
} from "@nuxt/ui/components/InputNumber.vue";
|
|
7
|
-
import { computed, nextTick } from "vue";
|
|
7
|
+
import { computed, nextTick, watch } from "vue";
|
|
8
8
|
import { useForwardPropsEmits } from "reka-ui";
|
|
9
9
|
import { useControlError } from "../../../build/composables/form/useControlError";
|
|
10
|
+
import {
|
|
11
|
+
BELOW_MIN_MESSAGE,
|
|
12
|
+
numberRangeError,
|
|
13
|
+
numberStepperState,
|
|
14
|
+
} from "../../../build/composables/form/numberRange";
|
|
10
15
|
import {
|
|
11
16
|
numberAdjustment,
|
|
12
17
|
parseTypedNumber,
|
|
@@ -18,6 +23,11 @@ defineOptions({ inheritAttrs: false });
|
|
|
18
23
|
interface InputNumberFieldProps extends InputNumberProps {
|
|
19
24
|
/** Unit after the field ("days"): an i18n key with `$`, or a literal. */
|
|
20
25
|
suffix?: string;
|
|
26
|
+
/**
|
|
27
|
+
* `false` keeps a value typed outside `min` / `max` as typed and reports
|
|
28
|
+
* it as the field's error, instead of clamping it into range on blur.
|
|
29
|
+
*/
|
|
30
|
+
clamp?: boolean;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
interface InputNumberAdjustEmits {
|
|
@@ -30,14 +40,29 @@ interface InputNumberAdjustEmits {
|
|
|
30
40
|
|
|
31
41
|
type InputNumberFieldEmits = InputNumberEmits & InputNumberAdjustEmits;
|
|
32
42
|
|
|
33
|
-
const props = defineProps<InputNumberFieldProps>()
|
|
43
|
+
const props = withDefaults(defineProps<InputNumberFieldProps>(), {
|
|
44
|
+
clamp: true,
|
|
45
|
+
});
|
|
34
46
|
const emits = defineEmits<InputNumberFieldEmits>();
|
|
35
47
|
defineSlots<InputNumberSlots>();
|
|
36
48
|
|
|
37
49
|
const forwarded = useForwardPropsEmits(props, emits);
|
|
50
|
+
const bounds = computed(() => ({ min: props.min, max: props.max }));
|
|
51
|
+
// Unclamped, the input is not told the bounds (it would clamp to them on
|
|
52
|
+
// blur): its steppers stop there instead, and the range is checked here.
|
|
38
53
|
const inputProps = computed(() => {
|
|
39
|
-
const {
|
|
40
|
-
|
|
54
|
+
const {
|
|
55
|
+
suffix: _suffix,
|
|
56
|
+
clamp: _clamp,
|
|
57
|
+
onAdjust: _onAdjust,
|
|
58
|
+
...rest
|
|
59
|
+
} = forwarded.value;
|
|
60
|
+
if (props.clamp) return rest;
|
|
61
|
+
const { min: _min, max: _max, ...unbounded } = rest;
|
|
62
|
+
return {
|
|
63
|
+
...unbounded,
|
|
64
|
+
...numberStepperState(props.modelValue, bounds.value),
|
|
65
|
+
};
|
|
41
66
|
});
|
|
42
67
|
const { processI18n } = useTranslation();
|
|
43
68
|
const { locale } = useI18n();
|
|
@@ -48,16 +73,25 @@ const DEFAULT_ORIENTATION = "vertical";
|
|
|
48
73
|
// A minus typed or pasted where no negative number is allowed is refused
|
|
49
74
|
// by the field before it shows: it says why, rather than leaving an emptied
|
|
50
75
|
// field to read as never filled.
|
|
51
|
-
const BELOW_MIN = "$dms.field_errors.too_small";
|
|
52
76
|
const { report } = useControlError();
|
|
53
77
|
|
|
54
78
|
function onIncomingText(event: InputEvent): void {
|
|
79
|
+
if (!props.clamp) return;
|
|
55
80
|
const text = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
|
|
56
81
|
if (!text) return;
|
|
57
82
|
const refusesNegative = props.min !== undefined && props.min >= 0;
|
|
58
|
-
report(refusesNegative && text.includes("-") ?
|
|
83
|
+
report(refusesNegative && text.includes("-") ? BELOW_MIN_MESSAGE : undefined);
|
|
59
84
|
}
|
|
60
85
|
|
|
86
|
+
watch(
|
|
87
|
+
() =>
|
|
88
|
+
props.clamp ? undefined : numberRangeError(props.modelValue, bounds.value),
|
|
89
|
+
(message) => {
|
|
90
|
+
if (!props.clamp) report(message);
|
|
91
|
+
},
|
|
92
|
+
{ immediate: true },
|
|
93
|
+
);
|
|
94
|
+
|
|
61
95
|
// What was typed since the field last settled, and the value it settled on.
|
|
62
96
|
let typedText: string | null = null;
|
|
63
97
|
let settledValue: number | null | undefined;
|
|
@@ -87,7 +121,12 @@ async function settle(event: Event): Promise<void> {
|
|
|
87
121
|
await nextTick();
|
|
88
122
|
const typed = parseTypedNumber(text, props.locale ?? locale.value);
|
|
89
123
|
const kept = settledValue === undefined ? props.modelValue : settledValue;
|
|
90
|
-
const adjustment = numberAdjustment(
|
|
124
|
+
const adjustment = numberAdjustment(
|
|
125
|
+
typed,
|
|
126
|
+
kept,
|
|
127
|
+
// Unclamped, only the step changes what was typed.
|
|
128
|
+
props.clamp ? bounds.value : {},
|
|
129
|
+
);
|
|
91
130
|
if (!adjustment) return;
|
|
92
131
|
emits("adjust", adjustment);
|
|
93
132
|
redraw(input);
|
|
@@ -14,6 +14,10 @@ import { useChartFetch } from "../../composables/chart/useChartFetch";
|
|
|
14
14
|
import { formatValue } from "../../composables/chart/formatValue";
|
|
15
15
|
import { resolveSparklineAccent } from "../../composables/chart/resolveSparklineAccent";
|
|
16
16
|
import { usePeriodScope } from "#dms-core/app/composables/period/usePeriodScope";
|
|
17
|
+
import {
|
|
18
|
+
hasUrlVariables,
|
|
19
|
+
resolveLinkVariables,
|
|
20
|
+
} from "../../build/utils/urlVariables";
|
|
17
21
|
import type {
|
|
18
22
|
TopListCardResponse,
|
|
19
23
|
TopListItem,
|
|
@@ -30,6 +34,7 @@ interface Props extends DefaultComponentProps {
|
|
|
30
34
|
fetchUrl?: string;
|
|
31
35
|
fetchUrlMethod?: string;
|
|
32
36
|
periodScope?: string;
|
|
37
|
+
realtimeTopic?: string | string[];
|
|
33
38
|
valueFormat?: ValueFormat;
|
|
34
39
|
currencyCode?: string;
|
|
35
40
|
valuePrecision?: ValuePrecision;
|
|
@@ -120,11 +125,28 @@ const { data, isLoading } = useChartFetch<TopListCardResponse>({
|
|
|
120
125
|
fetchUrlMethod: props.fetchUrlMethod,
|
|
121
126
|
routeParams: () => props.routeParams,
|
|
122
127
|
periodScope: props.periodScope,
|
|
128
|
+
realtimeTopic: props.realtimeTopic,
|
|
123
129
|
staticData: () => staticData.value,
|
|
124
130
|
watchSource: () => watchKey.value,
|
|
125
131
|
});
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
// A row's link may name the page it is shown on (`{{query.website}}`), so a
|
|
134
|
+
// list served once leads on with the page's own context, as the URL changes.
|
|
135
|
+
// The route is only read for a list holding such a link.
|
|
136
|
+
function withPageValues(item: TopListItem): TopListItem {
|
|
137
|
+
if (!item.to || !hasUrlVariables(item.to)) return item;
|
|
138
|
+
return {
|
|
139
|
+
...item,
|
|
140
|
+
to: resolveLinkVariables(item.to, {
|
|
141
|
+
routeParams: props.routeParams,
|
|
142
|
+
routeQuery: useDmsRoute().query as Record<string, unknown>,
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const items = computed<TopListItem[]>(() =>
|
|
148
|
+
(data.value?.items ?? []).map(withPageValues),
|
|
149
|
+
);
|
|
128
150
|
const hasAnyIcon = computed(() =>
|
|
129
151
|
items.value.some((item) => Boolean(item.icon || item.avatar)),
|
|
130
152
|
);
|
|
@@ -70,6 +70,26 @@ export interface ConfirmOptions
|
|
|
70
70
|
) => Promise<void | boolean | ConfirmPartialOutcome>;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* How `useConfirmFrom().confirmFrom(url, options)` asks a dialog the server
|
|
75
|
+
* words, as a declarative action's `confirm: { from }` does.
|
|
76
|
+
*/
|
|
77
|
+
export interface ConfirmFromOptions {
|
|
78
|
+
/**
|
|
79
|
+
* Fill-ins of the URL's `{placeholders}` and of the dialog's texts (a
|
|
80
|
+
* record's fields); the dialog's own `params` win in its texts.
|
|
81
|
+
*/
|
|
82
|
+
params?: Record<string, unknown>;
|
|
83
|
+
/** What the dialog leaves out (a colour, an icon, a confirm label). */
|
|
84
|
+
fallback?: Partial<ConfirmOptions>;
|
|
85
|
+
/**
|
|
86
|
+
* Runs the action from inside the dialog with its fields' values, as
|
|
87
|
+
* {@link ConfirmOptions.onConfirm}; left out, run it once `confirmFrom`
|
|
88
|
+
* resolved `true`.
|
|
89
|
+
*/
|
|
90
|
+
onConfirm?: ConfirmOptions["onConfirm"];
|
|
91
|
+
}
|
|
92
|
+
|
|
73
93
|
/** A secondary button of the confirm modal: its text resolved, its handler. */
|
|
74
94
|
export interface ConfirmSecondaryAction {
|
|
75
95
|
label: string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useActionConfirm } from "../../build/composables/confirm/useActionConfirm";
|
|
2
|
+
import type { ConfirmFromOptions } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Opens the confirmation dialog a route words (a `ConfirmDialogSerialized`,
|
|
6
|
+
* see `serializeConfirmDialog`), as a declarative action's
|
|
7
|
+
* `confirm: { from }` does: the URL is read, its texts resolved, then the
|
|
8
|
+
* standard confirm dialog shown. A failed read is a toast and resolves
|
|
9
|
+
* `false`.
|
|
10
|
+
*
|
|
11
|
+
* @returns `confirmFrom(url, options)`, resolving `true` when the user
|
|
12
|
+
* confirmed — never for a `blocked` dialog, which only explains
|
|
13
|
+
*/
|
|
14
|
+
export function useConfirmFrom() {
|
|
15
|
+
const { confirmAction } = useActionConfirm();
|
|
16
|
+
|
|
17
|
+
function confirmFrom(
|
|
18
|
+
from: string,
|
|
19
|
+
options: ConfirmFromOptions = {},
|
|
20
|
+
): Promise<boolean> {
|
|
21
|
+
return confirmAction(
|
|
22
|
+
{ from },
|
|
23
|
+
{
|
|
24
|
+
row: options.params,
|
|
25
|
+
fallback: options.fallback,
|
|
26
|
+
run: options.onConfirm,
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { confirmFrom };
|
|
32
|
+
}
|
|
@@ -721,10 +721,10 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
|
|
|
721
721
|
// them: the field shows which ones to fix.
|
|
722
722
|
message: entry.values?.length
|
|
723
723
|
? t("dms.field_errors.with_values", {
|
|
724
|
-
message: processApiMessage(entry.message),
|
|
724
|
+
message: processApiMessage(entry.message, entry.params),
|
|
725
725
|
values: entry.values.join(", "),
|
|
726
726
|
})
|
|
727
|
-
: processApiMessage(entry.message),
|
|
727
|
+
: processApiMessage(entry.message, entry.params),
|
|
728
728
|
values: entry.values,
|
|
729
729
|
path: entry.path,
|
|
730
730
|
})),
|
|
@@ -49,6 +49,8 @@ const BLOCKS: Record<string, string> = {
|
|
|
49
49
|
ActivityFeed:
|
|
50
50
|
"../layers/dms-ui/app/components/activity-feed/ActivityFeed.vue",
|
|
51
51
|
MeterBlock: "../layers/dms-ui/app/components/meter/MeterBlock.vue",
|
|
52
|
+
ChartCard: "../layers/dms-ui/app/components/chart/ChartCard.vue",
|
|
53
|
+
TopListCard: "../layers/dms-ui/app/components/top-list/TopListCard.vue",
|
|
52
54
|
};
|
|
53
55
|
const GLOBAL_COMPONENTS = ["DmsCard", "UIcon", "USkeleton", "UButton", "ULink"];
|
|
54
56
|
const SCOPE = "dashboard";
|
|
@@ -81,6 +83,9 @@ beforeEach(() => {
|
|
|
81
83
|
vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
|
|
82
84
|
vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
|
|
83
85
|
vi.stubGlobal("useDmsCookie", () => ref<string[]>([]));
|
|
86
|
+
// The cards reach these through auto-imports rather than imports.
|
|
87
|
+
vi.stubGlobal("useComponentEvent", () => ({}));
|
|
88
|
+
vi.stubGlobal("useWatch", () => ({ state: ref({}) }));
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
afterEach(() => vi.unstubAllGlobals());
|
|
@@ -18,6 +18,16 @@ describe("buildBreadcrumbTarget", () => {
|
|
|
18
18
|
expect(buildBreadcrumbTarget("/projects", {}, {})).to.equal(undefined);
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
it("links a module's root to the module's landing page", () => {
|
|
22
|
+
expect(
|
|
23
|
+
buildBreadcrumbTarget(
|
|
24
|
+
"/modules/api",
|
|
25
|
+
{ landingSlug: "/modules/api/overview" },
|
|
26
|
+
{},
|
|
27
|
+
),
|
|
28
|
+
).to.equal("/modules/api/overview");
|
|
29
|
+
});
|
|
30
|
+
|
|
21
31
|
it("carries only the query values an ancestor page requires", () => {
|
|
22
32
|
expect(
|
|
23
33
|
buildBreadcrumbTarget("/projects/project", PROJECT_PAGE, {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* `useConfirmFrom().confirmFrom(url)` opens the dialog a route words, as a
|
|
4
|
+
* declarative action's `confirm: { from }` does, for code of a module's own
|
|
5
|
+
* (a custom page, a block's button).
|
|
6
|
+
*/
|
|
7
|
+
import { createApp, defineComponent, h, type App } from "vue";
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { interpolateUrl } from "../layers/dms-core/app/utils/url-interpolation";
|
|
10
|
+
import { useConfirmFrom } from "../layers/dms-ui/app/composables/confirm/useConfirmFrom";
|
|
11
|
+
import { isConfirmTextMatch } from "../layers/dms-ui/app/build/composables/confirm/confirmTextMatch";
|
|
12
|
+
|
|
13
|
+
const authFetch = vi.fn();
|
|
14
|
+
const confirm = vi.fn(async () => true);
|
|
15
|
+
const addToast = vi.fn();
|
|
16
|
+
let app: App | undefined;
|
|
17
|
+
|
|
18
|
+
function mountConfirmFrom() {
|
|
19
|
+
let api: ReturnType<typeof useConfirmFrom> | undefined;
|
|
20
|
+
app = createApp(
|
|
21
|
+
defineComponent({
|
|
22
|
+
setup() {
|
|
23
|
+
api = useConfirmFrom();
|
|
24
|
+
return () => h("div");
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
app.mount(document.createElement("div"));
|
|
29
|
+
return api!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
authFetch.mockReset();
|
|
34
|
+
confirm.mockClear();
|
|
35
|
+
addToast.mockClear();
|
|
36
|
+
const fetch = Object.assign(authFetch, { raw: vi.fn() });
|
|
37
|
+
vi.stubGlobal("useAuthFetch", () => ({ $authFetch: fetch }));
|
|
38
|
+
vi.stubGlobal("useConfirm", () => ({ confirm }));
|
|
39
|
+
vi.stubGlobal("useToast", () => ({ add: addToast }));
|
|
40
|
+
vi.stubGlobal("useI18n", () => ({
|
|
41
|
+
t: (key: string, params?: Record<string, unknown>) =>
|
|
42
|
+
params?.name ? `${key}(${String(params.name)})` : key,
|
|
43
|
+
}));
|
|
44
|
+
vi.stubGlobal("interpolateUrl", interpolateUrl);
|
|
45
|
+
vi.stubGlobal("Color", { error: "error" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
app?.unmount();
|
|
50
|
+
app = undefined;
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("confirmFrom", () => {
|
|
55
|
+
it("reads the dialog from the URL filled with the params and shows it", async () => {
|
|
56
|
+
authFetch.mockResolvedValue({
|
|
57
|
+
title: "$campaigns.delete_title",
|
|
58
|
+
color: "error",
|
|
59
|
+
});
|
|
60
|
+
const onConfirm = vi.fn(async () => undefined);
|
|
61
|
+
const confirmed = await mountConfirmFrom().confirmFrom(
|
|
62
|
+
"/api/campaigns/{id}/delete-confirm",
|
|
63
|
+
{
|
|
64
|
+
params: { id: "c1", name: "Spring" },
|
|
65
|
+
fallback: { icon: "i-ph-trash" },
|
|
66
|
+
onConfirm,
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
expect(confirmed).toBe(true);
|
|
70
|
+
expect(authFetch).toHaveBeenCalledWith("/api/campaigns/c1/delete-confirm");
|
|
71
|
+
expect(confirm).toHaveBeenCalledWith(
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
title: "campaigns.delete_title(Spring)",
|
|
74
|
+
color: "error",
|
|
75
|
+
icon: "i-ph-trash",
|
|
76
|
+
onConfirm,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("never resolves true for a blocked dialog", async () => {
|
|
82
|
+
authFetch.mockResolvedValue({ title: "Cannot delete", blocked: true });
|
|
83
|
+
expect(await mountConfirmFrom().confirmFrom("/api/x")).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("toasts a failed read and resolves false", async () => {
|
|
87
|
+
authFetch.mockRejectedValue(new Error("down"));
|
|
88
|
+
expect(await mountConfirmFrom().confirmFrom("/api/x")).toBe(false);
|
|
89
|
+
expect(confirm).not.toHaveBeenCalled();
|
|
90
|
+
expect(addToast).toHaveBeenCalledOnce();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("isConfirmTextMatch", () => {
|
|
95
|
+
it("compares the trimmed text, ignoring case only when asked", () => {
|
|
96
|
+
expect(isConfirmTextMatch(" ACME ", "ACME")).toBe(true);
|
|
97
|
+
expect(isConfirmTextMatch("acme", "ACME")).toBe(false);
|
|
98
|
+
expect(isConfirmTextMatch("acme", "ACME", true)).toBe(true);
|
|
99
|
+
expect(isConfirmTextMatch("acmé", "ACME", true)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|