@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
@@ -9,6 +9,7 @@ import type {
9
9
  TableColumn,
10
10
  TableFilter,
11
11
  TableSharedData,
12
+ TableUrlFilter,
12
13
  } from "./Table.vue";
13
14
  import TableMenuFilter from "./MenuFilter.vue";
14
15
  import { formatFilterValue } from "../../composables/table/utils/formatFilterValue";
@@ -126,6 +127,17 @@ const deleteFilter = (index: number) => {
126
127
  tableSharedData?.value?.deleteFilter(index);
127
128
  };
128
129
 
130
+ const urlFilters = computed(() => tableSharedData?.value?.urlFilters ?? []);
131
+
132
+ const urlFilterLabel = (filter: TableUrlFilter): string =>
133
+ (filter.label && processI18n(filter.label)) ||
134
+ getColumn(filter.accessorKey)?.label ||
135
+ filter.param;
136
+
137
+ const removeUrlFilter = (filter: TableUrlFilter) => {
138
+ tableSharedData?.value?.emits("removeUrlFilter", filter.param);
139
+ };
140
+
129
141
  // The "clear all" action: offered while something narrows the rows, named
130
142
  // after what it clears (filters, search or both).
131
143
  const clearable = computed(
@@ -171,9 +183,9 @@ const displayValue = (filter: TableFilter): string => {
171
183
  };
172
184
 
173
185
  // The whole filter in words, for a chip shrunk to an ellipsis.
174
- const chipTitle = (filter: TableFilter): string =>
186
+ const chipTitle = (filter: TableFilter, label?: string): string =>
175
187
  [
176
- getColumn(filter.accessorKey)?.label,
188
+ label ?? getColumn(filter.accessorKey)?.label,
177
189
  t(`dms.table.compare_mode.${filter.mode}`),
178
190
  displayValue(filter),
179
191
  ]
@@ -184,6 +196,31 @@ const chipTitle = (filter: TableFilter): string =>
184
196
  <template>
185
197
  <div :class="ui.root()">
186
198
  <ul :class="ui.list()">
199
+ <li
200
+ v-for="filter in urlFilters"
201
+ :key="`url-${filter.param}`"
202
+ :class="ui.item()"
203
+ data-dms-url-filter
204
+ >
205
+ <span
206
+ :class="ui.chip()"
207
+ :title="chipTitle(filter, urlFilterLabel(filter))"
208
+ >
209
+ <span :class="ui.label()">{{ urlFilterLabel(filter) }}</span>
210
+ <span :class="ui.mode()">
211
+ {{ t(`dms.table.compare_mode.${filter.mode}`) }}
212
+ </span>
213
+ <span :class="ui.value()">{{ displayValue(filter) }}</span>
214
+ </span>
215
+ <button
216
+ type="button"
217
+ :class="ui.remove()"
218
+ :aria-label="t('dms.button.delete')"
219
+ @click="removeUrlFilter(filter)"
220
+ >
221
+ <UIcon :name="appConfig.ui.icons.close" />
222
+ </button>
223
+ </li>
187
224
  <li
188
225
  v-for="(filter, index) in filters"
189
226
  :key="`${filter.accessorKey}-${index}`"
@@ -247,6 +247,11 @@ export interface TableProps<T> {
247
247
  * says no row matches rather than that there are none yet.
248
248
  */
249
249
  hiddenFiltersApply?: boolean;
250
+ /**
251
+ * The page URL's filters declared `visible`: drawn as chips of the filters
252
+ * row, each removable (`removeUrlFilter`).
253
+ */
254
+ urlFilters?: TableUrlFilter[];
250
255
 
251
256
  data?: T[] | null;
252
257
  columns?: TableColumn<T>[];
@@ -307,6 +312,7 @@ export interface TableEmits<T> {
307
312
  (e: "details" | "edit", item: T): void;
308
313
  (e: "delete" | "archive" | "restore" | "export", itemIds: string[]): void;
309
314
  (e: "duplicate", itemId: string): void;
315
+ (e: "removeUrlFilter", param: string): void;
310
316
  }
311
317
 
312
318
  export interface TableFilter {
@@ -317,6 +323,14 @@ export interface TableFilter {
317
323
  initialValue?: unknown | unknown[];
318
324
  }
319
325
 
326
+ /** A filter the page URL applies and shows (see `TableProps.urlFilters`). */
327
+ export interface TableUrlFilter extends TableFilter {
328
+ /** The query parameter carrying it, removed with the chip. */
329
+ param: string;
330
+ /** The chip's name, before the column's label. */
331
+ label?: string;
332
+ }
333
+
320
334
  export interface TableSharedData<T> {
321
335
  table: Table<T>;
322
336
  emits: TableEmits<T>;
@@ -343,6 +357,8 @@ export interface TableSharedData<T> {
343
357
  resetSorting: () => void;
344
358
  hasCustomColumns: ComputedRef<boolean>;
345
359
  deleteFilter: (index: number) => void;
360
+ /** The URL filters shown as chips (see `TableProps.urlFilters`). */
361
+ urlFilters: TableUrlFilter[];
346
362
  /**
347
363
  * Clears everything that narrows the rows — filter chips (quick filters
348
364
  * included) and the toolbar search — and goes back to the first page.
@@ -1183,6 +1199,7 @@ watchEffect(() => {
1183
1199
  resetSorting,
1184
1200
  hasCustomColumns,
1185
1201
  deleteFilter,
1202
+ urlFilters: props.urlFilters ?? [],
1186
1203
  resetFilters,
1187
1204
  clearSearch,
1188
1205
  clearFilters,
@@ -1348,7 +1365,7 @@ defineShortcuts({
1348
1365
 
1349
1366
  <TableFiltersRow
1350
1367
  v-if="
1351
- filtersRowOpen &&
1368
+ (filtersRowOpen || !!urlFilters?.length) &&
1352
1369
  resolvedCapabilities.filters &&
1353
1370
  resolvedChrome.filters
1354
1371
  "
@@ -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
- const url = rowData
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,10 @@
1
+ import type { InjectionKey } from "vue";
2
+
3
+ /** Where a form collects the errors its controls report, by field id. */
4
+ export interface FormControlErrors {
5
+ report: (fieldId: string, message: string | undefined) => void;
6
+ }
7
+
8
+ export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
9
+ "dms-form-control-errors",
10
+ );
@@ -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
+ }
@@ -1,34 +1,10 @@
1
- import { formFieldInjectionKey } from "@nuxt/ui/composables/useFormField";
2
- import { type InjectionKey, inject, onBeforeUnmount, ref } from "vue";
3
-
4
- /** Where a form collects the errors its controls report, by field id. */
5
- export interface FormControlErrors {
6
- report: (fieldId: string, message: string | undefined) => void;
7
- }
8
-
9
- export const FORM_CONTROL_ERRORS_KEY: InjectionKey<FormControlErrors> = Symbol(
10
- "dms-form-control-errors",
11
- );
12
-
13
1
  /**
14
- * Lets a control refuse what was typed into it (a duration or JSON it cannot
15
- * read): inside a form, the message becomes the field's error, which blocks
16
- * the submit and joins the form's summary like any other. `message` holds it
17
- * for a control used outside a form.
2
+ * Former home of `useControlError`, kept importable for the modules that
3
+ * reached it here.
4
+ *
5
+ * @deprecated Import `useControlError` from
6
+ * `#dms-ui/app/composables/form/useControlError` (or rely on its auto-import).
7
+ * This path is removed in the next breaking release.
18
8
  */
19
- export function useControlError() {
20
- const field = inject(formFieldInjectionKey, undefined);
21
- const errors = inject(FORM_CONTROL_ERRORS_KEY, null);
22
- const message = ref<string>();
23
-
24
- function report(next: string | undefined): void {
25
- if (message.value === next) return;
26
- message.value = next;
27
- const name = field?.value?.name;
28
- if (name) errors?.report(name, next);
29
- }
30
-
31
- onBeforeUnmount(() => report(undefined));
32
-
33
- return { message, report };
34
- }
9
+ export * from "../../../composables/form/useControlError";
10
+ export * from "./formControlErrors";
@@ -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;
@@ -82,12 +82,18 @@ export interface DeepLinkedRow<T> {
82
82
  row?: T;
83
83
  /** The route answered 404: the row the link names no longer exists. */
84
84
  isMissing: boolean;
85
+ /**
86
+ * The read failed for another reason (a refusal, the network, a server
87
+ * error): the row may still exist, the link just could not be followed.
88
+ */
89
+ isFailed?: boolean;
85
90
  }
86
91
 
87
92
  /**
88
93
  * The row a deep link names: the listed one, else the one read by id — a row
89
- * beyond the first page. A route answering 404 marks it missing; any other
90
- * failure (a refusal, the network) or no route at all finds nothing.
94
+ * beyond the first page. A route answering 404 marks it missing, any other
95
+ * failure (a refusal, the network) marks it failed, and no route at all finds
96
+ * nothing.
91
97
  */
92
98
  export async function resolveDeepLinkedRow<T>(
93
99
  id: string,
@@ -99,6 +105,31 @@ export async function resolveDeepLinkedRow<T>(
99
105
  const row = await source.getRow(id);
100
106
  return { row: row ?? undefined, isMissing: false };
101
107
  } catch (error: unknown) {
102
- return { isMissing: readFetchNotFound(error) !== null };
108
+ if (readFetchNotFound(error) !== null) return { isMissing: true };
109
+ return { isMissing: false, isFailed: true };
103
110
  }
104
111
  }
112
+
113
+ /**
114
+ * What a table does with the row a deep link resolved to: open it, let a side
115
+ * panel show its own "no longer available" state, tell the user the link led
116
+ * nowhere (a drawer or a modal has no such state, and a table with no route to
117
+ * read one row finds nothing either), or tell them the row could not be read
118
+ * (a refusal, the network) without claiming it is gone.
119
+ */
120
+ export type DeepLinkOutcome =
121
+ | "open"
122
+ | "missingPanel"
123
+ | "notify"
124
+ | "notifyFailed";
125
+
126
+ export function deepLinkOutcome(
127
+ found: DeepLinkedRow<unknown>,
128
+ action: CustomRowAction,
129
+ ): DeepLinkOutcome {
130
+ if (found.row) return "open";
131
+ if (found.isFailed) return "notifyFailed";
132
+ if (found.isMissing && action.target.type === "sidePanel")
133
+ return "missingPanel";
134
+ return "notify";
135
+ }
@@ -0,0 +1,29 @@
1
+ import type { TableUrlFilter } from "../../../components/table/Table.vue";
2
+ import type { QueryParamFilters } from "../../../../composables/table-view/types/config";
3
+
4
+ const DEFAULT_FILTER_MODE = "is";
5
+
6
+ /** The `queryParamFilters` declared `visible` that the URL applies now. */
7
+ export function visibleUrlFilters(
8
+ filters: QueryParamFilters | undefined,
9
+ query: Record<string, unknown>,
10
+ ): TableUrlFilter[] {
11
+ return Object.entries(filters ?? {})
12
+ .filter(([param, config]) => config.visible && query[param] !== undefined)
13
+ .map(([param, config]) => ({
14
+ param,
15
+ label: config.label,
16
+ accessorKey: config.field,
17
+ mode: config.mode || DEFAULT_FILTER_MODE,
18
+ value: query[param],
19
+ }));
20
+ }
21
+
22
+ /** The URL query without `param`, for a chip the user removed. */
23
+ export function withoutQueryParam<Query extends Record<string, unknown>>(
24
+ query: Query,
25
+ param: string,
26
+ ): Query {
27
+ const { [param]: _removed, ...rest } = query;
28
+ return rest as Query;
29
+ }
@@ -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
- const UNRESOLVED_ROUTE_TOKEN = /\{\{(?:params|query)\.\w+(?::\d+)?\}\}/g;
125
- const UNRESOLVED_ROUTE_VALUE = /^\{\{(?:params|query)\.\w+(?::\d+)?\}\}$/;
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 = "&";
@@ -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 "../../build/components/form/FormRequiredLegend.vue";
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
- () => !props.confirmText || typedText.value.trim() === props.confirmText,
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) {
@@ -56,7 +56,7 @@ import {
56
56
  resolveSectionNav,
57
57
  sectionState,
58
58
  } from "../../build/composables/form/formSections";
59
- import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/useControlError";
59
+ import { FORM_CONTROL_ERRORS_KEY } from "../../build/composables/form/formControlErrors";
60
60
  import { useInstantForm } from "../../build/composables/form/useInstantForm";
61
61
  import { useInstantSaveHeader } from "#dms-layout/app/composables/layout/useInstantSaveHeader";
62
62
  import DmsFormEntries from "../../build/components/form/FormEntries.vue";
@@ -66,7 +66,7 @@ import {
66
66
  type FormRecordLoad,
67
67
  trackFormRecordLoad,
68
68
  } from "../../build/composables/form/formRecordLoad";
69
- import DmsFormRequiredLegend from "../../build/components/form/FormRequiredLegend.vue";
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";
@@ -14,7 +14,7 @@ import {
14
14
  } from "@internationalized/date";
15
15
  import { reactivePick } from "@vueuse/core";
16
16
  import { useFormField } from "@nuxt/ui/composables/useFormField";
17
- import { useControlError } from "../../../build/composables/form/useControlError";
17
+ import { useControlError } from "../../../composables/form/useControlError";
18
18
  import {
19
19
  FIELD_TRIGGER_CLASS,
20
20
  FIELD_TRIGGER_INVALID_CLASS,
@@ -2,7 +2,7 @@
2
2
  import { getCurrentInstance, h, render } from "vue";
3
3
  import { useFormField } from "@nuxt/ui/composables/useFormField";
4
4
  import UIcon from "@nuxt/ui/runtime/vue/components/Icon.vue";
5
- import { useControlError } from "../../../build/composables/form/useControlError";
5
+ import { useControlError } from "../../../composables/form/useControlError";
6
6
  import { codeLanguageLabel } from "../../../build/utils/codeLanguages";
7
7
  import type {
8
8
  CodeEditor,
@@ -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
- import { useControlError } from "../../../build/composables/form/useControlError";
9
+ import { useControlError } from "../../../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 { suffix: _suffix, onAdjust: _onAdjust, ...rest } = forwarded.value;
40
- return rest;
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("-") ? BELOW_MIN : undefined);
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(typed, kept, props);
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);