@antelopejs/dms 0.7.11 → 0.7.13

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 (79) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/implementations/dms/page.js +14 -1
  3. package/dist/implementations/dms/page.js.map +1 -1
  4. package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
  5. package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
  6. package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  10. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  13. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  14. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  15. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  16. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  17. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  18. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +23 -2
  19. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
  20. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
  21. package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
  22. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  23. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  24. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  29. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  30. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  31. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  32. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  33. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  34. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  35. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  36. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  37. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +7 -1
  38. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  39. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  40. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  41. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  42. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  43. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +2 -0
  44. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  45. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  46. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  50. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  51. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  52. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  53. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  54. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  55. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  56. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  57. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  58. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  59. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  60. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
  61. package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
  62. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  63. package/frontend-vue/tests/code-block.test.ts +28 -0
  64. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  65. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  66. package/frontend-vue/tests/header-actions.test.ts +1 -1
  67. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  68. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  69. package/frontend-vue/tests/module-routing.test.ts +158 -0
  70. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  71. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  72. package/frontend-vue/tests/regional-format.test.ts +69 -2
  73. package/frontend-vue/tests/scroll-to-block.test.ts +171 -0
  74. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  75. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  76. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  77. package/package.json +2 -2
  78. package/skills/dms/SKILL.md +28 -4
  79. package/skills/dms-module/SKILL.md +2 -1
@@ -1,16 +1,55 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { TableLiveHoldReason } from "../../composables/table-view/useTableLiveHold";
4
+ import type { ConfirmTranslate } from "../../composables/confirm/confirmDialogTexts";
5
+ import { missedRowsText, showLiveNote } from "../../composables/live/liveNotes";
6
+ import type { PageLiveNote } from "../../../types/live";
7
+
2
8
  /**
3
- * The strip a `liveHold` table shows while its page is paused and events came
4
- * in: "N new · Show". Show reads the rows once; the page stays paused.
9
+ * The strip of a `liveHold` table: "N new · Show" once events came in while
10
+ * it holds, led by why it holds when it does so by itself, its page staying
11
+ * live ("Table on hold · search active · 3 new · Show"), and followed, while
12
+ * the page is paused, by the breakdown a component gave the page ("12 new
13
+ * requests · 2 in 5xx · Show"). Show reads the rows once; the hold stays.
5
14
  */
6
- defineProps<{
7
- /** Events the table received since its rows were held or last shown. */
8
- count: number;
9
- }>();
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ /** Events the table received since its rows were held or last shown. */
18
+ count: number;
19
+ /** Why the table holds by itself, `null` while its page is paused. */
20
+ reason?: TableLiveHoldReason | null;
21
+ /** What the new rows are called, given `{count}`; "N new" without. */
22
+ missedLabel?: string;
23
+ /** The breakdown of what the paused page missed. */
24
+ detail?: PageLiveNote | null;
25
+ }>(),
26
+ { reason: null, missedLabel: undefined, detail: null },
27
+ );
10
28
 
11
29
  const emit = defineEmits<{ show: [] }>();
12
30
 
13
31
  const { t } = useI18n();
32
+ const translate = t as ConfirmTranslate;
33
+
34
+ const REASON_KEYS: Record<TableLiveHoldReason, string> = {
35
+ rowOpen: "dms.live.held_row_open",
36
+ searching: "dms.live.held_searching",
37
+ sorted: "dms.live.held_sorted",
38
+ scrolled: "dms.live.held_scrolled",
39
+ };
40
+
41
+ const reasonText = computed(() =>
42
+ props.reason
43
+ ? `${t("dms.live.table_held")} · ${t(REASON_KEYS[props.reason])}`
44
+ : "",
45
+ );
46
+
47
+ const countText = computed(() =>
48
+ missedRowsText(props.count, props.missedLabel, translate),
49
+ );
50
+ const detailNote = computed(() =>
51
+ props.detail ? showLiveNote(props.detail, translate) : null,
52
+ );
14
53
  </script>
15
54
 
16
55
  <template>
@@ -19,17 +58,30 @@ const { t } = useI18n();
19
58
  class="dms-live-hold-banner text-highlighted flex items-center gap-2.5 border-b border-(--dms-info-line) bg-(--dms-info-tint) py-[7px] ps-[18px] pe-3.5 text-[13px]"
20
59
  >
21
60
  <UIcon name="i-ph-pause-circle" class="text-info size-4 shrink-0" />
22
- <span class="min-w-0">
23
- {{ t("dms.live.new_rows", { count }, count) }}
61
+ <span v-if="reasonText" class="dms-live-hold-banner__reason min-w-0">
62
+ {{ reasonText }}
24
63
  </span>
25
- <span aria-hidden="true" class="text-muted">·</span>
26
- <UButton
27
- :label="t('dms.live.show_new')"
28
- color="neutral"
29
- variant="link"
30
- size="xs"
31
- class="p-0 font-medium"
32
- @click="emit('show')"
33
- />
64
+ <template v-if="count > 0">
65
+ <span v-if="reasonText" aria-hidden="true" class="text-muted">·</span>
66
+ <span class="min-w-0">{{ countText }}</span>
67
+ <template v-if="detailNote">
68
+ <span aria-hidden="true" class="text-muted">·</span>
69
+ <span
70
+ class="dms-live-hold-banner__detail min-w-0"
71
+ :class="detailNote.toneClass"
72
+ >
73
+ {{ detailNote.text }}
74
+ </span>
75
+ </template>
76
+ <span aria-hidden="true" class="text-muted">·</span>
77
+ <UButton
78
+ :label="t('dms.live.show_new')"
79
+ color="neutral"
80
+ variant="link"
81
+ size="xs"
82
+ class="p-0 font-medium"
83
+ @click="emit('show')"
84
+ />
85
+ </template>
34
86
  </div>
35
87
  </template>
