@antelopejs/dms 0.7.13 → 0.7.15
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 +44 -0
- package/dist/db/models/userNotifications.model.js +3 -27
- package/dist/db/models/userNotifications.model.js.map +1 -1
- 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/frontend-modules.d.ts +4 -2
- package/dist/implementations/dms/frontend-modules.js +4 -0
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
- package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
- package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
- package/dist/implementations/dms/manifest.d.ts +1 -0
- package/dist/implementations/dms/manifest.js +3 -1
- package/dist/implementations/dms/manifest.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/integration/notifications.test.js +6 -3
- package/dist/test/integration/notifications.test.js.map +1 -1
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
- package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
- package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
- 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/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/utils/pageValidationRedirect.ts +32 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
- package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
- package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
- 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-ui/app/build/components/table/FiltersRow.vue +39 -2
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
- 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/formControlErrors.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +2 -2
- package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +47 -8
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +61 -2
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +21 -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/useControlError.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -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/control-error-path.test.ts +11 -0
- package/frontend-vue/tests/email-layout.test.ts +124 -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/input-tags.test.ts +1 -1
- package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
- package/frontend-vue/tests/row-side-panel.test.ts +2 -2
- package/frontend-vue/tests/table-url-filters.test.ts +158 -0
- package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
- 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
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
formErrorsInjectionKey,
|
|
4
4
|
formFieldInjectionKey,
|
|
5
5
|
} from "@nuxt/ui/composables/useFormField";
|
|
6
|
-
import { useControlError } from "../../../
|
|
6
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
7
7
|
import {
|
|
8
8
|
addTags,
|
|
9
9
|
invalidTags,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { InputProps } from "@nuxt/ui/components/Input.vue";
|
|
3
|
-
import { useControlError } from "../../../
|
|
3
|
+
import { useControlError } from "../../../composables/form/useControlError";
|
|
4
4
|
|
|
5
5
|
interface InputTimeProps extends Omit<InputProps, "modelValue" | "type"> {
|
|
6
6
|
modelValue?: number | null;
|
|
@@ -78,9 +78,12 @@ import { selectedRowIds } from "../../build/composables/table-view/utils/bulkAct
|
|
|
78
78
|
import { isBulkAction } from "../../build/composables/actions/bulkSelection";
|
|
79
79
|
import { selectionScopeKey } from "../../build/utils/selectionScope";
|
|
80
80
|
import {
|
|
81
|
+
type DeepLinkOutcome,
|
|
82
|
+
deepLinkOutcome,
|
|
81
83
|
isDeepLinkAction,
|
|
82
84
|
readRecordId,
|
|
83
85
|
resolveDeepLinkedRow,
|
|
86
|
+
writeRecordId,
|
|
84
87
|
} from "../../build/composables/table-view/utils/recordLink";
|
|
85
88
|
import {
|
|
86
89
|
rowSidePanelOwnerKey,
|
|
@@ -112,8 +115,13 @@ import {
|
|
|
112
115
|
} from "../../build/composables/table-view/useExpandedRowDetails";
|
|
113
116
|
import type {
|
|
114
117
|
TableAccumulation,
|
|
118
|
+
TableUrlFilter,
|
|
115
119
|
TableViewSwitcherItem,
|
|
116
120
|
} from "../../build/components/table/Table.vue";
|
|
121
|
+
import {
|
|
122
|
+
visibleUrlFilters,
|
|
123
|
+
withoutQueryParam,
|
|
124
|
+
} from "../../build/composables/table-view/utils/urlFilters";
|
|
117
125
|
import {
|
|
118
126
|
defineAsyncComponent,
|
|
119
127
|
useTemplateRef,
|
|
@@ -396,6 +404,15 @@ const queryParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
|
396
404
|
}));
|
|
397
405
|
});
|
|
398
406
|
|
|
407
|
+
const shownUrlFilters = computed<TableUrlFilter[]>(() =>
|
|
408
|
+
visibleUrlFilters(queryParamFilters, route.query as Record<string, unknown>),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const removeUrlFilter = (param: string) =>
|
|
412
|
+
router.replace({
|
|
413
|
+
query: withoutQueryParam(route.query, param),
|
|
414
|
+
});
|
|
415
|
+
|
|
399
416
|
const routeParamHiddenFilters = computed<TableFilter[]>(() => {
|
|
400
417
|
if (!routeParamFilters || !routeParams) return [];
|
|
401
418
|
|
|
@@ -1686,10 +1703,50 @@ const openRecordFromUrl = async () => {
|
|
|
1686
1703
|
idOf: getRowId,
|
|
1687
1704
|
getRow: tableRows.getRow,
|
|
1688
1705
|
});
|
|
1689
|
-
|
|
1690
|
-
|
|
1706
|
+
const outcome = deepLinkOutcome({ row, isMissing }, deepLinkAction);
|
|
1707
|
+
deepLinkOutcomes[outcome](deepLinkAction, id, row as Data);
|
|
1691
1708
|
};
|
|
1692
1709
|
|
|
1710
|
+
const notifyUnresolvedRecordLink = () => {
|
|
1711
|
+
writeRecordId(urlScope);
|
|
1712
|
+
toast.add({
|
|
1713
|
+
title: t("dms.record_link.not_found_title"),
|
|
1714
|
+
description: t("dms.record_link.not_found_description"),
|
|
1715
|
+
color: Color.warning,
|
|
1716
|
+
icon: "i-ph-link-break",
|
|
1717
|
+
});
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
const notifyFailedRecordLink = () => {
|
|
1721
|
+
writeRecordId(urlScope);
|
|
1722
|
+
toast.add({
|
|
1723
|
+
title: t("dms.record_link.failed_title"),
|
|
1724
|
+
description: t("dms.record_link.failed_description"),
|
|
1725
|
+
color: Color.error,
|
|
1726
|
+
icon: "i-ph-warning-circle",
|
|
1727
|
+
});
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
const deepLinkOutcomes: Record<
|
|
1731
|
+
DeepLinkOutcome,
|
|
1732
|
+
(action: CustomRowAction, id: string, row: Data) => void
|
|
1733
|
+
> = {
|
|
1734
|
+
open: (action, _id, row) => handleCustomRowAction(action, row),
|
|
1735
|
+
missingPanel: (action, id) => handleMissingRow(action, id),
|
|
1736
|
+
notify: notifyUnresolvedRecordLink,
|
|
1737
|
+
notifyFailed: notifyFailedRecordLink,
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
// A visit that keeps the page mounted (a link to the same page with another
|
|
1741
|
+
// `?record=`) opens the row it names, as a fresh load would. The table's own
|
|
1742
|
+
// writes replace the URL in place and never reach the route.
|
|
1743
|
+
watch(
|
|
1744
|
+
() => readRecordId(route.query, urlScope),
|
|
1745
|
+
(id, previous) => {
|
|
1746
|
+
if (id && id !== previous) void openRecordFromUrl();
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1693
1750
|
const selectedIds = computed(() => selectedRowIds(rowSelect.value));
|
|
1694
1751
|
|
|
1695
1752
|
const isRowActionEnabled = (
|
|
@@ -2065,6 +2122,7 @@ onMounted(() => {
|
|
|
2065
2122
|
:footer="resolvedFooter"
|
|
2066
2123
|
:empty-states="props.emptyStates"
|
|
2067
2124
|
:hidden-filters-apply="queryParamHiddenFilters.length > 0"
|
|
2125
|
+
:url-filters="shownUrlFilters"
|
|
2068
2126
|
:default-page-size="props.pageSize"
|
|
2069
2127
|
:archive-toggle="canToggleArchived"
|
|
2070
2128
|
:displays="availableDisplays"
|
|
@@ -2102,6 +2160,7 @@ onMounted(() => {
|
|
|
2102
2160
|
@export-all="handleExportTable"
|
|
2103
2161
|
@bulk-action="runBulkAction"
|
|
2104
2162
|
@duplicate="handleRowDuplicate"
|
|
2163
|
+
@remove-url-filter="removeUrlFilter"
|
|
2105
2164
|
@edit="handleRowEdit"
|
|
2106
2165
|
>
|
|
2107
2166
|
<template
|
|
@@ -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,
|
|
@@ -126,7 +130,23 @@ const { data, isLoading } = useChartFetch<TopListCardResponse>({
|
|
|
126
130
|
watchSource: () => watchKey.value,
|
|
127
131
|
});
|
|
128
132
|
|
|
129
|
-
|
|
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
|
+
);
|
|
130
150
|
const hasAnyIcon = computed(() =>
|
|
131
151
|
items.value.some((item) => Boolean(item.icon || item.avatar)),
|
|
132
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
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
|
|
2
|
+
import { inject, onBeforeUnmount, ref } from "vue";
|
|
3
|
+
import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Lets a control refuse what was typed into it (a duration or JSON it cannot
|
|
7
|
+
* read): inside a form, the message becomes the field's error, which blocks
|
|
8
|
+
* the submit and joins the form's summary like any other. `message` holds it
|
|
9
|
+
* for a control used outside a form.
|
|
10
|
+
*
|
|
11
|
+
* Call it in the setup of a field's input component. `report(message)` sets
|
|
12
|
+
* the error, `report(undefined)` clears it; the error is cleared when the
|
|
13
|
+
* component unmounts.
|
|
14
|
+
*/
|
|
15
|
+
export function useControlError() {
|
|
16
|
+
const field = inject(formFieldInjectionKey, undefined);
|
|
17
|
+
const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
|
|
18
|
+
const message = ref<string>();
|
|
19
|
+
|
|
20
|
+
function report(next: string | undefined): void {
|
|
21
|
+
if (message.value === next) return;
|
|
22
|
+
message.value = next;
|
|
23
|
+
const name = field?.value?.name;
|
|
24
|
+
if (name) errors?.report(name, next);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onBeforeUnmount(() => report(undefined));
|
|
28
|
+
|
|
29
|
+
return { message, report };
|
|
30
|
+
}
|
|
@@ -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
|
})),
|
|
@@ -14,6 +14,10 @@ import type { BlockAction } from "../../../build/components/blocks/BlockActions.
|
|
|
14
14
|
export interface QueryParamFilter {
|
|
15
15
|
field: string;
|
|
16
16
|
mode?: string;
|
|
17
|
+
/** Shown as a removable chip while the URL applies it. */
|
|
18
|
+
visible?: boolean;
|
|
19
|
+
/** The chip's name; the field's column label by default. */
|
|
20
|
+
label?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export type QueryParamFilters = Record<string, QueryParamFilter>;
|
|
@@ -753,6 +753,12 @@
|
|
|
753
753
|
"held_searching": "search active",
|
|
754
754
|
"held_sorted": "sorted otherwise than by default",
|
|
755
755
|
"held_scrolled": "scrolled down"
|
|
756
|
+
},
|
|
757
|
+
"record_link": {
|
|
758
|
+
"not_found_title": "This entry is no longer available",
|
|
759
|
+
"not_found_description": "The link points to an entry that was deleted, or that this list cannot show.",
|
|
760
|
+
"failed_title": "This entry could not be opened",
|
|
761
|
+
"failed_description": "Loading it failed. Try again in a moment."
|
|
756
762
|
}
|
|
757
763
|
}
|
|
758
764
|
}
|
|
@@ -754,6 +754,12 @@
|
|
|
754
754
|
"held_searching": "recherche active",
|
|
755
755
|
"held_sorted": "tri autre que celui par défaut",
|
|
756
756
|
"held_scrolled": "défilé vers le bas"
|
|
757
|
+
},
|
|
758
|
+
"record_link": {
|
|
759
|
+
"not_found_title": "Cette entrée n’est plus disponible",
|
|
760
|
+
"not_found_description": "Le lien mène à une entrée supprimée, ou que cette liste ne peut pas afficher.",
|
|
761
|
+
"failed_title": "Impossible d’ouvrir cette entrée",
|
|
762
|
+
"failed_description": "Son chargement a échoué. Réessayez dans un instant."
|
|
757
763
|
}
|
|
758
764
|
}
|
|
759
765
|
}
|
|
@@ -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
|
+
});
|
|
@@ -171,6 +171,41 @@ it("flags an empty or different typed text instead of disabling confirm", async
|
|
|
171
171
|
expect(closed).toHaveBeenCalledWith(true);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
it("matches the typed text ignoring case when asked", async () => {
|
|
175
|
+
const onConfirm = vi.fn(async () => undefined);
|
|
176
|
+
mount({
|
|
177
|
+
title: "Delete?",
|
|
178
|
+
confirmLabel: "Delete",
|
|
179
|
+
confirmText: "Acme Corp",
|
|
180
|
+
confirmTextCaseInsensitive: true,
|
|
181
|
+
onConfirm,
|
|
182
|
+
});
|
|
183
|
+
await flush();
|
|
184
|
+
|
|
185
|
+
await type("acme corp");
|
|
186
|
+
confirmButton().click();
|
|
187
|
+
await flush();
|
|
188
|
+
expect(typedError()).toBeUndefined();
|
|
189
|
+
expect(onConfirm).toHaveBeenCalledOnce();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("keeps the case-sensitive match by default", async () => {
|
|
193
|
+
const onConfirm = vi.fn(async () => undefined);
|
|
194
|
+
mount({
|
|
195
|
+
title: "Delete?",
|
|
196
|
+
confirmLabel: "Delete",
|
|
197
|
+
confirmText: "Acme Corp",
|
|
198
|
+
onConfirm,
|
|
199
|
+
});
|
|
200
|
+
await flush();
|
|
201
|
+
|
|
202
|
+
await type("acme corp");
|
|
203
|
+
confirmButton().click();
|
|
204
|
+
await flush();
|
|
205
|
+
expect(typedError()).toBe('dms.confirm.type_mismatch {"text":"Acme Corp"}');
|
|
206
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
174
209
|
it("runs the body check with the typed one and stays open when it fails", async () => {
|
|
175
210
|
const validate = vi.fn(() => false);
|
|
176
211
|
const onConfirm = vi.fn(async () => undefined);
|