Keep inline drawer expansion local

This commit is contained in:
rcourtman 2026-05-19 19:36:22 +01:00
parent 34d53b23ea
commit 86ac84f68e
10 changed files with 152 additions and 171 deletions

View file

@ -238,6 +238,10 @@ Feature surfaces under `frontend-modern/src/features/` may own product-specific
assessment semantics, but they must keep those semantics in their governed
presentation helpers and render them inside the shared neutral Pulse surface
language rather than introducing page-local verdict bands or nested cards.
Feature-owned table drawers use shared disclosure and inline-detail primitives
as local interaction state. Unless a surface has a separately governed deep-link
write contract, opening or closing a row drawer must preserve the current
document and URL instead of writing route state or reloading the page shell.
Shared filter/search primitives may provide the common shell, keyboard behavior,
history, and reset mechanics, but the owning page or table must supply

View file

@ -245,6 +245,13 @@ regression protection.
mounted, report errors out of band, and must not rely on Solid
`createResource` in a way that can bubble into the route-level Suspense
fallback or the setup/welcome shell.
Workload row drawer expansion, local row focus, and summary group pinning
are local interaction state. Inbound Workloads deep links may hydrate
`resource` and `summaryGroup` into the focused row or group, but opening,
closing, or clearing an inline drawer must not write those params back to
route state, schedule router navigation, or trip the app-shell fallback.
URL synchronization for Workloads stays limited to filter-owned scope in
`frontend-modern/src/components/Workloads/useWorkloadUrlSync.ts`.
The Workloads page may show a bounded partial-inventory banner when a
configured workload-capable source is unauthorized, unreachable, stale,
pending, or paused, but it must not fabricate VM/container rows from host

View file

@ -196,6 +196,12 @@ Swarm capability surfaces.
accessible group label, while Proxmox, Kubernetes, and other platform
clusters stay reserved for actual resource identity, filters, and detail
surfaces.
Infrastructure row drawer expansion and summary group focus are local table
interaction state. Inbound infrastructure deep links may hydrate `resource`
and `summaryGroup` into the focused resource or group, but local expansion,
closing, and clearing must not rewrite those params or navigate. Route sync
for this surface is limited to filter-owned `source` and `q` state so
unified-resource rows open inline without refreshing the page shell.
Cluster group headers may use a compact `Cluster` type chip when the group
name itself is only an estate label, but `unifiedResourceTableStateModel.ts`
must suppress that chip when the visible group name already ends in

View file

