@antelopejs/dms 0.7.21 → 0.8.0

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 (157) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/implementations/dms-auth/account-emails.d.ts +4 -0
  3. package/dist/implementations/dms-auth/account-emails.js +62 -0
  4. package/dist/implementations/dms-auth/account-emails.js.map +1 -0
  5. package/dist/implementations/dms-auth/index.d.ts +1 -3
  6. package/dist/implementations/dms-auth/index.js +15 -61
  7. package/dist/implementations/dms-auth/index.js.map +1 -1
  8. package/dist/pages/settings/users/email-change.js +3 -4
  9. package/dist/pages/settings/users/email-change.js.map +1 -1
  10. package/dist/pages/settings/users/invites.js +1 -1
  11. package/dist/pages/settings/users/invites.js.map +1 -1
  12. package/dist/pages/settings/users/members.js +1 -1
  13. package/dist/pages/settings/users/members.js.map +1 -1
  14. package/dist/pages/settings/users/notifications.js +1 -0
  15. package/dist/pages/settings/users/notifications.js.map +1 -1
  16. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.d.ts +1 -0
  17. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js +34 -0
  18. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms/hook-handler-results.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js +49 -0
  21. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-topics.test.js +17 -0
  23. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  24. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +2 -2
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -1
  26. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.d.ts +1 -0
  27. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js +29 -0
  28. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js.map +1 -0
  29. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +5 -0
  30. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
  31. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js +12 -2
  32. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js.map +1 -1
  33. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +29 -4
  34. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +14 -11
  36. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  37. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js +8 -5
  38. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js.map +1 -1
  39. package/dist/test/unit/utils/email-subjects.test.d.ts +1 -0
  40. package/dist/test/unit/utils/email-subjects.test.js +27 -0
  41. package/dist/test/unit/utils/email-subjects.test.js.map +1 -0
  42. package/dist/utils/admin-invite-email.js +2 -4
  43. package/dist/utils/admin-invite-email.js.map +1 -1
  44. package/dist/utils/email-change-mail.d.ts +1 -1
  45. package/dist/utils/email-change-mail.js +11 -6
  46. package/dist/utils/email-change-mail.js.map +1 -1
  47. package/dist/utils/email-subjects.d.ts +3 -0
  48. package/dist/utils/email-subjects.js +32 -0
  49. package/dist/utils/email-subjects.js.map +1 -0
  50. package/dist/utils/export-jobs.js +15 -10
  51. package/dist/utils/export-jobs.js.map +1 -1
  52. package/frontend-vue/layers/dms-core/app/utils/url-interpolation.ts +29 -11
  53. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +17 -0
  54. package/frontend-vue/layers/dms-layout/app/app.config.ts +5 -0
  55. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +18 -2
  56. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +16 -1
  57. package/frontend-vue/layers/dms-layout/app/build/composables/layout/useHeaderActionControls.ts +14 -0
  58. package/frontend-vue/layers/dms-layout/app/components/layout/HStack.vue +16 -0
  59. package/frontend-vue/layers/dms-layout/app/components/layout/VStack.vue +2 -0
  60. package/frontend-vue/layers/dms-layout/app/composables/useFooterLinks.ts +11 -1
  61. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +1 -1
  62. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +1 -1
  63. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeNotice.vue +13 -7
  64. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeVerification.vue +18 -7
  65. package/frontend-vue/layers/dms-layout/app/emails/EmailExportReady.vue +23 -12
  66. package/frontend-vue/layers/dms-layout/app/emails/EmailResetPassword.vue +18 -7
  67. package/frontend-vue/layers/dms-layout/app/emails/EmailTwoFactor.vue +23 -4
  68. package/frontend-vue/layers/dms-layout/app/emails/EmailUserValidation.vue +18 -6
  69. package/frontend-vue/layers/dms-layout/app/emails/components/EmailOTP.vue +8 -2
  70. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +40 -0
  71. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +40 -0
  72. package/frontend-vue/layers/dms-ui/app/build/components/form/FormFieldControl.vue +2 -1
  73. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +11 -3
  74. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +27 -36
  75. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +59 -2
  76. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +27 -4
  77. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +10 -2
  78. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +0 -5
  79. package/frontend-vue/layers/dms-ui/app/build/composables/form/formEntryOrder.ts +23 -0
  80. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +19 -15
  81. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +26 -2
  82. package/frontend-vue/layers/dms-ui/app/build/composables/page/pageUrlValues.ts +41 -0
  83. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +2 -2
  84. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +3 -5
  85. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +7 -12
  86. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -3
  87. package/frontend-vue/layers/dms-ui/app/build/utils/link.ts +7 -3
  88. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +32 -9
  89. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +8 -1
  90. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +9 -4
  91. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +5 -11
  92. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +2 -1
  93. package/frontend-vue/layers/dms-ui/app/components/chart/ApexChartHost.vue +32 -1
  94. package/frontend-vue/layers/dms-ui/app/components/check-list/CheckList.vue +1 -1
  95. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +12 -2
  96. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +7 -3
  97. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +7 -0
  98. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +4 -1
  99. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +4 -2
  100. package/frontend-vue/layers/dms-ui/app/components/form/components/InputSecret.vue +3 -2
  101. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -1
  102. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +15 -3
  103. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +2 -1
  104. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +9 -2
  105. package/frontend-vue/layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue +13 -5
  106. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +1 -1
  107. package/frontend-vue/layers/dms-ui/app/components/stat-group/StatGroup.vue +3 -2
  108. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +53 -31
  109. package/frontend-vue/layers/dms-ui/app/composables/chart/apexPlotOptions.ts +15 -4
  110. package/frontend-vue/layers/dms-ui/app/composables/chart/apexYRange.ts +123 -0
  111. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.ts +49 -49
  112. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartTheme.ts +20 -1
  113. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +1 -1
  114. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  115. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +8 -6
  116. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +1 -1
  117. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +3 -5
  118. package/frontend-vue/layers/dms-ui/app/utils/bannerToneIcons.ts +13 -0
  119. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +26 -0
  120. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -0
  121. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -0
  122. package/frontend-vue/tests/activity-feed-fill-height.test.ts +7 -0
  123. package/frontend-vue/tests/banner-tone-icon.test.ts +55 -0
  124. package/frontend-vue/tests/builtin-emails.test.ts +141 -0
  125. package/frontend-vue/tests/cascader.test.ts +27 -0
  126. package/frontend-vue/tests/chart-auto-y-range.test.ts +25 -9
  127. package/frontend-vue/tests/chart-colors.test.ts +8 -0
  128. package/frontend-vue/tests/chart-resize-after-animation.test.ts +100 -0
  129. package/frontend-vue/tests/chart-type-defaults.test.ts +128 -0
  130. package/frontend-vue/tests/check-list-marker.test.ts +58 -0
  131. package/frontend-vue/tests/confirm-from.test.ts +1 -1
  132. package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -2
  133. package/frontend-vue/tests/context-links.test.ts +2 -2
  134. package/frontend-vue/tests/custom-action-confirm-from.test.ts +2 -2
  135. package/frontend-vue/tests/download-action-target.test.ts +2 -2
  136. package/frontend-vue/tests/file-drop-prompt.test.ts +72 -0
  137. package/frontend-vue/tests/footer-config.test.ts +135 -0
  138. package/frontend-vue/tests/form-actions.test.ts +7 -0
  139. package/frontend-vue/tests/form-entry-order.test.ts +45 -0
  140. package/frontend-vue/tests/form-footer-kind.test.ts +11 -6
  141. package/frontend-vue/tests/form-load-error.test.ts +38 -1
  142. package/frontend-vue/tests/grid-responsive-columns.test.ts +9 -0
  143. package/frontend-vue/tests/header-actions.test.ts +55 -9
  144. package/frontend-vue/tests/instant-save-badge.test.ts +11 -4
  145. package/frontend-vue/tests/list-row-truncate.test.ts +46 -0
  146. package/frontend-vue/tests/roles-name-errors.test.ts +2 -3
  147. package/frontend-vue/tests/save-bar.test.ts +15 -3
  148. package/frontend-vue/tests/save-status-error.test.ts +2 -2
  149. package/frontend-vue/tests/table-row-action-errors.test.ts +5 -5
  150. package/frontend-vue/tests/table-row-hover.test.ts +2 -2
  151. package/frontend-vue/tests/table-view-sources.test.ts +1 -1
  152. package/frontend-vue/tests/template-tokens.test.ts +235 -0
  153. package/frontend-vue/tests/url-interpolation.test.ts +6 -6
  154. package/package.json +2 -2
  155. package/skills/dms/SKILL.md +2 -1
  156. package/skills/dms-pages/REFERENCE.md +2 -2
  157. package/skills/dms-project/SKILL.md +0 -1
