Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string, unknown> = {},
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(
<TableCollection
{...defaultProps}
columns={columns}
headerGroups={result.current.headerGroups}
rows={result.current.rows}
prepareRow={result.current.prepareRow}
{...props}
/>,
);
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 <col>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<HTMLElement>(
'.ant-table-content, .ant-table-body',
)!;
paddings.push(scroller.style.scrollPaddingInlineStart);
});
const columns: ListViewColumn[] = scrollingColumns.map(column =>
column.id === 'c'
? { ...column, Cell: () => <a href="#c">Column C details</a> }
: 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<HTMLElement>('.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: () => <a href="#name">Row name</a> }
: 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<HTMLElement>('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<HTMLElement>(
'.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<HTMLElement>('.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<HTMLElement>(
'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: () => <a href="#c">Column C details</a> }
: 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');
});
Loading
Loading