From 2547255aea055a23ff0b7d762c0427b650ef9866 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 19:16:57 +0100 Subject: [PATCH] workloads(columns): let explicit toggles override responsive column gating Below the 1440px wide breakpoint the workloads table layout-gated IP, Node, Tags, OS, Net I/O, and Disk I/O with no recourse: the columns menu still offered the toggles but flipping them changed nothing, and v5's horizontal-scroll fallback was gone, so a 1280px laptop simply could not see guest tags or I/O rates. Flagged by the Proxmox overview parity audit; applies to every workloads surface. - Track explicitly pinned columns per storage scope (workloadsHiddenColumns[:scope]:forced); pinned columns render even when the breakpoint gates them off and the table wrapper's existing overflow-x-auto picks up the slack. - Menu checkboxes now mirror what the table actually renders: a layout-gated column reads as hidden, the first toggle pins it, the second unpins it back to the layout default (without poisoning the user-hidden set for wide viewports). - Column reset clears pinned columns along with hidden ones. --- .../useWorkloadsControlsState.test.ts | 54 ++++++++++++ .../Workloads/useWorkloadsControlsState.ts | 83 +++++++++++++++++-- 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts index 7efd88dc51..ca1d7930f1 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts @@ -30,6 +30,13 @@ const setWideViewport = () => { }); }; +const setCompactViewport = () => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 1280, + }); +}; + describe('useWorkloadsControlsState', () => { beforeEach(() => { localStorage.clear(); @@ -38,6 +45,53 @@ describe('useWorkloadsControlsState', () => { setWideViewport(); }); + it('lets an explicit toggle pin a layout-hidden column into the compact table', () => { + setCompactViewport(); + createRoot((dispose) => { + try { + const [showFilters, setShowFilters] = createSignal(false); + const state = useWorkloadsControlsState({ + viewMode: () => 'all' as ViewMode, + showFilters, + setShowFilters, + }); + const menu = state.workloadsFilterColumnVisibility(); + + // netIo is layout-gated to the wide breakpoint: invisible at 1280px + // and reported hidden by the menu even though the user never hid it. + expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo'); + expect(menu.isColumnHidden('netIo')).toBe(true); + + // First toggle pins it into view instead of flipping a flag the user + // cannot see the effect of. + menu.onColumnToggle('netIo'); + expect(state.workloadTableVisibleColumnIds()).toContain('netIo'); + expect(menu.isColumnHidden('netIo')).toBe(false); + expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo'); + + // Second toggle unpins it; the column returns to its layout default + // rather than becoming user-hidden on wide viewports too. + menu.onColumnToggle('netIo'); + expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo'); + expect(state.columnVisibility.hiddenColumns()).not.toContain('netIo'); + + // Layout-visible columns keep the plain hide/show semantics. + menu.onColumnToggle('backup'); + expect(state.columnVisibility.hiddenColumns()).toContain('backup'); + menu.onColumnToggle('backup'); + expect(state.columnVisibility.hiddenColumns()).not.toContain('backup'); + + // Reset clears pinned columns along with user-hidden ones. + menu.onColumnToggle('netIo'); + expect(state.workloadTableVisibleColumnIds()).toContain('netIo'); + menu.onColumnReset(); + expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo'); + } finally { + dispose(); + } + }); + }); + it('lets Docker scope use a container-native column profile without hiding disk globally', () => { createRoot((dispose) => { try { diff --git a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts index f11858e731..1b7451b8e4 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts @@ -194,10 +194,52 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions ['aiContext'], ); - const visibleColumns = columnVisibility.visibleColumns; - const workloadTableVisibleColumns = createMemo(() => - getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode()), + // Columns the user explicitly pinned into view while the responsive layout + // had them gated off. Without this, the columns menu offered toggles (IP, + // Tags, Net I/O, ...) that did nothing below the wide breakpoint, and + // sub-1440px viewports had no path to data v5 reached via horizontal + // scroll. Explicit intent beats the breakpoint default; the table wrapper + // scrolls horizontally when the pinned columns no longer fit. + const [forcedColumnIds, setForcedColumnIds] = usePersistentSignal( + `${columnStorageKey}:forced`, + [], + { + serialize: (arr) => JSON.stringify(arr), + deserialize: (str) => { + try { + const parsed: unknown = JSON.parse(str); + if (!Array.isArray(parsed)) return []; + return parsed.filter((value): value is string => typeof value === 'string'); + } catch { + return []; + } + }, + }, ); + const forcedColumnIdSet = createMemo(() => new Set(forcedColumnIds())); + const layoutHiddenColumnIds = createMemo(() => { + const layoutVisible = new Set( + getWorkloadVisibleColumnsForLayout(workloadColumns, workloadTableLayoutMode()).map( + (column) => column.id, + ), + ); + return new Set( + workloadColumns.filter((column) => !layoutVisible.has(column.id)).map((c) => c.id), + ); + }); + + const visibleColumns = columnVisibility.visibleColumns; + const workloadTableVisibleColumns = createMemo(() => { + const layoutVisible = new Set( + getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode()).map( + (column) => column.id, + ), + ); + const forced = forcedColumnIdSet(); + return visibleColumns().filter( + (column) => layoutVisible.has(column.id) || forced.has(column.id), + ); + }); const workloadTableVisibleColumnIds = createMemo(() => workloadTableVisibleColumns().map((column) => column.id), ); @@ -277,11 +319,40 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions } }; + // Menu checkbox state mirrors what the table actually renders: a column the + // layout gates off reads as hidden even when the user never hid it. Toggling + // such a column pins it into view (or unpins it) instead of flipping a + // user-hidden flag the user cannot see the effect of. + const isColumnHiddenForMenu = (id: string): boolean => + columnVisibility.isHiddenByUser(id) || + (layoutHiddenColumnIds().has(id) && !forcedColumnIdSet().has(id)); + + const handleColumnToggle = (id: string): void => { + if (!layoutHiddenColumnIds().has(id)) { + setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id)); + columnVisibility.toggle(id); + return; + } + if (forcedColumnIdSet().has(id)) { + setForcedColumnIds(forcedColumnIds().filter((existing) => existing !== id)); + return; + } + setForcedColumnIds([...forcedColumnIds(), id]); + if (columnVisibility.isHiddenByUser(id)) { + columnVisibility.toggle(id); + } + }; + + const handleColumnReset = (): void => { + setForcedColumnIds([]); + columnVisibility.resetToDefaults(); + }; + const workloadsFilterColumnVisibility = createMemo(() => ({ availableColumns: columnVisibility.availableToggles(), - isColumnHidden: columnVisibility.isHiddenByUser, - onColumnToggle: columnVisibility.toggle, - onColumnReset: columnVisibility.resetToDefaults, + isColumnHidden: isColumnHiddenForMenu, + onColumnToggle: handleColumnToggle, + onColumnReset: handleColumnReset, })); return {