chore(explore): Hide non-droppable metric and column list (#27717)

This commit is contained in:
JUST.in DO IT
2024-04-05 09:29:05 -07:00
committed by GitHub
parent 9377227e06
commit eda304bda9
8 changed files with 393 additions and 107 deletions

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import DatasourcePanel, {
IDatasource,
@@ -29,6 +29,11 @@ import {
} from 'src/explore/components/DatasourcePanel/fixtures';
import { DatasourceType } from '@superset-ui/core';
import DatasourceControl from 'src/explore/components/controls/DatasourceControl';
import ExploreContainer from '../ExploreContainer';
import {
DndColumnSelect,
DndMetricSelect,
} from '../controls/DndColumnSelectControl';
jest.mock(
'react-virtualized-auto-sizer',
@@ -83,6 +88,12 @@ const props: DatasourcePanelProps = {
width: 300,
};
const metricProps = {
savedMetrics: [],
columns: [],
onChange: jest.fn(),
};
const search = (value: string, input: HTMLElement) => {
userEvent.clear(input);
userEvent.type(input, value);
@@ -104,7 +115,13 @@ test('should display items in controls', async () => {
});
test('should render the metrics', async () => {
render(<DatasourcePanel {...props} />, { useRedux: true, useDnd: true });
render(
<ExploreContainer>
<DatasourcePanel {...props} />
<DndMetricSelect {...metricProps} />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
);
const metricsNum = metrics.length;
metrics.forEach(metric =>
expect(screen.getByText(metric.metric_name)).toBeInTheDocument(),
@@ -115,7 +132,13 @@ test('should render the metrics', async () => {
});
test('should render the columns', async () => {
render(<DatasourcePanel {...props} />, { useRedux: true, useDnd: true });
render(
<ExploreContainer>
<DatasourcePanel {...props} />
<DndMetricSelect {...metricProps} />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
);
const columnsNum = columns.length;
columns.forEach(col =>
expect(screen.getByText(col.column_name)).toBeInTheDocument(),
@@ -134,7 +157,13 @@ test('should render 0 search results', async () => {
});
test('should search and render matching columns', async () => {
render(<DatasourcePanel {...props} />, { useRedux: true, useDnd: true });
render(
<ExploreContainer>
<DatasourcePanel {...props} />
<DndMetricSelect {...metricProps} />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
);
const searchInput = screen.getByPlaceholderText('Search Metrics & Columns');
search(columns[0].column_name, searchInput);
@@ -146,7 +175,13 @@ test('should search and render matching columns', async () => {
});
test('should search and render matching metrics', async () => {
render(<DatasourcePanel {...props} />, { useRedux: true, useDnd: true });
render(
<ExploreContainer>
<DatasourcePanel {...props} />
<DndMetricSelect {...metricProps} />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
);
const searchInput = screen.getByPlaceholderText('Search Metrics & Columns');
search(metrics[0].metric_name, searchInput);
@@ -211,3 +246,41 @@ test('should not render a save dataset modal when datasource is not query or dat
expect(screen.queryByText(/create a dataset/i)).toBe(null);
});
test('should render only droppable metrics and columns', async () => {
const column1FilterProps = {
type: 'DndColumnSelect' as const,
name: 'Filter',
onChange: jest.fn(),
options: [{ column_name: columns[1].column_name }],
actions: { setControlValue: jest.fn() },
};
const column2FilterProps = {
type: 'DndColumnSelect' as const,
name: 'Filter',
onChange: jest.fn(),
options: [
{ column_name: columns[1].column_name },
{ column_name: columns[2].column_name },
],
actions: { setControlValue: jest.fn() },
};
const { getByTestId } = render(
<ExploreContainer>
<DatasourcePanel {...props} />
<DndColumnSelect {...column1FilterProps} />
<DndColumnSelect {...column2FilterProps} />
</ExploreContainer>,
{ useRedux: true, useDnd: true },
);
const selections = getByTestId('fieldSelections');
expect(
within(selections).queryByText(columns[0].column_name),
).not.toBeInTheDocument();
expect(
within(selections).queryByText(columns[1].column_name),
).toBeInTheDocument();
expect(
within(selections).queryByText(columns[2].column_name),
).toBeInTheDocument();
});

View File

@@ -39,6 +39,8 @@ const mockData = {
onCollapseMetricsChange: jest.fn(),
collapseColumns: false,
onCollapseColumnsChange: jest.fn(),
hiddenMetricCount: 0,
hiddenColumnCount: 0,
};
test('renders each item accordingly', () => {
@@ -166,3 +168,32 @@ test('can collapse metrics and columns', () => {
);
expect(queryByText('Columns')).toBeInTheDocument();
});
test('shows ineligible items count', () => {
const hiddenColumnCount = 3;
const hiddenMetricCount = 1;
const dataWithHiddenItems = {
...mockData,
hiddenColumnCount,
hiddenMetricCount,
};
const { getByText, rerender } = render(
<DatasourcePanelItem index={1} data={dataWithHiddenItems} style={{}} />,
{ useDnd: true },
);
expect(
getByText(`${hiddenMetricCount} ineligible item(s) are hidden`),
).toBeInTheDocument();
const startIndexOfColumnSection = mockData.metricSlice.length + 3;
rerender(
<DatasourcePanelItem
index={startIndexOfColumnSection + 1}
data={dataWithHiddenItems}
style={{}}
/>,
);
expect(
getByText(`${hiddenColumnCount} ineligible item(s) are hidden`),
).toBeInTheDocument();
});

View File

@@ -51,6 +51,8 @@ type Props = {
onCollapseMetricsChange: (collapse: boolean) => void;
collapseColumns: boolean;
onCollapseColumnsChange: (collapse: boolean) => void;
hiddenMetricCount: number;
hiddenColumnCount: number;
};
};
@@ -130,6 +132,19 @@ const SectionHeader = styled.span`
`}
`;
const Box = styled.div`
${({ theme }) => `
border: 1px ${theme.colors.grayscale.light4} solid;
border-radius: ${theme.gridUnit}px;
font-size: ${theme.typography.sizes.s}px;
padding: ${theme.gridUnit}px;
color: ${theme.colors.grayscale.light1};
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`}
`;
const DatasourcePanelItem: React.FC<Props> = ({ index, style, data }) => {
const {
metricSlice: _metricSlice,
@@ -145,6 +160,8 @@ const DatasourcePanelItem: React.FC<Props> = ({ index, style, data }) => {
onCollapseMetricsChange,
collapseColumns,
onCollapseColumnsChange,
hiddenMetricCount,
hiddenColumnCount,
} = data;
const metricSlice = collapseMetrics ? [] : _metricSlice;
@@ -169,6 +186,7 @@ const DatasourcePanelItem: React.FC<Props> = ({ index, style, data }) => {
? onShowAllColumnsChange
: onShowAllMetricsChange;
const theme = useTheme();
const hiddenCount = isColumnSection ? hiddenColumnCount : hiddenMetricCount;
return (
<div
@@ -190,10 +208,27 @@ const DatasourcePanelItem: React.FC<Props> = ({ index, style, data }) => {
</SectionHeaderButton>
)}
{index === SUBTITLE_LINE && !collapsed && (
<div className="field-length">
{isColumnSection
? t(`Showing %s of %s`, columnSlice?.length, totalColumns)
: t(`Showing %s of %s`, metricSlice?.length, totalMetrics)}
<div
css={css`
display: flex;
gap: ${theme.gridUnit * 2}px;
justify-content: space-between;
align-items: baseline;
`}
>
<div
className="field-length"
css={css`
flex-shrink: 0;
`}
>
{isColumnSection
? t(`Showing %s of %s`, columnSlice?.length, totalColumns)
: t(`Showing %s of %s`, metricSlice?.length, totalMetrics)}
</div>
{hiddenCount > 0 && (
<Box>{t(`%s ineligible item(s) are hidden`, hiddenCount)}</Box>
)}
</div>
)}
{index > SUBTITLE_LINE && index < BOTTOM_LINE && (

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useMemo, useState } from 'react';
import React, { useContext, useMemo, useState } from 'react';
import {
css,
DatasourceType,
@@ -30,7 +30,7 @@ import { ControlConfig } from '@superset-ui/chart-controls';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { debounce, isArray } from 'lodash';
import { isArray } from 'lodash';
import { matchSorter, rankings } from 'match-sorter';
import Alert from 'src/components/Alert';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
@@ -39,12 +39,16 @@ import { Input } from 'src/components/Input';
import { FAST_DEBOUNCE } from 'src/constants';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import Control from 'src/explore/components/Control';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import DatasourcePanelItem, {
ITEM_HEIGHT,
DataSourcePanelColumn,
DEFAULT_MAX_COLUMNS_LENGTH,
DEFAULT_MAX_METRICS_LENGTH,
} from './DatasourcePanelItem';
import { DndItemType } from '../DndItemType';
import { DndItemValue } from './types';
import { DropzoneContext } from '../ExploreContainer';
interface DatasourceControl extends ControlConfig {
datasource?: IDatasource;
@@ -122,6 +126,9 @@ const StyledInfoboxWrapper = styled.div`
const BORDER_WIDTH = 2;
const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) =>
slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
export default function DataSourcePanel({
datasource,
formData,
@@ -129,11 +136,26 @@ export default function DataSourcePanel({
actions,
width,
}: Props) {
const [dropzones] = useContext(DropzoneContext);
const { columns: _columns, metrics } = datasource;
const allowedColumns = useMemo(() => {
const validators = Object.values(dropzones);
if (!isArray(_columns)) return [];
return _columns.filter(column =>
validators.some(validator =>
validator({
value: column as DndItemValue,
type: DndItemType.Column,
}),
),
);
}, [dropzones, _columns]);
// display temporal column first
const columns = useMemo(
() =>
[...(isArray(_columns) ? _columns : [])].sort((col1, col2) => {
[...allowedColumns].sort((col1, col2) => {
if (col1?.is_dttm && !col2?.is_dttm) {
return -1;
}
@@ -142,106 +164,102 @@ export default function DataSourcePanel({
}
return 0;
}),
[_columns],
[allowedColumns],
);
const allowedMetrics = useMemo(() => {
const validators = Object.values(dropzones);
return metrics.filter(metric =>
validators.some(validator =>
validator({ value: metric, type: DndItemType.Metric }),
),
);
}, [dropzones, metrics]);
const hiddenColumnCount = _columns.length - allowedColumns.length;
const hiddenMetricCount = metrics.length - allowedMetrics.length;
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
const [inputValue, setInputValue] = useState('');
const [lists, setList] = useState({
columns,
metrics,
});
const [showAllMetrics, setShowAllMetrics] = useState(false);
const [showAllColumns, setShowAllColumns] = useState(false);
const [collapseMetrics, setCollapseMetrics] = useState(false);
const [collapseColumns, setCollapseColumns] = useState(false);
const searchKeyword = useDebounceValue(inputValue, FAST_DEBOUNCE);
const search = useMemo(
() =>
debounce((value: string) => {
if (value === '') {
setList({ columns, metrics });
return;
}
setList({
columns: matchSorter(columns, value, {
keys: [
{
key: 'verbose_name',
threshold: rankings.CONTAINS,
},
{
key: 'column_name',
threshold: rankings.CONTAINS,
},
{
key: item =>
[item?.description ?? '', item?.expression ?? ''].map(
x => x?.replace(/[_\n\s]+/g, ' ') || '',
),
threshold: rankings.CONTAINS,
maxRanking: rankings.CONTAINS,
},
],
keepDiacritics: true,
}),
metrics: matchSorter(metrics, value, {
keys: [
{
key: 'verbose_name',
threshold: rankings.CONTAINS,
},
{
key: 'metric_name',
threshold: rankings.CONTAINS,
},
{
key: item =>
[item?.description ?? '', item?.expression ?? ''].map(
x => x?.replace(/[_\n\s]+/g, ' ') || '',
),
threshold: rankings.CONTAINS,
maxRanking: rankings.CONTAINS,
},
],
keepDiacritics: true,
baseSort: (a, b) =>
Number(b?.item?.is_certified ?? 0) -
Number(a?.item?.is_certified ?? 0) ||
String(a?.rankedValue ?? '').localeCompare(b?.rankedValue ?? ''),
}),
});
}, FAST_DEBOUNCE),
[columns, metrics],
);
useEffect(() => {
setList({
columns,
metrics,
const filteredColumns = useMemo(() => {
if (!searchKeyword) {
return columns ?? [];
}
return matchSorter(columns, searchKeyword, {
keys: [
{
key: 'verbose_name',
threshold: rankings.CONTAINS,
},
{
key: 'column_name',
threshold: rankings.CONTAINS,
},
{
key: item =>
[item?.description ?? '', item?.expression ?? ''].map(
x => x?.replace(/[_\n\s]+/g, ' ') || '',
),
threshold: rankings.CONTAINS,
maxRanking: rankings.CONTAINS,
},
],
keepDiacritics: true,
});
setInputValue('');
}, [columns, datasource, metrics]);
}, [columns, searchKeyword]);
const sortCertifiedFirst = (slice: DataSourcePanelColumn[]) =>
slice.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
const filteredMetrics = useMemo(() => {
if (!searchKeyword) {
return allowedMetrics ?? [];
}
return matchSorter(allowedMetrics, searchKeyword, {
keys: [
{
key: 'verbose_name',
threshold: rankings.CONTAINS,
},
{
key: 'metric_name',
threshold: rankings.CONTAINS,
},
{
key: item =>
[item?.description ?? '', item?.expression ?? ''].map(
x => x?.replace(/[_\n\s]+/g, ' ') || '',
),
threshold: rankings.CONTAINS,
maxRanking: rankings.CONTAINS,
},
],
keepDiacritics: true,
baseSort: (a, b) =>
Number(b?.item?.is_certified ?? 0) -
Number(a?.item?.is_certified ?? 0) ||
String(a?.rankedValue ?? '').localeCompare(b?.rankedValue ?? ''),
});
}, [allowedMetrics, searchKeyword]);
const metricSlice = useMemo(
() =>
showAllMetrics
? lists?.metrics
: lists?.metrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH),
[lists?.metrics, showAllMetrics],
? filteredMetrics
: filteredMetrics?.slice?.(0, DEFAULT_MAX_METRICS_LENGTH),
[filteredMetrics, showAllMetrics],
);
const columnSlice = useMemo(
() =>
showAllColumns
? sortCertifiedFirst(lists?.columns)
? sortCertifiedFirst(filteredColumns)
: sortCertifiedFirst(
lists?.columns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH),
filteredColumns?.slice?.(0, DEFAULT_MAX_COLUMNS_LENGTH),
),
[lists.columns, showAllColumns],
[filteredColumns, showAllColumns],
);
const showInfoboxCheck = () => {
@@ -268,13 +286,12 @@ export default function DataSourcePanel({
allowClear
onChange={evt => {
setInputValue(evt.target.value);
search(evt.target.value);
}}
value={inputValue}
className="form-control input-md"
placeholder={t('Search Metrics & Columns')}
/>
<div className="field-selections">
<div className="field-selections" data-test="fieldSelections">
{datasourceIsSaveable && showInfoboxCheck() && (
<StyledInfoboxWrapper>
<Alert
@@ -321,8 +338,8 @@ export default function DataSourcePanel({
metricSlice,
columnSlice,
width,
totalMetrics: lists?.metrics.length,
totalColumns: lists?.columns.length,
totalMetrics: filteredMetrics.length,
totalColumns: filteredColumns.length,
showAllMetrics,
onShowAllMetricsChange: setShowAllMetrics,
showAllColumns,
@@ -331,6 +348,8 @@ export default function DataSourcePanel({
onCollapseMetricsChange: setCollapseMetrics,
collapseColumns,
onCollapseColumnsChange: setCollapseColumns,
hiddenMetricCount,
hiddenColumnCount,
}}
overscanCount={5}
>
@@ -345,10 +364,9 @@ export default function DataSourcePanel({
[
columnSlice,
inputValue,
lists.columns.length,
lists?.metrics?.length,
filteredColumns.length,
filteredMetrics.length,
metricSlice,
search,
showAllColumns,
showAllMetrics,
collapseMetrics,