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..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 @@ -16,10 +16,11 @@ * 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 '.'; +import { mapColumns, type ListViewColumn } from './utils'; let defaultProps: any; @@ -421,3 +422,340 @@ 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 }, + // 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 = {}, + columns: ListViewColumn[] = layoutColumns, +) { + 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, 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 }; +} + +// 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', () => { + 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(640); + 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', '320px', '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']); +}); + +// 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 } = 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); +}); + +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 } = renderLayoutTable( + 1200, + { managedColumnWidths: true }, + scrollingColumns, + ); + expect(frozenHeaders(table)).toEqual([]); +}); + +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', () => { + // 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); + 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', () => { + 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(); +}); + +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 => + 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'); +}); + +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/columnWidths.test.ts b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts new file mode 100644 index 000000000000..98ff5764e36f --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.test.ts @@ -0,0 +1,295 @@ +/** + * 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, + FREEZE_MAX_CONTAINER_SHARE, + FREEZE_MIN_SCROLL_PX, + getColumnSpec, + getFrozenColumnCount, + getLeadingBlockLength, + 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, 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', () => { + const specs = [flex(240, 480, 3), fixed(120)]; + expect(resolveColumnWidths(specs, 0)).toEqual({ + widths: [240, 120], + totalWidth: 360, + scrolls: true, + }); +}); + +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, + scrolls: false, + }); +}); + +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); + }); +}); + +test('the preset table holds the measured widths', () => { + expect(COLUMN_LAYOUT_PRESETS).toEqual({ + icon: { fixed: 48 }, + statusIcon: { fixed: 48 }, + statusIcons: { fixed: 144 }, + 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('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 => + 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); +}); + +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 new file mode 100644 index 000000000000..d818a679d057 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/TableCollection/columnWidths.ts @@ -0,0 +1,322 @@ +/** + * 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' + | 'statusIcon' + | 'statusIcons' + | 'status' + | 'flag' + | 'count' + | 'timer' + | 'people' + | 'iconList' + | 'dateRelative' + | 'dateTwoLine' + | 'timestamp' + | 'actions' + | 'actionsWide' + | 'title' + | 'text' + | 'identifier' + | '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, including + * the 24px of cell padding. + * + * 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 }, + /** One state icon (query, alert or task state). */ + statusIcon: { fixed: 48 }, + /** + * 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. + */ + 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, 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 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 }, + /** + * 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 + * 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 }, + /** 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; + +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 `scrolls` is true: the table scrolls + * horizontally. + */ +export function resolveColumnWidths( + specs: ColumnSpec[], + containerWidth: 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 || 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]); + 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); + // 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, + ); + const target = + byWeight.find(index => floored[index] + remainder <= specs[index].max) ?? + byWeight[0]; + floored[target] += remainder; + 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. + * + * 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'); + 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 bc6d511fef5e..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 @@ -16,7 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { HTMLAttributes, memo, useMemo, useCallback } from 'react'; +import { + FocusEvent, + HTMLAttributes, + memo, + useMemo, + useCallback, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { HeaderGroup, Row, @@ -32,9 +41,27 @@ 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, + getFrozenColumnCount, + getLeadingBlockLength, + resolveColumnWidths, +} from './columnWidths'; 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'; export interface TableCollectionProps { getTableProps: TablePropGetter; @@ -64,13 +91,45 @@ 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, 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; +} + +/** + * 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)<{ isPaginationSticky?: boolean; showRowCount?: boolean; + managedColumnWidths?: boolean; }>` - ${({ theme, isPaginationSticky, showRowCount }) => ` + ${({ theme, isPaginationSticky, showRowCount, managedColumnWidths }) => ` .actions { opacity: 0; font-size: ${theme.fontSizeXL}px; @@ -92,6 +151,21 @@ const StyledTable = styled(Table)<{ line-height: initial; } + ${ + 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; + } + ` + : '' + } + .ant-table-row:hover { .actions { opacity: 1; @@ -184,10 +258,69 @@ 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 layouts = visibleColumns.map(column => getColumnLayout(column)); + const specs = [ + ...Array(utilityColumns).fill( + getColumnSpec({ fixed: UTILITY_COLUMN_WIDTH }), + ), + ...layouts.map(getColumnSpec), + ]; + 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, + widths: Object.fromEntries( + visibleColumns.map((column, index) => [ + getColumnId(column), + 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, + columns, + bulkSelectEnabled, + expandable, + containerWidth, + ]); + + const frozenWidth = columnLayout?.frozenWidth ?? 0; + const mappedColumns = useMemo( - () => mapColumns(columns, headerGroups, columnsForWrapText), - [columns, headerGroups, columnsForWrapText], + () => + mapColumns( + columns, + headerGroups, + columnsForWrapText, + columnLayout?.widths, + columnLayout?.frozenColumnIds, + ), + [columns, headerGroups, columnsForWrapText, columnLayout], ); const mappedRows = useMemo( @@ -213,6 +346,8 @@ function TableCollection({ // so wrapping per-row checkboxes there is safe. return { selectedRowKeys, + ...(managedColumnWidths && { columnWidth: UTILITY_COLUMN_WIDTH }), + ...(frozenWidth > 0 && { fixed: 'left' as const }), onSelect: (record, selected) => { toggleRowSelected?.(record.rowId, selected); }, @@ -228,8 +363,47 @@ function TableCollection({ selectedRowKeys, toggleRowSelected, toggleAllRowsSelected, + managedColumnWidths, + frozenWidth, ]); + const tableExpandable = useMemo( + () => + expandable && managedColumnWidths + ? { + columnWidth: UTILITY_COLUMN_WIDTH, + ...(frozenWidth > 0 && { fixed: 'left' as const }), + ...expandable, + } + : expandable, + [expandable, managedColumnWidths, frozenWidth], + ); + + // Keyboard focus never lands under the frozen leading columns: a focused + // 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, th'); + 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], + ); + const handlePaginationChange = useCallback( (page: number, size: number) => { const validPage = Math.max(0, (page || 1) - 1); @@ -338,7 +512,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 }} sortDirections={['ascend', 'descend', 'ascend']} isPaginationSticky={isPaginationSticky} showRowCount={showRowCount} + managedColumnWidths={managedColumnWidths} 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..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 @@ -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,21 @@ export function mapColumns( columns: ListViewColumn[], headerGroups: HeaderGroup[], columnsForWrapText?: string[], + resolvedWidths?: Record, + frozenColumnIds?: string[], ) { 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, + fixed: frozenColumnIds?.includes(id) ? ('left' as const) : undefined, 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/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/spec/helpers/listViewColumnWidths.ts b/superset-frontend/spec/helpers/listViewColumnWidths.ts new file mode 100644 index 000000000000..f11416a6c27c --- /dev/null +++ b/superset-frontend/spec/helpers/listViewColumnWidths.ts @@ -0,0 +1,72 @@ +/** + * 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, + ); +} + +/** 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/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/ListView.columnLayouts.test.ts b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts new file mode 100644 index 000000000000..6764073fb34c --- /dev/null +++ b/superset-frontend/src/components/ListView/ListView.columnLayouts.test.ts @@ -0,0 +1,82 @@ +/** + * 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); + // 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), + match => match[1], + ); + expect(presets.length).toBeGreaterThan(0); + 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/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/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 0484b69ed94b..9ef9cdce39c9 100644 --- a/superset-frontend/src/components/index.ts +++ b/superset-frontend/src/components/index.ts @@ -25,7 +25,9 @@ export { ListViewUIFilters, DashboardCrossLinks, ListViewFilterOperator, + ListViewTitleCell, 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..5fa420247b1e 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: 'timestamp', Cell: ({ row: { original: { dttm }, diff --git a/superset-frontend/src/pages/AlertReportList/index.tsx b/superset-frontend/src/pages/AlertReportList/index.tsx index 8d43c343708e..fa147faeaf8f 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: 'statusIcon', disableSortBy: true, id: 'last_state', }, @@ -343,19 +344,19 @@ function AlertList({ : '', accessor: 'last_eval_dttm', Header: t('Last run'), - size: 'lg', + layout: 'timestamp', 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: 'iconList', 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: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -430,7 +431,7 @@ function AlertList({ Header: t('Active'), accessor: 'active', id: 'active', - size: 'sm', + layout: 'flag', }, { 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..d8c8dcdc69dd 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: 'dateRelative', 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/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 948b1284a818..c208d84e9c23 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: 'dateRelative', }, { Cell: ({ @@ -193,6 +196,7 @@ function AnnotationList({ Header: t('End'), accessor: 'end_dttm', id: 'end_dttm', + layout: 'dateRelative', }, { 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..eed4769b2ded 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: 'dateRelative', }, { // 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 new file mode 100644 index 000000000000..ee46ad56188b --- /dev/null +++ b/superset-frontend/src/pages/ChartList/ChartList.columnSizing.test.tsx @@ -0,0 +1,148 @@ +/** + * 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, within } from 'spec/helpers/testing-library'; +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 = 1920; + +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(); + jest.restoreAllMocks(); +}); + +test('every visible column resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); + renderChartList(mockUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + // 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: 'identifier', + 'On dashboards': 'text', + Tags: 'tags', + Editors: 'people', + Viewers: 'people', + '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); + + 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); + } +}); + +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 b81a7f64804b..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, @@ -72,12 +72,14 @@ import { TagsList, TagType, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, DashboardCrossLinks, type ListViewProps, 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'; @@ -99,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). @@ -247,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}; `; @@ -501,7 +512,7 @@ function ChartList(props: ChartListProps) { [addDangerToast], ); - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -519,7 +530,7 @@ function ChartList(props: ChartListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'sm', + layout: 'icon', hidden: !userId, }, { @@ -534,7 +545,9 @@ function ChartList(props: ChartListProps) { }, }, }: any) => ( - + } + > - {description && } - + ), Header: t('Name'), accessor: 'slice_name', - size: 'xxxl', + layout: 'title', id: 'slice_name', }, { @@ -563,22 +575,21 @@ 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', id: 'viz_type', }, { @@ -607,7 +618,7 @@ function ChartList(props: ChartListProps) { Header: datasetLabel(), accessor: 'datasource_id', disableSortBy: true, - size: 'xl', + layout: 'identifier', id: 'datasource_id', }, { @@ -619,7 +630,7 @@ function ChartList(props: ChartListProps) { Header: t('On dashboards'), accessor: 'dashboards', disableSortBy: true, - size: 'xl', + layout: 'text', id: 'dashboards', }, { @@ -643,6 +654,7 @@ function ChartList(props: ChartListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), + layout: 'tags', id: 'tags', }, { @@ -654,7 +666,7 @@ function ChartList(props: ChartListProps) { Header: t('Editors'), accessor: 'editors', disableSortBy: true, - size: 'xl', + layout: 'people', id: 'editors', }, ...(isFeatureEnabled(FeatureFlag.EnableViewers) @@ -668,6 +680,7 @@ function ChartList(props: ChartListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, + layout: 'people' as const, id: 'viewers', }, ] @@ -683,7 +696,7 @@ function ChartList(props: ChartListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -756,7 +769,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..8a66be8de265 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: 'dateRelative', 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 new file mode 100644 index 000000000000..0462fca54ad4 --- /dev/null +++ b/superset-frontend/src/pages/DashboardList/DashboardList.columnSizing.test.tsx @@ -0,0 +1,100 @@ +/** + * 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'; +import { + expectColumnWidthsWithinLayouts, + mockListViewContainerWidth, +} from 'spec/helpers/listViewColumnWidths'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; + +const CONTAINER_WIDTH = 1440; + +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(); + jest.restoreAllMocks(); +}); + +test('every visible column resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); + renderDashboardList(mockAdminUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + // 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': 'dateRelative', + Actions: 'actions', + }, + 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 b307af27c288..09da138e55a5 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -57,10 +57,12 @@ import { ModifiedInfo, ImportModal as ImportModelsModal, ListView, + type ListViewColumn, ListViewFilterOperator as FilterOperator, type ListViewProps, type ListViewFilter, type ListViewFilters, + ListViewTitleCell, } from 'src/components'; import handleResourceExport from 'src/utils/export'; import { @@ -137,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', @@ -385,7 +369,7 @@ function DashboardList(props: DashboardListProps) { ); } - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -403,7 +387,7 @@ function DashboardList(props: DashboardListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'sm', + layout: 'icon', hidden: !user?.userId, }, { @@ -418,7 +402,9 @@ function DashboardList(props: DashboardListProps) { }, }, }: any) => ( - + } + > {certifiedBy && ( <> @@ -430,12 +416,11 @@ function DashboardList(props: DashboardListProps) { )} {dashboardTitle} - {description && } - + ), Header: t('Name'), accessor: 'dashboard_title', - size: 'xxxl', + layout: 'title', id: 'dashboard_title', }, { @@ -448,7 +433,7 @@ function DashboardList(props: DashboardListProps) { ), Header: t('Status'), accessor: 'published', - size: 'sm', + layout: 'status', id: 'published', className: 'no-ellipsis', }, @@ -478,6 +463,7 @@ function DashboardList(props: DashboardListProps) { accessor: 'tags', disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), + layout: 'tags', id: 'tags', }, { @@ -489,6 +475,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Editors'), accessor: 'editors', disableSortBy: true, + layout: 'people', id: 'editors', }, ...(isFeatureEnabled(FeatureFlag.EnableViewers) @@ -502,6 +489,7 @@ function DashboardList(props: DashboardListProps) { Header: t('Viewers'), accessor: 'viewers', disableSortBy: true, + layout: 'people' as const, id: 'viewers', }, ] @@ -517,6 +505,7 @@ function DashboardList(props: DashboardListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -620,6 +609,7 @@ function DashboardList(props: DashboardListProps) { }, Header: t('Actions'), id: 'actions', + layout: 'actions', hidden: !canEdit && !canDelete && !canExport, disableSortBy: true, }, 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 921ed517161b..7fcfc92bf6dc 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: 'identifier', disableSortBy: true, id: 'backend', }, @@ -871,7 +872,7 @@ function DatabaseList({ ) : ( ), - size: 'sm', + layout: 'flag', id: 'allow_run_async', }, { @@ -891,7 +892,7 @@ function DatabaseList({ ) : ( ), - size: 'sm', + layout: 'flag', id: 'allow_dml', }, { @@ -903,7 +904,7 @@ function DatabaseList({ ) : ( ), - size: 'md', + layout: 'flag', id: 'allow_file_upload', }, { @@ -915,7 +916,7 @@ function DatabaseList({ ) : ( ), - size: 'md', + layout: 'flag', id: 'expose_in_sqllab', }, { @@ -931,7 +932,7 @@ function DatabaseList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -1042,6 +1043,7 @@ function DatabaseList({ }, Header: t('Actions'), id: 'actions', + layout: 'actionsWide', 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 new file mode 100644 index 000000000000..dd4dd8509a0c --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.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 { act, screen, waitFor } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { + setupMocks, + renderDatasetList, + mockAdminUser, +} from './DatasetList.testHelpers'; +import { + expectColumnWidthsWithinLayouts, + getFrozenHeaders, + mockListViewContainerWidth, +} from 'spec/helpers/listViewColumnWidths'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; + +const CONTAINER_WIDTH = 1440; + +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 resolves to a width within its layout', async () => { + mockListViewContainerWidth(CONTAINER_WIDTH); + renderDatasetList(mockAdminUser); + + const table = await screen.findByTestId('listview-table'); + await waitFor(() => + expect( + table.querySelectorAll('tbody tr[data-test="table-row"]').length, + ).toBeGreaterThan(0), + ); + + // 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: 'identifier', + Schema: 'identifier', + Editors: 'people', + '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); + + // 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 ffc15ca1b43d..12c32df5ca04 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -70,8 +70,10 @@ import { ListView, ListViewFilterOperator as FilterOperator, type ListViewProps, + 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'; @@ -141,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}; @@ -863,7 +855,7 @@ const DatasetList: FunctionComponent = ({ } }; - const columns = useMemo( + const columns = useMemo[]>( () => [ { accessor: 'id', @@ -918,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; @@ -945,7 +942,7 @@ const DatasetList: FunctionComponent = ({ }, Header: t('Name'), accessor: 'table_name', - size: 'xxxl', + layout: 'title', id: 'table_name', }, { @@ -962,7 +959,7 @@ const DatasetList: FunctionComponent = ({ Header: t('Type'), accessor: 'kind', disableSortBy: true, - size: 'sm', + layout: 'status', id: 'kind', }, { @@ -973,7 +970,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => database?.database_name || '-', Header: databaseLabel(), accessor: 'database.database_name', - size: 'xl', + layout: 'identifier', id: 'database.database_name', }, { @@ -984,7 +981,7 @@ const DatasetList: FunctionComponent = ({ }: CellProps) => schema || '-', Header: t('Schema'), accessor: 'schema', - size: 'lg', + layout: 'identifier', id: 'schema', }, { @@ -1002,7 +999,7 @@ const DatasetList: FunctionComponent = ({ Header: t('Editors'), id: 'editors', disableSortBy: true, - size: 'lg', + layout: 'people', }, { Cell: ({ @@ -1017,7 +1014,7 @@ const DatasetList: FunctionComponent = ({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -1156,6 +1153,7 @@ const DatasetList: FunctionComponent = ({ }, Header: t('Actions'), id: 'actions', + 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..2cdbf0a1d5de 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: 'statusIcon', 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: 'timestamp', }, { Cell: ({ @@ -141,6 +142,7 @@ function ExecutionLog({ Header: t('Start at (UTC)'), accessor: 'start_dttm', id: 'start_dttm', + layout: 'timestamp', }, { Cell: ({ @@ -160,11 +162,13 @@ function ExecutionLog({ Header: t('Duration'), disableSortBy: true, id: 'duration', + layout: 'timer', }, { 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/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 447fcbe92651..cb6efde4205a 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, @@ -83,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; @@ -147,7 +159,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }; const initialSort = [{ id: QueryObjectColumns.StartTime, desc: true }]; - const columns = useMemo( + const columns = useMemo( () => [ { Cell: ({ @@ -221,14 +233,14 @@ function QueryList({ addDangerToast }: QueryListProps) { ); }, accessor: QueryObjectColumns.Status, - size: 'xs', + layout: 'statusIcon', disableSortBy: true, id: QueryObjectColumns.Status, }, { accessor: QueryObjectColumns.StartTime, Header: t('Time'), - size: 'lg', + layout: 'dateTwoLine', Cell: ({ row: { original: { start_time }, @@ -251,7 +263,7 @@ function QueryList({ addDangerToast }: QueryListProps) { }, { Header: t('Duration'), - size: 'lg', + layout: 'timer', Cell: ({ row: { original: { status, start_time, start_running_time, end_time }, @@ -277,13 +289,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: 'identifier', id: QueryObjectColumns.DatabaseName, }, { @@ -294,7 +306,7 @@ function QueryList({ addDangerToast }: QueryListProps) { { accessor: QueryObjectColumns.Schema, Header: t('Schema'), - size: 'lg', + layout: 'identifier', id: QueryObjectColumns.Schema, }, { @@ -309,7 +321,7 @@ function QueryList({ addDangerToast }: QueryListProps) { if (names.length) { return ( - {main} + {main} ), - 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..b708a3e1e054 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: 'people', 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: 'dateRelative', 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/SavedQueryList.cells.test.tsx b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.cells.test.tsx new file mode 100644 index 000000000000..33f00f33b2c5 --- /dev/null +++ b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.cells.test.tsx @@ -0,0 +1,118 @@ +/** + * 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 { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +import { render, screen, within } from 'spec/helpers/testing-library'; +import { expectTruncatingTitle } from 'spec/helpers/listViewTitleCell'; +import SavedQueryList from '.'; + +jest.setTimeout(30000); + +jest.mock('@superset-ui/core', () => ({ + ...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 2a603d2fe48e..e03ae6b09f3b 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, @@ -100,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; @@ -340,12 +352,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 +368,13 @@ function SavedQueryList({ { accessor: 'description', Header: t('Description'), - size: 'xl', + layout: 'longtext', id: 'description', }, { accessor: 'database.database_name', Header: t('Database'), - size: 'lg', + layout: 'identifier', id: 'database.database_name', }, { @@ -374,7 +386,7 @@ function SavedQueryList({ { accessor: 'schema', Header: t('Schema'), - size: 'lg', + layout: 'identifier', id: 'schema', }, { @@ -389,7 +401,7 @@ function SavedQueryList({ if (names.length) { return ( - {main} + {main} tag.type === TagTypeEnum.Custom, )} + maxTags={3} /> ), Header: t('Tags'), @@ -434,6 +447,7 @@ function SavedQueryList({ disableSortBy: true, hidden: !isFeatureEnabled(FeatureFlag.TaggingSystem), id: 'tags', + layout: 'tags', }, { Cell: ({ @@ -448,14 +462,14 @@ function SavedQueryList({ ), Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', 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 +536,7 @@ function SavedQueryList({ }, Header: t('Actions'), id: 'actions', + layout: 'actionsWide', disableSortBy: true, }, { 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 1b590c62959f..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, @@ -33,6 +34,7 @@ import { import { Tag as AntdTag, ListView, + type ListViewColumn, ModifiedInfo, ListViewFilterOperator as FilterOperator, type ListViewFilter, @@ -50,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; @@ -142,7 +153,7 @@ function TagList(props: TagListProps) { buttonText: t('Create a new Tag'), }; - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ @@ -160,7 +171,7 @@ function TagList(props: TagListProps) { Header: '', id: 'id', disableSortBy: true, - size: 'xs', + layout: 'icon', hidden: !userId, }, { @@ -169,13 +180,13 @@ function TagList(props: TagListProps) { original: { id, name: tagName }, }, }: any) => ( - + {tagName} - + ), Header: t('Name'), accessor: 'name', - size: 'xxl', + layout: 'title', id: 'name', }, { @@ -189,7 +200,7 @@ function TagList(props: TagListProps) { }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', id: 'changed_on_delta_humanized', }, { @@ -240,6 +251,7 @@ function TagList(props: TagListProps) { }, Header: t('Actions'), id: 'actions', + layout: 'actions', hidden: !canDelete, disableSortBy: true, }, 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 91203b0591d6..917b89ef3988 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: ({ @@ -282,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 = []; @@ -301,13 +298,13 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { } placement="top" > - {truncated} + {displayText} ); }, accessor: 'task_name', Header: t('Task'), - size: 'xl', + layout: 'title', id: 'task_name', }, { @@ -328,13 +325,13 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), accessor: 'status', Header: t('Status'), - size: 'xs', + layout: 'statusIcon', id: 'status', }, { accessor: 'task_type', Header: t('Type'), - size: 'md', + layout: 'text', id: 'task_type', }, { @@ -361,7 +358,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'scope', Header: t('Scope'), - size: 'sm', + layout: 'status', id: 'scope', }, { @@ -398,7 +395,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'subscriber_count', Header: t('Subscribers'), - size: 'md', + layout: 'people', id: 'subscribers', disableSortBy: true, }, @@ -415,7 +412,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), Header: t('Created'), accessor: 'created_on', - size: 'xl', + layout: 'dateRelative', id: 'created_on', }, { @@ -442,7 +439,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { ), accessor: 'duration_seconds', Header: t('Duration'), - size: 'sm', + layout: 'count', id: 'duration_seconds', disableSortBy: true, }, @@ -567,7 +564,7 @@ function TaskList({ addDangerToast, addSuccessToast, user }: TaskListProps) { }, accessor: 'payload', Header: t('Details'), - size: 'sm', + layout: 'statusIcons', id: 'payload', disableSortBy: true, }, @@ -651,7 +648,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/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 2fb8245a8e3e..ff14a799e09e 100644 --- a/superset-frontend/src/pages/ThemeList/index.tsx +++ b/superset-frontend/src/pages/ThemeList/index.tsx @@ -43,12 +43,14 @@ import getBootstrapData from 'src/utils/getBootstrapData'; import { ModifiedInfo, ListView, + type ListViewColumn, ListViewActionsBar, ListViewFilterOperator as FilterOperator, ImportModal as ImportModelsModal, type ListViewProps, type ListViewActionProps, type ListViewFilters, + ListViewTitleCell, } from 'src/components'; import ThemeModal from 'src/features/themes/ThemeModal'; @@ -72,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; @@ -443,7 +435,7 @@ function ThemesList({ ]); const initialSort = [{ id: 'theme_name', desc: true }]; - const columns = useMemo( + const columns = useMemo[]>( () => [ { Cell: ({ row: { original } }: any) => { @@ -453,40 +445,46 @@ 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'), accessor: 'theme_name', id: 'theme_name', + layout: 'title', }, { Cell: ({ @@ -499,7 +497,7 @@ function ThemesList({ }: any) => , Header: t('Last modified'), accessor: 'changed_on_delta_humanized', - size: 'xl', + layout: 'dateRelative', disableSortBy: true, id: 'changed_on_delta_humanized', }, @@ -513,6 +511,7 @@ function ThemesList({ accessor: 'editors', disableSortBy: true, id: 'editors', + layout: 'people', }, { Cell: ({ row: { original } }: any) => { @@ -634,7 +633,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..5eff1bd674aa 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: 'timestamp', 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..ad7ac51f3977 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: 'flag', 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: 'timestamp', Cell: ({ row: { original: { created_on }, @@ -312,6 +316,7 @@ function UsersList({ user }: UsersListProps) { id: 'changed_on', Header: t('Changed on'), hidden: true, + layout: 'timestamp', Cell: ({ row: { original: { changed_on }, @@ -323,6 +328,7 @@ function UsersList({ user }: UsersListProps) { id: 'last_login', Header: t('Last login'), hidden: true, + layout: 'timestamp', Cell: ({ row: { original: { last_login }, @@ -361,7 +367,7 @@ function UsersList({ user }: UsersListProps) { id: 'actions', disableSortBy: true, hidden: !isAdmin, - size: 'xl', + layout: 'actions', }, ], [isAdmin],