From 46e1f4ae546aa28c4a323859011d2d291fa554eb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 3 Aug 2026 00:23:00 +0100 Subject: [PATCH] Centralize platform table View controls --- .../subsystems/frontend-primitives.md | 5 +- .../internal/subsystems/unified-resources.md | 3 ++ frontend-modern/browser-verification.json | 46 +++++++++++-------- .../SharedPrimitives.guardrails.test.ts | 6 +++ .../features/docker/DockerContainersTable.tsx | 22 ++++----- .../platformOverviewLayout.guardrails.test.ts | 1 + .../__tests__/sharedPlatformPage.test.ts | 17 +++++-- .../platformPage/sharedPlatformPage.tsx | 18 ++++++-- .../standalone/AgentsMachinesTable.tsx | 20 +++++--- .../__tests__/AgentsMachinesTable.test.tsx | 39 ++++++++++------ 10 files changed, 118 insertions(+), 59 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 64319c493..fb927626f 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 81fc5c020..3d1b57635 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 68205940e..6bb2e238c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 95d9a2fd9..12076fab6 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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('{props.viewOptions}', + ); + 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"'); diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 279afc778..312413e5d 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -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 = (pro rowNoun="containers" hasActiveFilters={hasActiveFilters()} onResetFilters={resetFilters} - viewOptionsTrailing={ + viewOptions={ - -
-
- Layout -
- +
+
+ Layout
- + +
} /> diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 88fd59ae0..177a942b5 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -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`) diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index 3c17a700d..1efb17f17 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -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')); }); }); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 824ffd3ae..874a2b86b 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -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(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(props: { savedViewsKey={props.savedViewsKey} viewOptionsTrailing={ <> - {props.viewOptionsTrailing} + + {props.viewOptions} + + viewOptions={ +
+
+ Table +
+ +
} /> diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx index 852289fde..bb6df8f05 100644 --- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx @@ -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(() => ( { )); 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');