@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { ROUTE_STATE_REPLACE_OPTIONS } from '@/utils/routeStateNavigation';
import { resolveWorkloadResourceSelection } from '../workloadSelectionModel';
import { useWorkloadSelectionState } from '../useWorkloadSelectionState';
@ -101,7 +100,7 @@ describe('useWorkloadSelectionState', () => {
});
});
it('writes workload row selection back into the route state without dropping filters', () => {
it('opens workload row selection locally without route navigation', () => {
locationSearch = '?type=app-container&platform=truenas&agent=truenas-main';
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
@ -115,13 +114,11 @@ describe('useWorkloadSelectionState', () => {
result.setSelectedGuestId('app-container:truenas-main:nextcloud');
vi.runAllTimers();
expect(navigateSpy).toHaveBeenCalledWith(
'/workloads?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud',
ROUTE_STATE_REPLACE_OPTIONS,
);
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
expect(navigateSpy).not.toHaveBeenCalled();
});
it('clears pinned workload scope back to page state without mutating route filters', () => {
it('clears pinned workload scope locally without mutating route filters', () => {
locationSearch =
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud&summaryGroup=docker-host%3Atruenas-main';
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
@ -147,10 +144,9 @@ describe('useWorkloadSelectionState', () => {
result.clearPinnedSummaryScope();
vi.runAllTimers();
expect(navigateSpy).toHaveBeenCalledWith(
'/workloads?type=app-container&platform=truenas&agent=truenas-main',
ROUTE_STATE_REPLACE_OPTIONS,
);
expect(result.selectedGuestId()).toBeNull();
expect(result.focusedSummaryWorkloadGroupId()).toBeNull();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('routes Escape through workload scope clearing and additional page reset work', () => {
@ -171,10 +167,7 @@ describe('useWorkloadSelectionState', () => {
vi.runAllTimers();
expect(clearAdditionalPageStateOnEscape).toHaveBeenCalledTimes(1);
expect(navigateSpy).toHaveBeenCalledWith(
'/workloads?type=app-container&platform=truenas&agent=truenas-main',
ROUTE_STATE_REPLACE_OPTIONS,
);
expect(navigateSpy).not.toHaveBeenCalled();
});
it('preserves the nearest scrollable ancestor when row focus changes locally', () => {
@ -211,10 +204,8 @@ describe('useWorkloadSelectionState', () => {
vi.runAllTimers();
expect(navigateSpy).toHaveBeenCalledWith(
'/workloads?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud',
ROUTE_STATE_REPLACE_OPTIONS,
);
expect(result.selectedGuestId()).toBe('app-container:truenas-main:nextcloud');
expect(navigateSpy).not.toHaveBeenCalled();
});
it('shows a deliberate jump affordance when a hovered workload row is off-screen', () => {
@ -436,7 +427,7 @@ describe('useWorkloadSelectionState', () => {
expect(result.focusedSummaryWorkloadGroupId()).toBe('cluster-a');
});
it('clears row focus without leaving behind an inferred agent filter', () => {
it('clears row focus locally without leaving behind an inferred agent filter', () => {
locationSearch = '?resource=cluster-a:node-1:101';
const [filteredGuests] = createSignal<WorkloadGuest[]>([]);
@ -452,6 +443,7 @@ describe('useWorkloadSelectionState', () => {
result.setSelectedGuestId(null);
vi.runAllTimers();
expect(navigateSpy).toHaveBeenCalledWith('/workloads', ROUTE_STATE_REPLACE_OPTIONS);
expect(result.selectedGuestId()).toBeNull();
expect(navigateSpy).not.toHaveBeenCalled();
});
});

View file

@ -5,7 +5,6 @@ import type { WorkloadGuest } from '@/types/workloads';
import {
workloadsHasHoveredWorkload,
resolveWorkloadResourceSelection,
resolveWorkloadsSelectionNavigateTarget,
} from '../workloadSelectionModel';
describe('workloadSelectionModel', () => {
@ -51,57 +50,10 @@ describe('workloadSelectionModel', () => {
expect(workloadsHasHoveredWorkload(guests, 'cluster-a:node-1:102')).toBe(false);
});
it('builds route-backed workload selection targets without dropping other filters', () => {
expect(
resolveWorkloadsSelectionNavigateTarget({
pathname: '/workloads',
search: '?type=app-container&platform=truenas&agent=truenas-main',
resourceId: 'app-container:truenas-main:nextcloud',
summaryGroupId: null,
}),
).toBe(
'/workloads?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud',
);
expect(
resolveWorkloadsSelectionNavigateTarget({
pathname: '/workloads',
search:
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud',
resourceId: null,
summaryGroupId: null,
}),
).toBe('/workloads?type=app-container&platform=truenas&agent=truenas-main');
expect(
resolveWorkloadsSelectionNavigateTarget({
pathname: '/workloads',
search:
'?type=app-container&platform=truenas&agent=truenas-main&resource=app-container%3Atruenas-main%3Anextcloud',
resourceId: 'app-container:truenas-main:nextcloud',
summaryGroupId: null,
}),
).toBeNull();
expect(
resolveWorkloadsSelectionNavigateTarget({
pathname: '/workloads',
search: '?type=app-container&platform=truenas&agent=truenas-main',
resourceId: null,
summaryGroupId: 'docker-host:truenas-main',
}),
).toBe(
'/workloads?type=app-container&platform=truenas&agent=truenas-main&summaryGroup=docker-host%3Atruenas-main',
);
expect(
resolveWorkloadsSelectionNavigateTarget({
pathname: '/workloads',
search:
'?type=app-container&platform=truenas&agent=truenas-main&summaryGroup=docker-host%3Atruenas-main',
resourceId: null,
summaryGroupId: 'docker-host:truenas-main',
}),
).toBeNull();
it('resolves summary group deep links separately from local row expansion', () => {
expect(resolveWorkloadResourceSelection('?summaryGroup=docker-host%3Atruenas-main')).toEqual({
resourceId: null,
summaryGroupId: 'docker-host:truenas-main',
});
});
});

View file

@ -1,5 +1,5 @@
import { useLocation, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
import { useLocation } from '@solidjs/router';
import { createEffect, createMemo, createSignal, type Accessor } from 'solid-js';
import { preserveScrollableAncestorVerticalOffset } from '@/components/shared/contextualFocus';
import { useSummaryPageInteractionState } from '@/components/shared/summaryTableFocus';
@ -9,12 +9,10 @@ import {
} from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { capturePendingAppShellRestoreTop } from '@/utils/appShellScrollRestoration';
import { createRouteStateNavigateScheduler } from '@/utils/routeStateNavigation';
import {
workloadsHasHoveredWorkload,
workloadsHasVisibleWorkloadGroupScope,
resolveWorkloadResourceSelection,
resolveWorkloadsSelectionNavigateTarget,
} from './workloadSelectionModel';
interface UseWorkloadsSelectionStateOptions {
@ -25,11 +23,6 @@ interface UseWorkloadsSelectionStateOptions {
export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOptions) {
const location = useLocation();
const navigate = useNavigate();
const routeStateNavigate = createRouteStateNavigateScheduler(
navigate,
() => `${untrack(() => location.pathname)}${untrack(() => location.search)}`,
);
const [selectedGuestId, setSelectedGuestIdRaw] = createSignal<string | null>(null);
const [selectedWorkloadGroupId, setSelectedWorkloadGroupIdRaw] = createSignal<string | null>(null);
@ -57,15 +50,6 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
setSelectedGuestIdRaw(null);
setSelectedWorkloadGroupIdRaw(null);
});
const nextPath = resolveWorkloadsSelectionNavigateTarget({
pathname: location.pathname,
search: location.search,
resourceId: null,
summaryGroupId: null,
});
if (nextPath) {
routeStateNavigate.schedule(nextPath);
}
};
const summaryInteraction = useSummaryPageInteractionState({
@ -111,15 +95,6 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
setSelectedGuestIdRaw(id);
setSelectedWorkloadGroupIdRaw(nextGroupScope?.id ?? null);
});
const nextPath = resolveWorkloadsSelectionNavigateTarget({
pathname: location.pathname,
search: location.search,
resourceId: id,
summaryGroupId: nextGroupScope?.id ?? null,
});
if (nextPath) {
routeStateNavigate.schedule(nextPath);
}
};
const setFocusedWorkloadGroupScopeState = (scope: SummarySeriesGroupScope | null) => {
@ -132,19 +107,7 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
};
const setFocusedWorkloadGroupScope = (scope: SummarySeriesGroupScope | null) => {
const nextGroupId = scope?.id ?? null;
const nextSelectedGuestId =
scope && !isSummarySeriesInGroupScope(scope, selectedGuestId()) ? null : selectedGuestId();
setFocusedWorkloadGroupScopeState(scope);
const nextPath = resolveWorkloadsSelectionNavigateTarget({
pathname: location.pathname,
search: location.search,
resourceId: nextSelectedGuestId,
summaryGroupId: nextGroupId,
});
if (nextPath) {
routeStateNavigate.schedule(nextPath);
}
};
createEffect(() => {
@ -219,10 +182,6 @@ export function useWorkloadSelectionState(options: UseWorkloadsSelectionStateOpt
}
});
onCleanup(() => {
routeStateNavigate.cleanup();
});
return {
activeSummaryScopeState: summaryInteraction.activeScopeState,
activeSummaryWorkloadGroupScope: summaryInteraction.activeGroupScope,

View file

@ -1,11 +1,8 @@
import {
parseWorkloadsLinkSearch,
WORKLOADS_PATH,
WORKLOADS_QUERY_PARAMS,
} from '@/routing/resourceLinks';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import type { WorkloadGuest } from '@/types/workloads';
import { areSearchParamsEquivalent } from '@/utils/searchParams';
import { getCanonicalWorkloadId } from '@/utils/workloads';
export interface WorkloadsResourceSelection {
@ -13,13 +10,6 @@ export interface WorkloadsResourceSelection {
summaryGroupId: string | null;
}
export interface WorkloadsSelectionNavigateTargetOptions {
pathname: string;
search: string;
resourceId: string | null;
summaryGroupId: string | null;
}
export const resolveWorkloadResourceSelection = (
search: string,
): WorkloadsResourceSelection | null => {
@ -32,35 +22,6 @@ export const resolveWorkloadResourceSelection = (
};
};
export const resolveWorkloadsSelectionNavigateTarget = ({
pathname,
search,
resourceId,
summaryGroupId,
}: WorkloadsSelectionNavigateTargetOptions): string | null => {
const currentParams = new URLSearchParams(search);
const nextParams = new URLSearchParams(search);
nextParams.delete(WORKLOADS_QUERY_PARAMS.resource);
nextParams.delete(WORKLOADS_QUERY_PARAMS.summaryGroup);
const normalizedResourceId = resourceId?.trim() || '';
const normalizedSummaryGroupId = summaryGroupId?.trim() || '';
if (normalizedResourceId) {
nextParams.set(WORKLOADS_QUERY_PARAMS.resource, normalizedResourceId);
}
if (normalizedSummaryGroupId) {
nextParams.set(WORKLOADS_QUERY_PARAMS.summaryGroup, normalizedSummaryGroupId);
}
if (areSearchParamsEquivalent(currentParams, nextParams)) {
return null;
}
const nextSearch = nextParams.toString();
const nextPathname = pathname.trim() || WORKLOADS_PATH;
return nextSearch ? `${nextPathname}?${nextSearch}` : nextPathname;
};
export const workloadsHasHoveredWorkload = (
filteredGuests: WorkloadGuest[],
hoveredId: string,

View file

@ -0,0 +1,112 @@
import { renderHook } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { ROUTE_STATE_REPLACE_OPTIONS } from '@/utils/routeStateNavigation';
import { useInfrastructurePageRouteState } from '../useInfrastructurePageRouteState';
let locationPath = '/infrastructure';
let locationSearch = '';
const navigateSpy = vi.fn();
vi.mock('@solidjs/router', () => ({
useLocation: () => ({
get pathname() {
return locationPath;
},
get search() {
return locationSearch;
},
}),
useNavigate: () => navigateSpy,
}));
const makeResource = (id: string): Resource =>
({
id,
name: id,
displayName: id,
sourceType: 'agent',
status: 'online',
type: 'agent',
}) as Resource;
describe('useInfrastructurePageRouteState', () => {
beforeEach(() => {
locationPath = '/infrastructure';
locationSearch = '';
navigateSpy.mockReset();
vi.useFakeTimers();
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
Object.defineProperty(window.history, 'scrollRestoration', {
configurable: true,
value: 'auto',
writable: true,
});
window.scrollTo = vi.fn() as typeof window.scrollTo;
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
const setup = () => {
const [resources] = createSignal<Resource[]>([makeResource('agent-1'), makeResource('agent-2')]);
const [selectedSource, setSelectedSource] = createSignal('');
const [searchQuery, setSearchQuery] = createSignal('');
const [initialLoadComplete] = createSignal(true);
const rendered = renderHook(() =>
useInfrastructurePageRouteState({
resources,
filteredResources: resources,
initialLoadComplete,
selectedSource,
setSelectedSource,
searchQuery,
setSearchQuery,
}),
);
return { ...rendered, setSelectedSource, setSearchQuery };
};
it('hydrates an inbound resource deep link into drawer state', () => {
locationSearch = '?resource=agent-1';
const { result } = setup();
expect(result.expandedResourceId()).toBe('agent-1');
expect(navigateSpy).not.toHaveBeenCalled();
});
it('keeps local drawer expansion off the route navigation path', () => {
const { result } = setup();
result.setExpandedResourceId('agent-2');
vi.runAllTimers();
expect(result.expandedResourceId()).toBe('agent-2');
expect(navigateSpy).not.toHaveBeenCalled();
});
it('keeps infrastructure filters route-backed', () => {
const { setSelectedSource } = setup();
setSelectedSource('docker');
vi.runAllTimers();
expect(navigateSpy).toHaveBeenCalledWith(
'/infrastructure?source=docker',
ROUTE_STATE_REPLACE_OPTIONS,
);
});
});

View file

@ -145,28 +145,16 @@ export function useInfrastructurePageRouteState(options: InfrastructurePageRoute
const nextSource = selectedSource();
const nextQuery = searchQuery().trim();
const currentLinkedResource = parsed.resource;
const selectedResourceId = expandedResourceId();
const shouldPreserveIncomingResource =
!selectedResourceId && Boolean(currentLinkedResource) && !initialLoadComplete();
const nextResource = shouldPreserveIncomingResource
? currentLinkedResource
: (selectedResourceId ?? '');
const nextSummaryGroup = focusedResourceGroupId() ?? '';
const managedPath = buildInfrastructurePath({
source: nextSource || null,
query: nextQuery || null,
resource: nextResource || null,
summaryGroup: nextSummaryGroup || null,
});
const managedUrl = new URL(managedPath, 'http://pulse.local');
const currentParams = new URLSearchParams(location.search);
const nextParams = new URLSearchParams(location.search);
nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.source);
nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.query);
nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.resource);
nextParams.delete(INFRASTRUCTURE_QUERY_PARAMS.summaryGroup);
managedUrl.searchParams.forEach((value, key) => {
nextParams.set(key, value);
});

View file

@ -762,7 +762,7 @@ describe('frontend resource type boundaries', () => {
expect(workloadsWorkloadRouteStateSource).toContain('useWorkloadFilterOptions');
expect(workloadsWorkloadFilterOptionsSource).toContain("from './workloadFilterConfigModel'");
expect(workloadsWorkloadFilterOptionsSource).toContain(
'buildWorkloadNodeOptions(options.allGuests())',
'buildWorkloadNodeOptions(platformScopedGuests())',
);
expect(workloadsWorkloadFilterOptionsSource).not.toContain(
'const onContextChange = (value: string) =>',
@ -1018,7 +1018,7 @@ describe('frontend resource type boundaries', () => {
expect(guestDrawerSource).toContain('useGuestDrawerState');
expect(guestDrawerSource).toContain('GuestDrawerOverview');
expect(guestDrawerStateSource).toContain('getCanonicalWorkloadId');
expect(guestDrawerStateSource).toContain("from '@/routing/resourceLinks'");
expect(guestDrawerStateSource).not.toContain("from '@/routing/resourceLinks'");
expect(guestDrawerStateSource).not.toContain('./infrastructureLink');
expect(guestDrawerStateSource).toContain('guestOsSummary');
expect(guestDrawerSource).not.toContain('const guestId = () => {');
@ -2702,7 +2702,7 @@ describe('frontend resource type boundaries', () => {
expect(commandPaletteModalSource).not.toContain('buildInfrastructurePath');
expect(commandPaletteStateSource).toContain('useNavigate');
expect(commandPaletteStateSource).toContain('createSignal');
expect(commandPaletteStateSource).toContain('buildInfrastructurePath');
expect(commandPaletteStateSource).not.toContain('buildInfrastructurePath');
expect(commandPaletteModelSource).toContain('buildCommandPaletteCommands');
expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery');
expect(commandPaletteModelSource).toContain('filterCommandPaletteCommands');