@@ -18,9 +18,19 @@ const DATA_LABEL_FONT_SIZE = "11px";
18
18
  const DATA_LABEL_FONT_WEIGHT = 600;
19
19
  const TOOLTIP_DATETIME_FORMAT = "dd MMM yyyy";
20
20
  const HOVER_MARKER_SIZE = 5;
21
+ /** Grows a visible point on hover instead of setting a size below its own. */
22
+ const HOVER_POINT_SIZE_OFFSET = 2;
23
+ /** A scatter is nothing but its points, so it draws them without `pointSize`. */
24
+ const DEFAULT_POINT_SIZE = 6;
25
+ const POINT_MARKER_TYPES: ChartType[] = ["scatter"];
26
+ /** Half a data label's box and a gap: lifts the label off the point it names. */
27
+ const POINT_LABEL_CLEARANCE = 11;
28
+ const HALF = 2;
21
29
  const HEAD_MARKER_SIZE = 5;
22
30
  const SOLID_GRID = 0;
23
31
  const HEAD_MARKER_TYPES: ChartType[] = ["line", "area"];
32
+ /** A radar draws its own polygon grid; the cartesian one would cross the card. */
33
+ const OWN_GRID_TYPES: ChartType[] = ["radar"];
24
34
 
25
35
  /** Theme colors every section of an Apex config reads once per build. */
