From d4c6bdfd8fc643d611d3e3154c46f8c7911c5dd2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:22:19 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20shrink=20table=20colu?= =?UTF-8?q?mns=20to=20fit=20and=20pin=20the=20group=20Update=20All=20butto?= =?UTF-8?q?n=20(#467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rc.4 v6→v7 preferences migration force-adds the new Version column (~220px) to every existing user's containers column set. The responsive auto-hide budget sums column *minimum* widths while the renderer laid columns out at their *preferred* widths and never shrank them, so in the band between those two sums the table overflowed horizontally and the group-header Update All button — a plain flex child of a full-row cell, unlike the sticky per-row Actions column — scrolled out of view. - 🐛 DataTable resolveColumnWidths() now shrinks columns proportionally toward (never below) minSize when the base widths overflow, so rendered widths agree with the auto-hide budget's fits decision - 🐛 the group-header Update All button is pinned to the visible end edge (sticky, logical props) so it stays reachable even when a table legitimately overflows between auto-hide steps - ✅ regression specs reproduce the exact rc.4 width band and pin the expansion path unchanged --- CHANGELOG.md | 6 + ui/src/components/DataTable.vue | 70 +++++++++++- .../containers/ContainersGroupHeader.vue | 93 ++++++++-------- ui/tests/components/DataTable.spec.ts | 103 ++++++++++++++++++ .../containers/ContainersGroupHeader.spec.ts | 14 +++ 5 files changed, 238 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 207ae9e6a..86a2c3f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.5.1-rc.5] — 2026-07-02 + +### Fixed + +- **Grouped "Update All" buttons could scroll out of view after upgrading to rc.4.** The new Version column added in rc.4 widened the containers table for existing users, and at moderate desktop widths the table overflowed horizontally in a way its responsive column-hiding never accounted for: columns were only ever laid out at their preferred widths, so the per-stack **Update All** button — positioned at the far end of the group header row — ended up past the visible edge while everything else looked normal. Tables now shrink columns proportionally toward their minimum widths when space is tight (matching the widths the responsive logic already budgets for), and the group header's Update All button is additionally pinned to the visible edge, so it stays reachable even when a table legitimately overflows. (#467) + ## [1.5.1-rc.4] — 2026-06-29 ### Added diff --git a/ui/src/components/DataTable.vue b/ui/src/components/DataTable.vue index a6cf2a45e..7d04e0050 100644 --- a/ui/src/components/DataTable.vue +++ b/ui/src/components/DataTable.vue @@ -193,8 +193,64 @@ const normalizedColumns = computed(() => })), ); +interface ColumnWidthEntry { + key: string; + width: number; + sizing: NormalizedTableColumnSizing; +} + +// Shrinks columns proportionally toward (never below) their minSize using water-filling: on +// each pass, the deficit is distributed across still-shrinkable columns weighted by remaining +// headroom (width - minSize). Columns whose proportional share would take them past their floor +// are clamped to minSize and dropped from the pool; whatever they couldn't absorb is +// redistributed among the columns that still have headroom left. This guarantees resolved +// widths never fall below minSize while eliminating the overflow whenever total headroom covers +// the deficit (see #467 — rendered widths must agree with the auto-hide budget's minSize-based +// "fits" decision). When total headroom is smaller than the deficit, every shrinkable column +// bottoms out at minSize and the remaining overflow is left for auto-hide to resolve. +function shrinkColumnWidthsToFit( + base: ColumnWidthEntry[], + widthMap: Record, + deficit: number, +): void { + const widths = new Map(base.map((entry) => [entry.key, entry.width])); + let shrinkable = base.filter((entry) => entry.width > entry.sizing.minSize); + let remainingDeficit = deficit; + + while (remainingDeficit > 0.5 && shrinkable.length > 0) { + const totalHeadroom = shrinkable.reduce( + (acc, entry) => acc + ((widths.get(entry.key) ?? 0) - entry.sizing.minSize), + 0, + ); + if (totalHeadroom <= 0) { + break; + } + + let absorbed = 0; + const stillShrinkable: ColumnWidthEntry[] = []; + for (const entry of shrinkable) { + const width = widths.get(entry.key) ?? 0; + const headroom = width - entry.sizing.minSize; + const shrinkBy = Math.min(remainingDeficit * (headroom / totalHeadroom), headroom); + const nextWidth = width - shrinkBy; + widths.set(entry.key, nextWidth); + absorbed += shrinkBy; + if (nextWidth - entry.sizing.minSize > 0.5) { + stillShrinkable.push(entry); + } + } + remainingDeficit -= absorbed; + shrinkable = stillShrinkable; + } + + for (const entry of base) { + const width = widths.get(entry.key) ?? entry.width; + widthMap[entry.key] = Math.max(entry.sizing.minSize, Math.floor(width)); + } +} + function resolveColumnWidths(): Record { - const base = normalizedColumns.value.map(({ column, sizing }) => { + const base: ColumnWidthEntry[] = normalizedColumns.value.map(({ column, sizing }) => { const manual = liveColumnWidths[column.key]; const persisted = getPersistedColumnWidth(column.key); const width = manual ?? persisted ?? sizing.size; @@ -207,15 +263,21 @@ function resolveColumnWidths(): Record { const widthMap = Object.fromEntries(base.map((entry) => [entry.key, entry.width])); const available = viewportWidth.value; - const flexColumns = base.filter((entry) => entry.sizing.flex > 0); - if (available <= 0 || flexColumns.length === 0) { + if (available <= 0) { return widthMap; } const actionsWidth = props.showActions ? actionsColumn.value.resolvedWidth : 0; const totalBaseWidth = base.reduce((acc, entry) => acc + entry.width, 0) + actionsWidth; const extra = available - totalBaseWidth; - if (extra <= 0) { + + if (extra < 0) { + shrinkColumnWidthsToFit(base, widthMap, -extra); + return widthMap; + } + + const flexColumns = base.filter((entry) => entry.sizing.flex > 0); + if (extra <= 0 || flexColumns.length === 0) { return widthMap; } diff --git a/ui/src/components/containers/ContainersGroupHeader.vue b/ui/src/components/containers/ContainersGroupHeader.vue index 505962bea..a55197114 100644 --- a/ui/src/components/containers/ContainersGroupHeader.vue +++ b/ui/src/components/containers/ContainersGroupHeader.vue @@ -51,52 +51,57 @@ const emit = defineEmits<{ {{ group.updatesAvailable }} {{ group.updatesAvailable === 1 ? t('containerComponents.groupHeader.updateSingular') : t('containerComponents.groupHeader.updatePlural') }} - - - {{ - !containerActionsEnabled - ? t('containerComponents.groupHeader.actionsDisabled') - : inProgress && frozenTotal !== undefined && doneCount !== undefined && frozenTotal >= 2 - ? t('containerComponents.groupHeader.updatingStack', { done: doneCount, total: frozenTotal }) - : t('containerComponents.groupHeader.updateAll') - }} - + :disabled="!containerActionsEnabled || group.updatableCount === 0 || inProgress" + v-tooltip.top=" + tt( + !containerActionsEnabled + ? containerActionsDisabledReason + : group.updatableCount === 0 + ? t('containerComponents.groupHeader.allBlockedTooltip') + : t('containerComponents.groupHeader.updateAllInGroupTooltip'), + ) + " + @click.stop="emit('updateAll', group)" + > + + {{ + !containerActionsEnabled + ? t('containerComponents.groupHeader.actionsDisabled') + : inProgress && frozenTotal !== undefined && doneCount !== undefined && frozenTotal >= 2 + ? t('containerComponents.groupHeader.updatingStack', { done: doneCount, total: frozenTotal }) + : t('containerComponents.groupHeader.updateAll') + }} + + diff --git a/ui/tests/components/DataTable.spec.ts b/ui/tests/components/DataTable.spec.ts index 7ef942c5f..d30fd75cf 100644 --- a/ui/tests/components/DataTable.spec.ts +++ b/ui/tests/components/DataTable.spec.ts @@ -565,6 +565,109 @@ describe('DataTable', () => { }); }); + describe('column width shrink-to-fit (#467)', () => { + function setViewportWidth(w: ReturnType, width: number) { + const scrollEl = w.find('.overflow-x-auto').element as HTMLElement; + Object.defineProperty(scrollEl, 'clientWidth', { configurable: true, value: width }); + window.dispatchEvent(new Event('resize')); + } + + function columnWidths(w: ReturnType): Record { + const widths: Record = {}; + for (const col of w.findAll('colgroup col')) { + const key = col.attributes('data-col-key'); + const match = (col.attributes('style') ?? '').match(/width:\s*([0-9.]+)px/); + if (key && match) { + widths[key] = Number.parseFloat(match[1]); + } + } + return widths; + } + + // Mirrors the rc.4 default containers column set after the v6->v7 preferences migration + // force-adds `softwareVersion` (220 size / 150 minSize) to every existing user's column + // set: default-size sum grows from 1264px to 1484px while the minSize sum stays 1136px. + const regressionColumns = [ + { key: 'icon', label: '', icon: true, size: 40, minSize: 40, maxSize: 40 }, + { key: 'name', label: 'Container', size: 360, minSize: 220, maxSize: 640, flex: 1 }, + { key: 'version', label: 'Tag', size: 220, minSize: 150, maxSize: 320 }, + { key: 'softwareVersion', label: 'Version', size: 220, minSize: 150, maxSize: 320 }, + { key: 'kind', label: 'Update', size: 128, minSize: 116, maxSize: 180 }, + { key: 'status', label: 'Status', size: 118, minSize: 112, maxSize: 160 }, + { key: 'server', label: 'Host', size: 152, minSize: 132, maxSize: 240 }, + { key: 'registry', label: 'Registry', size: 126, minSize: 116, maxSize: 180 }, + { key: 'uptime', label: 'Uptime', size: 120, minSize: 100, maxSize: 180 }, + ]; + + it('shrinks columns to fit when size-sum overflows the available width but minSize-sum still fits (#467)', async () => { + // available (1300) sits inside the 1136 (minSize-sum) .. 1484 (size-sum) overflow band — + // the exact divergence window where rc.4 rendered wider than the auto-hide budget allowed. + const available = 1300; + const w = mount(DataTable, { + props: { columns: regressionColumns, rows: [], rowKey: 'id' }, + global: { stubs: { AppIcon: { template: '' } } }, + }); + setViewportWidth(w, available); + await nextTick(); + + const widths = columnWidths(w); + const total = Object.values(widths).reduce((sum, width) => sum + width, 0); + + expect(total).toBeLessThanOrEqual(available); + for (const col of regressionColumns) { + expect(widths[col.key]).toBeGreaterThanOrEqual(col.minSize); + } + // Confirms shrink actually engaged rather than silently no-op'ing. + expect(widths.softwareVersion).toBeLessThan(220); + expect(widths.name).toBeLessThan(360); + }); + + it('floors shrunk columns at minSize and leaves the remainder for auto-hide when available is below the minSize-sum (#467)', async () => { + // available (1000) is below the 1136px minSize-sum entirely, so every shrinkable column + // must bottom out at exactly minSize — no negative or sub-minSize widths — and the table + // legitimately keeps overflowing; resolving that residual overflow is auto-hide's job, not + // DataTable's. + const available = 1000; + const w = mount(DataTable, { + props: { columns: regressionColumns, rows: [], rowKey: 'id' }, + global: { stubs: { AppIcon: { template: '' } } }, + }); + setViewportWidth(w, available); + await nextTick(); + + const widths = columnWidths(w); + const total = Object.values(widths).reduce((sum, width) => sum + width, 0); + + for (const col of regressionColumns) { + expect(widths[col.key]).toBe(col.minSize); + } + expect(total).toBeGreaterThan(available); + }); + + it('distributes extra space across flex columns exactly as before when the layout has room to expand', async () => { + const w = mount(DataTable, { + props: { + columns: [ + { key: 'name', label: 'Name', size: 300, minSize: 200, maxSize: 640, flex: 1 }, + { key: 'status', label: 'Status', size: 120, minSize: 100, maxSize: 200 }, + ], + rows: [], + rowKey: 'id', + }, + global: { stubs: { AppIcon: { template: '' } } }, + }); + // totalBaseWidth = 300 + 120 = 420; extra = 500 - 420 = 80, all absorbed by the sole flex + // column exactly as resolveColumnWidths() distributed it before the #467 shrink path + // existed. + setViewportWidth(w, 500); + await nextTick(); + + const widths = columnWidths(w); + expect(widths.name).toBe(380); + expect(widths.status).toBe(120); + }); + }); + describe('column resize performance', () => { it('renders resize movement through colgroup instead of header width attributes', async () => { const resizeColumns = [ diff --git a/ui/tests/components/containers/ContainersGroupHeader.spec.ts b/ui/tests/components/containers/ContainersGroupHeader.spec.ts index 19db3d85c..cc9956c10 100644 --- a/ui/tests/components/containers/ContainersGroupHeader.spec.ts +++ b/ui/tests/components/containers/ContainersGroupHeader.spec.ts @@ -87,4 +87,18 @@ describe('ContainersGroupHeader', () => { expect(wrapper.text()).toContain('Update all'); expect(wrapper.text()).not.toContain('Updating stack'); }); + + // #467: on rc.4, existing users' default column set overflows the table at moderate desktop + // widths (see DataTable.spec.ts "column width shrink-to-fit (#467)"), and the group header's + // full-row renders as wide as the overflowing row. Without sticky positioning, the + // "Update all" button scrolls out of the visible area. Pin it to the logical end edge (RTL + // safe) so it stays reachable regardless of overflow. + it('pins the update-all action to the end edge so it stays visible when the row overflows (#467)', () => { + const wrapper = mountHeader(); + const sticky = wrapper.find('[data-test="group-header-update-all-sticky"]'); + + expect(sticky.exists()).toBe(true); + expect(sticky.classes()).toEqual(expect.arrayContaining(['sticky', 'end-0'])); + expect(sticky.find('button').exists()).toBe(true); + }); });