@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.
- package/CHANGELOG.md +33 -0
- package/dist/implementations/dms/page.js +14 -1
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
- package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
- package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
- 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 +23 -2
- package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
- package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
- package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
- 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 +7 -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/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/components/top-list/TopListCard.vue +2 -0
- 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/block-fetch-options-forwarding.test.ts +5 -0
- package/frontend-vue/tests/breadcrumb-target.test.ts +10 -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 +158 -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 +171 -0
- package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -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
|
@@ -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
|
|
4
|
-
*
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
{{
|
|
61
|
+
<span v-if="reasonText" class="dms-live-hold-banner__reason min-w-0">
|
|
62
|
+
{{ reasonText }}
|
|
24
63
|
</span>
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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>
|