perf(datasource): add pagination to datasource editor tables to prevent browser freeze (#37555)

Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
This commit is contained in:
madhushreeag
2026-02-23 15:19:33 -08:00
committed by GitHub
parent e06427d1ef
commit 8f070169a5
7 changed files with 723 additions and 177 deletions

View File

@@ -46,7 +46,9 @@ const DatasourceEditor = AsyncEsmComponent(
const StyledDatasourceModal = styled(Modal)`
&& .ant-modal-content {
height: 900px;
max-height: none;
margin-top: 0;
margin-bottom: 0;
}
&& .ant-modal-body {
@@ -363,6 +365,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
</>
}
responsive
resizable
resizableConfig={{ defaultSize: { width: 'auto', height: '900px' } }}
draggable
>
<DatasourceEditor
showLoadingForImport

View File

@@ -84,12 +84,21 @@ export default class CRUDCollection extends PureComponent<
const { collection, collectionArray } = createKeyedCollection(
props.collection,
);
// Get initial page size from pagination prop
const initialPageSize =
typeof props.pagination === 'object' && props.pagination?.pageSize
? props.pagination.pageSize
: 10;
this.state = {
expandedColumns: {},
collection,
collectionArray,
sortColumn: '',
sort: 0,
currentPage: 1,
pageSize: initialPageSize,
};
this.onAddItem = this.onAddItem.bind(this);
this.renderExpandableSection = this.renderExpandableSection.bind(this);
@@ -238,10 +247,19 @@ export default class CRUDCollection extends PureComponent<
}
handleTableChange(
_pagination: TablePaginationConfig,
pagination: TablePaginationConfig,
_filters: Record<string, FilterValue | null>,
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
) {
// Handle pagination changes
if (pagination.current !== undefined && pagination.pageSize !== undefined) {
this.setState({
currentPage: pagination.current,
pageSize: pagination.pageSize,
});
}
// Handle sorting changes
const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
let newSortColumn = '';
let newSortOrder = 0;
@@ -397,8 +415,22 @@ export default class CRUDCollection extends PureComponent<
stickyHeader,
emptyMessage = t('No items'),
expandFieldset,
pagination = false,
filterTerm,
filterFields,
} = this.props;
const displayData =
filterTerm && filterFields?.length
? this.state.collectionArray.filter(item =>
filterFields.some(field =>
String(item[field] ?? '')
.toLowerCase()
.includes(filterTerm.toLowerCase()),
),
)
: this.state.collectionArray;
const tableColumns = this.buildTableColumns();
const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
id => this.state.expandedColumns[id],
@@ -416,6 +448,22 @@ export default class CRUDCollection extends PureComponent<
}
: undefined;
// Build controlled pagination config, clamping currentPage to valid range
// based on displayData (filtered) length, not the full collection
const { pageSize, currentPage: statePage } = this.state;
const totalItems = displayData.length;
const maxPage = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 1;
const currentPage = Math.min(statePage, maxPage);
const paginationConfig: false | TablePaginationConfig | undefined =
pagination === false || pagination === undefined
? pagination
: {
...(typeof pagination === 'object' ? pagination : {}),
current: currentPage,
pageSize,
total: totalItems,
};
return (
<>
<CrudButtonWrapper>
@@ -439,16 +487,15 @@ export default class CRUDCollection extends PureComponent<
<Table<CollectionItem>
data-test="crud-table"
columns={tableColumns}
data={this.state.collectionArray as CollectionItem[]}
data={displayData as CollectionItem[]}
rowKey={(record: CollectionItem) => String(record.id)}
sticky={stickyHeader}
pagination={false}
pagination={paginationConfig}
onChange={this.handleTableChange}
locale={{ emptyText: emptyMessage }}
css={
stickyHeader &&
css`
height: 350px;
overflow: auto;
`
}

View File

@@ -62,6 +62,7 @@ import {
FormLabel,
Icons,
InfoTooltip,
Input,
Loading,
Row,
Select,
@@ -274,6 +275,9 @@ interface DatasourceEditorState {
datasourceType: string;
usageCharts: ChartUsageData[];
usageChartsCount: number;
metricSearchTerm: string;
columnSearchTerm: string;
calculatedColumnSearchTerm: string;
}
interface AbortControllers {
@@ -302,6 +306,8 @@ interface ColumnCollectionTableProps {
className?: string;
itemGenerator?: () => Partial<Column>;
columnLabelTooltips?: Record<string, string>;
filterTerm?: string;
filterFields?: string[];
}
interface StackedFieldProps {
@@ -520,6 +526,8 @@ function ColumnCollectionTable({
groupby: true,
}),
columnLabelTooltips,
filterTerm,
filterFields,
}: ColumnCollectionTableProps): JSX.Element {
return (
<CollectionTable
@@ -552,6 +560,8 @@ function ColumnCollectionTable({
itemGenerator={itemGenerator}
collection={columns}
columnLabelTooltips={columnLabelTooltips}
filterTerm={filterTerm}
filterFields={filterFields}
stickyHeader
expandFieldset={
<FormContainer>
@@ -941,6 +951,9 @@ class DatasourceEditor extends PureComponent<
: DATASOURCE_TYPES.physical.key,
usageCharts: [],
usageChartsCount: 0,
metricSearchTerm: '',
columnSearchTerm: '',
calculatedColumnSearchTerm: '',
};
this.isComponentMounted = false;
@@ -2111,171 +2124,187 @@ class DatasourceEditor extends PureComponent<
}
renderMetricCollection() {
const { datasource } = this.state;
const { datasource, metricSearchTerm } = this.state;
const { metrics } = datasource;
const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : [];
return (
<CollectionTable
tableColumns={['metric_name', 'verbose_name', 'expression']}
sortColumns={['metric_name', 'verbose_name', 'expression']}
columnLabels={{
metric_name: t('Metric Key'),
verbose_name: t('Label'),
expression: t('SQL expression'),
}}
columnLabelTooltips={{
metric_name: t(
'This field is used as a unique identifier to attach ' +
'the metric to charts. It is also used as the alias in the ' +
'SQL query.',
),
}}
expandFieldset={
<FormContainer>
<Fieldset compact>
<Field
fieldKey="description"
label={t('Description')}
control={
<TextControl
controlId="description"
placeholder={t('Description')}
<div>
<Input.Search
placeholder={t('Search metrics by key or label')}
value={metricSearchTerm}
onChange={e => this.setState({ metricSearchTerm: e.target.value })}
style={{ marginBottom: 16, width: 300 }}
allowClear
/>
<CollectionTable
tableColumns={['metric_name', 'verbose_name', 'expression']}
sortColumns={['metric_name', 'verbose_name', 'expression']}
filterTerm={metricSearchTerm}
filterFields={['metric_name', 'verbose_name']}
columnLabels={{
metric_name: t('Metric Key'),
verbose_name: t('Label'),
expression: t('SQL expression'),
}}
columnLabelTooltips={{
metric_name: t(
'This field is used as a unique identifier to attach ' +
'the metric to charts. It is also used as the alias in the ' +
'SQL query.',
),
}}
pagination={{
pageSize: 25,
showSizeChanger: true,
pageSizeOptions: [10, 25, 50, 100],
}}
expandFieldset={
<FormContainer>
<Fieldset compact>
<Field
fieldKey="description"
label={t('Description')}
control={
<TextControl
controlId="description"
placeholder={t('Description')}
/>
}
/>
<Field
fieldKey="d3format"
label={t('D3 format')}
control={
<TextControl controlId="d3format" placeholder="%y/%m/%d" />
}
/>
<Field
fieldKey="currency"
label={t('Metric currency')}
control={
<CurrencyControl
onChange={() => {}}
currencySelectOverrideProps={{
placeholder: t('Select or type currency symbol'),
}}
symbolSelectAdditionalStyles={css`
max-width: 30%;
`}
/>
}
/>
<Field
label={t('Certified by')}
fieldKey="certified_by"
description={t(
'Person or group that has certified this metric',
)}
control={
<TextControl
controlId="certified_by"
placeholder={t('Certified by')}
/>
}
/>
<Field
label={t('Certification details')}
fieldKey="certification_details"
description={t('Details of the certification')}
control={
<TextControl
controlId="certification_details"
placeholder={t('Certification details')}
/>
}
/>
<Field
label={t('Warning')}
fieldKey="warning_markdown"
description={t('Optional warning about use of this metric')}
control={
<TextAreaControl
controlId="warning_markdown"
language="markdown"
offerEditInModal={false}
resize="vertical"
/>
}
/>
</Fieldset>
</FormContainer>
}
collection={sortedMetrics}
allowAddItem
onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
itemGenerator={() => ({
metric_name: t('<new metric>'),
verbose_name: '',
expression: '',
})}
itemCellProps={{
expression: () => ({
width: '240px',
}),
}}
itemRenderers={{
metric_name: (v, onChange, _, record) => (
<FlexRowContainer>
{record.is_certified && (
<CertifiedBadge
certifiedBy={record.certified_by}
details={record.certification_details}
/>
}
/>
<Field
fieldKey="d3format"
label={t('D3 format')}
control={
<TextControl controlId="d3format" placeholder="%y/%m/%d" />
}
/>
<Field
fieldKey="currency"
label={t('Metric currency')}
control={
<CurrencyControl
onChange={() => {}}
currencySelectOverrideProps={{
placeholder: t('Select or type currency symbol'),
}}
symbolSelectAdditionalStyles={css`
max-width: 30%;
`}
/>
}
/>
<Field
label={t('Certified by')}
fieldKey="certified_by"
description={t(
'Person or group that has certified this metric',
)}
control={
<TextControl
controlId="certified_by"
placeholder={t('Certified by')}
{record.warning_markdown && (
<WarningIconWithTooltip
warningMarkdown={record.warning_markdown}
/>
}
/>
<Field
label={t('Certification details')}
fieldKey="certification_details"
description={t('Details of the certification')}
control={
<TextControl
controlId="certification_details"
placeholder={t('Certification details')}
/>
}
/>
<Field
label={t('Warning')}
fieldKey="warning_markdown"
description={t('Optional warning about use of this metric')}
control={
<TextAreaControl
controlId="warning_markdown"
language="markdown"
offerEditInModal={false}
resize="vertical"
/>
}
/>
</Fieldset>
</FormContainer>
}
collection={sortedMetrics}
allowAddItem
onChange={this.onDatasourcePropChange.bind(this, 'metrics')}
itemGenerator={() => ({
metric_name: t('<new metric>'),
verbose_name: '',
expression: '',
})}
itemCellProps={{
expression: () => ({
width: '240px',
}),
}}
itemRenderers={{
metric_name: (v, onChange, _, record) => (
<FlexRowContainer>
{record.is_certified && (
<CertifiedBadge
certifiedBy={record.certified_by}
details={record.certification_details}
)}
<EditableTitle
canEdit
title={v as string}
onSaveTitle={onChange}
maxWidth={300}
/>
)}
{record.warning_markdown && (
<WarningIconWithTooltip
warningMarkdown={record.warning_markdown}
/>
)}
<EditableTitle
</FlexRowContainer>
),
verbose_name: (v, onChange) => (
<TextControl value={v as string} onChange={onChange} />
),
expression: (v, onChange) => (
<TextAreaControl
canEdit
title={v as string}
onSaveTitle={onChange}
maxWidth={300}
initialValue={v as string}
onChange={onChange}
extraClasses={['datasource-sql-expression']}
language="sql"
offerEditInModal={false}
minLines={5}
textAreaStyles={{ minWidth: '200px', maxWidth: '450px' }}
resize="both"
/>
</FlexRowContainer>
),
verbose_name: (v, onChange) => (
<TextControl value={v as string} onChange={onChange} />
),
expression: (v, onChange) => (
<TextAreaControl
canEdit
initialValue={v as string}
onChange={onChange}
extraClasses={['datasource-sql-expression']}
language="sql"
offerEditInModal={false}
minLines={5}
textAreaStyles={{ minWidth: '200px', maxWidth: '450px' }}
resize="both"
/>
),
description: (v, onChange, label) => (
<StackedField
label={label}
formElement={
<TextControl value={v as string} onChange={onChange} />
}
/>
),
d3format: (v, onChange, label) => (
<StackedField
label={label}
formElement={
<TextControl value={v as string} onChange={onChange} />
}
/>
),
}}
allowDeletes
stickyHeader
/>
),
description: (v, onChange, label) => (
<StackedField
label={label}
formElement={
<TextControl value={v as string} onChange={onChange} />
}
/>
),
d3format: (v, onChange, label) => (
<StackedField
label={label}
formElement={
<TextControl value={v as string} onChange={onChange} />
}
/>
),
}}
allowDeletes
stickyHeader
/>
</div>
);
}
@@ -2332,9 +2361,6 @@ class DatasourceEditor extends PureComponent<
children: (
<StyledTableTabWrapper>
{this.renderDefaultColumnSettings()}
<DefaultColumnSettingsTitle>
{t('Column Settings')}
</DefaultColumnSettingsTitle>
<ColumnButtonWrapper>
<StyledButtonWrapper>
<Button
@@ -2349,9 +2375,20 @@ class DatasourceEditor extends PureComponent<
</Button>
</StyledButtonWrapper>
</ColumnButtonWrapper>
<Input.Search
placeholder={t('Search columns by name')}
value={this.state.columnSearchTerm}
onChange={e =>
this.setState({ columnSearchTerm: e.target.value })
}
style={{ marginBottom: 16, width: 300 }}
allowClear
/>
<ColumnCollectionTable
className="columns-table"
columns={this.state.databaseColumns}
filterTerm={this.state.columnSearchTerm}
filterFields={['column_name']}
datasource={datasource}
onColumnsChange={databaseColumns =>
this.setColumns({ databaseColumns })
@@ -2373,11 +2410,21 @@ class DatasourceEditor extends PureComponent<
children: (
<StyledTableTabWrapper>
{this.renderDefaultColumnSettings()}
<DefaultColumnSettingsTitle>
{t('Column Settings')}
</DefaultColumnSettingsTitle>
<Input.Search
placeholder={t('Search calculated columns by name')}
value={this.state.calculatedColumnSearchTerm}
onChange={e =>
this.setState({
calculatedColumnSearchTerm: e.target.value,
})
}
style={{ marginBottom: 16, width: 300 }}
allowClear
/>
<ColumnCollectionTable
columns={this.state.calculatedColumns}
filterTerm={this.state.calculatedColumnSearchTerm}
filterFields={['column_name']}
onColumnsChange={calculatedColumns =>
this.setColumns({ calculatedColumns })
}

View File

@@ -547,3 +547,146 @@ test('handles AbortError without setState after unmount', async () => {
consoleErrorSpy.mockRestore();
});
test('can search charts by chart name', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 2')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
expect(searchInput).toBeInTheDocument();
await userEvent.type(searchInput, 'Chart 1');
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.queryByText('Test Chart 2')).not.toBeInTheDocument();
});
await userEvent.clear(searchInput);
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 2')).toBeInTheDocument();
});
});
test('can search charts by owner name', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
await userEvent.type(searchInput, 'Bob');
await waitFor(() => {
expect(screen.queryByText('Test Chart 1')).not.toBeInTheDocument();
expect(screen.getByText('Test Chart 2')).toBeInTheDocument();
});
});
test('can search charts by dashboard title', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
await userEvent.type(searchInput, 'Test Dashboard');
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.queryByText('Test Chart 2')).not.toBeInTheDocument();
});
});
test('chart search is case-insensitive', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
await userEvent.type(searchInput, 'CHART 1');
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.queryByText('Test Chart 2')).not.toBeInTheDocument();
});
});
test('shows No items when search has no results', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
await userEvent.type(searchInput, 'nonexistent chart');
await waitFor(() => {
expect(screen.queryByText('Test Chart 1')).not.toBeInTheDocument();
expect(screen.queryByText('Test Chart 2')).not.toBeInTheDocument();
expect(screen.getByText('No items')).toBeInTheDocument();
});
});
test('hides pagination when searching and restores it when cleared', async () => {
setupTest();
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 2')).toBeInTheDocument();
});
// Pagination is visible when not searching (check for page number listitem)
expect(screen.getByTitle('1')).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText(
'Search charts by name, owner, or dashboard',
);
await userEvent.type(searchInput, 'Chart 1');
// Only matching chart is shown
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.queryByText('Test Chart 2')).not.toBeInTheDocument();
});
// Pagination is hidden while searching
expect(screen.queryByTitle('Next Page')).not.toBeInTheDocument();
await userEvent.clear(searchInput);
// Both charts are visible again after clearing search
await waitFor(() => {
expect(screen.getByText('Test Chart 1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 2')).toBeInTheDocument();
});
// Pagination is restored
expect(screen.getByTitle('1')).toBeInTheDocument();
});

View File

@@ -19,7 +19,11 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { t } from '@apache-superset/core';
import { styled, css } from '@apache-superset/core/ui';
import { CertifiedBadge, InfoTooltip } from '@superset-ui/core/components';
import {
CertifiedBadge,
InfoTooltip,
Input,
} from '@superset-ui/core/components';
import Table, {
TableSize,
SortOrder,
@@ -116,6 +120,7 @@ const DatasetUsageTab = ({
'changed_on_delta_humanized',
);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [searchTerm, setSearchTerm] = useState('');
const handleFetchCharts = useCallback(
async (page = 1, column = sortColumn, direction = sortDirection) => {
@@ -289,20 +294,66 @@ const DatasetUsageTab = ({
[handleSortChange, sortColumn, sortDirection],
);
const filteredCharts = useMemo(() => {
if (!searchTerm) return charts;
const lowerSearch = searchTerm.toLowerCase();
return charts.filter(chart => {
// Search in chart name
if (chart.slice_name?.toLowerCase().includes(lowerSearch)) return true;
// Search in owner names
if (
chart.owners?.some(
owner =>
owner.first_name?.toLowerCase().includes(lowerSearch) ||
owner.last_name?.toLowerCase().includes(lowerSearch),
)
)
return true;
// Search in dashboard titles
if (
chart.dashboards?.some(dashboard =>
dashboard.dashboard_title?.toLowerCase().includes(lowerSearch),
)
)
return true;
return false;
});
}, [charts, searchTerm]);
return (
<div ref={tableContainerRef}>
<Input.Search
placeholder={t('Search charts by name, owner, or dashboard')}
value={searchTerm}
onChange={e => {
setSearchTerm(e.target.value);
if (!e.target.value) {
setCurrentPage(1);
}
}}
style={{ marginBottom: 16, width: 400 }}
allowClear
/>
<Table
sticky
columns={columns}
data={charts}
pagination={{
current: currentPage,
total: totalCount,
pageSize: PAGE_SIZE,
onChange: handlePageChange,
showSizeChanger: false,
size: 'default',
}}
data={filteredCharts}
pagination={
searchTerm
? false
: {
current: currentPage,
total: totalCount,
pageSize: PAGE_SIZE,
onChange: handlePageChange,
showSizeChanger: false,
size: 'default',
}
}
loading={loading}
size={TableSize.Middle}
rowKey={(record: Chart) =>

View File

@@ -579,3 +579,245 @@ test('immediate unmount after mount does not cause unhandled rejection from init
consoleErrorSpy.mockRestore();
});
test('can search metrics by metric name', async () => {
const testProps = createProps();
await asyncRender(testProps);
// Navigate to Metrics tab
const metricsTab = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricsTab);
// Find the search input
const searchInput = screen.getByPlaceholderText(
'Search metrics by key or label',
);
expect(searchInput).toBeInTheDocument();
// Verify initial metrics are shown
expect(screen.getByText('sum__num')).toBeInTheDocument();
expect(screen.getByText('avg__num')).toBeInTheDocument();
// Search for a specific metric
await userEvent.type(searchInput, 'sum');
// Verify filtered results
expect(screen.getByText('sum__num')).toBeInTheDocument();
expect(screen.queryByText('avg__num')).not.toBeInTheDocument();
// Clear search
await userEvent.clear(searchInput);
// Verify all metrics are shown again
await waitFor(() => {
expect(screen.getByText('sum__num')).toBeInTheDocument();
expect(screen.getByText('avg__num')).toBeInTheDocument();
});
});
test('can search metrics by verbose name', async () => {
const testProps = createProps();
await asyncRender(testProps);
// Navigate to Metrics tab
const metricsTab = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricsTab);
const searchInput = screen.getByPlaceholderText(
'Search metrics by key or label',
);
// Search by verbose name
await userEvent.type(searchInput, 'avg');
// Verify filtered results
await waitFor(() => {
expect(screen.queryByText('sum__num')).not.toBeInTheDocument();
expect(screen.getByText('avg__num')).toBeInTheDocument();
});
});
test('metric search is case-insensitive', async () => {
const testProps = createProps();
await asyncRender(testProps);
const metricsTab = screen.getByTestId('collection-tab-Metrics');
await userEvent.click(metricsTab);
const searchInput = screen.getByPlaceholderText(
'Search metrics by key or label',
);
// Search with uppercase
await userEvent.type(searchInput, 'SUM');
// Verify results are still found
await waitFor(() => {
expect(screen.getByText('sum__num')).toBeInTheDocument();
});
});
test('can search columns by name', async () => {
const testProps = createProps();
await asyncRender(testProps);
// Navigate to Columns tab
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
// Find the search input
const searchInput = screen.getByPlaceholderText('Search columns by name');
expect(searchInput).toBeInTheDocument();
// Get initial column count
const initialColumns = screen.getAllByRole('row');
// Search for a specific column
await userEvent.type(searchInput, 'ds');
// Verify filtered results (fewer rows than before)
await waitFor(() => {
const filteredColumns = screen.getAllByRole('row');
expect(filteredColumns.length).toBeLessThan(initialColumns.length);
});
// Clear search
await userEvent.clear(searchInput);
// Verify all columns are shown again
await waitFor(() => {
const allColumns = screen.getAllByRole('row');
expect(allColumns.length).toBe(initialColumns.length);
});
});
test('column search is case-insensitive', async () => {
const testProps = createProps();
await asyncRender(testProps);
const columnsTab = screen.getByTestId('collection-tab-Columns');
await userEvent.click(columnsTab);
const searchInput = screen.getByPlaceholderText('Search columns by name');
// Search with uppercase
await userEvent.type(searchInput, 'DS');
// Verify results are still found
await waitFor(() => {
const filteredColumns = screen.getAllByRole('row');
// Should have fewer rows than total (header + filtered rows)
expect(filteredColumns.length).toBeGreaterThan(0);
});
});
test('can search calculated columns by name', async () => {
const testProps = createProps();
// Add some calculated columns with expressions
testProps.datasource.columns = [
...testProps.datasource.columns,
{
id: 999,
type: 'VARCHAR',
filterable: true,
is_dttm: false,
is_active: true,
expression: 'CASE WHEN state = "CA" THEN 1 ELSE 0 END',
groupby: true,
column_name: 'is_california',
},
{
id: 1000,
type: 'VARCHAR',
filterable: true,
is_dttm: false,
is_active: true,
expression: 'UPPER(name)',
groupby: true,
column_name: 'upper_name',
},
];
await asyncRender(testProps);
// Navigate to Calculated Columns tab
const calculatedColumnsTab = screen.getByTestId(
'collection-tab-Calculated columns',
);
await userEvent.click(calculatedColumnsTab);
// Wait for calculated columns to be displayed
// column_name is rendered as an input (TextControl) since editableColumnName=true
await waitFor(() => {
expect(screen.getByDisplayValue('is_california')).toBeInTheDocument();
});
// Find the search input
const searchInput = screen.getByPlaceholderText(
'Search calculated columns by name',
);
expect(searchInput).toBeInTheDocument();
// Verify both columns are visible initially
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
// Search for a specific calculated column
await userEvent.type(searchInput, 'california');
// Verify filtered results
await waitFor(() => {
expect(screen.getByDisplayValue('is_california')).toBeInTheDocument();
expect(screen.queryByDisplayValue('upper_name')).not.toBeInTheDocument();
});
// Clear search
await userEvent.clear(searchInput);
// Verify all calculated columns are shown again
await waitFor(() => {
expect(screen.getByDisplayValue('is_california')).toBeInTheDocument();
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
});
});
test('calculated column search is case-insensitive', async () => {
const testProps = createProps();
testProps.datasource.columns = [
...testProps.datasource.columns,
{
id: 999,
type: 'VARCHAR',
filterable: true,
is_dttm: false,
is_active: true,
expression: 'UPPER(name)',
groupby: true,
column_name: 'upper_name',
},
];
await asyncRender(testProps);
const calculatedColumnsTab = screen.getByTestId(
'collection-tab-Calculated columns',
);
await userEvent.click(calculatedColumnsTab);
// Wait for calculated column to be displayed
// column_name is rendered as an input (TextControl) since editableColumnName=true
await waitFor(() => {
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
'Search calculated columns by name',
);
// Search with different case
await userEvent.type(searchInput, 'UPPER');
// Verify results are still found
await waitFor(() => {
expect(screen.getByDisplayValue('upper_name')).toBeInTheDocument();
});
});

View File

@@ -79,6 +79,15 @@ export interface CRUDCollectionProps {
tableLayout?: 'fixed' | 'auto';
sortColumns: string[];
stickyHeader?: boolean;
pagination?:
| boolean
| {
pageSize?: number;
showSizeChanger?: boolean;
pageSizeOptions?: number[];
};
filterTerm?: string;
filterFields?: string[];
}
export type Sort = number | string | boolean | any;
@@ -95,6 +104,8 @@ export interface CRUDCollectionState {
expandedColumns: Record<PropertyKey, any>;
sortColumn: string;
sort: SortOrder;
currentPage: number;
pageSize: number;
}
export enum FoldersEditorItemType {