@@ -63,7 +63,7 @@ export function buildLegend(
63
63
  ): Record<string, unknown> {
64
64
  return {
65
65
  show: input.showLegend ?? true,
66
- position: isCircular ? "bottom" : "top",
66
+ position: input.legendPosition ?? (isCircular ? "bottom" : "top"),
67
67
  horizontalAlign: "right",
68
68
  fontSize: LEGEND_FONT_SIZE,
69
69
  labels: { colors: theme.muted },
@@ -0,0 +1,38 @@
1
+ import type { InjectionKey } from "vue";
2
+ import type { RegisterValidator } from "../../../composables/form/types/validation";
3
+
4
+ /**
5
+ * What a `Form` offers the fields it draws (see `useEnclosingForm`).
6
+ *
7
+ * @internal
8
+ */
9
+ export interface FormHandle {
10
+ /** Submits as the save button does: validation, validators, then the request. */
11
+ submit: () => Promise<void>;
12
+ registerValidator: RegisterValidator;
13
+ /** Brings a field of the form into view and focuses its control. */
14
+ revealField: (fieldId: string) => Promise<void>;
15
+ }
16
+
17
+ /** @internal */
18
+ export const FORM_HANDLE_KEY: InjectionKey<FormHandle> = Symbol("form-handle");
19
+
20
+ /**
21
+ * The field of `fieldIds` a URL fragment names (`#requestLogMaxBodyKb`, the
22
+ * id its control carries), `undefined` when it names none.
23
+ *
24
+ * @internal
25
+ */
26
+ export function linkedFieldId(
27
+ hash: string,
28
+ fieldIds: readonly string[],
29
+ ): string | undefined {
30
+ if (!hash.startsWith("#")) return undefined;
31
+ let id: string;
32
+ try {
33
+ id = decodeURIComponent(hash.slice(1));
34
+ } catch {
35
+ return undefined;
36
+ }
37
+ return fieldIds.includes(id) ? id : undefined;
38
+ }
@@ -1,3 +1,10 @@
1
+ // The page search keys are public (`composables/global/searchShortcuts`):
2
+ // re-exported for the layer's own callers.
3
+ export {
4
+ buildPageSearchShortcuts,
5
+ PAGE_SEARCH_SHORTCUTS,
6
+ } from "../../../composables/global/searchShortcuts";
7
+
1
8
  const GLOBAL_COMPONENT = "$dms.components.global";
2
9
 
3
10
  /**
@@ -6,15 +13,6 @@ const GLOBAL_COMPONENT = "$dms.components.global";
6
13
  */
7
14
  export const NAV_SEARCH_SHORTCUT = "/";
8
15
 
9
- /**
10
- * Focuses a page's own search (⌘ / on macOS, Ctrl / elsewhere), so it never
11
- * competes with "/" for the navigation search. `meta` is read as Ctrl off
12
- * macOS by `defineShortcuts`. The shifted variant is the same physical keys
13
- * on layouts where "/" needs Shift (AZERTY: Shift + :), which
14
- * `defineShortcuts` would otherwise reject as an extra modifier.
15
- */
16
- export const PAGE_SEARCH_SHORTCUTS = ["meta_/", "meta_shift_/"] as const;
17
-
18
16
  /**
19
17
  * The page search keys as hint chips, by `defineShortcuts` key name: print
20
18
  * them with `keyboardKeyLabel` ("⌘" "/" on macOS, "Ctrl" "/" elsewhere).
@@ -33,13 +31,6 @@ export function buildNavSearchShortcuts(
33
31
  return { [NAV_SEARCH_SHORTCUT]: focus };
34
32
  }
35
33
 
36
- /** `defineShortcuts` map focusing a page's own search on ⌘ / or Ctrl /. */
37
- export function buildPageSearchShortcuts(
38
- focus: () => void,
39
- ): Record<string, () => void> {
40
- return Object.fromEntries(PAGE_SEARCH_SHORTCUTS.map((key) => [key, focus]));
41
- }
42
-
43
34
  /** "/" focuses the navigation search (listed on the shortcuts page). */
44
35
  export const GLOBAL_SLASH_METADATA: ShortcutMetadata = {
45
36
  key: [NAV_SEARCH_SHORTCUT],
@@ -0,0 +1,54 @@
1
+ import type { PageLiveNote, PageLiveNoteTone } from "../../../types/live";
2
+ import {
3
+ resolveConfirmText,
4
+ type ConfirmTranslate,
5
+ } from "../confirm/confirmDialogTexts";
6
+
7
+ /**
8
+ * How a live note is coloured: a breakdown in 5xx reads as an error.
9
+ *
10
+ * @internal
11
+ */
12
+ export const LIVE_NOTE_TONE_CLASSES: Record<PageLiveNoteTone, string> = {
13
+ neutral: "",
14
+ warning: "text-warning",
15
+ error: "text-error",
16
+ };
17
+
18
+ /** A live note as shown, and its colour. @internal */
19
+ export interface ShownLiveNote {
20
+ text: string;
21
+ toneClass: string;
22
+ }
23
+
24
+ /**
25
+ * A live note as the toggle and a held table's strip show it: its `$` key
26
+ * translated with its params, or its text.
27
+ *
28
+ * @internal
29
+ */
30
+ export function showLiveNote(
31
+ note: PageLiveNote,
32
+ t: ConfirmTranslate,
33
+ ): ShownLiveNote {
34
+ return {
35
+ text: resolveConfirmText(note.text, note.params ?? {}, t),
36
+ toneClass: LIVE_NOTE_TONE_CLASSES[note.tone ?? "neutral"],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * How many new rows came in: a module's own wording given `{count}`
42
+ * (`missedLabel`), or "N new".
43
+ *
44
+ * @internal
45
+ */
46
+ export function missedRowsText(
47
+ count: number,
48
+ missedLabel: string | undefined,
49
+ t: ConfirmTranslate,
50
+ ): string {
51
+ return missedLabel
52
+ ? resolveConfirmText(missedLabel, { count }, t)
53
+ : t("dms.live.new_rows", { count }, count);
54
+ }
@@ -0,0 +1,50 @@
1
+ import type { ComponentPublicInstance, VNode } from "vue";
2
+
3
+ // The blocks a page draws, by their anchor: the key path `RecursiveComponent`
4
+ // renders them under (`summary`, `tabs.statistics`). Registered from a
5
+ // template ref, which only runs in the browser, so a server render never
6
+ // fills it.
7
+ const blocks = new Map<string, ComponentPublicInstance>();
8
+
9
+ /**
10
+ * Records the block drawn under `anchor`, or forgets it once unmounted
11
+ * (`instance` is `null`). A block drawn again under the same anchor (a
12
+ * drawer showing the same page) takes it over until it unmounts.
13
+ *
14
+ * @internal
15
+ */
16
+ export function registerBlockAnchor(
17
+ anchor: string | undefined,
18
+ instance: ComponentPublicInstance | null,
19
+ previous?: ComponentPublicInstance | null,
20
+ ): void {
21
+ if (!anchor) return;
22
+ if (instance) {
23
+ blocks.set(anchor, instance);
24
+ return;
25
+ }
26
+ if (!previous || blocks.get(anchor) === previous) blocks.delete(anchor);
27
+ }
28
+
29
+ function firstElementOf(vnode: VNode | null | undefined): Element | null {
30
+ if (!vnode) return null;
31
+ if (vnode.el instanceof Element) return vnode.el;
32
+ if (vnode.component) return firstElementOf(vnode.component.subTree);
33
+ if (!Array.isArray(vnode.children)) return null;
34
+ for (const child of vnode.children) {
35
+ const element = firstElementOf(child as VNode);
36
+ if (element) return element;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * The first element the block drawn under `anchor` renders, `null` when no
43
+ * such block is on screen (an unknown key, a closed tab, a hidden block).
44
+ *
45
+ * @internal
46
+ */
47
+ export function blockAnchorElement(anchor: string): Element | null {
48
+ const instance = blocks.get(anchor);
49
+ return instance ? firstElementOf(instance.$.subTree) : null;
50
+ }
@@ -1,6 +1,9 @@
1
1
  import { computed, ref, watch, type ComputedRef } from "vue";
2
2
  import { usePageLive } from "../../../composables/live/usePageLive";
3
- import type { TableViewLiveHoldOptions } from "../../../types/live";
3
+ import type {
4
+ PageLiveNote,
5
+ TableViewLiveHoldOptions,
6
+ } from "../../../types/live";
4
7
  import type { LiveGate } from "../live/currentPageLive";
5
8
  import { useScrolledPastTop } from "./useScrolledPastTop";
6
9
  import { LIVE_HELD_AT_QUERY_KEY } from "./useTableRows";
@@ -11,10 +14,21 @@ type Query = Record<string, unknown>;
11
14
  export interface TableLiveHoldSignals {
12
15
  /** One of its rows is open in the side panel. */
13
16
  isRowOpen?: () => boolean;
17
+ /** The user's search narrows the rows. */
18
+ isSearching?: () => boolean;
19
+ /** The rows are sorted otherwise than by the table's default. */
20
+ isSorted?: () => boolean;
14
21
  /** The table's element, whose first row `whileScrolled` follows. */
15
22
  tableElement?: () => unknown;
16
23
  }
17
24
 
25
+ /** Why a table holds its rows by itself, its page staying live. */
26
+ export type TableLiveHoldReason =
27
+ | "rowOpen"
28
+ | "searching"
29
+ | "sorted"
30
+ | "scrolled";
31
+
18
32
  export interface TableLiveHoldOptions {
19
33
  /** The table's `liveHold`: `true` or an object enables it. */
20
34
  liveHold: boolean | TableViewLiveHoldOptions | undefined;
@@ -33,6 +47,18 @@ export interface TableLiveHold {
33
47
  isHolding: ComputedRef<boolean>;
34
48
  /** Events the table received since its rows were held or last shown. */
35
49
  newCount: ComputedRef<number>;
50
+ /**
51
+ * Why the table holds by itself while its page is live, `null` otherwise:
52
+ * a paused page says why on its toggle.
53
+ */
54
+ reason: ComputedRef<TableLiveHoldReason | null>;
55
+ /** What the new rows are called, given `{count}` (`liveHold.missedLabel`). */
56
+ missedLabel: string | undefined;
57
+ /**
58
+ * The breakdown of what the paused page missed (`setMissedDetail`), `null`
59
+ * while the page is live or nothing came in.
60
+ */
61
+ missedDetail: ComputedRef<PageLiveNote | null>;
36
62
  /** Reads the rows once, staying held, and holds them from now. */
37
63
  showNew: () => void;
38
64
  /** The bound a source route receives with each request while held. */
@@ -51,54 +77,53 @@ function holdOptionsOf(
51
77
  }
52
78
 
53
79
  // The table alone holds while the page is live: the user reads a row in the
54
- // side panel, or reads further down than the rows that would move.
80
+ // side panel, narrows or reorders the rows, or reads further down than the
81
+ // rows that would move. The first reason in this order is the one shown.
55
82
  function useLocalHold(
56
83
  holdOptions: TableViewLiveHoldOptions | null,
57
84
  signals: TableLiveHoldSignals,
58
- ): ComputedRef<boolean> {
59
- const isRowOpen = signals.isRowOpen ?? NEVER;
85
+ ): ComputedRef<TableLiveHoldReason | null> {
60
86
  const tableElement = signals.tableElement ?? NO_ELEMENT;
61
87
  const isScrolled = useScrolledPastTop(() => {
62
88
  const element = tableElement();
63
89
  return element instanceof HTMLElement ? element : null;
64
90
  }, !!holdOptions?.whileScrolled);
91
+ const reasons: Array<[TableLiveHoldReason, boolean, () => boolean]> = [
92
+ ["rowOpen", !!holdOptions?.whileRowOpen, signals.isRowOpen ?? NEVER],
93
+ ["searching", !!holdOptions?.whileSearching, signals.isSearching ?? NEVER],
94
+ ["sorted", !!holdOptions?.whileSorted, signals.isSorted ?? NEVER],
95
+ ["scrolled", !!holdOptions?.whileScrolled, () => isScrolled.value],
96
+ ];
65
97
  return computed(
66
98
  () =>
67
- (!!holdOptions?.whileRowOpen && isRowOpen()) ||
68
- (!!holdOptions?.whileScrolled && isScrolled.value),
99
+ reasons.find(([, isEnabled, isActive]) => isEnabled && isActive())?.[0] ??
100
+ null,
69
101
  );
70
102
  }
71
103
 
72
104
  /**
73
105
  * The `liveHold` of a table view: while its live page is paused — or, with
74
- * `whileRowOpen` / `whileScrolled`, while the user reads a row or scrolled
75
- * down — the table keeps its rows: the gate requests nothing on its events
76
- * and counts them, and a source route is told the moment they were frozen, so
77
- * the user's own search, sort and pagination list the rows as they stood.
106
+ * `whileRowOpen` / `whileSearching` / `whileSorted` / `whileScrolled`, while
107
+ * the user reads a row, searches, sorts or scrolled down — the table keeps
108
+ * its rows: the gate requests nothing on its events and counts them, and a
109
+ * source route is told the moment they were frozen, so the user's own
110
+ * search, sort and pagination list the rows as they stood.
78
111
  * Show reads them once more without leaving the hold; the end of a hold of
79
112
  * the table's own reads them if it missed any.
80
113
  *
81
114
  * @internal
82
115
  */
83
116
  export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
84
- const { isEnabled, isPaused } = usePageLive();
117
+ const { isEnabled, isPaused, missedDetail } = usePageLive();
85
118
  const holdOptions = holdOptionsOf(options.liveHold);
86
- const isLocallyHeld = useLocalHold(holdOptions, options.signals ?? {});
87
- const pausedAt = ref<number | null>(null);
119
+ const localReason = useLocalHold(holdOptions, options.signals ?? {});
120
+ const isLocallyHeld = computed(() => localReason.value !== null);
88
121
  const isHolding = computed(
89
122
  () =>
90
123
  !!holdOptions &&
91
124
  isEnabled.value &&
92
125
  (isPaused.value || isLocallyHeld.value),
93
126
  );
94
-
95
- watch(
96
- isHolding,
97
- (holding) => {
98
- pausedAt.value = holding ? Date.now() : null;
99
- },
100
- { immediate: true },
101
- );
102
127
  // Again on a new controller: the gate binds to the page on screen.
103
128
  watch(
104
129
  [isLocallyHeld, isEnabled],
@@ -107,25 +132,43 @@ export function useTableLiveHold(options: TableLiveHoldOptions): TableLiveHold {
107
132
  },
108
133
  { flush: "sync" },
109
134
  );
110
-
111
- // The rows are as fresh as the hold, or as the last read the user asked
112
- // for since (Show, an action of theirs).
113
- const heldAt = () =>
114
- pausedAt.value === null
115
- ? null
116
- : Math.max(pausedAt.value, options.gate().lastReadAt.value);
135
+ const newCount = computed(() =>
136
+ isHolding.value ? options.gate().missed.value : 0,
137
+ );
117
138
 
118
139
  return {
119
140
  isHolding,
120
- newCount: computed(() =>
121
- isHolding.value ? options.gate().missed.value : 0,
141
+ newCount,
142
+ reason: computed(() =>
143
+ isHolding.value && !isPaused.value ? localReason.value : null,
144
+ ),
145
+ missedLabel: holdOptions?.missedLabel,
146
+ missedDetail: computed(() =>
147
+ isPaused.value && newCount.value > 0 ? missedDetail.value : null,
122
148
  ),
123
149
  showNew: () => options.gate().catchUp(),
124
- heldQuery: () => {
125
- const at = heldAt();
126
- return options.isSource && at !== null
127
- ? { [LIVE_HELD_AT_QUERY_KEY]: new Date(at).toISOString() }
128
- : NO_QUERY;
150
+ heldQuery: useHeldQuery(isHolding, options),
151
+ };
152
+ }
153
+
154
+ // The bound a source route receives while the rows are held: they are as
155
+ // fresh as the hold, or as the last read the user asked for since (Show, an
156
+ // action of theirs).
157
+ function useHeldQuery(
158
+ isHolding: ComputedRef<boolean>,
159
+ options: TableLiveHoldOptions,
160
+ ): () => Query {
161
+ const pausedAt = ref<number | null>(null);
162
+ watch(
163
+ isHolding,
164
+ (holding) => {
165
+ pausedAt.value = holding ? Date.now() : null;
129
166
  },
167
+ { immediate: true },
168
+ );
169
+ return () => {
170
+ if (!options.isSource || pausedAt.value === null) return NO_QUERY;
171
+ const at = Math.max(pausedAt.value, options.gate().lastReadAt.value);
172
+ return { [LIVE_HELD_AT_QUERY_KEY]: new Date(at).toISOString() };
130
173
  };
131
174
  }
@@ -0,0 +1,60 @@
1
+ import type {
2
+ InputNumberAdjustment,
3
+ InputNumberAdjustmentReason,
4
+ } from "../../types/input-number";
5
+
6
+ /** The bounds a number field keeps its value within. */
7
+ export interface NumberFieldBounds {
8
+ min?: number;
9
+ max?: number;
10
+ }
11
+
12
+ // 11111.1 writes both the group and the decimal separator in every locale.
13
+ const SEPARATOR_SAMPLE = 11111.1;
14
+ const WHITESPACE = /\s/g;
15
+ const NOT_NUMERIC = /[^\d.+-]/g;
16
+
17
+ function separatorsOf(locale: string): { group?: string; decimal: string } {
18
+ const parts = new Intl.NumberFormat(locale).formatToParts(SEPARATOR_SAMPLE);
19
+ return {
20
+ group: parts.find((part) => part.type === "group")?.value,
21
+ decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
22
+ };
23
+ }
24
+
25
+ /**
26
+ * A number as typed in a field written in `locale` ("1,020", "1 020,5"):
27
+ * `null` when nothing is typed or it does not read as a number.
28
+ */
29
+ export function parseTypedNumber(text: string, locale: string): number | null {
30
+ const { group, decimal } = separatorsOf(locale);
31
+ let digits = text.replace(WHITESPACE, "");
32
+ if (group && group.trim()) digits = digits.split(group).join("");
33
+ digits = digits.split(decimal).join(".").replace(NOT_NUMERIC, "");
34
+ if (!digits) return null;
35
+ const value = Number(digits);
36
+ return Number.isFinite(value) ? value : null;
37
+ }
38
+
39
+ function reasonOf(
40
+ typed: number,
41
+ bounds: NumberFieldBounds,
42
+ ): InputNumberAdjustmentReason {
43
+ if (bounds.max !== undefined && typed > bounds.max) return "max";
44
+ if (bounds.min !== undefined && typed < bounds.min) return "min";
45
+ return "step";
46
+ }
47
+
48
+ /**
49
+ * How a number field changed what was typed into the value it keeps: kept
50
+ * within its bounds, or put on its steps. `null` when it keeps what was typed.
51
+ */
52
+ export function numberAdjustment(
53
+ typed: number | null,
54
+ value: number | null | undefined,
55
+ bounds: NumberFieldBounds,
56
+ ): InputNumberAdjustment | null {
57
+ if (typed === null || typeof value !== "number" || value === typed)
58
+ return null;
59
+ return { typed, value, reason: reasonOf(typed, bounds) };
60
+ }
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import type { ComponentPublicInstance } from "vue";
2
3
  import { usePermissionPreview } from "#dms-core/app/build/composables/auth/usePermissionPreview";
3
4
  import { childLayoutPath } from "#dms-core/app/build/utils/permission-preview";
4
5
  import { childComponentId } from "../utils/componentId";
@@ -7,6 +8,7 @@ import {
7
8
  type GridDeclaredColumns,
8
9
  } from "./grid/constants";
9
10
  import DmsPermissionVeil from "../build/components/permission/PermissionVeil.vue";
11
+ import { registerBlockAnchor } from "../build/composables/page/blockAnchors";
10
12
 
11
13
  interface Props {
12
14
  component: ResolvedComponentInfo;
@@ -22,6 +24,14 @@ interface Props {
22
24
 
23
25
  const props = defineProps<Props>();
24
26
 
27
+ // The block's anchor is its layout path: `scrollToBlock(key)` finds it there.
28
+ let anchored: ComponentPublicInstance | null = null;
29
+ function anchorBlock(instance: unknown): void {
30
+ const block = (instance as ComponentPublicInstance | null) ?? null;
31
+ registerBlockAnchor(props.layoutPath, block, anchored);
32
+ anchored = block;
33
+ }
34
+
25
35
  const { t } = useI18n();
26
36
  const preview = usePermissionPreview();
27
37
  const previewBlock = computed(() => preview.blockFor(props.layoutPath));
@@ -97,6 +107,7 @@ provide<GridDeclaredColumns>(GRID_DECLARED_COLUMNS, {
97
107
  >
98
108
  <Component
99
109
  :is="component.component"
110
+ :ref="anchorBlock"
100
111
  v-bind="JSON.parse(JSON.stringify(component.options || {})) || {}"
101
112
  :page-id="pageId"
102
113
  :component-id="componentId"
@@ -26,6 +26,12 @@ interface Props {
26
26
  to?: string;
27
27
  /** Hover band; on by default. */
28
28
  interactive?: boolean;
29
+ /**
30
+ * The title, details and detail line wrap over as many lines as they
31
+ * need, a long path or query breaking anywhere, instead of one line each
32
+ * cut with an ellipsis.
33
+ */
34
+ wrap?: boolean;
29
35
  }
30
36
 
31
37
  interface Slots {
@@ -45,6 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
45
51
  unread: false,
46
52
  to: undefined,
47
53
  interactive: true,
54
+ wrap: false,
48
55
  });
49
56
  const slots = defineSlots<Slots>();
50
57
 
@@ -57,7 +64,8 @@ const hasTitle = computed(() => !!props.title || !!slots.default);
57
64
  size="sm"
58
65
  icon-size="xs"
59
66
  marker="trailing"
60
- truncate
67
+ :truncate="!props.wrap"
68
+ :class="props.wrap && '[overflow-wrap:anywhere]'"
61
69
  :icon="props.icon"
62
70
  :tone="props.iconColor"
63
71
  :meta="meta"
@@ -71,15 +79,15 @@ const hasTitle = computed(() => !!props.title || !!slots.default);
71
79
  <template v-if="hasTitle" #default>
72
80
  <!-- One line on wide screens (the full text shows on hover); on a
73
81
  phone a sentence wraps rather than losing its end, while a mono
74
- path or query keeps its line. -->
82
+ path or query keeps its line. `wrap` keeps every word. -->
75
83
  <span
76
- class="min-w-0 truncate"
77
- :class="
78
- props.mono
79
- ? 'text-toned font-mono text-[12.5px]'
80
- : 'max-sm:whitespace-normal'
81
- "
82
- :title="props.title"
84
+ class="min-w-0"
85
+ :class="[
86
+ props.mono && 'text-toned font-mono text-[12.5px]',
87
+ !props.wrap && 'truncate',
88
+ !props.wrap && !props.mono && 'max-sm:whitespace-normal',
89
+ ]"
90
+ :title="props.wrap ? undefined : props.title"
83
91
  >
84
92
  <slot>{{ props.title }}</slot>
85
93
  </span>