mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Add manual workload column sizing
Refs #1746 Change-source: pulse-maintainer
This commit is contained in:
parent
3414a5abda
commit
5a78231607
30 changed files with 1635 additions and 42 deletions
|
|
@ -182,10 +182,14 @@ discarding existing explicit disk exclusions.
|
|||
|
||||
`frontend-modern/src/utils/localStorage.ts` is a shared browser-preference key
|
||||
registry, not deployment state. Workload presentation preferences added there,
|
||||
including the Proxmox guest-memory comparison basis, must remain optional,
|
||||
client-local, and backwards-compatible: a missing or invalid stored value
|
||||
falls back to the shipped guest-allocation view and cannot affect install,
|
||||
upgrade, update, release, or artifact-selection behavior.
|
||||
including the Proxmox guest-memory comparison basis and per-surface workload
|
||||
column widths, must remain optional, client-local, and backwards-compatible. A
|
||||
missing or invalid stored memory basis falls back to the shipped guest-allocation
|
||||
view; missing or invalid column widths fall back to the responsive table layout.
|
||||
The shareable `cols` query parameter may temporarily override the viewer's
|
||||
column layout, but it must not rewrite their stored preference. None of these
|
||||
presentation values can affect install, upgrade, update, release, or
|
||||
artifact-selection behaviour.
|
||||
|
||||
1. `frontend-modern/src/api/updates.ts` shared with `api-contracts`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
The version payload consumed by this client must preserve the distinction
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ stacks. The Proxmox page owns and persists the `Guest` / `Host` memory basis;
|
|||
the workload state, table, panel, and row contracts carry the selected basis
|
||||
and resolved parent-node data to the canonical memory bar, and the memory
|
||||
column header must expose the non-default Host basis after the control closes.
|
||||
The shared `ColumnPicker` may also expose `Reset widths` when an owning table
|
||||
has active manual column sizing. That action is separate from restoring column
|
||||
visibility defaults: feature state owns the width reset callback and active
|
||||
flag, while the shared picker owns the discoverable menu placement and button
|
||||
presentation. Tables without manual sizing omit both properties and retain the
|
||||
existing picker unchanged.
|
||||
|
||||
Feature-owned scope controls that use the shared filter rail must keep their
|
||||
state in the owning route and use stable, domain-authored option identities.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ start a goroutine, timer, or notification lifecycle per target.
|
|||
13. `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
|
||||
14. `frontend-modern/src/hooks/useWorkloads.ts`
|
||||
15. `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts`
|
||||
15a. `frontend-modern/src/components/Workloads/workloadColumnWidths.ts`
|
||||
15b. `frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts`
|
||||
16. `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`
|
||||
17. `frontend-modern/src/components/Workloads/useWorkloadGuestMetadataState.ts`
|
||||
18. `frontend-modern/src/components/Workloads/useWorkloadSelectionState.ts`
|
||||
|
|
@ -745,6 +747,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
|||
duplicate hot-path type matching.
|
||||
16. Extend grouped workload derivation, summary fallbacks, and grouped/windowed table presentation through `frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`, extend viewport-driven grouped table synchronization through `frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts`, and extend node parent mapping through `frontend-modern/src/components/Workloads/workloadTopology.ts`, rather than rebuilding grouped selectors, summary snapshot math, scroll listeners, or topology lookups inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
|
||||
17. Extend workload control defaults, persistent view preferences, keyboard reset behavior, column-visibility ownership, and tag-search flow through `frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts` and `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` rather than rebuilding sort/search/grouping state, reset drift, or column-toggle plumbing inside `frontend-modern/src/components/Workloads/useWorkloadsState.ts`
|
||||
Manual workload column sizing stays dormant until an operator drags a
|
||||
header edge at tablet, compact, or wide layout. The first drag snapshots
|
||||
only the rendered header widths, seeds newly revealed selected columns
|
||||
from their canonical definitions, and publishes one bounded width map at
|
||||
pointer release; it must not measure rows, write storage per pointer move,
|
||||
add global pointer listeners, or change the default responsive
|
||||
no-horizontal-scroll path. Persisted widths are scoped with the owning
|
||||
column-visibility preference. A validated `cols` route parameter may own a
|
||||
shareable ordered layout without overwriting that viewer preference;
|
||||
unknown ids, invalid widths, oversized lists, and phone/mobile layouts must
|
||||
fail closed to the ordinary responsive table.
|
||||
A platform-owned memory comparison preference may be supplied as an
|
||||
accessor to `useWorkloadsState`. Host-relative sorting must reuse the
|
||||
bounded parent-node map from `workloadTopology.ts`; it must not scan the
|
||||
|
|
@ -2486,6 +2499,13 @@ the canonical owners for desktop and mobile workload column sizing. Global CSS
|
|||
must not reintroduce competing `.workload-table [data-workload-col=…]` width
|
||||
rules or `min-width: max-content` fallbacks that can blow the table out
|
||||
horizontally on Firefox or other desktop browsers.
|
||||
Operator-pinned widths remain part of that same contract: the header owns the
|
||||
pointer gesture, `workloadColumnWidths.ts` owns clamping, snapshots and totals,
|
||||
`workloadColumnLayoutUrl.ts` owns the bounded URL projection, and
|
||||
`useWorkloadsControlsState.ts` owns persistence plus reset. Manual mode must
|
||||
publish the exact sum of visible pinned widths to the existing table scroll
|
||||
shell, while narrow, phone, and mobile layouts ignore both stored and linked
|
||||
widths and expose no resize handles.
|
||||
The mixed Workloads `Info`/compact `ID` column must also remain sortable by the
|
||||
exact scalar rendered for each workload kind. `guestRowModel.tsx` owns that
|
||||
projection and `workloadSelectors.ts` owns its natural ordering, including
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ build path.
|
|||
- Platform search and row visibility share the same predicates, including
|
||||
Proxmox nodes and their visible guests. Navigation tabs remain stable across
|
||||
WebSocket updates instead of remounting with live state.
|
||||
- Workload table columns can be resized at tablet and desktop widths. A resized
|
||||
layout keeps every selected column reachable with horizontal scrolling,
|
||||
persists per surface, and can be shared through the page URL or reset from
|
||||
the Columns menu without changing the compact default layout.
|
||||
- Route-level code splitting remains effective on cold start: the frontend no
|
||||
longer modulepreloads every lazy route chunk, while integrity coverage stays
|
||||
enforced for dynamically loaded assets.
|
||||
|
|
|
|||
|
|
@ -1,19 +1,51 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "08b8144fcff526519a8b7d7706dcc044bebf170c",
|
||||
"verified_at": "2026-08-26T11:44:58Z",
|
||||
"base_sha": "3414a5abdad055f99192d44fc4d11becc6d73d7a",
|
||||
"verified_at": "2026-08-26T12:08:15Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts"
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadColumnWidths.ts",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
|
||||
"frontend-modern/src/components/shared/ColumnPicker.tsx",
|
||||
"frontend-modern/src/components/shared/columnPickerModel.ts",
|
||||
"frontend-modern/src/components/shared/useColumnPickerState.ts",
|
||||
"frontend-modern/src/index.css",
|
||||
"frontend-modern/src/utils/localStorage.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "63066dea7134226ea5348be26147fd433e56225b429b88854825410dba80111a"
|
||||
"frontend-modern/src/components/Workloads/GuestRow.tsx": "ca36c0adf46aacefe7f2d40c54fbb682077095e8754b6d6c3322ca73ecdfd310",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "867aaa1c71471385f3a26d4aeb535e063f33fe32402d44e2695933ee45663416",
|
||||
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "6a3b4e9e7efd702d1f9a9a549865808a1cfb80a259cfb65130beb8779655cc76",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "564caa9752878f3b416e0953a895ed2113b93090438da64f6c32524336c45c79",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsSurface.tsx": "73e31b9b3c4ac4d28687b71c2f029ae4605c5697ddec19d604e4706c0184cebd",
|
||||
"frontend-modern/src/components/Workloads/WorkloadsTable.tsx": "955090567fd1302828d7e156e04b43e2c2841ac71f50ee5cd6dac8f3eabece50",
|
||||
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "fb6324c436f3b160d4655fb0d758956ed856c0b811a07c49bdf1a49d7375bb21",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "e40a0a099092a7578f39b433b989c61219a01351f41009ea8fed6ab279f9ea78",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "9a6fd576040ec74f7e45d9af1622a0fad401e18c249ad93449dfa7e808b8250e",
|
||||
"frontend-modern/src/components/Workloads/workloadColumnLayoutUrl.ts": "6df93e2f9ca5b7db0d2230cb4786700fe337cf6974630fa1f334e755fa63cc40",
|
||||
"frontend-modern/src/components/Workloads/workloadColumnWidths.ts": "bb8db7a4891ec714e0ec7c42c821f6106f4a2e52f38bc885b47954d23873eb8a",
|
||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "fe413753be3f22165e991630598f89952dfee65f2bdb8d4012e181d308b6f057",
|
||||
"frontend-modern/src/components/shared/ColumnPicker.tsx": "93570c648a3fd2f8b085019a868715f00e0cb2bf41cdcb89c1fdcc6dafd468b0",
|
||||
"frontend-modern/src/components/shared/columnPickerModel.ts": "374a27ad36f65531df322f424e8df813260df4d7ad0595b7afced661fcc1aa23",
|
||||
"frontend-modern/src/components/shared/useColumnPickerState.ts": "548bb62622c4e81728435a3c93536df58d2664afc6bbc3b0a2576f30bbdd3ba6",
|
||||
"frontend-modern/src/index.css": "7b28bd7056b4f846a9c71cc17f3fdef1f9dfaa2edb4491c91181ebd475129710",
|
||||
"frontend-modern/src/utils/localStorage.ts": "bd1b11bf5aa0fbf216faf1c7df3bc1b6e6238c607c7ea46e65a69a876dabec60"
|
||||
},
|
||||
"routes": ["/docker/overview"],
|
||||
"routes": ["/proxmox/guests"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1230,
|
||||
"height": 1234
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
|
|
@ -21,13 +53,17 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"explicit Docker host History at 24 hours with utilization, network I/O, disk I/O, and thermal series populated",
|
||||
"explicit Docker host History at 7 days with the same canonical host chart catalog populated",
|
||||
"narrow explicit Docker host History with the complete host chart catalog and no collecting placeholders"
|
||||
"desktop responsive default with eight fitted columns and no horizontal overflow",
|
||||
"desktop manual layout with all eleven selected columns and horizontal overflow",
|
||||
"clean shared-link restoration with URL-owned widths and no stored preference",
|
||||
"column picker explaining the active manual layout and offering Reset widths",
|
||||
"narrow shared-link fallback with five responsive columns, no resize handles, and no page overflow"
|
||||
],
|
||||
"interactions": [
|
||||
"expanded auth-service-01 from the Docker host table and selected History; confirmed Utilization, Network I/O, Disk I/O, and Thermals with zero Collecting history placeholders",
|
||||
"switched the host History selector to 7 days and back to 24 hours; all four canonical host groups remained populated",
|
||||
"rechecked auth-service-01 at 390 by 844 and scrolled through Thermals; charts, range control, table rows, and page navigation remained visible and usable"
|
||||
"dragged the Name header edge from 324 to 172 pixels; confirmed the cols URL, stored widths, eleven resize handles, and a 1420-pixel table inside a 1173-pixel scroll shell",
|
||||
"cleared the workload-width preference and reloaded the shared cols URL; confirmed the 172-pixel Name width and complete manual layout without adopting the URL as a stored preference",
|
||||
"opened View then Columns and selected Reset widths; confirmed the clean route, 324-pixel responsive Name width, eight handles, and no horizontal overflow",
|
||||
"loaded the same shared cols URL at 390 by 844; confirmed the responsive five-column table, zero resize handles, and a document width equal to the viewport",
|
||||
"inspected the browser console after desktop and narrow checks; no errors were reported"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,6 +233,14 @@ describe('App architecture', () => {
|
|||
expect(appStylesSource).not.toContain('white-space: normal;\n -webkit-box-orient');
|
||||
});
|
||||
|
||||
it('keeps manual workload widths inside the existing horizontal table shell', () => {
|
||||
expect(appStylesSource).toContain('.table-scroll-shell > table.workload-table--manual-widths');
|
||||
expect(appStylesSource).toContain('.workload-col-resizer');
|
||||
expect(appStylesSource).toContain('cursor: col-resize');
|
||||
expect(appStylesSource).toContain('touch-action: none');
|
||||
expect(appStylesSource).toContain('@media (prefers-reduced-motion: reduce)');
|
||||
});
|
||||
|
||||
it('keeps native form and browser autofill paint on semantic theme tokens', () => {
|
||||
expect(appStylesSource).toContain('color-scheme: light');
|
||||
expect(appStylesSource).toContain('color-scheme: dark');
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ export function GuestRow(props: GuestRowProps) {
|
|||
isMobile(),
|
||||
props.workloadTableLayoutMode,
|
||||
props.visibleColumnIds,
|
||||
props.columnWidths,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ type WorkloadPanelProps = Pick<
|
|||
| 'workloadMetricHistory'
|
||||
| 'workloadTableLayoutMode'
|
||||
| 'workloadTableVisibleColumnIds'
|
||||
| 'workloadColumnWidths'
|
||||
| 'workloadTableVisibleColumns'
|
||||
>;
|
||||
|
||||
|
|
@ -579,6 +580,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
|||
onCustomUrlUpdate={props.handleCustomUrlUpdate}
|
||||
isGroupedView={props.groupingMode() === 'grouped'}
|
||||
visibleColumnIds={props.workloadTableVisibleColumnIds()}
|
||||
columnWidths={props.workloadColumnWidths()}
|
||||
workloadTableLayoutMode={props.workloadTableLayoutMode()}
|
||||
onClick={() =>
|
||||
props.setSelectedGuestId(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For } from 'solid-js';
|
||||
import { For, Show } from 'solid-js';
|
||||
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/shared/Table';
|
||||
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import type { PlatformTableCellAlign } from '@/features/platformPage/sharedPlatformPage';
|
||||
|
||||
import { getGuestColumnStyle } from './guestRowModel';
|
||||
import { shouldEngageColumnResize, WORKLOAD_COLUMN_MIN_WIDTH } from './workloadColumnWidths';
|
||||
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
|
||||
import type { WorkloadsState, WorkloadSortKey } from './useWorkloadsState';
|
||||
|
||||
|
|
@ -23,6 +24,13 @@ type WorkloadTableHeaderProps = Pick<
|
|||
| 'workloadTableLayoutMode'
|
||||
| 'workloadTableVisibleColumnIds'
|
||||
| 'workloadTableVisibleColumns'
|
||||
| 'workloadColumnWidths'
|
||||
| 'workloadManualColumnSizingSupported'
|
||||
| 'beginWorkloadColumnResize'
|
||||
| 'previewWorkloadColumnWidth'
|
||||
| 'commitWorkloadColumnResize'
|
||||
| 'cancelWorkloadColumnResize'
|
||||
| 'clearWorkloadColumnWidth'
|
||||
>;
|
||||
|
||||
export const getWorkloadColumnHeaderLabel = (
|
||||
|
|
@ -72,6 +80,17 @@ const resolveAlignClasses = (
|
|||
return { textAlign: 'text-left', flexJustify: 'justify-start' };
|
||||
};
|
||||
|
||||
const measureRenderedColumnWidths = (headerCell: HTMLElement): Record<string, number> => {
|
||||
const widths: Record<string, number> = {};
|
||||
const row = headerCell.closest('tr');
|
||||
if (!row) return widths;
|
||||
for (const cell of Array.from(row.querySelectorAll<HTMLElement>('th[data-workload-col]'))) {
|
||||
const columnId = cell.dataset.workloadCol;
|
||||
if (columnId) widths[columnId] = cell.getBoundingClientRect().width;
|
||||
}
|
||||
return widths;
|
||||
};
|
||||
|
||||
export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
|
||||
return (
|
||||
<TableHeader>
|
||||
|
|
@ -95,10 +114,67 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
|
|||
props.workloadMemoryDisplayBasis(),
|
||||
usesCompactHeader(),
|
||||
);
|
||||
const isResizable = () => props.workloadManualColumnSizingSupported();
|
||||
let headerCell: HTMLTableCellElement | undefined;
|
||||
let dragPointerId: number | null = null;
|
||||
let dragStartX = 0;
|
||||
let dragStartWidth = 0;
|
||||
let dragMeasuredWidths: Record<string, number> = {};
|
||||
let dragEngaged = false;
|
||||
|
||||
const endDrag = (handle: HTMLElement): void => {
|
||||
if (dragPointerId === null) return;
|
||||
if (handle.hasPointerCapture(dragPointerId))
|
||||
handle.releasePointerCapture(dragPointerId);
|
||||
dragPointerId = null;
|
||||
};
|
||||
const onPointerDown = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
|
||||
if (!isResizable() || event.button !== 0 || !headerCell) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dragPointerId = event.pointerId;
|
||||
dragStartX = event.clientX;
|
||||
dragStartWidth = headerCell.getBoundingClientRect().width;
|
||||
dragMeasuredWidths = measureRenderedColumnWidths(headerCell);
|
||||
dragEngaged = false;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
|
||||
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
|
||||
const delta = event.clientX - dragStartX;
|
||||
if (!dragEngaged) {
|
||||
if (!shouldEngageColumnResize(delta, false)) return;
|
||||
dragEngaged = true;
|
||||
props.beginWorkloadColumnResize(dragMeasuredWidths);
|
||||
}
|
||||
event.preventDefault();
|
||||
props.previewWorkloadColumnWidth(
|
||||
col.id,
|
||||
Math.max(WORKLOAD_COLUMN_MIN_WIDTH, dragStartWidth + delta),
|
||||
);
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent & { currentTarget: HTMLElement }): void => {
|
||||
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const engaged = dragEngaged;
|
||||
dragEngaged = false;
|
||||
endDrag(event.currentTarget);
|
||||
if (engaged) props.commitWorkloadColumnResize();
|
||||
};
|
||||
const onPointerCancel = (
|
||||
event: PointerEvent & { currentTarget: HTMLElement },
|
||||
): void => {
|
||||
if (dragPointerId === null || event.pointerId !== dragPointerId) return;
|
||||
dragEngaged = false;
|
||||
endDrag(event.currentTarget);
|
||||
props.cancelWorkloadColumnResize();
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
class={`py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
|
||||
ref={headerCell}
|
||||
class={`relative py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
|
||||
${isFirst() ? 'pl-2 sm:pl-3 pr-1.5 sm:pr-2' : 'px-1.5 sm:px-2'} ${alignClasses().textAlign} align-middle
|
||||
${isSortable ? 'cursor-pointer hover:bg-surface-hover' : ''}`}
|
||||
data-workload-col={col.id}
|
||||
|
|
@ -107,6 +183,7 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
|
|||
props.isMobile(),
|
||||
props.workloadTableLayoutMode(),
|
||||
props.workloadTableVisibleColumnIds(),
|
||||
props.workloadColumnWidths(),
|
||||
)}
|
||||
onClick={() => isSortable && props.handleSort(sortKeyForCol!)}
|
||||
title={
|
||||
|
|
@ -130,6 +207,25 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
|
|||
)}
|
||||
{getTableSortIndicator(Boolean(isSorted()), props.sortDirection())}
|
||||
</div>
|
||||
<Show when={isResizable()}>
|
||||
<span
|
||||
class="workload-col-resizer"
|
||||
data-workload-col-resizer={col.id}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
title={`Drag to resize · double-click to reset ${col.label}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onDblClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
props.clearWorkloadColumnWidth(col.id);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Show>
|
||||
</TableHead>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -418,6 +418,8 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
|
|||
onToggle={visibility().onColumnToggle}
|
||||
onReset={visibility().onColumnReset}
|
||||
showReset={visibility().showReset}
|
||||
onResetWidths={visibility().onColumnWidthsReset}
|
||||
hasManualWidths={visibility().hasColumnWidthOverrides}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -231,6 +231,15 @@ export function WorkloadsSurface(props: WorkloadsSurfaceComponentProps) {
|
|||
workloadTableLayoutMode={state.workloadTableLayoutMode}
|
||||
workloadTableVisibleColumnIds={state.workloadTableVisibleColumnIds}
|
||||
workloadTableVisibleColumns={state.workloadTableVisibleColumns}
|
||||
workloadColumnWidths={state.workloadColumnWidths}
|
||||
workloadManualColumnSizing={state.workloadManualColumnSizing}
|
||||
workloadManualColumnSizingSupported={state.workloadManualColumnSizingSupported}
|
||||
workloadTableManualWidth={state.workloadTableManualWidth}
|
||||
beginWorkloadColumnResize={state.beginWorkloadColumnResize}
|
||||
previewWorkloadColumnWidth={state.previewWorkloadColumnWidth}
|
||||
commitWorkloadColumnResize={state.commitWorkloadColumnResize}
|
||||
cancelWorkloadColumnResize={state.cancelWorkloadColumnResize}
|
||||
clearWorkloadColumnWidth={state.clearWorkloadColumnWidth}
|
||||
title={props.tableTitle}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,15 @@ type WorkloadsTableProps = Pick<
|
|||
| 'workloadTableLayoutMode'
|
||||
| 'workloadTableVisibleColumnIds'
|
||||
| 'workloadTableVisibleColumns'
|
||||
| 'workloadColumnWidths'
|
||||
| 'workloadManualColumnSizing'
|
||||
| 'workloadManualColumnSizingSupported'
|
||||
| 'workloadTableManualWidth'
|
||||
| 'beginWorkloadColumnResize'
|
||||
| 'previewWorkloadColumnWidth'
|
||||
| 'commitWorkloadColumnResize'
|
||||
| 'cancelWorkloadColumnResize'
|
||||
| 'clearWorkloadColumnWidth'
|
||||
> & {
|
||||
title?: JSX.Element;
|
||||
};
|
||||
|
|
@ -77,7 +86,12 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
<TableCardHeader title={props.title} />
|
||||
<Table
|
||||
wrapperRef={props.setTableWrapperRef}
|
||||
class={`platform-table workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}`}
|
||||
class={`platform-table workload-table table-fixed ${props.isMobile() ? `workload-table--mobile ${WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS}` : 'workload-table--desktop min-w-full'}${props.workloadManualColumnSizing() ? ' workload-table--manual-widths' : ''}`}
|
||||
style={
|
||||
props.workloadTableManualWidth() === null
|
||||
? undefined
|
||||
: { width: `${props.workloadTableManualWidth()}px` }
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
<For each={props.workloadTableVisibleColumns()}>
|
||||
|
|
@ -89,6 +103,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
props.isMobile(),
|
||||
props.workloadTableLayoutMode(),
|
||||
props.workloadTableVisibleColumnIds(),
|
||||
props.workloadColumnWidths(),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -104,6 +119,13 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
workloadTableLayoutMode={props.workloadTableLayoutMode}
|
||||
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
|
||||
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
|
||||
workloadColumnWidths={props.workloadColumnWidths}
|
||||
workloadManualColumnSizingSupported={props.workloadManualColumnSizingSupported}
|
||||
beginWorkloadColumnResize={props.beginWorkloadColumnResize}
|
||||
previewWorkloadColumnWidth={props.previewWorkloadColumnWidth}
|
||||
commitWorkloadColumnResize={props.commitWorkloadColumnResize}
|
||||
cancelWorkloadColumnResize={props.cancelWorkloadColumnResize}
|
||||
clearWorkloadColumnWidth={props.clearWorkloadColumnWidth}
|
||||
/>
|
||||
<WorkloadPanel
|
||||
activeAlerts={props.activeAlerts}
|
||||
|
|
@ -147,6 +169,7 @@ export function WorkloadsTable(props: WorkloadsTableProps) {
|
|||
workloadTableLayoutMode={props.workloadTableLayoutMode}
|
||||
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
|
||||
workloadTableVisibleColumns={props.workloadTableVisibleColumns}
|
||||
workloadColumnWidths={props.workloadColumnWidths}
|
||||
/>
|
||||
</Table>
|
||||
</TableCard>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import workloadFilterConfigModelSource from '../workloadFilterConfigModel.ts?raw
|
|||
import workloadRouteModelSource from '../workloadRouteModel.ts?raw';
|
||||
import workloadRouteStateModelSource from '../workloadRouteStateModel.ts?raw';
|
||||
import workloadUrlSyncModelSource from '../workloadUrlSyncModel.ts?raw';
|
||||
import workloadColumnLayoutUrlSource from '../workloadColumnLayoutUrl.ts?raw';
|
||||
import workloadColumnWidthsSource from '../workloadColumnWidths.ts?raw';
|
||||
import workloadsWorkloadViewportSyncSource from '../useWorkloadViewportSync.ts?raw';
|
||||
import workloadsWorkloadRouteStateSource from '../useWorkloadRouteState.ts?raw';
|
||||
import workloadsWorkloadUrlSyncSource from '../useWorkloadUrlSync.ts?raw';
|
||||
|
|
@ -1646,6 +1648,8 @@ describe('Workloads performance contract', () => {
|
|||
expect(workloadsWorkloadTableSource).toContain('WORKLOAD_TABLE_MOBILE_MIN_WIDTH_CLASS');
|
||||
expect(workloadsWorkloadTableSource).toContain('props.workloadTableLayoutMode()');
|
||||
expect(workloadsWorkloadTableSource).toContain('props.workloadTableVisibleColumnIds()');
|
||||
expect(workloadsWorkloadTableSource).toContain('workload-table--manual-widths');
|
||||
expect(workloadsWorkloadTableSource).toContain('props.workloadTableManualWidth()');
|
||||
expect(workloadsWorkloadTableSource).toContain('<colgroup>');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('<TableHead');
|
||||
expect(workloadsWorkloadTableSource).not.toContain('NodeGroupHeader');
|
||||
|
|
@ -1663,6 +1667,21 @@ describe('Workloads performance contract', () => {
|
|||
expect(workloadTableHeaderSource).toContain('props.workloadTableVisibleColumnIds()');
|
||||
expect(workloadTableHeaderSource).toContain('aria-hidden="true"');
|
||||
expect(workloadTableHeaderSource).toContain('<span class="sr-only">{label()}</span>');
|
||||
expect(workloadTableHeaderSource).toContain('data-workload-col-resizer={col.id}');
|
||||
expect(workloadTableHeaderSource).toContain('event.currentTarget.setPointerCapture');
|
||||
expect(workloadTableHeaderSource).toContain('onPointerCancel={onPointerCancel}');
|
||||
expect(workloadColumnWidthsSource).toContain(
|
||||
'const MANUAL_SIZING_LAYOUT_MODES: ReadonlySet<WorkloadTableLayoutMode>',
|
||||
);
|
||||
expect(workloadColumnWidthsSource).toContain("'tablet'");
|
||||
expect(workloadColumnWidthsSource).toContain("'compact'");
|
||||
expect(workloadColumnWidthsSource).toContain("'wide'");
|
||||
expect(workloadColumnWidthsSource).not.toContain("'phone'");
|
||||
expect(workloadColumnWidthsSource).toContain('sumWorkloadColumnWidths');
|
||||
expect(workloadColumnLayoutUrlSource).toContain(
|
||||
"export const WORKLOAD_COLUMNS_URL_PARAM = 'cols'",
|
||||
);
|
||||
expect(workloadColumnLayoutUrlSource).toContain('resolveWorkloadColumnLayoutEntries');
|
||||
expect(workloadTableHeaderSource).not.toContain('style={{');
|
||||
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
|
||||
expect(workloadPanelSource).toContain('NodeGroupHeader');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
|
||||
import {
|
||||
GUEST_COLUMNS,
|
||||
getGuestColumnStyle,
|
||||
getGuestColumnWidthStyle,
|
||||
getWorkloadVisibleColumnsForLayout,
|
||||
} from '../guestRowModel';
|
||||
|
||||
const columnsFor = (ids: readonly string[]): ColumnDef[] =>
|
||||
ids.map((id) => GUEST_COLUMNS.find((column) => column.id === id)!).filter(Boolean);
|
||||
|
||||
// The wide-only trio is what operators lose first on a laptop-width table.
|
||||
const SELECTED_COLUMN_IDS = [
|
||||
'name',
|
||||
'type',
|
||||
'info',
|
||||
'cpu',
|
||||
'memory',
|
||||
'disk',
|
||||
'uptime',
|
||||
'tags',
|
||||
'netIo',
|
||||
'diskIo',
|
||||
];
|
||||
|
||||
describe('workload column manual sizing', () => {
|
||||
describe('getWorkloadVisibleColumnsForLayout', () => {
|
||||
it('drops wide-only columns on a compact table by default', () => {
|
||||
const visible = getWorkloadVisibleColumnsForLayout(
|
||||
columnsFor(SELECTED_COLUMN_IDS),
|
||||
'compact',
|
||||
).map((column) => column.id);
|
||||
|
||||
expect(visible).not.toContain('netIo');
|
||||
expect(visible).not.toContain('diskIo');
|
||||
expect(visible).not.toContain('tags');
|
||||
expect(visible).toContain('cpu');
|
||||
});
|
||||
|
||||
it('keeps every selected column once the operator has pinned a width', () => {
|
||||
const visible = getWorkloadVisibleColumnsForLayout(
|
||||
columnsFor(SELECTED_COLUMN_IDS),
|
||||
'compact',
|
||||
{ manualSizing: true },
|
||||
).map((column) => column.id);
|
||||
|
||||
expect(visible).toEqual(SELECTED_COLUMN_IDS);
|
||||
});
|
||||
|
||||
it('still drops wide-only columns on touch layouts even with widths persisted', () => {
|
||||
for (const layoutMode of ['narrow', 'phone', 'mobile'] as const) {
|
||||
const visible = getWorkloadVisibleColumnsForLayout(
|
||||
columnsFor(SELECTED_COLUMN_IDS),
|
||||
layoutMode,
|
||||
{ manualSizing: true },
|
||||
).map((column) => column.id);
|
||||
|
||||
expect(visible, layoutMode).not.toContain('netIo');
|
||||
}
|
||||
});
|
||||
|
||||
it('is unchanged when manualSizing is absent or false', () => {
|
||||
const base = getWorkloadVisibleColumnsForLayout(columnsFor(SELECTED_COLUMN_IDS), 'compact');
|
||||
const explicitlyOff = getWorkloadVisibleColumnsForLayout(
|
||||
columnsFor(SELECTED_COLUMN_IDS),
|
||||
'compact',
|
||||
{ manualSizing: false },
|
||||
);
|
||||
|
||||
expect(explicitlyOff.map((column) => column.id)).toEqual(base.map((column) => column.id));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestColumnStyle', () => {
|
||||
it('leaves the responsive weights untouched when nothing is pinned', () => {
|
||||
const withoutOverrides = getGuestColumnStyle('name', false, 'compact', SELECTED_COLUMN_IDS);
|
||||
const withEmptyOverrides = getGuestColumnStyle(
|
||||
'name',
|
||||
false,
|
||||
'compact',
|
||||
SELECTED_COLUMN_IDS,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(withEmptyOverrides).toEqual(withoutOverrides);
|
||||
expect(String(withoutOverrides?.width)).toContain('%');
|
||||
});
|
||||
|
||||
it('pins width, min-width and max-width together so table-fixed cannot redistribute', () => {
|
||||
const style = getGuestColumnStyle('name', false, 'compact', SELECTED_COLUMN_IDS, {
|
||||
name: 240,
|
||||
});
|
||||
|
||||
expect(style).toMatchObject({
|
||||
width: '240px',
|
||||
'min-width': '240px',
|
||||
'max-width': '240px',
|
||||
});
|
||||
});
|
||||
|
||||
it('only overrides the pinned column', () => {
|
||||
const overrides = { name: 240 };
|
||||
const cpu = getGuestColumnStyle('cpu', false, 'compact', SELECTED_COLUMN_IDS, overrides);
|
||||
|
||||
expect(String(cpu?.width)).toContain('%');
|
||||
});
|
||||
|
||||
it('feeds the same pinned width to the colgroup', () => {
|
||||
expect(
|
||||
getGuestColumnWidthStyle('netIo', false, 'compact', SELECTED_COLUMN_IDS, { netIo: 210 }),
|
||||
).toEqual({ width: '210px' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -455,4 +455,230 @@ describe('useWorkloadsControlsState', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual column sizing', () => {
|
||||
const withState = (run: (state: ReturnType<typeof useWorkloadsControlsState>) => void) =>
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
run(
|
||||
useWorkloadsControlsState({
|
||||
viewMode: () => 'all' as ViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('is inert until a column edge is dragged', () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
expect(state.workloadColumnWidths()).toEqual({});
|
||||
expect(state.workloadTableManualWidth()).toBeNull();
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
});
|
||||
});
|
||||
|
||||
it("reveals the operator's full column selection once a width is pinned", () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 302, cpu: 151 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
expect(state.workloadManualColumnSizing()).toBe(true);
|
||||
expect(state.workloadColumnWidths().name).toBe(172);
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('netIo');
|
||||
expect(state.workloadTableVisibleColumnIds()).toContain('diskIo');
|
||||
});
|
||||
});
|
||||
|
||||
it('pins every rendered column so the published table width is complete', () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 302 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
const widths = state.workloadColumnWidths();
|
||||
for (const columnId of state.workloadTableVisibleColumnIds()) {
|
||||
expect(widths[columnId], columnId).toBeGreaterThan(0);
|
||||
}
|
||||
expect(state.workloadTableManualWidth()).toBe(
|
||||
state.workloadTableVisibleColumnIds().reduce((total, id) => total + widths[id], 0),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('discards an abandoned drag without persisting anything', () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 302 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.cancelWorkloadColumnResize();
|
||||
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
expect(state.workloadColumnWidths()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it('restores one column to its design width rather than leaving it unpinned', () => {
|
||||
// A deleted pin inside an already fully claimed table-fixed table
|
||||
// collapses that column to zero width.
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 302 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
state.clearWorkloadColumnWidth('name');
|
||||
|
||||
expect(state.workloadManualColumnSizing()).toBe(true);
|
||||
expect(state.workloadColumnWidths().name).toBe(200);
|
||||
expect(state.workloadTableManualWidth()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to the responsive layout when the last pin is cleared', () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 302 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
state.resetWorkloadColumnWidths();
|
||||
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
expect(state.workloadColumnWidths()).toEqual({});
|
||||
expect(state.workloadTableManualWidth()).toBeNull();
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
});
|
||||
});
|
||||
|
||||
it('never engages on touch layouts, even with widths already persisted', () => {
|
||||
localStorage.setItem('workloadsColumnWidths', JSON.stringify({ name: 172 }));
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
|
||||
withState((state) => {
|
||||
expect(state.workloadManualColumnSizingSupported()).toBe(false);
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
expect(state.workloadColumnWidths()).toEqual({});
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareable column layout (?cols=)', () => {
|
||||
const withState = (run: (state: ReturnType<typeof useWorkloadsControlsState>) => void) =>
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
const [showFilters, setShowFilters] = createSignal(false);
|
||||
run(
|
||||
useWorkloadsControlsState({
|
||||
viewMode: () => 'all' as ViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
const currentCols = () => new URLSearchParams(mockRouterSearch()).get('cols');
|
||||
|
||||
it('reproduces the linked table on a profile that has never resized anything', () => {
|
||||
setCompactViewport();
|
||||
setMockRouterSearch('?cols=name:172,cpu:151,netIo:170');
|
||||
withState((state) => {
|
||||
expect(localStorage.getItem('workloadsColumnWidths')).toBeNull();
|
||||
expect(state.workloadManualColumnSizing()).toBe(true);
|
||||
expect(state.workloadTableVisibleColumnIds()).toEqual(['name', 'cpu', 'netIo']);
|
||||
expect(state.workloadColumnWidths()).toEqual({ name: 172, cpu: 151, netIo: 170 });
|
||||
});
|
||||
});
|
||||
|
||||
it("lets the link win over the viewer's stored widths", () => {
|
||||
setCompactViewport();
|
||||
localStorage.setItem('workloadsColumnWidths', JSON.stringify({ name: 500, cpu: 500 }));
|
||||
setMockRouterSearch('?cols=name:172,cpu:151');
|
||||
withState((state) => {
|
||||
expect(state.workloadColumnWidths()).toEqual({ name: 172, cpu: 151 });
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores columns the link names that this view cannot render', () => {
|
||||
setCompactViewport();
|
||||
setMockRouterSearch('?cols=name:172,totallyUnknown:120,cpu:151');
|
||||
withState((state) => {
|
||||
expect(state.workloadTableVisibleColumnIds()).toEqual(['name', 'cpu']);
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the layout to the URL when a drag is committed', () => {
|
||||
setCompactViewport();
|
||||
withState((state) => {
|
||||
expect(currentCols()).toBeNull();
|
||||
|
||||
state.beginWorkloadColumnResize({ name: 302 });
|
||||
state.previewWorkloadColumnWidth('name', 172);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
const cols = currentCols();
|
||||
expect(cols).toContain('name:172');
|
||||
expect(cols).toContain('netIo:');
|
||||
});
|
||||
});
|
||||
|
||||
it("edits the link rather than the viewer's saved widths while a link is active", () => {
|
||||
setCompactViewport();
|
||||
setMockRouterSearch('?cols=name:172,cpu:151');
|
||||
withState((state) => {
|
||||
state.beginWorkloadColumnResize({ name: 172, cpu: 151 });
|
||||
state.previewWorkloadColumnWidth('cpu', 260);
|
||||
state.commitWorkloadColumnResize();
|
||||
|
||||
expect(currentCols()).toBe('name:172,cpu:260');
|
||||
expect(localStorage.getItem('workloadsColumnWidths')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('adds and removes columns through the link, leaving the stored preference alone', () => {
|
||||
setCompactViewport();
|
||||
setMockRouterSearch('?cols=name:172,cpu:151');
|
||||
withState((state) => {
|
||||
const before = state.columnVisibility.hiddenColumns().slice();
|
||||
|
||||
state.workloadsFilterColumnVisibility().onColumnToggle('netIo');
|
||||
expect(currentCols()).toBe('name:172,cpu:151,netIo:170');
|
||||
|
||||
state.workloadsFilterColumnVisibility().onColumnToggle('netIo');
|
||||
expect(currentCols()).toBe('name:172,cpu:151');
|
||||
|
||||
expect(state.columnVisibility.hiddenColumns()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the parameter when widths are reset', () => {
|
||||
setCompactViewport();
|
||||
setMockRouterSearch('?cols=name:172,cpu:151');
|
||||
withState((state) => {
|
||||
state.resetWorkloadColumnWidths();
|
||||
expect(currentCols()).toBeNull();
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a linked layout on touch layouts', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
|
||||
setMockRouterSearch('?cols=name:172,cpu:151,netIo:170');
|
||||
withState((state) => {
|
||||
expect(state.workloadManualColumnSizing()).toBe(false);
|
||||
expect(state.workloadTableVisibleColumnIds()).not.toContain('netIo');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkloadColumnLayoutEntries,
|
||||
parseWorkloadColumnLayoutParam,
|
||||
resolveWorkloadColumnLayoutEntries,
|
||||
serializeWorkloadColumnLayout,
|
||||
toggleWorkloadColumnLayoutEntry,
|
||||
workloadColumnLayoutIds,
|
||||
workloadColumnLayoutWidths,
|
||||
WORKLOAD_COLUMNS_URL_PARAM,
|
||||
} from '../workloadColumnLayoutUrl';
|
||||
import { WORKLOAD_COLUMN_MAX_WIDTH, WORKLOAD_COLUMN_MIN_WIDTH } from '../workloadColumnWidths';
|
||||
|
||||
describe('workloadColumnLayoutUrl', () => {
|
||||
it('uses a single compact query parameter', () => {
|
||||
expect(WORKLOAD_COLUMNS_URL_PARAM).toBe('cols');
|
||||
});
|
||||
|
||||
describe('parseWorkloadColumnLayoutParam', () => {
|
||||
it('reads ordered id:width pairs', () => {
|
||||
expect(parseWorkloadColumnLayoutParam('name:172,cpu:151,netIo:170')).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'cpu', width: 151 },
|
||||
{ id: 'netIo', width: 170 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an absent or empty parameter as no layout', () => {
|
||||
expect(parseWorkloadColumnLayoutParam(null)).toEqual([]);
|
||||
expect(parseWorkloadColumnLayoutParam(undefined)).toEqual([]);
|
||||
expect(parseWorkloadColumnLayoutParam('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips malformed pairs rather than rejecting the whole link', () => {
|
||||
expect(
|
||||
parseWorkloadColumnLayoutParam('name:172,broken,cpu:,:80,memory:abc,disk:163'),
|
||||
).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'disk', width: 163 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects ids that are not plain column identifiers', () => {
|
||||
expect(parseWorkloadColumnLayoutParam('<script>:100,na me:100,name:172')).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first entry when a link repeats a column', () => {
|
||||
expect(parseWorkloadColumnLayoutParam('name:172,name:400')).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps widths from a hand-edited link', () => {
|
||||
expect(parseWorkloadColumnLayoutParam('name:1,cpu:99999')).toEqual([
|
||||
{ id: 'name', width: WORKLOAD_COLUMN_MIN_WIDTH },
|
||||
{ id: 'cpu', width: WORKLOAD_COLUMN_MAX_WIDTH },
|
||||
]);
|
||||
});
|
||||
|
||||
it('caps how many columns a link can request', () => {
|
||||
const raw = Array.from({ length: 60 }, (_, index) => `c${index}:100`).join(',');
|
||||
expect(parseWorkloadColumnLayoutParam(raw)).toHaveLength(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeWorkloadColumnLayout', () => {
|
||||
it('round-trips through the parser', () => {
|
||||
const entries = [
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'netIo', width: 170 },
|
||||
];
|
||||
expect(serializeWorkloadColumnLayout(entries)).toBe('name:172,netIo:170');
|
||||
expect(parseWorkloadColumnLayoutParam(serializeWorkloadColumnLayout(entries))).toEqual(
|
||||
entries,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkloadColumnLayoutEntries', () => {
|
||||
it('drops columns this view cannot render but keeps the link order', () => {
|
||||
const entries = [
|
||||
{ id: 'netIo', width: 170 },
|
||||
{ id: 'vsphereOnly', width: 120 },
|
||||
{ id: 'name', width: 172 },
|
||||
];
|
||||
expect(
|
||||
resolveWorkloadColumnLayoutEntries(entries, new Set(['name', 'netIo', 'cpu'])),
|
||||
).toEqual([
|
||||
{ id: 'netIo', width: 170 },
|
||||
{ id: 'name', width: 172 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkloadColumnLayoutEntries', () => {
|
||||
it('emits the rendered order and skips columns with no pinned width', () => {
|
||||
expect(
|
||||
buildWorkloadColumnLayoutEntries(['name', 'cpu', 'memory'], { name: 172, memory: 163 }),
|
||||
).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'memory', width: 163 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workloadColumnLayoutWidths / Ids', () => {
|
||||
it('projects entries for the render path', () => {
|
||||
const entries = [
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'cpu', width: 151 },
|
||||
];
|
||||
expect(workloadColumnLayoutWidths(entries)).toEqual({ name: 172, cpu: 151 });
|
||||
expect(workloadColumnLayoutIds(entries)).toEqual(['name', 'cpu']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleWorkloadColumnLayoutEntry', () => {
|
||||
it('appends a new column at its design width', () => {
|
||||
expect(toggleWorkloadColumnLayoutEntry([{ id: 'name', width: 172 }], 'netIo', 170)).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'netIo', width: 170 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a column that is already in the link', () => {
|
||||
expect(
|
||||
toggleWorkloadColumnLayoutEntry(
|
||||
[
|
||||
{ id: 'name', width: 172 },
|
||||
{ id: 'netIo', width: 170 },
|
||||
],
|
||||
'netIo',
|
||||
170,
|
||||
),
|
||||
).toEqual([{ id: 'name', width: 172 }]);
|
||||
});
|
||||
|
||||
it('refuses to empty the table', () => {
|
||||
expect(toggleWorkloadColumnLayoutEntry([{ id: 'name', width: 172 }], 'name', 200)).toEqual([
|
||||
{ id: 'name', width: 172 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
clampWorkloadColumnWidth,
|
||||
shouldEngageColumnResize,
|
||||
hasWorkloadColumnWidths,
|
||||
isWorkloadManualSizingSupported,
|
||||
normalizeWorkloadColumnWidths,
|
||||
pruneWorkloadColumnWidths,
|
||||
snapshotWorkloadColumnWidths,
|
||||
withWorkloadColumnWidth,
|
||||
withoutWorkloadColumnWidth,
|
||||
seedWorkloadColumnWidthsFromDefaults,
|
||||
sumWorkloadColumnWidths,
|
||||
workloadColumnWidthsStorageKey,
|
||||
WORKLOAD_COLUMN_FALLBACK_WIDTH,
|
||||
WORKLOAD_COLUMN_MAX_WIDTH,
|
||||
WORKLOAD_COLUMN_MIN_WIDTH,
|
||||
} from '../workloadColumnWidths';
|
||||
|
||||
describe('workloadColumnWidths', () => {
|
||||
describe('clampWorkloadColumnWidth', () => {
|
||||
it('keeps a column wide enough to stay clickable and narrow enough to stay usable', () => {
|
||||
expect(clampWorkloadColumnWidth(1)).toBe(WORKLOAD_COLUMN_MIN_WIDTH);
|
||||
expect(clampWorkloadColumnWidth(10_000)).toBe(WORKLOAD_COLUMN_MAX_WIDTH);
|
||||
expect(clampWorkloadColumnWidth(180.4)).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldEngageColumnResize', () => {
|
||||
it('treats a press with no travel as a click, not a resize', () => {
|
||||
// Committing a zero-delta drag would pin every width and switch the
|
||||
// table into manual sizing without the operator asking for it.
|
||||
expect(shouldEngageColumnResize(0, false)).toBe(false);
|
||||
expect(shouldEngageColumnResize(2, false)).toBe(false);
|
||||
expect(shouldEngageColumnResize(-2, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('engages once the pointer has actually moved, in either direction', () => {
|
||||
expect(shouldEngageColumnResize(3, false)).toBe(true);
|
||||
expect(shouldEngageColumnResize(-40, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('stays engaged for the rest of the drag even when the pointer returns', () => {
|
||||
expect(shouldEngageColumnResize(0, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a non-finite delta', () => {
|
||||
expect(shouldEngageColumnResize(Number.NaN, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkloadManualSizingSupported', () => {
|
||||
it('is offered on pointer-sized layouts only', () => {
|
||||
expect(isWorkloadManualSizingSupported('wide')).toBe(true);
|
||||
expect(isWorkloadManualSizingSupported('compact')).toBe(true);
|
||||
expect(isWorkloadManualSizingSupported('tablet')).toBe(true);
|
||||
expect(isWorkloadManualSizingSupported('mobile')).toBe(false);
|
||||
expect(isWorkloadManualSizingSupported('phone')).toBe(false);
|
||||
expect(isWorkloadManualSizingSupported('narrow')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWorkloadColumnWidths', () => {
|
||||
it('returns an empty map for anything that is not a width record', () => {
|
||||
expect(normalizeWorkloadColumnWidths(null)).toEqual({});
|
||||
expect(normalizeWorkloadColumnWidths(undefined)).toEqual({});
|
||||
expect(normalizeWorkloadColumnWidths('name')).toEqual({});
|
||||
expect(normalizeWorkloadColumnWidths(['name'])).toEqual({});
|
||||
});
|
||||
|
||||
it('drops unusable entries instead of throwing so one bad key cannot wedge the table', () => {
|
||||
expect(
|
||||
normalizeWorkloadColumnWidths({
|
||||
name: 240,
|
||||
cpu: 'not-a-number',
|
||||
memory: Number.NaN,
|
||||
disk: 0,
|
||||
netIo: -20,
|
||||
'': 100,
|
||||
diskIo: '180',
|
||||
}),
|
||||
).toEqual({ name: 240, diskIo: 180 });
|
||||
});
|
||||
|
||||
it('clamps persisted widths that fall outside the supported range', () => {
|
||||
expect(normalizeWorkloadColumnWidths({ name: 5, cpu: 5_000 })).toEqual({
|
||||
name: WORKLOAD_COLUMN_MIN_WIDTH,
|
||||
cpu: WORKLOAD_COLUMN_MAX_WIDTH,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasWorkloadColumnWidths', () => {
|
||||
it('treats an empty or missing map as untouched', () => {
|
||||
expect(hasWorkloadColumnWidths(undefined)).toBe(false);
|
||||
expect(hasWorkloadColumnWidths({})).toBe(false);
|
||||
expect(hasWorkloadColumnWidths({ name: 200 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withWorkloadColumnWidth', () => {
|
||||
it('pins a clamped width without mutating the previous map', () => {
|
||||
const current = { name: 200 };
|
||||
const next = withWorkloadColumnWidth(current, 'cpu', 12);
|
||||
expect(next).toEqual({ name: 200, cpu: WORKLOAD_COLUMN_MIN_WIDTH });
|
||||
expect(current).toEqual({ name: 200 });
|
||||
});
|
||||
|
||||
it('ignores an unusable column id or width', () => {
|
||||
const current = { name: 200 };
|
||||
expect(withWorkloadColumnWidth(current, ' ', 120)).toBe(current);
|
||||
expect(withWorkloadColumnWidth(current, 'cpu', Number.NaN)).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withoutWorkloadColumnWidth', () => {
|
||||
it('clears one pin and leaves the map identical when there was nothing to clear', () => {
|
||||
const current = { name: 200, cpu: 140 };
|
||||
expect(withoutWorkloadColumnWidth(current, 'cpu')).toEqual({ name: 200 });
|
||||
expect(withoutWorkloadColumnWidth(current, 'memory')).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneWorkloadColumnWidths', () => {
|
||||
it('drops pins for columns the operator has since hidden', () => {
|
||||
expect(pruneWorkloadColumnWidths({ name: 200, cpu: 140 }, ['name'])).toEqual({ name: 200 });
|
||||
});
|
||||
|
||||
it('returns the same reference when every pin is still rendered', () => {
|
||||
const current = { name: 200, cpu: 140 };
|
||||
expect(pruneWorkloadColumnWidths(current, ['name', 'cpu', 'memory'])).toBe(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshotWorkloadColumnWidths', () => {
|
||||
it('freezes the widths the operator can currently see', () => {
|
||||
expect(snapshotWorkloadColumnWidths({}, { name: 302.4, cpu: 151.2 })).toEqual({
|
||||
name: 302,
|
||||
cpu: 151,
|
||||
});
|
||||
});
|
||||
|
||||
it('never overwrites a width the operator already pinned', () => {
|
||||
expect(snapshotWorkloadColumnWidths({ name: 240 }, { name: 302, cpu: 151 })).toEqual({
|
||||
name: 240,
|
||||
cpu: 151,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips columns the browser reported no usable width for', () => {
|
||||
expect(snapshotWorkloadColumnWidths({}, { name: 0, cpu: Number.NaN, disk: 140 })).toEqual({
|
||||
disk: 140,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedWorkloadColumnWidthsFromDefaults', () => {
|
||||
it('gives columns that were never on screen their design width', () => {
|
||||
expect(
|
||||
seedWorkloadColumnWidthsFromDefaults({ name: 172 }, [
|
||||
{ id: 'name', width: '200px' },
|
||||
{ id: 'netIo', width: '170px', minWidth: '170px' },
|
||||
{ id: 'tags', width: '60px' },
|
||||
]),
|
||||
).toEqual({ name: 172, netIo: 170, tags: 60 });
|
||||
});
|
||||
|
||||
it('falls back to minWidth, then to a usable default, for columns with no design width', () => {
|
||||
expect(
|
||||
seedWorkloadColumnWidthsFromDefaults({}, [
|
||||
{ id: 'a', minWidth: '96px' },
|
||||
{ id: 'b' },
|
||||
{ id: 'c', width: '50%' },
|
||||
]),
|
||||
).toEqual({
|
||||
a: 96,
|
||||
b: WORKLOAD_COLUMN_FALLBACK_WIDTH,
|
||||
c: WORKLOAD_COLUMN_FALLBACK_WIDTH,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumWorkloadColumnWidths', () => {
|
||||
it('publishes the exact total so table-layout: fixed keeps applying', () => {
|
||||
expect(sumWorkloadColumnWidths({ name: 172, cpu: 151, memory: 163 }, ['name', 'cpu'])).toBe(
|
||||
323,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null while nothing is pinned so the percentage layout is untouched', () => {
|
||||
expect(sumWorkloadColumnWidths({}, ['name', 'cpu'])).toBeNull();
|
||||
expect(sumWorkloadColumnWidths({ name: 172 }, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when any rendered column is still unpinned', () => {
|
||||
// A partial total would under-size the table and let the browser
|
||||
// redistribute, which is exactly what the pins are meant to prevent.
|
||||
expect(sumWorkloadColumnWidths({ name: 172 }, ['name', 'cpu'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('workloadColumnWidthsStorageKey', () => {
|
||||
it('scopes the key per surface so Proxmox and vSphere do not share widths', () => {
|
||||
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths')).toBe('workloadsColumnWidths');
|
||||
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths', ' proxmox ')).toBe(
|
||||
'workloadsColumnWidths:proxmox',
|
||||
);
|
||||
expect(workloadColumnWidthsStorageKey('workloadsColumnWidths', ' ')).toBe(
|
||||
'workloadsColumnWidths',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
WorkloadTableMetric,
|
||||
} from './workloadMetricHistoryModel';
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
import type { WorkloadColumnWidths } from './workloadColumnWidths';
|
||||
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
|
||||
|
||||
export type WorkloadTableLayoutMode = 'narrow' | 'phone' | 'mobile' | 'tablet' | 'compact' | 'wide';
|
||||
|
|
@ -113,6 +114,7 @@ export interface GuestRowProps {
|
|||
onCustomUrlUpdate?: (guestId: string, url: string) => void;
|
||||
isGroupedView?: boolean;
|
||||
visibleColumnIds?: string[];
|
||||
columnWidths?: WorkloadColumnWidths;
|
||||
onClick?: () => void;
|
||||
isExpanded?: boolean;
|
||||
isSummaryHighlighted?: boolean;
|
||||
|
|
@ -557,6 +559,7 @@ const getGuestColumnSizing = (
|
|||
isMobile = false,
|
||||
layoutMode: WorkloadTableLayoutMode = isMobile ? 'phone' : 'wide',
|
||||
visibleColumnIds?: readonly string[],
|
||||
manualWidths?: WorkloadColumnWidths,
|
||||
): Pick<ColumnDef, 'width' | 'minWidth' | 'maxWidth'> | undefined => {
|
||||
const column = GUEST_COLUMN_BY_ID.get(columnId);
|
||||
if (!column) return undefined;
|
||||
|
|
@ -567,7 +570,16 @@ const getGuestColumnSizing = (
|
|||
maxWidth: column.maxWidth,
|
||||
};
|
||||
|
||||
const override = getResponsiveColumnOverride(columnId, layoutMode, visibleColumnIds);
|
||||
const pinned = manualWidths?.[columnId];
|
||||
const manualOverride =
|
||||
typeof pinned === 'number' && Number.isFinite(pinned) && pinned > 0
|
||||
? (() => {
|
||||
const width = `${Math.round(pinned)}px`;
|
||||
return { width, minWidth: width, maxWidth: width };
|
||||
})()
|
||||
: undefined;
|
||||
const override =
|
||||
manualOverride ?? getResponsiveColumnOverride(columnId, layoutMode, visibleColumnIds);
|
||||
if (override) {
|
||||
if ('width' in override) sizing.width = override.width ?? undefined;
|
||||
if ('minWidth' in override) sizing.minWidth = override.minWidth ?? undefined;
|
||||
|
|
@ -582,8 +594,15 @@ export const getGuestColumnStyle = (
|
|||
isMobile = false,
|
||||
layoutMode?: WorkloadTableLayoutMode,
|
||||
visibleColumnIds?: readonly string[],
|
||||
manualWidths?: WorkloadColumnWidths,
|
||||
): JSX.CSSProperties | undefined => {
|
||||
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
|
||||
const sizing = getGuestColumnSizing(
|
||||
columnId,
|
||||
isMobile,
|
||||
layoutMode,
|
||||
visibleColumnIds,
|
||||
manualWidths,
|
||||
);
|
||||
if (!sizing) return undefined;
|
||||
|
||||
const style: JSX.CSSProperties = {};
|
||||
|
|
@ -600,8 +619,15 @@ export const getGuestColumnWidthStyle = (
|
|||
isMobile = false,
|
||||
layoutMode?: WorkloadTableLayoutMode,
|
||||
visibleColumnIds?: readonly string[],
|
||||
manualWidths?: WorkloadColumnWidths,
|
||||
): JSX.CSSProperties | undefined => {
|
||||
const sizing = getGuestColumnSizing(columnId, isMobile, layoutMode, visibleColumnIds);
|
||||
const sizing = getGuestColumnSizing(
|
||||
columnId,
|
||||
isMobile,
|
||||
layoutMode,
|
||||
visibleColumnIds,
|
||||
manualWidths,
|
||||
);
|
||||
if (!sizing?.width) return undefined;
|
||||
return { width: sizing.width };
|
||||
};
|
||||
|
|
@ -627,7 +653,15 @@ export const getWorkloadTableLayoutModeForContainer = (width: number): WorkloadT
|
|||
export const getWorkloadVisibleColumnsForLayout = (
|
||||
columns: ColumnDef[],
|
||||
layoutMode: WorkloadTableLayoutMode,
|
||||
options?: { manualSizing?: boolean },
|
||||
): ColumnDef[] => {
|
||||
if (
|
||||
options?.manualSizing &&
|
||||
WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode] >= WORKLOAD_TABLE_LAYOUT_ORDER.tablet
|
||||
) {
|
||||
return [...columns];
|
||||
}
|
||||
|
||||
const layoutRank = WORKLOAD_TABLE_LAYOUT_ORDER[layoutMode];
|
||||
return columns.filter((column) => {
|
||||
const minimumLayout = WORKLOAD_COLUMN_MIN_LAYOUT[column.id] ?? 'wide';
|
||||
|
|
|
|||
|
|
@ -32,6 +32,31 @@ import {
|
|||
WORKLOAD_TABLE_HISTORY_DEFAULT_RANGE,
|
||||
type WorkloadTableMetricHistoryRange,
|
||||
} from './workloadMetricHistoryModel';
|
||||
import {
|
||||
buildWorkloadColumnLayoutEntries,
|
||||
parseWorkloadColumnLayoutParam,
|
||||
resolveWorkloadColumnLayoutEntries,
|
||||
serializeWorkloadColumnLayout,
|
||||
toggleWorkloadColumnLayoutEntry,
|
||||
workloadColumnLayoutIds,
|
||||
workloadColumnLayoutWidths,
|
||||
WORKLOAD_COLUMNS_URL_PARAM,
|
||||
type WorkloadColumnLayoutEntry,
|
||||
} from './workloadColumnLayoutUrl';
|
||||
import {
|
||||
hasWorkloadColumnWidths,
|
||||
isWorkloadManualSizingSupported,
|
||||
normalizeWorkloadColumnWidths,
|
||||
pruneWorkloadColumnWidths,
|
||||
seedWorkloadColumnWidthsFromDefaults,
|
||||
snapshotWorkloadColumnWidths,
|
||||
sumWorkloadColumnWidths,
|
||||
WORKLOAD_COLUMN_FALLBACK_WIDTH,
|
||||
withWorkloadColumnWidth,
|
||||
withoutWorkloadColumnWidth,
|
||||
workloadColumnWidthsStorageKey,
|
||||
type WorkloadColumnWidths,
|
||||
} from './workloadColumnWidths';
|
||||
|
||||
interface WorkloadsControlsStateOptions {
|
||||
layoutWidth?: Accessor<number | null | undefined>;
|
||||
|
|
@ -274,17 +299,151 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
|||
);
|
||||
|
||||
const visibleColumns = columnVisibility.visibleColumns;
|
||||
// User choices persist independently of the current width, but the live
|
||||
// table only presents columns its responsive stage can keep readable. A
|
||||
// selected wide-only column therefore disappears while space is tight and
|
||||
// returns automatically when the table grows again.
|
||||
const workloadTableVisibleColumns = createMemo(() =>
|
||||
getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode()),
|
||||
const [columnWidths, setColumnWidths] = usePersistentSignal<WorkloadColumnWidths>(
|
||||
workloadColumnWidthsStorageKey(
|
||||
STORAGE_KEYS.WORKLOADS_COLUMN_WIDTHS,
|
||||
options.columnVisibilityStorageScope,
|
||||
),
|
||||
{},
|
||||
{
|
||||
serialize: (widths) => JSON.stringify(widths),
|
||||
deserialize: (raw) => {
|
||||
try {
|
||||
return normalizeWorkloadColumnWidths(JSON.parse(raw));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const [pendingColumnWidths, setPendingColumnWidths] = createSignal<WorkloadColumnWidths | null>(
|
||||
null,
|
||||
);
|
||||
const manualColumnSizingSupported = createMemo(() =>
|
||||
isWorkloadManualSizingSupported(workloadTableLayoutMode()),
|
||||
);
|
||||
const renderableColumnIds = createMemo(() => {
|
||||
const relevant = relevantColumns();
|
||||
return new Set(
|
||||
workloadColumns
|
||||
.filter((column) => !relevant || relevant.has(column.id))
|
||||
.map((column) => column.id),
|
||||
);
|
||||
});
|
||||
const urlColumnLayout = createMemo<WorkloadColumnLayoutEntry[]>(() => {
|
||||
if (!routeStateEnabled()) return [];
|
||||
return resolveWorkloadColumnLayoutEntries(
|
||||
parseWorkloadColumnLayoutParam(
|
||||
new URLSearchParams(activeRouteSearch()).get(WORKLOAD_COLUMNS_URL_PARAM),
|
||||
),
|
||||
renderableColumnIds(),
|
||||
);
|
||||
});
|
||||
const hasUrlColumnLayout = createMemo(
|
||||
() => manualColumnSizingSupported() && urlColumnLayout().length > 0,
|
||||
);
|
||||
const manualColumnSizingActive = createMemo(
|
||||
() =>
|
||||
manualColumnSizingSupported() &&
|
||||
(pendingColumnWidths() !== null ||
|
||||
hasUrlColumnLayout() ||
|
||||
hasWorkloadColumnWidths(columnWidths())),
|
||||
);
|
||||
const activeColumnWidths = createMemo<WorkloadColumnWidths>(() => {
|
||||
if (!manualColumnSizingActive()) return {};
|
||||
const pending = pendingColumnWidths();
|
||||
if (pending) return pending;
|
||||
if (hasUrlColumnLayout()) return workloadColumnLayoutWidths(urlColumnLayout());
|
||||
return columnWidths();
|
||||
});
|
||||
const workloadTableVisibleColumns = createMemo(() => {
|
||||
if (hasUrlColumnLayout()) {
|
||||
const byId = new Map(workloadColumns.map((column) => [column.id, column] as const));
|
||||
return workloadColumnLayoutIds(urlColumnLayout())
|
||||
.map((columnId) => byId.get(columnId))
|
||||
.filter((column): column is (typeof workloadColumns)[number] => !!column);
|
||||
}
|
||||
return getWorkloadVisibleColumnsForLayout(visibleColumns(), workloadTableLayoutMode(), {
|
||||
manualSizing: manualColumnSizingActive(),
|
||||
});
|
||||
});
|
||||
const workloadTableVisibleColumnIds = createMemo(() =>
|
||||
workloadTableVisibleColumns().map((column) => column.id),
|
||||
);
|
||||
const totalColumns = createMemo(() => workloadTableVisibleColumns().length);
|
||||
const workloadTableManualWidth = createMemo<number | null>(() =>
|
||||
sumWorkloadColumnWidths(activeColumnWidths(), workloadTableVisibleColumnIds()),
|
||||
);
|
||||
|
||||
const beginWorkloadColumnResize = (measuredWidths: Readonly<Record<string, number>>): void => {
|
||||
if (!manualColumnSizingSupported()) return;
|
||||
const measured = snapshotWorkloadColumnWidths(columnWidths(), measuredWidths);
|
||||
setPendingColumnWidths(seedWorkloadColumnWidthsFromDefaults(measured, visibleColumns()));
|
||||
};
|
||||
const previewWorkloadColumnWidth = (columnId: string, width: number): void => {
|
||||
setPendingColumnWidths((current) =>
|
||||
current ? withWorkloadColumnWidth(current, columnId, width) : current,
|
||||
);
|
||||
};
|
||||
const writeColumnLayoutParam = (entries: readonly WorkloadColumnLayoutEntry[] | null): void => {
|
||||
updateSearchParam((params) => {
|
||||
if (!entries || entries.length === 0) params.delete(WORKLOAD_COLUMNS_URL_PARAM);
|
||||
else params.set(WORKLOAD_COLUMNS_URL_PARAM, serializeWorkloadColumnLayout(entries));
|
||||
});
|
||||
};
|
||||
const publishColumnLayout = (
|
||||
widths: WorkloadColumnWidths,
|
||||
orderedIds: readonly string[],
|
||||
): void => {
|
||||
writeColumnLayoutParam(buildWorkloadColumnLayoutEntries(orderedIds, widths));
|
||||
};
|
||||
const commitWorkloadColumnResize = (): void => {
|
||||
const pending = pendingColumnWidths();
|
||||
if (!pending) return;
|
||||
const renderedColumnIds = workloadTableVisibleColumnIds();
|
||||
const committed = pruneWorkloadColumnWidths(pending, renderedColumnIds);
|
||||
if (!hasUrlColumnLayout()) setColumnWidths(committed);
|
||||
setPendingColumnWidths(null);
|
||||
publishColumnLayout(committed, renderedColumnIds);
|
||||
};
|
||||
const cancelWorkloadColumnResize = (): void => {
|
||||
setPendingColumnWidths(null);
|
||||
};
|
||||
const clearWorkloadColumnWidth = (columnId: string): void => {
|
||||
const renderedColumnIds = workloadTableVisibleColumnIds();
|
||||
const current = activeColumnWidths();
|
||||
setPendingColumnWidths(null);
|
||||
const linkOwnsLayout = hasUrlColumnLayout();
|
||||
const without = withoutWorkloadColumnWidth(current, columnId);
|
||||
if (!hasWorkloadColumnWidths(without)) {
|
||||
if (!linkOwnsLayout) setColumnWidths({});
|
||||
writeColumnLayoutParam(null);
|
||||
return;
|
||||
}
|
||||
const column = workloadColumns.find((candidate) => candidate.id === columnId);
|
||||
const restored = column ? seedWorkloadColumnWidthsFromDefaults(without, [column]) : without;
|
||||
if (!linkOwnsLayout) setColumnWidths(restored);
|
||||
publishColumnLayout(restored, renderedColumnIds);
|
||||
};
|
||||
const resetWorkloadColumnWidths = (): void => {
|
||||
setPendingColumnWidths(null);
|
||||
setColumnWidths({});
|
||||
writeColumnLayoutParam(null);
|
||||
};
|
||||
const toggleWorkloadColumn = (columnId: string): void => {
|
||||
if (!hasUrlColumnLayout()) {
|
||||
columnVisibility.toggle(columnId);
|
||||
return;
|
||||
}
|
||||
const column = workloadColumns.find((candidate) => candidate.id === columnId);
|
||||
const defaultWidth =
|
||||
column?.width && /^\d+(?:\.\d+)?px$/.test(column.width)
|
||||
? Number.parseFloat(column.width)
|
||||
: WORKLOAD_COLUMN_FALLBACK_WIDTH;
|
||||
writeColumnLayoutParam(
|
||||
toggleWorkloadColumnLayoutEntry(urlColumnLayout(), columnId, defaultWidth),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSort = (key: WorkloadsSortKey) => {
|
||||
if (sortKey() === key) {
|
||||
|
|
@ -364,10 +523,16 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
|||
availableColumns: getWorkloadVisibleColumnsForLayout(
|
||||
columnVisibility.availableToggles(),
|
||||
workloadTableLayoutMode(),
|
||||
{ manualSizing: manualColumnSizingActive() },
|
||||
),
|
||||
isColumnHidden: columnVisibility.isHiddenByUser,
|
||||
onColumnToggle: columnVisibility.toggle,
|
||||
isColumnHidden: (columnId: string) =>
|
||||
hasUrlColumnLayout()
|
||||
? !workloadTableVisibleColumnIds().includes(columnId)
|
||||
: columnVisibility.isHiddenByUser(columnId),
|
||||
onColumnToggle: toggleWorkloadColumn,
|
||||
onColumnReset: columnVisibility.resetToDefaults,
|
||||
hasColumnWidthOverrides: manualColumnSizingActive(),
|
||||
onColumnWidthsReset: resetWorkloadColumnWidths,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
|
@ -396,6 +561,16 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions
|
|||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadColumnWidths: activeColumnWidths,
|
||||
workloadManualColumnSizing: manualColumnSizingActive,
|
||||
workloadManualColumnSizingSupported: manualColumnSizingSupported,
|
||||
workloadTableManualWidth,
|
||||
beginWorkloadColumnResize,
|
||||
previewWorkloadColumnWidth,
|
||||
commitWorkloadColumnResize,
|
||||
cancelWorkloadColumnResize,
|
||||
clearWorkloadColumnWidth,
|
||||
resetWorkloadColumnWidths,
|
||||
setWorkloadMetricHistoryRange,
|
||||
setWorkloadMetricDisplayMode,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -313,6 +313,16 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadColumnWidths,
|
||||
workloadManualColumnSizing,
|
||||
workloadManualColumnSizingSupported,
|
||||
workloadTableManualWidth,
|
||||
beginWorkloadColumnResize,
|
||||
previewWorkloadColumnWidth,
|
||||
commitWorkloadColumnResize,
|
||||
cancelWorkloadColumnResize,
|
||||
clearWorkloadColumnWidth,
|
||||
resetWorkloadColumnWidths,
|
||||
workloadMetricDisplayMode,
|
||||
workloadMetricHistoryRange,
|
||||
setWorkloadMetricDisplayMode,
|
||||
|
|
@ -638,6 +648,16 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
|
|||
workloadTableVisibleColumnIds,
|
||||
workloadTableVisibleColumns,
|
||||
workloadTableLayoutMode,
|
||||
workloadColumnWidths,
|
||||
workloadManualColumnSizing,
|
||||
workloadManualColumnSizingSupported,
|
||||
workloadTableManualWidth,
|
||||
beginWorkloadColumnResize,
|
||||
previewWorkloadColumnWidth,
|
||||
commitWorkloadColumnResize,
|
||||
cancelWorkloadColumnResize,
|
||||
clearWorkloadColumnWidth,
|
||||
resetWorkloadColumnWidths,
|
||||
workloadNodeOptions,
|
||||
workloads,
|
||||
workloadInventoryIssues,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { clampWorkloadColumnWidth, type WorkloadColumnWidths } from './workloadColumnWidths';
|
||||
|
||||
/**
|
||||
* Shareable workload table layout, carried in the `cols` query parameter as
|
||||
* `id:width` pairs — for example `cols=name:172,cpu:151,netIo:170`.
|
||||
*
|
||||
* The parameter is authoritative while it is present: the ids decide which
|
||||
* columns render and in what order, and the widths pin them. That is what makes
|
||||
* a copied URL reproduce the same table for whoever opens it, instead of
|
||||
* landing on their own stored column preference. Removing the parameter returns
|
||||
* the table to that stored preference, which is never overwritten by a link.
|
||||
*/
|
||||
export const WORKLOAD_COLUMNS_URL_PARAM = 'cols';
|
||||
|
||||
export interface WorkloadColumnLayoutEntry {
|
||||
id: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Guards against a hand-edited URL asking for an absurd number of columns. */
|
||||
const MAX_LAYOUT_ENTRIES = 32;
|
||||
|
||||
const isSafeColumnId = (id: string): boolean => /^[A-Za-z][A-Za-z0-9_-]*$/.test(id);
|
||||
|
||||
export const parseWorkloadColumnLayoutParam = (
|
||||
raw: string | null | undefined,
|
||||
): WorkloadColumnLayoutEntry[] => {
|
||||
if (!raw) return [];
|
||||
|
||||
const entries: WorkloadColumnLayoutEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const chunk of raw.split(',')) {
|
||||
if (entries.length >= MAX_LAYOUT_ENTRIES) break;
|
||||
|
||||
const separator = chunk.lastIndexOf(':');
|
||||
if (separator <= 0) continue;
|
||||
|
||||
const id = chunk.slice(0, separator).trim();
|
||||
if (!isSafeColumnId(id) || seen.has(id)) continue;
|
||||
|
||||
const width = Number(chunk.slice(separator + 1).trim());
|
||||
if (!Number.isFinite(width) || width <= 0) continue;
|
||||
|
||||
seen.add(id);
|
||||
entries.push({ id, width: clampWorkloadColumnWidth(width) });
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const serializeWorkloadColumnLayout = (
|
||||
entries: readonly WorkloadColumnLayoutEntry[],
|
||||
): string => entries.map((entry) => `${entry.id}:${Math.round(entry.width)}`).join(',');
|
||||
|
||||
/**
|
||||
* Drops ids the current view cannot render — a stale link, a column retired by
|
||||
* an upgrade, or a vSphere layout opened on Proxmox. Order follows the link.
|
||||
*/
|
||||
export const resolveWorkloadColumnLayoutEntries = (
|
||||
entries: readonly WorkloadColumnLayoutEntry[],
|
||||
renderableColumnIds: ReadonlySet<string>,
|
||||
): WorkloadColumnLayoutEntry[] => entries.filter((entry) => renderableColumnIds.has(entry.id));
|
||||
|
||||
export const workloadColumnLayoutWidths = (
|
||||
entries: readonly WorkloadColumnLayoutEntry[],
|
||||
): WorkloadColumnWidths => {
|
||||
const widths: Record<string, number> = {};
|
||||
for (const entry of entries) widths[entry.id] = entry.width;
|
||||
return widths;
|
||||
};
|
||||
|
||||
export const workloadColumnLayoutIds = (entries: readonly WorkloadColumnLayoutEntry[]): string[] =>
|
||||
entries.map((entry) => entry.id);
|
||||
|
||||
export const buildWorkloadColumnLayoutEntries = (
|
||||
orderedColumnIds: readonly string[],
|
||||
widths: WorkloadColumnWidths,
|
||||
): WorkloadColumnLayoutEntry[] => {
|
||||
const entries: WorkloadColumnLayoutEntry[] = [];
|
||||
for (const id of orderedColumnIds) {
|
||||
const width = widths[id];
|
||||
if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) continue;
|
||||
entries.push({ id, width: clampWorkloadColumnWidth(width) });
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or removes one column while a link-driven layout is active. The stored
|
||||
* column preference is deliberately left alone so that toggling inside somebody
|
||||
* else's shared view does not rewrite the viewer's own defaults.
|
||||
*/
|
||||
export const toggleWorkloadColumnLayoutEntry = (
|
||||
entries: readonly WorkloadColumnLayoutEntry[],
|
||||
columnId: string,
|
||||
defaultWidth: number,
|
||||
): WorkloadColumnLayoutEntry[] => {
|
||||
const id = columnId.trim();
|
||||
if (!isSafeColumnId(id)) return [...entries];
|
||||
|
||||
const existing = entries.findIndex((entry) => entry.id === id);
|
||||
if (existing >= 0) {
|
||||
const next = entries.filter((entry) => entry.id !== id);
|
||||
// Never let a link strip the table down to nothing.
|
||||
return next.length > 0 ? next : [...entries];
|
||||
}
|
||||
|
||||
if (entries.length >= MAX_LAYOUT_ENTRIES) return [...entries];
|
||||
return [...entries, { id, width: clampWorkloadColumnWidth(defaultWidth) }];
|
||||
};
|
||||
189
frontend-modern/src/components/Workloads/workloadColumnWidths.ts
Normal file
189
frontend-modern/src/components/Workloads/workloadColumnWidths.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type { WorkloadTableLayoutMode } from './guestRowModel';
|
||||
|
||||
/**
|
||||
* User-pinned workload column widths, in CSS pixels, keyed by column id.
|
||||
*
|
||||
* An empty map is the canonical "untouched" state: the responsive weights in
|
||||
* `guestRowModel` own every width and the table keeps the maintained contract
|
||||
* of fitting its container without horizontal scroll. A non-empty map means the
|
||||
* operator has explicitly dragged a column edge and has therefore opted into
|
||||
* manual sizing for the current scope.
|
||||
*/
|
||||
export type WorkloadColumnWidths = Readonly<Record<string, number>>;
|
||||
|
||||
export const WORKLOAD_COLUMN_MIN_WIDTH = 48;
|
||||
export const WORKLOAD_COLUMN_MAX_WIDTH = 720;
|
||||
|
||||
/**
|
||||
* Manual sizing is a pointer-driven desktop affordance. Touch layouts stay on
|
||||
* the responsive weights so a width pinned on a wide monitor can never leak
|
||||
* into a phone-sized render of the same scope.
|
||||
*/
|
||||
const MANUAL_SIZING_LAYOUT_MODES: ReadonlySet<WorkloadTableLayoutMode> = new Set([
|
||||
'tablet',
|
||||
'compact',
|
||||
'wide',
|
||||
]);
|
||||
|
||||
export const isWorkloadManualSizingSupported = (layoutMode: WorkloadTableLayoutMode): boolean =>
|
||||
MANUAL_SIZING_LAYOUT_MODES.has(layoutMode);
|
||||
|
||||
/**
|
||||
* Pointer travel, in CSS pixels, before a press on a resize handle counts as a
|
||||
* resize rather than a click.
|
||||
*
|
||||
* Without a threshold a plain click on the handle would open and immediately
|
||||
* commit a zero-delta drag, which pins every rendered width and silently
|
||||
* switches the table into manual sizing — a mode change the operator never
|
||||
* asked for.
|
||||
*/
|
||||
export const WORKLOAD_COLUMN_DRAG_ENGAGE_THRESHOLD = 3;
|
||||
|
||||
export const shouldEngageColumnResize = (deltaX: number, alreadyEngaged: boolean): boolean => {
|
||||
if (alreadyEngaged) return true;
|
||||
if (!Number.isFinite(deltaX)) return false;
|
||||
return Math.abs(deltaX) >= WORKLOAD_COLUMN_DRAG_ENGAGE_THRESHOLD;
|
||||
};
|
||||
|
||||
export const clampWorkloadColumnWidth = (width: number): number =>
|
||||
Math.min(WORKLOAD_COLUMN_MAX_WIDTH, Math.max(WORKLOAD_COLUMN_MIN_WIDTH, Math.round(width)));
|
||||
|
||||
export const hasWorkloadColumnWidths = (widths: WorkloadColumnWidths | undefined): boolean =>
|
||||
!!widths && Object.keys(widths).length > 0;
|
||||
|
||||
/**
|
||||
* Accepts anything `localStorage` hands back and returns a map that the render
|
||||
* path can trust. Unusable entries are dropped rather than throwing, so one bad
|
||||
* key written by an older build cannot wedge the table.
|
||||
*/
|
||||
export const normalizeWorkloadColumnWidths = (value: unknown): WorkloadColumnWidths => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
|
||||
const normalized: Record<string, number> = {};
|
||||
for (const [columnId, rawWidth] of Object.entries(value as Record<string, unknown>)) {
|
||||
const id = columnId.trim();
|
||||
if (!id) continue;
|
||||
const width = typeof rawWidth === 'number' ? rawWidth : Number(rawWidth);
|
||||
if (!Number.isFinite(width) || width <= 0) continue;
|
||||
normalized[id] = clampWorkloadColumnWidth(width);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const withWorkloadColumnWidth = (
|
||||
widths: WorkloadColumnWidths,
|
||||
columnId: string,
|
||||
width: number,
|
||||
): WorkloadColumnWidths => {
|
||||
const id = columnId.trim();
|
||||
if (!id || !Number.isFinite(width)) return widths;
|
||||
return { ...widths, [id]: clampWorkloadColumnWidth(width) };
|
||||
};
|
||||
|
||||
export const withoutWorkloadColumnWidth = (
|
||||
widths: WorkloadColumnWidths,
|
||||
columnId: string,
|
||||
): WorkloadColumnWidths => {
|
||||
if (!(columnId in widths)) return widths;
|
||||
const next = { ...widths };
|
||||
delete next[columnId];
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drops pins for columns that are no longer part of the visible set so a column
|
||||
* the operator has since hidden cannot keep reserving width forever.
|
||||
*/
|
||||
export const pruneWorkloadColumnWidths = (
|
||||
widths: WorkloadColumnWidths,
|
||||
visibleColumnIds: readonly string[],
|
||||
): WorkloadColumnWidths => {
|
||||
const visible = new Set(visibleColumnIds);
|
||||
const next: Record<string, number> = {};
|
||||
for (const [columnId, width] of Object.entries(widths)) {
|
||||
if (visible.has(columnId)) next[columnId] = width;
|
||||
}
|
||||
return Object.keys(next).length === Object.keys(widths).length ? widths : next;
|
||||
};
|
||||
|
||||
/**
|
||||
* `table-fixed` distributes leftover space across columns that have no explicit
|
||||
* width, so pinning a single column would silently reflow every other one. The
|
||||
* first drag therefore freezes the widths the operator can currently see, and
|
||||
* only then applies their delta.
|
||||
*/
|
||||
export const snapshotWorkloadColumnWidths = (
|
||||
widths: WorkloadColumnWidths,
|
||||
measuredWidths: Readonly<Record<string, number>>,
|
||||
): WorkloadColumnWidths => {
|
||||
const next: Record<string, number> = { ...widths };
|
||||
for (const [columnId, measured] of Object.entries(measuredWidths)) {
|
||||
if (columnId in next) continue;
|
||||
if (!Number.isFinite(measured) || measured <= 0) continue;
|
||||
next[columnId] = clampWorkloadColumnWidth(measured);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Columns that only become visible once manual sizing engages were never
|
||||
* measured, so they would otherwise arrive unpinned and let `table-fixed`
|
||||
* redistribute the surplus across every column. Seeding them from their design
|
||||
* width keeps the table exactly as wide as the sum of its columns.
|
||||
*/
|
||||
export const WORKLOAD_COLUMN_FALLBACK_WIDTH = 120;
|
||||
|
||||
export const parsePixelWidth = (value: string | undefined): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const match = /^(\d+(?:\.\d+)?)px$/.exec(value.trim());
|
||||
if (!match) return undefined;
|
||||
const parsed = Number(match[1]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
export const seedWorkloadColumnWidthsFromDefaults = (
|
||||
widths: WorkloadColumnWidths,
|
||||
columns: readonly { id: string; width?: string; minWidth?: string }[],
|
||||
): WorkloadColumnWidths => {
|
||||
const next: Record<string, number> = { ...widths };
|
||||
for (const column of columns) {
|
||||
if (column.id in next) continue;
|
||||
const preferred =
|
||||
parsePixelWidth(column.width) ??
|
||||
parsePixelWidth(column.minWidth) ??
|
||||
WORKLOAD_COLUMN_FALLBACK_WIDTH;
|
||||
next[column.id] = clampWorkloadColumnWidth(preferred);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Total width the table must claim so `table-layout: fixed` keeps applying.
|
||||
*
|
||||
* A table with `width: auto` silently drops out of fixed layout and falls back
|
||||
* to auto layout, where a narrow column is re-expanded to its content minimum.
|
||||
* Publishing the exact sum keeps the width definite, so every pin is honoured
|
||||
* and the shell scrolls by precisely the overflow.
|
||||
*
|
||||
* Returns null when there is nothing pinned, which leaves the default
|
||||
* percentage layout completely untouched.
|
||||
*/
|
||||
export const sumWorkloadColumnWidths = (
|
||||
widths: WorkloadColumnWidths,
|
||||
visibleColumnIds: readonly string[],
|
||||
): number | null => {
|
||||
if (!hasWorkloadColumnWidths(widths) || visibleColumnIds.length === 0) return null;
|
||||
let total = 0;
|
||||
for (const columnId of visibleColumnIds) {
|
||||
const pinned = widths[columnId];
|
||||
if (typeof pinned !== 'number' || !Number.isFinite(pinned) || pinned <= 0) return null;
|
||||
total += pinned;
|
||||
}
|
||||
return total > 0 ? total : null;
|
||||
};
|
||||
|
||||
export const workloadColumnWidthsStorageKey = (baseKey: string, scope?: string): string => {
|
||||
const trimmed = scope?.trim();
|
||||
return trimmed ? `${baseKey}:${trimmed}` : baseKey;
|
||||
};
|
||||
|
|
@ -66,6 +66,8 @@ export interface WorkloadsFilterProps {
|
|||
onColumnToggle: (id: string) => void;
|
||||
onColumnReset?: () => void;
|
||||
showReset?: boolean;
|
||||
onColumnWidthsReset?: () => void;
|
||||
hasColumnWidthOverrides?: boolean;
|
||||
};
|
||||
hostFilter?: WorkloadsToolbarFilterConfig;
|
||||
platformFilter?: WorkloadsToolbarFilterConfig;
|
||||
|
|
|
|||
|
|
@ -265,6 +265,17 @@ describe('post-update telemetry disclosure', () => {
|
|||
});
|
||||
|
||||
describe('current candidate notification packet', () => {
|
||||
it('keeps manual workload column sizing in the customer-facing release packet', () => {
|
||||
const releaseNotes = readFileSync(
|
||||
path.resolve(process.cwd(), '../docs/releases/RELEASE_NOTES_v6.4.0-rc.1.md'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(releaseNotes).toContain('Workload table columns can be resized');
|
||||
expect(releaseNotes).toContain('keeps every selected column reachable');
|
||||
expect(releaseNotes).toContain('shared through the page URL or reset');
|
||||
});
|
||||
|
||||
it('keeps translation-ready webhook support in both release summaries', () => {
|
||||
const releaseNotes = readFileSync(
|
||||
path.resolve(process.cwd(), '../docs/releases/RELEASE_NOTES_v6.4.0-rc.3.md'),
|
||||
|
|
|
|||
|
|
@ -114,6 +114,33 @@ describe('ColumnPicker', () => {
|
|||
expect(onReset).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('explains and clears an active manual-width layout independently of column visibility', async () => {
|
||||
const onResetWidths = vi.fn();
|
||||
render(() => (
|
||||
<ColumnPicker
|
||||
columns={[{ id: 'subject', label: 'Subject' }]}
|
||||
isHidden={() => false}
|
||||
onToggle={vi.fn()}
|
||||
onResetWidths={onResetWidths}
|
||||
hasManualWidths
|
||||
/>
|
||||
));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /columns/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'Custom widths are active, so every enabled column stays visible and the table scrolls sideways.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Enabled columns appear when table space allows.'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset widths' }));
|
||||
expect(onResetWidths).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes when the user clicks outside the open picker', async () => {
|
||||
render(() => (
|
||||
<ColumnPicker
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import {
|
|||
COLUMN_PICKER_EMPTY_LABEL,
|
||||
COLUMN_PICKER_PANEL_TITLE,
|
||||
COLUMN_PICKER_RESPONSIVE_NOTE,
|
||||
COLUMN_PICKER_MANUAL_WIDTH_NOTE,
|
||||
COLUMN_PICKER_RESET_LABEL,
|
||||
COLUMN_PICKER_RESET_WIDTHS_LABEL,
|
||||
getColumnPickerOptionTextClass,
|
||||
} from '@/components/shared/columnPickerModel';
|
||||
import {
|
||||
|
|
@ -29,17 +31,32 @@ export const ColumnPicker: Component<ColumnPickerProps> = (props) => {
|
|||
<div class="border-b border-border-subtle px-3 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-base-content">{COLUMN_PICKER_PANEL_TITLE}</span>
|
||||
<Show when={state.showReset()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.handleResetClick}
|
||||
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{COLUMN_PICKER_RESET_LABEL}
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={state.showResetWidths()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.handleResetWidthsClick}
|
||||
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{COLUMN_PICKER_RESET_WIDTHS_LABEL}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={state.showReset()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={state.handleResetClick}
|
||||
class="text-[10px] text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{COLUMN_PICKER_RESET_LABEL}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-[10px] leading-4 text-muted">{COLUMN_PICKER_RESPONSIVE_NOTE}</p>
|
||||
<p class="mt-1 text-[10px] leading-4 text-muted">
|
||||
{state.showResetWidths()
|
||||
? COLUMN_PICKER_MANUAL_WIDTH_NOTE
|
||||
: COLUMN_PICKER_RESPONSIVE_NOTE}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto py-1">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ export const COLUMN_PICKER_BUTTON_TITLE = 'Choose which columns to display';
|
|||
export const COLUMN_PICKER_PANEL_TITLE = 'Show Columns';
|
||||
export const COLUMN_PICKER_RESPONSIVE_NOTE = 'Enabled columns appear when table space allows.';
|
||||
export const COLUMN_PICKER_RESET_LABEL = 'Reset';
|
||||
export const COLUMN_PICKER_RESET_WIDTHS_LABEL = 'Reset widths';
|
||||
export const COLUMN_PICKER_MANUAL_WIDTH_NOTE =
|
||||
'Custom widths are active, so every enabled column stays visible and the table scrolls sideways.';
|
||||
export const COLUMN_PICKER_EMPTY_LABEL = 'No columns available to toggle';
|
||||
|
||||
export function getHiddenColumnCount(
|
||||
|
|
@ -21,6 +24,13 @@ export function shouldShowColumnPickerReset(
|
|||
return Boolean(onReset) && hiddenCount > 0;
|
||||
}
|
||||
|
||||
export function shouldShowColumnPickerWidthReset(
|
||||
onResetWidths: (() => void) | undefined,
|
||||
hasManualWidths: boolean | undefined,
|
||||
): boolean {
|
||||
return Boolean(onResetWidths) && hasManualWidths === true;
|
||||
}
|
||||
|
||||
export function getColumnPickerOptionTextClass(isChecked: boolean): string {
|
||||
return `text-sm ${isChecked ? 'text-base-content' : 'text-muted'}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import type { ColumnDef } from '@/hooks/useColumnVisibility';
|
||||
import { getHiddenColumnCount, shouldShowColumnPickerReset } from './columnPickerModel';
|
||||
import {
|
||||
getHiddenColumnCount,
|
||||
shouldShowColumnPickerReset,
|
||||
shouldShowColumnPickerWidthReset,
|
||||
} from './columnPickerModel';
|
||||
|
||||
export interface ColumnPickerProps {
|
||||
columns: ColumnDef[];
|
||||
|
|
@ -8,6 +12,8 @@ export interface ColumnPickerProps {
|
|||
onToggle: (id: string) => void;
|
||||
onReset?: () => void;
|
||||
showReset?: boolean;
|
||||
onResetWidths?: () => void;
|
||||
hasManualWidths?: boolean;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,10 +42,15 @@ export function useColumnPickerState(props: ColumnPickerProps) {
|
|||
const showReset = createMemo(
|
||||
() => Boolean(props.showReset) || shouldShowColumnPickerReset(props.onReset, hiddenCount()),
|
||||
);
|
||||
const showResetWidths = createMemo(() =>
|
||||
shouldShowColumnPickerWidthReset(props.onResetWidths, props.hasManualWidths),
|
||||
);
|
||||
|
||||
return {
|
||||
handleColumnToggle: (id: string) => props.onToggle(id),
|
||||
handleResetClick: () => props.onReset?.(),
|
||||
handleResetWidthsClick: () => props.onResetWidths?.(),
|
||||
showResetWidths,
|
||||
hiddenCount,
|
||||
isColumnChecked: (id: string) => !props.isHidden(id),
|
||||
isOpen,
|
||||
|
|
|
|||
|
|
@ -1295,3 +1295,54 @@ body,
|
|||
.animate-enter {
|
||||
animation: enter 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Workload table manual column sizing
|
||||
|
||||
Default (no pinned widths): none of this applies and the responsive weights
|
||||
keep the table inside its container, as they always have. Once an operator
|
||||
drags a column edge the table opts into its pinned pixel widths, which means
|
||||
it may exceed the shell — `.table-scroll-shell` already scrolls, so the
|
||||
trade the operator asked for is simply allowed to happen.
|
||||
--------------------------------------------------------------------------- */
|
||||
/* The table publishes an explicit pixel width while manual sizing is on (see
|
||||
`sumWorkloadColumnWidths`). `width: auto` would drop the table out of
|
||||
`table-layout: fixed` and let a narrow column snap back to its content
|
||||
minimum, so only the floor is expressed here. */
|
||||
.table-scroll-shell > table.workload-table--manual-widths {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.workload-col-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
/* Keeps the hit target from selecting header text mid-drag. */
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background-clip: content-box;
|
||||
background-color: transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: border-color 0.12s ease-in-out;
|
||||
}
|
||||
|
||||
.workload-col-resizer:hover,
|
||||
.workload-col-resizer:focus-visible {
|
||||
border-right-color: var(--color-border-strong, currentColor);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.workload-col-resizer:focus-visible {
|
||||
border-right-width: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workload-col-resizer {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// Column visibility
|
||||
WORKLOADS_HIDDEN_COLUMNS: 'workloadsHiddenColumns',
|
||||
WORKLOADS_COLUMN_WIDTHS: 'workloadsColumnWidths',
|
||||
RECOVERY_HIDDEN_COLUMNS: 'backupsHiddenColumns',
|
||||
STORAGE_HIDDEN_COLUMNS: 'storageHiddenColumns',
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue