@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
@@ -1,11 +1,13 @@
1
1
  import { computed } from "vue";
2
2
  import { APEX_TYPE_CONFIGS } from "./apexTypeConfigs";
3
3
  import { buildAnnotations } from "./apexAnnotations";
4
- import { buildPlotOptions } from "./apexPlotOptions";
4
+ import { buildPlotOptions, ringFitsSliceLabels } from "./apexPlotOptions";
5
+ import { buildAutoYRange, formatPlainAxisValue } from "./apexYRange";
5
6
  import { isReducedMotionActive } from "../../build/utils/accessibilityPreferences";
6
7
  import {
7
8
  axisLabelStyle,
8
9
  buildDataLabels,
10
+ dataLabelOffset,
9
11
  buildGrid,
10
12
  buildLegend,
11
13
  buildMarkers,
@@ -286,36 +288,6 @@ function applySeriesStroke(
286
288
 
287
289
  const HIGH_CONTRAST_YAXIS_TYPES: ChartType[] = ["radar"];
288
290
 
289
- const DEFAULT_Y_RANGE_PADDING = 0.08;
290
- const ZERO_BOUND_MAGNITUDE = 1;
291
-
292
- function buildAutoYRange(input: UseApexChartInput): Record<string, unknown> {
293
- if (!input.autoYRange || APEX_TYPE_CONFIGS[input.type].isCircular) return {};
294
- const requested = input.autoYRange.padding ?? DEFAULT_Y_RANGE_PADDING;
295
- const padding =
296
- Number.isFinite(requested) && requested >= 0
297
- ? requested
298
- : DEFAULT_Y_RANGE_PADDING;
299
- const bounds = (input.annotations ?? [])
300
- .flatMap((annotation) => [annotation.y, annotation.y2])
301
- .filter(
302
- (value): value is number =>
303
- typeof value === "number" && Number.isFinite(value),
304
- );
305
- const lower = bounds.reduce((min, value) => Math.min(min, value), Infinity);
306
- const upper = bounds.reduce((max, value) => Math.max(max, value), -Infinity);
307
- return {
308
- min: (nativeMin: number) => {
309
- const min = Math.min(nativeMin, lower);
310
- return min - (Math.abs(min) || ZERO_BOUND_MAGNITUDE) * padding;
311
- },
312
- max: (nativeMax: number) => {
313
- const max = Math.max(nativeMax, upper);
314
- return max + (Math.abs(max) || ZERO_BOUND_MAGNITUDE) * padding;
315
- },
316
- };
317
- }
318
-
319
291
  function buildYAxis(
320
292
  input: UseApexChartInput,
321
293
  theme: ApexThemeColors,
@@ -331,9 +303,8 @@ function buildYAxis(
331
303
  yaxis.min = input.yRange.min;
332
304
  yaxis.max = input.yRange.max;
333
305
  }
334
- if (input.yAxisFormatter) {
335
- (yaxis.labels as Record<string, unknown>).formatter = input.yAxisFormatter;
336
- }
306
+ (yaxis.labels as Record<string, unknown>).formatter =
307
+ input.yAxisFormatter ?? formatPlainAxisValue;
337
308
  return yaxis;
338
309
  }
339
310
 
@@ -387,20 +358,50 @@ function applyMixedSeriesDefs(
387
358
  });
388
359
  }
389
360
 
