Centralize platform table View controls

This commit is contained in:
rcourtman
2026-08-03 00:23:00 +01:00
parent 8be236e99a
commit 46e1f4ae54
10 changed files with 118 additions and 59 deletions
@@ -3881,7 +3881,10 @@ and chip popovers (`AddFilterMenu` and `FilterChip`). Low-frequency view options
(grouping segmented control, charts toggle, columns picker, sort key) compose
the shared `ViewOptionsMenu` inside the `viewOptionsTrailing` slot instead of
remaining as permanent toolbar controls; table counters and other persistent
orientation readouts may remain adjacent in that slot. Recovery is event-first
orientation readouts may remain adjacent in that slot. Platform tables using
`PlatformTableToolbar` pass only their panel content through its `viewOptions`
prop: the shared toolbar owns `ViewOptionsMenu` creation, and feature consumers
must not pass or render their own View trigger. Recovery is event-first
and does not use equal workspace subtabs for protected rollups versus event
history; Storage subtabs (Pools / Physical Disks) sit above the bar as
navigation, not filters.
@@ -311,6 +311,9 @@ a frequently changed resource filter. Multi-host Overview therefore exposes
Grouped versus List inside the frontend-primitives-owned shared View
preferences popover, matching the Proxmox workload surface, and must not leave
that segmented control permanently inline beside Status, Add filter, and Saved.
Docker supplies only the layout preference content to `PlatformTableToolbar`;
the shared toolbar owns the View trigger and popover so this consumer cannot
drift back to a page-local presentation control.
The empty state for this route must preserve that runtime-lens contract:
standalone Docker / Podman hosts use a local runtime agent, while Docker inside
Proxmox LXCs is represented as the explicitly opted-in Proxmox host-side guest
+28 -18
View File
@@ -1,16 +1,21 @@
{
"version": 1,
"base_sha": "b109c1230f3efce7d89750318e996afa00dcf40c",
"verified_at": "2026-08-02T22:47:16Z",
"base_sha": "8be236e99a1f6328cc8e246d4789b84283484e01",
"verified_at": "2026-08-02T23:15:31Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docker/DockerContainersTable.tsx"
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "36071da0390d3b831526b33989f587ee00b7c16c16b607fe9dd3097223442e0b"
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "ffa97a53dae52e2072ea2adb3fd9bda36654275776c4ba96cc12cd515641ca5f",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "32618bee6d96fad013fa0cb2efc0a93c3eabac1ab402d117b56f621f59639930",
"frontend-modern/src/features/standalone/AgentsMachinesTable.tsx": "91302f9489dcee2b3cb9fa0b3042b4552397d358091767e77fc94c1d72eb9d5a"
},
"routes": [
"/docker/overview"
"/docker/overview",
"/standalone/machines"
],
"viewports": [
{
@@ -23,20 +28,25 @@
}
],
"states": [
"Desktop Docker container toolbar with the shared View trigger closed",
"Desktop Docker View preferences popover open with Grouped selected",
"Desktop flat container layout with List selected",
"Reloaded desktop View preferences with the persisted List selection",
"Phone-width expanded filter rail with the View trigger closed",
"Phone-width View preferences popover open with Grouped selected"
"Desktop Docker toolbar with View closed and its resource counter permanently visible",
"Desktop Docker View preferences open with Grouped and List inside the Layout section",
"Docker List layout before and after reload with host group rows absent",
"Phone-width Docker filter rail collapsed and expanded",
"Phone-width Docker View preferences open with the popover contained inside the viewport",
"Desktop Machines toolbar with only View and the machine counter permanently visible",
"Desktop Machines View preferences with Columns collapsed and expanded",
"Phone-width Machines filter rail collapsed and expanded",
"Phone-width Machines View preferences with Columns collapsed and expanded",
"Machines IP column preference before toggle, after toggle, after reload, and restored"
],
"interactions": [
"Opened Docker View preferences and confirmed Grouped and List are no longer permanent toolbar controls",
"Selected List and confirmed host group header rows were removed from the container table",
"Reloaded Docker Overview, reopened View, and confirmed List remained selected",
"Restored Grouped and confirmed host group headers returned",
"Expanded the phone-width filter rail, opened View, and confirmed the popover remained inside the viewport",
"Pressed Escape and confirmed the popover closed and focus returned to the View trigger",
"Confirmed the browser console reported no errors during the final desktop and phone-width pass"
"Opened Docker View and confirmed Grouped and List are no longer permanent toolbar controls",
"Selected Docker List, confirmed host group rows were removed, reloaded, and confirmed List remained selected",
"Pressed Escape from Docker View and confirmed the popover closed and focus returned to View",
"Expanded the phone-width Docker filter rail and confirmed the View popover remained inside the viewport",
"Opened Machines View and expanded the nested Columns control at desktop and phone widths",
"Toggled the Machines IP column, reloaded, confirmed the persisted checked state, and restored the original preference",
"Pressed Escape from nested Machines column content and confirmed the View popover dismissed",
"Confirmed both routes had no horizontal page overflow at 390px and no browser console errors"
]
}
@@ -8102,6 +8102,7 @@ describe('shared primitive guardrails', () => {
expect(filterBarIndexSource).toContain("export { FilterChip } from './FilterChip';");
expect(filterBarIndexSource).toContain("export { AddFilterMenu } from './AddFilterMenu';");
expect(filterBarIndexSource).toContain("export { SavedViewsMenu } from './SavedViewsMenu';");
expect(filterBarIndexSource).toContain("export { ViewOptionsMenu } from './ViewOptionsMenu';");
expect(filterBarIndexSource).toContain("export { useSavedViews } from './useSavedViews';");
expect(filterBarIndexSource).toContain(
"export { filterChipStatusDot } from './filterOptionPresentation';",
@@ -8229,6 +8230,11 @@ describe('shared primitive guardrails', () => {
expect(filterToolbarSource).toContain("filterToolbarControlClass = 'h-7");
expect(viewOptionsMenuSource).toContain('<FilterPopoverTrigger');
expect(savedViewsMenuSource).toContain('<FilterPopoverTrigger');
expect(sharedPlatformPageSource).toContain('viewOptions?: JSX.Element;');
expect(sharedPlatformPageSource).toContain(
'<ViewOptionsMenu>{props.viewOptions}</ViewOptionsMenu>',
);
expect(sharedPlatformPageSource).not.toContain('viewOptionsTrailing?: JSX.Element;');
expect(savedViewsMenuSource).toContain('class="relative shrink-0"');
expect(savedViewsMenuSource).toContain('class="h-3.5 w-3.5"');
expect(savedViewsMenuSource).toContain('aria-label="Saved views"');
@@ -1,6 +1,6 @@
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import { ViewOptionsMenu, type FilterDef } from '@/components/shared/FilterBar';
import { type FilterDef } from '@/components/shared/FilterBar';
import { UpdateButton } from '@/components/shared/ContainerUpdateBadge';
import { isContainerUpdatePinned } from '@/components/shared/containerUpdateBadgeModel';
import {
@@ -725,19 +725,17 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
rowNoun="containers"
hasActiveFilters={hasActiveFilters()}
onResetFilters={resetFilters}
viewOptionsTrailing={
viewOptions={
<Show when={isGroupable()}>
<ViewOptionsMenu>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Layout
</div>
<GroupedTableModeSegmentedControl
value={groupingMode()}
onChange={setGroupingModePreference}
/>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Layout
</div>
</ViewOptionsMenu>
<GroupedTableModeSegmentedControl
value={groupingMode()}
onChange={setGroupingModePreference}
/>
</div>
</Show>
}
/>
@@ -206,6 +206,7 @@ describe('platform overview layout guardrails', () => {
expect(source).toContain('PlatformTableToolbar');
expect(source).toContain('createPlatformTableFilterState');
expect(source).toContain('PLATFORM_HEALTH_FILTER_OPTIONS');
expect(source).not.toContain('ViewOptionsMenu');
expect(source).not.toContain("from '@/components/shared/SearchInput'");
expect(source).not.toContain("from '@/components/shared/FilterButtonGroup'");
// Forbid a bespoke search signal (`const [search, setSearch] = createSignal`)
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { createRoot, createSignal } from 'solid-js';
import type { Resource } from '@/types/resource';
@@ -281,7 +281,7 @@ describe('filterPlatformResources', () => {
});
describe('PlatformTableToolbar', () => {
it('keeps table view options inside the shared filter toolbar with the row counter', () => {
it('owns the View popover while keeping the row counter permanently visible', () => {
render(() =>
PlatformTableToolbar({
search: () => '',
@@ -293,15 +293,22 @@ describe('PlatformTableToolbar', () => {
visible: 2,
total: 3,
rowNoun: 'rows',
viewOptionsTrailing: 'Columns',
viewOptions: 'Columns',
}),
);
const columnsLabel = screen.getByText('Columns');
const filterBar = columnsLabel.closest('.filter-bar');
const viewTrigger = screen.getByRole('button', { name: 'View' });
const filterBar = viewTrigger.closest('.filter-bar');
expect(filterBar).not.toBeNull();
expect(filterBar).toContainElement(screen.getByText('2 of 3 rows'));
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
fireEvent.click(viewTrigger);
const viewDialog = screen.getByRole('dialog', { name: 'View preferences' });
expect(viewDialog).toHaveTextContent('Columns');
expect(viewDialog).not.toContainElement(screen.getByText('2 of 3 rows'));
});
});
@@ -12,7 +12,12 @@ import {
} from 'solid-js';
import { EmptyState } from '@/components/shared/EmptyState';
import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup';
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import {
FilterBar,
ViewOptionsMenu,
filterChipStatusDot,
type FilterDef,
} from '@/components/shared/FilterBar';
import { type SearchInputProps } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
@@ -1003,7 +1008,12 @@ export function PlatformTableToolbar<T extends string | number>(props: {
// chips behind "+ Filter".
filters?: FilterDef[];
savedViewsKey?: string;
viewOptionsTrailing?: JSX.Element;
/**
* Low-frequency table presentation controls. PlatformTableToolbar owns the
* shared View popover so feature tables cannot accidentally render these as
* permanent toolbar controls.
*/
viewOptions?: JSX.Element;
}) {
const { isMobile } = useBreakpoint();
@@ -1056,7 +1066,9 @@ export function PlatformTableToolbar<T extends string | number>(props: {
savedViewsKey={props.savedViewsKey}
viewOptionsTrailing={
<>
{props.viewOptionsTrailing}
<Show when={props.viewOptions}>
<ViewOptionsMenu>{props.viewOptions}</ViewOptionsMenu>
</Show>
<PlatformResourceCounter
visible={props.visible}
total={props.total}
@@ -1394,13 +1394,19 @@ export const AgentsMachinesTable: Component<{
rowNoun="machines"
hasActiveFilters={tableState.hasActiveFilters()}
onResetFilters={tableState.resetFilters}
viewOptionsTrailing={
<ColumnPicker
columns={columnVisibility.availableToggles()}
isHidden={columnVisibility.isHiddenByUser}
onToggle={columnVisibility.toggle}
onReset={columnVisibility.resetToDefaults}
/>
viewOptions={
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Table
</div>
<ColumnPicker
inline
columns={columnVisibility.availableToggles()}
isHidden={columnVisibility.isHiddenByUser}
onToggle={columnVisibility.toggle}
onReset={columnVisibility.resetToDefaults}
/>
</div>
}
/>
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentMetadataAPI } from '@/api/agentMetadata';
import { MonitoringAPI } from '@/api/monitoring';
@@ -116,6 +116,14 @@ const getAllAgentMetadataMock = vi.mocked(AgentMetadataAPI.getAllMetadata);
const deleteAgentMetadataMock = vi.mocked(AgentMetadataAPI.deleteMetadata);
const deleteAgentMock = vi.mocked(MonitoringAPI.deleteAgent);
const openMachineColumnPicker = async () => {
const viewTrigger = screen.getByRole('button', { name: 'View' });
await fireEvent.click(viewTrigger);
const viewDialog = screen.getByRole('dialog', { name: 'View preferences' });
await fireEvent.click(within(viewDialog).getByTitle('Choose which columns to display'));
return viewDialog;
};
beforeEach(() => {
getAllAgentMetadataMock.mockResolvedValue({});
deleteAgentMetadataMock.mockResolvedValue(undefined);
@@ -138,7 +146,7 @@ afterEach(() => {
});
describe('AgentsMachinesTable', () => {
it('keeps the column picker inside the shared machine filter toolbar', () => {
it('keeps the column picker inside the shared View preferences popover', async () => {
render(() => (
<AgentsMachinesTable
resources={[resource({ id: 'tower', name: 'Tower' })]}
@@ -149,11 +157,16 @@ describe('AgentsMachinesTable', () => {
));
const search = screen.getByPlaceholderText('Search machines');
const columnsButton = screen.getByTitle('Choose which columns to display');
const filterBar = columnsButton.closest('.filter-bar');
const viewTrigger = screen.getByRole('button', { name: 'View' });
const filterBar = viewTrigger.closest('.filter-bar');
expect(filterBar).not.toBeNull();
expect(filterBar).toContainElement(search);
expect(screen.queryByTitle('Choose which columns to display')).not.toBeInTheDocument();
const viewDialog = await openMachineColumnPicker();
expect(within(viewDialog).getByText('Table')).toBeInTheDocument();
expect(within(viewDialog).getByLabelText('IP')).toBeInTheDocument();
});
it('surfaces machine-native monitoring columns for agent machines', async () => {
@@ -229,7 +242,7 @@ describe('AgentsMachinesTable', () => {
expect(screen.queryByRole('button', { name: 'Sort by Net I/O' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Sort by Disk I/O' })).not.toBeInTheDocument();
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Net I/O'));
await fireEvent.click(screen.getByLabelText('Disk I/O'));
@@ -519,7 +532,7 @@ describe('AgentsMachinesTable', () => {
expect(screen.queryByText('192.168.0.21 | seen 5m ago')).not.toBeInTheDocument();
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Last seen'));
expect(screen.getByText('192.168.0.21 | seen 5m ago')).toBeInTheDocument();
@@ -685,7 +698,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Net I/O'));
const trigger = container.querySelector('[data-agent-machine-network-trigger="true"]');
expect(trigger).not.toBeNull();
@@ -730,7 +743,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('IP'));
const trigger = container.querySelector('[data-agent-machine-ip-trigger="true"]');
@@ -779,7 +792,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Disk I/O'));
const trigger = container.querySelector('[data-agent-machine-diskio-trigger="true"]');
expect(trigger).not.toBeNull();
@@ -835,7 +848,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('RAID'));
const trigger = container.querySelector('[data-agent-machine-raid-trigger="true"]');
@@ -905,7 +918,7 @@ describe('AgentsMachinesTable', () => {
expect(screen.queryByText('arm64')).not.toBeInTheDocument();
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Arch'));
expect(screen.getByText('arm64')).toBeInTheDocument();
@@ -983,7 +996,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Temp'));
const trigger = container.querySelector('[data-agent-machine-temperature-trigger="true"]');
expect(trigger).not.toBeNull();
@@ -1026,7 +1039,7 @@ describe('AgentsMachinesTable', () => {
/>
));
await fireEvent.click(screen.getByTitle('Choose which columns to display'));
await openMachineColumnPicker();
await fireEvent.click(screen.getByLabelText('Temp'));
const pressure = await screen.findByText('Nominal');