@antelopejs/dms 0.7.10 → 0.7.12
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.
- package/CHANGELOG.md +29 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
- package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
- package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
- package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
- package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +1 -1
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -10
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
- package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
- package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
- package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
- package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
- package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -1
- package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
- package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
- package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
- package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +3 -2
- package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
- package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
- package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
- package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
- package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
- package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
- package/frontend-vue/tests/chart-legend.test.ts +133 -0
- package/frontend-vue/tests/code-block.test.ts +28 -0
- package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
- package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
- package/frontend-vue/tests/header-actions.test.ts +1 -1
- package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
- package/frontend-vue/tests/module-routing.test.ts +139 -0
- package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
- package/frontend-vue/tests/page-record-label.test.ts +53 -0
- package/frontend-vue/tests/regional-format.test.ts +69 -2
- package/frontend-vue/tests/scroll-to-block.test.ts +142 -0
- package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
- package/frontend-vue/tests/tab-slot-spacing.test.ts +8 -0
- package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
- package/frontend-vue/tests/table-live-hold.test.ts +90 -0
- package/package.json +2 -2
- package/skills/dms/SKILL.md +28 -4
- package/skills/dms-module/SKILL.md +2 -1
|
@@ -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 {
|
|
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
|
|
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<
|
|
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
|
-
(
|
|
68
|
-
|
|
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` / `
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
87
|
-
const
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
77
|
-
:class="
|
|
78
|
-
props.mono
|
|
79
|
-
|
|
80
|
-
|
|
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>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, resolveComponent } from "vue";
|
|
2
|
+
import { computed, ref, resolveComponent } from "vue";
|
|
3
3
|
import DmsActivityItem from "../activity/ActivityItem.vue";
|
|
4
4
|
import DmsBlockActions, {
|
|
5
5
|
type BlockAction,
|
|
@@ -48,6 +48,16 @@ interface ActivityFeedProps extends Partial<DefaultComponentProps> {
|
|
|
48
48
|
groupByDay?: boolean;
|
|
49
49
|
/** Shows at most this many entries. */
|
|
50
50
|
maxItems?: number;
|
|
51
|
+
/**
|
|
52
|
+
* Shows the first this many entries, and a "Show N more" button under them
|
|
53
|
+
* that unfolds the rest (up to `maxItems`), and folds them back.
|
|
54
|
+
*/
|
|
55
|
+
visibleItems?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Entries wrap their title, details and detail line rather than cutting
|
|
58
|
+
* each to one line with an ellipsis.
|
|
59
|
+
*/
|
|
60
|
+
wrap?: boolean;
|
|
51
61
|
/**
|
|
52
62
|
* Fills its grid cell instead of growing with its entries, which scroll
|
|
53
63
|
* under the fixed head: the cards beside it set the row's height.
|
|
@@ -80,6 +90,8 @@ const props = withDefaults(defineProps<ActivityFeedProps>(), {
|
|
|
80
90
|
realtimeTopic: undefined,
|
|
81
91
|
groupByDay: true,
|
|
82
92
|
maxItems: undefined,
|
|
93
|
+
visibleItems: undefined,
|
|
94
|
+
wrap: false,
|
|
83
95
|
fillHeight: false,
|
|
84
96
|
skeletonCount: undefined,
|
|
85
97
|
actions: () => [],
|
|
@@ -119,13 +131,36 @@ const entries = computed<ActivityFeedItem[]>(() =>
|
|
|
119
131
|
props.maxItems ? list.value.slice(0, props.maxItems) : list.value,
|
|
120
132
|
);
|
|
121
133
|
|
|
134
|
+
// Folded, the feed shows its first `visibleItems` entries; the button under
|
|
135
|
+
// them unfolds the rest and folds them back.
|
|
136
|
+
const isUnfolded = ref(false);
|
|
137
|
+
const foldedCount = computed(() =>
|
|
138
|
+
props.visibleItems
|
|
139
|
+
? Math.max(0, entries.value.length - props.visibleItems)
|
|
140
|
+
: 0,
|
|
141
|
+
);
|
|
142
|
+
const shownEntries = computed<ActivityFeedItem[]>(() =>
|
|
143
|
+
foldedCount.value > 0 && !isUnfolded.value
|
|
144
|
+
? entries.value.slice(0, props.visibleItems)
|
|
145
|
+
: entries.value,
|
|
146
|
+
);
|
|
147
|
+
const foldLabel = computed(() =>
|
|
148
|
+
isUnfolded.value
|
|
149
|
+
? t("dms.activity_feed.show_fewer")
|
|
150
|
+
: t(
|
|
151
|
+
"dms.activity_feed.show_more",
|
|
152
|
+
{ count: foldedCount.value },
|
|
153
|
+
foldedCount.value,
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
|
|
122
157
|
const days = computed<ActivityFeedDay[]>(() =>
|
|
123
158
|
props.groupByDay
|
|
124
|
-
? groupActivityByDay(
|
|
159
|
+
? groupActivityByDay(shownEntries.value, locale.value, {
|
|
125
160
|
today: t("dms.activity_feed.today"),
|
|
126
161
|
yesterday: t("dms.activity_feed.yesterday"),
|
|
127
162
|
})
|
|
128
|
-
: [{ key: "all", items:
|
|
163
|
+
: [{ key: "all", items: shownEntries.value }],
|
|
129
164
|
);
|
|
130
165
|
|
|
131
166
|
const trailingOf = (item: ActivityFeedItem): string | undefined => {
|
|
@@ -158,11 +193,12 @@ const metaOf = (item: ActivityFeedItem): string | undefined =>
|
|
|
158
193
|
const isFirstLoad = isPending;
|
|
159
194
|
|
|
160
195
|
// The placeholder takes the feed's loaded shape: as many rows as it will show
|
|
161
|
-
// (`skeletonCount`, else `maxItems`; never more than
|
|
162
|
-
// separators when the entries are grouped by day.
|
|
196
|
+
// (`skeletonCount`, else `visibleItems` or `maxItems`; never more than
|
|
197
|
+
// those), under day separators when the entries are grouped by day.
|
|
163
198
|
const skeletonDays = computed<number[][]>(() => {
|
|
164
|
-
const
|
|
165
|
-
const
|
|
199
|
+
const shown = props.visibleItems ?? props.maxItems;
|
|
200
|
+
const wanted = props.skeletonCount ?? shown ?? SKELETON_ROWS;
|
|
201
|
+
const count = shown ? Math.min(wanted, shown) : wanted;
|
|
166
202
|
const perDay = props.groupByDay ? SKELETON_ROWS_PER_DAY : count;
|
|
167
203
|
const days: number[][] = [];
|
|
168
204
|
for (let start = 0; start < count; start += perDay) {
|
|
@@ -328,10 +364,25 @@ const wrapperProps = computed(() =>
|
|
|
328
364
|
:trailing="trailingOf(item)"
|
|
329
365
|
:unread="item.unread"
|
|
330
366
|
:mono="props.mono"
|
|
367
|
+
:wrap="props.wrap"
|
|
331
368
|
:to="item.to"
|
|
332
369
|
:interactive="!!item.to"
|
|
333
370
|
/>
|
|
334
371
|
</section>
|
|
372
|
+
<div
|
|
373
|
+
v-if="foldedCount > 0"
|
|
374
|
+
class="dms-activity-feed__fold border-t border-(--ui-border-muted) px-[18px] pt-2 pb-1"
|
|
375
|
+
>
|
|
376
|
+
<UButton
|
|
377
|
+
:label="foldLabel"
|
|
378
|
+
:aria-expanded="isUnfolded"
|
|
379
|
+
color="neutral"
|
|
380
|
+
variant="link"
|
|
381
|
+
size="xs"
|
|
382
|
+
class="p-0 font-medium"
|
|
383
|
+
@click="isUnfolded = !isUnfolded"
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
335
386
|
</div>
|
|
336
387
|
</div>
|
|
337
388
|
</component>
|
|
@@ -4,6 +4,7 @@ import DmsCodeSnippet from "../code-snippet/CodeSnippet.vue";
|
|
|
4
4
|
import DmsBlockStatus from "../../build/components/blocks/BlockStatus.vue";
|
|
5
5
|
import { useChartFetch } from "../../composables/chart/useChartFetch";
|
|
6
6
|
import { readFetchNotFound } from "../../build/utils/fetchNotFound";
|
|
7
|
+
import { PLAIN_CODE_LANGUAGE } from "../../build/utils/codeLanguages";
|
|
7
8
|
import { useWatch } from "../../../../dms-core/app/composables/watch/useWatch";
|
|
8
9
|
import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
|
|
9
10
|
import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
|
|
@@ -58,7 +59,10 @@ const { data, isLoading, error, refresh } = useChartFetch<CodeBlockResponse>({
|
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
const code = computed(() => data.value?.code ?? props.code);
|
|
61
|
-
|
|
62
|
+
// The block always heads its code with its language, plain text included.
|
|
63
|
+
const language = computed(
|
|
64
|
+
() => data.value?.language ?? props.language ?? PLAIN_CODE_LANGUAGE,
|
|
65
|
+
);
|
|
62
66
|
const title = computed(() =>
|
|
63
67
|
props.title ? processText(props.title) : undefined,
|
|
64
68
|
);
|
|
@@ -5,6 +5,7 @@ import DmsEmptyState, {
|
|
|
5
5
|
type EmptyStateVariant,
|
|
6
6
|
} from "../empty-state/EmptyState.vue";
|
|
7
7
|
import DmsCodeSnippet from "../code-snippet/CodeSnippet.vue";
|
|
8
|
+
import { PLAIN_CODE_LANGUAGE } from "../../build/utils/codeLanguages";
|
|
8
9
|
import DmsBlockActions, {
|
|
9
10
|
type BlockAction,
|
|
10
11
|
} from "../../build/components/blocks/BlockActions.vue";
|
|
@@ -75,7 +76,7 @@ const description = computed(() =>
|
|
|
75
76
|
<template v-if="props.code" #content>
|
|
76
77
|
<DmsCodeSnippet
|
|
77
78
|
:code="props.code.content"
|
|
78
|
-
:language="props.code.language"
|
|
79
|
+
:language="props.code.language ?? PLAIN_CODE_LANGUAGE"
|
|
79
80
|
:copy="props.code.copy ?? true"
|
|
80
81
|
/>
|
|
81
82
|
</template>
|