mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
fix(frontend): unify summary scope clearing
This commit is contained in:
parent
57c8757011
commit
fc255e4d8c
19 changed files with 258 additions and 87 deletions
|
|
@ -152,7 +152,7 @@ work extends shared components instead of creating new local variants.
|
|||
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
|
||||
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
|
||||
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
|
||||
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns whitespace clearing: pinned scope may clear only from governed neutral interaction-surface space, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
|
||||
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. Pinned page/group/entity scope on workloads, infrastructure, or storage must stay row-first: the pinned row or group header is the visible scoped state, not a second strip or search-row widget. Page shells therefore must not reintroduce always-on scope banners, preview bars, page-local chips, breadcrumbs, or search/filter-row scope accessories just to explain pinned state. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. That same shared table-focus owner now also owns reversible clearing: pinned scope may clear only from governed neutral interaction-surface space or the shared `Escape` path, with page owners binding a broader clear-surface root separately from the row-lookup table root when needed and supplying one page-level reset callback for filters plus summary-linked selections. Row cells, group headers, inline detail, summary cards, and explicit controls must not accidentally clear pinned scope, while governed table/card clear surfaces must still allow real user clicks on neutral whitespace to clear it. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: infrastructure and workload row toggles that already have the row in view must hand the current `.app-scroll-shell` position through `frontend-modern/src/utils/appShellScrollRestoration.ts` so the remounted shell in `frontend-modern/src/App.tsx` can reopen the inline detail without looking like a page refresh, and then still route through the shared reveal helper whenever the opened drawer would otherwise land below the fold. Same-route drawers must therefore scroll only enough to keep the row header plus the top of the inline detail visible, never hard-center the row just because the route state changed.
|
||||
Shared summary-linked rows and group headers must also route their preview
|
||||
semantics through
|
||||
`frontend-modern/src/components/shared/summaryInteractionA11y.ts`.
|
||||
|
|
@ -503,8 +503,9 @@ an exhausted quickstart-credit badge cannot override an otherwise active Patrol
|
|||
runtime unless quickstart exhaustion is the active blocker.
|
||||
When those shared helpers surface quickstart state, the wording must stay
|
||||
Patrol-scoped as well: the badge copy should talk about Patrol quickstart runs
|
||||
or Patrol quickstart exhaustion rather than generic AI credits or broad hosted
|
||||
AI availability.
|
||||
or Patrol quickstart exhaustion on activated or trial-backed installs rather
|
||||
than generic AI credits, anonymous free hosted AI, or broad hosted AI
|
||||
availability.
|
||||
That same route-owned presentation rule also governs Patrol findings empty
|
||||
states: shared section shells under `frontend-modern/src/features/patrol/`
|
||||
must not render a green healthy empty state from `0 active findings` alone
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ regression protection.
|
|||
24. Extend dashboard shell rendering through `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`, `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`, and `frontend-modern/src/components/Dashboard/DashboardStatsStrip.tsx` rather than accreting loading cards, workload table markup, or stats-strip presentation back into `frontend-modern/src/components/Dashboard/Dashboard.tsx`
|
||||
25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`
|
||||
26. Keep long-range workload chart capping time-proportional across `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx`, `frontend-modern/src/api/charts.ts`, and `internal/api/router.go`: when the workload hot path caps mixed-cadence history for top cards, it must bucket by time window rather than raw point index so 7-day and 30-day workload cards stay visually even without relaxing the protected payload budget.
|
||||
27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. That hot path stays row-first rather than adding fallback chrome: the on-screen row or group header is the scoped state, and clearing belongs to governed neutral interaction surfaces instead of bolting one-off clear buttons or search-row widgets into each page shell. The same hot path must therefore separate row-lookup roots from broader clear-surface roots when page whitespace should clear pinned scope. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, or idle header metadata cannot feed layout loops that grow or shrink the top cards over time. Recovery’s summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise.
|
||||
27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. That hot path stays row-first rather than adding fallback chrome: the on-screen row or group header is the scoped state, and clearing belongs to governed neutral interaction surfaces instead of bolting one-off clear buttons or search-row widgets into each page shell. The same hot path must therefore separate row-lookup roots from broader clear-surface roots when page whitespace should clear pinned scope, and it must route `Escape` through the same shared clear owner so page filters and pinned summary selections reset together instead of stacking competing page-local listeners. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, or idle header metadata cannot feed layout loops that grow or shrink the top cards over time. Recovery’s summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise.
|
||||
The input path for that hot summary contract must stay shared too:
|
||||
`frontend-modern/src/components/shared/summaryInteractionA11y.ts` owns
|
||||
fine-pointer preview and focus-preview continuity, while
|
||||
|
|
|
|||
|
|
@ -203,9 +203,10 @@ querying, and the operator-facing storage health presentation layer.
|
|||
Any page, group, or entity scope that becomes pinned through storage
|
||||
interaction must stay row-first: the pinned row or group remains the
|
||||
visible scoped state, and clearing belongs to governed neutral
|
||||
interaction-surface whitespace rather than an extra storage-local strip or
|
||||
search-row widget, with storage owning a broader clear-surface root
|
||||
separately from the content-card row-lookup root.
|
||||
interaction-surface whitespace plus the shared `Escape` reset path rather
|
||||
than an extra storage-local strip or search-row widget, with storage
|
||||
owning a broader clear-surface root separately from the content-card
|
||||
row-lookup root.
|
||||
When that scope is a storage
|
||||
pool group, member pool rows should expose shared
|
||||
`data-summary-group-member-active="preview|pinned"` state so the grouped
|
||||
|
|
|
|||
|
|
@ -217,9 +217,10 @@ assembly branch.
|
|||
same route-backed summary contract as row focus. Infrastructure must stay
|
||||
row-first here: the pinned cluster header remains the visible scoped
|
||||
state, and clearing belongs to governed neutral interaction-surface
|
||||
whitespace rather than a search-row fallback widget or a second
|
||||
scope/pinned pill inside the cluster row chrome, with infrastructure
|
||||
owning a broader clear-surface root separately from the table row-lookup
|
||||
whitespace plus the shared `Escape` reset path rather than a search-row
|
||||
fallback widget or a second scope/pinned pill inside the cluster row
|
||||
chrome, with infrastructure owning a broader clear-surface root
|
||||
separately from the table row-lookup
|
||||
root.
|
||||
14. Keep infrastructure row emphasis on the shared frontend presentation
|
||||
contract. Host, PBS, and PMG table sections may decide whether a resource
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ export function Dashboard(props: DashboardProps) {
|
|||
const state = useDashboardState(props);
|
||||
|
||||
return (
|
||||
<div class="space-y-3" data-testid="workloads-page">
|
||||
<div
|
||||
ref={state.setClearSurfaceRootRef}
|
||||
class="space-y-3"
|
||||
data-testid="workloads-page"
|
||||
>
|
||||
<Show when={state.isWorkloadsRoute() && !state.workloadsSummaryCollapsed()}>
|
||||
<StickySummarySection>
|
||||
<WorkloadsSummary
|
||||
|
|
@ -60,11 +64,7 @@ export function Dashboard(props: DashboardProps) {
|
|||
workloads={state.workloads}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={state.setClearSurfaceRootRef}
|
||||
class="space-y-3"
|
||||
data-testid="workloads-interaction-surface"
|
||||
>
|
||||
<div class="space-y-3" data-testid="workloads-interaction-surface">
|
||||
<Show
|
||||
when={
|
||||
!state.kioskMode() &&
|
||||
|
|
|
|||
|
|
@ -153,6 +153,30 @@ describe('useDashboardSelectionState', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('routes Escape through workload scope clearing and additional page reset work', () => {
|
||||
locationSearch =
|
||||
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
const clearAdditionalPageStateOnEscape = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useDashboardSelectionState({
|
||||
clearAdditionalPageStateOnEscape,
|
||||
filteredGuests,
|
||||
summaryGroupScopes: emptySummaryGroupScopes,
|
||||
}),
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(clearAdditionalPageStateOnEscape).toHaveBeenCalledTimes(1);
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
'/workloads?type=app-container&platform=truenas&agent=truenas-main',
|
||||
ROUTE_STATE_REPLACE_OPTIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the nearest scrollable ancestor when row focus changes locally', () => {
|
||||
locationSearch = '?type=app-container&platform=truenas&agent=truenas-main';
|
||||
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
|
||||
import { createMemo, createSignal, type Accessor } from 'solid-js';
|
||||
|
||||
import { useBreakpoint } from '@/hooks/useBreakpoint';
|
||||
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
|
||||
|
|
@ -20,13 +20,6 @@ import {
|
|||
} from './dashboardFilterModel';
|
||||
|
||||
interface DashboardControlsStateOptions {
|
||||
containerRuntime: Accessor<string>;
|
||||
resetWorkloadRouteFilters: () => void;
|
||||
selectedHostHint: Accessor<string | null>;
|
||||
selectedPlatform: Accessor<string | null>;
|
||||
selectedKubernetesContext: Accessor<string | null>;
|
||||
selectedKubernetesNamespace: Accessor<string | null>;
|
||||
selectedNode: Accessor<string | null>;
|
||||
setShowFilters: (value: boolean | ((current: boolean) => boolean)) => void;
|
||||
showFilters: Accessor<boolean>;
|
||||
viewMode: Accessor<ViewMode>;
|
||||
|
|
@ -129,40 +122,14 @@ export function useDashboardControlsState(options: DashboardControlsStateOptions
|
|||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
|
||||
const hasActiveFilters =
|
||||
search().trim() ||
|
||||
sortKey() !== DEFAULT_DASHBOARD_SORT_KEY ||
|
||||
sortDirection() !== DEFAULT_DASHBOARD_SORT_DIRECTION ||
|
||||
options.selectedNode() !== null ||
|
||||
options.selectedHostHint() !== null ||
|
||||
options.selectedPlatform() !== null ||
|
||||
options.selectedKubernetesContext() !== null ||
|
||||
options.selectedKubernetesNamespace() !== null ||
|
||||
options.containerRuntime().trim() !== '' ||
|
||||
options.viewMode() !== 'all' ||
|
||||
statusMode() !== DEFAULT_DASHBOARD_STATUS_MODE;
|
||||
|
||||
if (!hasActiveFilters) {
|
||||
options.setShowFilters(!options.showFilters());
|
||||
return;
|
||||
}
|
||||
|
||||
setSearch('');
|
||||
setIsSearchLocked(false);
|
||||
setSortKey(DEFAULT_DASHBOARD_SORT_KEY);
|
||||
setSortDirection(DEFAULT_DASHBOARD_SORT_DIRECTION);
|
||||
options.resetWorkloadRouteFilters();
|
||||
setStatusMode(DEFAULT_DASHBOARD_STATUS_MODE);
|
||||
blurFocusedTypeToSearch();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
const resetDashboardControls = () => {
|
||||
setSearch('');
|
||||
setIsSearchLocked(false);
|
||||
setSortKey(DEFAULT_DASHBOARD_SORT_KEY);
|
||||
setSortDirection(DEFAULT_DASHBOARD_SORT_DIRECTION);
|
||||
setStatusMode(DEFAULT_DASHBOARD_STATUS_MODE);
|
||||
blurFocusedTypeToSearch();
|
||||
};
|
||||
|
||||
const handleBeforeAutoFocus = () => {
|
||||
if (aiChatStore.focusInput()) return true;
|
||||
|
|
@ -226,6 +193,7 @@ export function useDashboardControlsState(options: DashboardControlsStateOptions
|
|||
isSearchLocked,
|
||||
mobileVisibleColumnIds,
|
||||
mobileVisibleColumns,
|
||||
resetDashboardControls,
|
||||
search,
|
||||
setGroupingMode,
|
||||
setSearch,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from './dashboardSelectionModel';
|
||||
|
||||
interface UseDashboardSelectionStateOptions {
|
||||
clearAdditionalPageStateOnEscape?: () => void;
|
||||
filteredGuests: Accessor<WorkloadGuest[]>;
|
||||
summaryGroupScopes: Accessor<Map<string, SummarySeriesGroupScope>>;
|
||||
}
|
||||
|
|
@ -74,6 +75,10 @@ export function useDashboardSelectionState(options: UseDashboardSelectionStateOp
|
|||
focusedSeriesId: selectedGuestId,
|
||||
focusedGroupId: selectedWorkloadGroupId,
|
||||
focusedGroupScope: focusedWorkloadGroupScope,
|
||||
onEscapeClear: () => {
|
||||
clearPinnedSummaryScope();
|
||||
options.clearAdditionalPageStateOnEscape?.();
|
||||
},
|
||||
revealActiveSeries: setRevealedGuestId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export function useDashboardState(props: DashboardProps) {
|
|||
isSearchLocked,
|
||||
mobileVisibleColumnIds,
|
||||
mobileVisibleColumns,
|
||||
resetDashboardControls,
|
||||
search,
|
||||
setGroupingMode,
|
||||
setSearch,
|
||||
|
|
@ -135,13 +136,6 @@ export function useDashboardState(props: DashboardProps) {
|
|||
setWorkloadsSummaryCollapsed,
|
||||
setWorkloadsSummaryRange,
|
||||
} = useDashboardControlsState({
|
||||
containerRuntime,
|
||||
resetWorkloadRouteFilters,
|
||||
selectedHostHint,
|
||||
selectedPlatform,
|
||||
selectedKubernetesContext,
|
||||
selectedKubernetesNamespace,
|
||||
selectedNode,
|
||||
setShowFilters,
|
||||
showFilters,
|
||||
viewMode,
|
||||
|
|
@ -231,6 +225,10 @@ export function useDashboardState(props: DashboardProps) {
|
|||
shouldShowJumpToActiveWorkloadRow,
|
||||
tableBodyRef,
|
||||
} = useDashboardSelectionState({
|
||||
clearAdditionalPageStateOnEscape: () => {
|
||||
resetDashboardControls();
|
||||
resetWorkloadRouteFilters();
|
||||
},
|
||||
filteredGuests,
|
||||
summaryGroupScopes,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ const Storage: Component = () => {
|
|||
} = useStoragePageModel();
|
||||
|
||||
return (
|
||||
<div class="space-y-4" data-testid="storage-page">
|
||||
<div
|
||||
ref={setClearSurfaceRootRef}
|
||||
class="space-y-4"
|
||||
data-testid="storage-page"
|
||||
>
|
||||
<StickySummarySection desktopOnly={false}>
|
||||
<StoragePageSummary
|
||||
filteredRecordCount={() => filteredRecords().length}
|
||||
|
|
@ -92,11 +96,7 @@ const Storage: Component = () => {
|
|||
isCephRecord={isStorageRecordCeph}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={setClearSurfaceRootRef}
|
||||
class="space-y-4"
|
||||
data-testid="storage-interaction-surface"
|
||||
>
|
||||
<div class="space-y-4" data-testid="storage-interaction-surface">
|
||||
<div data-summary-clear-ignore>
|
||||
<StoragePageControls
|
||||
kioskMode={kioskMode}
|
||||
|
|
|
|||
|
|
@ -815,7 +815,7 @@ describe('Storage', () => {
|
|||
).toBeTruthy();
|
||||
});
|
||||
|
||||
const clearSurface = screen.getByTestId('storage-interaction-surface');
|
||||
const clearSurface = screen.getByTestId('storage-page');
|
||||
expect(clearSurface).not.toBeNull();
|
||||
|
||||
fireEvent.click(clearSurface);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import { useStoragePageFilters } from './useStoragePageFilters';
|
|||
import { useStoragePageResources } from './useStoragePageResources';
|
||||
import { useStoragePageStatus } from './useStoragePageStatus';
|
||||
import { useStorageResourceHighlight } from './useStorageResourceHighlight';
|
||||
import { isStorageRecordCeph } from './storagePageState';
|
||||
import {
|
||||
DEFAULT_STORAGE_SELECTED_NODE_ID,
|
||||
DEFAULT_STORAGE_SOURCE_FILTER,
|
||||
isStorageRecordCeph,
|
||||
} from './storagePageState';
|
||||
import { buildStorageSummaryGroupScopeMap } from './storageSummaryGroups';
|
||||
|
||||
export const useStoragePageModel = () => {
|
||||
|
|
@ -208,6 +212,16 @@ export const useStoragePageModel = () => {
|
|||
setSelectedDiskId(null);
|
||||
clearFocusedStorageGroup();
|
||||
};
|
||||
const clearStorageFilters = () => {
|
||||
setSearch('');
|
||||
setSourceFilter(DEFAULT_STORAGE_SOURCE_FILTER);
|
||||
setStorageFilterStatus('all');
|
||||
setSelectedNodeId(DEFAULT_STORAGE_SELECTED_NODE_ID);
|
||||
};
|
||||
const clearAllPageStateOnEscape = () => {
|
||||
clearPinnedSummaryScope();
|
||||
clearStorageFilters();
|
||||
};
|
||||
|
||||
const setExpandedPoolId = (
|
||||
value: string | null | ((current: string | null) => string | null),
|
||||
|
|
@ -333,6 +347,7 @@ export const useStoragePageModel = () => {
|
|||
focusedGroupScope: focusedStorageGroupScope,
|
||||
focusedGroupId: selectedStorageGroupId,
|
||||
focusedSeriesId: focusedStorageResourceId,
|
||||
onEscapeClear: clearAllPageStateOnEscape,
|
||||
revealActiveSeries: (seriesId) => {
|
||||
if (physicalDiskSeriesIds().has(seriesId)) {
|
||||
if (view() !== 'disks') {
|
||||
|
|
|
|||
|
|
@ -313,9 +313,10 @@ describe('shared primitive guardrails', () => {
|
|||
expect(summaryTableFocusSource).toContain('revealInlineDetailInViewport');
|
||||
expect(summaryTableFocusSource).toContain('MutationObserver');
|
||||
expect(summaryTableFocusSource).toContain('clearPinnedScope?: () => void;');
|
||||
expect(summaryTableFocusSource).toContain('onEscapeClear?: () => void;');
|
||||
expect(summaryTableFocusSource).toContain('setClearSurfaceRootRef');
|
||||
expect(summaryTableFocusSource).toContain('[data-summary-clear-ignore]');
|
||||
expect(summaryTableFocusSource).toContain("target.closest('[data-summary-clear-surface]')");
|
||||
expect(summaryTableFocusSource).toContain("event.key !== 'Escape'");
|
||||
expect(summaryTableFocusSource).toContain('querySelector<HTMLElement>(');
|
||||
expect(summaryTableFocusSource).toContain(
|
||||
"row.scrollIntoView({ behavior: 'smooth', block: 'center' })",
|
||||
|
|
|
|||
|
|
@ -228,4 +228,74 @@ describe('useSummaryPageInteractionState', () => {
|
|||
|
||||
expect(clearPinnedScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears pinned scope when operators click neutral whitespace inside the table root', () => {
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const clearPinnedScope = vi.fn();
|
||||
const clearRoot = document.createElement('div');
|
||||
const root = document.createElement('div');
|
||||
const neutral = document.createElement('div');
|
||||
root.appendChild(neutral);
|
||||
clearRoot.appendChild(root);
|
||||
document.body.appendChild(clearRoot);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
clearPinnedScope,
|
||||
focusedGroupId,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
result.setClearSurfaceRootRef(clearRoot);
|
||||
neutral.click();
|
||||
|
||||
expect(clearPinnedScope).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not clear pinned scope when page-shell clicks land above the table root', () => {
|
||||
const [focusedGroupId] = createSignal<string | null>('group-a');
|
||||
const clearPinnedScope = vi.fn();
|
||||
const clearRoot = document.createElement('div');
|
||||
const root = document.createElement('div');
|
||||
const summary = document.createElement('div');
|
||||
clearRoot.append(summary, root);
|
||||
document.body.appendChild(clearRoot);
|
||||
|
||||
Object.defineProperty(root, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(200, 120),
|
||||
});
|
||||
Object.defineProperty(summary, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => buildRect(40, 80),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
clearPinnedScope,
|
||||
focusedGroupId,
|
||||
}),
|
||||
);
|
||||
|
||||
result.setTableRootRef(root);
|
||||
result.setClearSurfaceRootRef(clearRoot);
|
||||
summary.dispatchEvent(new MouseEvent('click', { bubbles: true, clientY: 80 }));
|
||||
|
||||
expect(clearPinnedScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes Escape through the shared page-clear owner', () => {
|
||||
const onEscapeClear = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSummaryPageInteractionState({
|
||||
onEscapeClear,
|
||||
}),
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(onEscapeClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const SUMMARY_CLEAR_IGNORE_SELECTOR = [
|
|||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'th',
|
||||
'label',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
|
|
@ -70,6 +71,7 @@ export interface UseSummaryTableFocusBridgeOptions {
|
|||
focusedSeriesId?: Accessor<string | null | undefined>;
|
||||
focusedGroupId?: Accessor<string | null | undefined>;
|
||||
clearPinnedScope?: () => void;
|
||||
onEscapeClear?: () => void;
|
||||
revealActiveSeries?: (seriesId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +151,14 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
|||
if (target.closest(SUMMARY_CLEAR_IGNORE_SELECTOR)) {
|
||||
return;
|
||||
}
|
||||
if (lookupRoot.contains(target) && !target.closest('[data-summary-clear-surface]')) {
|
||||
return;
|
||||
if (!lookupRoot.contains(target)) {
|
||||
const lookupRect = lookupRoot.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const targetCenterY =
|
||||
targetRect.height > 0 ? targetRect.top + targetRect.height / 2 : event.clientY;
|
||||
if (targetCenterY < lookupRect.top) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
options.clearPinnedScope?.();
|
||||
};
|
||||
|
|
@ -161,6 +169,38 @@ export function useSummaryTableFocusBridge(options: UseSummaryTableFocusBridgeOp
|
|||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === 'undefined' || !options.onEscapeClear) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest('[role="dialog"], [aria-modal="true"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
options.onEscapeClear?.();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleDocumentKeyDown);
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('keydown', handleDocumentKeyDown);
|
||||
});
|
||||
});
|
||||
|
||||
const jumpToActiveRow = () => {
|
||||
const activeSeriesId = normalizedActiveSeriesId();
|
||||
if (!activeSeriesId) {
|
||||
|
|
@ -361,6 +401,7 @@ export interface UseSummaryPageInteractionStateOptions {
|
|||
hoveredGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
focusedGroupScope?: Accessor<SummarySeriesGroupScope | null | undefined>;
|
||||
clearPinnedScope?: () => void;
|
||||
onEscapeClear?: () => void;
|
||||
revealActiveSeries?: (seriesId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -404,6 +445,10 @@ export function useSummaryPageInteractionState(options: UseSummaryPageInteractio
|
|||
clearPinnedScope: options.clearPinnedScope,
|
||||
focusedSeriesId,
|
||||
focusedGroupId,
|
||||
onEscapeClear: () => {
|
||||
setChartHoverSync(null);
|
||||
options.onEscapeClear?.();
|
||||
},
|
||||
revealActiveSeries: options.revealActiveSeries,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,11 @@ export function InfrastructurePageSurface() {
|
|||
const infrastructureLoadFailureState = () => getInfrastructureLoadFailureState();
|
||||
|
||||
return (
|
||||
<div data-testid="infrastructure-page" class="space-y-4">
|
||||
<div
|
||||
ref={setSummaryClearSurfaceRootRef}
|
||||
data-testid="infrastructure-page"
|
||||
class="space-y-4"
|
||||
>
|
||||
<Show
|
||||
when={!loading() || initialLoadComplete()}
|
||||
fallback={
|
||||
|
|
@ -161,11 +165,7 @@ export function InfrastructurePageSurface() {
|
|||
</StickySummarySection>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={setSummaryClearSurfaceRootRef}
|
||||
class="space-y-3"
|
||||
data-testid="infrastructure-interaction-surface"
|
||||
>
|
||||
<div class="space-y-3" data-testid="infrastructure-interaction-surface">
|
||||
<Show when={!kioskMode()}>
|
||||
<div data-summary-clear-ignore>
|
||||
<Card padding="sm" class="mb-4">
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ describe('InfrastructurePageSurface guardrails', () => {
|
|||
expect(infrastructurePageSurfaceSource).toContain('onGroupHoverChange={setHoveredResourceGroupScope}');
|
||||
expect(infrastructurePageSurfaceSource).toContain('setSummaryClearSurfaceRootRef');
|
||||
expect(infrastructurePageSurfaceSource).toContain('setTableRootRef={setSummaryTableRootRef}');
|
||||
expect(infrastructurePageSurfaceSource).toContain('data-testid="infrastructure-page"');
|
||||
expect(infrastructurePageSurfaceSource).toContain('ref={setSummaryClearSurfaceRootRef}');
|
||||
expect(infrastructurePageSurfaceSource).toContain('data-testid="infrastructure-interaction-surface"');
|
||||
expect(infrastructurePageSurfaceSource).toContain('data-summary-clear-ignore');
|
||||
expect(infrastructurePageSurfaceSource).not.toContain('SummaryScopeBar');
|
||||
|
|
@ -64,6 +66,8 @@ describe('InfrastructurePageSurface guardrails', () => {
|
|||
expect(infrastructurePageStateSource).toContain('activeSummaryResourceGroupScope');
|
||||
expect(infrastructurePageStateSource).toContain('focusedSummaryResourceGroupScope');
|
||||
expect(infrastructurePageStateSource).toContain('clearPinnedSummaryScope');
|
||||
expect(infrastructurePageStateSource).toContain('clearAllPageStateOnEscape');
|
||||
expect(infrastructurePageStateSource).toContain('onEscapeClear: clearAllPageStateOnEscape');
|
||||
expect(infrastructurePageStateSource).toContain('setHoveredResourceGroupScope');
|
||||
expect(infrastructurePageStateSource).toContain('jumpToActiveResourceRow');
|
||||
expect(infrastructurePageStateSource).toContain('setSummaryClearSurfaceRootRef');
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ export function useInfrastructurePageState() {
|
|||
routeState.setFocusedResourceGroupId(null);
|
||||
});
|
||||
};
|
||||
const clearAllPageStateOnEscape = () => {
|
||||
clearPinnedSummaryScope();
|
||||
clearFilters();
|
||||
};
|
||||
const summaryInteraction = useSummaryPageInteractionState({
|
||||
clearPinnedScope: clearPinnedSummaryScope,
|
||||
hoveredSeriesId: routeState.hoveredResourceId,
|
||||
|
|
@ -114,6 +118,7 @@ export function useInfrastructurePageState() {
|
|||
focusedSeriesId: routeState.expandedResourceId,
|
||||
focusedGroupId: routeState.focusedResourceGroupId,
|
||||
focusedGroupScope: focusedResourceGroupScope,
|
||||
onEscapeClear: clearAllPageStateOnEscape,
|
||||
revealActiveSeries: routeState.setRevealedResourceId,
|
||||
});
|
||||
const setSummaryTableRootRef = (element: HTMLDivElement | undefined) => {
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ async function findNeutralClearPoint(
|
|||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'th',
|
||||
'label',
|
||||
'summary',
|
||||
'[role="button"]',
|
||||
|
|
@ -128,7 +129,9 @@ async function findNeutralClearPoint(
|
|||
].join(', ');
|
||||
|
||||
const surfaceRect = surface.getBoundingClientRect();
|
||||
for (let y = surfaceRect.top + 12; y <= surfaceRect.bottom - 12; y += 12) {
|
||||
const tableRect = table.getBoundingClientRect();
|
||||
const startY = Math.max(surfaceRect.top + 12, tableRect.top + 12);
|
||||
for (let y = startY; y <= surfaceRect.bottom - 12; y += 12) {
|
||||
for (let x = surfaceRect.left + 12; x <= surfaceRect.right - 12; x += 12) {
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!(target instanceof HTMLElement) || !surface.contains(target)) {
|
||||
|
|
@ -1056,14 +1059,14 @@ test.describe.serial("Summary hover selection", () => {
|
|||
{
|
||||
path: "/workloads",
|
||||
summaryTestId: "workloads-summary",
|
||||
interactionSurfaceTestId: "workloads-interaction-surface",
|
||||
interactionSurfaceTestId: "workloads-page",
|
||||
tableSurfaceTestId: "workloads-table-surface",
|
||||
tableRowSelector: "tr[data-guest-id]",
|
||||
},
|
||||
{
|
||||
path: "/infrastructure",
|
||||
summaryTestId: "infrastructure-summary",
|
||||
interactionSurfaceTestId: "infrastructure-interaction-surface",
|
||||
interactionSurfaceTestId: "infrastructure-page",
|
||||
tableSurfaceTestId: "infrastructure-table-surface",
|
||||
tableRowSelector: "tr[data-summary-series-id]",
|
||||
},
|
||||
|
|
@ -1187,6 +1190,21 @@ test.describe.serial("Summary hover selection", () => {
|
|||
await expect
|
||||
.poll(() => readRenderedSeriesCounts(summary))
|
||||
.toEqual(baselineCounts);
|
||||
|
||||
await matchedGroupRow.scrollIntoViewIfNeeded();
|
||||
await matchedGroupRow.click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("summaryGroup"))
|
||||
.toBe(matchedGroupId);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("summaryGroup"))
|
||||
.toBeNull();
|
||||
await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount(0);
|
||||
await page.mouse.move(1, 1);
|
||||
await expect
|
||||
.poll(() => readRenderedSeriesCounts(summary))
|
||||
.toEqual(baselineCounts);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1202,7 +1220,7 @@ test.describe.serial("Summary hover selection", () => {
|
|||
|
||||
await page.goto("/storage?group=node", { waitUntil: "domcontentloaded" });
|
||||
const storageSummary = page.getByTestId("storage-summary");
|
||||
const storageInteractionSurface = page.getByTestId("storage-interaction-surface");
|
||||
const storageInteractionSurface = page.getByTestId("storage-page");
|
||||
await expect(storageSummary).toBeVisible();
|
||||
await expect(page.getByText("Pinned to")).toHaveCount(0);
|
||||
|
||||
|
|
@ -1304,7 +1322,7 @@ test.describe.serial("Summary hover selection", () => {
|
|||
|
||||
const clearPoint = await findNeutralClearPoint(
|
||||
page,
|
||||
"storage-interaction-surface",
|
||||
"storage-page",
|
||||
"storage-content-surface",
|
||||
);
|
||||
await expect(storageInteractionSurface).toBeVisible();
|
||||
|
|
@ -1318,6 +1336,21 @@ test.describe.serial("Summary hover selection", () => {
|
|||
await expect
|
||||
.poll(() => readRenderedSeriesCounts(storageSummary))
|
||||
.toEqual(baselineCounts);
|
||||
|
||||
await matchedGroupRow.scrollIntoViewIfNeeded();
|
||||
await matchedGroupRow.click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("summaryGroup"))
|
||||
.toBe(matchedGroupId);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator('tr[data-summary-group-member-active="pinned"]')).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("summaryGroup"))
|
||||
.toBeNull();
|
||||
await page.mouse.move(1, 1);
|
||||
await expect
|
||||
.poll(() => readRenderedSeriesCounts(storageSummary))
|
||||
.toEqual(baselineCounts);
|
||||
});
|
||||
|
||||
test("keeps density-map detail inside the hover tooltip without extra chart chrome", async ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue