This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8f070169a59 perf(datasource): add pagination to datasource editor
tables to prevent browser freeze (#37555)
8f070169a59 is described below
commit 8f070169a59a8b84deabfb508a6bc0137a67181d
Author: madhushreeag <[email protected]>
AuthorDate: Mon Feb 23 15:19:33 2026 -0800
perf(datasource): add pagination to datasource editor tables to prevent
browser freeze (#37555)
Co-authored-by: madhushree agarwal <[email protected]>
---
.../Datasource/DatasourceModal/index.tsx | 7 +-
.../components/CollectionTable/index.tsx | 55 ++-
.../DatasourceEditor/DatasourceEditor.tsx | 367 ++++++++++++---------
.../DatasetUsageTab/DatasetUsageTab.test.tsx | 143 ++++++++
.../components/DatasetUsageTab/index.tsx | 71 +++-
.../tests/DatasourceEditor.test.tsx | 242 ++++++++++++++
.../src/components/Datasource/types.ts | 11 +
7 files changed, 721 insertions(+), 175 deletions(-)
diff --git
a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
index a6e3e84f949..f8b9f5dbb30 100644
--- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
+++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
@@ -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
diff --git
a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
index 40914d24a20..a99e7f344f5 100644
---
a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
+++
b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
@@ -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;
`
}
diff --git
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index f5592a2c645..fe1a35cc72e 100644
---
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -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')}
- />
- }
- />
- <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%;
- `}
+ <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
- 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}
/>
- }
+ )}
+ <EditableTitle
+ canEdit
+ title={v as string}
+ onSaveTitle={onChange}
+ maxWidth={300}
+ />
+ </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"
/>
- <Field
- label={t('Certification details')}
- fieldKey="certification_details"
- description={t('Details of the certification')}
- control={
- <TextControl
- controlId="certification_details"
- placeholder={t('Certification details')}
- />
+ ),
+ description: (v, onChange, label) => (
+ <StackedField
+ label={label}
+ formElement={
+ <TextControl value={v as string} onChange={onChange} />
}
/>
- <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"
- />
+ ),
+ d3format: (v, onChange, label) => (
+ <StackedField
+ label={label}
+ formElement={
+ <TextControl value={v as string} onChange={onChange} />
}
/>
- </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}
- />
- )}
- {record.warning_markdown && (
- <WarningIconWithTooltip
- warningMarkdown={record.warning_markdown}
- />
- )}
- <EditableTitle
- canEdit
- title={v as string}
- onSaveTitle={onChange}
- maxWidth={300}
- />
- </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
- />
+ ),
+ }}
+ 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 })
}
diff --git
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/DatasetUsageTab.test.tsx
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/DatasetUsageTab.test.tsx
index 103964fd7c2..5695dc2e277 100644
---
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/DatasetUsageTab.test.tsx
+++
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/DatasetUsageTab.test.tsx
@@ -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();
+});
diff --git
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/index.tsx
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/index.tsx
index 05bd1aa65d7..85b52f5f08f 100644
---
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/index.tsx
+++
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/components/DatasetUsageTab/index.tsx
@@ -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) =>
diff --git
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
index a2da3a5544c..a4bb7733337 100644
---
a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
+++
b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx
@@ -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();
+ });
+});
diff --git a/superset-frontend/src/components/Datasource/types.ts
b/superset-frontend/src/components/Datasource/types.ts
index a9432b641b1..3d5dea5c659 100644
--- a/superset-frontend/src/components/Datasource/types.ts
+++ b/superset-frontend/src/components/Datasource/types.ts
@@ -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 {