@antelopejs/dms 0.7.20 → 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 (172) hide show
  1. package/CHANGELOG.md +62 -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-row-menu-label.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
  37. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +18 -4
  39. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js +8 -5
  41. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js.map +1 -1
  42. package/dist/test/unit/utils/email-subjects.test.d.ts +1 -0
  43. package/dist/test/unit/utils/email-subjects.test.js +27 -0
  44. package/dist/test/unit/utils/email-subjects.test.js.map +1 -0
  45. package/dist/utils/admin-invite-email.js +2 -4
  46. package/dist/utils/admin-invite-email.js.map +1 -1
  47. package/dist/utils/email-change-mail.d.ts +1 -1
  48. package/dist/utils/email-change-mail.js +11 -6
  49. package/dist/utils/email-change-mail.js.map +1 -1
  50. package/dist/utils/email-subjects.d.ts +3 -0
  51. package/dist/utils/email-subjects.js +32 -0
  52. package/dist/utils/email-subjects.js.map +1 -0
  53. package/dist/utils/export-jobs.js +15 -10
  54. package/dist/utils/export-jobs.js.map +1 -1
  55. package/frontend-vue/layers/dms-core/app/utils/url-interpolation.ts +29 -11
  56. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +17 -0
  57. package/frontend-vue/layers/dms-layout/app/app.config.ts +5 -0
  58. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +18 -2
  59. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +16 -1
  60. package/frontend-vue/layers/dms-layout/app/build/composables/layout/useHeaderActionControls.ts +14 -0
  61. package/frontend-vue/layers/dms-layout/app/components/layout/HStack.vue +16 -0
  62. package/frontend-vue/layers/dms-layout/app/components/layout/VStack.vue +2 -0
  63. package/frontend-vue/layers/dms-layout/app/composables/useFooterLinks.ts +11 -1
  64. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +1 -1
  65. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +1 -1
  66. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeNotice.vue +13 -7
  67. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeVerification.vue +18 -7
  68. package/frontend-vue/layers/dms-layout/app/emails/EmailExportReady.vue +23 -12
  69. package/frontend-vue/layers/dms-layout/app/emails/EmailResetPassword.vue +18 -7
  70. package/frontend-vue/layers/dms-layout/app/emails/EmailTwoFactor.vue +23 -4
  71. package/frontend-vue/layers/dms-layout/app/emails/EmailUserValidation.vue +18 -6
  72. package/frontend-vue/layers/dms-layout/app/emails/components/EmailOTP.vue +8 -2
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +40 -0
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +40 -0
  75. package/frontend-vue/layers/dms-ui/app/build/components/form/FormFieldControl.vue +2 -1
  76. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +11 -3
  77. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
  78. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
  79. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +90 -43
  81. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +59 -2
  82. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +27 -4
  83. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +10 -2
  84. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +0 -5
  85. package/frontend-vue/layers/dms-ui/app/build/composables/form/formEntryOrder.ts +23 -0
  86. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +19 -15
  87. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +26 -2
  88. package/frontend-vue/layers/dms-ui/app/build/composables/page/pageUrlValues.ts +41 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +2 -2
  90. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +3 -5
  91. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
  92. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +7 -11
  93. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -3
  94. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +10 -11
  95. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
  96. package/frontend-vue/layers/dms-ui/app/build/utils/link.ts +7 -3
  97. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +32 -9
  98. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +8 -1
  99. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +9 -4
  100. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +5 -11
  101. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +2 -1
  102. package/frontend-vue/layers/dms-ui/app/components/chart/ApexChartHost.vue +32 -1
  103. package/frontend-vue/layers/dms-ui/app/components/check-list/CheckList.vue +1 -1
  104. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +12 -2
  105. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +7 -3
  106. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +7 -0
  107. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +4 -1
  108. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +4 -2
  109. package/frontend-vue/layers/dms-ui/app/components/form/components/InputSecret.vue +3 -2
  110. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -1
  111. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +15 -3
  112. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +2 -1
  113. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +9 -2
  114. package/frontend-vue/layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue +13 -5
  115. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +1 -1
  116. package/frontend-vue/layers/dms-ui/app/components/stat-group/StatGroup.vue +3 -2
  117. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +53 -31
  118. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +14 -6
  119. package/frontend-vue/layers/dms-ui/app/composables/chart/apexPlotOptions.ts +15 -4
  120. package/frontend-vue/layers/dms-ui/app/composables/chart/apexYRange.ts +123 -0
  121. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.ts +49 -49
  122. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartTheme.ts +20 -1
  123. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +1 -1
  124. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  125. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +8 -6
  126. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +1 -1
  127. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +3 -3
  128. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
  129. package/frontend-vue/layers/dms-ui/app/utils/bannerToneIcons.ts +13 -0
  130. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +26 -0
  131. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -0
  132. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -0
  133. package/frontend-vue/tests/activity-feed-fill-height.test.ts +7 -0
  134. package/frontend-vue/tests/banner-tone-icon.test.ts +55 -0
  135. package/frontend-vue/tests/builtin-emails.test.ts +141 -0
  136. package/frontend-vue/tests/cascader.test.ts +27 -0
  137. package/frontend-vue/tests/chart-auto-y-range.test.ts +25 -9
  138. package/frontend-vue/tests/chart-colors.test.ts +8 -0
  139. package/frontend-vue/tests/chart-resize-after-animation.test.ts +100 -0
  140. package/frontend-vue/tests/chart-type-defaults.test.ts +128 -0
  141. package/frontend-vue/tests/check-list-marker.test.ts +58 -0
  142. package/frontend-vue/tests/confirm-from.test.ts +1 -1
  143. package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -2
  144. package/frontend-vue/tests/context-links.test.ts +2 -2
  145. package/frontend-vue/tests/custom-action-confirm-from.test.ts +2 -2
  146. package/frontend-vue/tests/download-action-target.test.ts +2 -2
  147. package/frontend-vue/tests/file-drop-prompt.test.ts +72 -0
  148. package/frontend-vue/tests/footer-config.test.ts +135 -0
  149. package/frontend-vue/tests/form-actions.test.ts +7 -0
  150. package/frontend-vue/tests/form-entry-order.test.ts +45 -0
  151. package/frontend-vue/tests/form-footer-kind.test.ts +11 -6
  152. package/frontend-vue/tests/form-load-error.test.ts +38 -1
  153. package/frontend-vue/tests/grid-responsive-columns.test.ts +9 -0
  154. package/frontend-vue/tests/header-actions.test.ts +55 -9
  155. package/frontend-vue/tests/instant-save-badge.test.ts +11 -4
  156. package/frontend-vue/tests/list-row-truncate.test.ts +46 -0
  157. package/frontend-vue/tests/roles-name-errors.test.ts +2 -3
  158. package/frontend-vue/tests/row-side-panel.test.ts +93 -2
  159. package/frontend-vue/tests/save-bar.test.ts +15 -3
  160. package/frontend-vue/tests/save-status-error.test.ts +2 -2
  161. package/frontend-vue/tests/table-row-action-errors.test.ts +5 -5
  162. package/frontend-vue/tests/table-row-hover.test.ts +2 -2
  163. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +114 -0
  164. package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
  165. package/frontend-vue/tests/table-view-sources.test.ts +1 -1
  166. package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
  167. package/frontend-vue/tests/template-tokens.test.ts +235 -0
  168. package/frontend-vue/tests/url-interpolation.test.ts +6 -6
  169. package/package.json +2 -2
  170. package/skills/dms/SKILL.md +2 -1
  171. package/skills/dms-pages/REFERENCE.md +2 -2
  172. package/skills/dms-project/SKILL.md +0 -1
