mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Centralize platform table View controls
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
+1
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user