@antelopejs/dms 0.7.7 → 0.7.8
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 +23 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
- package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
- package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
- package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
- package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
- package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
- package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
- package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
- package/frontend-vue/tests/field-row.test.ts +14 -0
- package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
- package/frontend-vue/tests/live-toggle.test.ts +162 -0
- package/frontend-vue/tests/page-live-gate.test.ts +241 -0
- package/frontend-vue/tests/period-live-pause.test.ts +120 -0
- package/frontend-vue/tests/period-selector-url.test.ts +268 -0
- package/frontend-vue/tests/period-url-query.test.ts +351 -0
- package/frontend-vue/tests/row-side-panel.test.ts +653 -0
- package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
- package/frontend-vue/tests/table-live-hold.test.ts +204 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
- package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
- package/frontend-vue/tests/table-view-sources.test.ts +40 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +9 -2
- package/skills/dms-pages/REFERENCE.md +5 -0
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
FormPageUrls,
|
|
38
38
|
} from "../../composables/table-view/useTableViewConfig";
|
|
39
39
|
import type { CustomRowAction } from "../../../types/row-action";
|
|
40
|
+
import type { TableViewRowClick } from "../../utils/rowClickTrigger";
|
|
40
41
|
import type { RowActionConfig } from "#dms-core/app/types/row-action";
|
|
41
42
|
import type { TableTabItem } from "./Tabs.vue";
|
|
42
43
|
import type { ColumnDisplay } from "../../composables/data-types/useColumnValueRenderer";
|
|
@@ -149,6 +150,13 @@ export interface TableProps<T> {
|
|
|
149
150
|
|
|
150
151
|
rowIdKey?: string;
|
|
151
152
|
rowActions?: TableRowActionOptions;
|
|
153
|
+
/**
|
|
154
|
+
* What runs a row's click action: a double-click (the default) or a single
|
|
155
|
+
* click (`"single"`). See `isRowClickTrigger`.
|
|
156
|
+
*/
|
|
157
|
+
rowClick?: TableViewRowClick;
|
|
158
|
+
/** Id of the row a side panel has open: the grid highlights it. */
|
|
159
|
+
openRowId?: string;
|
|
152
160
|
customNavItems?: NavItem[];
|
|
153
161
|
tabs?: TableTabItem[];
|
|
154
162
|
customButtons?: CustomButton[];
|
|
@@ -412,6 +420,7 @@ import {
|
|
|
412
420
|
} from "../../composables/table/constants";
|
|
413
421
|
import { mergeColumnOrder } from "../../composables/table/utils/columnOrder";
|
|
414
422
|
import { EYEBROW_CLASS } from "../../utils/eyebrow";
|
|
423
|
+
import { isRowClickTrigger } from "../../utils/rowClickTrigger";
|
|
415
424
|
|
|
416
425
|
// Same color as the surrounding card frame (.dms-card) so sticky
|
|
417
426
|
// rail/pinned cells blend in instead of showing a contrasting block.
|
|
@@ -435,6 +444,12 @@ const ROW_SELECTED_BG = "data-[selected=true]:bg-primary/10";
|
|
|
435
444
|
const ROW_SELECTED_CELL_BG =
|
|
436
445
|
"group-data-[selected=true]:bg-[color-mix(in_srgb,var(--ui-primary)_10%,var(--dms-surface-card))]";
|
|
437
446
|
|
|
447
|
+
// The row a side panel has open: the hover tint, held, and an accent rule on
|
|
448
|
+
// its leading edge.
|
|
449
|
+
const ROW_OPEN_BG =
|
|
450
|
+
"data-[open=true]:bg-elevated data-[open=true]:shadow-[inset_2px_0_0_0_var(--ui-primary)]";
|
|
451
|
+
const ROW_OPEN_CELL_BG = "group-data-[open=true]:bg-elevated";
|
|
452
|
+
|
|
438
453
|
// An expanded row keeps the hover tint and hands its bottom rule to the detail
|
|
439
454
|
// band below it.
|
|
440
455
|
const ROW_EXPANDED_BG = "data-[expanded=true]:bg-elevated";
|
|
@@ -501,7 +516,7 @@ const theme = tv({
|
|
|
501
516
|
colResizer:
|
|
502
517
|
"bg-primary/40 absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize touch-none select-none opacity-0 hover:opacity-100",
|
|
503
518
|
|
|
504
|
-
row: `group ${ROW_SELECTED_BG} ${ROW_EXPANDED_BG}`,
|
|
519
|
+
row: `group ${ROW_SELECTED_BG} ${ROW_EXPANDED_BG} ${ROW_OPEN_BG}`,
|
|
505
520
|
rowCell: `${FIRST_ROW_CELL_GUTTER} border-b-muted border-b h-11 px-3.5 py-0 text-[13px] last:pe-2.5 in-[tr:last-child]:border-b-0 group-data-[expanded=true]:border-b-transparent`,
|
|
506
521
|
rowInternal: "transition-opacity duration-150",
|
|
507
522
|
rowContainer: "relative",
|
|
@@ -560,11 +575,11 @@ const theme = tv({
|
|
|
560
575
|
pinned: {
|
|
561
576
|
left: {
|
|
562
577
|
headCell: `${HEADER_MATCH_BG} sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
563
|
-
rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
578
|
+
rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} ${ROW_OPEN_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
564
579
|
},
|
|
565
580
|
right: {
|
|
566
581
|
headCell: `${HEADER_MATCH_BG} sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
567
|
-
rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
582
|
+
rowCell: `${PANEL_MATCH_BG} ${ROW_SELECTED_CELL_BG} ${ROW_EXPANDED_CELL_BG} ${ROW_OPEN_CELL_BG} group-data-[presence=true]:bg-elevated sticky z-10 shadow-[-2px_0_0_0_rgba(0,0,0,0.06)]`,
|
|
568
583
|
},
|
|
569
584
|
},
|
|
570
585
|
// Dense lists: 36px rows under a 32px header band.
|
|
@@ -1205,7 +1220,7 @@ const rowClickAction = (row: T): RowClickAction | undefined =>
|
|
|
1205
1220
|
// lights up always opens something.
|
|
1206
1221
|
const isRowClickable = (row: T): boolean => rowClickAction(row) !== undefined;
|
|
1207
1222
|
|
|
1208
|
-
const
|
|
1223
|
+
const runRowClickAction = (row: T) => {
|
|
1209
1224
|
const action = rowClickAction(row);
|
|
1210
1225
|
if (!action) return;
|
|
1211
1226
|
if (typeof action === "string") {
|
|
@@ -1215,6 +1230,19 @@ const handleRowDoubleClick = (row: T) => {
|
|
|
1215
1230
|
props.onCustomRowAction?.(action, row);
|
|
1216
1231
|
};
|
|
1217
1232
|
|
|
1233
|
+
// Both events reach the row: `rowClick` tells which one runs its action.
|
|
1234
|
+
const handleRowPointer = (event: MouseEvent, row: T) => {
|
|
1235
|
+
if (!isRowClickTrigger(props.rowClick, event)) return;
|
|
1236
|
+
runRowClickAction(row);
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const isOpenRow = (row: T): boolean =>
|
|
1240
|
+
props.openRowId !== undefined &&
|
|
1241
|
+
String(get(row, rowIdKey)) === props.openRowId;
|
|
1242
|
+
|
|
1243
|
+
const isSingleClickRow = (row: T): boolean =>
|
|
1244
|
+
props.rowClick === "single" && isRowClickable(row);
|
|
1245
|
+
|
|
1218
1246
|
const presenceRailBackground = (row: T): string => {
|
|
1219
1247
|
if (getRowPresence(row)) return PRESENCE_RAIL_ACTIVE_BG;
|
|
1220
1248
|
if (props.loading || !isRowClickable(row)) return PANEL_MATCH_BG;
|
|
@@ -1339,6 +1367,10 @@ defineShortcuts({
|
|
|
1339
1367
|
/>
|
|
1340
1368
|
</template>
|
|
1341
1369
|
|
|
1370
|
+
<!-- A strip the table view adds over its rows (the live hold's "N new"),
|
|
1371
|
+
shown whichever chrome the display keeps. -->
|
|
1372
|
+
<slot name="notice" />
|
|
1373
|
+
|
|
1342
1374
|
<slot name="body" :table="table">
|
|
1343
1375
|
<section
|
|
1344
1376
|
ref="tableRoot"
|
|
@@ -1472,20 +1504,24 @@ defineShortcuts({
|
|
|
1472
1504
|
<tr
|
|
1473
1505
|
:data-selected="row.getIsSelected()"
|
|
1474
1506
|
:data-expanded="row.getIsExpanded()"
|
|
1507
|
+
:data-open="isOpenRow(row.original) || undefined"
|
|
1508
|
+
:aria-current="isOpenRow(row.original) || undefined"
|
|
1475
1509
|
:data-presence="!!getRowPresence(row.original)"
|
|
1476
1510
|
:title="
|
|
1477
1511
|
getRowPresence(row.original)
|
|
1478
1512
|
? presenceTooltipText(getRowPresence(row.original)!)
|
|
1479
1513
|
: undefined
|
|
1480
1514
|
"
|
|
1481
|
-
:class="
|
|
1515
|
+
:class="[
|
|
1482
1516
|
uiTable.row({
|
|
1483
1517
|
loading,
|
|
1484
1518
|
rowClickable: isRowClickable(row.original),
|
|
1485
1519
|
presence: !!getRowPresence(row.original),
|
|
1486
|
-
})
|
|
1487
|
-
|
|
1488
|
-
|
|
1520
|
+
}),
|
|
1521
|
+
{ 'cursor-pointer': isSingleClickRow(row.original) },
|
|
1522
|
+
]"
|
|
1523
|
+
@click="handleRowPointer($event, row.original)"
|
|
1524
|
+
@dblclick="handleRowPointer($event, row.original)"
|
|
1489
1525
|
@mouseenter="handleRowHover(row)"
|
|
1490
1526
|
@mouseleave="handleRowLeave"
|
|
1491
1527
|
@dragover="reorderHandles.allowDrop"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
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.
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{
|
|
7
|
+
/** Events the table received since its rows were held or last shown. */
|
|
8
|
+
count: number;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{ show: [] }>();
|
|
12
|
+
|
|
13
|
+
const { t } = useI18n();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div
|
|
18
|
+
role="status"
|
|
19
|
+
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
|
+
>
|
|
21
|
+
<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) }}
|
|
24
|
+
</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
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
</template>
|
|
@@ -83,3 +83,20 @@ export function listenRowSteps(current: () => RowNavigation | undefined) {
|
|
|
83
83
|
window.addEventListener("keydown", onKey);
|
|
84
84
|
return () => window.removeEventListener("keydown", onKey);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
const CLOSE_KEY = "Escape";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Escape closes a row's side panel, unless the user types in a field or a
|
|
91
|
+
* layer above it (a dialog, a menu) already handled the key. Returns the
|
|
92
|
+
* listener's removal.
|
|
93
|
+
*/
|
|
94
|
+
export function listenRowPanelClose(close: () => void) {
|
|
95
|
+
const onKey = (event: KeyboardEvent) => {
|
|
96
|
+
if (event.key !== CLOSE_KEY || event.defaultPrevented) return;
|
|
97
|
+
if (isTypingTarget(event.target)) return;
|
|
98
|
+
close();
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener("keydown", onKey);
|
|
101
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
102
|
+
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
isConfirmFrom,
|
|
5
5
|
} from "#dms-core/app/types/confirm-dialog";
|
|
6
6
|
import { get } from "@nuxt/ui/runtime/utils/index.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type Component, getCurrentScope, onScopeDispose } from "vue";
|
|
8
8
|
import type { ActionTarget } from "../../../composables/table-view/types/action-target";
|
|
9
9
|
import type { CustomButton } from "../../../composables/table-view/types/custom-button";
|
|
10
10
|
import type { CustomRowAction } from "../../../types/row-action";
|
|
@@ -26,7 +26,18 @@ import {
|
|
|
26
26
|
type RowNavigationSource,
|
|
27
27
|
rowNavigation,
|
|
28
28
|
} from "./rowNavigation";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
clearWrittenRecord,
|
|
31
|
+
isDeepLinkAction,
|
|
32
|
+
writeRecordId,
|
|
33
|
+
type WrittenRecord,
|
|
34
|
+
} from "../table-view/utils/recordLink";
|
|
35
|
+
import {
|
|
36
|
+
closeRowSidePanel,
|
|
37
|
+
closeRowSidePanelOf,
|
|
38
|
+
openRowSidePanel,
|
|
39
|
+
rowSidePanelOwnerKey,
|
|
40
|
+
} from "#dms-layout/app/build/composables/layout/rowSidePanel";
|
|
30
41
|
import type { TableUrlScope } from "../table-view/utils/views";
|
|
31
42
|
import { useActionConfirm } from "../confirm/useActionConfirm";
|
|
32
43
|
import {
|
|
@@ -72,6 +83,16 @@ interface TargetRunExtras {
|
|
|
72
83
|
deepLink?: boolean;
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
type SidePanelTarget = ActionTarget & { type: "sidePanel" };
|
|
87
|
+
|
|
88
|
+
/** A side panel to open, and on what. */
|
|
89
|
+
interface SidePanelTargetRun extends TargetRunExtras {
|
|
90
|
+
target: SidePanelTarget;
|
|
91
|
+
title: string;
|
|
92
|
+
containerId: string;
|
|
93
|
+
rowData?: Data;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
/** A drawer or modal to open, and on what. */
|
|
76
97
|
interface ComponentTargetRun extends TargetRunExtras {
|
|
77
98
|
target: ActionTarget & { type: "modal" | "drawer" };
|
|
@@ -214,14 +235,84 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
214
235
|
: openDrawer({ ...options, direction: run.target.direction });
|
|
215
236
|
};
|
|
216
237
|
|
|
238
|
+
const resolveTargetComponent = (componentName: string): Component =>
|
|
239
|
+
(resolveDmsComponent(componentName) || componentName) as Component;
|
|
240
|
+
|
|
241
|
+
const rowIdOf = (row?: Data): string | undefined => {
|
|
242
|
+
if (!row) return undefined;
|
|
243
|
+
const idKey =
|
|
244
|
+
config.rowNavigation?.rowIdKey ?? config.rowIdKey ?? DEFAULT_ROW_ID_KEY;
|
|
245
|
+
const id = get(row, idKey);
|
|
246
|
+
return id === undefined || id === null ? undefined : String(id);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// A deep-linked action keeps the row it has open in the URL; `undefined`
|
|
250
|
+
// removes it, but only the row this run wrote, on the page it wrote it:
|
|
251
|
+
// closing as the user navigates leaves the next page's `?record=` alone.
|
|
252
|
+
const rowRecorder = (deepLink?: boolean) => {
|
|
253
|
+
let written: WrittenRecord | undefined;
|
|
254
|
+
return (row?: Data): void => {
|
|
255
|
+
if (!deepLink || !config.recordScope || !config.rowNavigation) return;
|
|
256
|
+
if (!row) {
|
|
257
|
+
clearWrittenRecord(written);
|
|
258
|
+
written = undefined;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
written = writeRecordId(config.recordScope, rowIdOf(row));
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const ownerKey = rowSidePanelOwnerKey(config.pageId, config.componentId);
|
|
266
|
+
|
|
267
|
+
// The panel belongs to the table that opened it: it closes with it.
|
|
268
|
+
if (getCurrentScope()) onScopeDispose(() => closeRowSidePanelOf(ownerKey));
|
|
269
|
+
|
|
270
|
+
// A side panel opened on a listed row steps through the rows shown in place
|
|
271
|
+
// (the panel computes its position); a deep-linked one keeps the row in the
|
|
272
|
+
// URL while it is open. A bulk action's panel receives the selection.
|
|
273
|
+
const handleSidePanelTarget = (run: SidePanelTargetRun) => {
|
|
274
|
+
const componentName = run.target.component?.componentName;
|
|
275
|
+
if (!componentName) return;
|
|
276
|
+
const session = Symbol(componentName);
|
|
277
|
+
const component = resolveTargetComponent(componentName);
|
|
278
|
+
const recordRow = rowRecorder(run.deepLink && !run.selection);
|
|
279
|
+
const componentOptions = {
|
|
280
|
+
...run.target.component?.options,
|
|
281
|
+
pageId: config.pageId,
|
|
282
|
+
componentId: config.componentId,
|
|
283
|
+
containerId: run.containerId,
|
|
284
|
+
selection: run.selection,
|
|
285
|
+
onSuccessCallback: () => {
|
|
286
|
+
closeRowSidePanel(session);
|
|
287
|
+
config.refreshCallback?.();
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const showRow = (row?: Data) => {
|
|
291
|
+
openRowSidePanel({
|
|
292
|
+
session,
|
|
293
|
+
ownerKey,
|
|
294
|
+
title: run.title,
|
|
295
|
+
positionLabel: run.target.positionLabel,
|
|
296
|
+
component,
|
|
297
|
+
componentOptions,
|
|
298
|
+
row,
|
|
299
|
+
rowKey: rowIdOf(row),
|
|
300
|
+
source: row ? config.rowNavigation : undefined,
|
|
301
|
+
showRow,
|
|
302
|
+
onClose: () => recordRow(undefined),
|
|
303
|
+
});
|
|
304
|
+
recordRow(row);
|
|
305
|
+
};
|
|
306
|
+
showRow(run.rowData);
|
|
307
|
+
};
|
|
308
|
+
|
|
217
309
|
// A container opened on a listed row steps through the rows shown (its
|
|
218
310
|
// `navigation` prop, J and K), mounting its component anew on each; a
|
|
219
311
|
// deep-linked one keeps the row in the URL while it is open.
|
|
220
312
|
const handleComponentTarget = (run: ComponentTargetRun) => {
|
|
221
313
|
const componentName = run.target.component?.componentName;
|
|
222
314
|
if (!componentName) return;
|
|
223
|
-
const component = (
|
|
224
|
-
componentName) as Component;
|
|
315
|
+
const component = resolveTargetComponent(componentName);
|
|
225
316
|
const source = config.rowNavigation;
|
|
226
317
|
let instance: ContainerInstance | undefined;
|
|
227
318
|
let navigation: RowNavigation | undefined;
|
|
@@ -242,13 +333,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
242
333
|
},
|
|
243
334
|
};
|
|
244
335
|
};
|
|
245
|
-
const recordRow = (
|
|
246
|
-
if (!run.deepLink || !config.recordScope || !source) return;
|
|
247
|
-
writeRecordId(
|
|
248
|
-
config.recordScope,
|
|
249
|
-
row ? String(get(row, source.rowIdKey)) : undefined,
|
|
250
|
-
);
|
|
251
|
-
};
|
|
336
|
+
const recordRow = rowRecorder(run.deepLink);
|
|
252
337
|
function showRow(row: Data) {
|
|
253
338
|
instance?.patch({
|
|
254
339
|
componentOptions: optionsFor(row),
|
|
@@ -286,6 +371,15 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
286
371
|
...extras,
|
|
287
372
|
});
|
|
288
373
|
|
|
374
|
+
const sidePanelHandler: TargetHandler = (target, label, rowData, extras) =>
|
|
375
|
+
handleSidePanelTarget({
|
|
376
|
+
target: target as SidePanelTarget,
|
|
377
|
+
title: processI18n((target as SidePanelTarget).title || label),
|
|
378
|
+
containerId: `${config.pageId}-${config.componentId}-${target.type}`,
|
|
379
|
+
rowData: extras?.selection ? undefined : rowData,
|
|
380
|
+
...extras,
|
|
381
|
+
});
|
|
382
|
+
|
|
289
383
|
const targetHandlers: Record<string, TargetHandler> = {
|
|
290
384
|
page: (target, _label, rowData) => {
|
|
291
385
|
const pageTarget = target as ActionTarget & { type: "page" };
|
|
@@ -332,6 +426,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
332
426
|
if (quickAction) dispatchQuickActionTarget(quickAction.target);
|
|
333
427
|
},
|
|
334
428
|
drawer: componentHandler,
|
|
429
|
+
sidePanel: sidePanelHandler,
|
|
335
430
|
};
|
|
336
431
|
|
|
337
432
|
const targetUrl = (url: string, rowData?: Data): string =>
|
|
@@ -421,7 +516,7 @@ export function useActionTargets(config: ActionTargetsConfig) {
|
|
|
421
516
|
action.label,
|
|
422
517
|
rowData,
|
|
423
518
|
action.confirm,
|
|
424
|
-
{ deepLink: action
|
|
519
|
+
{ deepLink: isDeepLinkAction(action) },
|
|
425
520
|
);
|
|
426
521
|
};
|
|
427
522
|
|
|
@@ -1,40 +1,55 @@
|
|
|
1
1
|
import { onBeforeUnmount, onMounted } from "vue";
|
|
2
2
|
import { useRealtimeTopic } from "../../../../../dms-core/app/composables/realtime/useRealtimeTopic";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
onLivePageBlocksRefresh,
|
|
5
|
+
onPageBlocksRefresh,
|
|
6
|
+
} from "../../../utils/blockRefresh";
|
|
7
|
+
import { type LiveGate, useLiveGate } from "../live/currentPageLive";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Calls `onRefresh` whenever the data behind a block moved without its inputs
|
|
7
11
|
* changing: on each event of one of its realtime topics, and each time the
|
|
8
|
-
* page asks its blocks to refresh (refreshPageBlocks).
|
|
12
|
+
* page asks its blocks to refresh (refreshPageBlocks, refreshLivePageBlocks).
|
|
13
|
+
*
|
|
14
|
+
* On a page declared with `live`, the realtime events and the live refreshes
|
|
15
|
+
* go through the page's gate: in Direct at most once per `maxRate`, in Pause
|
|
16
|
+
* not at all — the block counts what it missed and refetches once on resume.
|
|
17
|
+
* A refresh the user asked for (refreshPageBlocks) reads at once, paused or
|
|
18
|
+
* not. The returned gate says what was missed.
|
|
9
19
|
*/
|
|
10
20
|
export function useRefreshTriggers(
|
|
11
21
|
realtimeTopic: string | string[] | undefined,
|
|
12
22
|
onRefresh: () => void,
|
|
13
|
-
):
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
): LiveGate {
|
|
24
|
+
const gate = useLiveGate(onRefresh);
|
|
25
|
+
subscribeRealtimeTopics(realtimeTopic, gate.trigger);
|
|
26
|
+
subscribePageRefresh(gate);
|
|
27
|
+
return gate;
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
function subscribeRealtimeTopics(
|
|
19
31
|
topic: string | string[] | undefined,
|
|
20
|
-
onEvent: () => void,
|
|
32
|
+
onEvent: (event: object) => void,
|
|
21
33
|
): void {
|
|
22
34
|
if (!topic) return;
|
|
23
35
|
const topics = Array.isArray(topic) ? topic : [topic];
|
|
24
36
|
for (const entry of topics) {
|
|
25
37
|
useRealtimeTopic(entry, (event) => {
|
|
26
38
|
if (!("type" in event)) return;
|
|
27
|
-
onEvent();
|
|
39
|
+
onEvent(event);
|
|
28
40
|
});
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
// Mounted only: the page refresh is a browser event, and a block the server
|
|
33
45
|
// renders has nothing to refetch.
|
|
34
|
-
function subscribePageRefresh(
|
|
35
|
-
|
|
46
|
+
function subscribePageRefresh(gate: LiveGate): void {
|
|
47
|
+
const unsubscribes: Array<() => void> = [];
|
|
36
48
|
onMounted(() => {
|
|
37
|
-
|
|
49
|
+
unsubscribes.push(
|
|
50
|
+
onPageBlocksRefresh(() => gate.catchUp()),
|
|
51
|
+
onLivePageBlocksRefresh(gate.trigger),
|
|
52
|
+
);
|
|
38
53
|
});
|
|
39
|
-
onBeforeUnmount(() =>
|
|
54
|
+
onBeforeUnmount(() => unsubscribes.splice(0).forEach((stop) => stop()));
|
|
40
55
|
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
type ComposableText,
|
|
5
5
|
isComposedText,
|
|
6
6
|
} from "../../../../../dms-core/app/utils/composedText";
|
|
7
|
+
import type { Tone } from "../../../types/tone";
|
|
8
|
+
import { DMS_TONE_TEXT, isDmsTone } from "../../utils/tone";
|
|
7
9
|
|
|
8
10
|
// What the cell data types share: reading a sibling field off the row, and
|
|
9
11
|
// the text tones a cell takes.
|
|
@@ -13,6 +15,28 @@ export type Row = Record<string, unknown> | undefined;
|
|
|
13
15
|
export const readRowField = (row: Row, field: string | undefined): unknown =>
|
|
14
16
|
field && row ? get(row, field) : undefined;
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The `Tone` a row field holds (`toneField` of a display), or nothing when the
|
|
20
|
+
* field is unset or holds no known tone.
|
|
21
|
+
*/
|
|
22
|
+
export const readRowTone = (
|
|
23
|
+
row: Row,
|
|
24
|
+
field: string | undefined,
|
|
25
|
+
): Tone | undefined => {
|
|
26
|
+
const tone = readRowField(row, field);
|
|
27
|
+
return typeof tone === "string" && isDmsTone(tone) ? tone : undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The text colour of the row's tone, else `fallback` (the display's own). */
|
|
31
|
+
export const rowToneTextClass = (
|
|
32
|
+
row: Row,
|
|
33
|
+
field: string | undefined,
|
|
34
|
+
fallback: string,
|
|
35
|
+
): string => {
|
|
36
|
+
const tone = readRowTone(row, field);
|
|
37
|
+
return tone ? DMS_TONE_TEXT[tone] : fallback;
|
|
38
|
+
};
|
|
39
|
+
|
|
16
40
|
export const stringOf = (value: unknown): string | undefined =>
|
|
17
41
|
value === null || value === undefined || value === ""
|
|
18
42
|
? undefined
|
|
@@ -3,8 +3,14 @@ import type { DataType } from "#dms-core/app/composables/data-types/useDataType"
|
|
|
3
3
|
import StatusPill from "../../../components/status-pill/StatusPill.vue";
|
|
4
4
|
import MonoCell from "../../components/table/MonoCell.vue";
|
|
5
5
|
import { useComposedText } from "../../../../../dms-core/app/composables/translation/useComposedText";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
readRowField,
|
|
8
|
+
readRowTone,
|
|
9
|
+
readSubline,
|
|
10
|
+
rowToneTextClass,
|
|
11
|
+
toneClass,
|
|
12
|
+
type Row,
|
|
13
|
+
} from "./cellHelpers";
|
|
8
14
|
|
|
9
15
|
const Sparkline = defineAsyncComponent(
|
|
10
16
|
() => import("../../../components/chart/internal/Sparkline.vue"),
|
|
@@ -34,9 +40,7 @@ interface StatusPillOptions {
|
|
|
34
40
|
|
|
35
41
|
// A row field that holds no known tone gives way to the column's map.
|
|
36
42
|
function statusPillTone(opts: StatusPillOptions, key: string, row: Row) {
|
|
37
|
-
|
|
38
|
-
if (typeof rowTone === "string" && isDmsTone(rowTone)) return rowTone;
|
|
39
|
-
return opts.tones?.[key] ?? "neutral";
|
|
43
|
+
return readRowTone(row, opts.toneField) ?? opts.tones?.[key] ?? "neutral";
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -186,6 +190,17 @@ const withSymbol = (
|
|
|
186
190
|
): string =>
|
|
187
191
|
`${new Intl.NumberFormat(locale, { maximumFractionDigits }).format(value)} ${symbol}`;
|
|
188
192
|
|
|
193
|
+
/** A duration always in milliseconds, its digits grouped: "2,149 ms". */
|
|
194
|
+
export function formatMilliseconds(ms: number, locale: string): string {
|
|
195
|
+
const figure = new Intl.NumberFormat(locale, {
|
|
196
|
+
maximumFractionDigits: 0,
|
|
197
|
+
// `true` groups a four-digit figure too, where a locale's default (es,
|
|
198
|
+
// pl) would leave "2149" against "12.149" further down the column.
|
|
199
|
+
useGrouping: true,
|
|
200
|
+
}).format(ms);
|
|
201
|
+
return `${figure} ms`;
|
|
202
|
+
}
|
|
203
|
+
|
|
189
204
|
/**
|
|
190
205
|
* A duration in its most readable unit: "910 ms", "1.8 s", "4 min 12 s",
|
|
191
206
|
* "2 h 5 min".
|
|
@@ -225,8 +240,42 @@ export function formatBytes(bytes: number, locale: string): string {
|
|
|
225
240
|
);
|
|
226
241
|
}
|
|
227
242
|
|
|
228
|
-
const
|
|
229
|
-
|
|
243
|
+
const FIGURE_TONE_CLASS = "text-toned";
|
|
244
|
+
|
|
245
|
+
const figure = (text: string, toneClass = FIGURE_TONE_CLASS) =>
|
|
246
|
+
h("span", { class: [FIGURE_CLASS, "block text-right", toneClass] }, text);
|
|
247
|
+
|
|
248
|
+
interface DurationOptions {
|
|
249
|
+
unit?: string;
|
|
250
|
+
/** `readable` (the default) or `ms`. */
|
|
251
|
+
format?: string;
|
|
252
|
+
/** Row field holding the figure's tone. */
|
|
253
|
+
toneField?: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const DURATION_FORMATS: Record<string, typeof formatDuration> = {
|
|
257
|
+
readable: formatDuration,
|
|
258
|
+
ms: formatMilliseconds,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* `duration`: the figure in its most readable unit, or in milliseconds
|
|
263
|
+
* (`format: "ms"`), in the tone the row gives (`toneField`).
|
|
264
|
+
*/
|
|
265
|
+
function renderDuration(
|
|
266
|
+
value: unknown,
|
|
267
|
+
locale: string,
|
|
268
|
+
options: unknown,
|
|
269
|
+
row: Row,
|
|
270
|
+
) {
|
|
271
|
+
const opts = (options ?? {}) as DurationOptions;
|
|
272
|
+
const ms = numberOf(value) * (DURATION_UNIT_MS[opts.unit ?? "ms"] ?? 1);
|
|
273
|
+
const format = DURATION_FORMATS[opts.format ?? "readable"] ?? formatDuration;
|
|
274
|
+
return figure(
|
|
275
|
+
format(ms, locale),
|
|
276
|
+
rowToneTextClass(row, opts.toneField, FIGURE_TONE_CLASS),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
230
279
|
|
|
231
280
|
/**
|
|
232
281
|
* Cell data types a column picks through its `display` option to draw a
|
|
@@ -259,15 +308,8 @@ export const registerMetricCellTypes = (
|
|
|
259
308
|
registerDataType({
|
|
260
309
|
id: "duration",
|
|
261
310
|
formatter: {
|
|
262
|
-
default: (value, locale, options) =>
|
|
263
|
-
|
|
264
|
-
return figure(
|
|
265
|
-
formatDuration(
|
|
266
|
-
numberOf(value) * (DURATION_UNIT_MS[unit] ?? 1),
|
|
267
|
-
locale,
|
|
268
|
-
),
|
|
269
|
-
);
|
|
270
|
-
},
|
|
311
|
+
default: (value, locale, options, row) =>
|
|
312
|
+
renderDuration(value, locale, options, row),
|
|
271
313
|
},
|
|
272
314
|
});
|
|
273
315
|
registerDataType({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerFieldTypes } from "./registerFieldTypes";
|
|
2
|
-
import { defineAsyncComponent, type VNode } from "vue";
|
|
2
|
+
import { defineAsyncComponent, type VNode, type VNodeChild } from "vue";
|
|
3
3
|
import type { DataType } from "#dms-core/app/composables/data-types/useDataType";
|
|
4
4
|
import UAvatar from "@nuxt/ui/components/Avatar.vue";
|
|
5
5
|
import UBadge from "@nuxt/ui/components/Badge.vue";
|
|
@@ -10,6 +10,7 @@ import { mapRelationBeforeState, sameRelationValue } from "./relationValue";
|
|
|
10
10
|
import {
|
|
11
11
|
isSet,
|
|
12
12
|
readRowField,
|
|
13
|
+
readRowTone,
|
|
13
14
|
readSubline,
|
|
14
15
|
type Row,
|
|
15
16
|
stringOf,
|
|
@@ -21,7 +22,7 @@ import { useComposedText } from "../../../../../dms-core/app/composables/transla
|
|
|
21
22
|
import StatusPill, {
|
|
22
23
|
type StatusPillVariant,
|
|
23
24
|
} from "../../../components/status-pill/StatusPill.vue";
|
|
24
|
-
import { isDmsTone } from "../../utils/tone";
|
|
25
|
+
import { DMS_TONE_TEXT, isDmsTone } from "../../utils/tone";
|
|
25
26
|
import IdentityCell from "../../components/table-view/IdentityCell.vue";
|
|
26
27
|
import {
|
|
27
28
|
firstNameOf,
|
|
@@ -738,6 +739,32 @@ const registerCellTypes = (registerDataType: (dataType: DataType) => void) => {
|
|
|
738
739
|
});
|
|
739
740
|
};
|
|
740
741
|
|
|
742
|
+
interface TextOptions {
|
|
743
|
+
/** Row field holding the text's tone. */
|
|
744
|
+
toneField?: string;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* `string`: the value as written, in the tone the row gives (`toneField`) when
|
|
749
|
+
* it gives one; a row without one keeps the bare value, as a string column
|
|
750
|
+
* always drew it.
|
|
751
|
+
*/
|
|
752
|
+
function renderText(
|
|
753
|
+
value: unknown,
|
|
754
|
+
_locale: string,
|
|
755
|
+
options?: unknown,
|
|
756
|
+
row?: Row,
|
|
757
|
+
): unknown {
|
|
758
|
+
const tone = readRowTone(
|
|
759
|
+
row,
|
|
760
|
+
(options as TextOptions | undefined)?.toneField,
|
|
761
|
+
);
|
|
762
|
+
if (!tone) return value;
|
|
763
|
+
// The value stays the child, as the untoned cell returns it: a number or a
|
|
764
|
+
// list renders as it did, never as "[object Object]".
|
|
765
|
+
return h("span", { class: DMS_TONE_TEXT[tone] }, [value as VNodeChild]);
|
|
766
|
+
}
|
|
767
|
+
|
|
741
768
|
const registerPrimitiveTypes = (
|
|
742
769
|
registerDataType: (dataType: DataType) => void,
|
|
743
770
|
) => {
|
|
@@ -747,7 +774,7 @@ const registerPrimitiveTypes = (
|
|
|
747
774
|
});
|
|
748
775
|
registerDataType({
|
|
749
776
|
id: "string",
|
|
750
|
-
formatter: { default:
|
|
777
|
+
formatter: { default: renderText },
|
|
751
778
|
});
|
|
752
779
|
registerDataType({
|
|
753
780
|
id: "price",
|