@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/db/models/userNotifications.model.js +3 -27
  3. package/dist/db/models/userNotifications.model.js.map +1 -1
  4. package/dist/implementations/dms/client-ip.d.ts +2 -0
  5. package/dist/implementations/dms/client-ip.js +9 -0
  6. package/dist/implementations/dms/client-ip.js.map +1 -0
  7. package/dist/implementations/dms/frontend-modules.d.ts +4 -2
  8. package/dist/implementations/dms/frontend-modules.js +4 -0
  9. package/dist/implementations/dms/frontend-modules.js.map +1 -1
  10. package/dist/implementations/dms/frontend-public-endpoints.d.ts +3 -0
  11. package/dist/implementations/dms/frontend-public-endpoints.js +70 -0
  12. package/dist/implementations/dms/frontend-public-endpoints.js.map +1 -0
  13. package/dist/implementations/dms/manifest.d.ts +1 -0
  14. package/dist/implementations/dms/manifest.js +3 -1
  15. package/dist/implementations/dms/manifest.js.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/routes/auth/client-ip.d.ts +1 -4
  19. package/dist/routes/auth/client-ip.js.map +1 -1
  20. package/dist/test/integration/notifications.test.js +6 -3
  21. package/dist/test/integration/notifications.test.js.map +1 -1
  22. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.d.ts +1 -0
  23. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js +74 -0
  24. package/dist/test/unit/implementations/dms/frontend-public-endpoints.test.js.map +1 -0
  25. package/dist/test/unit/implementations/dms/manifest-strip.test.js +5 -0
  26. package/dist/test/unit/implementations/dms/manifest-strip.test.js.map +1 -1
  27. package/dist/test/unit/interfaces/dms/client-ip.test.d.ts +1 -0
  28. package/dist/test/unit/interfaces/dms/client-ip.test.js +61 -0
  29. package/dist/test/unit/interfaces/dms/client-ip.test.js.map +1 -0
  30. package/frontend-vue/layers/dms-core/app/composables/period/periodLink.ts +60 -0
  31. package/frontend-vue/layers/dms-core/app/composables/translation/useTranslation.ts +9 -4
  32. package/frontend-vue/layers/dms-core/app/composables/useFieldErrors.ts +34 -4
  33. package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +2 -0
  34. package/frontend-vue/layers/dms-layout/app/build/utils/pageValidationRedirect.ts +32 -0
  35. package/frontend-vue/layers/dms-layout/app/emails/components/EmailLayout.vue +35 -12
  36. package/frontend-vue/layers/dms-layout/app/emails/components/email-logo.ts +54 -0
  37. package/frontend-vue/layers/dms-layout/app/emails/components/index.ts +2 -0
  38. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +29 -7
  39. package/frontend-vue/layers/dms-layout/app/types/page.ts +2 -0
  40. package/frontend-vue/layers/dms-ui/app/build/components/table/FiltersRow.vue +39 -2
  41. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +18 -1
  42. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +28 -7
  43. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmTextMatch.ts +16 -0
  44. package/frontend-vue/layers/dms-ui/app/build/composables/form/formControlErrors.ts +10 -0
  45. package/frontend-vue/layers/dms-ui/app/build/composables/form/numberRange.ts +53 -0
  46. package/frontend-vue/layers/dms-ui/app/build/composables/form/useControlError.ts +8 -32
  47. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +6 -29
  48. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +34 -3
  49. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +29 -0
  50. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +6 -3
  51. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +12 -2
  52. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +2 -2
  53. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +1 -1
  54. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +1 -1
  55. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +47 -8
  56. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTags.vue +1 -1
  57. package/frontend-vue/layers/dms-ui/app/components/form/components/InputTime.vue +1 -1
  58. package/frontend-vue/layers/dms-ui/app/components/form/components/KeyValue.vue +1 -1
  59. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +61 -2
  60. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +21 -1
  61. package/frontend-vue/layers/dms-ui/app/composables/confirm/index.ts +1 -0
  62. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +20 -0
  63. package/frontend-vue/layers/dms-ui/app/composables/confirm/useConfirmFrom.ts +32 -0
  64. package/frontend-vue/layers/dms-ui/app/composables/form/useControlError.ts +30 -0
  65. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +2 -2
  66. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  67. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +6 -0
  68. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +6 -0
  69. package/frontend-vue/tests/confirm-from.test.ts +101 -0
  70. package/frontend-vue/tests/confirm-typed-text.test.ts +35 -0
  71. package/frontend-vue/tests/context-links.test.ts +220 -0
  72. package/frontend-vue/tests/control-error-path.test.ts +11 -0
  73. package/frontend-vue/tests/email-layout.test.ts +124 -0
  74. package/frontend-vue/tests/field-errors.test.ts +35 -0
  75. package/frontend-vue/tests/form-required-legend.test.ts +34 -0
  76. package/frontend-vue/tests/form-server-field-errors.test.ts +18 -1
  77. package/frontend-vue/tests/input-number-range.test.ts +135 -0
  78. package/frontend-vue/tests/input-tags.test.ts +1 -1
  79. package/frontend-vue/tests/page-validation-redirect.test.ts +221 -0
  80. package/frontend-vue/tests/row-side-panel.test.ts +2 -2
  81. package/frontend-vue/tests/table-url-filters.test.ts +158 -0
  82. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +23 -0
  83. package/package.json +2 -2
  84. package/skills/dms-module/SKILL.md +3 -0
  85. /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 "../../../build/composables/form/useControlError";
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 "../../../build/composables/form/useControlError";
3
+ import { useControlError } from "../../../composables/form/useControlError";
4
4
 
5
5
  interface InputTimeProps extends Omit<InputProps, "modelValue" | "type"> {
6
6
  modelValue?: number | null;
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { useControlError } from "../../../build/composables/form/useControlError";
2
+ import { useControlError } from "../../../composables/form/useControlError";
3
3
  import {
4
4
  type KeyValueRow,
5
5
  keyValueProblem,
@@ -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
- if (row) handleCustomRowAction(deepLinkAction, row as Data);
1690
- else if (isMissing) handleMissingRow(deepLinkAction, id);
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
- const items = computed<TopListItem[]>(() => data.value?.items ?? []);
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
  );
@@ -1,3 +1,4 @@
1
1
  export * from "./types";
2
2
  export * from "./useConfirm";
3
+ export * from "./useConfirmFrom";
3
4
  export * from "./usePresenceEditWarning";
@@ -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);