@@ -25,10 +25,12 @@ import {
25
25
  type RowNavigation,
26
26
  type RowNavigationSource,
27
27
  rowNavigation,
28
+ type ShowRow,
28
29
  } from "./rowNavigation";
29
30
  import {
30
31
  clearWrittenRecord,
31
32
  isDeepLinkAction,
33
+ resolveDeepLinkedRow,
32
34
  writeRecordId,
33
35
  type WrittenRecord,
34
36
  } from "../table-view/utils/recordLink";
@@ -37,6 +39,7 @@ import {
37
39
  closeRowSidePanelOf,
38
40
  openRowSidePanel,
39
41
  rowSidePanelOwnerKey,
42
+ useRowSidePanelContent,
40
43
  } from "#dms-layout/app/build/composables/layout/rowSidePanel";
41
44
  import type { TableUrlScope } from "../table-view/utils/views";
42
45
  import { useActionConfirm } from "../confirm/useActionConfirm";
@@ -49,52 +52,47 @@ import {
49
52
  findServedQuickAction,
50
53
  } from "../../utils/dispatchQuickActionTarget";
51
54
  import {
52
- hasUrlVariables,
55
+ namesPageValue,
53
56
  resolveLinkVariables,
54
57
  resolveTextVariables,
55
58
  } from "../../utils/urlVariables";
