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.
This commit is contained in:
rcourtman
2026-06-11 19:16:57 +01:00
parent 955c750019
commit 2547255aea
2 changed files with 131 additions and 6 deletions
@@ -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 {
@@ -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<string[]>(
`${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 {