@antelopejs/dms 0.7.8 → 0.7.10
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 +45 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/implementations/dms/api-base-url.d.ts +1 -0
- package/dist/implementations/dms/api-base-url.js +6 -0
- package/dist/implementations/dms/api-base-url.js.map +1 -0
- package/dist/implementations/dms/frontend-modules.js +7 -6
- package/dist/implementations/dms/frontend-modules.js.map +1 -1
- package/dist/implementations/dms/page.js +8 -2
- package/dist/implementations/dms/page.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/test/attachment-host/capabilities.d.ts +25 -0
- package/dist/test/attachment-host/capabilities.js +53 -1
- package/dist/test/attachment-host/capabilities.js.map +1 -1
- package/dist/test/integration/table-view-capabilities.test.js +13 -0
- package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
- package/dist/test/unit/api-base-url.test.d.ts +1 -0
- package/dist/test/unit/api-base-url.test.js +31 -0
- package/dist/test/unit/api-base-url.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js +106 -0
- package/dist/test/unit/interfaces/dms/page-layout-query.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms/page-teardown.test.js +1 -0
- package/dist/test/unit/interfaces/dms/page-teardown.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +46 -0
- package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +26 -0
- package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +10 -0
- package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/guard.test.d.ts +1 -0
- package/dist/test/unit/interfaces/dms-base/guard.test.js +53 -0
- package/dist/test/unit/interfaces/dms-base/guard.test.js.map +1 -0
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js +23 -0
- package/dist/test/unit/interfaces/dms-base/layout-header-actions.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/page-live.test.js +55 -0
- package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +17 -0
- package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +8 -1
- package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
- package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +28 -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/user/preferencesCookie.ts +149 -0
- package/frontend-vue/layers/dms-core/app/build/composables/user/tablePreferences.ts +321 -0
- package/frontend-vue/layers/dms-core/app/composables/period/compareResolvers.ts +4 -3
- package/frontend-vue/layers/dms-core/app/composables/period/presetResolvers.ts +4 -3
- package/frontend-vue/layers/dms-core/app/composables/period/types.ts +3 -1
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +2 -2
- package/frontend-vue/layers/dms-core/app/composables/period/usePeriodScope.ts +9 -2
- package/frontend-vue/layers/dms-core/app/composables/realtime/usePageRealtime.ts +48 -16
- package/frontend-vue/layers/dms-core/app/composables/realtime/useUserRealtime.ts +21 -4
- package/frontend-vue/layers/dms-core/app/composables/user/usePreferences.ts +81 -49
- package/frontend-vue/layers/dms-core/app/types/confirm-dialog.ts +35 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardPageBody.vue +10 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionBar.vue +32 -1
- package/frontend-vue/layers/dms-layout/app/build/components/layout/PageHeaderActionsOutlet.vue +13 -3
- package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +27 -3
- package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +16 -0
- package/frontend-vue/layers/dms-layout/app/composables/layout/useServedHeaderActions.ts +75 -0
- package/frontend-vue/layers/dms-layout/app/composables/page/usePageLayout.ts +8 -1
- package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +13 -2
- package/frontend-vue/layers/dms-layout/app/custom-layouts/DefaultLayout.vue +7 -0
- package/frontend-vue/layers/dms-layout/app/custom-layouts/SettingsLayout.vue +7 -0
- package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +7 -4
- package/frontend-vue/layers/dms-layout/app/utils/pageQuery.ts +59 -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/activity-feed/activityFeedDays.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockInlineStatus.vue +67 -0
- package/frontend-vue/layers/dms-ui/app/build/components/blocks/BlockStatus.vue +60 -20
- package/frontend-vue/layers/dms-ui/app/build/components/table/Actions.vue +15 -8
- package/frontend-vue/layers/dms-ui/app/build/components/table/Empty.vue +14 -0
- package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +29 -4
- package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +76 -28
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useBlockItems.ts +26 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useGuard.ts +115 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/actionError.ts +1 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +58 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/secondaryTarget.ts +86 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +6 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveCadence.ts +42 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveHolds.ts +66 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/livePoller.ts +42 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/liveStorage.ts +42 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/missedTally.ts +65 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +247 -88
- package/frontend-vue/layers/dms-ui/app/build/composables/live/useLivePeriodSpan.ts +33 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +79 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodComparisonControl.ts +70 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodDisplay.ts +55 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +41 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodDraft.ts +22 -9
- package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +13 -6
- package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +1 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +27 -2
- package/frontend-vue/layers/dms-ui/app/build/composables/table/utils/clearTableFilters.ts +18 -3
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/usePersistedPagination.ts +19 -26
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useScrolledPastTop.ts +66 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +7 -1
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +65 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTablePreferences.ts +149 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +39 -8
- 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/useTableViewRowActions.ts +16 -11
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViews.ts +39 -16
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/pinnedRows.ts +29 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -5
- package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/sortableColumns.ts +17 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/fetchNotFound.ts +79 -0
- package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +46 -1
- package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +5 -4
- package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +4 -0
- package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +40 -2
- package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +11 -1
- package/frontend-vue/layers/dms-ui/app/components/blocks/KeyValueListBlock.vue +12 -2
- package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +129 -4
- package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +6 -0
- package/frontend-vue/layers/dms-ui/app/components/blocks/StatGroupBlock.vue +6 -1
- package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +33 -0
- package/frontend-vue/layers/dms-ui/app/components/form/components/ChoiceCards.vue +22 -4
- package/frontend-vue/layers/dms-ui/app/components/guard/Guard.vue +74 -0
- package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -17
- package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +15 -0
- package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +12 -15
- package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +104 -16
- package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +113 -99
- package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +11 -1
- package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/composables/chart/useChartFetch.ts +11 -1
- package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +15 -1
- package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +86 -10
- package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewShiftF.ts +20 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +12 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -0
- package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +8 -1
- package/frontend-vue/layers/dms-ui/app/composables/table-view/useTableViewEmptyState.ts +55 -0
- package/frontend-vue/layers/dms-ui/app/types/live.ts +47 -1
- package/frontend-vue/layers/dms-ui/app/types/row-action.ts +2 -0
- package/frontend-vue/layers/dms-ui/app/utils/componentId.ts +21 -0
- package/frontend-vue/layers/dms-ui/app/utils/rowClickAction.ts +12 -0
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +13 -4
- package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +12 -3
- package/frontend-vue/tests/activity-feed-fill-height.test.ts +1 -0
- package/frontend-vue/tests/activity-feed-head.test.ts +140 -0
- package/frontend-vue/tests/block-not-found.test.ts +220 -0
- package/frontend-vue/tests/choice-cards-columns.test.ts +60 -0
- package/frontend-vue/tests/component-id.test.ts +18 -0
- package/frontend-vue/tests/confirm-secondary-action.test.ts +269 -0
- package/frontend-vue/tests/guard.test.ts +242 -0
- package/frontend-vue/tests/header-action-page-query.test.ts +286 -0
- package/frontend-vue/tests/live-toggle.test.ts +87 -1
- package/frontend-vue/tests/page-layout-registration.test.ts +1 -0
- package/frontend-vue/tests/page-live-parity.test.ts +367 -0
- package/frontend-vue/tests/page-realtime-remount.test.ts +185 -0
- package/frontend-vue/tests/period-selector-parity.test.ts +138 -0
- package/frontend-vue/tests/period-selector-url.test.ts +84 -6
- package/frontend-vue/tests/period-url-query.test.ts +52 -0
- package/frontend-vue/tests/preferences-cookie-size.test.ts +311 -0
- package/frontend-vue/tests/row-side-panel.test.ts +109 -10
- package/frontend-vue/tests/table-actions-search-label.test.ts +57 -1
- package/frontend-vue/tests/table-clear-filters.test.ts +99 -1
- package/frontend-vue/tests/table-density-menu.test.ts +4 -3
- package/frontend-vue/tests/table-live-hold.test.ts +95 -5
- package/frontend-vue/tests/table-row-action-labels.test.ts +181 -0
- package/frontend-vue/tests/table-row-hover.test.ts +32 -0
- package/frontend-vue/tests/table-source-period-scope.test.ts +31 -3
- package/frontend-vue/tests/table-view-hidden-filters-page.test.ts +14 -17
- package/frontend-vue/tests/table-view-pinned-rows.test.ts +143 -0
- package/frontend-vue/tests/table-view-sortable-columns.test.ts +45 -0
- package/frontend-vue/tests/table-view-views.test.ts +56 -15
- package/frontend-vue/tests/top-list-card-value-suffix.test.ts +107 -0
- package/frontend-vue/tests/vue-inventory.test.ts +1 -1
- package/package.json +2 -2
- package/skills/dms/SKILL.md +9 -3
- package/skills/dms-module/SKILL.md +5 -0
- package/skills/dms-pages/REFERENCE.md +23 -2
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from "vue";
|
|
2
|
+
import { useChartFetch } from "../../../composables/chart/useChartFetch";
|
|
3
|
+
import {
|
|
4
|
+
type FetchNotFound,
|
|
5
|
+
readFetchNotFound,
|
|
6
|
+
} from "../../utils/fetchNotFound";
|
|
7
|
+
import type { BlockAction } from "../../components/blocks/BlockActions.vue";
|
|
8
|
+
import type {
|
|
9
|
+
EmptyStateSize,
|
|
10
|
+
EmptyStateVariant,
|
|
11
|
+
} from "../../../components/empty-state/EmptyState.vue";
|
|
12
|
+
import type { Tone } from "../../../types/tone";
|
|
13
|
+
|
|
14
|
+
/** The empty state a guard shows in place of its children (interface-dms `GuardFallback`). */
|
|
15
|
+
export interface GuardFallback {
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
variant?: EmptyStateVariant;
|
|
19
|
+
icon?: string;
|
|
20
|
+
tone?: Tone;
|
|
21
|
+
hatched?: boolean;
|
|
22
|
+
size?: EmptyStateSize;
|
|
23
|
+
actions?: BlockAction[];
|
|
24
|
+
code?: { content: string; language?: string; copy?: boolean };
|
|
25
|
+
card?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What a guard checks its route for (interface-dms `GuardCondition`). */
|
|
29
|
+
export type GuardCondition = "empty" | "notFound";
|
|
30
|
+
|
|
31
|
+
/** The route a guard reads and what it checks it for (interface-dms `GuardOptions`). */
|
|
32
|
+
export interface GuardSource {
|
|
33
|
+
fetchUrl?: string;
|
|
34
|
+
fetchUrlMethod?: string;
|
|
35
|
+
periodScope?: string;
|
|
36
|
+
realtimeTopic?: string | string[];
|
|
37
|
+
when?: GuardCondition;
|
|
38
|
+
/** Dot path of the value `empty` checks; the whole answer without it. */
|
|
39
|
+
field?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the route answers: any value, maybe with an `emptyState` laid over the fallback. */
|
|
43
|
+
export interface GuardResponse {
|
|
44
|
+
emptyState?: GuardFallback;
|
|
45
|
+
[field: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Which condition held: the fallback to show, before its defaults. */
|
|
49
|
+
export interface GuardState {
|
|
50
|
+
condition: GuardCondition;
|
|
51
|
+
/** The answer's `emptyState`, when the route sent one. */
|
|
52
|
+
override?: GuardFallback;
|
|
53
|
+
/** What the 404 said, for `notFound`: its message sets the default texts. */
|
|
54
|
+
notFound?: FetchNotFound;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FIELD_PATH_SEPARATOR = ".";
|
|
58
|
+
|
|
59
|
+
/** The value at a dot path of an answer, `undefined` where the path stops. */
|
|
60
|
+
export function readFieldPath(source: unknown, path: string): unknown {
|
|
61
|
+
return path.split(FIELD_PATH_SEPARATOR).reduce<unknown>((value, key) => {
|
|
62
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
63
|
+
return (value as Record<string, unknown>)[key];
|
|
64
|
+
}, source);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Absent, `null`, `false`, `0`, `""` or an empty list: nothing to show. */
|
|
68
|
+
export function isEmptyValue(value: unknown): boolean {
|
|
69
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
70
|
+
return !value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function answerOverride(answer: unknown): GuardFallback | undefined {
|
|
74
|
+
if (!answer || typeof answer !== "object" || Array.isArray(answer)) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const { emptyState } = answer as GuardResponse;
|
|
78
|
+
return emptyState && typeof emptyState === "object" ? emptyState : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The condition that holds, or `null` while the children show: while the
|
|
83
|
+
* route loads, after any failure other than the condition, and whenever the
|
|
84
|
+
* value is set. The route is read like a block's `fetchUrl` (URL tokens,
|
|
85
|
+
* period scope, realtime topics, page refresh); nothing is requested while
|
|
86
|
+
* a token has no value.
|
|
87
|
+
*/
|
|
88
|
+
export function useGuard(
|
|
89
|
+
options: () => GuardSource,
|
|
90
|
+
routeParams: () => Record<string, string> | undefined,
|
|
91
|
+
): ComputedRef<GuardState | null> {
|
|
92
|
+
const initial = options();
|
|
93
|
+
const { data, error } = useChartFetch<unknown>({
|
|
94
|
+
fetchUrl: initial.fetchUrl,
|
|
95
|
+
fetchUrlMethod: initial.fetchUrlMethod,
|
|
96
|
+
periodScope: initial.periodScope,
|
|
97
|
+
realtimeTopic: initial.realtimeTopic,
|
|
98
|
+
routeParams,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return computed(() => {
|
|
102
|
+
const { when = "empty", field } = options();
|
|
103
|
+
const failure = error.value;
|
|
104
|
+
if (when === "notFound") {
|
|
105
|
+
const notFound = readFetchNotFound(failure);
|
|
106
|
+
if (!notFound) return null;
|
|
107
|
+
return { condition: when, override: notFound.emptyState, notFound };
|
|
108
|
+
}
|
|
109
|
+
const answer = data.value;
|
|
110
|
+
if (failure || answer === null || answer === undefined) return null;
|
|
111
|
+
const value = field ? readFieldPath(answer, field) : answer;
|
|
112
|
+
if (!isEmptyValue(value)) return null;
|
|
113
|
+
return { condition: when, override: answerOverride(answer) };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -68,7 +68,7 @@ const isTechnical = (text: string) =>
|
|
|
68
68
|
* as it came. Undefined when it is not user-facing (an untranslated key, JSON,
|
|
69
69
|
* a stack trace…), for the caller to fall back on a generic message.
|
|
70
70
|
*/
|
|
71
|
-
const userFacingText = (
|
|
71
|
+
export const userFacingText = (
|
|
72
72
|
text: string | undefined,
|
|
73
73
|
translate: I18nTranslate,
|
|
74
74
|
): string | undefined => {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ConfirmDialog,
|
|
3
|
+
ConfirmDialogSecondaryAction,
|
|
4
|
+
ConfirmDialogSecondaryTarget,
|
|
5
|
+
} from "#dms-core/app/types/confirm-dialog";
|
|
6
|
+
import { interpolateUrl } from "#dms-core/app/utils/url-interpolation";
|
|
2
7
|
import { readTranslationKey } from "#dms-core/app/composables/translation/useTranslation";
|
|
3
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
ConfirmOptions,
|
|
10
|
+
ConfirmSecondaryAction,
|
|
11
|
+
} from "../../../composables/confirm/types";
|
|
4
12
|
import { processFieldI18n } from "../../utils/fieldOptionsI18n";
|
|
5
13
|
import { interpolateLiteral } from "../../utils/literalText";
|
|
6
14
|
|
|
@@ -28,21 +36,68 @@ export function resolveConfirmText(
|
|
|
28
36
|
: t(key, params);
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/** Runs a dialog's secondary target, its URL already filled in. */
|
|
40
|
+
export type SecondaryTargetRun = (
|
|
41
|
+
target: ConfirmDialogSecondaryTarget,
|
|
42
|
+
) => Promise<void> | void;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A dialog's secondary target with its URL (and file name) filled in with
|
|
46
|
+
* the same parameters as its texts.
|
|
47
|
+
*/
|
|
48
|
+
function resolveSecondaryTarget(
|
|
49
|
+
target: ConfirmDialogSecondaryTarget,
|
|
50
|
+
params: Record<string, unknown>,
|
|
51
|
+
): ConfirmDialogSecondaryTarget {
|
|
52
|
+
const url = interpolateUrl(target.url, params);
|
|
53
|
+
if (target.type !== "download" || target.filename === undefined) {
|
|
54
|
+
return { ...target, url };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
...target,
|
|
58
|
+
url,
|
|
59
|
+
filename: interpolateLiteral(target.filename, params),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveSecondaryAction(
|
|
64
|
+
action: ConfirmDialogSecondaryAction | undefined,
|
|
65
|
+
params: Record<string, unknown>,
|
|
66
|
+
t: ConfirmTranslate,
|
|
67
|
+
runTarget: SecondaryTargetRun | undefined,
|
|
68
|
+
): ConfirmSecondaryAction | undefined {
|
|
69
|
+
if (!action || !runTarget) return undefined;
|
|
70
|
+
const target = resolveSecondaryTarget(action.target, params);
|
|
71
|
+
return {
|
|
72
|
+
label: resolveConfirmText(action.label, params, t),
|
|
73
|
+
icon: action.icon,
|
|
74
|
+
run: () => runTarget(target),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
31
78
|
/**
|
|
32
79
|
* A confirmation dialog with its texts resolved, ready for `useConfirm()`:
|
|
33
80
|
* interpolated with the row's fields (`row`) and the dialog's own `params`.
|
|
81
|
+
* Its secondary action is drawn only when given `runTarget` to run it with.
|
|
34
82
|
*/
|
|
35
83
|
export function resolveConfirmDialog(
|
|
36
84
|
dialog: ConfirmDialog,
|
|
37
85
|
row: Record<string, unknown> | undefined,
|
|
38
86
|
t: ConfirmTranslate,
|
|
87
|
+
runTarget?: SecondaryTargetRun,
|
|
39
88
|
): ConfirmOptions {
|
|
40
89
|
const params = { ...row, ...dialog.params };
|
|
41
90
|
const text = (value: string | undefined) =>
|
|
42
91
|
value === undefined ? undefined : resolveConfirmText(value, params, t);
|
|
43
|
-
const { params: _params, ...rest } = dialog;
|
|
92
|
+
const { params: _params, secondaryAction, ...rest } = dialog;
|
|
44
93
|
return {
|
|
45
94
|
...rest,
|
|
95
|
+
secondaryAction: resolveSecondaryAction(
|
|
96
|
+
secondaryAction,
|
|
97
|
+
params,
|
|
98
|
+
t,
|
|
99
|
+
runTarget,
|
|
100
|
+
),
|
|
46
101
|
title: text(dialog.title) ?? "",
|
|
47
102
|
description: text(dialog.description),
|
|
48
103
|
confirmLabel: text(dialog.confirmLabel),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfirmDialogDownloadTarget,
|
|
3
|
+
ConfirmDialogLinkTarget,
|
|
4
|
+
ConfirmDialogSecondaryTarget,
|
|
5
|
+
} from "#dms-core/app/types/confirm-dialog";
|
|
6
|
+
import { downloadFile } from "#dms-core/app/utils/downloadFile";
|
|
7
|
+
import type { SecondaryTargetRun } from "./confirmDialogTexts";
|
|
8
|
+
|
|
9
|
+
/** The response a download reads: its body and its headers. */
|
|
10
|
+
export interface DownloadResponse {
|
|
11
|
+
_data?: Blob;
|
|
12
|
+
headers: Headers;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Fetches a download with the user's session (`$authFetch.raw`). */
|
|
16
|
+
export type DownloadFetch = (
|
|
17
|
+
url: string,
|
|
18
|
+
options: { responseType: "blob" },
|
|
19
|
+
) => Promise<DownloadResponse>;
|
|
20
|
+
|
|
21
|
+
const DISPOSITION_NAME = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i;
|
|
22
|
+
const FALLBACK_FILENAME = "download";
|
|
23
|
+
|
|
24
|
+
// A malformed escape is kept as written rather than failing the download.
|
|
25
|
+
function decodeName(name: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(name);
|
|
28
|
+
} catch {
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The name a download is saved under: the declared one, else the server's
|
|
35
|
+
* `Content-Disposition` one, else the URL's last path segment.
|
|
36
|
+
*/
|
|
37
|
+
export function downloadFilename(
|
|
38
|
+
target: ConfirmDialogDownloadTarget,
|
|
39
|
+
disposition: string | null,
|
|
40
|
+
): string {
|
|
41
|
+
if (target.filename) return target.filename;
|
|
42
|
+
const served = disposition ? DISPOSITION_NAME.exec(disposition)?.[1] : "";
|
|
43
|
+
if (served) return decodeName(served.trim());
|
|
44
|
+
const path = target.url.split(/[?#]/)[0] ?? "";
|
|
45
|
+
return decodeName(path.split("/").pop() || FALLBACK_FILENAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openLink(target: ConfirmDialogLinkTarget): void {
|
|
49
|
+
if (typeof window === "undefined" || !target.url) return;
|
|
50
|
+
if (target.newTab) {
|
|
51
|
+
window.open(target.url, "_blank", "noopener,noreferrer");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
window.location.href = target.url;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function saveDownload(
|
|
58
|
+
target: ConfirmDialogDownloadTarget,
|
|
59
|
+
fetchRaw: DownloadFetch,
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
if (!target.url) return;
|
|
62
|
+
const response = await fetchRaw(target.url, { responseType: "blob" });
|
|
63
|
+
const blob = response._data ?? new Blob([]);
|
|
64
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
65
|
+
try {
|
|
66
|
+
downloadFile(
|
|
67
|
+
objectUrl,
|
|
68
|
+
downloadFilename(target, response.headers.get("content-disposition")),
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
URL.revokeObjectURL(objectUrl);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Runs the target of a confirmation's secondary action, the dialog left
|
|
77
|
+
* open: a link opens, a download is fetched with `fetchRaw` and saved.
|
|
78
|
+
*/
|
|
79
|
+
export function secondaryTargetRunner(
|
|
80
|
+
fetchRaw: DownloadFetch,
|
|
81
|
+
): SecondaryTargetRun {
|
|
82
|
+
return (target: ConfirmDialogSecondaryTarget) =>
|
|
83
|
+
target.type === "download"
|
|
84
|
+
? saveDownload(target, fetchRaw)
|
|
85
|
+
: openLink(target);
|
|
86
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ConfirmTranslate,
|
|
14
14
|
resolveConfirmDialog,
|
|
15
15
|
} from "./confirmDialogTexts";
|
|
16
|
+
import { type DownloadFetch, secondaryTargetRunner } from "./secondaryTarget";
|
|
16
17
|
|
|
17
18
|
/** What an action asks its confirmation with. */
|
|
18
19
|
export interface ActionConfirmRequest {
|
|
@@ -44,6 +45,9 @@ export function useActionConfirm() {
|
|
|
44
45
|
const { $authFetch } = useAuthFetch();
|
|
45
46
|
const { t } = useI18n();
|
|
46
47
|
const toast = useToast();
|
|
48
|
+
const runSecondaryTarget = secondaryTargetRunner(
|
|
49
|
+
$authFetch.raw as unknown as DownloadFetch,
|
|
50
|
+
);
|
|
47
51
|
|
|
48
52
|
const loadDialog = async (
|
|
49
53
|
declared: ActionConfirm,
|
|
@@ -81,6 +85,7 @@ export function useActionConfirm() {
|
|
|
81
85
|
dialog,
|
|
82
86
|
request.row,
|
|
83
87
|
t as ConfirmTranslate,
|
|
88
|
+
runSecondaryTarget,
|
|
84
89
|
);
|
|
85
90
|
const isConfirmed = await confirm({
|
|
86
91
|
...request.fallback,
|
|
@@ -48,6 +48,11 @@ export interface LiveGate {
|
|
|
48
48
|
catchUp(): void;
|
|
49
49
|
/** Records a read the caller makes itself (see LiveSubscription). */
|
|
50
50
|
acknowledge(): void;
|
|
51
|
+
/**
|
|
52
|
+
* Holds the block alone while the page stays live (see LiveSubscription).
|
|
53
|
+
* Does nothing on a page without `live`.
|
|
54
|
+
*/
|
|
55
|
+
setHeld(isHeld: boolean): void;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
interface BoundSubscription {
|
|
@@ -103,5 +108,6 @@ export function useLiveGate(refresh: () => void): LiveGate {
|
|
|
103
108
|
else refresh();
|
|
104
109
|
},
|
|
105
110
|
acknowledge: () => resolve()?.acknowledge(),
|
|
111
|
+
setHeld: (isHeld) => resolve()?.setHeld(isHeld),
|
|
106
112
|
};
|
|
107
113
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PageLiveOptions, PageLivePeriod } from "../../../types/live";
|
|
2
|
+
|
|
3
|
+
const MS_PER_SECOND = 1000;
|
|
4
|
+
const MS_PER_UNIT: Record<string, number> = {
|
|
5
|
+
m: 60 * MS_PER_SECOND,
|
|
6
|
+
h: 60 * 60 * MS_PER_SECOND,
|
|
7
|
+
d: 24 * 60 * 60 * MS_PER_SECOND,
|
|
8
|
+
};
|
|
9
|
+
const PERIOD_PATTERN = /^(\d+)([mhd])$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The length of a `slowRateAfter.period` in ms, `null` when it does not read
|
|
13
|
+
* like `"30m"`, `"24h"` or `"7d"`.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export function livePeriodMs(period: PageLivePeriod | string): number | null {
|
|
18
|
+
const match = PERIOD_PATTERN.exec(period);
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const [, amount, unit] = match;
|
|
21
|
+
return Number(amount) * (MS_PER_UNIT[unit!] ?? 0) || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The shortest interval between two reads of one block, in ms, for a page
|
|
26
|
+
* showing a period spanning `periodSpanMs` (`null` without a period): the
|
|
27
|
+
* slower `slowRateAfter` once the span reaches its period, `maxRate` else,
|
|
28
|
+
* 0 for no cap.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export function liveIntervalMs(
|
|
33
|
+
options: PageLiveOptions,
|
|
34
|
+
periodSpanMs: number | null,
|
|
35
|
+
): number {
|
|
36
|
+
const slow = options.slowRateAfter;
|
|
37
|
+
const slowFrom = slow ? livePeriodMs(slow.period) : null;
|
|
38
|
+
if (slow && slowFrom !== null && periodSpanMs !== null) {
|
|
39
|
+
if (periodSpanMs >= slowFrom) return slow.maxRate * MS_PER_SECOND;
|
|
40
|
+
}
|
|
41
|
+
return (options.maxRate ?? 0) * MS_PER_SECOND;
|
|
42
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { computed, shallowRef, type ComputedRef } from "vue";
|
|
2
|
+
import type { PageLiveNote, PageLiveNoteInput } from "../../../types/live";
|
|
3
|
+
|
|
4
|
+
interface HoldEntry {
|
|
5
|
+
note: PageLiveNote;
|
|
6
|
+
/** The user resumed over this hold: it no longer pauses the page. */
|
|
7
|
+
isLifted: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A note given as its text alone, as a full note.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
export function toLiveNote(input: PageLiveNoteInput): PageLiveNote {
|
|
16
|
+
return typeof input === "string" ? { text: input } : input;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The components holding a live page paused, each with its reason. A hold the
|
|
21
|
+
* user resumed over is lifted: it stays lifted while its owner keeps holding,
|
|
22
|
+
* and pauses again once released and taken anew.
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export class LiveHolds {
|
|
27
|
+
private readonly entries = shallowRef(new Map<symbol, HoldEntry>());
|
|
28
|
+
|
|
29
|
+
/** The reason of the first hold still pausing the page, `null` without one. */
|
|
30
|
+
readonly activeNote: ComputedRef<PageLiveNote | null> = computed(() => {
|
|
31
|
+
for (const entry of this.entries.value.values()) {
|
|
32
|
+
if (!entry.isLifted) return entry.note;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
get isActive(): boolean {
|
|
38
|
+
return this.activeNote.value !== null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
set(owner: symbol, note: PageLiveNoteInput): void {
|
|
42
|
+
const isLifted = this.entries.value.get(owner)?.isLifted ?? false;
|
|
43
|
+
this.replace((entries) =>
|
|
44
|
+
entries.set(owner, { note: toLiveNote(note), isLifted }),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
release(owner: symbol): void {
|
|
49
|
+
if (!this.entries.value.has(owner)) return;
|
|
50
|
+
this.replace((entries) => entries.delete(owner));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
liftAll(): void {
|
|
54
|
+
this.replace((entries) => {
|
|
55
|
+
for (const entry of entries.values()) entry.isLifted = true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private replace(change: (entries: Map<symbol, HoldEntry>) => void): void {
|
|
60
|
+
const next = new Map(
|
|
61
|
+
[...this.entries.value].map(([owner, entry]) => [owner, { ...entry }]),
|
|
62
|
+
);
|
|
63
|
+
change(next);
|
|
64
|
+
this.entries.value = next;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readonly, ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the page's blocks on a clock while the realtime stream is down: the
|
|
5
|
+
* fallback of a live page with a cadence. Each tick asks the interval again,
|
|
6
|
+
* so a period changed meanwhile applies from the next one.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export class LivePoller {
|
|
11
|
+
private readonly active = ref(false);
|
|
12
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
+
/** The poller is running: the stream is down and the page reads by itself. */
|
|
14
|
+
readonly isPolling: Readonly<Ref<boolean>> = readonly(this.active);
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly intervalMs: () => number,
|
|
18
|
+
private readonly poll: () => void,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
/** Runs while `shouldRun` holds and the page has a cadence; stops else. */
|
|
22
|
+
update(shouldRun: boolean): void {
|
|
23
|
+
const isRunning = shouldRun && this.intervalMs() > 0;
|
|
24
|
+
if (isRunning === this.active.value) return;
|
|
25
|
+
this.active.value = isRunning;
|
|
26
|
+
if (isRunning) this.schedule();
|
|
27
|
+
else this.stop();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
stop(): void {
|
|
31
|
+
if (this.timer) clearTimeout(this.timer);
|
|
32
|
+
this.timer = null;
|
|
33
|
+
this.active.value = false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private schedule(): void {
|
|
37
|
+
this.timer = setTimeout(() => {
|
|
38
|
+
this.poll();
|
|
39
|
+
if (this.active.value) this.schedule();
|
|
40
|
+
}, this.intervalMs());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const STORAGE_KEY_PREFIX = "dms.live.paused:";
|
|
2
|
+
const REMEMBER_KEY_PREFIX = "key:";
|
|
3
|
+
const PAUSED_VALUE = "1";
|
|
4
|
+
|
|
5
|
+
/** Browser storage of the paused pages; absent on the server or when denied. */
|
|
6
|
+
function liveStorage(): Storage | undefined {
|
|
7
|
+
try {
|
|
8
|
+
return typeof window === "undefined" ? undefined : window.localStorage;
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The storage key of a page's Live / Pause choice: its `live.rememberKey` when it
|
|
16
|
+
* shares one, its own id else.
|
|
17
|
+
*
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export function liveStorageKey(pageId: string, rememberKey?: string): string {
|
|
21
|
+
return (
|
|
22
|
+
STORAGE_KEY_PREFIX +
|
|
23
|
+
(rememberKey ? REMEMBER_KEY_PREFIX + rememberKey : pageId)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @internal */
|
|
28
|
+
export function readStoredPause(key: string): boolean {
|
|
29
|
+
return liveStorage()?.getItem(key) === PAUSED_VALUE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @internal */
|
|
33
|
+
export function storePause(key: string, isPaused: boolean): void {
|
|
34
|
+
const storage = liveStorage();
|
|
35
|
+
if (!storage) return;
|
|
36
|
+
try {
|
|
37
|
+
if (isPaused) storage.setItem(key, PAUSED_VALUE);
|
|
38
|
+
else storage.removeItem(key);
|
|
39
|
+
} catch {
|
|
40
|
+
// A full or locked storage only loses the memory of the choice.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readonly, ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
interface PublishedEvent {
|
|
4
|
+
topic?: unknown;
|
|
5
|
+
payload?: { ids?: unknown; count?: unknown };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function rowKeysOf(event: PublishedEvent): string[] {
|
|
9
|
+
const ids = event.payload?.ids;
|
|
10
|
+
if (!Array.isArray(ids)) return [];
|
|
11
|
+
const topic = typeof event.topic === "string" ? event.topic : "";
|
|
12
|
+
return ids
|
|
13
|
+
.filter((id) => typeof id === "string" || typeof id === "number")
|
|
14
|
+
.map((id) => JSON.stringify([topic, id]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function batchCountOf(event: PublishedEvent): number | null {
|
|
18
|
+
const count = event.payload?.count;
|
|
19
|
+
return typeof count === "number" && Number.isInteger(count) && count > 0
|
|
20
|
+
? count
|
|
21
|
+
: null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the events received since the pause stand for, published rows rather
|
|
26
|
+
* than messages: a message naming rows in `payload.ids` counts each row once
|
|
27
|
+
* (a table view's row events, a bulk write), a batch announcing
|
|
28
|
+
* `payload.count` counts that many (a module publishing its requests every
|
|
29
|
+
* second or so), anything else counts one. The same event object reaching
|
|
30
|
+
* the tally twice (one event, several blocks) counts once.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
export class MissedTally {
|
|
35
|
+
private readonly count = ref(0);
|
|
36
|
+
private events = new WeakSet<object>();
|
|
37
|
+
private rows = new Set<string>();
|
|
38
|
+
/** The rows and events counted since the last reset. */
|
|
39
|
+
readonly total: Readonly<Ref<number>> = readonly(this.count);
|
|
40
|
+
|
|
41
|
+
add(event: object | undefined): void {
|
|
42
|
+
if (event) {
|
|
43
|
+
if (this.events.has(event)) return;
|
|
44
|
+
this.events.add(event);
|
|
45
|
+
}
|
|
46
|
+
this.count.value += this.weightOf(event as PublishedEvent | undefined);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
reset(): void {
|
|
50
|
+
this.count.value = 0;
|
|
51
|
+
this.events = new WeakSet();
|
|
52
|
+
this.rows = new Set();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private weightOf(event: PublishedEvent | undefined): number {
|
|
56
|
+
if (!event) return 1;
|
|
57
|
+
const rowKeys = rowKeysOf(event);
|
|
58
|
+
if (rowKeys.length > 0) {
|
|
59
|
+
const fresh = rowKeys.filter((key) => !this.rows.has(key));
|
|
60
|
+
fresh.forEach((key) => this.rows.add(key));
|
|
61
|
+
return fresh.length;
|
|
62
|
+
}
|
|
63
|
+
return batchCountOf(event) ?? 1;
|
|
64
|
+
}
|
|
65
|
+
}
|