56
- import { interpolateLiteral } from "../../utils/literalText";
59
+ import { currentPageUrlValues } from "../page/pageUrlValues";
57
60
  import { type DownloadFetch, saveDownload } from "../../utils/fileDownload";
61
+ import { interpolateRowText } from "#dms-core/app/utils/url-interpolation";
58
62
 
59
63
  /** Runs a confirmed action from inside its dialog (see `onConfirm`). */
60
64
  type ConfirmedRun = NonNullable<ConfirmOptions["onConfirm"]>;
61
65
 
62
66
  /**
63
- * A link target's URL with the page's `{{params.X}}` / `{{query.X}}` filled
64
- * (a query pair without a value left out), then the row's `{field}`: a row
67
+ * A target's URL with the page's `{{params.X}}` / `{{query.X}}` filled (a
68
+ * query pair without a value left out), then the row's `{{row.field}}`: an
65
69
  * action keeps the page's context (`?website={{query.website}}`) as a page
66
- * header button does. Read when the link is followed, so it carries the URL
67
- * as it is then.
70
+ * header button does, whatever its type and wherever it sits. Read when the
71
+ * action runs, so it carries the URL as it is then.
68
72
  */
69
- function linkTargetUrl(url: string, rowData?: Data): string {
70
- const withPageValues = hasUrlVariables(url)
73
+ function targetUrl(url: string, rowData?: Data): string {
74
+ const withPageValues = namesPageValue(url)
71
75
  ? resolveLinkVariables(url, currentPageUrlValues())
72
76
  : url;
73
77
  return rowData ? interpolateUrl(withPageValues, rowData) : withPageValues;
74
78
  }
75
79
 
76
80
  /**
77
- * A download target's file name with the page's tokens filled as they are
78
- * (a name, not a URL: nothing is encoded), then the row's `{field}`. A token
79
- * the page has no value for leaves no name: the server's one is used.
81
+ * A download target's file name with the row's `{{row.field}}` and the
82
+ * page's tokens filled as they are (a name, not a URL: nothing is encoded). A
83
+ * token without a value leaves no name: the server's one is used.
80
84
  */
81
85
  function downloadTargetFilename(
82
86
  filename: string | undefined,
83
87
  rowData?: Data,
84
88
  ): string | undefined {
85
89
  if (!filename) return undefined;
86
- const withPageValues = resolveTextVariables(filename, currentPageUrlValues());
87
- if (!withPageValues || !rowData) return withPageValues;
88
- return interpolateLiteral(withPageValues, rowData);
90
+ const withRow = rowData ? interpolateRowText(filename, rowData) : filename;
91
+ return resolveTextVariables(withRow, currentPageUrlValues());
89
92
  }
90
93
 
91
- function currentPageUrlValues() {
92
- const route = useDmsRoute();
93
- return {
94
- routeParams: useSiteLayout().findMatchingRoute(route.path)?.params,
95
- routeQuery: route.query as Record<string, unknown>,
96
- };
97
- }
94
+ // The id an export job's start answered, in its `statusUrl` / `downloadUrl`.
95
+ const JOB_ID_TOKEN = /\{\{\s*job\.id\s*\}\}/g;
98
96
 
99
97
  /** Where the buttons run: the requests they send, what they refresh. */
100
98
  export interface ActionTargetsConfig {
@@ -116,7 +114,7 @@ export interface ActionTargetsConfig {
116
114
  */
117
115
  onRecordShown?: (id?: string) => void;
118
116
  /**
119
- * The field a row's id is read from (`_id` by default): it fills `{id}` in
117
+ * The field a row's id is read from (`_id` by default): it fills `{{row.id}}` in
120
118
  * the `from` URL of a row action's confirmation, as on a built-in action.
121
119
  */
122
120
  rowIdKey?: string;
@@ -145,6 +143,18 @@ interface SidePanelTargetRun extends TargetRunExtras {
145
143
  missingId?: string;
146
144
  }
147
145
 
146
+ /**
147
+ * `show`, run only while the row side panel still shows `session`: a row read
148
+ * after the panel closed, or moved to another run, leaves it alone.
149
+ */
150
+ function whileShown<V>(session: symbol, show: (value: V) => void) {
151
+ return (value: V): void => {
152
+ if (useRowSidePanelContent().value?.session === session) show(value);
153
+ };
154
+ }
155
+
156
+ const NO_ROW_ROUTE = async (): Promise<undefined> => undefined;
157
+
148
158
  const NOT_FOUND_TITLE_KEY = "side_panel.not_found.title";
149
159
  const NOT_FOUND_DESCRIPTION_KEY = "side_panel.not_found.description";
150
160
 
@@ -260,7 +270,10 @@ export function useActionTargets(config: ActionTargetsConfig) {
260
270
  const statusUrlTemplate = target.statusUrl;
261
271
  const downloadUrlTemplate = target.downloadUrl;
262
272
  const interpolate = (template: string, ticket: ExportJobTicket): string =>
263
- interpolateUrl(template, { ...(rowData ?? {}), jobId: ticket.jobId });
273
+ targetUrl(
274
+ template.replace(JOB_ID_TOKEN, encodeURIComponent(ticket.jobId)),
275
+ rowData,
276
+ );
264
277
  await runExportJob({
265
278
  startUrl: url,
266
279
  startMethod: target.method ?? "POST",
@@ -284,7 +297,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
284
297
  try {
285
298
  await saveDownload(
286
299
  {
287
- url: linkTargetUrl(target.url, rowData),
300
+ url: targetUrl(target.url, rowData),
288
301
  filename: downloadTargetFilename(target.filename, rowData),
289
302
  },
290
303
  fetchRaw,
@@ -339,6 +352,31 @@ export function useActionTargets(config: ActionTargetsConfig) {
339
352
  config.onRecordShown?.(id);
340
353
  };
341
354
  };
355
+ // `showRow` given an id: the listed row, else the one the table's route
356
+ // reads. A row its route answered 404 for goes to `showMissing` when the
357
+ // container has a state for it; any other row not found rejects. Only the
358
+ // latest call applies: a read that resolves after a later call is dropped.
359
+ const rowShower = (
360
+ show: (row: Data) => void,
361
+ showMissing?: (id: string) => void,
362
+ ): ShowRow => {
363
+ let latest = 0;
364
+ return async (rowOrId) => {
365
+ const call = ++latest;
366
+ if (typeof rowOrId !== "string") return show(rowOrId);
367
+ const source = config.rowNavigation;
368
+ const found = await resolveDeepLinkedRow(rowOrId, {
369
+ listed: source?.rows(),
370
+ idOf: rowIdOf,
371
+ getRow: source?.getRow ?? NO_ROW_ROUTE,
372
+ });
373
+ if (call !== latest) return;
374
+ if (found.row) return show(found.row);
375
+ if (found.isMissing && showMissing) return showMissing(rowOrId);
376
+ throw new Error(`The row "${rowOrId}" could not be read`);
377
+ };
378
+ };
379
+
342
380
  const rowRecorder = (deepLink?: boolean) => {
343
381
  const recordId = recordRecorder(deepLink);
344
382
  return (row?: Data): void => recordId(rowIdOf(row));
@@ -382,6 +420,10 @@ export function useActionTargets(config: ActionTargetsConfig) {
382
420
  componentId: config.componentId,
383
421
  containerId: run.containerId,
384
422
  selection: run.selection,
423
+ showRow: rowShower(
424
+ whileShown(session, showRow),
425
+ whileShown(session, showMissing),
426
+ ),
385
427
  onSuccessCallback: () => {
386
428
  closeRowSidePanel(session);
387
429
  config.refreshCallback?.();
@@ -389,7 +431,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
389
431
  },
390
432
  onClose: () => recordId(undefined),
391
433
  };
392
- const showRow = (row?: Data) => {
434
+ function showRow(row?: Data) {
393
435
  openRowSidePanel({
394
436
  ...panel,
395
437
  rowTitle: rowTitleOf(run.target, row),
@@ -399,10 +441,13 @@ export function useActionTargets(config: ActionTargetsConfig) {
399
441
  showRow,
400
442
  });
401
443
  recordId(rowIdOf(row));
402
- };
403
- if (!run.missingId) return showRow(run.rowData);
404
- openRowSidePanel({ ...panel, notFound: notFoundOf(run.target), showRow });
405
- recordId(run.missingId);
444
+ }
445
+ function showMissing(id: string) {
446
+ openRowSidePanel({ ...panel, notFound: notFoundOf(run.target), showRow });
447
+ recordId(id);
448
+ }
449
+ if (run.missingId) return showMissing(run.missingId);
450
+ showRow(run.rowData);
406
451
  };
407
452
 
408
453
  // A container opened on a listed row steps through the rows shown (its
@@ -415,6 +460,12 @@ export function useActionTargets(config: ActionTargetsConfig) {
415
460
  const source = config.rowNavigation;
416
461
  let instance: ContainerInstance | undefined;
417
462
  let navigation: RowNavigation | undefined;
463
+ // Set once the container closes: a row read that resolves later neither
464
+ // writes `?record=` back nor patches the closed container.
465
+ let closed = false;
466
+ const showRowById = rowShower((row) => {
467
+ if (!closed) showRow(row);
468
+ });
418
469
  const optionsFor = (row?: Data) => {
419
470
  navigation =
420
471
  row && source ? rowNavigation(source, row, showRow) : undefined;
@@ -425,6 +476,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
425
476
  containerId: run.containerId,
426
477
  rowData: row,
427
478
  navigation,
479
+ showRow: showRowById,
428
480
  selection: run.selection,
429
481
  onSuccessCallback: () => {
430
482
  instance?.close();
@@ -436,14 +488,16 @@ export function useActionTargets(config: ActionTargetsConfig) {
436
488
  function showRow(row: Data) {
437
489
  instance?.patch({
438
490
  componentOptions: optionsFor(row),
439
- componentKey: String(get(row, source!.rowIdKey)),
491
+ componentKey: rowIdOf(row),
440
492
  });
441
493
  recordRow(row);
442
494
  }
443
495
  instance = openContainer(run, component, optionsFor(run.rowData));
444
496
  recordRow(run.rowData);
445
- const stopSteps = navigation ? listenRowSteps(() => navigation) : undefined;
497
+ // `showRow` may move an unlisted row's container onto a listed one.
498
+ const stopSteps = source ? listenRowSteps(() => navigation) : undefined;
446
499
  const settle = () => {
500
+ closed = true;
447
501
  stopSteps?.();
448
502
  recordRow(undefined);
449
503
  };
@@ -482,11 +536,11 @@ export function useActionTargets(config: ActionTargetsConfig) {
482
536
  const targetHandlers: Record<string, TargetHandler> = {
483
537
  page: (target, _label, rowData) => {
484
538
  const pageTarget = target as ActionTarget & { type: "page" };
485
- navigateDms(linkTargetUrl(pageTarget.url, rowData));
539
+ navigateDms(targetUrl(pageTarget.url, rowData));
486
540
  },
487
541
  external: (target, _label, rowData) => {
488
542
  const externalTarget = target as ActionTarget & { type: "external" };
489
- const url = linkTargetUrl(externalTarget.url, rowData);
543
+ const url = targetUrl(externalTarget.url, rowData);
490
544
  if (typeof window === "undefined" || !url) return;
491
545
  if (externalTarget.newTab) {
492
546
  window.open(url, "_blank", "noopener,noreferrer");
@@ -496,16 +550,12 @@ export function useActionTargets(config: ActionTargetsConfig) {
496
550
  },
497
551
  api: async (target, label, rowData) => {
498
552
  const apiTarget = target as ActionTarget & { type: "api" };
499
- const url = rowData
500
- ? interpolateUrl(apiTarget.url, rowData)
501
- : apiTarget.url;
553
+ const url = targetUrl(apiTarget.url, rowData);
502
554
  await handleApiTarget(apiTarget, url, label, rowData);
503
555
  },
504
556
  exportJob: async (target, _label, rowData) => {
505
557
  const jobTarget = target as ActionTarget & { type: "exportJob" };
506
- const url = rowData
507
- ? interpolateUrl(jobTarget.url, rowData)
508
- : jobTarget.url;
558
+ const url = targetUrl(jobTarget.url, rowData);
509
559
  await handleExportJobTarget(jobTarget, url, rowData);
510
560
  },
511
561
  download: (target, label, rowData) =>
@@ -525,9 +575,6 @@ export function useActionTargets(config: ActionTargetsConfig) {
525
575
  sidePanel: sidePanelHandler,
526
576
  };
527
577
 
528
- const targetUrl = (url: string, rowData?: Data): string =>
529
- rowData ? interpolateUrl(url, rowData) : url;
530
-
531
578
  // A target making a request runs it from inside its confirmation, which
532
579
  // shows a refusal (under its field when it names one) and stays open; any
533
580
  // other runs once confirmed.
@@ -548,7 +595,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
548
595
  },
549
596
  };
550
597
 
551
- // `{id}` of a confirmation's `from` URL, when the row carries an id.
598
+ // `{{row.id}}` of a confirmation's `from` URL, when the row carries an id.
552
599
  const rowUrlParams = (rowData?: Data) => {
553
600
  const id = rowData
554
601
  ? get(rowData, config.rowIdKey ?? DEFAULT_ROW_ID_KEY)
@@ -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
+ }