From 7abfe9f7630b9fbf95e8a8c736dbf78c04bf82d8 Mon Sep 17 00:00:00 2001 From: sadpandajoe Date: Wed, 28 Oct 2026 18:19:40 +0000 Subject: [PATCH 01/12] fix(listview): size every column in dataset, dashboard and chart lists The Datasets, Dashboards and Charts list tables pin the Name column to a fixed size but left other columns unsized. With auto table layout, spare container width goes to unsized columns, so Dataset and Dashboard Actions (and Dashboard Last modified, Chart Type) stretched to hundreds of pixels while Name stayed squashed. Give every visible column an explicit size so spare width is distributed proportionally across all of them, keep Name as the widest column, bound the Chart Type column, and use a consistent Actions size across the three pages. Co-Authored-By: Claude Sonnet 5.5 --- .../TableCollection/TableCollection.test.tsx | 19 ++++ .../ChartList/ChartList.columnSizing.test.tsx | 106 ++++++++++++++++++ .../src/pages/ChartList/index.tsx | 3 + .../DashboardList.columnSizing.test.tsx | 89 +++++++++++++++ .../src/pages/DashboardList/index.tsx | 5 + .../DatasetList.columnSizing.test.tsx | 77 +++++++++++++ .../src/pages/DatasetList/index.tsx | 1 + 7 files changed, 300 insertions(+) create mode 100644 superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx create mode 100644 superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 1a61ff141b0d..52efc160ad39 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -20,6 +20,7 @@ import { render, screen, fireEvent } from '@superset-ui/core/spec'; import { renderHook } from '@testing-library/react'; import { TableInstance, useTable } from 'react-table'; import TableCollection from '.'; +import { mapColumns } from './utils'; let defaultProps: any; @@ -421,3 +422,21 @@ test('should render a static Cell node alongside an ordinary accessor value', () // accessor value, confirming the static branch doesn't swallow it. expect(screen.getByText('Line 01 - Col 01')).toBeVisible(); }); + +test('should map a column size token to an explicit column width', () => { + const sizedColumns = [ + { id: 'small', Header: 'Small', accessor: 'small', size: 'sm' }, + { id: 'large', Header: 'Large', accessor: 'large', size: 'xxxl' }, + { id: 'unsized', Header: 'Unsized', accessor: 'unsized' }, + ]; + + const mapped = mapColumns(sizedColumns, []); + + expect(mapped.map(({ key, width }) => ({ key, width }))).toEqual([ + { key: 'small', width: 50 }, + { key: 'large', width: 360 }, + // Without a size the column gets no width, so it absorbs spare table + // width. List views size every visible column to avoid that. + { key: 'unsized', width: undefined }, + ]); +}); diff --git a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx new file mode 100644 index 000000000000..5bace28a6359 --- /dev/null +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -0,0 +1,106 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData'; +import { screen, waitFor } from 'spec/helpers/testing-library'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +import { setupMocks, renderChartList } from './ChartList.testHelpers'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +jest.mock('src/utils/getBootstrapData', () => + mockUserSubjectsBootstrapData([1]), +); + +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; + +const mockUser = { + userId: 1, + firstName: 'Test', + lastName: 'User', + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ['can_export', 'Chart'], + ], + }, +}; + +beforeEach(() => { + setupMocks(); + // Enable the optional Tags and Viewers columns; the card-view default + // stays off so the table is rendered. + mockIsFeatureEnabled.mockImplementation( + flag => + flag === FeatureFlag.TaggingSystem || flag === FeatureFlag.EnableViewers, + ); +}); + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + mockIsFeatureEnabled.mockReset(); +}); + +test('every visible column renders with an explicit width', async () => { + renderChartList(mockUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + const headers = Array.from(table.querySelectorAll('thead th')).map(th => + th.textContent?.trim(), + ); + expect(headers).toEqual( + expect.arrayContaining([ + 'Name', + 'Type', + 'Dataset', + 'On dashboards', + 'Tags', + 'Editors', + 'Viewers', + 'Last modified', + 'Actions', + ]), + ); + + const cols = Array.from(table.querySelectorAll('colgroup col')); + expect(cols).toHaveLength(headers.length); + cols.forEach((col, index) => { + // An unsized column absorbs all spare table width, squashing the + // sized ones (e.g. Name) and inflating the Type column. + expect({ + column: headers[index], + width: (col as HTMLElement).style.width, + }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); + }); +}); diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index b81a7f64804b..fcf02a694b6e 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -579,6 +579,7 @@ function ChartList(props: ChartListProps) { ), Header: t('Type'), accessor: 'viz_type', + size: 'xxl', id: 'viz_type', }, { @@ -643,6 +644,7 @@ function ChartList(props: ChartListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), + size: 'xl', id: 'tags', }, { @@ -668,6 +670,7 @@ function ChartList(props: ChartListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, + size: 'xl', id: 'viewers', }, ] diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx new file mode 100644 index 000000000000..58ea722ea782 --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx @@ -0,0 +1,89 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import { screen, waitFor } from 'spec/helpers/testing-library'; +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +import { + mockAdminUser, + setupMocks, + renderDashboardList, +} from './DashboardList.testHelpers'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; + +beforeEach(() => { + setupMocks(); + // Enable the optional Tags and Viewers columns; the card-view default + // stays off so the table is rendered. + mockIsFeatureEnabled.mockImplementation( + flag => + flag === FeatureFlag.TaggingSystem || flag === FeatureFlag.EnableViewers, + ); +}); + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + mockIsFeatureEnabled.mockReset(); +}); + +test('every visible column renders with an explicit width', async () => { + renderDashboardList(mockAdminUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + const headers = Array.from(table.querySelectorAll('thead th')).map(th => + th.textContent?.trim(), + ); + expect(headers).toEqual( + expect.arrayContaining([ + 'Name', + 'Status', + 'Tags', + 'Editors', + 'Viewers', + 'Last modified', + 'Actions', + ]), + ); + + const cols = Array.from(table.querySelectorAll('colgroup col')); + expect(cols).toHaveLength(headers.length); + cols.forEach((col, index) => { + // An unsized column absorbs all spare table width, squashing the + // sized ones (e.g. Name) and inflating the Actions column. + expect({ + column: headers[index], + width: (col as HTMLElement).style.width, + }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); + }); +}); diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index b307af27c288..5a262212dab6 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -478,6 +478,7 @@ function DashboardList(props: DashboardListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), + size: 'xl', id: 'tags', }, { @@ -489,6 +490,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Editors'), accessor: 'editors', disableSortBy: true, + size: 'xl', id: 'editors', }, ...(isFeatureEnabled(FeatureFlag.EnableViewers) @@ -502,6 +504,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, + size: 'xl', id: 'viewers', }, ] @@ -517,6 +520,7 @@ function DashboardList(props: DashboardListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', + size: 'xl', id: 'changed_on_delta_humanized', }, { @@ -620,6 +624,7 @@ function DashboardList(props: DashboardListProps) { }, Header: t('Actions'), id: 'actions', + size: 'lg', hidden: !canEdit && !canDelete && !canExport, disableSortBy: true, }, diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx new file mode 100644 index 000000000000..735c152bdb17 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx @@ -0,0 +1,77 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { act, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { + setupMocks, + renderDatasetList, + mockAdminUser, +} from './DatasetList.testHelpers'; + +jest.setTimeout(15000); + +beforeEach(() => { + setupMocks(); +}); + +afterEach(async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + window.history.replaceState({}, '', '/'); + fetchMock.clearHistory().removeRoutes(); + jest.restoreAllMocks(); +}); + +test('every visible column renders with an explicit width', async () => { + renderDatasetList(mockAdminUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + const headers = Array.from(table.querySelectorAll('thead th')).map(th => + th.textContent?.trim(), + ); + expect(headers).toEqual( + expect.arrayContaining([ + 'Name', + 'Type', + 'Database', + 'Schema', + 'Editors', + 'Last modified', + 'Actions', + ]), + ); + + const cols = Array.from(table.querySelectorAll('colgroup col')); + expect(cols).toHaveLength(headers.length); + cols.forEach((col, index) => { + // An unsized column absorbs all spare table width, squashing the + // sized ones (e.g. Name) and inflating the Actions column. + expect({ + column: headers[index], + width: (col as HTMLElement).style.width, + }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); + }); +}); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index ffc15ca1b43d..6854c950b5c8 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -1156,6 +1156,7 @@ const DatasetList: FunctionComponent = ({ }, Header: t('Actions'), id: 'actions', + size: 'lg', hidden: !canEdit && !canDelete && !canDuplicate, disableSortBy: true, }, From 031f0ad27fcd7434f59349b432c765d4eb7f2283 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 28 Oct 2026 19:57:26 +0000 Subject: [PATCH 02/12] feat(listview): shared min/max column-width model for CRUD lists List view tables sized columns from per-page `size` hints under an auto table layout, so whichever columns were left unsized absorbed all spare width (huge Actions/Type columns, squashed Name) and every page had to be tuned by hand. Give every ListView column a `layout`: a preset from a single COLUMN_LAYOUT_PRESETS table (fixed widths for icons, status badges, counts, avatar groups and actions; min/max/weight ranges for titles, text, long text, tags and dates) or an explicit fixed / min-max object. TableCollection resolves the widths from the measured container width (water-filling spare width by weight, capping each flexible column at its max until all are capped) and renders them with a fixed table layout, scrolling horizontally only when the minimums don't fit. The legacy `size` token keeps working as a fixed width and is deprecated. Only ListView opts in (`managedColumnWidths`); other TableCollection / TableView callers keep the browser's auto layout. Every list page is migrated to presets, and a guard test keeps legacy `size` tokens out of list pages. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 90 +++++++- .../TableCollection/columnWidths.test.ts | 150 +++++++++++++ .../TableCollection/columnWidths.ts | 206 ++++++++++++++++++ .../src/components/TableCollection/index.tsx | 117 +++++++++- .../src/components/TableCollection/utils.tsx | 61 +++--- .../spec/helpers/listViewColumnWidths.ts | 65 ++++++ .../ListView/ListView.columnLayouts.test.ts | 63 ++++++ .../src/components/ListView/ListView.tsx | 3 + superset-frontend/src/components/index.ts | 1 + .../src/extensions/ExtensionsList.tsx | 6 +- .../src/pages/ActionLog/index.tsx | 11 +- .../src/pages/AlertReportList/index.tsx | 21 +- .../src/pages/AnnotationLayerList/index.tsx | 11 +- .../src/pages/AnnotationList/index.tsx | 9 +- .../src/pages/ArchivedList/index.tsx | 6 +- .../ChartList/ChartList.columnSizing.test.tsx | 54 ++--- .../src/pages/ChartList/index.tsx | 23 +- .../src/pages/CssTemplateList/index.tsx | 9 +- .../DashboardList.columnSizing.test.tsx | 50 ++--- .../src/pages/DashboardList/index.tsx | 19 +- .../src/pages/DatabaseList/index.tsx | 18 +- .../DatasetList.columnSizing.test.tsx | 48 ++-- .../src/pages/DatasetList/index.tsx | 17 +- .../src/pages/ExecutionLogList/index.tsx | 13 +- .../src/pages/GroupsList/index.tsx | 16 +- .../src/pages/QueryHistoryList/index.tsx | 25 ++- .../src/pages/RolesList/index.tsx | 10 +- .../src/pages/RowLevelSecurityList/index.tsx | 17 +- .../src/pages/SavedQueryList/index.tsx | 19 +- superset-frontend/src/pages/Tags/index.tsx | 10 +- .../src/pages/TaskList/index.tsx | 21 +- .../src/pages/ThemeList/index.tsx | 9 +- .../src/pages/UserRegistrations/index.tsx | 10 +- .../src/pages/UsersList/index.tsx | 24 +- 34 files changed, 990 insertions(+), 242 deletions(-) create mode 100644 superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts create mode 100644 superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts create mode 100644 superset-frontend/spec/helpers/listViewColumnWidths.ts create mode 100644 superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 52efc160ad39..02b6dd2f8379 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -20,7 +20,7 @@ import { render, screen, fireEvent } from '@superset-ui/core/spec'; import { renderHook } from '@testing-library/react'; import { TableInstance, useTable } from 'react-table'; import TableCollection from '.'; -import { mapColumns } from './utils'; +import { mapColumns, type ListViewColumn } from './utils'; let defaultProps: any; @@ -435,8 +435,92 @@ test('should map a column size token to an explicit column width', () => { expect(mapped.map(({ key, width }) => ({ key, width }))).toEqual([ { key: 'small', width: 50 }, { key: 'large', width: 360 }, - // Without a size the column gets no width, so it absorbs spare table - // width. List views size every visible column to avoid that. + // Unmanaged tables leave unsized columns to the browser's auto layout. { key: 'unsized', width: undefined }, ]); }); + +const layoutColumns: ListViewColumn[] = [ + { id: 'favorite', Header: 'Fav', accessor: 'favorite', layout: 'icon' }, + { id: 'name', Header: 'Name', accessor: 'name', layout: 'title' }, + { id: 'database', Header: 'Database', accessor: 'database' }, + { id: 'legacy', Header: 'Legacy', accessor: 'legacy', size: 'lg' }, + { id: 'secret', Header: 'Secret', accessor: 'secret', hidden: true }, + { id: 'actions', Header: 'Actions' }, +]; + +function renderLayoutTable( + containerWidth: number, + props: Record = {}, +) { + jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: containerWidth } as DOMRect); + const data = [{ favorite: 'x', name: 'n', database: 'd', legacy: 'l' }]; + const { result } = renderHook(() => + // @ts-expect-error -- raw column configs, as ListView passes them + useTable({ columns: layoutColumns, data }), + ); + const utils = render( + , + ); + const table = screen.getByTestId('listview-table').querySelector('table')!; + const widths = Array.from(table.querySelectorAll('colgroup col')).map( + col => (col as HTMLElement).style.width, + ); + return { ...utils, table, widths }; +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('managed column widths size every visible column to fill the container', () => { + const { table, widths } = renderLayoutTable(1000, { + managedColumnWidths: true, + }); + + expect(table.style.tableLayout).toBe('fixed'); + // Hidden columns are excluded; every visible column gets a px width. + expect(widths).toHaveLength(5); + widths.forEach(width => expect(width).toMatch(/^\d+px$/)); + const px = widths.map(width => parseInt(width, 10)); + expect(px.reduce((a, b) => a + b, 0)).toBe(1000); + // icon, legacy `size` and inferred actions columns stay fixed. + expect([px[0], px[3], px[4]]).toEqual([48, 100, 120]); + // The title flexes but never beyond its max while the text column can grow. + expect(px[1]).toBeLessThanOrEqual(480); + expect(px[1]).toBeGreaterThan(px[2]); +}); + +test('managed column widths fall back to minimums and scroll when narrow', () => { + const { widths } = renderLayoutTable(300, { managedColumnWidths: true }); + expect(widths).toEqual(['48px', '240px', '140px', '100px', '120px']); +}); + +test('managed column widths include the row selection column', () => { + const { widths } = renderLayoutTable(1200, { + managedColumnWidths: true, + bulkSelectEnabled: true, + selectedFlatRows: [], + }); + expect(widths).toHaveLength(6); + const px = widths.map(width => parseInt(width, 10)); + expect(px[0]).toBe(48); + expect(px.reduce((a, b) => a + b, 0)).toBe(1200); +}); + +test('unmanaged tables keep the auto layout', () => { + const { table, widths } = renderLayoutTable(1000); + expect(table.style.tableLayout).not.toBe('fixed'); + // Only the legacy `size` column carries a width (rc-table omits trailing + // width-less s). + expect(widths.filter(Boolean)).toEqual(['100px']); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts new file mode 100644 index 000000000000..172b5391ffbc --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts @@ -0,0 +1,150 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + COLUMN_LAYOUT_PRESETS, + getColumnSpec, + resolveColumnWidths, + type ColumnSpec, +} from './columnWidths'; + +const fixed = (width: number): ColumnSpec => ({ + min: width, + max: width, + weight: 0, +}); +const flex = (min: number, max: number, weight: number): ColumnSpec => ({ + min, + max, + weight, +}); + +const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); + +test('uses minimum widths and overflows when the container is too narrow', () => { + const specs = [fixed(48), flex(240, 480, 3), flex(140, 280, 1), fixed(120)]; + const { widths, totalWidth } = resolveColumnWidths(specs, 400); + expect(widths).toEqual([48, 240, 140, 120]); + expect(totalWidth).toBe(548); +}); + +test('uses minimum widths when the container width is unknown', () => { + const specs = [flex(240, 480, 3), fixed(120)]; + expect(resolveColumnWidths(specs, 0)).toEqual({ + widths: [240, 120], + totalWidth: 360, + }); +}); + +test('fits exactly when the container equals the sum of minimums', () => { + const specs = [flex(240, 480, 3), flex(140, 280, 1), fixed(120)]; + expect(resolveColumnWidths(specs, 500)).toEqual({ + widths: [240, 140, 120], + totalWidth: 500, + }); +}); + +test('distributes spare width by weight', () => { + const specs = [flex(200, 1000, 3), flex(100, 1000, 1), fixed(100)]; + // 400 spare: 300 to the weight-3 column, 100 to the weight-1 column. + const { widths, totalWidth } = resolveColumnWidths(specs, 800); + expect(widths).toEqual([500, 200, 100]); + expect(totalWidth).toBe(800); +}); + +test('caps the title at its max while other flexible columns keep growing', () => { + const specs = [flex(240, 480, 3), flex(140, 280, 1), flex(140, 280, 1)]; + // 520 + 400 spare. Weighted shares would give the title 240 extra (480, + // exactly its cap); the remaining 160 go to the text columns. + const { widths } = resolveColumnWidths(specs, 920); + expect(widths).toEqual([480, 220, 220]); + + // With more spare width the title is frozen at its cap and the rest is + // redistributed to the uncapped columns. + const wider = resolveColumnWidths(specs, 1040); + expect(wider.widths).toEqual([480, 280, 280]); + expect(wider.totalWidth).toBe(1040); +}); + +test('soft-overflows past max by weight once every flexible column is capped', () => { + const specs = [flex(240, 480, 3), flex(140, 280, 1), fixed(120)]; + // Every flexible column at max = 880. 120 left: 90 / 30 by weight. + const { widths, totalWidth } = resolveColumnWidths(specs, 1000); + expect(widths).toEqual([570, 310, 120]); + expect(totalWidth).toBe(1000); +}); + +test('keeps fixed columns constant across container widths', () => { + const specs = [ + fixed(48), + flex(240, 480, 3), + fixed(96), + flex(150, 220, 1), + fixed(120), + ]; + [300, 700, 1024, 1440, 1920, 2560].forEach(width => { + const { widths } = resolveColumnWidths(specs, width); + expect([widths[0], widths[2], widths[4]]).toEqual([48, 96, 120]); + }); +}); + +test('gives the rounding remainder to the highest-weight column', () => { + const specs = [flex(100, 1000, 1), flex(100, 1000, 2), flex(100, 1000, 1)]; + // 301 spare split 1:2:1 = 75.25 / 150.5 / 75.25 -> floored 75/150/75, + // the leftover 1px goes to the weight-2 column. + const { widths, totalWidth } = resolveColumnWidths(specs, 601); + expect(widths).toEqual([175, 251, 175]); + expect(totalWidth).toBe(601); +}); + +test('fills the container when a selection column is included', () => { + const specs = [ + getColumnSpec({ fixed: 48 }), + flex(240, 480, 3), + flex(140, 280, 1), + ]; + [500, 1000, 1920].forEach(width => { + const { widths, totalWidth } = resolveColumnWidths(specs, width); + expect(widths[0]).toBe(48); + expect(sum(widths)).toBe(Math.max(width, 428)); + expect(totalWidth).toBe(sum(widths)); + }); +}); + +test('getColumnSpec resolves presets, explicit layouts and legacy sizes', () => { + expect(getColumnSpec('title')).toEqual( + getColumnSpec(COLUMN_LAYOUT_PRESETS.title), + ); + expect(getColumnSpec({ fixed: 160 })).toEqual(fixed(160)); + expect(getColumnSpec({ min: 100 })).toEqual({ + min: 100, + max: Infinity, + weight: 1, + }); + expect(getColumnSpec({ min: 100, max: 200, weight: 2 })).toEqual( + flex(100, 200, 2), + ); + const presetNames = Object.keys(COLUMN_LAYOUT_PRESETS) as Array< + keyof typeof COLUMN_LAYOUT_PRESETS + >; + presetNames.forEach(name => { + const spec = getColumnSpec(name); + expect(spec.min).toBeGreaterThan(0); + expect(spec.max).toBeGreaterThanOrEqual(spec.min); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts new file mode 100644 index 000000000000..a4c1fc7fe300 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -0,0 +1,206 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Column width model shared by every list view table. + * + * Neither antd nor react-table can express "flex between a min and a max", so + * widths are resolved here from the measured container width and handed to + * antd as explicit pixel widths with a fixed table layout. + */ + +export type ColumnLayoutPreset = + | 'icon' + | 'status' + | 'statusWide' + | 'count' + | 'people' + | 'actions' + | 'actionsWide' + | 'date' + | 'title' + | 'text' + | 'longtext' + | 'tags'; + +export type FixedColumnLayout = { fixed: number }; +export type FlexColumnLayout = { min: number; max?: number; weight?: number }; + +export type ColumnLayout = + | ColumnLayoutPreset + | FixedColumnLayout + | FlexColumnLayout; + +/** + * Single source of truth for list view column widths, in pixels. + * + * Fixed presets are for content that always fits a known width; flexible + * presets start at `min`, share spare width by `weight` and stop growing at + * `max` until every flexible column has reached its max. + * + * - icon: favorite star, kind icon + * - status: status/state badges, active and boolean flags, small enums + * - statusWide: status-like content under a header too long for `status` + * (e.g. "Expose in SQL Lab", "Notification method") + * - count: rows, durations, ids and counts (sized for headers such as + * "Dashboard Id" and the query duration timer label) + * - people: owner/editor/viewer avatar groups + * - actions: hover action icons (up to four) + * - actionsWide: hover action icons (five or six) + * - date: "Last modified" style relative times, optionally with an avatar + * - title: the primary identifier of a row (name/title) + * - text: database, schema, dataset, backend, user names and similar + * - longtext: description, SQL, clause, error message, JSON payload + * - tags: tag chips and other chip lists + */ +export const COLUMN_LAYOUT_PRESETS: Record< + ColumnLayoutPreset, + FixedColumnLayout | Required +> = { + icon: { fixed: 48 }, + status: { fixed: 112 }, + statusWide: { fixed: 168 }, + count: { fixed: 136 }, + people: { fixed: 140 }, + actions: { fixed: 120 }, + actionsWide: { fixed: 168 }, + date: { min: 150, max: 220, weight: 1 }, + title: { min: 240, max: 480, weight: 2 }, + text: { min: 140, max: 280, weight: 1 }, + longtext: { min: 200, max: 560, weight: 1.5 }, + tags: { min: 160, max: 320, weight: 1 }, +}; + +/** Width of antd's row-selection and row-expand columns in managed tables. */ +export const UTILITY_COLUMN_WIDTH = 48; + +type TableSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'; + +/** Legacy `size` tokens, each treated as a fixed width. */ +export const COLUMN_SIZE_MAP: Record = { + xs: 25, + sm: 50, + md: 75, + lg: 100, + xl: 150, + xxl: 200, + xxxl: 360, +}; + +export interface ColumnSpec { + min: number; + max: number; + /** Share of spare width; 0 for fixed columns. */ + weight: number; +} + +export function getColumnSpec(layout: ColumnLayout): ColumnSpec { + const resolved = + typeof layout === 'string' ? COLUMN_LAYOUT_PRESETS[layout] : layout; + if ('fixed' in resolved) { + return { min: resolved.fixed, max: resolved.fixed, weight: 0 }; + } + return { + min: resolved.min, + max: resolved.max ?? Infinity, + weight: resolved.weight ?? 1, + }; +} + +/** + * Resolves the layout of a list view column: an explicit `layout` wins, then + * a legacy `size` token (fixed), then `actions` for the actions column and + * `text` for everything else. + */ +export function getColumnLayout(column: { + id?: string; + layout?: ColumnLayout; + size?: string; +}): ColumnLayout { + if (column.layout) return column.layout; + const legacyWidth = column.size + ? COLUMN_SIZE_MAP[column.size as TableSize] + : undefined; + if (legacyWidth) return { fixed: legacyWidth }; + return column.id === 'actions' ? 'actions' : 'text'; +} + +/** + * Resolves pixel widths for `specs` within `containerWidth`. + * + * Every column starts at its min. Spare width is shared by weight among + * flexible columns, each clamped to its max; once every flexible column is at + * its max the remainder is shared by weight beyond max so the table still + * fills the container. When the container is narrower than the sum of minimums + * (or unknown) the minimums are used and the table scrolls horizontally. + */ +export function resolveColumnWidths( + specs: ColumnSpec[], + containerWidth: number, +): { widths: number[]; totalWidth: number } { + const widths = specs.map(spec => spec.min); + const minTotal = widths.reduce((total, width) => total + width, 0); + const flexIndexes = specs + .map((spec, index) => (spec.weight > 0 ? index : -1)) + .filter(index => index >= 0); + let spare = containerWidth - minTotal; + if (spare <= 0 || flexIndexes.length === 0) { + return { widths, totalWidth: minTotal }; + } + + let growing = flexIndexes.filter(index => specs[index].max > widths[index]); + while (spare > 0 && growing.length > 0) { + const totalWeight = growing.reduce((t, i) => t + specs[i].weight, 0); + const capped = growing.filter( + index => + widths[index] + (spare * specs[index].weight) / totalWeight >= + specs[index].max, + ); + if (capped.length === 0) { + const available = spare; + growing.forEach(index => { + widths[index] += (available * specs[index].weight) / totalWeight; + }); + spare = 0; + } else { + capped.forEach(index => { + spare -= specs[index].max - widths[index]; + widths[index] = specs[index].max; + }); + growing = growing.filter(index => !capped.includes(index)); + } + } + + if (spare > 0) { + const totalWeight = flexIndexes.reduce((t, i) => t + specs[i].weight, 0); + const available = spare; + flexIndexes.forEach(index => { + widths[index] += (available * specs[index].weight) / totalWeight; + }); + } + + const floored = widths.map(Math.floor); + const remainder = + containerWidth - floored.reduce((total, width) => total + width, 0); + const heaviest = flexIndexes.reduce((best, index) => + specs[index].weight > specs[best].weight ? index : best, + ); + floored[heaviest] += remainder; + return { widths: floored, totalWidth: containerWidth }; +} diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index bc6d511fef5e..2820d4118972 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -16,7 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { HTMLAttributes, memo, useMemo, useCallback } from 'react'; +import { + HTMLAttributes, + memo, + useMemo, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { HeaderGroup, Row, @@ -32,9 +40,21 @@ import { SorterResult, } from 'antd/es/table/interface'; import type { TableProps } from 'antd/es/table'; -import { mapColumns, mapRows, type ListViewColumn } from './utils'; +import { getColumnId, mapColumns, mapRows, type ListViewColumn } from './utils'; +import { + UTILITY_COLUMN_WIDTH, + getColumnLayout, + getColumnSpec, + resolveColumnWidths, +} from './columnWidths'; export type { ListViewColumn }; +export type { ColumnLayout, ColumnLayoutPreset } from './columnWidths'; +export { + COLUMN_LAYOUT_PRESETS, + getColumnSpec, + resolveColumnWidths, +} from './columnWidths'; export interface TableCollectionProps { getTableProps: TablePropGetter; @@ -64,6 +84,35 @@ export interface TableCollectionProps { isPaginationSticky?: boolean; showRowCount?: boolean; expandable?: Record; + /** + * Resolve every visible column's width from its `layout` against the + * measured container width, with a fixed table layout. Off by default so + * generic tables keep the browser's auto layout. + */ + managedColumnWidths?: boolean; +} + +/** + * Tracks the width of `ref`'s element. Measured before paint so managed + * widths don't flash; 0 when the width can't be measured (e.g. in jsdom). + */ +function useContainerWidth( + ref: { current: HTMLElement | null }, + enabled: boolean, +) { + const [width, setWidth] = useState(0); + useLayoutEffect(() => { + const element = ref.current; + if (!enabled || !element) return undefined; + const measure = () => + setWidth(Math.floor(element.getBoundingClientRect().width)); + measure(); + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [ref, enabled]); + return width; } const StyledTable = styled(Table)<{ @@ -184,10 +233,49 @@ function TableCollection({ isPaginationSticky = false, showRowCount = true, expandable, + managedColumnWidths = false, }: TableCollectionProps) { + const containerRef = useRef(null); + const containerWidth = useContainerWidth(containerRef, managedColumnWidths); + + const columnLayout = useMemo(() => { + if (!managedColumnWidths) return undefined; + const visibleColumns = columns.filter(column => !column.hidden); + const utilityColumns = (bulkSelectEnabled ? 1 : 0) + (expandable ? 1 : 0); + const specs = [ + ...Array(utilityColumns).fill( + getColumnSpec({ fixed: UTILITY_COLUMN_WIDTH }), + ), + ...visibleColumns.map(column => getColumnSpec(getColumnLayout(column))), + ]; + const { widths, totalWidth } = resolveColumnWidths(specs, containerWidth); + const columnWidths = widths.slice(utilityColumns); + return { + totalWidth, + widths: Object.fromEntries( + visibleColumns.map((column, index) => [ + getColumnId(column), + columnWidths[index], + ]), + ), + }; + }, [ + managedColumnWidths, + columns, + bulkSelectEnabled, + expandable, + containerWidth, + ]); + const mappedColumns = useMemo( - () => mapColumns(columns, headerGroups, columnsForWrapText), - [columns, headerGroups, columnsForWrapText], + () => + mapColumns( + columns, + headerGroups, + columnsForWrapText, + columnLayout?.widths, + ), + [columns, headerGroups, columnsForWrapText, columnLayout], ); const mappedRows = useMemo( @@ -213,6 +301,7 @@ function TableCollection({ // so wrapping per-row checkboxes there is safe. return { selectedRowKeys, + ...(managedColumnWidths && { columnWidth: UTILITY_COLUMN_WIDTH }), onSelect: (record, selected) => { toggleRowSelected?.(record.rowId, selected); }, @@ -228,8 +317,17 @@ function TableCollection({ selectedRowKeys, toggleRowSelected, toggleAllRowsSelected, + managedColumnWidths, ]); + const tableExpandable = useMemo( + () => + expandable && managedColumnWidths + ? { columnWidth: UTILITY_COLUMN_WIDTH, ...expandable } + : expandable, + [expandable, managedColumnWidths], + ); + const handlePaginationChange = useCallback( (page: number, size: number) => { const validPage = Math.max(0, (page || 1) - 1); @@ -338,7 +436,7 @@ function TableCollection({ [], ); - return ( + const table = ( ({ size={size} data-test="listview-table" pagination={paginationConfig} - scroll={{ x: 'max-content' }} - tableLayout="auto" + scroll={{ x: columnLayout ? columnLayout.totalWidth : 'max-content' }} + tableLayout={columnLayout ? 'fixed' : 'auto'} rowKey="rowId" rowSelection={rowSelection} locale={{ emptyText: null }} @@ -361,11 +459,14 @@ function TableCollection({ rowClassName={ getRowClassName as unknown as TableProps['rowClassName'] } - expandable={expandable} + expandable={tableExpandable} components={tableComponents} onChange={handleTableChange as unknown as TableProps['onChange']} /> ); + + if (!managedColumnWidths) return table; + return
{table}
; } // React.memo erases the wrapped component's generic type parameter; cast diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx index 2ff923e6ad96..704b4a2f30db 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx @@ -26,21 +26,12 @@ import { ComponentType, ReactNode } from 'react'; import { Accessor, CellValue, HeaderGroup, Row } from 'react-table'; import { SortOrder } from '../Table'; +import { COLUMN_SIZE_MAP, type ColumnLayout } from './columnWidths'; -type TableSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl'; +type TableSize = keyof typeof COLUMN_SIZE_MAP; type RowWithId = Row & { rowId: string }; -const COLUMN_SIZE_MAP: Record = { - xs: 25, - sm: 50, - md: 75, - lg: 100, - xl: 150, - xxl: 200, - xxxl: 360, -}; - // Mirrors react-table's `Renderer` (a component, a render function, // or a static node) loosely enough that both a hand-authored column config // and a `ColumnInstance` produced by `useTable()` satisfy it, so callers @@ -67,10 +58,39 @@ export interface ListViewColumn { Cell?: ColumnRenderer; disableSortBy?: boolean; hidden?: boolean; + /** + * Width behavior of the column in list views: a preset from + * `COLUMN_LAYOUT_PRESETS` or an explicit fixed / min-max layout. Defaults + * to `actions` for the `actions` column and `text` otherwise. + */ + layout?: ColumnLayout; + /** + * @deprecated Use `layout`. Treated as a fixed width from the legacy size + * token map. + */ size?: string; className?: string; } +/** + * The key of a column: its `id`, or its string `accessor`. + * + * react-table backfills `id` from a string `accessor` for its own internal + * column instances, but callers routinely author raw column configs with + * only `accessor` set. Falling back to it keeps every column's antd + * `key`/`dataIndex` unique; without it, id-less columns all collapse onto the + * same (undefined) key and antd can't tell them apart for sorting. + */ +export function getColumnId( + column: ListViewColumn, +): string { + return ( + column.id ?? + (typeof column.accessor === 'string' ? column.accessor : undefined) ?? + '' + ); +} + function getSortingInfo( headerGroups: HeaderGroup[], headerId: string, @@ -94,28 +114,19 @@ export function mapColumns( columns: ListViewColumn[], headerGroups: HeaderGroup[], columnsForWrapText?: string[], + resolvedWidths?: Record, ) { return columns.map(column => { - // react-table backfills `id` from a string `accessor` for its own - // internal column instances, but callers routinely author raw column - // configs (the shape this function actually receives, per - // `ListViewColumn`) with only `accessor` set. Falling back to it here - // keeps every column's antd `key`/`dataIndex` unique; without it, - // id-less columns all collapse onto the same (undefined) key and antd - // can't tell them apart for sorting. - const id = - column.id ?? - (typeof column.accessor === 'string' ? column.accessor : undefined) ?? - ''; + const id = getColumnId(column); const { isSorted, isSortedDesc } = getSortingInfo(headerGroups, id); return { title: column.Header as ReactNode, dataIndex: id.includes('.') ? id.split('.') : id, hidden: column.hidden, key: id, - width: column.size - ? COLUMN_SIZE_MAP[column.size as TableSize] - : undefined, + width: + resolvedWidths?.[id] ?? + (column.size ? COLUMN_SIZE_MAP[column.size as TableSize] : undefined), ellipsis: !columnsForWrapText?.includes(id), defaultSortOrder: (isSorted ? isSortedDesc diff --git a/superset-frontend/spec/helpers/listViewColumnWidths.ts b/superset-frontend/spec/helpers/listViewColumnWidths.ts new file mode 100644 index 000000000000..2aef495fb5db --- /dev/null +++ b/superset-frontend/spec/helpers/listViewColumnWidths.ts @@ -0,0 +1,65 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + getColumnSpec, + type ColumnLayout, +} from '@superset-ui/core/components/TableCollection'; + +/** + * jsdom has no layout, so list view tables can't measure their container. + * Pins every element's measured width so managed column widths resolve + * against `width`. Call before rendering; restored by `jest.restoreAllMocks`. + */ +export function mockListViewContainerWidth(width: number) { + jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width } as DOMRect); +} + +/** + * Asserts that every rendered column of a list view `table` has a pixel width + * within the bounds of its expected layout, and that the widths fill + * `containerWidth`. + */ +export function expectColumnWidthsWithinLayouts( + table: HTMLElement, + expectedLayouts: Record, + containerWidth: number, +) { + const headers = Array.from(table.querySelectorAll('thead th')).map( + th => th.textContent?.trim() ?? '', + ); + const widths = Array.from(table.querySelectorAll('colgroup col')).map(col => + parseInt((col as HTMLElement).style.width, 10), + ); + expect(headers).toEqual(Object.keys(expectedLayouts)); + expect(widths).toHaveLength(headers.length); + + headers.forEach((header, index) => { + const { min, max } = getColumnSpec(expectedLayouts[header]); + const width = widths[index]; + expect({ header, withinLayout: width >= min && width <= max }).toEqual({ + header, + withinLayout: true, + }); + }); + expect(widths.reduce((total, width) => total + width, 0)).toBe( + containerWidth, + ); +} diff --git a/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts new file mode 100644 index 000000000000..a320ccdc3a14 --- /dev/null +++ b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts @@ -0,0 +1,63 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { readdirSync, readFileSync } from 'fs'; +import { join, relative } from 'path'; +import { COLUMN_LAYOUT_PRESETS } from '@superset-ui/core/components/TableCollection'; + +const SRC_ROOT = join(__dirname, '..', '..'); + +function listSourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return listSourceFiles(path); + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) + ? [path] + : []; + }); +} + +// Every page that renders the shared ListView table. +const listViewPages = listSourceFiles(SRC_ROOT) + .filter(path => !path.includes(join('components', 'ListView'))) + .map(path => ({ + path: relative(SRC_ROOT, path), + source: readFileSync(path, 'utf8'), + })) + .filter(({ source }) => / { + expect(listViewPages.length).toBeGreaterThanOrEqual(20); +}); + +test.each(listViewPages.map(({ path, source }) => [path, source]))( + '%s sizes columns with layout presets, not legacy size tokens', + (_path, source) => { + // Legacy `size` tokens are fixed widths that bypass the shared presets. + expect(source).not.toMatch(/^\s*size: '(xs|sm|md|lg|xl|xxl|xxxl)',$/m); + + const presets = Array.from( + source.matchAll(/^\s*layout: '(\w+)'(?: as const)?,$/gm), + match => match[1], + ); + expect(presets.length).toBeGreaterThan(0); + presets.forEach(preset => + expect(Object.keys(COLUMN_LAYOUT_PRESETS)).toContain(preset), + ); + }, +); diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 3c48a45c5f5d..23501a9f8f61 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -316,6 +316,8 @@ const ViewModeToggle = ({ ); +export type { ListViewColumn }; + export interface ListViewProps { columns: ListViewColumn[]; data: T[]; @@ -667,6 +669,7 @@ export function ListView({ isRowHighlighted={isRowHighlighted} columnsForWrapText={columnsForWrapText} expandable={expandable} + managedColumnWidths bulkSelectEnabled={bulkSelectEnabled} selectedFlatRows={selectedFlatRows} toggleRowSelected={(rowId, value) => { diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts index 0484b69ed94b..4884634d5ce9 100644 --- a/superset-frontend/src/components/index.ts +++ b/superset-frontend/src/components/index.ts @@ -26,6 +26,7 @@ export { DashboardCrossLinks, ListViewFilterOperator, type ListViewProps, + type ListViewColumn, type ListViewActionProps, type ListViewFilters, type ListViewFilter, diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx index 93e61607ae70..48f605339fb6 100644 --- a/superset-frontend/src/extensions/ExtensionsList.tsx +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -23,7 +23,7 @@ import { Loading } from '@superset-ui/core/components'; import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { createErrorHandler } from 'src/views/CRUD/utils'; -import { ListView } from 'src/components'; +import { ListView, type ListViewColumn } from 'src/components'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; @@ -86,12 +86,12 @@ const ExtensionsList: FunctionComponent = ({ ); }, []); - const columns = useMemo( + const columns = useMemo[]>( () => [ { Header: t('Name'), accessor: 'name', - size: 'lg', + layout: 'title', id: 'name', Cell: ({ row: { diff --git a/superset-frontend/src/pages/ActionLog/index.tsx b/superset-frontend/src/pages/ActionLog/index.tsx index 433dae5c9fdd..a845f30d6bc2 100644 --- a/superset-frontend/src/pages/ActionLog/index.tsx +++ b/superset-frontend/src/pages/ActionLog/index.tsx @@ -24,6 +24,7 @@ import { useListViewResource } from 'src/views/CRUD/hooks'; import { useToasts } from 'src/components/MessageToasts/withToasts'; import { ListView, + type ListViewColumn, ListViewFilterOperator, type ListViewFilter, } from 'src/components/ListView'; @@ -149,11 +150,12 @@ function ActionLogList() { [], ); - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'action', Header: t('Action'), + layout: 'text', Cell: ({ row: { original: { action }, @@ -163,6 +165,7 @@ function ActionLogList() { { accessor: 'user', Header: t('User'), + layout: 'text', // The log API only orders by scalar fields (e.g. `user.username`), // not the `user` relationship itself. disableSortBy: true, @@ -191,6 +194,7 @@ function ActionLogList() { { accessor: 'duration_ms', Header: t('Duration Ms'), + layout: 'count', Cell: ({ row: { @@ -201,6 +205,7 @@ function ActionLogList() { { accessor: 'dashboard_id', Header: t('Dashboard Id'), + layout: 'count', hidden: false, Cell: ({ row: { @@ -211,6 +216,7 @@ function ActionLogList() { { accessor: 'slice_id', Header: t('Slice Id'), + layout: 'count', hidden: false, Cell: ({ row: { @@ -221,6 +227,7 @@ function ActionLogList() { { accessor: 'json', Header: t('JSON'), + layout: 'longtext', Cell: ({ row: { @@ -267,6 +274,7 @@ function ActionLogList() { { accessor: 'referrer', Header: t('Referrer'), + layout: 'text', Cell: ({ row: { @@ -297,6 +305,7 @@ function ActionLogList() { { accessor: 'dttm', Header: t('Dttm'), + layout: 'date', Cell: ({ row: { original: { dttm }, diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index 8d43c343708e..16c257f5bfde 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -36,6 +36,7 @@ import { import { ModifiedInfo, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, ListViewActionsBar, type ListViewActionProps, @@ -311,7 +312,7 @@ function AlertList({ [alerts, setResourceCollection, updateResource], ); - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -325,7 +326,7 @@ function AlertList({ /> ), accessor: 'last_state', - size: 'xs', + layout: 'icon', disableSortBy: true, id: 'last_state', }, @@ -343,19 +344,19 @@ function AlertList({ : '', accessor: 'last_eval_dttm', Header: t('Last run'), - size: 'lg', + layout: 'date', id: 'last_eval_dttm', }, { accessor: 'name', Header: t('Name'), - size: 'xxl', + layout: 'title', id: 'name', }, { Header: t('Schedule'), accessor: 'crontab_humanized', - size: 'xl', + layout: 'text', Cell: ({ row: { original: { crontab_humanized = '', timezone }, @@ -382,7 +383,7 @@ function AlertList({ accessor: 'recipients', Header: t('Notification method'), disableSortBy: true, - size: 'lg', + layout: 'statusWide', id: 'recipients', }, { @@ -394,7 +395,7 @@ function AlertList({ Header: t('Editors'), id: 'editors', disableSortBy: true, - size: 'xl', + layout: 'people', }, { Cell: ({ @@ -407,7 +408,7 @@ function AlertList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -430,7 +431,7 @@ function AlertList({ Header: t('Active'), accessor: 'active', id: 'active', - size: 'sm', + layout: 'status', }, { Cell: ({ row: { original } }: any) => { @@ -492,7 +493,7 @@ function AlertList({ id: 'actions', hidden: !canEdit && !canDelete, disableSortBy: true, - size: 'lg', + layout: 'actions', }, { accessor: QueryObjectColumns.ChangedBy, diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index 9dca6fe73bf4..9e40a72fcea7 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -32,6 +32,7 @@ import { DeleteModal, ConfirmStatusChange } from '@superset-ui/core/components'; import { ModifiedInfo, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, ListViewActionsBar, type ListViewActionProps, @@ -130,7 +131,7 @@ function AnnotationLayersList({ } const initialSort = [{ id: 'name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'name', @@ -161,13 +162,13 @@ function AnnotationLayersList({ ); }, - size: 'xxl', + layout: 'title', id: 'name', }, { accessor: 'descr', Header: t('Description'), - size: 'xl', + layout: 'longtext', id: 'descr', }, { @@ -181,7 +182,7 @@ function AnnotationLayersList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on', - size: 'xl', + layout: 'date', id: 'changed_on', }, { @@ -218,7 +219,7 @@ function AnnotationLayersList({ id: 'actions', disableSortBy: true, hidden: !canEdit && !canDelete, - size: 'xl', + layout: 'actions', }, { accessor: QueryObjectColumns.ChangedBy, diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index 948b1284a818..b83a64068676 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -28,6 +28,7 @@ import rison from 'rison'; import { ConfirmStatusChange, DeleteModal } from '@superset-ui/core/components'; import { ListView, + type ListViewColumn, ListViewActionsBar, type ListViewProps, type ListViewActionProps, @@ -157,18 +158,19 @@ function AnnotationList({ }, [fetchAnnotationLayer]); const initialSort = [{ id: 'short_descr', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'short_descr', Header: t('Name'), - size: 'xxl', + layout: 'title', id: 'short_descr', }, { accessor: 'long_descr', Header: t('Description'), id: 'long_descr', + layout: 'longtext', }, { Cell: ({ @@ -181,6 +183,7 @@ function AnnotationList({ Header: t('Start'), accessor: 'start_dttm', id: 'start_dttm', + layout: 'date', }, { Cell: ({ @@ -193,6 +196,7 @@ function AnnotationList({ Header: t('End'), accessor: 'end_dttm', id: 'end_dttm', + layout: 'date', }, { Cell: ({ @@ -224,6 +228,7 @@ function AnnotationList({ }, Header: t('Actions'), id: 'actions', + layout: 'actions', disableSortBy: true, }, ], diff --git a/superset-frontend/src/pages/ArchivedList/index.tsx b/superset-frontend/src/pages/ArchivedList/index.tsx index c1e1f45bbbb8..2dab617df98a 100644 --- a/superset-frontend/src/pages/ArchivedList/index.tsx +++ b/superset-frontend/src/pages/ArchivedList/index.tsx @@ -454,11 +454,13 @@ function ArchivedListBody({ accessor: config.nameField, Header: t('Name'), id: config.nameField, + layout: 'title', }, { Cell: () => TYPE_LABELS[type](), Header: t('Type'), id: 'type', + layout: 'status', disableSortBy: true, }, { @@ -473,6 +475,7 @@ function ArchivedListBody({ String(original.deleted_at_delta_humanized ?? ''), Header: t('Archived'), id: 'deleted_at', + layout: 'date', }, { // Archiving user, from changed_by. Non-sortable — there is no backend @@ -488,6 +491,7 @@ function ArchivedListBody({ }, Header: t('Archived by'), id: 'archived_by', + layout: 'text', disableSortBy: true, }, { @@ -505,7 +509,7 @@ function ArchivedListBody({ id: 'actions', hidden: !canWrite, disableSortBy: true, - size: 'sm', + layout: 'actions', }, ], [ diff --git a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx index 5bace28a6359..187489e406fa 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -21,6 +21,12 @@ import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData'; import { screen, waitFor } from 'spec/helpers/testing-library'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { setupMocks, renderChartList } from './ChartList.testHelpers'; +import { + expectColumnWidthsWithinLayouts, + mockListViewContainerWidth, +} from 'spec/helpers/listViewColumnWidths'; + +const CONTAINER_WIDTH = 1440; jest.setTimeout(30000); @@ -64,9 +70,11 @@ beforeEach(() => { afterEach(() => { fetchMock.clearHistory().removeRoutes(); mockIsFeatureEnabled.mockReset(); + jest.restoreAllMocks(); }); -test('every visible column renders with an explicit width', async () => { +test('every visible column resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); renderChartList(mockUser); const table = await screen.findByTestId('listview-table'); @@ -76,31 +84,23 @@ test('every visible column renders with an explicit width', async () => { ).toBeGreaterThan(0), ); - const headers = Array.from(table.querySelectorAll('thead th')).map(th => - th.textContent?.trim(), - ); - expect(headers).toEqual( - expect.arrayContaining([ - 'Name', - 'Type', - 'Dataset', - 'On dashboards', - 'Tags', - 'Editors', - 'Viewers', - 'Last modified', - 'Actions', - ]), + // Every column is sized from its layout preset, so no column absorbs the + // spare width (squashing Name or inflating Actions) and the table fills + // the container exactly. + expectColumnWidthsWithinLayouts( + table, + { + '': 'icon', + Name: 'title', + Type: 'text', + Dataset: 'text', + 'On dashboards': 'tags', + Tags: 'tags', + Editors: 'people', + Viewers: 'people', + 'Last modified': 'date', + Actions: 'actions', + }, + CONTAINER_WIDTH, ); - - const cols = Array.from(table.querySelectorAll('colgroup col')); - expect(cols).toHaveLength(headers.length); - cols.forEach((col, index) => { - // An unsized column absorbs all spare table width, squashing the - // sized ones (e.g. Name) and inflating the Type column. - expect({ - column: headers[index], - width: (col as HTMLElement).style.width, - }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); - }); }); diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index fcf02a694b6e..e9271f40860b 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -72,6 +72,7 @@ import { TagsList, TagType, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, DashboardCrossLinks, type ListViewProps, @@ -501,7 +502,7 @@ function ChartList(props: ChartListProps) { [addDangerToast], ); - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -519,7 +520,7 @@ function ChartList(props: ChartListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'sm', + layout: 'icon', hidden: !userId, }, { @@ -555,7 +556,7 @@ function ChartList(props: ChartListProps) { ), Header: t('Name'), accessor: 'slice_name', - size: 'xxxl', + layout: 'title', id: 'slice_name', }, { @@ -579,7 +580,7 @@ function ChartList(props: ChartListProps) { ), Header: t('Type'), accessor: 'viz_type', - size: 'xxl', + layout: 'text', id: 'viz_type', }, { @@ -608,7 +609,7 @@ function ChartList(props: ChartListProps) { Header: datasetLabel(), accessor: 'datasource_id', disableSortBy: true, - size: 'xl', + layout: 'text', id: 'datasource_id', }, { @@ -620,7 +621,7 @@ function ChartList(props: ChartListProps) { Header: t('On dashboards'), accessor: 'dashboards', disableSortBy: true, - size: 'xl', + layout: 'tags', id: 'dashboards', }, { @@ -644,7 +645,7 @@ function ChartList(props: ChartListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), - size: 'xl', + layout: 'tags', id: 'tags', }, { @@ -656,7 +657,7 @@ function ChartList(props: ChartListProps) { Header: t('Editors'), accessor: 'editors', disableSortBy: true, - size: 'xl', + layout: 'people', id: 'editors', }, ...(isFeatureEnabled(FeatureFlag.EnableViewers) @@ -670,7 +671,7 @@ function ChartList(props: ChartListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, - size: 'xl', + layout: 'people' as const, id: 'viewers', }, ] @@ -686,7 +687,7 @@ function ChartList(props: ChartListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -759,7 +760,7 @@ function ChartList(props: ChartListProps) { }, Header: t('Actions'), id: 'actions', - size: 'lg', + layout: 'actions', disableSortBy: true, hidden: !canEdit && !canDelete, }, diff --git a/superset-frontend/src/pages/CssTemplateList/index.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx index 1326ebaf26eb..baa671208c33 100644 --- a/superset-frontend/src/pages/CssTemplateList/index.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -30,6 +30,7 @@ import { DeleteModal, ConfirmStatusChange } from '@superset-ui/core/components'; import { ModifiedInfo, ListView, + type ListViewColumn, ListViewActionsBar, ListViewFilterOperator as FilterOperator, type ListViewProps, @@ -128,12 +129,12 @@ function CssTemplatesList({ } const initialSort = [{ id: 'template_name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'template_name', Header: t('Name'), - size: 'xxl', + layout: 'title', id: 'template_name', }, { @@ -147,7 +148,7 @@ function CssTemplatesList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', disableSortBy: true, id: 'changed_on_delta_humanized', }, @@ -185,7 +186,7 @@ function CssTemplatesList({ id: 'actions', disableSortBy: true, hidden: !canEdit && !canDelete, - size: 'xl', + layout: 'actions', }, { accessor: QueryObjectColumns.ChangedBy, diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx index 58ea722ea782..798addd53a7d 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx @@ -24,6 +24,12 @@ import { setupMocks, renderDashboardList, } from './DashboardList.testHelpers'; +import { + expectColumnWidthsWithinLayouts, + mockListViewContainerWidth, +} from 'spec/helpers/listViewColumnWidths'; + +const CONTAINER_WIDTH = 1440; jest.setTimeout(30000); @@ -49,9 +55,11 @@ beforeEach(() => { afterEach(() => { fetchMock.clearHistory().removeRoutes(); mockIsFeatureEnabled.mockReset(); + jest.restoreAllMocks(); }); -test('every visible column renders with an explicit width', async () => { +test('every visible column resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); renderDashboardList(mockAdminUser); const table = await screen.findByTestId('listview-table'); @@ -61,29 +69,21 @@ test('every visible column renders with an explicit width', async () => { ).toBeGreaterThan(0), ); - const headers = Array.from(table.querySelectorAll('thead th')).map(th => - th.textContent?.trim(), + // Every column is sized from its layout preset, so no column absorbs the + // spare width (squashing Name or inflating Actions) and the table fills + // the container exactly. + expectColumnWidthsWithinLayouts( + table, + { + '': 'icon', + Name: 'title', + Status: 'status', + Tags: 'tags', + Editors: 'people', + Viewers: 'people', + 'Last modified': 'date', + Actions: 'actions', + }, + CONTAINER_WIDTH, ); - expect(headers).toEqual( - expect.arrayContaining([ - 'Name', - 'Status', - 'Tags', - 'Editors', - 'Viewers', - 'Last modified', - 'Actions', - ]), - ); - - const cols = Array.from(table.querySelectorAll('colgroup col')); - expect(cols).toHaveLength(headers.length); - cols.forEach((col, index) => { - // An unsized column absorbs all spare table width, squashing the - // sized ones (e.g. Name) and inflating the Actions column. - expect({ - column: headers[index], - width: (col as HTMLElement).style.width, - }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); - }); }); diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 5a262212dab6..96c7c4359e25 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -57,6 +57,7 @@ import { ModifiedInfo, ImportModal as ImportModelsModal, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, type ListViewProps, type ListViewFilter, @@ -385,7 +386,7 @@ function DashboardList(props: DashboardListProps) { ); } - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -403,7 +404,7 @@ function DashboardList(props: DashboardListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'sm', + layout: 'icon', hidden: !user?.userId, }, { @@ -435,7 +436,7 @@ function DashboardList(props: DashboardListProps) { ), Header: t('Name'), accessor: 'dashboard_title', - size: 'xxxl', + layout: 'title', id: 'dashboard_title', }, { @@ -448,7 +449,7 @@ function DashboardList(props: DashboardListProps) { ), Header: t('Status'), accessor: 'published', - size: 'sm', + layout: 'status', id: 'published', className: 'no-ellipsis', }, @@ -478,7 +479,7 @@ function DashboardList(props: DashboardListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), - size: 'xl', + layout: 'tags', id: 'tags', }, { @@ -490,7 +491,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Editors'), accessor: 'editors', disableSortBy: true, - size: 'xl', + layout: 'people', id: 'editors', }, ...(isFeatureEnabled(FeatureFlag.EnableViewers) @@ -504,7 +505,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, - size: 'xl', + layout: 'people' as const, id: 'viewers', }, ] @@ -520,7 +521,7 @@ function DashboardList(props: DashboardListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -624,7 +625,7 @@ function DashboardList(props: DashboardListProps) { }, Header: t('Actions'), id: 'actions', - size: 'lg', + layout: 'actions', hidden: !canEdit && !canDelete && !canExport, disableSortBy: true, }, diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index 921ed517161b..ecfd4e90cc0c 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -52,6 +52,7 @@ import { import { ModifiedInfo, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, ListViewFilters, type ListViewFetchDataConfig, @@ -837,12 +838,12 @@ function DatabaseList({ } } - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'database_name', Header: t('Name'), - size: 'xxl', + layout: 'title', id: 'database_name', }, { @@ -850,7 +851,7 @@ function DatabaseList({ // i18n: the kind of system behind a connection: a database engine // (PostgreSQL, MySQL) or a semantic layer; not a server tier or a driver Header: t('Backend'), - size: 'xl', + layout: 'text', disableSortBy: true, id: 'backend', }, @@ -871,7 +872,7 @@ function DatabaseList({ ) : ( ), - size: 'sm', + layout: 'status', id: 'allow_run_async', }, { @@ -891,7 +892,7 @@ function DatabaseList({ ) : ( ), - size: 'sm', + layout: 'status', id: 'allow_dml', }, { @@ -903,7 +904,7 @@ function DatabaseList({ ) : ( ), - size: 'md', + layout: 'status', id: 'allow_file_upload', }, { @@ -915,7 +916,7 @@ function DatabaseList({ ) : ( ), - size: 'md', + layout: 'statusWide', id: 'expose_in_sqllab', }, { @@ -931,7 +932,7 @@ function DatabaseList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -1042,6 +1043,7 @@ function DatabaseList({ }, Header: t('Actions'), id: 'actions', + layout: 'actions', hidden: !canEdit && !canDelete && diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx index 735c152bdb17..a774f81072b6 100644 --- a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx @@ -23,6 +23,12 @@ import { renderDatasetList, mockAdminUser, } from './DatasetList.testHelpers'; +import { + expectColumnWidthsWithinLayouts, + mockListViewContainerWidth, +} from 'spec/helpers/listViewColumnWidths'; + +const CONTAINER_WIDTH = 1440; jest.setTimeout(15000); @@ -39,7 +45,8 @@ afterEach(async () => { jest.restoreAllMocks(); }); -test('every visible column renders with an explicit width', async () => { +test('every visible column resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); renderDatasetList(mockAdminUser); const table = await screen.findByTestId('listview-table'); @@ -49,29 +56,20 @@ test('every visible column renders with an explicit width', async () => { ).toBeGreaterThan(0), ); - const headers = Array.from(table.querySelectorAll('thead th')).map(th => - th.textContent?.trim(), - ); - expect(headers).toEqual( - expect.arrayContaining([ - 'Name', - 'Type', - 'Database', - 'Schema', - 'Editors', - 'Last modified', - 'Actions', - ]), + // Every column is sized from its layout preset, so no column absorbs the + // spare width (squashing Name or inflating Actions) and the table fills + // the container exactly. + expectColumnWidthsWithinLayouts( + table, + { + Name: 'title', + Type: 'status', + Database: 'text', + Schema: 'text', + Editors: 'people', + 'Last modified': 'date', + Actions: 'actions', + }, + CONTAINER_WIDTH, ); - - const cols = Array.from(table.querySelectorAll('colgroup col')); - expect(cols).toHaveLength(headers.length); - cols.forEach((col, index) => { - // An unsized column absorbs all spare table width, squashing the - // sized ones (e.g. Name) and inflating the Actions column. - expect({ - column: headers[index], - width: (col as HTMLElement).style.width, - }).toEqual({ column: headers[index], width: expect.stringMatching(/px$/) }); - }); }); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 6854c950b5c8..74dfe90cd118 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -70,6 +70,7 @@ import { ListView, ListViewFilterOperator as FilterOperator, type ListViewProps, + type ListViewColumn, type ListViewFilters, type ListViewFetchDataConfig, } from 'src/components'; @@ -863,7 +864,7 @@ const DatasetList: FunctionComponent = ({ } }; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'id', @@ -945,7 +946,7 @@ const DatasetList: FunctionComponent = ({ }, Header: t('Name'), accessor: 'table_name', - size: 'xxxl', + layout: 'title', id: 'table_name', }, { @@ -962,7 +963,7 @@ const DatasetList: FunctionComponent = ({ Header: t('Type'), accessor: 'kind', disableSortBy: true, - size: 'sm', + layout: 'status', id: 'kind', }, { @@ -973,7 +974,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => database?.database_name || '-', Header: databaseLabel(), accessor: 'database.database_name', - size: 'xl', + layout: 'text', id: 'database.database_name', }, { @@ -984,7 +985,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => schema || '-', Header: t('Schema'), accessor: 'schema', - size: 'lg', + layout: 'text', id: 'schema', }, { @@ -1002,7 +1003,7 @@ const DatasetList: FunctionComponent = ({ Header: t('Editors'), id: 'editors', disableSortBy: true, - size: 'lg', + layout: 'people', }, { Cell: ({ @@ -1017,7 +1018,7 @@ const DatasetList: FunctionComponent = ({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -1156,7 +1157,7 @@ const DatasetList: FunctionComponent = ({ }, Header: t('Actions'), id: 'actions', - size: 'lg', + layout: 'actions', hidden: !canEdit && !canDelete && !canDuplicate, disableSortBy: true, }, diff --git a/superset-frontend/src/pages/ExecutionLogList/index.tsx b/superset-frontend/src/pages/ExecutionLogList/index.tsx index 997eae32fb0a..41af6febb93d 100644 --- a/superset-frontend/src/pages/ExecutionLogList/index.tsx +++ b/superset-frontend/src/pages/ExecutionLogList/index.tsx @@ -26,7 +26,7 @@ import { import { useEffect, useMemo } from 'react'; import { Link, useParams } from 'react-router-dom'; import { Label, Tooltip } from '@superset-ui/core/components'; -import { ListView } from 'src/components'; +import { ListView, type ListViewColumn } from 'src/components'; import SubMenu from 'src/features/home/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; import AlertStatusIcon from 'src/features/alerts/components/AlertStatusIcon'; @@ -91,7 +91,7 @@ function ExecutionLog({ }, [alertId]); const initialSort = [{ id: 'start_dttm', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -103,7 +103,7 @@ function ExecutionLog({ ), accessor: 'state', Header: t('State'), - size: 'xs', + layout: 'status', disableSortBy: true, id: 'state', }, @@ -115,7 +115,7 @@ function ExecutionLog({ }: any) => (executionId ? executionId.slice(0, 6) : 'none'), accessor: 'uuid', Header: t('Execution ID'), - size: 'xs', + layout: 'text', disableSortBy: true, id: 'uuid', }, @@ -129,6 +129,7 @@ function ExecutionLog({ accessor: 'scheduled_dttm', Header: t('Scheduled at (UTC)'), id: 'scheduled_dttm', + layout: 'date', }, { Cell: ({ @@ -141,6 +142,7 @@ function ExecutionLog({ Header: t('Start at (UTC)'), accessor: 'start_dttm', id: 'start_dttm', + layout: 'date', }, { Cell: ({ @@ -160,11 +162,13 @@ function ExecutionLog({ Header: t('Duration'), disableSortBy: true, id: 'duration', + layout: 'count', }, { accessor: 'value', Header: t('Value'), id: 'value', + layout: 'count', }, { accessor: 'error_message', @@ -179,6 +183,7 @@ function ExecutionLog({ ), id: 'error_message', + layout: 'longtext', }, ], [isReportEnabled], diff --git a/superset-frontend/src/pages/GroupsList/index.tsx b/superset-frontend/src/pages/GroupsList/index.tsx index 2f1e1bc0e831..441cf9d52db9 100644 --- a/superset-frontend/src/pages/GroupsList/index.tsx +++ b/superset-frontend/src/pages/GroupsList/index.tsx @@ -22,7 +22,11 @@ import { t } from '@apache-superset/core/translation'; import { useListViewResource } from 'src/views/CRUD/hooks'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import { ActionsBar, ActionProps } from 'src/components/ListView/ActionsBar'; -import { ListView, ListViewProps } from 'src/components/ListView'; +import { + ListView, + ListViewProps, + type ListViewColumn, +} from 'src/components/ListView'; import { type ListViewFilters, ListViewFilterOperator } from 'src/components'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import { @@ -164,13 +168,13 @@ function GroupsList({ user }: GroupsListProps) { }; const initialSort = [{ id: 'name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'name', id: 'name', Header: t('Name'), - size: 'xxl', + layout: 'title', Cell: ({ row: { original: { name }, @@ -181,6 +185,7 @@ function GroupsList({ user }: GroupsListProps) { accessor: 'label', id: 'label', Header: t('Label'), + layout: 'text', Cell: ({ row: { original: { label }, @@ -191,6 +196,7 @@ function GroupsList({ user }: GroupsListProps) { accessor: 'description', id: 'description', Header: t('Description'), + layout: 'longtext', Cell: ({ row: { original: { description }, @@ -202,6 +208,7 @@ function GroupsList({ user }: GroupsListProps) { accessor: 'roles', id: 'roles', Header: t('Roles'), + layout: 'tags', Cell: ({ row: { original: { roles }, @@ -221,6 +228,7 @@ function GroupsList({ user }: GroupsListProps) { accessor: 'users', id: 'users', Header: t('Users'), + layout: 'tags', Cell: ({ row: { original: { users }, @@ -263,7 +271,7 @@ function GroupsList({ user }: GroupsListProps) { id: 'actions', disableSortBy: true, hidden: !isAdmin, - size: 'xl', + layout: 'actions', }, ], [isAdmin], diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index 447fcbe92651..ef5e99fff578 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -34,6 +34,7 @@ import { Popover, Label, Tooltip } from '@superset-ui/core/components'; import { commonMenuData } from 'src/features/home/commonMenuData'; import { ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, type ListViewProps, type ListViewFilters, @@ -147,7 +148,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }; const initialSort = [{ id: QueryObjectColumns.StartTime, desc: true }]; - const columns = useMemo( + const columns = useMemo( () => [ { Cell: ({ @@ -221,14 +222,14 @@ function QueryList({ addDangerToast }: QueryListProps) { ); }, accessor: QueryObjectColumns.Status, - size: 'xs', + layout: 'icon', disableSortBy: true, id: QueryObjectColumns.Status, }, { accessor: QueryObjectColumns.StartTime, Header: t('Time'), - size: 'lg', + layout: 'date', Cell: ({ row: { original: { start_time }, @@ -251,7 +252,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }, { Header: t('Duration'), - size: 'lg', + layout: 'count', Cell: ({ row: { original: { status, start_time, start_running_time, end_time }, @@ -277,13 +278,13 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.TabName, Header: t('Tab name'), - size: 'xl', + layout: 'text', id: QueryObjectColumns.TabName, }, { accessor: QueryObjectColumns.DatabaseName, Header: t('Database'), - size: 'lg', + layout: 'text', id: QueryObjectColumns.DatabaseName, }, { @@ -294,7 +295,7 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.Schema, Header: t('Schema'), - size: 'lg', + layout: 'text', id: QueryObjectColumns.Schema, }, { @@ -332,14 +333,14 @@ function QueryList({ addDangerToast }: QueryListProps) { }, accessor: QueryObjectColumns.SqlTables, Header: t('Tables'), - size: 'lg', + layout: 'tags', disableSortBy: true, id: QueryObjectColumns.SqlTables, }, { accessor: QueryObjectColumns.UserFirstName, Header: t('User'), - size: 'xl', + layout: 'text', Cell: ({ row: { original: { user }, @@ -355,7 +356,7 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.Rows, Header: t('Rows'), - size: 'sm', + layout: 'count', id: QueryObjectColumns.Rows, }, { @@ -390,14 +391,14 @@ function QueryList({ addDangerToast }: QueryListProps) { ), - size: 'xxl', + layout: 'longtext', id: QueryObjectColumns.Sql, }, { Header: t('Actions'), id: 'actions', disableSortBy: true, - size: 'sm', + layout: 'actions', Cell: ({ row: { original: { id }, diff --git a/superset-frontend/src/pages/RolesList/index.tsx b/superset-frontend/src/pages/RolesList/index.tsx index 09209398e385..be27b5238ed2 100644 --- a/superset-frontend/src/pages/RolesList/index.tsx +++ b/superset-frontend/src/pages/RolesList/index.tsx @@ -29,6 +29,7 @@ import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import { ConfirmStatusChange, DeleteModal } from '@superset-ui/core/components'; import { ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, ListViewActionsBar, type ListViewProps, @@ -145,13 +146,13 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { }; const initialSort = [{ id: 'name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'name', id: 'name', Header: t('Name'), - size: 'xxl', + layout: 'title', Cell: ({ row: { original: { name }, @@ -162,6 +163,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { accessor: 'user_ids', id: 'user_ids', Header: t('Users'), + layout: 'tags', hidden: true, Cell: ({ row: { original } }: any) => original.user_ids.join(', '), }, @@ -169,6 +171,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { accessor: 'group_ids', id: 'group_ids', Header: t('Groups'), + layout: 'tags', hidden: true, Cell: ({ row: { original } }: any) => original.group_ids.join(', '), }, @@ -176,6 +179,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { accessor: 'permission_ids', id: 'permission_ids', Header: t('Permissions'), + layout: 'tags', hidden: true, Cell: ({ row: { original } }: any) => original.permission_ids.join(', '), @@ -226,7 +230,7 @@ function RolesList({ addDangerToast, addSuccessToast, user }: RolesListProps) { id: 'actions', disableSortBy: true, hidden: !isAdmin, - size: 'xl', + layout: 'actions', }, ], [isAdmin], diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx index d38543966058..747dbad9d455 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx @@ -26,6 +26,7 @@ import { import { ModifiedInfo, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, type ListViewProps, type ListViewFilters, @@ -135,18 +136,18 @@ function RowLevelSecurityList(props: RLSProps) { const canEdit = hasPerm('can_write'); const canExport = hasPerm('can_export'); - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'name', Header: t('Name'), - size: 'xxl', + layout: 'title', id: 'name', }, { accessor: 'filter_type', Header: t('Filter Type'), - size: 'lg', + layout: 'status', id: 'filter_type', }, { @@ -159,20 +160,20 @@ function RowLevelSecurityList(props: RLSProps) { }) => , Header: t('Subjects'), accessor: 'subjects', - size: 'xl', + layout: 'tags', id: 'subjects', disableSortBy: true, }, { accessor: 'group_key', Header: t('Group Key'), - size: 'lg', + layout: 'text', id: 'group_key', }, { accessor: 'clause', Header: t('Clause'), - size: 'xl', + layout: 'longtext', id: 'clause', }, { @@ -186,7 +187,7 @@ function RowLevelSecurityList(props: RLSProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -246,7 +247,7 @@ function RowLevelSecurityList(props: RLSProps) { id: 'actions', hidden: !canEdit && !canWrite && !canExport, disableSortBy: true, - size: 'lg', + layout: 'actions', }, { accessor: QueryObjectColumns.ChangedBy, diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index 2a603d2fe48e..57bc250a02db 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -48,6 +48,7 @@ import { ModifiedInfo, TagsList, ListView, + type ListViewColumn, ListViewActionsBar, ListViewFilterOperator as FilterOperator, type ListViewProps, @@ -340,12 +341,12 @@ function SavedQueryList({ }; const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'label', Header: t('Name'), - size: 'xxl', + layout: 'title', Cell: ({ row: { original: { id, label }, @@ -356,13 +357,13 @@ function SavedQueryList({ { accessor: 'description', Header: t('Description'), - size: 'xl', + layout: 'longtext', id: 'description', }, { accessor: 'database.database_name', Header: t('Database'), - size: 'lg', + layout: 'text', id: 'database.database_name', }, { @@ -374,7 +375,7 @@ function SavedQueryList({ { accessor: 'schema', Header: t('Schema'), - size: 'lg', + layout: 'text', id: 'schema', }, { @@ -412,7 +413,7 @@ function SavedQueryList({ }, accessor: 'sql_tables', Header: t('Tables'), - size: 'lg', + layout: 'tags', disableSortBy: true, id: 'sql_tables', }, @@ -434,6 +435,7 @@ function SavedQueryList({ disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), id: 'tags', + layout: 'tags', }, { Cell: ({ @@ -448,14 +450,14 @@ function SavedQueryList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { accessor: 'created_by.first_name', Header: t('Created by'), disableSortBy: true, - size: 'xl', + layout: 'text', Cell: ({ row: { original: { created_by: createdBy }, @@ -522,6 +524,7 @@ function SavedQueryList({ }, Header: t('Actions'), id: 'actions', + layout: 'actionsWide', disableSortBy: true, }, { diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index 1b590c62959f..32115743206c 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -33,6 +33,7 @@ import { import { Tag as AntdTag, ListView, + type ListViewColumn, ModifiedInfo, ListViewFilterOperator as FilterOperator, type ListViewFilter, @@ -142,7 +143,7 @@ function TagList(props: TagListProps) { buttonText: t('Create a new Tag'), }; - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -160,7 +161,7 @@ function TagList(props: TagListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'xs', + layout: 'icon', hidden: !userId, }, { @@ -175,7 +176,7 @@ function TagList(props: TagListProps) { ), Header: t('Name'), accessor: 'name', - size: 'xxl', + layout: 'title', id: 'name', }, { @@ -189,7 +190,7 @@ function TagList(props: TagListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', id: 'changed_on_delta_humanized', }, { @@ -240,6 +241,7 @@ function TagList(props: TagListProps) { }, Header: t('Actions'), id: 'actions', + layout: 'actions', hidden: !canDelete, disableSortBy: true, }, diff --git a/superset-frontend/src/pages/TaskList/index.tsx b/superset-frontend/src/pages/TaskList/index.tsx index 91203b0591d6..7b021876ca61 100644 --- a/superset-frontend/src/pages/TaskList/index.tsx +++ b/superset-frontend/src/pages/TaskList/index.tsx @@ -37,6 +37,7 @@ import { import { CreatedInfo, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, type ListViewFilters, } from 'src/components'; @@ -272,7 +273,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { setForceCancel(false); }, []); - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -307,7 +308,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'task_name', Header: t('Task'), - size: 'xl', + layout: 'title', id: 'task_name', }, { @@ -328,13 +329,13 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), accessor: 'status', Header: t('Status'), - size: 'xs', + layout: 'status', id: 'status', }, { accessor: 'task_type', Header: t('Type'), - size: 'md', + layout: 'text', id: 'task_type', }, { @@ -361,7 +362,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'scope', Header: t('Scope'), - size: 'sm', + layout: 'status', id: 'scope', }, { @@ -398,7 +399,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'subscriber_count', Header: t('Subscribers'), - size: 'md', + layout: 'people', id: 'subscribers', disableSortBy: true, }, @@ -415,7 +416,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), Header: t('Created'), accessor: 'created_on', - size: 'xl', + layout: 'date', id: 'created_on', }, { @@ -442,7 +443,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), accessor: 'duration_seconds', Header: t('Duration'), - size: 'sm', + layout: 'count', id: 'duration_seconds', disableSortBy: true, }, @@ -567,7 +568,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'payload', Header: t('Details'), - size: 'sm', + layout: 'status', id: 'payload', disableSortBy: true, }, @@ -651,7 +652,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, Header: t('Actions'), id: 'actions', - size: 'sm', + layout: 'actions', disableSortBy: true, }, ], diff --git a/superset-frontend/src/pages/ThemeList/index.tsx b/superset-frontend/src/pages/ThemeList/index.tsx index 2fb8245a8e3e..db1082f783eb 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -43,6 +43,7 @@ import getBootstrapData from 'src/utils/getBootstrapData'; import { ModifiedInfo, ListView, + type ListViewColumn, ListViewActionsBar, ListViewFilterOperator as FilterOperator, ImportModal as ImportModelsModal, @@ -443,7 +444,7 @@ function ThemesList({ ]); const initialSort = [{ id: 'theme_name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ row: { original } }: any) => { @@ -487,6 +488,7 @@ function ThemesList({ Header: t('Name'), accessor: 'theme_name', id: 'theme_name', + layout: 'title', }, { Cell: ({ @@ -499,7 +501,7 @@ function ThemesList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'date', disableSortBy: true, id: 'changed_on_delta_humanized', }, @@ -513,6 +515,7 @@ function ThemesList({ accessor: 'editors', disableSortBy: true, id: 'editors', + layout: 'people', }, { Cell: ({ row: { original } }: any) => { @@ -634,7 +637,7 @@ function ThemesList({ id: 'actions', disableSortBy: true, hidden: !canEdit && !canDelete && !canApply && !canExport, - size: 'xl', + layout: 'actionsWide', }, { accessor: QueryObjectColumns.ChangedBy, diff --git a/superset-frontend/src/pages/UserRegistrations/index.tsx b/superset-frontend/src/pages/UserRegistrations/index.tsx index 8230b2339624..960307ed7225 100644 --- a/superset-frontend/src/pages/UserRegistrations/index.tsx +++ b/superset-frontend/src/pages/UserRegistrations/index.tsx @@ -26,6 +26,7 @@ import { ListViewFilters, ListViewFilterOperator, ListView, + type ListViewColumn, } from 'src/components'; import { DeleteModal } from '@superset-ui/core/components'; import { ActionProps, ActionsBar } from 'src/components/ListView/ActionsBar'; @@ -83,36 +84,41 @@ export default function UserRegistrations() { const initialSort = [{ id: 'registration_date', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'username', id: 'username', Header: t('Username'), + layout: 'title', Cell: ({ row: { original } }: any) => original.username, }, { accessor: 'first_name', id: 'first_name', Header: t('First name'), + layout: 'text', Cell: ({ row: { original } }: any) => original.first_name, }, { accessor: 'last_name', id: 'last_name', Header: t('Last name'), + layout: 'text', Cell: ({ row: { original } }: any) => original.last_name, }, { accessor: 'email', id: 'email', Header: t('Email'), + layout: 'text', Cell: ({ row: { original } }: any) => original.email, }, { accessor: 'registration_date', id: 'registration_date', Header: t('Registration date'), + layout: 'date', Cell: ({ row: { original } }: any) => original.registration_date, }, { @@ -134,7 +140,7 @@ export default function UserRegistrations() { }, hidden: false, disableSortBy: true, - size: 'xl', + layout: 'actions', }, ], [], diff --git a/superset-frontend/src/pages/UsersList/index.tsx b/superset-frontend/src/pages/UsersList/index.tsx index b2419641a334..dd49dda58525 100644 --- a/superset-frontend/src/pages/UsersList/index.tsx +++ b/superset-frontend/src/pages/UsersList/index.tsx @@ -30,6 +30,7 @@ import { } from '@superset-ui/core/components'; import { ListView, + type ListViewColumn, ListViewProps, ListViewFilterOperator, ListViewFilters, @@ -184,13 +185,13 @@ function UsersList({ user }: UsersListProps) { }; const initialSort = [{ id: 'username', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'first_name', id: 'first_name', Header: t('First name'), - size: 'lg', + layout: 'text', Cell: ({ row: { original: { first_name }, @@ -201,7 +202,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'last_name', id: 'last_name', Header: t('Last name'), - size: 'lg', + layout: 'text', Cell: ({ row: { original: { last_name }, @@ -212,7 +213,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'username', id: 'username', Header: t('Username'), - size: 'xxl', + layout: 'title', Cell: ({ row: { original: { username }, @@ -223,7 +224,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'email', id: 'email', Header: t('Email'), - size: 'xl', + layout: 'text', Cell: ({ row: { original: { email }, @@ -234,7 +235,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'active', id: 'active', Header: t('Is active?'), - size: 'sm', + layout: 'status', Cell: ({ row: { original: { active }, @@ -245,7 +246,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'roles', id: 'roles', Header: t('Roles'), - size: 'lg', + layout: 'tags', Cell: ({ row: { original: { roles }, @@ -265,7 +266,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'groups', Header: t('Groups'), id: 'groups', - size: 'lg', + layout: 'tags', Cell: ({ row: { original: { groups }, @@ -287,6 +288,7 @@ function UsersList({ user }: UsersListProps) { id: 'login_count', Header: t('Login count'), hidden: true, + layout: 'count', Cell: ({ row: { original } }: any) => original.login_count ?? 0, }, { @@ -294,6 +296,7 @@ function UsersList({ user }: UsersListProps) { id: 'fail_login_count', Header: t('Fail login count'), hidden: true, + layout: 'count', Cell: ({ row: { original } }: any) => original.fail_login_count ?? 0, }, { @@ -301,6 +304,7 @@ function UsersList({ user }: UsersListProps) { id: 'created_on', Header: t('Created on'), hidden: true, + layout: 'date', Cell: ({ row: { original: { created_on }, @@ -312,6 +316,7 @@ function UsersList({ user }: UsersListProps) { id: 'changed_on', Header: t('Changed on'), hidden: true, + layout: 'date', Cell: ({ row: { original: { changed_on }, @@ -323,6 +328,7 @@ function UsersList({ user }: UsersListProps) { id: 'last_login', Header: t('Last login'), hidden: true, + layout: 'date', Cell: ({ row: { original: { last_login }, @@ -361,7 +367,7 @@ function UsersList({ user }: UsersListProps) { id: 'actions', disableSortBy: true, hidden: !isAdmin, - size: 'xl', + layout: 'actions', }, ], [isAdmin], From 81624849db9889ac3fc01830d10cc32f64b51a05 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 28 Oct 2026 20:34:24 +0000 Subject: [PATCH 03/12] fix(listview): widen the status column preset to fit tag badges Status badges render as antd tags with an inline margin, so the dataset "Physical"/"Virtual" type and dashboard "Published"/"Draft" badges need 128px including cell padding; at 112px they overflowed and showed an ellipsis next to the badge. Co-Authored-By: Claude Opus 5.5 --- .../src/components/TableCollection/columnWidths.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts index a4c1fc7fe300..fbf0ff7fb334 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -74,7 +74,7 @@ export const COLUMN_LAYOUT_PRESETS: Record< FixedColumnLayout | Required > = { icon: { fixed: 48 }, - status: { fixed: 112 }, + status: { fixed: 128 }, statusWide: { fixed: 168 }, count: { fixed: 136 }, people: { fixed: 140 }, From 5183443c0cae5e85c8bcc1adb35a778ab38eaf46 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 28 Oct 2026 23:11:57 +0000 Subject: [PATCH 04/12] fix(listview): share one truncating title cell across list views The Name cell of the Datasets and Themes lists hard-clipped long titles without an ellipsis and could push the adornments beside them (badges, description tooltip, status tags) out of the cell. Each list view carried its own copy of a flex-row wrapper, and only the Dashboard and Chart copies remembered to make the title link shrinkable; inside a flex row the table cell's own text-overflow does not apply. Add ListViewTitleCell, which owns the flex row, the single-line ellipsis on the title and the non-shrinking adornments once, and use it in the Dataset, Dashboard, Chart and Theme lists in place of the per-page wrappers. Title columns that render bare text or inline links already ellipsize through the shared table cell styles and are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../spec/helpers/listViewTitleCell.ts | 47 ++++++++ .../components/ListView/TitleCell.test.tsx | 103 ++++++++++++++++++ .../src/components/ListView/TitleCell.tsx | 81 ++++++++++++++ .../src/components/ListView/index.ts | 1 + superset-frontend/src/components/index.ts | 1 + .../ChartList/ChartList.columnSizing.test.tsx | 11 ++ .../src/pages/ChartList/index.tsx | 26 +---- .../DashboardList.columnSizing.test.tsx | 11 ++ .../src/pages/DashboardList/index.tsx | 26 +---- .../DatasetList.columnSizing.test.tsx | 17 +++ .../src/pages/DatasetList/index.tsx | 54 +++++---- .../src/pages/ThemeList/ThemeList.test.tsx | 22 ++++ .../src/pages/ThemeList/index.tsx | 72 ++++++------ 13 files changed, 363 insertions(+), 109 deletions(-) create mode 100644 superset-frontend/spec/helpers/listViewTitleCell.ts create mode 100644 superset-frontend/src/components/ListView/TitleCell.test.tsx create mode 100644 superset-frontend/src/components/ListView/TitleCell.tsx diff --git a/superset-frontend/spec/helpers/listViewTitleCell.ts b/superset-frontend/spec/helpers/listViewTitleCell.ts new file mode 100644 index 000000000000..ebb6e63e770d --- /dev/null +++ b/superset-frontend/spec/helpers/listViewTitleCell.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Asserts that `element` (a list view title link or text) truncates to a + * single line with an ellipsis instead of being hard-clipped by its cell. + * + * Inside a flex row the cell's own `text-overflow` no longer applies, so the + * truncation has to come from a shrinkable flex item between `element` + * (inclusive) and its table cell (exclusive). + */ +export function expectTruncatingTitle(element: HTMLElement) { + const cell = element.closest('td'); + const truncating: HTMLElement[] = []; + for ( + let node: HTMLElement | null = element; + node && node !== cell; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if ( + style.textOverflow === 'ellipsis' && + style.whiteSpace === 'nowrap' && + style.overflow === 'hidden' && + ['0', '0px'].includes(style.minWidth) + ) { + truncating.push(node); + } + } + expect(truncating).not.toHaveLength(0); +} diff --git a/superset-frontend/src/components/ListView/TitleCell.test.tsx b/superset-frontend/src/components/ListView/TitleCell.test.tsx new file mode 100644 index 000000000000..0b40d6f14888 --- /dev/null +++ b/superset-frontend/src/components/ListView/TitleCell.test.tsx @@ -0,0 +1,103 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { render, screen } from 'spec/helpers/testing-library'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; +import { ListViewTitleCell } from './TitleCell'; + +test('truncates a title link and keeps its attributes', () => { + render( + + + A long title + + , + ); + + const link = screen.getByTestId('title-link'); + expect(link).toHaveAttribute('title', 'A long title'); + expect(link).toHaveAttribute('href', '/target'); + expectTruncatingTitle(link); +}); + +test('truncates plain title text', () => { + render(Plain title); + + expectTruncatingTitle(screen.getByText('Plain title')); +}); + +test('renders adornments around the title without letting them shrink', () => { + render( + + + + + } + trailing={} + > + Title + , + ); + + const title = screen.getByText('Title'); + const row = title.parentElement!.parentElement!; + const children = Array.from(row.children); + expect(children).toEqual([ + screen.getByTestId('first-badge'), + screen.getByTestId('second-badge'), + title.parentElement, + screen.getByTestId('trailing-tooltip'), + ]); + expect(getComputedStyle(row).display).toBe('flex'); + for (const testId of ['first-badge', 'second-badge', 'trailing-tooltip']) { + expect(getComputedStyle(screen.getByTestId(testId)).flexShrink).toBe('0'); + } + expect(getComputedStyle(title.parentElement!).flexShrink).toBe('1'); + expectTruncatingTitle(title); +}); + +test('spaces icons in badges but leaves the icons inside tags alone', () => { + render( + + + + } + trailing={ + + + + } + > + Title + , + ); + + expect(getComputedStyle(screen.getByTestId('badge-icon')).marginRight).toBe( + '4px', + ); + expect(getComputedStyle(screen.getByTestId('tag-icon')).marginRight).not.toBe( + '4px', + ); + expect(getComputedStyle(screen.getByTestId('status-tag')).marginLeft).toBe( + '8px', + ); +}); diff --git a/superset-frontend/src/components/ListView/TitleCell.tsx b/superset-frontend/src/components/ListView/TitleCell.tsx new file mode 100644 index 000000000000..94b7a7b07790 --- /dev/null +++ b/superset-frontend/src/components/ListView/TitleCell.tsx @@ -0,0 +1,81 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ReactNode } from 'react'; +import { styled, css } from '@apache-superset/core/theme'; + +export interface ListViewTitleCellProps { + /** Badges rendered before the title, e.g. certification or warnings. */ + leading?: ReactNode; + /** The title link or text, truncated to a single line with an ellipsis. */ + children: ReactNode; + /** Adornments rendered after the title, e.g. a tooltip or status tags. */ + trailing?: ReactNode; +} + +const TITLE_CLASS_NAME = 'list-view-title'; + +// Inside a flex row the cell's own `text-overflow` no longer applies, so the +// title is the only shrinkable item and truncates itself; the adornments +// keep their natural size. +const Row = styled.div` + ${({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.sizeUnit}px; + + & > * { + flex-shrink: 0; + } + + & > .${TITLE_CLASS_NAME} { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; + } + + & > :not(.ant-tag) svg { + margin-right: ${theme.sizeUnit}px; + } + + & > .ant-tag { + margin-left: ${theme.sizeUnit * 2}px; + } + `} +`; + +/** + * The standard content of a list view title column: optional leading + * badges, the title, and optional trailing adornments on one line. + */ +export function ListViewTitleCell({ + leading, + children, + trailing, +}: ListViewTitleCellProps) { + return ( + + {leading} + {children} + {trailing} + + ); +} diff --git a/superset-frontend/src/components/ListView/index.ts b/superset-frontend/src/components/ListView/index.ts index 0d24fd2cf4bd..b6987a3ce7a6 100644 --- a/superset-frontend/src/components/ListView/index.ts +++ b/superset-frontend/src/components/ListView/index.ts @@ -23,4 +23,5 @@ export { type ActionProps as ListViewActionProps, } from './ActionsBar'; export { DashboardCrossLinks } from './DashboardCrossLinks'; +export { ListViewTitleCell, type ListViewTitleCellProps } from './TitleCell'; export { default as ListViewUIFilters } from './Filters'; diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts index 4884634d5ce9..9ef9cdce39c9 100644 --- a/superset-frontend/src/components/index.ts +++ b/superset-frontend/src/components/index.ts @@ -25,6 +25,7 @@ export { ListViewUIFilters, DashboardCrossLinks, ListViewFilterOperator, + ListViewTitleCell, type ListViewProps, type ListViewColumn, type ListViewActionProps, diff --git a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx index 187489e406fa..bbd13e7a0e11 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -25,6 +25,7 @@ import { expectColumnWidthsWithinLayouts, mockListViewContainerWidth, } from 'spec/helpers/listViewColumnWidths'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; const CONTAINER_WIDTH = 1440; @@ -104,3 +105,13 @@ test('every visible column resolves to a width within its layout', async () => { CONTAINER_WIDTH, ); }); + +test('chart names truncate with an ellipsis beside their badges', async () => { + renderChartList(mockUser); + + for (const name of ['Test Chart 0', 'Test Chart 1', 'Test Chart 2']) { + const link = await screen.findByTestId(`${name}-list-chart-title`); + expect(link).toHaveAttribute('title', name); + expectTruncatingTitle(link); + } +}); diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index e9271f40860b..fe34ca7f170b 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -79,6 +79,7 @@ import { type ListViewFetchDataConfig, type ListViewFilters, type ListViewFilter, + ListViewTitleCell, } from 'src/components'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import { Link, useHistory } from 'react-router-dom'; @@ -100,24 +101,6 @@ import { datasetLabel } from 'src/features/semanticLayers/label'; import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils'; import type { CellProps } from 'react-table'; -const FlexRowContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.sizeUnit}px; - - a { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.2; - min-width: 0; - } - - svg { - margin-right: ${({ theme }) => theme.sizeUnit}px; - } -`; - const PAGE_SIZE = 25; // How many blocking alerts/reports the archive modal previews before the // "... and N more" overflow line (dataset-modal parity). @@ -535,7 +518,9 @@ function ChartList(props: ChartListProps) { }, }, }: any) => ( - + } + > - {description && } - + ), Header: t('Name'), accessor: 'slice_name', diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx index 798addd53a7d..9491a3e7c1f5 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx @@ -28,6 +28,7 @@ import { expectColumnWidthsWithinLayouts, mockListViewContainerWidth, } from 'spec/helpers/listViewColumnWidths'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; const CONTAINER_WIDTH = 1440; @@ -87,3 +88,13 @@ test('every visible column resolves to a width within its layout', async () => { CONTAINER_WIDTH, ); }); + +test('dashboard titles truncate with an ellipsis beside their badges', async () => { + renderDashboardList(mockAdminUser); + + for (const title of ['Sales Dashboard', 'Analytics Dashboard']) { + const link = await screen.findByTitle(title); + expect(link.tagName).toBe('A'); + expectTruncatingTitle(link); + } +}); diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index 96c7c4359e25..df3c48443c9e 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -62,6 +62,7 @@ import { type ListViewProps, type ListViewFilter, type ListViewFilters, + ListViewTitleCell, } from 'src/components'; import handleResourceExport from 'src/utils/export'; import { @@ -138,24 +139,6 @@ const Actions = styled.div` color: ${({ theme }) => theme.colorIcon}; `; -const FlexRowContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.sizeUnit}px; - - a { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.2; - min-width: 0; - } - - svg { - margin-right: ${({ theme }) => theme.sizeUnit}px; - } -`; - const DASHBOARD_COLUMNS_TO_FETCH = [ 'id', 'dashboard_title', @@ -419,7 +402,9 @@ function DashboardList(props: DashboardListProps) { }, }, }: any) => ( - + } + > {certifiedBy && ( <> @@ -431,8 +416,7 @@ function DashboardList(props: DashboardListProps) { )} {dashboardTitle} - {description && } - + ), Header: t('Name'), accessor: 'dashboard_title', diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx index a774f81072b6..8df9db69001e 100644 --- a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx @@ -27,6 +27,7 @@ import { expectColumnWidthsWithinLayouts, mockListViewContainerWidth, } from 'spec/helpers/listViewColumnWidths'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; const CONTAINER_WIDTH = 1440; @@ -73,3 +74,19 @@ test('every visible column resolves to a width within its layout', async () => { CONTAINER_WIDTH, ); }); + +test('dataset names truncate with an ellipsis beside their badges', async () => { + renderDatasetList(mockAdminUser); + + // Plain, certified, warning and RLS-badged rows all share the Name cell. + for (const name of [ + 'public.sales_data', + 'Analytics Query', + 'Customer Metrics', + 'Restricted Sales', + ]) { + const link = await screen.findByTitle(name); + expect(link.tagName).toBe('A'); + expectTruncatingTitle(link); + } +}); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 74dfe90cd118..c9ddd07159ee 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -73,6 +73,7 @@ import { type ListViewColumn, type ListViewFilters, type ListViewFetchDataConfig, + ListViewTitleCell, } from 'src/components'; import type { SelectOption } from 'src/components/ListView/types'; import { Typography } from '@superset-ui/core/components/Typography'; @@ -142,16 +143,6 @@ const DatasetDeleteRelatedExtension = extensionsRegistry.get( 'dataset.delete.related', ); -const FlexRowContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.sizeUnit}px; - - svg { - margin-right: ${({ theme }) => theme.sizeUnit}px; - } -`; - const Actions = styled.div` ${({ theme }) => css` color: ${theme.colorIcon}; @@ -919,26 +910,31 @@ const DatasetList: FunctionComponent = ({ ? JSON.parse(extra) : (extra as DatasetExtra | null); return ( - - {parsedExtra?.certification && ( - - )} - {parsedExtra?.warning_markdown && ( - - )} - {rlsFilters && rlsFilters.length > 0 && ( - - )} + + {parsedExtra?.certification && ( + + )} + {parsedExtra?.warning_markdown && ( + + )} + {rlsFilters && rlsFilters.length > 0 && ( + + )} + + } + trailing={description && } + > {titleLink} - {description && } - + ); } catch { return titleLink; diff --git a/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx b/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx index ae730762b61e..15c43e333a4b 100644 --- a/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx +++ b/superset-frontend/src/pages/ThemeList/ThemeList.test.tsx @@ -27,6 +27,7 @@ import * as hooks from 'src/views/CRUD/hooks'; import { useThemeContext } from 'src/theme/ThemeProvider'; import { setSystemDefaultTheme } from 'src/features/themes/api'; import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; import ThemesList from './index'; jest.mock('src/dashboard/util/permissionUtils', () => ({ @@ -1055,3 +1056,24 @@ test('a slow live re-apply does not block the confirm modal, list refresh, or to resolveRefresh(); }); + +test('theme names truncate with an ellipsis beside their tags', async () => { + render( + , + { + useRedux: true, + useRouter: true, + useQueryParams: true, + useTheme: true, + }, + ); + + // Light/Dark carry the default and system tags, Custom has none. + for (const name of ['Light Theme', 'Dark Theme', 'Custom Theme']) { + expectTruncatingTitle(await screen.findByText(name)); + } +}); diff --git a/superset-frontend/src/pages/ThemeList/index.tsx b/superset-frontend/src/pages/ThemeList/index.tsx index db1082f783eb..2599603314b1 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -50,6 +50,7 @@ import { type ListViewProps, type ListViewActionProps, type ListViewFilters, + ListViewTitleCell, } from 'src/components'; import ThemeModal from 'src/features/themes/ThemeModal'; @@ -73,16 +74,6 @@ import { const PAGE_SIZE = 25; -const FlexRowContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.sizeUnit}px; - - .ant-tag { - margin-left: ${({ theme }) => theme.sizeUnit * 2}px; - } -`; - const IconTag = styled(Tag)` display: inline-flex; align-items: center; @@ -454,35 +445,40 @@ function ThemesList({ original.id === appliedThemeId; return ( - + + {isCurrentTheme && ( + + {t('Local')} + + )} + {original.is_system && ( + + {t('System')} + + )} + {original.is_system_default && ( + + }> + {t('Default')} + + + )} + {original.is_system_dark && ( + + }> + {t('Dark')} + + + )} + + } + > {original.theme_name} - {isCurrentTheme && ( - - {t('Local')} - - )} - {original.is_system && ( - - {t('System')} - - )} - {original.is_system_default && ( - - }> - {t('Default')} - - - )} - {original.is_system_dark && ( - - }> - {t('Dark')} - - - )} - + ); }, Header: t('Name'), From e2c8a7a78979e76f6f407c16378f4e96cee47c66 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 17:18:52 +0000 Subject: [PATCH 05/12] feat(listview): right-size typed column widths and freeze the leading block when a list scrolls Rework the shared list view preset table from measured content, English and German included: - typed fixed presets: icon/statusIcon 48, statusIcons 64, status 144, flag 176 (boolean columns; longer headers ellipsize with the full text in the header title), count 104, timer 136, people 120, iconList 120, dateRelative 176, dateTwoLine 136, timestamp 240, actions 120, actionsWide 168 - title is the one primary flexible column: min 320, max 640, weight 3 - flexible text 140-280, identifier 180-360 (database, schema, table and dataset names), longtext 200-560, tags 200-320 - statusWide and date are replaced by flag, iconList, dateRelative, dateTwoLine and timestamp When the minimums do not fit, the table scrolls inside its container and the leading block (selection checkbox, favorite star or state icon, and the title) is frozen, but only while that block takes at most half of the container and leaves at least 240px to scroll. The rule lives in pure functions (getLeadingBlockLength, getFrozenColumnCount) with exported tuning constants. Frozen tables set scroll-padding on the scroll container and scroll a focused cell into view, so keyboard focus never lands under the frozen columns. Every list view page is migrated to the new presets; column ids, accessors, renderers, hidden flags and sorting are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 141 ++++++++++++++- .../TableCollection/columnWidths.test.ts | 125 +++++++++++++- .../TableCollection/columnWidths.ts | 163 ++++++++++++++---- .../src/components/TableCollection/index.tsx | 90 +++++++++- .../src/components/TableCollection/utils.tsx | 2 + .../spec/helpers/listViewColumnWidths.ts | 7 + .../ListView/ListView.columnLayouts.test.ts | 16 ++ .../src/pages/ActionLog/index.tsx | 2 +- .../src/pages/AlertReportList/index.tsx | 10 +- .../src/pages/AnnotationLayerList/index.tsx | 2 +- .../src/pages/AnnotationList/index.tsx | 4 +- .../src/pages/ArchivedList/index.tsx | 2 +- .../ChartList/ChartList.columnSizing.test.tsx | 25 ++- .../src/pages/ChartList/index.tsx | 6 +- .../src/pages/CssTemplateList/index.tsx | 2 +- .../DashboardList.columnSizing.test.tsx | 2 +- .../src/pages/DashboardList/index.tsx | 2 +- .../src/pages/DatabaseList/index.tsx | 12 +- .../DatasetList.columnSizing.test.tsx | 22 ++- .../src/pages/DatasetList/index.tsx | 6 +- .../src/pages/ExecutionLogList/index.tsx | 8 +- .../src/pages/QueryHistoryList/index.tsx | 12 +- .../src/pages/RowLevelSecurityList/index.tsx | 4 +- .../src/pages/SavedQueryList/index.tsx | 8 +- superset-frontend/src/pages/Tags/index.tsx | 2 +- .../src/pages/TaskList/index.tsx | 6 +- .../src/pages/ThemeList/index.tsx | 2 +- .../src/pages/UserRegistrations/index.tsx | 2 +- .../src/pages/UsersList/index.tsx | 8 +- 29 files changed, 589 insertions(+), 104 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 02b6dd2f8379..6b95d2e35fb0 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { render, screen, fireEvent } from '@superset-ui/core/spec'; +import { render, screen, fireEvent, within } from '@superset-ui/core/spec'; import { renderHook } from '@testing-library/react'; import { TableInstance, useTable } from 'react-table'; import TableCollection from '.'; @@ -452,6 +452,7 @@ const layoutColumns: ListViewColumn[] = [ function renderLayoutTable( containerWidth: number, props: Record = {}, + columns: ListViewColumn[] = layoutColumns, ) { jest .spyOn(HTMLElement.prototype, 'getBoundingClientRect') @@ -459,12 +460,12 @@ function renderLayoutTable( const data = [{ favorite: 'x', name: 'n', database: 'd', legacy: 'l' }]; const { result } = renderHook(() => // @ts-expect-error -- raw column configs, as ListView passes them - useTable({ columns: layoutColumns, data }), + useTable({ columns, data }), ); const utils = render( { const { widths } = renderLayoutTable(300, { managedColumnWidths: true }); - expect(widths).toEqual(['48px', '240px', '140px', '100px', '120px']); + expect(widths).toEqual(['48px', '320px', '140px', '100px', '120px']); }); test('managed column widths include the row selection column', () => { @@ -524,3 +525,133 @@ test('unmanaged tables keep the auto layout', () => { // width-less s). expect(widths.filter(Boolean)).toEqual(['100px']); }); + +// Minimums: 48 + 320 + 3 * 140 + 120 = 908px. +const scrollingColumns: ListViewColumn[] = [ + { id: 'favorite', Header: 'Fav', accessor: 'favorite', layout: 'icon' }, + { id: 'name', Header: 'Name', accessor: 'name', layout: 'title' }, + { id: 'a', Header: 'A', accessor: 'a', layout: 'text' }, + { id: 'b', Header: 'B', accessor: 'b', layout: 'text' }, + { id: 'c', Header: 'C', accessor: 'c', layout: 'text' }, + { id: 'actions', Header: 'Actions' }, +]; + +const frozenHeaders = (table: HTMLElement) => + Array.from(table.querySelectorAll('thead th.ant-table-cell-fix-start')).map( + th => th.textContent, + ); + +test('freezes the favorite and title columns when the table scrolls', () => { + const { table, container } = renderLayoutTable( + 800, + { managedColumnWidths: true }, + scrollingColumns, + ); + expect(frozenHeaders(table)).toEqual(['Fav', 'Name']); + expect( + table.querySelectorAll( + 'tbody tr.ant-table-row td.ant-table-cell-fix-start', + ), + ).toHaveLength(2); + expect( + (container.firstChild as HTMLElement).style.getPropertyValue( + '--list-view-frozen-width', + ), + ).toBe('368px'); +}); + +test('freezes the selection column with the leading block', () => { + const { table } = renderLayoutTable( + 900, + { + managedColumnWidths: true, + bulkSelectEnabled: true, + selectedFlatRows: [], + }, + scrollingColumns, + ); + // 48 + 48 + 320 = 416px frozen, under half of 900. + expect( + table.querySelectorAll('thead th.ant-table-cell-fix-start'), + ).toHaveLength(3); +}); + +test('does not freeze columns when the table fits', () => { + const { table, container } = renderLayoutTable( + 1200, + { managedColumnWidths: true }, + scrollingColumns, + ); + expect(frozenHeaders(table)).toEqual([]); + expect( + (container.firstChild as HTMLElement).style.getPropertyValue( + '--list-view-frozen-width', + ), + ).toBe(''); +}); + +test('does not freeze a leading block wider than half the container', () => { + // 368px frozen in a 700px container. + const narrow = renderLayoutTable( + 700, + { managedColumnWidths: true }, + scrollingColumns, + ); + expect(frozenHeaders(narrow.table)).toEqual([]); + narrow.unmount(); + // With bulk select the block is 416px, over half of 800. + const bulk = renderLayoutTable( + 800, + { + managedColumnWidths: true, + bulkSelectEnabled: true, + selectedFlatRows: [], + }, + scrollingColumns, + ); + expect( + bulk.table.querySelectorAll('thead th.ant-table-cell-fix-start'), + ).toHaveLength(0); +}); + +test('does not freeze columns in unmanaged tables', () => { + const { table } = renderLayoutTable(800, {}, scrollingColumns); + expect(frozenHeaders(table)).toEqual([]); +}); + +test('scrolls a focused cell out from under the frozen columns', () => { + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const columns: ListViewColumn[] = scrollingColumns.map(column => + column.id === 'c' + ? { ...column, Cell: () => Column C details } + : column, + ); + renderLayoutTable(800, { managedColumnWidths: true }, columns); + screen.getByRole('link', { name: 'Column C details' }).focus(); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'nearest', + inline: 'nearest', + }); +}); + +test('leaves focus scrolling to the browser when nothing is frozen', () => { + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const columns: ListViewColumn[] = scrollingColumns.map(column => + column.id === 'c' + ? { ...column, Cell: () => Column C details } + : column, + ); + renderLayoutTable(1200, { managedColumnWidths: true }, columns); + screen.getByRole('link', { name: 'Column C details' }).focus(); + expect(scrollIntoView).not.toHaveBeenCalled(); +}); + +test('managed text headers carry their full text as a tooltip', () => { + const { table } = renderLayoutTable(1000, { managedColumnWidths: true }); + const header = within(table.querySelector('thead')!) + .getByText('Database') + .closest('th'); + expect(header).toHaveAttribute('title', 'Database'); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts index 172b5391ffbc..8f542157daab 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts @@ -18,7 +18,11 @@ */ import { COLUMN_LAYOUT_PRESETS, + FREEZE_MAX_CONTAINER_SHARE, + FREEZE_MIN_SCROLL_PX, getColumnSpec, + getFrozenColumnCount, + getLeadingBlockLength, resolveColumnWidths, type ColumnSpec, } from './columnWidths'; @@ -38,9 +42,10 @@ const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); test('uses minimum widths and overflows when the container is too narrow', () => { const specs = [fixed(48), flex(240, 480, 3), flex(140, 280, 1), fixed(120)]; - const { widths, totalWidth } = resolveColumnWidths(specs, 400); + const { widths, totalWidth, scrolls } = resolveColumnWidths(specs, 400); expect(widths).toEqual([48, 240, 140, 120]); expect(totalWidth).toBe(548); + expect(scrolls).toBe(true); }); test('uses minimum widths when the container width is unknown', () => { @@ -48,6 +53,7 @@ test('uses minimum widths when the container width is unknown', () => { expect(resolveColumnWidths(specs, 0)).toEqual({ widths: [240, 120], totalWidth: 360, + scrolls: true, }); }); @@ -56,6 +62,7 @@ test('fits exactly when the container equals the sum of minimums', () => { expect(resolveColumnWidths(specs, 500)).toEqual({ widths: [240, 140, 120], totalWidth: 500, + scrolls: false, }); }); @@ -148,3 +155,119 @@ test('getColumnSpec resolves presets, explicit layouts and legacy sizes', () => expect(spec.max).toBeGreaterThanOrEqual(spec.min); }); }); + +test('the preset table holds the measured widths', () => { + expect(COLUMN_LAYOUT_PRESETS).toEqual({ + icon: { fixed: 48 }, + statusIcon: { fixed: 48 }, + statusIcons: { fixed: 64 }, + status: { fixed: 144 }, + flag: { fixed: 176 }, + count: { fixed: 104 }, + timer: { fixed: 136 }, + people: { fixed: 120 }, + iconList: { fixed: 120 }, + dateRelative: { fixed: 176 }, + dateTwoLine: { fixed: 136 }, + timestamp: { fixed: 240 }, + actions: { fixed: 120 }, + actionsWide: { fixed: 168 }, + title: { min: 320, max: 640, weight: 3 }, + text: { min: 140, max: 280, weight: 1 }, + identifier: { min: 180, max: 360, weight: 1 }, + longtext: { min: 200, max: 560, weight: 1.5 }, + tags: { min: 200, max: 320, weight: 1 }, + }); +}); + +test('reports no scroll whenever the minimums fit', () => { + const specs = [flex(320, 640, 3), fixed(120)]; + [440, 800, 2560].forEach(width => + expect(resolveColumnWidths(specs, width).scrolls).toBe(false), + ); + expect(resolveColumnWidths(specs, 439).scrolls).toBe(true); +}); + +test('the leading block is the title plus the icon columns before it', () => { + expect(getLeadingBlockLength(['icon', 'title', 'text', 'actions'])).toBe(2); + expect(getLeadingBlockLength(['title', 'dateRelative'])).toBe(1); + expect(getLeadingBlockLength(['statusIcon', 'title'])).toBe(2); + expect(getLeadingBlockLength([{ fixed: 48 }, 'title'])).toBe(0); +}); + +test('there is no leading block when the title is not led by icons only', () => { + // Alerts: state icon and "Last run" precede the name. + expect(getLeadingBlockLength(['statusIcon', 'timestamp', 'title'])).toBe(0); + // Users: first and last name precede the username. + expect(getLeadingBlockLength(['text', 'text', 'title'])).toBe(0); + // Query history has no title column. + expect(getLeadingBlockLength(['statusIcon', 'dateTwoLine', 'text'])).toBe(0); +}); + +test('freezes the leading block only when the table scrolls', () => { + const widths = [48, 320, 400, 400]; + expect( + getFrozenColumnCount({ + widths, + leadingCount: 2, + containerWidth: 1000, + scrolls: true, + }), + ).toBe(2); + expect( + getFrozenColumnCount({ + widths, + leadingCount: 2, + containerWidth: 1168, + scrolls: false, + }), + ).toBe(0); + expect( + getFrozenColumnCount({ + widths, + leadingCount: 0, + containerWidth: 1000, + scrolls: true, + }), + ).toBe(0); +}); + +test('does not freeze a leading block wider than half the container', () => { + const widths = [48, 48, 320, 400, 400]; + // 416px frozen: fits in 832 (exactly half) but not in 831. + const frozenAt = (containerWidth: number) => + getFrozenColumnCount({ + widths, + leadingCount: 3, + containerWidth, + scrolls: true, + }); + expect(FREEZE_MAX_CONTAINER_SHARE).toBe(0.5); + expect(frozenAt(832)).toBe(3); + expect(frozenAt(831)).toBe(0); +}); + +test('does not freeze unless enough of the container is left to scroll', () => { + expect(FREEZE_MIN_SCROLL_PX).toBe(240); + // 200px frozen is under half of 439 but leaves only 239px to scroll. + const frozenAt = (containerWidth: number) => + getFrozenColumnCount({ + widths: [200, 400], + leadingCount: 1, + containerWidth, + scrolls: true, + }); + expect(frozenAt(439)).toBe(0); + expect(frozenAt(440)).toBe(1); +}); + +test('does not freeze when the container width is unknown', () => { + expect( + getFrozenColumnCount({ + widths: [48, 320, 400], + leadingCount: 2, + containerWidth: 0, + scrolls: true, + }), + ).toBe(0); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts index fbf0ff7fb334..f6ae7411659e 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -27,15 +27,22 @@ export type ColumnLayoutPreset = | 'icon' + | 'statusIcon' + | 'statusIcons' | 'status' - | 'statusWide' + | 'flag' | 'count' + | 'timer' | 'people' + | 'iconList' + | 'dateRelative' + | 'dateTwoLine' + | 'timestamp' | 'actions' | 'actionsWide' - | 'date' | 'title' | 'text' + | 'identifier' | 'longtext' | 'tags'; @@ -48,45 +55,85 @@ export type ColumnLayout = | FlexColumnLayout; /** - * Single source of truth for list view column widths, in pixels. + * Single source of truth for list view column widths, in pixels, including + * the 24px of cell padding. * - * Fixed presets are for content that always fits a known width; flexible - * presets start at `min`, share spare width by `weight` and stop growing at - * `max` until every flexible column has reached its max. - * - * - icon: favorite star, kind icon - * - status: status/state badges, active and boolean flags, small enums - * - statusWide: status-like content under a header too long for `status` - * (e.g. "Expose in SQL Lab", "Notification method") - * - count: rows, durations, ids and counts (sized for headers such as - * "Dashboard Id" and the query duration timer label) - * - people: owner/editor/viewer avatar groups - * - actions: hover action icons (up to four) - * - actionsWide: hover action icons (five or six) - * - date: "Last modified" style relative times, optionally with an avatar - * - title: the primary identifier of a row (name/title) - * - text: database, schema, dataset, backend, user names and similar - * - longtext: description, SQL, clause, error message, JSON payload - * - tags: tag chips and other chip lists + * Fixed presets are for content whose width is known up front; they never + * change with the viewport. Flexible presets start at `min`, share spare width + * by `weight` and stop growing at `max` until every flexible column has + * reached its max. Widths were measured on rendered cells with English and + * German strings (the longest common UI translation). */ export const COLUMN_LAYOUT_PRESETS: Record< ColumnLayoutPreset, FixedColumnLayout | Required > = { + /** Favorite star or a single icon: 20px icon + padding. */ icon: { fixed: 48 }, - status: { fixed: 128 }, - statusWide: { fixed: 168 }, - count: { fixed: 136 }, - people: { fixed: 140 }, + /** One state icon (query, alert or task state). */ + statusIcon: { fixed: 48 }, + /** Two or three small icons side by side (e.g. task details). */ + statusIcons: { fixed: 64 }, + /** + * A badge: Published/Draft, Physical/Virtual/Semantic View, task scope. + * German badges ("Veröffentlicht", "Semantische Ansicht") need 144. + */ + status: { fixed: 144 }, + /** + * A boolean flag (check mark, switch, Yes/No). The content is tiny, so the + * width is set by the header; longer headers ellipsize with a tooltip. + */ + flag: { fixed: 176 }, + /** A plain number: rows, ids, counts and durations as text. */ + count: { fixed: 104 }, + /** A monospace duration timer badge (needs 132). */ + timer: { fixed: 136 }, + /** Owner/editor/viewer avatar group (up to four avatars + "+N"). */ + people: { fixed: 120 }, + /** A short row of icons (notification recipients). */ + iconList: { fixed: 120 }, + /** + * "3 months ago" style relative date; German strings such as + * "vor ein paar Sekunden" need 176. + */ + dateRelative: { fixed: 176 }, + /** A date over a time on two lines. */ + dateTwoLine: { fixed: 136 }, + /** An absolute timestamp such as "2026-10-29 14:03:12+00:00". */ + timestamp: { fixed: 240 }, + /** Hover action icons, up to four (24px per icon + 24px). */ actions: { fixed: 120 }, + /** Hover action icons, five or six. */ actionsWide: { fixed: 168 }, - date: { min: 150, max: 220, weight: 1 }, - title: { min: 240, max: 480, weight: 2 }, + /** + * The primary name of a row. The one main flexible column: wide enough for + * nearly all real titles at its min, and the largest share of spare width. + * Titles that carry trailing tags (themes) need at least 280. + */ + title: { min: 320, max: 640, weight: 3 }, + /** Short free text: user names, labels, chart types, keys. */ text: { min: 140, max: 280, weight: 1 }, + /** + * Names of databases, schemas, tables and datasets, which are often long + * unbroken identifiers (p95 ~335px). + */ + identifier: { min: 180, max: 360, weight: 1 }, + /** Descriptions, SQL, clauses, error messages, JSON. */ longtext: { min: 200, max: 560, weight: 1.5 }, - tags: { min: 160, max: 320, weight: 1 }, + /** Tag chips (at most three + "+N") and other chip or name lists. */ + tags: { min: 200, max: 320, weight: 1 }, }; +/** + * The leading block (selection checkbox, favorite star and title) is frozen + * while the table scrolls horizontally only if it takes at most this share of + * the container... + */ +export const FREEZE_MAX_CONTAINER_SHARE = 0.5; + +/** ...and leaves at least this many pixels for the scrolling columns. */ +export const FREEZE_MIN_SCROLL_PX = 240; + /** Width of antd's row-selection and row-expand columns in managed tables. */ export const UTILITY_COLUMN_WIDTH = 48; @@ -148,20 +195,24 @@ export function getColumnLayout(column: { * flexible columns, each clamped to its max; once every flexible column is at * its max the remainder is shared by weight beyond max so the table still * fills the container. When the container is narrower than the sum of minimums - * (or unknown) the minimums are used and the table scrolls horizontally. + * (or unknown) the minimums are used and `scrolls` is true: the table scrolls + * horizontally. */ export function resolveColumnWidths( specs: ColumnSpec[], containerWidth: number, -): { widths: number[]; totalWidth: number } { +): { widths: number[]; totalWidth: number; scrolls: boolean } { const widths = specs.map(spec => spec.min); const minTotal = widths.reduce((total, width) => total + width, 0); const flexIndexes = specs .map((spec, index) => (spec.weight > 0 ? index : -1)) .filter(index => index >= 0); let spare = containerWidth - minTotal; - if (spare <= 0 || flexIndexes.length === 0) { - return { widths, totalWidth: minTotal }; + if (spare < 0 || containerWidth <= 0) { + return { widths, totalWidth: minTotal, scrolls: true }; + } + if (spare === 0 || flexIndexes.length === 0) { + return { widths, totalWidth: minTotal, scrolls: false }; } let growing = flexIndexes.filter(index => specs[index].max > widths[index]); @@ -202,5 +253,51 @@ export function resolveColumnWidths( specs[index].weight > specs[best].weight ? index : best, ); floored[heaviest] += remainder; - return { widths: floored, totalWidth: containerWidth }; + return { widths: floored, totalWidth: containerWidth, scrolls: false }; +} + +const LEADING_ICON_PRESETS: ColumnLayout[] = ['icon', 'statusIcon']; + +/** + * Number of leading columns that form the block to freeze: the `title` + * column plus any icon columns before it (e.g. the favorite star). 0 when the + * title is preceded by anything else, since antd can only freeze a + * contiguous run of columns from the start and freezing those would hide + * more than the row's name. + */ +export function getLeadingBlockLength(layouts: ColumnLayout[]): number { + const titleIndex = layouts.indexOf('title'); + if (titleIndex < 0) return 0; + const ledByIcons = layouts + .slice(0, titleIndex) + .every(layout => LEADING_ICON_PRESETS.includes(layout)); + return ledByIcons ? titleIndex + 1 : 0; +} + +/** + * Number of leading columns to freeze: all `leadingCount` columns when the + * table scrolls horizontally and the frozen block takes at most + * FREEZE_MAX_CONTAINER_SHARE of the container while leaving at least + * FREEZE_MIN_SCROLL_PX for the rest; otherwise 0 and the table scrolls as a + * whole. + */ +export function getFrozenColumnCount({ + widths, + leadingCount, + containerWidth, + scrolls, +}: { + widths: number[]; + leadingCount: number; + containerWidth: number; + scrolls: boolean; +}): number { + if (!scrolls || leadingCount <= 0 || containerWidth <= 0) return 0; + const frozenWidth = widths + .slice(0, leadingCount) + .reduce((total, width) => total + width, 0); + const fits = + frozenWidth <= containerWidth * FREEZE_MAX_CONTAINER_SHARE && + containerWidth - frozenWidth >= FREEZE_MIN_SCROLL_PX; + return fits ? leadingCount : 0; } diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index 2820d4118972..eae4693cdda6 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -17,6 +17,8 @@ * under the License. */ import { + CSSProperties, + FocusEvent, HTMLAttributes, memo, useMemo, @@ -45,6 +47,8 @@ import { UTILITY_COLUMN_WIDTH, getColumnLayout, getColumnSpec, + getFrozenColumnCount, + getLeadingBlockLength, resolveColumnWidths, } from './columnWidths'; @@ -52,7 +56,11 @@ export type { ListViewColumn }; export type { ColumnLayout, ColumnLayoutPreset } from './columnWidths'; export { COLUMN_LAYOUT_PRESETS, + FREEZE_MAX_CONTAINER_SHARE, + FREEZE_MIN_SCROLL_PX, getColumnSpec, + getFrozenColumnCount, + getLeadingBlockLength, resolveColumnWidths, } from './columnWidths'; @@ -86,8 +94,10 @@ export interface TableCollectionProps { expandable?: Record; /** * Resolve every visible column's width from its `layout` against the - * measured container width, with a fixed table layout. Off by default so - * generic tables keep the browser's auto layout. + * measured container width, with a fixed table layout, and freeze the + * leading block (selection, favorite star, title) while the table scrolls + * horizontally. Off by default so generic tables keep the browser's auto + * layout. */ managedColumnWidths?: boolean; } @@ -141,6 +151,21 @@ const StyledTable = styled(Table)<{ line-height: initial; } + // Headers longer than a fixed column ellipsize; antd puts the full text + // in the header cell's title. + .ant-table-column-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Keyboard focus never lands under the frozen leading columns. + .ant-table-content, + .ant-table-body { + scroll-padding-inline-start: var(--list-view-frozen-width, 0); + } + .ant-table-row:hover { .actions { opacity: 1; @@ -242,13 +267,24 @@ function TableCollection({ if (!managedColumnWidths) return undefined; const visibleColumns = columns.filter(column => !column.hidden); const utilityColumns = (bulkSelectEnabled ? 1 : 0) + (expandable ? 1 : 0); + const layouts = visibleColumns.map(column => getColumnLayout(column)); const specs = [ ...Array(utilityColumns).fill( getColumnSpec({ fixed: UTILITY_COLUMN_WIDTH }), ), - ...visibleColumns.map(column => getColumnSpec(getColumnLayout(column))), + ...layouts.map(getColumnSpec), ]; - const { widths, totalWidth } = resolveColumnWidths(specs, containerWidth); + const { widths, totalWidth, scrolls } = resolveColumnWidths( + specs, + containerWidth, + ); + const leadingBlock = getLeadingBlockLength(layouts); + const frozenCount = getFrozenColumnCount({ + widths, + leadingCount: leadingBlock ? utilityColumns + leadingBlock : 0, + containerWidth, + scrolls, + }); const columnWidths = widths.slice(utilityColumns); return { totalWidth, @@ -258,6 +294,12 @@ function TableCollection({ columnWidths[index], ]), ), + frozenColumnIds: visibleColumns + .slice(0, Math.max(0, frozenCount - utilityColumns)) + .map(getColumnId), + frozenWidth: widths + .slice(0, frozenCount) + .reduce((total, width) => total + width, 0), }; }, [ managedColumnWidths, @@ -267,6 +309,8 @@ function TableCollection({ containerWidth, ]); + const frozenWidth = columnLayout?.frozenWidth ?? 0; + const mappedColumns = useMemo( () => mapColumns( @@ -274,6 +318,7 @@ function TableCollection({ headerGroups, columnsForWrapText, columnLayout?.widths, + columnLayout?.frozenColumnIds, ), [columns, headerGroups, columnsForWrapText, columnLayout], ); @@ -302,6 +347,7 @@ function TableCollection({ return { selectedRowKeys, ...(managedColumnWidths && { columnWidth: UTILITY_COLUMN_WIDTH }), + ...(frozenWidth > 0 && { fixed: 'left' as const }), onSelect: (record, selected) => { toggleRowSelected?.(record.rowId, selected); }, @@ -318,14 +364,30 @@ function TableCollection({ toggleRowSelected, toggleAllRowsSelected, managedColumnWidths, + frozenWidth, ]); const tableExpandable = useMemo( () => expandable && managedColumnWidths - ? { columnWidth: UTILITY_COLUMN_WIDTH, ...expandable } + ? { + columnWidth: UTILITY_COLUMN_WIDTH, + ...(frozenWidth > 0 && { fixed: 'left' as const }), + ...expandable, + } : expandable, - [expandable, managedColumnWidths], + [expandable, managedColumnWidths, frozenWidth], + ); + + // scroll-padding alone doesn't move a partly visible cell; scrolling the + // focused element into view does, and respects the padding. + const handleFocus = useCallback( + (event: FocusEvent) => { + if (frozenWidth > 0 && event.target.closest('td')) { + event.target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + }, + [frozenWidth], ); const handlePaginationChange = useCallback( @@ -466,7 +528,21 @@ function TableCollection({ ); if (!managedColumnWidths) return table; - return
{table}
; + return ( +
0 + ? ({ + '--list-view-frozen-width': `${frozenWidth}px`, + } as CSSProperties) + : undefined + } + > + {table} +
+ ); } // React.memo erases the wrapped component's generic type parameter; cast diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx index 704b4a2f30db..81e1cd9d6884 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/utils.tsx @@ -115,12 +115,14 @@ export function mapColumns( headerGroups: HeaderGroup[], columnsForWrapText?: string[], resolvedWidths?: Record, + frozenColumnIds?: string[], ) { return columns.map(column => { const id = getColumnId(column); const { isSorted, isSortedDesc } = getSortingInfo(headerGroups, id); return { title: column.Header as ReactNode, + fixed: frozenColumnIds?.includes(id) ? ('left' as const) : undefined, dataIndex: id.includes('.') ? id.split('.') : id, hidden: column.hidden, key: id, diff --git a/superset-frontend/spec/helpers/listViewColumnWidths.ts b/superset-frontend/spec/helpers/listViewColumnWidths.ts index 2aef495fb5db..f11416a6c27c 100644 --- a/superset-frontend/spec/helpers/listViewColumnWidths.ts +++ b/superset-frontend/spec/helpers/listViewColumnWidths.ts @@ -63,3 +63,10 @@ export function expectColumnWidthsWithinLayouts( containerWidth, ); } + +/** Header texts of the columns frozen at the start of a list view `table`. */ +export function getFrozenHeaders(table: HTMLElement) { + return Array.from( + table.querySelectorAll('thead th.ant-table-cell-fix-start'), + ).map(th => th.textContent?.trim() ?? ''); +} diff --git a/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts index a320ccdc3a14..743506c0307e 100644 --- a/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts +++ b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts @@ -59,5 +59,21 @@ test.each(listViewPages.map(({ path, source }) => [path, source]))( presets.forEach(preset => expect(Object.keys(COLUMN_LAYOUT_PRESETS)).toContain(preset), ); + // The title is the one primary flexible column of a list. + expect(presets.filter(preset => preset === 'title').length).toBeLessThan(2); }, ); + +test('every layout preset is used by a list view page', () => { + const used = new Set( + listViewPages.flatMap(({ source }) => + Array.from( + source.matchAll(/^\s*layout: '(\w+)'(?: as const)?,$/gm), + match => match[1], + ), + ), + ); + expect(Object.keys(COLUMN_LAYOUT_PRESETS).filter(p => !used.has(p))).toEqual( + [], + ); +}); diff --git a/superset-frontend/src/pages/ActionLog/index.tsx b/superset-frontend/src/pages/ActionLog/index.tsx index a845f30d6bc2..5fa420247b1e 100644 --- a/superset-frontend/src/pages/ActionLog/index.tsx +++ b/superset-frontend/src/pages/ActionLog/index.tsx @@ -305,7 +305,7 @@ function ActionLogList() { { accessor: 'dttm', Header: t('Dttm'), - layout: 'date', + layout: 'timestamp', Cell: ({ row: { original: { dttm }, diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index 16c257f5bfde..fa147faeaf8f 100644 --- a/superset-frontend/src/pages/AlertReportList/index.tsx +++ b/superset-frontend/src/pages/AlertReportList/index.tsx @@ -326,7 +326,7 @@ function AlertList({ /> ), accessor: 'last_state', - layout: 'icon', + layout: 'statusIcon', disableSortBy: true, id: 'last_state', }, @@ -344,7 +344,7 @@ function AlertList({ : '', accessor: 'last_eval_dttm', Header: t('Last run'), - layout: 'date', + layout: 'timestamp', id: 'last_eval_dttm', }, { @@ -383,7 +383,7 @@ function AlertList({ accessor: 'recipients', Header: t('Notification method'), disableSortBy: true, - layout: 'statusWide', + layout: 'iconList', id: 'recipients', }, { @@ -408,7 +408,7 @@ function AlertList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -431,7 +431,7 @@ function AlertList({ Header: t('Active'), accessor: 'active', id: 'active', - layout: 'status', + layout: 'flag', }, { Cell: ({ row: { original } }: any) => { diff --git a/superset-frontend/src/pages/AnnotationLayerList/index.tsx b/superset-frontend/src/pages/AnnotationLayerList/index.tsx index 9e40a72fcea7..d8c8dcdc69dd 100644 --- a/superset-frontend/src/pages/AnnotationLayerList/index.tsx +++ b/superset-frontend/src/pages/AnnotationLayerList/index.tsx @@ -182,7 +182,7 @@ function AnnotationLayersList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on', - layout: 'date', + layout: 'dateRelative', id: 'changed_on', }, { diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index b83a64068676..ceb8ca895487 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -183,7 +183,7 @@ function AnnotationList({ Header: t('Start'), accessor: 'start_dttm', id: 'start_dttm', - layout: 'date', + layout: 'timestamp', }, { Cell: ({ @@ -196,7 +196,7 @@ function AnnotationList({ Header: t('End'), accessor: 'end_dttm', id: 'end_dttm', - layout: 'date', + layout: 'timestamp', }, { Cell: ({ diff --git a/superset-frontend/src/pages/ArchivedList/index.tsx b/superset-frontend/src/pages/ArchivedList/index.tsx index 2dab617df98a..eed4769b2ded 100644 --- a/superset-frontend/src/pages/ArchivedList/index.tsx +++ b/superset-frontend/src/pages/ArchivedList/index.tsx @@ -475,7 +475,7 @@ function ArchivedListBody({ String(original.deleted_at_delta_humanized ?? ''), Header: t('Archived'), id: 'deleted_at', - layout: 'date', + layout: 'dateRelative', }, { // Archiving user, from changed_by. Non-sortable — there is no backend diff --git a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx index bbd13e7a0e11..3cab8aae48a9 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -23,11 +23,12 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { setupMocks, renderChartList } from './ChartList.testHelpers'; import { expectColumnWidthsWithinLayouts, + getFrozenHeaders, mockListViewContainerWidth, } from 'spec/helpers/listViewColumnWidths'; import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; -const CONTAINER_WIDTH = 1440; +const CONTAINER_WIDTH = 1920; jest.setTimeout(30000); @@ -94,18 +95,33 @@ test('every visible column resolves to a width within its layout', async () => { '': 'icon', Name: 'title', Type: 'text', - Dataset: 'text', - 'On dashboards': 'tags', + Dataset: 'identifier', + 'On dashboards': 'text', Tags: 'tags', Editors: 'people', Viewers: 'people', - 'Last modified': 'date', + 'Last modified': 'dateRelative', Actions: 'actions', }, CONTAINER_WIDTH, ); }); +test('freezes the favorite star and name when the columns do not fit', async () => { + // With Tags and Viewers on the minimums add up to 1564px. + mockListViewContainerWidth(1440); + renderChartList(mockUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + expect(getFrozenHeaders(table)).toEqual(['', 'Name']); +}); + test('chart names truncate with an ellipsis beside their badges', async () => { renderChartList(mockUser); @@ -115,3 +131,4 @@ test('chart names truncate with an ellipsis beside their badges', async () => { expectTruncatingTitle(link); } }); + diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index fe34ca7f170b..4d393db1dd24 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -593,7 +593,7 @@ function ChartList(props: ChartListProps) { Header: datasetLabel(), accessor: 'datasource_id', disableSortBy: true, - layout: 'text', + layout: 'identifier', id: 'datasource_id', }, { @@ -605,7 +605,7 @@ function ChartList(props: ChartListProps) { Header: t('On dashboards'), accessor: 'dashboards', disableSortBy: true, - layout: 'tags', + layout: 'text', id: 'dashboards', }, { @@ -671,7 +671,7 @@ function ChartList(props: ChartListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/CssTemplateList/index.tsx b/superset-frontend/src/pages/CssTemplateList/index.tsx index baa671208c33..8a66be8de265 100644 --- a/superset-frontend/src/pages/CssTemplateList/index.tsx +++ b/superset-frontend/src/pages/CssTemplateList/index.tsx @@ -148,7 +148,7 @@ function CssTemplatesList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', disableSortBy: true, id: 'changed_on_delta_humanized', }, diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx index 9491a3e7c1f5..0462fca54ad4 100644 --- a/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx @@ -82,7 +82,7 @@ test('every visible column resolves to a width within its layout', async () => { Tags: 'tags', Editors: 'people', Viewers: 'people', - 'Last modified': 'date', + 'Last modified': 'dateRelative', Actions: 'actions', }, CONTAINER_WIDTH, diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index df3c48443c9e..09da138e55a5 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -505,7 +505,7 @@ function DashboardList(props: DashboardListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index ecfd4e90cc0c..e426a611e603 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -851,7 +851,7 @@ function DatabaseList({ // i18n: the kind of system behind a connection: a database engine // (PostgreSQL, MySQL) or a semantic layer; not a server tier or a driver Header: t('Backend'), - layout: 'text', + layout: 'identifier', disableSortBy: true, id: 'backend', }, @@ -872,7 +872,7 @@ function DatabaseList({ ) : ( ), - layout: 'status', + layout: 'flag', id: 'allow_run_async', }, { @@ -892,7 +892,7 @@ function DatabaseList({ ) : ( ), - layout: 'status', + layout: 'flag', id: 'allow_dml', }, { @@ -904,7 +904,7 @@ function DatabaseList({ ) : ( ), - layout: 'status', + layout: 'flag', id: 'allow_file_upload', }, { @@ -916,7 +916,7 @@ function DatabaseList({ ) : ( ), - layout: 'statusWide', + layout: 'flag', id: 'expose_in_sqllab', }, { @@ -932,7 +932,7 @@ function DatabaseList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx index 8df9db69001e..dd4dd8509a0c 100644 --- a/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/DatasetList/DatasetList.columnSizing.test.tsx @@ -25,6 +25,7 @@ import { } from './DatasetList.testHelpers'; import { expectColumnWidthsWithinLayouts, + getFrozenHeaders, mockListViewContainerWidth, } from 'spec/helpers/listViewColumnWidths'; import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; @@ -65,16 +66,31 @@ test('every visible column resolves to a width within its layout', async () => { { Name: 'title', Type: 'status', - Database: 'text', - Schema: 'text', + Database: 'identifier', + Schema: 'identifier', Editors: 'people', - 'Last modified': 'date', + 'Last modified': 'dateRelative', Actions: 'actions', }, CONTAINER_WIDTH, ); }); +test('freezes the name when the columns do not fit', async () => { + // The minimums add up to 1240px. + mockListViewContainerWidth(1024); + renderDatasetList(mockAdminUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + expect(getFrozenHeaders(table)).toEqual(['Name']); +}); + test('dataset names truncate with an ellipsis beside their badges', async () => { renderDatasetList(mockAdminUser); diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index c9ddd07159ee..12c32df5ca04 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -970,7 +970,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => database?.database_name || '-', Header: databaseLabel(), accessor: 'database.database_name', - layout: 'text', + layout: 'identifier', id: 'database.database_name', }, { @@ -981,7 +981,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => schema || '-', Header: t('Schema'), accessor: 'schema', - layout: 'text', + layout: 'identifier', id: 'schema', }, { @@ -1014,7 +1014,7 @@ const DatasetList: FunctionComponent = ({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/ExecutionLogList/index.tsx b/superset-frontend/src/pages/ExecutionLogList/index.tsx index 41af6febb93d..2cdbf0a1d5de 100644 --- a/superset-frontend/src/pages/ExecutionLogList/index.tsx +++ b/superset-frontend/src/pages/ExecutionLogList/index.tsx @@ -103,7 +103,7 @@ function ExecutionLog({ ), accessor: 'state', Header: t('State'), - layout: 'status', + layout: 'statusIcon', disableSortBy: true, id: 'state', }, @@ -129,7 +129,7 @@ function ExecutionLog({ accessor: 'scheduled_dttm', Header: t('Scheduled at (UTC)'), id: 'scheduled_dttm', - layout: 'date', + layout: 'timestamp', }, { Cell: ({ @@ -142,7 +142,7 @@ function ExecutionLog({ Header: t('Start at (UTC)'), accessor: 'start_dttm', id: 'start_dttm', - layout: 'date', + layout: 'timestamp', }, { Cell: ({ @@ -162,7 +162,7 @@ function ExecutionLog({ Header: t('Duration'), disableSortBy: true, id: 'duration', - layout: 'count', + layout: 'timer', }, { accessor: 'value', diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index ef5e99fff578..f597f8b7f503 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -222,14 +222,14 @@ function QueryList({ addDangerToast }: QueryListProps) { ); }, accessor: QueryObjectColumns.Status, - layout: 'icon', + layout: 'statusIcon', disableSortBy: true, id: QueryObjectColumns.Status, }, { accessor: QueryObjectColumns.StartTime, Header: t('Time'), - layout: 'date', + layout: 'dateTwoLine', Cell: ({ row: { original: { start_time }, @@ -252,7 +252,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }, { Header: t('Duration'), - layout: 'count', + layout: 'timer', Cell: ({ row: { original: { status, start_time, start_running_time, end_time }, @@ -284,7 +284,7 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.DatabaseName, Header: t('Database'), - layout: 'text', + layout: 'identifier', id: QueryObjectColumns.DatabaseName, }, { @@ -295,7 +295,7 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.Schema, Header: t('Schema'), - layout: 'text', + layout: 'identifier', id: QueryObjectColumns.Schema, }, { @@ -333,7 +333,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }, accessor: QueryObjectColumns.SqlTables, Header: t('Tables'), - layout: 'tags', + layout: 'identifier', disableSortBy: true, id: QueryObjectColumns.SqlTables, }, diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx index 747dbad9d455..b708a3e1e054 100644 --- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx +++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx @@ -160,7 +160,7 @@ function RowLevelSecurityList(props: RLSProps) { }) => , Header: t('Subjects'), accessor: 'subjects', - layout: 'tags', + layout: 'people', id: 'subjects', disableSortBy: true, }, @@ -187,7 +187,7 @@ function RowLevelSecurityList(props: RLSProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index 57bc250a02db..b365b64345e2 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -363,7 +363,7 @@ function SavedQueryList({ { accessor: 'database.database_name', Header: t('Database'), - layout: 'text', + layout: 'identifier', id: 'database.database_name', }, { @@ -375,7 +375,7 @@ function SavedQueryList({ { accessor: 'schema', Header: t('Schema'), - layout: 'text', + layout: 'identifier', id: 'schema', }, { @@ -413,7 +413,7 @@ function SavedQueryList({ }, accessor: 'sql_tables', Header: t('Tables'), - layout: 'tags', + layout: 'identifier', disableSortBy: true, id: 'sql_tables', }, @@ -450,7 +450,7 @@ function SavedQueryList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index 32115743206c..0031073bcae1 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -190,7 +190,7 @@ function TagList(props: TagListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { diff --git a/superset-frontend/src/pages/TaskList/index.tsx b/superset-frontend/src/pages/TaskList/index.tsx index 7b021876ca61..f1aa2f6a6345 100644 --- a/superset-frontend/src/pages/TaskList/index.tsx +++ b/superset-frontend/src/pages/TaskList/index.tsx @@ -329,7 +329,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), accessor: 'status', Header: t('Status'), - layout: 'status', + layout: 'statusIcon', id: 'status', }, { @@ -416,7 +416,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), Header: t('Created'), accessor: 'created_on', - layout: 'date', + layout: 'dateRelative', id: 'created_on', }, { @@ -568,7 +568,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'payload', Header: t('Details'), - layout: 'status', + layout: 'statusIcons', id: 'payload', disableSortBy: true, }, diff --git a/superset-frontend/src/pages/ThemeList/index.tsx b/superset-frontend/src/pages/ThemeList/index.tsx index 2599603314b1..ff14a799e09e 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -497,7 +497,7 @@ function ThemesList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - layout: 'date', + layout: 'dateRelative', disableSortBy: true, id: 'changed_on_delta_humanized', }, diff --git a/superset-frontend/src/pages/UserRegistrations/index.tsx b/superset-frontend/src/pages/UserRegistrations/index.tsx index 960307ed7225..5eff1bd674aa 100644 --- a/superset-frontend/src/pages/UserRegistrations/index.tsx +++ b/superset-frontend/src/pages/UserRegistrations/index.tsx @@ -118,7 +118,7 @@ export default function UserRegistrations() { accessor: 'registration_date', id: 'registration_date', Header: t('Registration date'), - layout: 'date', + layout: 'timestamp', Cell: ({ row: { original } }: any) => original.registration_date, }, { diff --git a/superset-frontend/src/pages/UsersList/index.tsx b/superset-frontend/src/pages/UsersList/index.tsx index dd49dda58525..ad7ac51f3977 100644 --- a/superset-frontend/src/pages/UsersList/index.tsx +++ b/superset-frontend/src/pages/UsersList/index.tsx @@ -235,7 +235,7 @@ function UsersList({ user }: UsersListProps) { accessor: 'active', id: 'active', Header: t('Is active?'), - layout: 'status', + layout: 'flag', Cell: ({ row: { original: { active }, @@ -304,7 +304,7 @@ function UsersList({ user }: UsersListProps) { id: 'created_on', Header: t('Created on'), hidden: true, - layout: 'date', + layout: 'timestamp', Cell: ({ row: { original: { created_on }, @@ -316,7 +316,7 @@ function UsersList({ user }: UsersListProps) { id: 'changed_on', Header: t('Changed on'), hidden: true, - layout: 'date', + layout: 'timestamp', Cell: ({ row: { original: { changed_on }, @@ -328,7 +328,7 @@ function UsersList({ user }: UsersListProps) { id: 'last_login', Header: t('Last login'), hidden: true, - layout: 'date', + layout: 'timestamp', Cell: ({ row: { original: { last_login }, From 9f829ad88c3184721fc77793d89f241791a96de6 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 17:19:04 +0000 Subject: [PATCH 06/12] fix(listview): keep chips and counts from clipping in list cells - Query history and Saved queries "Tables": the first table name ellipsizes while the "(+N)" count stays visible. - Tags list name pill and the Charts "Matrixified" pill get a max width and an ellipsis, so a pill is never cut mid-pill; the chart type name shrinks first. - Saved queries tags show at most three chips, like Dashboards and Charts. - Task names are no longer cut at 30 characters in JS on top of the cell's CSS ellipsis; the tooltip still carries the full name. Co-Authored-By: Claude Opus 5.5 --- .../ChartList/ChartList.columnSizing.test.tsx | 16 ++- .../src/pages/ChartList/index.tsx | 51 ++++++-- .../QueryHistoryList.cells.test.tsx | 84 +++++++++++++ .../src/pages/QueryHistoryList/index.tsx | 13 +- .../SavedQueryList.cells.test.tsx | 118 ++++++++++++++++++ .../src/pages/SavedQueryList/index.tsx | 14 ++- .../src/pages/Tags/Tags.test.tsx | 17 +++ superset-frontend/src/pages/Tags/index.tsx | 14 ++- .../src/pages/TaskList/TaskList.test.tsx | 24 ++++ .../src/pages/TaskList/index.tsx | 6 +- 10 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.cells.test.tsx create mode 100644 superset-frontend/src/pages/SavedQueryList/SavedQueryList.cells.test.tsx diff --git a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx index 3cab8aae48a9..ee46ad56188b 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -18,7 +18,7 @@ */ import fetchMock from 'fetch-mock'; import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData'; -import { screen, waitFor } from 'spec/helpers/testing-library'; +import { screen, waitFor, within } from 'spec/helpers/testing-library'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { setupMocks, renderChartList } from './ChartList.testHelpers'; import { @@ -132,3 +132,17 @@ test('chart names truncate with an ellipsis beside their badges', async () => { } }); +test('the Matrixified pill stays whole beside a truncating chart type', async () => { + renderChartList(mockUser); + + const row = (await screen.findByText('Test Chart 0')).closest('tr')!; + const pill = within(row).getByText('Matrixified').closest('.ant-tag')!; + const pillStyle = getComputedStyle(pill); + expect(pillStyle.maxWidth).toBe('100%'); + expect(pillStyle.overflow).toBe('hidden'); + expect(pillStyle.textOverflow).toBe('ellipsis'); + expect(getComputedStyle(pill.parentElement!).flexShrink).toBe('0'); + // The chart type name shrinks first. + const typeName = pill.parentElement!.previousElementSibling as HTMLElement; + expectTruncatingTitle(typeName); +}); diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 4d393db1dd24..9d4c38b45c81 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { SupersetTheme, css, styled } from '@apache-superset/core/theme'; +import { styled } from '@apache-superset/core/theme'; import { t, tn } from '@apache-superset/core/translation'; import { isFeatureEnabled, @@ -231,6 +231,33 @@ interface ChartListProps { user?: UserWithPermissionsAndRoles; } +// The chart type name ellipsizes first; the Matrixified pill is never cut +// mid-pill. +const VizTypeCell = styled.span` + display: flex; + align-items: center; + + .viz-type-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .viz-type-tag { + flex-shrink: 0; + max-width: 100%; + margin-left: ${({ theme }) => theme.marginXS}px; + } + + .viz-type-tag .ant-tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +`; + const Actions = styled.div` color: ${({ theme }) => theme.colorIcon}; `; @@ -548,20 +575,18 @@ function ChartList(props: ChartListProps) { row: { original: { viz_type: vizType, form_data: formData }, }, - }: any) => ( - <> - {registry.get(vizType)?.name || vizType} - {formData && isMatrixifyEnabled(formData) && ( - css` - margin-left: ${theme.marginXS}px; - `} - > + }: any) => { + const vizName = registry.get(vizType)?.name || vizType; + if (!formData || !isMatrixifyEnabled(formData)) return vizName; + return ( + + {vizName} + - )} - - ), + + ); + }, Header: t('Type'), accessor: 'viz_type', layout: 'text', diff --git a/superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.cells.test.tsx b/superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.cells.test.tsx new file mode 100644 index 000000000000..8959940266e6 --- /dev/null +++ b/superset-frontend/src/pages/QueryHistoryList/QueryHistoryList.cells.test.tsx @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryParamProvider } from 'use-query-params'; +import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5'; +import { render, screen } from 'spec/helpers/testing-library'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; +import QueryHistoryList from '.'; + +jest.setTimeout(30000); + +const longTable = 'fct_orders_enriched_with_customer_attributes_daily_snapshot'; + +const mockQuery = { + id: 1, + changed_on: '2026-01-01T00:00:00', + database: { database_name: 'examples' }, + schema: 'public', + sql: 'SELECT 1', + executed_sql: null, + sql_tables: [ + { catalog: null, schema: 'public', table: longTable }, + { catalog: null, schema: 'public', table: 'dim_customers' }, + { catalog: null, schema: 'public', table: 'dim_dates' }, + ], + status: 'success', + tab_name: 'Untitled Query 1', + user: { first_name: 'admin', id: 1, last_name: 'user', username: 'admin' }, + start_time: 1767225600000, + start_running_time: null, + end_time: 1767225601000, + rows: 10, + tmp_table_name: '', + tracking_url: '', +}; + +beforeEach(() => { + fetchMock.get('glob:*/api/v1/query/_info*', { + permissions: ['can_read'], + }); + fetchMock.get('glob:*/api/v1/query/?*', { + ids: [1], + result: [mockQuery], + count: 1, + }); + fetchMock.get('glob:*', { result: [], count: 0 }); +}); + +afterEach(() => { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); +}); + +test('the first table name ellipsizes while the table count stays visible', async () => { + render( + + + + + , + { useRedux: true }, + ); + const tableName = await screen.findByText(longTable); + expectTruncatingTitle(tableName); + const count = screen.getByText('(+2)'); + expect(getComputedStyle(count).flexShrink).toBe('0'); +}); diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index f597f8b7f503..cb6efde4205a 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -84,8 +84,19 @@ interface QueryListProps { addSuccessToast: (msg: string, config?: any) => any; } +// The first table name ellipsizes; the "(+N)" count always stays visible. const StyledTableLabel = styled.div` + display: flex; + + .table-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .count { + flex-shrink: 0; margin-left: 5px; color: ${({ theme }) => theme.colorPrimary}; text-decoration: underline; @@ -310,7 +321,7 @@ function QueryList({ addDangerToast }: QueryListProps) { if (names.length) { return ( - {main} + {main} ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction< + typeof isFeatureEnabled +>; + +const mockUser = { userId: 1, firstName: 'admin', lastName: 'admin' }; + +const longTable = 'fct_orders_enriched_with_customer_attributes_daily_snapshot'; + +const mockQuery = { + created_by: { id: 1, first_name: 'user', last_name: '1' }, + created_on: '1-2020', + database: { database_name: 'examples', id: 1 }, + changed_on_delta_humanized: '1 day ago', + db_id: 1, + description: 'SQL', + id: 1, + label: 'query with many tables', + schema: 'public', + sql: 'SELECT 1', + sql_tables: [ + { catalog: null, schema: null, table: longTable }, + { catalog: null, schema: null, table: 'dim_customers' }, + { catalog: null, schema: null, table: 'dim_dates' }, + ], + tags: ['alpha', 'bravo', 'charlie', 'delta', 'echo'].map((name, id) => ({ + id, + name, + type: 1, + })), +}; + +beforeEach(() => { + mockIsFeatureEnabled.mockImplementation( + flag => flag === FeatureFlag.TaggingSystem, + ); + fetchMock.get('glob:*/api/v1/saved_query/_info*', { + permissions: ['can_write', 'can_read', 'can_export'], + }); + fetchMock.get('glob:*/api/v1/saved_query/?*', { + ids: [1], + result: [mockQuery], + count: 1, + }); + fetchMock.get('glob:*', { result: [], count: 0 }); +}); + +afterEach(() => { + fetchMock.removeRoutes(); + fetchMock.clearHistory(); + mockIsFeatureEnabled.mockReset(); +}); + +const renderList = () => + render( + + + + + , + { + useRedux: true, + initialState: { + user: { ...mockUser, roles: { Admin: [['can_write', 'SavedQuery']] } }, + }, + }, + ); + +test('the first table name ellipsizes while the table count stays visible', async () => { + renderList(); + const tableName = await screen.findByText(longTable); + expectTruncatingTitle(tableName); + const count = screen.getByText('(+2)'); + expect(getComputedStyle(count).flexShrink).toBe('0'); +}); + +test('shows at most three tag chips', async () => { + renderList(); + const row = (await screen.findByText('query with many tables')).closest( + 'tr', + )!; + const chips = within(row).getAllByTestId('tag'); + expect(chips).toHaveLength(3); + expect(chips[2]).toHaveTextContent('+3...'); +}); diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index b365b64345e2..e03ae6b09f3b 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -101,8 +101,19 @@ type SavedQueryCellProps = { }; }; +// The first table name ellipsizes; the "(+N)" count always stays visible. const StyledTableLabel = styled.div` + display: flex; + + .table-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .count { + flex-shrink: 0; margin-left: 5px; color: ${({ theme }) => theme.colorPrimary}; text-decoration: underline; @@ -390,7 +401,7 @@ function SavedQueryList({ if (names.length) { return ( - {main} + {main} tag.type === TagTypeEnum.Custom, )} + maxTags={3} /> ), Header: t('Tags'), diff --git a/superset-frontend/src/pages/Tags/Tags.test.tsx b/superset-frontend/src/pages/Tags/Tags.test.tsx index 7b363125ad07..7fa537e77dbb 100644 --- a/superset-frontend/src/pages/Tags/Tags.test.tsx +++ b/superset-frontend/src/pages/Tags/Tags.test.tsx @@ -132,3 +132,20 @@ test('toggles the favorite star and updates the icon on click', async () => { { timeout: 5000 }, ); }); + +test('a long tag name ellipsizes inside its pill instead of clipping it', async () => { + const longName = 'quarterly_revenue_by_region_and_product_line_reviewed'; + fetchMock.get(ENDPOINTS.TAGS, { + result: [{ ...mockTags[0], name: longName }], + count: 1, + }); + setupMocks(); + renderTagList(); + + const pill = (await screen.findByText(longName)).closest('.ant-tag')!; + const style = getComputedStyle(pill); + expect(style.maxWidth).toBe('100%'); + expect(style.overflow).toBe('hidden'); + expect(style.textOverflow).toBe('ellipsis'); + expect(style.whiteSpace).toBe('nowrap'); +}); diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx index 0031073bcae1..4a2793f376db 100644 --- a/superset-frontend/src/pages/Tags/index.tsx +++ b/superset-frontend/src/pages/Tags/index.tsx @@ -18,6 +18,7 @@ */ import { useMemo, useState } from 'react'; import { t } from '@apache-superset/core/translation'; +import { styled } from '@apache-superset/core/theme'; import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; import { Actions, @@ -51,6 +52,15 @@ import TagModal from 'src/features/tags/TagModal'; const PAGE_SIZE = 25; +// A long tag name ellipsizes inside its pill instead of clipping the pill. +const TitleTag = styled(AntdTag)` + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +`; + interface TagListProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; @@ -170,9 +180,9 @@ function TagList(props: TagListProps) { original: { id, name: tagName }, }, }: any) => ( - + {tagName} - + ), Header: t('Name'), accessor: 'name', diff --git a/superset-frontend/src/pages/TaskList/TaskList.test.tsx b/superset-frontend/src/pages/TaskList/TaskList.test.tsx index 8c521931f765..228576f2a997 100644 --- a/superset-frontend/src/pages/TaskList/TaskList.test.tsx +++ b/superset-frontend/src/pages/TaskList/TaskList.test.tsx @@ -332,3 +332,27 @@ test('displays empty state when no tasks', async () => { response: { result: mockTasks, count: 3 }, }); }); + +test('shows the full task name once, truncated only by its cell', async () => { + const longName = 'Export every dashboard and chart for the quarterly review'; + fetchMock.removeRoute(tasksEndpoint); + fetchMock.get( + tasksEndpoint, + { result: [{ ...mockTasks[0], task_name: longName }], count: 1 }, + { name: tasksEndpoint }, + ); + try { + renderTaskList(); + + // No JS cut: the cell's CSS ellipsis is the only truncation. + expect(await screen.findByText(longName)).toBeInTheDocument(); + expect(screen.queryByText(/\.\.\.$/)).not.toBeInTheDocument(); + } finally { + fetchMock.removeRoute(tasksEndpoint); + fetchMock.get( + tasksEndpoint, + { result: mockTasks, count: 3 }, + { name: tasksEndpoint }, + ); + } +}); diff --git a/superset-frontend/src/pages/TaskList/index.tsx b/superset-frontend/src/pages/TaskList/index.tsx index f1aa2f6a6345..917b89ef3988 100644 --- a/superset-frontend/src/pages/TaskList/index.tsx +++ b/superset-frontend/src/pages/TaskList/index.tsx @@ -283,10 +283,6 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }: TaskCellProps) => { // Display preference: task_name > task_key const displayText = task_name || task_key; - const truncated = - displayText.length > 30 - ? `${displayText.slice(0, 30)}...` - : displayText; // Build tooltip with all identifiers const tooltipLines = []; @@ -302,7 +298,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { } placement="top" > - {truncated} + {displayText} ); }, From 00ced50e828d612024e4ee284da50d918a4e2986 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 19:30:43 +0000 Subject: [PATCH 07/12] fix(listview): keep managed-only header and scroll styles off generic tables The header ellipsis and the frozen-block scroll padding only make sense for list views with managed column widths. Scope them to managed tables so TableView and other auto-layout tables keep sizing columns to their headers. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 19 +++++++++++ .../src/components/TableCollection/index.tsx | 34 ++++++++++++------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 6b95d2e35fb0..257cea3c43f7 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -655,3 +655,22 @@ test('managed text headers carry their full text as a tooltip', () => { .closest('th'); expect(header).toHaveAttribute('title', 'Database'); }); + +test('only managed tables clip wrapping header titles', () => { + // A wrap-text column's header keeps antd's default in unmanaged tables. + const headerStyle = (table: HTMLElement) => + window.getComputedStyle( + within(table.querySelector('thead')!) + .getByText('Name') + .closest('.ant-table-column-title')!, + ); + const wrapName = { columnsForWrapText: ['name'] }; + const managed = renderLayoutTable(1000, { + ...wrapName, + managedColumnWidths: true, + }); + expect(headerStyle(managed.table).textOverflow).toBe('ellipsis'); + managed.unmount(); + const unmanaged = renderLayoutTable(1000, wrapName); + expect(headerStyle(unmanaged.table).textOverflow).not.toBe('ellipsis'); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index eae4693cdda6..1d286d0b64bc 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -128,8 +128,9 @@ function useContainerWidth( const StyledTable = styled(Table)<{ isPaginationSticky?: boolean; showRowCount?: boolean; + managedColumnWidths?: boolean; }>` - ${({ theme, isPaginationSticky, showRowCount }) => ` + ${({ theme, isPaginationSticky, showRowCount, managedColumnWidths }) => ` .actions { opacity: 0; font-size: ${theme.fontSizeXL}px; @@ -151,19 +152,25 @@ const StyledTable = styled(Table)<{ line-height: initial; } - // Headers longer than a fixed column ellipsize; antd puts the full text - // in the header cell's title. - .ant-table-column-title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + ${ + managedColumnWidths + ? ` + // Headers longer than a fixed column ellipsize; antd puts the full + // text in the header cell's title. + .ant-table-column-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - // Keyboard focus never lands under the frozen leading columns. - .ant-table-content, - .ant-table-body { - scroll-padding-inline-start: var(--list-view-frozen-width, 0); + // Keyboard focus never lands under the frozen leading columns. + .ant-table-content, + .ant-table-body { + scroll-padding-inline-start: var(--list-view-frozen-width, 0); + } + ` + : '' } .ant-table-row:hover { @@ -518,6 +525,7 @@ function TableCollection({ sortDirections={['ascend', 'descend', 'ascend']} isPaginationSticky={isPaginationSticky} showRowCount={showRowCount} + managedColumnWidths={managedColumnWidths} rowClassName={ getRowClassName as unknown as TableProps['rowClassName'] } From 6eea10a10e4b308f57142f2e89ecc2ab0483c027 Mon Sep 17 00:00:00 2001 From: sadpandajoe Date: Fri, 30 Oct 2026 21:45:43 +0000 Subject: [PATCH 08/12] fix(listview): give the databases actions column room for five icons A database with OAuth2 enabled shows Edit, Export, Sync permissions, Manage OAuth2 tokens and Delete. Five icons end flush with the 120px actions cell, so the column uses the 168px actionsWide preset like the other five- and six-icon lists. A TableView test pins that generic tables keep the auto layout and content-width scroll. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/columnWidths.ts | 5 +- .../components/TableView/TableView.test.tsx | 15 +++ .../DatabaseList.columnSizing.test.tsx | 108 ++++++++++++++++++ .../src/pages/DatabaseList/index.tsx | 2 +- 4 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 superset-frontend/src/pages/DatabaseList/DatabaseList.columnSizing.test.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts index f6ae7411659e..de84571ff96d 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -103,7 +103,10 @@ export const COLUMN_LAYOUT_PRESETS: Record< timestamp: { fixed: 240 }, /** Hover action icons, up to four (24px per icon + 24px). */ actions: { fixed: 120 }, - /** Hover action icons, five or six. */ + /** + * Hover action icons, five or six (e.g. databases with OAuth2 tokens: five + * icons end flush with a 120px cell and need 144). + */ actionsWide: { fixed: 168 }, /** * The primary name of a row. The one main flexible column: wide enough for diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableView/TableView.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableView/TableView.test.tsx index 22c2fd40a675..5975f5b9882e 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableView/TableView.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableView/TableView.test.tsx @@ -405,3 +405,18 @@ test('should reset to first page when data reduces below current page', async () expect(screen.getByText('1-5 of 5')).toBeInTheDocument(); }); }); + +test('keeps the auto layout and content-width scroll of a generic table', () => { + render(); + const table = screen.getByRole('table'); + // List view column sizing is opt-in; TableView keeps the browser's auto + // layout, scrolls at its content width and sets no column widths. + expect(table.style.tableLayout).toBe('auto'); + expect(table.style.width).toBe('max-content'); + expect( + Array.from(table.querySelectorAll('colgroup col')).filter( + col => (col as HTMLElement).style.width, + ), + ).toHaveLength(0); + expect(table.querySelectorAll('.ant-table-cell-fix-start')).toHaveLength(0); +}); diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.columnSizing.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.columnSizing.test.tsx new file mode 100644 index 000000000000..149620793032 --- /dev/null +++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.columnSizing.test.tsx @@ -0,0 +1,108 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import { configureStore } from '@reduxjs/toolkit'; +import { render, screen, within } from 'spec/helpers/testing-library'; +import { mockListViewContainerWidth } from 'spec/helpers/listViewColumnWidths'; +import DatabaseList from 'src/pages/DatabaseList'; + +// Each hover action icon takes 24px; the cell adds 24px of padding. +const ACTION_ICON_WIDTH = 24; +const ACTION_CELL_PADDING = 24; + +const user = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: { + Admin: [ + ['can_read', 'Database'], + ['can_write', 'Database'], + ['can_export', 'Database'], + ['can_manage_oauth2_tokens', 'Database'], + ], + }, + permissions: {}, + isActive: true, + email: 'admin@example.com', + createdOn: '2026-01-01T00:00:00', +}; + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + jest.restoreAllMocks(); +}); + +test('the actions column fits every action of an OAuth2 database', async () => { + mockListViewContainerWidth(1920); + fetchMock.get('glob:*/api/v1/database/_info*', { + permissions: ['can_read', 'can_write', 'can_export'], + }); + fetchMock.get('glob:*/api/v1/database/related/*', { result: [], count: 0 }); + fetchMock.get('glob:*/api/v1/database/?q=*', { + count: 1, + result: [ + { + id: 1, + uuid: '846ddf91-9cc3-4fb7-a218-e9d26ca5fe39', + source_type: 'database', + database_name: 'OAuth database', + backend: 'trino', + changed_by: null, + engine_information: { supports_oauth2: true, oauth2_enabled: true }, + }, + ], + }); + const store = configureStore({ + reducer: { + user: (state = user) => state, + common: ( + state = { + conf: { SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: false }, + }, + ) => state, + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }), + }); + render(, { + store, + useQueryParams: true, + useRouter: true, + }); + + const name = await screen.findByText('OAuth database'); + const row = name.closest('tr')!; + const actions = within(row).getAllByRole('button'); + expect(actions.map(action => action.getAttribute('aria-label'))).toEqual( + expect.arrayContaining(['Edit', 'Export', 'Manage OAuth2 tokens']), + ); + expect(actions).toHaveLength(5); + + const table = screen.getByTestId('listview-table').querySelector('table')!; + const headers = Array.from(table.querySelectorAll('thead th')).map( + th => th.textContent?.trim() ?? '', + ); + const widths = Array.from(table.querySelectorAll('colgroup col')).map(col => + parseInt((col as HTMLElement).style.width, 10), + ); + expect(widths[headers.indexOf('Actions')]).toBeGreaterThanOrEqual( + actions.length * ACTION_ICON_WIDTH + ACTION_CELL_PADDING, + ); +}); diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index e426a611e603..7fcfc92bf6dc 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -1043,7 +1043,7 @@ function DatabaseList({ }, Header: t('Actions'), id: 'actions', - layout: 'actions', + layout: 'actionsWide', hidden: !canEdit && !canDelete && From 34d8f0fabbfc46d171d3f894065ed6ea9d1441d8 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 21:55:21 +0000 Subject: [PATCH 09/12] fix(listview): size annotation dates, keep frozen focus still, cap rounding - Annotation start and end dates render as short dates, so they use the relative-date width instead of the full timestamp width. - Focusing a cell inside the frozen leading block no longer scrolls the table back to the start. - The rounding remainder goes to the heaviest flexible column that still has room below its max. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 30 +++++++- .../TableCollection/columnWidths.test.ts | 16 ++++ .../TableCollection/columnWidths.ts | 19 +++-- .../src/components/TableCollection/index.tsx | 9 ++- .../AnnotationList.columnSizing.test.tsx | 73 +++++++++++++++++++ .../src/pages/AnnotationList/index.tsx | 4 +- 6 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 superset-frontend/src/pages/AnnotationList/AnnotationList.columnSizing.test.tsx diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 257cea3c43f7..ecdde5470af2 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -479,8 +479,18 @@ function renderLayoutTable( return { ...utils, table, widths }; } +// jsdom has no scrollIntoView; tests that stub it restore the original. +const originalScrollIntoView = Element.prototype.scrollIntoView; + +function mockScrollIntoView() { + const scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + return scrollIntoView; +} + afterEach(() => { jest.restoreAllMocks(); + Element.prototype.scrollIntoView = originalScrollIntoView; }); test('managed column widths size every visible column to fill the container', () => { @@ -620,8 +630,7 @@ test('does not freeze columns in unmanaged tables', () => { }); test('scrolls a focused cell out from under the frozen columns', () => { - const scrollIntoView = jest.fn(); - Element.prototype.scrollIntoView = scrollIntoView; + const scrollIntoView = mockScrollIntoView(); const columns: ListViewColumn[] = scrollingColumns.map(column => column.id === 'c' ? { ...column, Cell: () => Column C details } @@ -635,9 +644,22 @@ test('scrolls a focused cell out from under the frozen columns', () => { }); }); +test('does not scroll when focus lands in the frozen columns', () => { + const scrollIntoView = mockScrollIntoView(); + const columns: ListViewColumn[] = scrollingColumns.map(column => + column.id === 'name' + ? { ...column, Cell: () => Row name } + : column, + ); + renderLayoutTable(800, { managedColumnWidths: true }, columns); + const link = screen.getByRole('link', { name: 'Row name' }); + expect(link.closest('td')).toHaveClass('ant-table-cell-fix-start'); + link.focus(); + expect(scrollIntoView).not.toHaveBeenCalled(); +}); + test('leaves focus scrolling to the browser when nothing is frozen', () => { - const scrollIntoView = jest.fn(); - Element.prototype.scrollIntoView = scrollIntoView; + const scrollIntoView = mockScrollIntoView(); const columns: ListViewColumn[] = scrollingColumns.map(column => column.id === 'c' ? { ...column, Cell: () => Column C details } diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts index 8f542157daab..fbd2f385a57b 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts @@ -271,3 +271,19 @@ test('does not freeze when the container width is unknown', () => { }), ).toBe(0); }); + +test('the rounding remainder never pushes a capped column past its max', () => { + // The title caps at 640; three text columns share 380px as 126.67 each. + const specs = [ + getColumnSpec('title'), + getColumnSpec('text'), + getColumnSpec('text'), + getColumnSpec('text'), + ]; + const { widths } = resolveColumnWidths(specs, 1440); + expect(widths.reduce((total, width) => total + width, 0)).toBe(1440); + widths.forEach((width, index) => { + expect(width).toBeGreaterThanOrEqual(specs[index].min); + expect(width).toBeLessThanOrEqual(specs[index].max); + }); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts index de84571ff96d..de4f35c94b41 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -93,13 +93,17 @@ export const COLUMN_LAYOUT_PRESETS: Record< /** A short row of icons (notification recipients). */ iconList: { fixed: 120 }, /** - * "3 months ago" style relative date; German strings such as + * "3 months ago" style relative date, or a short date without a time + * ("Oct 29, 2026", "29. Okt. 2026"); German strings such as * "vor ein paar Sekunden" need 176. */ dateRelative: { fixed: 176 }, /** A date over a time on two lines. */ dateTwoLine: { fixed: 136 }, - /** An absolute timestamp such as "2026-10-29 14:03:12+00:00". */ + /** + * An absolute date and time such as "2026-10-29 14:03:12+00:00" (action + * log, alert runs, execution log, user dates). + */ timestamp: { fixed: 240 }, /** Hover action icons, up to four (24px per icon + 24px). */ actions: { fixed: 120 }, @@ -252,10 +256,15 @@ export function resolveColumnWidths( const floored = widths.map(Math.floor); const remainder = containerWidth - floored.reduce((total, width) => total + width, 0); - const heaviest = flexIndexes.reduce((best, index) => - specs[index].weight > specs[best].weight ? index : best, + // The rounding remainder goes to the heaviest column with room below its + // max (any heaviest column once all are capped and overflowing anyway). + const byWeight = [...flexIndexes].sort( + (a, b) => specs[b].weight - specs[a].weight, ); - floored[heaviest] += remainder; + const target = + byWeight.find(index => floored[index] + remainder <= specs[index].max) ?? + byWeight[0]; + floored[target] += remainder; return { widths: floored, totalWidth: containerWidth, scrolls: false }; } diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index 1d286d0b64bc..52912855dd51 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -390,7 +390,14 @@ function TableCollection({ // focused element into view does, and respects the padding. const handleFocus = useCallback( (event: FocusEvent) => { - if (frozenWidth > 0 && event.target.closest('td')) { + // Frozen cells are always visible; scrolling to them would jump the + // table back to the start. + const cell = event.target.closest('td'); + if ( + frozenWidth > 0 && + cell && + !cell.classList.contains('ant-table-cell-fix-start') + ) { event.target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } }, diff --git a/superset-frontend/src/pages/AnnotationList/AnnotationList.columnSizing.test.tsx b/superset-frontend/src/pages/AnnotationList/AnnotationList.columnSizing.test.tsx new file mode 100644 index 000000000000..a76fd3a1d3eb --- /dev/null +++ b/superset-frontend/src/pages/AnnotationList/AnnotationList.columnSizing.test.tsx @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import fetchMock from 'fetch-mock'; +import { Route } from 'react-router-dom'; +import { render, screen } from 'spec/helpers/testing-library'; +import { COLUMN_LAYOUT_PRESETS } from '@superset-ui/core/components/TableCollection'; +import { mockListViewContainerWidth } from 'spec/helpers/listViewColumnWidths'; +import AnnotationList from 'src/pages/AnnotationList'; + +afterEach(() => { + fetchMock.clearHistory().removeRoutes(); + jest.restoreAllMocks(); +}); + +test('start and end dates use the short date width', async () => { + mockListViewContainerWidth(1440); + window.history.pushState({}, '', '/annotationlayer/1/annotation/'); + fetchMock.get('glob:*/api/v1/annotation_layer/1', { + id: 1, + result: { id: 1, name: 'Releases', descr: '' }, + }); + fetchMock.get('glob:*/api/v1/annotation_layer/1/annotation/_info*', { + permissions: ['can_write'], + }); + fetchMock.get('glob:*/api/v1/annotation_layer/1/annotation/?q=*', { + count: 1, + ids: [1], + result: [ + { + id: 1, + short_descr: 'Release 1', + long_descr: 'Major release', + start_dttm: '2026-10-29T10:00:00', + end_dttm: '2026-10-30T10:00:00', + changed_on_delta_humanized: '1 hour ago', + }, + ], + }); + render( + + + , + { useRedux: true, useRouter: true, useQueryParams: true }, + ); + await screen.findByText('Release 1'); + + const table = screen.getByTestId('listview-table').querySelector('table')!; + const headers = Array.from(table.querySelectorAll('thead th')).map( + th => th.textContent?.trim() ?? '', + ); + const widths = Array.from(table.querySelectorAll('colgroup col')).map(col => + parseInt((col as HTMLElement).style.width, 10), + ); + const { fixed } = COLUMN_LAYOUT_PRESETS.dateRelative as { fixed: number }; + expect(widths[headers.indexOf('Start')]).toBe(fixed); + expect(widths[headers.indexOf('End')]).toBe(fixed); +}); diff --git a/superset-frontend/src/pages/AnnotationList/index.tsx b/superset-frontend/src/pages/AnnotationList/index.tsx index ceb8ca895487..c208d84e9c23 100644 --- a/superset-frontend/src/pages/AnnotationList/index.tsx +++ b/superset-frontend/src/pages/AnnotationList/index.tsx @@ -183,7 +183,7 @@ function AnnotationList({ Header: t('Start'), accessor: 'start_dttm', id: 'start_dttm', - layout: 'timestamp', + layout: 'dateRelative', }, { Cell: ({ @@ -196,7 +196,7 @@ function AnnotationList({ Header: t('End'), accessor: 'end_dttm', id: 'end_dttm', - layout: 'timestamp', + layout: 'dateRelative', }, { Cell: ({ From 410786f720565d1b81181b54ea281a4381a35b1c Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 22:25:48 +0000 Subject: [PATCH 10/12] fix(listview): stop focus in the frozen block from jumping the table back A permanent scroll padding made the browser scroll to the start whenever a frozen cell took focus. Apply the frozen width as scroll padding only while a focused scrolling cell is brought into view. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 32 ++++++++++-------- .../src/components/TableCollection/index.tsx | 33 +++++++------------ 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index ecdde5470af2..914ca5f97dd7 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -552,7 +552,7 @@ const frozenHeaders = (table: HTMLElement) => ); test('freezes the favorite and title columns when the table scrolls', () => { - const { table, container } = renderLayoutTable( + const { table } = renderLayoutTable( 800, { managedColumnWidths: true }, scrollingColumns, @@ -563,11 +563,6 @@ test('freezes the favorite and title columns when the table scrolls', () => { 'tbody tr.ant-table-row td.ant-table-cell-fix-start', ), ).toHaveLength(2); - expect( - (container.firstChild as HTMLElement).style.getPropertyValue( - '--list-view-frozen-width', - ), - ).toBe('368px'); }); test('freezes the selection column with the leading block', () => { @@ -587,17 +582,12 @@ test('freezes the selection column with the leading block', () => { }); test('does not freeze columns when the table fits', () => { - const { table, container } = renderLayoutTable( + const { table } = renderLayoutTable( 1200, { managedColumnWidths: true }, scrollingColumns, ); expect(frozenHeaders(table)).toEqual([]); - expect( - (container.firstChild as HTMLElement).style.getPropertyValue( - '--list-view-frozen-width', - ), - ).toBe(''); }); test('does not freeze a leading block wider than half the container', () => { @@ -630,18 +620,34 @@ test('does not freeze columns in unmanaged tables', () => { }); test('scrolls a focused cell out from under the frozen columns', () => { + // The frozen width is the scroll padding only while the focused cell is + // scrolled into view; a permanent padding would make the browser scroll + // back to the start whenever a frozen cell takes focus. + const paddings: string[] = []; const scrollIntoView = mockScrollIntoView(); + scrollIntoView.mockImplementation(function (this: Element) { + const scroller = this.closest( + '.ant-table-content, .ant-table-body', + )!; + paddings.push(scroller.style.scrollPaddingInlineStart); + }); const columns: ListViewColumn[] = scrollingColumns.map(column => column.id === 'c' ? { ...column, Cell: () => Column C details } : column, ); renderLayoutTable(800, { managedColumnWidths: true }, columns); - screen.getByRole('link', { name: 'Column C details' }).focus(); + const link = screen.getByRole('link', { name: 'Column C details' }); + link.focus(); expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest', }); + expect(paddings).toEqual(['368px']); + expect( + link.closest('.ant-table-content, .ant-table-body')!.style + .scrollPaddingInlineStart, + ).toBe(''); }); test('does not scroll when focus lands in the frozen columns', () => { diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index 52912855dd51..ba754dab7df7 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -17,7 +17,6 @@ * under the License. */ import { - CSSProperties, FocusEvent, HTMLAttributes, memo, @@ -163,12 +162,6 @@ const StyledTable = styled(Table)<{ text-overflow: ellipsis; white-space: nowrap; } - - // Keyboard focus never lands under the frozen leading columns. - .ant-table-content, - .ant-table-body { - scroll-padding-inline-start: var(--list-view-frozen-width, 0); - } ` : '' } @@ -386,19 +379,25 @@ function TableCollection({ [expandable, managedColumnWidths, frozenWidth], ); - // scroll-padding alone doesn't move a partly visible cell; scrolling the - // focused element into view does, and respects the padding. + // Keyboard focus never lands under the frozen leading columns: a focused + // cell is scrolled into view with the frozen width as scroll padding. The + // padding is only set for that scroll, since a permanent one makes the + // browser scroll back to the start whenever a frozen cell takes focus. const handleFocus = useCallback( (event: FocusEvent) => { - // Frozen cells are always visible; scrolling to them would jump the - // table back to the start. const cell = event.target.closest('td'); + const scroller = cell?.closest( + '.ant-table-content, .ant-table-body', + ); if ( frozenWidth > 0 && cell && + scroller && !cell.classList.contains('ant-table-cell-fix-start') ) { + scroller.style.scrollPaddingInlineStart = `${frozenWidth}px`; event.target.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + scroller.style.scrollPaddingInlineStart = ''; } }, [frozenWidth], @@ -544,17 +543,7 @@ function TableCollection({ if (!managedColumnWidths) return table; return ( -
0 - ? ({ - '--list-view-frozen-width': `${frozenWidth}px`, - } as CSSProperties) - : undefined - } - > +
{table}
); From 3d519611bfe5f2cc25cda0f8511ff0da8c7910f0 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 22:48:37 +0000 Subject: [PATCH 11/12] fix(listview): size the task details column for its widest indicator row The task details cell can show up to four 16px indicator icons 8px apart, the last with a reuse count. Measured in Chrome that row takes 118px (+999), so the 64px statusIcons preset clipped anything past two icons. Widen the preset to 144px and add a regression test on the measured need. Also document that only the 'title' preset marks the frozen primary column, and guard that no list view page sizes a column with a custom layout object. Co-Authored-By: Claude Opus 5.5 --- .../components/TableCollection/columnWidths.test.ts | 8 +++++++- .../src/components/TableCollection/columnWidths.ts | 11 +++++++++-- .../ListView/ListView.columnLayouts.test.ts | 3 +++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts index fbd2f385a57b..98ff5764e36f 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts @@ -160,7 +160,7 @@ test('the preset table holds the measured widths', () => { expect(COLUMN_LAYOUT_PRESETS).toEqual({ icon: { fixed: 48 }, statusIcon: { fixed: 48 }, - statusIcons: { fixed: 64 }, + statusIcons: { fixed: 144 }, status: { fixed: 144 }, flag: { fixed: 176 }, count: { fixed: 104 }, @@ -180,6 +180,12 @@ test('the preset table holds the measured widths', () => { }); }); +test('the task details column fits its widest indicator row', () => { + // Measured in Chrome: warning or bug, info, partition and "+999" reuse + // icons (16px each, 8px apart) take 118px, plus 24px of cell padding. + expect(getColumnSpec('statusIcons').min).toBeGreaterThanOrEqual(118 + 24); +}); + test('reports no scroll whenever the minimums fit', () => { const specs = [flex(320, 640, 3), fixed(120)]; [440, 800, 2560].forEach(width => diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts index de4f35c94b41..d818a679d057 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -72,8 +72,11 @@ export const COLUMN_LAYOUT_PRESETS: Record< icon: { fixed: 48 }, /** One state icon (query, alert or task state). */ statusIcon: { fixed: 48 }, - /** Two or three small icons side by side (e.g. task details). */ - statusIcons: { fixed: 64 }, + /** + * A row of indicator icons (task details): up to four 16px icons 8px apart, + * the last with a reuse count ("+999"), measured at 118 + padding. + */ + statusIcons: { fixed: 144 }, /** * A badge: Published/Draft, Physical/Virtual/Semantic View, task scope. * German badges ("Veröffentlicht", "Semantische Ansicht") need 144. @@ -276,6 +279,10 @@ const LEADING_ICON_PRESETS: ColumnLayout[] = ['icon', 'statusIcon']; * title is preceded by anything else, since antd can only freeze a * contiguous run of columns from the start and freezing those would hide * more than the row's name. + * + * The primary name column is identified by the `title` preset only: a column + * with a custom `{ min, max }` or `{ fixed }` layout is never frozen, so a + * row's name column must use `layout: 'title'` to stay in view. */ export function getLeadingBlockLength(layouts: ColumnLayout[]): number { const titleIndex = layouts.indexOf('title'); diff --git a/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts index 743506c0307e..6764073fb34c 100644 --- a/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts +++ b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts @@ -50,6 +50,9 @@ test.each(listViewPages.map(({ path, source }) => [path, source]))( (_path, source) => { // Legacy `size` tokens are fixed widths that bypass the shared presets. expect(source).not.toMatch(/^\s*size: '(xs|sm|md|lg|xl|xxl|xxxl)',$/m); + // Custom min/max objects bypass the presets, and a primary name column + // sized with one would not be frozen as the leading block. + expect(source).not.toMatch(/^\s*layout: \{/m); const presets = Array.from( source.matchAll(/^\s*layout: '(\w+)'(?: as const)?,$/gm), From efc277f801631ffdcd33a02e152cb9a723ae430b Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Fri, 30 Oct 2026 23:01:16 +0000 Subject: [PATCH 12/12] fix(listview): keep focused sortable headers out from under frozen columns antd makes sortable header cells focusable, and list views have no sticky header, so headers scroll with the body. Shift+Tab through the headers with the leading block frozen scrolled each one under the frozen header cells. The managed focus handler only handled body cells; it also handles header cells, so a focused header gets the same frozen-width scroll padding while frozen headers are left alone. Co-Authored-By: Claude Opus 5.5 --- .../TableCollection/TableCollection.test.tsx | 57 +++++++++++++++++++ .../src/components/TableCollection/index.tsx | 9 +-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx index 914ca5f97dd7..7bc52e37b226 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/TableCollection.test.tsx @@ -664,6 +664,63 @@ test('does not scroll when focus lands in the frozen columns', () => { expect(scrollIntoView).not.toHaveBeenCalled(); }); +const headerCell = (table: HTMLElement, text: string) => + Array.from(table.querySelectorAll('thead th')).find( + th => th.textContent === text, + )!; + +test('scrolls a focused sortable header out from under the frozen columns', () => { + // Without a sticky header the headers scroll with the body, so a focused + // header can be hidden under the frozen header cells as well. + const paddings: string[] = []; + const scrollIntoView = mockScrollIntoView(); + scrollIntoView.mockImplementation(function (this: Element) { + const scroller = this.closest( + '.ant-table-content, .ant-table-body', + )!; + paddings.push(scroller.style.scrollPaddingInlineStart); + }); + const { table } = renderLayoutTable( + 800, + { managedColumnWidths: true }, + scrollingColumns, + ); + const header = headerCell(table, 'C'); + expect(header.tabIndex).toBe(0); + header.focus(); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: 'nearest', + inline: 'nearest', + }); + expect(paddings).toEqual(['368px']); + expect( + header.closest('.ant-table-content, .ant-table-body')!.style + .scrollPaddingInlineStart, + ).toBe(''); +}); + +test('does not scroll when focus lands in a frozen header', () => { + const scrollIntoView = mockScrollIntoView(); + const { table } = renderLayoutTable( + 900, + { + managedColumnWidths: true, + bulkSelectEnabled: true, + selectedFlatRows: [], + }, + scrollingColumns, + ); + const header = headerCell(table, 'Name'); + expect(header).toHaveClass('ant-table-cell-fix-start'); + header.focus(); + const selectAll = table.querySelector( + 'thead th.ant-table-selection-column input', + )!; + expect(selectAll.closest('th')).toHaveClass('ant-table-cell-fix-start'); + selectAll.focus(); + expect(scrollIntoView).not.toHaveBeenCalled(); +}); + test('leaves focus scrolling to the browser when nothing is frozen', () => { const scrollIntoView = mockScrollIntoView(); const columns: ListViewColumn[] = scrollingColumns.map(column => diff --git a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx index ba754dab7df7..18ba56f282c5 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/index.tsx @@ -380,12 +380,13 @@ function TableCollection({ ); // Keyboard focus never lands under the frozen leading columns: a focused - // cell is scrolled into view with the frozen width as scroll padding. The - // padding is only set for that scroll, since a permanent one makes the - // browser scroll back to the start whenever a frozen cell takes focus. + // body cell, or a sortable header cell (antd makes those focusable), is + // scrolled into view with the frozen width as scroll padding. The padding + // is only set for that scroll, since a permanent one makes the browser + // scroll back to the start whenever a frozen cell takes focus. const handleFocus = useCallback( (event: FocusEvent) => { - const cell = event.target.closest('td'); + const cell = event.target.closest('td, th'); const scroller = cell?.closest( '.ant-table-content, .ant-table-body', );