361
+ const RAW_OPTION_PATH_SEPARATOR = ".";
362
+
363
+ function parseRawOptionValue(value: string): unknown {
364
+ try {
365
+ return JSON.parse(value);
366
+ } catch {
367
+ return value;
368
+ }
369
+ }
370
+
371
+ function isOptionSection(value: unknown): value is Record<string, unknown> {
372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
373
+ }
374
+
375
+ /**
376
+ * Set `value` at a dotted path ("markers.size"), copying each section on the
377
+ * way so the generated options are not mutated. A key without a dot replaces
378
+ * its whole section, as it always has.
379
+ */
380
+ function setOptionPath(
381
+ options: Record<string, unknown>,
382
+ path: string[],
383
+ value: unknown,
384
+ ): Record<string, unknown> {
385
+ const [head, ...rest] = path;
386
+ if (head === undefined) return options;
387
+ if (rest.length === 0) return { ...options, [head]: value };
388
+ const section = isOptionSection(options[head]) ? options[head] : {};
389
+ return { ...options, [head]: setOptionPath(section, rest, value) };
390
+ }
391
+
390
392
  function injectRawOptions(
391
393
  options: Record<string, unknown>,
392
394
  rawOptions: Array<{ key: string; value: string }> | undefined,
393
395
  ): Record<string, unknown> {
394
- if (!rawOptions || rawOptions.length === 0) return options;
395
- const merged: Record<string, unknown> = { ...options };
396
- for (const entry of rawOptions) {
397
- try {
398
- merged[entry.key] = JSON.parse(entry.value);
399
- } catch {
400
- merged[entry.key] = entry.value;
401
- }
402
- }
403
- return merged;
396
+ return (rawOptions ?? []).reduce(
397
+ (merged, entry) =>
398
+ setOptionPath(
399
+ merged,
400
+ entry.key.split(RAW_OPTION_PATH_SEPARATOR),
401
+ parseRawOptionValue(entry.value),
402
+ ),
403
+ options,
404
+ );
404
405
  }
405
406
 
406
407
  const RANGE_AREA_APEX_TYPE = "rangeArea";