26
36
  export interface ApexThemeColors {
@@ -83,7 +93,10 @@ export function buildGrid(
83
93
  theme: ApexThemeColors,
84
94
  ): Record<string, unknown> {
85
95
  return {
86
- show: !isCircular && (input.showGrid ?? true),
96
+ show:
97
+ !isCircular &&
98
+ !OWN_GRID_TYPES.includes(input.type) &&
99
+ (input.showGrid ?? true),
87
100
  borderColor: theme.border,
88
101
  strokeDashArray: SOLID_GRID,
89
102
  padding: GRID_PADDING,
@@ -140,6 +153,13 @@ export function buildMarkers(
140
153
  colors: string[],
141
154
  theme: ApexThemeColors,
142
155
  ): Record<string, unknown> {
156
+ if (POINT_MARKER_TYPES.includes(input.type)) {
157
+ return {
158
+ size: pointSize(input),
159
+ strokeWidth: 0,
160
+ hover: { sizeOffset: HOVER_POINT_SIZE_OFFSET },
161
+ };
162
+ }
143
163
  const showsHead = HEAD_MARKER_TYPES.includes(input.type) && !input.stacked;
144
164
  return {
145
165
  size: 0,
@@ -151,18 +171,55 @@ export function buildMarkers(
151
171
  };
152
172
  }
153
173
 
154
- /** Value labels in mono, without Apex's default drop shadow. */
174
+ function pointSize(input: UseApexChartInput): number {
175
+ return input.pointSize ?? DEFAULT_POINT_SIZE;
176
+ }
177
+
178
+ /**
179
+ * How far a data label moves up: Apex centres it on its point, which hides a
180
+ * scatter's point under its own label.
181
+ */
182
+ export function dataLabelOffset(input: UseApexChartInput): number {
183
+ if (!POINT_MARKER_TYPES.includes(input.type)) return 0;
184
+ return -(pointSize(input) / HALF + POINT_LABEL_CLEARANCE);
185
+ }
186
+
187
+ /** Where Apex is drawing a data label. */
188
+ interface DataLabelContext {
189
+ seriesIndex: number;
190
+ dataPointIndex: number;
191
+ w: { config: { series: ChartSeries[] } };
192
+ }
193
+
194
+ /** A point's own `label` when it has one, else the value Apex would print. */
195
+ function pointLabel(value: unknown, context: DataLabelContext): unknown {
196
+ const point =
197
+ context.w.config.series[context.seriesIndex]?.data[context.dataPointIndex];
198
+ if (typeof point !== "object" || point === null || !("label" in point)) {
199
+ return value;
200
+ }
201
+ return point.label || value;
202
+ }
203
+
204
+ /**
205
+ * Value labels in mono, without Apex's default drop shadow. A circular chart
206
+ * keeps Apex's own percent formatter: its series hold bare numbers.
207
+ */
155
208
  export function buildDataLabels(
156
209
  enabled: boolean,
157
210
  theme: ApexThemeColors,
211
+ isCircular = false,
212
+ offsetY = 0,
158
213
  ): Record<string, unknown> {
159
214
  return {
160
215
  enabled,
216
+ offsetY,
161
217
  dropShadow: { enabled: false },
162
218
  style: {
163
219
  fontSize: DATA_LABEL_FONT_SIZE,
164
220
  fontFamily: theme.monoFont,
165
221
  fontWeight: DATA_LABEL_FONT_WEIGHT,
166
222
  },
223
+ ...(isCircular ? {} : { formatter: pointLabel }),
167
224
  };
168
225
  }
@@ -3,7 +3,10 @@ import type {
3
3
  ConfirmDialogSecondaryAction,
4
4
  ConfirmDialogSecondaryTarget,
5
5
  } from "#dms-core/app/types/confirm-dialog";
6
- import { interpolateUrl } from "#dms-core/app/utils/url-interpolation";
6
+ import {
7
+ interpolateRowText,
8
+ interpolateUrl,
9
+ } from "#dms-core/app/utils/url-interpolation";
7
10
  import { readTranslationKey } from "#dms-core/app/composables/translation/useTranslation";
8
11
  import type {
9
12
  ConfirmOptions,
@@ -11,6 +14,12 @@ import type {
11
14
  } from "../../../composables/confirm/types";
12
15
  import { processFieldI18n } from "../../utils/fieldOptionsI18n";
13
16
  import { interpolateLiteral } from "../../utils/literalText";
17
+ import {
18
+ namesPageValue,
19
+ resolveLinkVariables,
20
+ resolveTextVariables,
21
+ } from "../../utils/urlVariables";
22
+ import { currentPageUrlValues } from "../page/pageUrlValues";
14
23
 
15
24
  /** `t` as vue-i18n gives it: named parameters, then the plural count. */
16
25
  export type ConfirmTranslate = (
@@ -41,22 +50,36 @@ export type SecondaryTargetRun = (
41
50
  target: ConfirmDialogSecondaryTarget,
42
51
  ) => Promise<void> | void;
43
52
 
53
+ // The page's `{{params.X}}` / `{{query.X}}` come first, as on an action's own
54
+ // target; the route is only read for a text holding such a token.
55
+ function withPageLink(url: string): string {
56
+ return namesPageValue(url)
57
+ ? resolveLinkVariables(url, currentPageUrlValues())
58
+ : url;
59
+ }
60
+
61
+ function withPageText(text: string): string | undefined {
62
+ return namesPageValue(text)
63
+ ? resolveTextVariables(text, currentPageUrlValues())
64
+ : text;
65
+ }
66
+
44
67
  /**
45
68
  * A dialog's secondary target with its URL (and file name) filled in with
46
- * the same parameters as its texts.
69
+ * the page's values and, as `{{row.X}}`, the same parameters as its texts.
47
70
  */
48
71
  function resolveSecondaryTarget(
49
72
  target: ConfirmDialogSecondaryTarget,
50
73
  params: Record<string, unknown>,
51
74
  ): ConfirmDialogSecondaryTarget {
52
- const url = interpolateUrl(target.url, params);
75
+ const url = interpolateUrl(withPageLink(target.url), params);
53
76
  if (target.type !== "download" || target.filename === undefined) {
54
77
  return { ...target, url };
55
78
  }
56
79
  return {
57
80
  ...target,
58
81
  url,
59
- filename: interpolateLiteral(target.filename, params),
82
+ filename: withPageText(interpolateRowText(target.filename, params)),
60
83
  };
61
84
  }
62
85
 
@@ -15,12 +15,17 @@ import {
15
15
  } from "./confirmDialogTexts";
16
16
  import type { DownloadFetch } from "../../utils/fileDownload";
17
17
  import { secondaryTargetRunner } from "./secondaryTarget";
18
+ import {
19
+ namesPageValue,
20
+ resolveLinkVariables,
21
+ } from "../../utils/urlVariables";
22
+ import { currentPageUrlValues } from "../page/pageUrlValues";
18
23
 
19
24
  /** What an action asks its confirmation with. */
20
25
  export interface ActionConfirmRequest {
21
26
  /** The row the action targets: its fields fill the texts and `from` URL. */
22
27
  row?: Record<string, unknown>;
23
- /** Fill-ins of a `from` URL besides the row's fields (`{id}`). */
28
+ /** Fill-ins of a `from` URL besides the row's fields (`{{row.id}}`). */
24
29
  urlParams?: Record<string, unknown>;
25
30
  /**
26
31
  * The look of the action, where the dialog leaves it out (an archive's
@@ -55,7 +60,10 @@ export function useActionConfirm() {
55
60
  request: ActionConfirmRequest,
56
61
  ): Promise<ConfirmDialog | undefined> => {
57
62
  if (!isConfirmFrom(declared)) return declared;
58
- const url = interpolateUrl(declared.from, {
63
+ const from = namesPageValue(declared.from)
64
+ ? resolveLinkVariables(declared.from, currentPageUrlValues())
65
+ : declared.from;
66
+ const url = interpolateUrl(from, {
59
67
  ...request.row,
60
68
  ...request.urlParams,
61
69
  });
@@ -322,13 +322,8 @@ function renderRelation(value: unknown, options: unknown) {
322
322
  }
323
323
 
324
324
  function renderCascaderRelation(value: unknown, options: unknown) {
325
- const { t } = useI18n();
326
325
  const opts = options as CascaderRelationOptions | undefined;
327
326
 
328
- if (opts?.multiple && Array.isArray(value)) {
329
- return t("dms.form.cascader.selected_count", { count: value.length });
330
- }
331
-
332
327
  if (!value) return value;
333
328
 
334
329
  return h(CascaderPath, {
@@ -0,0 +1,23 @@
1
+ import type { FormFieldOrGroup } from "../../../composables/form/types/field";
2
+ import { isFieldGroup } from "../../../composables/form/types/field";
3
+
4
+ function entryOrder(entry: FormFieldOrGroup): number | undefined {
5
+ return isFieldGroup(entry) ? entry.order : undefined;
6
+ }
7
+
8
+ /**
9
+ * A form's entries with its groups' `order` applied: the groups giving one
10
+ * trade the places they were declared at, sorted on it (equal orders keep
11
+ * their declaration order); every other entry keeps its place.
12
+ */
13
+ export function orderFormEntries<T extends FormFieldOrGroup>(
14
+ entries: readonly T[],
15
+ ): T[] {
16
+ const ordered = entries
17
+ .filter((entry) => entryOrder(entry) !== undefined)
18
+ .sort((left, right) => entryOrder(left)! - entryOrder(right)!);
19
+ let next = 0;
20
+ return entries.map((entry) =>
21
+ entryOrder(entry) === undefined ? entry : ordered[next++]!,
22
+ );
23
+ }
@@ -6,10 +6,10 @@
6
6
  * it has somewhere to go back to, then "Unsaved changes" with Discard and
7
7
  * Save.
8
8
  * - `action`: a form that does something each time (send a notification,
9
- * invite someone, run a job). Reset and the form's own submit label once a
10
- * value changed; untouched — pre-filled or not —, no buttons on a page, and
11
- * Cancel beside a held submit in a drawer or a modal. It empties after a
12
- * successful submit.
9
+ * invite someone, run a job). Its own submit label, always ready: what it
10
+ * holds untouched, its defaults included, is a payload too. Beside it,
11
+ * Reset once a value changed, Cancel before that in a drawer or a modal.
12
+ * It empties after a successful submit.
13
13
  */
14
14
  export type FormKind = "record" | "action";
15
15
 
@@ -83,21 +83,25 @@ function recordSaveBar(context: SaveBarContext): SaveBarState {
83
83
  }
84
84
 
85
85
  /**
86
- * An action form's bar: Reset and its submit once a value changed. Before
87
- * that, a form in a drawer or a modal offers Cancel beside its held submit —
88
- * the modal's only way out and the proof it sends something —, a page form
89
- * nothing; a form nothing in which can be changed, its submit alone.
86
+ * An action form's bar: its submit, never held, so a pre-filled form sends as
87
+ * it is. Beside it, Reset once a value changed; before that, Cancel in a
88
+ * drawer or a modal, its only way out. A form nothing in which can be changed
89
+ * has nothing to reset.
90
90
  */
91
91
  function actionSaveBar(context: SaveBarContext): SaveBarState {
92
- const bar = { showsStatus: false, showsSubmit: true, isHidden: false };
93
- if (!context.resettable) return { ...bar, isSubmitHeld: false };
94
- if (context.dirty) {
95
- return { ...bar, secondary: "discard", isSubmitHeld: false };
92
+ const bar: SaveBarState = {
93
+ showsStatus: false,
94
+ showsSubmit: true,
95
+ isSubmitHeld: false,
96
+ isHidden: false,
97
+ };
98
+ if (context.dirty && context.resettable) {
99
+ return { ...bar, secondary: "discard" };
96
100
  }
97
- if (!context.cancellable) {
98
- return { ...HIDDEN_BAR, showsStatus: false };
101
+ if (!context.dirty && context.cancellable) {
102
+ return { ...bar, secondary: "cancel" };
99
103
  }
100
- return { ...bar, secondary: "cancel", isSubmitHeld: true };
104
+ return bar;
101
105
  }
102
106
 
103
107
  const SAVE_BARS: Record<FormKind, (context: SaveBarContext) => SaveBarState> = {
@@ -5,9 +5,33 @@ import { HTTP_FORBIDDEN } from "#dms-core/app/utils/http-status";
5
5
  /**
6
6
  * Why a form could not load the record it edits: `forbidden` when the server
7
7
  * refused the caller the read (a role that may edit rows but not view them),
8
- * `error` for anything else (a network failure, a server error, a row gone).
8
+ * `missing` when its address lacks the record's id (a page reached without
9
+ * its `?id=`), `error` for anything else (a network failure, a server error,
10
+ * a row gone).
9
11
  */
10
- export type FormLoadFailure = "forbidden" | "error";
12
+ export type FormLoadFailure = "forbidden" | "missing" | "error";
13
+
14
+ /** What a form knows of the record it is about, as far as `missing` goes. */
15
+ export interface FormRecordAddress {
16
+ requiresRecord?: boolean;
17
+ fetchUrl?: string;
18
+ /** `fetchUrl` with its route values filled; unset while one is missing. */
19
+ resolvedFetchUrl?: string;
20
+ }
21
+
22
+ /**
23
+ * `missing` for a form about one record whose address names no record yet:
24
+ * its fields would only hold their defaults, which read as a record.
25
+ */
26
+ export function missingRecordFailure(
27
+ address: FormRecordAddress,
28
+ ): FormLoadFailure | null {
29
+ const isMissing =
30
+ !!address.requiresRecord &&
31
+ !!address.fetchUrl &&
32
+ !address.resolvedFetchUrl;
33
+ return isMissing ? "missing" : null;
34
+ }
11
35
 
12
36
  /** The cached load of a form's record (`useDmsAsyncData`), as far as it is read. */
13
37
  export interface FormLoadSource<T> {
@@ -0,0 +1,41 @@
1
+ import {
2
+ namesPageValue,
3
+ type ReplaceUrlVariablesContext,
4
+ resolveLinkVariables,
5
+ resolveUrlVariables,
6
+ } from "../../utils/urlVariables";
7
+
8
+ /**
9
+ * The values of the page being shown that a `{{params.X}}` / `{{query.X}}`
10
+ * token names: the route parameters of the DMS page matching the URL, and its
11
+ * query. Read when called, so it follows the URL as it changes.
12
+ */
13
+ export function currentPageUrlValues(): ReplaceUrlVariablesContext {
14
+ const route = useDmsRoute();
15
+ return {
16
+ routeParams: useSiteLayout().findMatchingRoute(route.path)?.params,
17
+ routeQuery: route.query as Record<string, unknown>,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * A link declared on a block (`to`) with the page's tokens filled, as a page
23
+ * header button's: a query pair the page has no value for is left out. The
24
+ * route is only read for a link holding a token.
25
+ */
26
+ export function pageLink(to: string): string;
27
+ export function pageLink(to: string | undefined): string | undefined;
28
+ export function pageLink(to: string | undefined): string | undefined {
29
+ if (!to || !namesPageValue(to)) return to;
30
+ return resolveLinkVariables(to, currentPageUrlValues());
31
+ }
32
+
33
+ /**
34
+ * A route a field or block requests (`searchUrl`, `rotateUrl`, …) with the
35
+ * page's tokens filled, or `undefined` while one has no value, as a block's
36
+ * `fetchUrl`.
37
+ */
38
+ export function pageRequestUrl(url: string | undefined): string | undefined {
39
+ if (!url || !namesPageValue(url)) return url;
40
+ return resolveUrlVariables(url, currentPageUrlValues());
41
+ }
@@ -1,5 +1,5 @@
1
1
  import type { LocationQueryValue } from "#dms/frontend-module";
2
- import { replaceUrlVariables } from "../../utils/urlVariables";
2
+ import { resolveLinkVariables } from "../../utils/urlVariables";
3
3
 
4
4
  type RouteQuery = Record<string, LocationQueryValue | LocationQueryValue[]>;
5
5
 
@@ -23,7 +23,7 @@ export function resolveFormSuccessTarget(
23
23
  { url, preserveQuery }: FormSuccessParams,
24
24
  route: FormSuccessRoute,
25
25
  ): { path: string; query?: RouteQuery } {
26
- const path = replaceUrlVariables(url, {
26
+ const path = resolveLinkVariables(url, {
27
27
  routeParams: route.params,
28
28
  routeQuery: route.query,
29
29
  });
@@ -1,5 +1,6 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
2
  import { computed } from "vue";
3
+ import { interpolateUrl } from "#dms-core/app/utils/url-interpolation";
3
4
  import type {
4
5
  TableViewColumn,
5
6
  TableViewListResponse,
@@ -54,7 +55,6 @@ const DESCENDING = "desc";
54
55
  */
55
56
  export const LIVE_HELD_AT_QUERY_KEY = "liveHeldAt";
56
57
  const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
57
- const ROW_ID_TOKEN = "{id}";
58
58
 
59
59
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
60
60
 
@@ -251,10 +251,8 @@ export function useTableRows<T extends Record<string, unknown>>(
251
251
  id: string,
252
252
  ): Promise<T | undefined> => {
253
253
  const url = resolveUrlVariables(
254
- sourceConfig.fetchRowUrl?.replaceAll(
255
- ROW_ID_TOKEN,
256
- encodeURIComponent(id),
257
- ),
254
+ sourceConfig.fetchRowUrl &&
255
+ interpolateUrl(sourceConfig.fetchRowUrl, { id }),
258
256
  options.route?.() ?? NO_ROUTE_VALUES,
259
257
  );
260
258
  return url ? api<T>(url) : Promise.resolve(undefined);
@@ -102,20 +102,15 @@ export const useTableViewConfig = <T extends Data>(
102
102
  .map((col) => ({ ...col, header: processI18n(col.header) })),
103
103
  );
104
104
 
105
- // Config-derived (SSR-safe; the registry is client-only): the implicit `table`
106
- // display (unless `tableDisplay` is false) plus explicit `displays`. Built-in defaults
105
+ // Config-derived (SSR-safe; the registry is client-only): the listed
106
+ // `displays`, else the built-in `table` alone. Built-in defaults
107
107
  // (selfManagedData/capabilities) are merged per id.
108
- const resolvedDisplays: TableViewDisplayConfig[] = (() => {
109
- const list: TableViewDisplayConfig[] =
110
- config.tableDisplay === false ? [] : [{ id: TABLE_DISPLAY_ID }];
111
- for (const display of config.displays ?? []) {
112
- if (display.id === TABLE_DISPLAY_ID) continue;
113
- list.push(display);
114
- }
115
- return list.map(withBuiltinDefaults);
116
- })();
108
+ const resolvedDisplays: TableViewDisplayConfig[] = (
109
+ config.displays ?? [{ id: TABLE_DISPLAY_ID }]
110
+ ).map(withBuiltinDefaults);
117
111
 
118
- const defaultDisplay = config.defaultDisplay ?? TABLE_DISPLAY_ID;
112
+ const defaultDisplay =
113
+ config.defaultDisplay ?? resolvedDisplays[0]?.id ?? TABLE_DISPLAY_ID;
119
114
 
120
115
  const initialVisibility = buildInitialColumnVisibility(config.columns ?? []);
121
116
 
@@ -1,3 +1,4 @@
1
+ import { namesRowField } from "#dms-core/app/utils/url-interpolation";
1
2
  import {
2
3
  fillFormPageUrl,
3
4
  type FormContainer,
@@ -91,7 +92,6 @@ interface BulkActionConfig {
91
92
  /** Runs a confirmed action from inside its dialog (see `onConfirm`). */
92
93
  type ConfirmedRun = NonNullable<ConfirmOptions["onConfirm"]>;
93
94
 
94
- const ROW_URL_PLACEHOLDER = /\{[^}]+\}/;
95
95
  /** Options of a delete: from the archive, it is a permanent one. */
96
96
  interface DeleteRowsOptions {
97
97
  permanently?: boolean;
@@ -634,7 +634,7 @@ export const useTableRowActions = <T extends Data>(
634
634
  * reaches: a `from` dialog is worded for one row, so several rows keep the
635
635
  * generic confirmation. Undefined when none applies.
636
636
  */
637
- // A `from` URL naming a row (`{id}`, `{_id}`…) is worded for that row
637
+ // A `from` URL naming a row (`{{row.id}}`, `{{row._id}}`…) is worded for that row
638
638
  // alone; one naming none receives the rows as `?ids=`, one or several, like
639
639
  // the `from` of a bulk custom action.
640
640
  const declaredBulkConfirm = (
@@ -643,7 +643,7 @@ export const useTableRowActions = <T extends Data>(
643
643
  ): ActionConfirm | undefined => {
644
644
  const declared = normalizeActionConfig(actionConfig).confirm;
645
645
  if (!declared || !isConfirmFrom(declared)) return declared;
646
- if (ROW_URL_PLACEHOLDER.test(declared.from)) {
646
+ if (namesRowField(declared.from)) {
647
647
  return ids.length === 1 ? declared : undefined;
648
648
  }
649
649
  return {
@@ -3,6 +3,8 @@
3
3
  // bare `<a href="#…">` (an Inertia visit would reload the page to scroll), an
4
4
  // absolute URL opens in a new tab, and everything else is a DMS route.
5
5
 
6
+ import { pageLink } from "../composables/page/pageUrlValues";
7
+
6
8
  export type LinkKind = "internal" | "anchor" | "external";
7
9
 
8
10
  const EXTERNAL_LINK_PATTERN = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
@@ -20,12 +22,14 @@ export const EXTERNAL_LINK_ATTRIBUTES = {
20
22
  } as const;
21
23
 
22
24
  /**
23
- * Props for a `UButton` (or `ULink`) following `to`: an external target opens
24
- * in a new tab, the others navigate in place.
25
+ * Props for a `UButton` (or `ULink`) following `to`, its page tokens
26
+ * (`{{params.X}}`, `{{query.X}}`) filled: an external target opens in a new
27
+ * tab, the others navigate in place.
25
28
  */
26
29
  export function buttonLinkProps(
27
- to: string | undefined,
30
+ declared: string | undefined,
28
31
  ): Record<string, string> {
32
+ const to = pageLink(declared);
29
33
  if (!to) return {};
30
34
  return linkKind(to) === "external"
31
35
  ? { to, ...EXTERNAL_LINK_ATTRIBUTES }
@@ -1,11 +1,24 @@
1
+ import { get } from "@nuxt/ui/runtime/utils/index.js";
2
+
3
+ // Every token is `{{<scope>.<key>}}`, spaces inside the braces allowed, as the
4
+ // server's filter tokens (`{{ user.id }}`) are.
5
+ //
1
6
  // `{{params.id}}` takes the bare name, which on a route repeating a placeholder
2
7
  // is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
3
8
  // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
4
9
  // table view (see extractRouteParams).
5
- const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
10
+ const PARAM_KEY = String.raw`params\.(\w+(?::\d+)?)`;
6
11
  // A query key may carry dots and dashes: a period selector writes `<id>.period`.
7
- const QUERY_TOKEN = /\{\{query\.([\w.-]+)\}\}/g;
8
- const RESPONSE_TOKEN = /\{\{response\.(\w+)\}\}/g;
12
+ const QUERY_KEY = String.raw`query\.([\w.-]+)`;
13
+ // A response key is a path into the answered body: `{{response.data.id}}`.
14
+ const RESPONSE_KEY = String.raw`response\.([\w.-]+)`;
15
+
16
+ const tokenPattern = (key: string, flags = "g"): RegExp =>
17
+ new RegExp(String.raw`\{\{\s*${key}\s*\}\}`, flags);
18
+
19
+ const PARAM_TOKEN = tokenPattern(PARAM_KEY);
20
+ const QUERY_TOKEN = tokenPattern(QUERY_KEY);
21
+ const RESPONSE_TOKEN = tokenPattern(RESPONSE_KEY);
9
22
  const TOKEN_OPENING = "{{";
10
23
 
11
24
  export interface ReplaceUrlVariablesContext {
@@ -63,8 +76,8 @@ function fillResponseTokens(
63
76
  response: Record<string, unknown> | undefined,
64
77
  ): string {
65
78
  if (!response) return text;
66
- return text.replace(RESPONSE_TOKEN, (match, key) => {
67
- const value = response[key];
79
+ return text.replace(RESPONSE_TOKEN, (match, key: string) => {
80
+ const value: unknown = get(response, key);
68
81
  return typeof value === "string" || typeof value === "number"
69
82
  ? String(value)
70
83
  : match;
@@ -122,10 +135,20 @@ export function resolveTextVariables(
122
135
 
123
136
  // A route token left without a value: `{{params.id}}`, `{{query.workspace}}`,
124
137
  // `{{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.-]+)\}\}$/;
138
+ const ROUTE_KEY = `(?:${PARAM_KEY}|${QUERY_KEY})`;
139
+ const UNRESOLVED_ROUTE_TOKEN = tokenPattern(ROUTE_KEY);
140
+ const UNRESOLVED_ROUTE_VALUE = new RegExp(
141
+ `^${tokenPattern(ROUTE_KEY, "").source}$`,
142
+ );
143
+
144
+ /**
145
+ * Whether a text names a value of the page (`{{params.X}}`, `{{query.X}}`):
146
+ * the route is only read for one that does.
147
+ */
148
+ export function namesPageValue(text: string): boolean {
149
+ return new RegExp(UNRESOLVED_ROUTE_TOKEN.source).test(text);
150
+ }
151
+
129
152
  const QUERY_SEPARATOR = "?";
130
153
  const FRAGMENT_SEPARATOR = "#";
131
154
  const PAIR_SEPARATOR = "&";
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { pageLink } from "../../build/composables/page/pageUrlValues";
2
3
  import { computed, ref, resolveComponent } from "vue";
3
4
  import DmsActivityItem from "../activity/ActivityItem.vue";
4
5
  import DmsBlockActions, {
@@ -236,6 +237,11 @@ const scrollArea = computed(() =>
236
237
  const dayHeadClass = computed(
237
238
  () => props.fillHeight && "sticky top-0 z-[1] bg-(--dms-surface-card)",
238
239
  );
240
+ // …and the "Show N more" button at its foot, where entries taller than the
241
+ // row would otherwise push it out of sight.
242
+ const foldClass = computed(
243
+ () => props.fillHeight && "sticky bottom-0 z-[1] bg-(--dms-surface-card)",
244
+ );
239
245
 
240
246
  const Wrapper = props.card ? resolveComponent("DmsCard") : "div";
241
247
  const wrapperProps = computed(() =>
@@ -365,13 +371,14 @@ const wrapperProps = computed(() =>
365
371
  :unread="item.unread"
366
372
  :mono="props.mono"
367
373
  :wrap="props.wrap"
368
- :to="item.to"
374
+ :to="pageLink(item.to)"
369
375
  :interactive="!!item.to"
370
376
  />
371
377
  </section>
372
378
  <div
373
379
  v-if="foldedCount > 0"
374
380
  class="dms-activity-feed__fold border-t border-(--ui-border-muted) px-[18px] pt-2 pb-1"
381
+ :class="foldClass"
375
382
  >
376
383
  <UButton
377
384
  :label="foldLabel"
@@ -1,19 +1,22 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
3
  import DmsIconWell, { type IconWellSize } from "../icon-well/IconWell.vue";
4
- import type { SemanticColor } from "../../utils/semanticTint";
4
+ import {
5
+ BANNER_TONE_ICONS,
6
+ type BannerTone,
7
+ } from "../../utils/bannerToneIcons";
5
8
 
6
9
  // DMS-styled notice banner (design .banner): a left-to-right tinted wash over
7
10
  // the card surface with a boxed leading icon, used for module beta / info
8
11
  // notices. Wraps UAlert so we keep its a11y + slots while pinning the exact
9
12
  // reference look. The `cli` slot hosts the optional inset command box
10
13
  // (compose with DmsCopyButton), `actions` the right-aligned buttons.
11
- type BannerTone = Exclude<SemanticColor, "neutral">;
12
14
  type BannerSize = "sm" | "md";
13
15
 
14
16
  interface BannerProps {
15
17
  title?: string;
16
18
  description?: string;
19
+ /** Defaults to the tone's icon. */
17
20
  icon?: string;
18
21
  /** Colour of the wash and the icon well. */
19
22
  tone?: BannerTone;
@@ -39,7 +42,7 @@ interface BannerSlots {
39
42
  const props = withDefaults(defineProps<BannerProps>(), {
40
43
  title: undefined,
41
44
  description: undefined,
42
- icon: "i-ph-warning",
45
+ icon: undefined,
43
46
  tone: "warning",
44
47
  size: "md",
45
48
  dismissible: false,
@@ -99,6 +102,8 @@ const alertUi = computed(() => ({
99
102
  actions: `items-center gap-2 mt-0 ${slots.actions ? "max-sm:basis-full max-sm:flex-wrap" : ""}`,
100
103
  }));
101
104
 
105
+ const leadingIcon = computed(() => props.icon ?? BANNER_TONE_ICONS[props.tone]);
106
+
102
107
  function onOpenChange(open: boolean) {
103
108
  if (!open) emit("dismiss");
104
109
  }
@@ -116,7 +121,7 @@ function onOpenChange(open: boolean) {
116
121
  @update:open="onOpenChange"
117
122
  >
118
123
  <template #leading>
119
- <DmsIconWell :icon="icon" :tone="tone" :size="SIZE_WELL[size]" />
124
+ <DmsIconWell :icon="leadingIcon" :tone="tone" :size="SIZE_WELL[size]" />
120
125
  </template>
121
126
 
122
127
  <template v-if="slots.description || slots.cli" #description>