mirror of
https://github.com/apache/superset.git
synced 2026-04-18 07:35:09 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user