@@ -465,8 +466,11 @@ function buildBaseOptions(
465
466
  chart: buildChartConfig(input, theme.muted),
466
467
  colors: seriesColors,
467
468
  dataLabels: buildDataLabels(
468
- config.supportsDataLabels || input.showLabels === true,
469
+ (config.supportsDataLabels || input.showLabels === true) &&
470
+ ringFitsSliceLabels(input),
469
471
  theme,
472
+ config.isCircular,
473
+ dataLabelOffset(input),
470
474
  ),
471
475
  stroke: buildStroke(
472
476
  input,
@@ -600,10 +604,6 @@ function applyOptionalSections(
600
604
  if (isCircular) {
601
605
  baseOptions.labels = buildCircularLabels(input.donutData).labels;
602
606
  }
603
-
604
- if (input.type === "scatter" && input.pointSize !== undefined) {
605
- baseOptions.markers = { size: input.pointSize };
606
- }
607
607
  }
608
608
 
609
609
  function projectSeries(
@@ -11,6 +11,11 @@ const SHADE_TOKEN =
11
11
  const CSS_VARIABLE = /^--[\w-]+$/;
12
12
  const CSS_VARIABLE_FUNCTION = /^var\((--[\w-]+)\)$/;
13
13
  const COLOR_CHANNEL_MAX = 255;
14
+ const PERCENT_SCALE = 100;
15
+ const PERCENT_SUFFIX = "%";
16
+ /** CSS Color 4 `rgb(R G B / A)`, which Apex cannot parse: it splits on commas. */
17
+ const SPACE_SEPARATED_RGB =
18
+ /^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i;
14
19
  const DEFAULT_PALETTE = [
15
20
  "--dms-chart-1",
16
21
  "--dms-chart-2",
@@ -34,6 +39,20 @@ function isHexLike(value: string): boolean {
34
39
  return HEX_PREFIXES.some((prefix) => value.startsWith(prefix));
35
40
  }
36
41
 
42
+ function alphaValue(alpha: string): number {
43
+ if (!alpha.endsWith(PERCENT_SUFFIX)) return Number(alpha);
44
+ return Number(alpha.slice(0, -PERCENT_SUFFIX.length)) / PERCENT_SCALE;
45
+ }
46
+
47
+ /** Rewrite a space-separated `rgb()` in the comma form Apex reads. */
48
+ function toCommaSeparatedRgb(color: string): string {
49
+ const match = SPACE_SEPARATED_RGB.exec(color);
50
+ if (!match) return color;
51
+ const [, red, green, blue, alpha] = match;
52
+ if (alpha === undefined) return `rgb(${red}, ${green}, ${blue})`;
53
+ return `rgba(${red}, ${green}, ${blue}, ${alphaValue(alpha)})`;
54
+ }
55
+
37
56
  const colorProbeCache = new Map<string, string>();
38
57
 
39
58
  /** Invalidate resolved colors before consumers recompute for a theme change. */
@@ -105,7 +124,7 @@ function colorVariable(value: string): string | undefined {
105
124
  /** Resolve semantic colors, shade tokens and global CSS variables for Apex. */
106
125
  export function resolveChartColor(value: string | undefined): string {
107
126
  const colorName = value ?? DEFAULT_COLOR_NAME;
108
- if (isHexLike(colorName)) return colorName;
127
+ if (isHexLike(colorName)) return toCommaSeparatedRgb(colorName);
109
128
  const variable = colorVariable(colorName);
110
129
  if (!variable) return colorName;
111
130
  return readCssVariable(variable, SAFE_HEX_FALLBACK);
@@ -76,7 +76,7 @@ export interface ConfirmOptions
76
76
  */
77
77
  export interface ConfirmFromOptions {
78
78
  /**
79
- * Fill-ins of the URL's `{placeholders}` and of the dialog's texts (a
79
+ * Fill-ins of the URL's `{{row.X}}` tokens and of the dialog's texts (a
80
80
  * record's fields); the dialog's own `params` win in its texts.
81
81
  */
82
82
  params?: Record<string, unknown>;
@@ -59,4 +59,6 @@ export interface FormProps extends FormComponentProps {
59
59
  redirectOnSuccess?: string;
60
60
  /** Field of the loaded row a page form names in the breadcrumb. */
61
61
  labelKey?: string;
62
+ /** Without the record `fetchUrl` names, "No entry selected", no fields. */
63
+ requiresRecord?: boolean;
62
64
  }
@@ -9,10 +9,11 @@ import type { FormField, FormFieldOrGroup } from "./types/field";
9
9
  import { isFieldGroup } from "./types/field";
10
10
  import { resolveResponseToast } from "../../build/utils/responseWarning";
11
11
  import { processFieldI18n } from "../../build/utils/fieldOptionsI18n";
12
+ import { orderFormEntries } from "../../build/composables/form/formEntryOrder";
12
13
  import {
13
14
  type ReplaceUrlVariablesContext,
14
15
  hasUrlVariables,
15
- replaceUrlVariables,
16
+ resolveLinkVariables,
16
17
  resolveTextVariables,
17
18
  resolveUrlVariables,
18
19
  } from "../../build/utils/urlVariables";
@@ -90,16 +91,17 @@ export function cloneFormValue(
90
91
 
91
92
  /**
92
93
  * Whether a form offers to save: once it has an address and a field someone
93
- * can fill in, unless its `saveMode` is `none`.
94
+ * can fill in, unless its `saveMode` is `none`. An `action` form does
95
+ * something even with nothing to fill in, so it needs only the address.
94
96
  */
95
97
  export function formShowsActions(
96
- options: Pick<FormProps, "saveMode" | "submitUrl">,
98
+ options: Pick<FormProps, "saveMode" | "submitUrl" | "kind">,
97
99
  fields: ReadonlyArray<{ disabled?: boolean }>,
98
100
  ): boolean {
99
101
  return (
100
102
  options.saveMode !== "none" &&
101
103
  !!options.submitUrl &&
102
- !fields.every((field) => field.disabled)
104
+ (options.kind === "action" || !fields.every((field) => field.disabled))
103
105
  );
104
106
  }
105
107
 
@@ -573,7 +575,7 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
573
575
  );
574
576
 
575
577
  const fields = computed<FormFieldOrGroup[]>(() =>
576
- props.fields.map((item) => {
578
+ orderFormEntries(props.fields).map((item) => {
577
579
  if (isFieldGroup(item)) {
578
580
  return {
579
581
  ...item,
@@ -770,7 +772,7 @@ export const useForm = (props: FormProps, options: UseFormOptions = {}) => {
770
772
  showSubmitNotice(response);
771
773
  props.onSuccessCallback?.(response, plainData);
772
774
  if (props.redirectOnSuccess) {
773
- const target = replaceUrlVariables(
775
+ const target = resolveLinkVariables(
774
776
  props.redirectOnSuccess,
775
777
  buildUrlContext(response as Record<string, unknown> | undefined),
776
778
  );
@@ -31,7 +31,7 @@ export interface SidePanelTargetOptions {
31
31
  */
32
32
  export interface DownloadActionTarget {
33
33
  type: "download";
34
- /** Takes the `{field}` and `{{params.X}}` / `{{query.X}}` tokens. */
34
+ /** Takes the `{{row.field}}` and `{{params.X}}` / `{{query.X}}` tokens. */
35
35
  url: string;
36
36
  /** Saved under; the response's `Content-Disposition` name, else the URL's. */
37
37
  filename?: string;
@@ -224,7 +224,7 @@ export interface TableViewSourceCapabilities {
224
224
  /** Where a `TableView.fromSource` table reads its rows (backend `source`). */
225
225
  export interface TableViewSourceConfig {
226
226
  fetchUrl: string;
227
- /** Route answering one row, `{id}` its id (backend `fetchRowUrl`). */
227
+ /** Route answering one row, `{{row.id}}` its id (backend `fetchRowUrl`). */
228
228
  fetchRowUrl?: string;
229
229
  capabilities: TableViewSourceCapabilities;
230
230
  /** Id of the `PeriodSelector` whose period is sent to `fetchUrl`. */
@@ -294,9 +294,9 @@ export interface TableViewConfig<T extends Data>
294
294
  routeParamFilters?: RouteParamFilters;
295
295
  routeParams?: Record<string, string>;
296
296
  tabs?: TableViewTab[];
297
- /** Displays this table view offers, beyond the implicit built-in `table`. */
297
+ /** Displays this table view offers, in order; the built-in `table` alone when left out. */
298
298
  displays?: TableViewDisplayConfig[];
299
- /** Display shown by default when the user has no saved preference. Defaults to "table". */
299
+ /** Display shown by default when the user has no saved preference. Defaults to the first display. */
300
300
  defaultDisplay?: string;
301
301
  /** Expandable rows: caret column + detail band. */
302
302
  expandable?: TableViewExpandableConfig;
@@ -214,7 +214,13 @@ export interface TableViewDisplayContext<T = Record<string, unknown>> {
214
214
  items: T[];
215
215
  /** Full column metadata (types, labels, accessor keys) — for field rendering. */
216
216
  columns: TableViewColumn[];
217
+ /** The shared query is in flight: a first read or any re-read. */
217
218
  loading: boolean;
219
+ /**
220
+ * No answer has arrived yet: draw a skeleton. A re-read keeps the rows,
221
+ * or the empty state, on screen; swapping either for a skeleton flashes.
222
+ */
223
+ firstPageLoading: boolean;
218
224
 
219
225
  selection: TableViewDisplaySelection;
220
226
  pagination: TableViewDisplayPagination;
@@ -0,0 +1,13 @@
1
+ import type { SemanticColor } from "./semanticTint";
2
+
3
+ /** The tones a banner washes with. */
4
+ export type BannerTone = Exclude<SemanticColor, "neutral">;
5
+
6
+ /** The icon a banner shows for its tone when it is given none. */
7
+ export const BANNER_TONE_ICONS: Record<BannerTone, string> = {
8
+ info: "i-ph-info",
9
+ success: "i-ph-check-circle",
10
+ warning: "i-ph-warning",
11
+ error: "i-ph-warning-circle",
12
+ primary: "i-ph-sparkle",
13
+ };
@@ -201,3 +201,29 @@ export function buildCascaderPathLabels(
201
201
  }
202
202
  return labels;
203
203
  }
204
+
205
+ function asPickedRow(item: unknown): CascaderRow | undefined {
206
+ return typeof item === "object" && item !== null
207
+ ? (item as CascaderRow)
208
+ : undefined;
209
+ }
210
+
211
+ /**
212
+ * The label path of a picked value — an id, or a row carrying its id and its
213
+ * own label — resolved against the flat list of rows once it is there; its
214
+ * own label, or its id, until then or when the list does not hold it.
215
+ */
216
+ export function cascaderValuePath(
217
+ item: unknown,
218
+ keyMapping: CascaderKeyMapping,
219
+ rows?: CascaderRow[],
220
+ ): string[] {
221
+ const row = asPickedRow(item);
222
+ const ownLabel = row ? rowValue(row, keyMapping.label) : undefined;
223
+ const id = row
224
+ ? rowValue(row, keyMapping.value)
225
+ : rowValue({ id: item }, "id");
226
+ if (!id) return ownLabel ? [ownLabel] : [];
227
+ const labels = rows ? buildCascaderPathLabels(rows, keyMapping, id) : [];
228
+ return labels.length > 0 ? labels : [ownLabel ?? id];
229
+ }
@@ -284,6 +284,8 @@
284
284
  "error_description": "Check your connection, then try again. Nothing can be saved until the entry loads.",
285
285
  "forbidden_title": "You can't view this entry",
286
286
  "forbidden_description": "Your role doesn't let you view this entry, so the form can't open on its current values. Ask an administrator for access.",
287
+ "missing_title": "No entry selected",
288
+ "missing_description": "This form shows one entry, and the address names none. Open it from an entry to see its values.",
287
289
  "retry": "Try again"
288
290
  },
289
291
  "select_date": "Select date",
@@ -335,6 +337,8 @@
335
337
  },
336
338
  "file": {
337
339
  "count": "{count} file(s)",
340
+ "drop_prompt": "Drop a file or browse",
341
+ "drop_prompt_multiple": "Drop files or browse",
338
342
  "max_size": "Max size: {size}",
339
343
  "uploaded_files": "Uploaded files",
340
344
  "rejected_title": "File not added",
@@ -285,6 +285,8 @@
285
285
  "error_description": "Vérifiez votre connexion, puis réessayez. Rien ne peut être enregistré tant que l'élément n'est pas chargé.",
286
286
  "forbidden_title": "Vous ne pouvez pas voir cet élément",
287
287
  "forbidden_description": "Votre rôle ne vous permet pas de voir cet élément, le formulaire ne peut donc pas s'ouvrir sur ses valeurs actuelles. Demandez l'accès à un administrateur.",
288
+ "missing_title": "Aucun élément sélectionné",
289
+ "missing_description": "Ce formulaire affiche un élément, et l'adresse n'en désigne aucun. Ouvrez-le depuis un élément pour voir ses valeurs.",
288
290
  "retry": "Réessayer"
289
291
  },
290
292
  "select_date": "Sélectionner une date",
@@ -336,6 +338,8 @@
336
338
  },
337
339
  "file": {
338
340
  "count": "{count} fichier(s)",
341
+ "drop_prompt": "Glisser un fichier ou parcourir",
342
+ "drop_prompt_multiple": "Glisser des fichiers ou parcourir",
339
343
  "max_size": "Taille max : {size}",
340
344
  "uploaded_files": "Fichiers téléversés",
341
345
  "rejected_title": "Fichier non ajouté",
@@ -87,6 +87,13 @@ it("keeps each day's separator on top of the scrolled entries", async () => {
87
87
  expect(html).toMatch(/<div[^>]*class="[^"]*\bsticky top-0\b[^"]*"[^>]*><h3/);
88
88
  });
89
89
 
90
+ it("keeps the fold button in sight at the foot of the scrolled entries", async () => {
91
+ const html = await render({ fillHeight: true, visibleItems: 3 });
92
+ expect(html).toMatch(
93
+ /class="dms-activity-feed__fold[^"]*\bsticky bottom-0\b[^"]*"/,
94
+ );
95
+ });
96
+
90
97
  it("names the region after the feed when it has no title", async () => {
91
98
  const area = scrollArea(await render({ fillHeight: true, title: undefined }));
92
99
  expect(area).toContain('aria-label="dms.activity_feed.list_label"');
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A DmsBanner given no icon shows its tone's, as the Banner block does: a
4
+ * check on success, an "i" on info. An icon given wins.
5
+ */
6
+ import { createApp, defineComponent, h, type App } from "vue";
7
+ import { afterEach, expect, it, vi } from "vitest";
8
+
9
+ vi.mock("../layers/dms-ui/app/components/icon-well/IconWell.vue", () => ({
10
+ default: defineComponent({
11
+ props: { icon: { type: String, default: undefined } },
12
+ setup: (props) => () => h("i", { "data-icon": props.icon }),
13
+ }),
14
+ }));
15
+
16
+ const { default: DmsBanner } = await import(
17
+ "../layers/dms-ui/app/components/banner/Banner.vue"
18
+ );
19
+
20
+ const UAlert = defineComponent({
21
+ setup:
22
+ (_props, { slots }) =>
23
+ () =>
24
+ h("div", [slots.leading?.()]),
25
+ });
26
+
27
+ let app: App | undefined;
28
+
29
+ afterEach(() => app?.unmount());
30
+
31
+ function iconOf(props: Record<string, unknown>): string | null {
32
+ const host = document.createElement("div");
33
+ app = createApp({ render: () => h(DmsBanner, { title: "Notice", ...props }) });
34
+ app.component("UAlert", UAlert);
35
+ app.mount(host);
36
+ return host.querySelector("[data-icon]")?.getAttribute("data-icon") ?? null;
37
+ }
38
+
39
+ it.each([
40
+ ["warning", "i-ph-warning"],
41
+ ["success", "i-ph-check-circle"],
42
+ ["info", "i-ph-info"],
43
+ ["error", "i-ph-warning-circle"],
44
+ ["primary", "i-ph-sparkle"],
45
+ ])("shows the %s tone's icon when given none", (tone, icon) => {
46
+ expect(iconOf({ tone })).toBe(icon);
47
+ });
48
+
49
+ it("keeps the warning icon of its default tone", () => {
50
+ expect(iconOf({})).toBe("i-ph-warning");
51
+ });
52
+
53
+ it("shows the icon it is given whatever the tone", () => {
54
+ expect(iconOf({ tone: "success", icon: "i-ph-rocket" })).toBe("i-ph-rocket");
55
+ });
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { type Component, createSSRApp, defineComponent, h } from "vue";
3
+ import { renderToString } from "vue/server-renderer";
4
+ import { createI18n } from "vue-i18n";
5
+ import en from "../layers/dms-layout/i18n/locales/layout-en-GB.json";
6
+ import fr from "../layers/dms-layout/i18n/locales/layout-fr-FR.json";
7
+
8
+ interface EmailModule {
9
+ default: Component;
10
+ }
11
+
12
+ const emailModules = import.meta.glob<EmailModule>(
13
+ "../layers/dms-layout/app/emails/*.vue",
14
+ { eager: true },
15
+ );
16
+
17
+ const PUBLIC_BASE = "https://admin.example.com";
18
+ const PUBLIC_PNG_LOGO = "/images/logo.png";
19
+ const SVG_LOGO = "/images/logo.svg";
20
+ const ELEMENTS: Record<string, string> = {
21
+ EHtml: "html",
22
+ EHead: "head",
23
+ EBody: "body",
24
+ EContainer: "div",
25
+ ESection: "section",
26
+ EHeading: "h1",
27
+ EText: "p",
28
+ EButton: "a",
29
+ EImg: "img",
30
+ EHr: "hr",
31
+ EPreview: "span",
32
+ };
33
+
34
+ function element(tag: string) {
35
+ return defineComponent({
36
+ inheritAttrs: false,
37
+ setup:
38
+ (_, { attrs, slots }) =>
39
+ () =>
40
+ h(tag, { src: attrs.src }, slots.default?.()),
41
+ });
42
+ }
43
+
44
+ function templateName(path: string): string {
45
+ return path.split("/").pop()?.replace(".vue", "") ?? path;
46
+ }
47
+
48
+ const EMAIL_PROPS = {
49
+ userName: "Ada",
50
+ newEmail: "ada@new.test",
51
+ expiresIn: "24 hours",
52
+ expiresInHours: 24,
53
+ partial: true,
54
+ failedSources: ["media"],
55
+ };
56
+
57
+ async function renderEmail(
58
+ component: Component,
59
+ logo: string,
60
+ locale = "en",
61
+ ): Promise<string> {
62
+ vi.stubGlobal("useDmsAppConfig", () => ({
63
+ branding: { logo: { default: { light: logo } } },
64
+ }));
65
+ vi.stubGlobal("useDmsRuntimeConfig", () => ({
66
+ public: { dms: { clientBaseUrl: PUBLIC_BASE } },
67
+ }));
68
+ const app = createSSRApp(component, EMAIL_PROPS);
69
+ app.use(
70
+ createI18n({
71
+ legacy: false,
72
+ locale,
73
+ fallbackLocale: "en",
74
+ messages: { en, fr },
75
+ }),
76
+ );
77
+ Object.entries(ELEMENTS).forEach(([name, tag]) =>
78
+ app.component(name, element(tag)),
79
+ );
80
+ const html = await renderToString(app);
81
+ vi.unstubAllGlobals();
82
+ return html;
83
+ }
84
+
85
+ const templates = Object.entries(emailModules).map(
86
+ ([path, module]) => [templateName(path), module.default] as const,
87
+ );
88
+
89
+ describe("built-in emails", () => {
90
+ it.each(templates)(
91
+ "%s leaves out a logo mail clients cannot load",
92
+ async (_name, component) => {
93
+ const html = await renderEmail(component, SVG_LOGO);
94
+ expect(html).not.toContain("<img");
95
+ },
96
+ );
97
+
98
+ it.each(templates)("%s draws a public PNG logo", async (_name, component) => {
99
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO);
100
+ expect(html).toContain(`<img src="${PUBLIC_BASE}${PUBLIC_PNG_LOGO}"`);
101
+ });
102
+
103
+ it.each(templates)("%s is written in French", async (_name, component) => {
104
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO, "fr");
105
+ expect(html).toContain("Bonjour");
106
+ expect(html).not.toMatch(/Hello|If you/);
107
+ });
108
+
109
+ it("words the code lifetime in the recipient's language", async () => {
110
+ const [, component] = templates.find(
111
+ ([name]) => name === "EmailResetPassword",
112
+ )!;
113
+ expect(await renderEmail(component, PUBLIC_PNG_LOGO)).toContain(
114
+ "This code expires in 24 hours.",
115
+ );
116
+ expect(await renderEmail(component, PUBLIC_PNG_LOGO, "fr")).toContain(
117
+ "Ce code expire dans 24 heures.",
118
+ );
119
+ });
120
+
121
+ it("marks a partial export in French", async () => {
122
+ const [, component] = templates.find(
123
+ ([name]) => name === "EmailExportReady",
124
+ )!;
125
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO, "fr");
126
+ expect(html).toContain("<strong>incomplet</strong>");
127
+ expect(html.replace(/<!--[^>]*-->/g, "")).toContain("— media.");
128
+ });
129
+
130
+ it("puts the two-factor disclaimer under the separator", async () => {
131
+ const [, component] = templates.find(
132
+ ([name]) => name === "EmailTwoFactor",
133
+ )!;
134
+ const html = await renderEmail(component, PUBLIC_PNG_LOGO);
135
+ const separator = html.indexOf("<hr>");
136
+ expect(separator).toBeGreaterThan(-1);
137
+ expect(html.indexOf("If you did not attempt to sign in")).toBeGreaterThan(
138
+ separator,
139
+ );
140
+ });
141
+ });
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  buildCascaderPathLabels,
4
4
  buildCascaderTree,
5
+ cascaderValuePath,
5
6
  findCascaderNode,
6
7
  flattenCascaderTree,
7
8
  formatCascaderPath,
@@ -177,6 +178,32 @@ describe("buildCascaderPathLabels", () => {
177
178
  });
178
179
  });
179
180
 
181
+ describe("cascaderValuePath", () => {
182
+ it("resolves a picked id, or a picked row, to its path", () => {
183
+ expect(cascaderValuePath("speakers", keyMapping, categories)).toEqual([
184
+ "Electronics",
185
+ "Audio",
186
+ "Speakers",
187
+ ]);
188
+ expect(
189
+ cascaderValuePath({ _id: "tv", name: "TV" }, keyMapping, categories),
190
+ ).toEqual(["Electronics", "Video", "TV"]);
191
+ });
192
+
193
+ it("shows a picked row's own label, or its id, until the rows are there", () => {
194
+ expect(cascaderValuePath({ _id: "tv", name: "TV" }, keyMapping)).toEqual([
195
+ "TV",
196
+ ]);
197
+ expect(cascaderValuePath("tv", keyMapping)).toEqual(["tv"]);
198
+ expect(cascaderValuePath("nope", keyMapping, categories)).toEqual(["nope"]);
199
+ });
200
+
201
+ it("has no path for nothing picked", () => {
202
+ expect(cascaderValuePath(null, keyMapping, categories)).toEqual([]);
203
+ expect(cascaderValuePath({ name: "Loose" }, keyMapping)).toEqual(["Loose"]);
204
+ });
205
+ });
206
+
180
207
  describe("resolveCascaderKeyMapping", () => {
181
208
  it("falls back to default keys", () => {
182
209
  expect(resolveCascaderKeyMapping()).toEqual({