mirror of
https://github.com/apache/superset.git
synced 2024-09-17 11:09:47 -04:00
chore(explore): Hide non-droppable metric and column list (#27717)
(cherry picked from commit eda304bda9
)
This commit is contained in:
parent
23cd2b138b
commit
af9dc6fb01
@ -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();
|
||||
});
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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 && (
|
||||
|
@ -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,
|
||||
|
@ -20,7 +20,7 @@ import React from 'react';
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { OptionControlLabel } from 'src/explore/components/controls/OptionControls';
|
||||
|
||||
import ExploreContainer, { DraggingContext } from '.';
|
||||
import ExploreContainer, { DraggingContext, DropzoneContext } from '.';
|
||||
import OptionWrapper from '../controls/DndColumnSelectControl/OptionWrapper';
|
||||
|
||||
const MockChildren = () => {
|
||||
@ -32,6 +32,24 @@ const MockChildren = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const MockChildren2 = () => {
|
||||
const [zones, dispatch] = React.useContext(DropzoneContext);
|
||||
return (
|
||||
<>
|
||||
<div data-test="mock-children">{Object.keys(zones).join(':')}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ key: 'test_item_1', canDrop: () => true })}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button type="button" onClick={() => dispatch({ key: 'test_item_1' })}>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
test('should render children', () => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<ExploreContainer>
|
||||
@ -43,7 +61,7 @@ test('should render children', () => {
|
||||
expect(getByText('not dragging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should update the style on dragging state', () => {
|
||||
test('should propagate dragging state', () => {
|
||||
const defaultProps = {
|
||||
label: <span>Test label</span>,
|
||||
tooltipTitle: 'This is a tooltip title',
|
||||
@ -83,3 +101,23 @@ test('should update the style on dragging state', () => {
|
||||
fireEvent.dragStart(getByText('Label 2'));
|
||||
expect(container.getElementsByClassName('dragging')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should manage the dropValidators', () => {
|
||||
const { queryByText, getByText } = render(
|
||||
<ExploreContainer>
|
||||
<MockChildren2 />
|
||||
</ExploreContainer>,
|
||||
{
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText('test_item_1')).not.toBeInTheDocument();
|
||||
const addDropValidatorButton = getByText('Add');
|
||||
fireEvent.click(addDropValidatorButton);
|
||||
expect(getByText('test_item_1')).toBeInTheDocument();
|
||||
const removeDropValidatorButton = getByText('Remove');
|
||||
fireEvent.click(removeDropValidatorButton);
|
||||
expect(queryByText('test_item_1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
@ -16,17 +16,41 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, Dispatch, useReducer } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { useDragDropManager } from 'react-dnd';
|
||||
import { DatasourcePanelDndItem } from '../DatasourcePanel/types';
|
||||
|
||||
type CanDropValidator = (item: DatasourcePanelDndItem) => boolean;
|
||||
type DropzoneSet = Record<string, CanDropValidator>;
|
||||
type Action = { key: string; canDrop?: CanDropValidator };
|
||||
|
||||
export const DraggingContext = React.createContext(false);
|
||||
export const DropzoneContext = React.createContext<
|
||||
[DropzoneSet, Dispatch<Action>]
|
||||
>([{}, () => {}]);
|
||||
const StyledDiv = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const reducer = (state: DropzoneSet = {}, action: Action) => {
|
||||
if (action.canDrop) {
|
||||
return {
|
||||
...state,
|
||||
[action.key]: action.canDrop,
|
||||
};
|
||||
}
|
||||
if (action.key) {
|
||||
const newState = { ...state };
|
||||
delete newState[action.key];
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const ExploreContainer: React.FC<{}> = ({ children }) => {
|
||||
const dragDropManager = useDragDropManager();
|
||||
const [dragging, setDragging] = React.useState(
|
||||
@ -50,10 +74,14 @@ const ExploreContainer: React.FC<{}> = ({ children }) => {
|
||||
};
|
||||
}, [dragDropManager]);
|
||||
|
||||
const dropzoneValue = useReducer(reducer, {});
|
||||
|
||||
return (
|
||||
<DraggingContext.Provider value={dragging}>
|
||||
<StyledDiv>{children}</StyledDiv>
|
||||
</DraggingContext.Provider>
|
||||
<DropzoneContext.Provider value={dropzoneValue}>
|
||||
<DraggingContext.Provider value={dragging}>
|
||||
<StyledDiv>{children}</StyledDiv>
|
||||
</DraggingContext.Provider>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -23,6 +23,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
|
||||
import DndSelectLabel, {
|
||||
DndSelectLabelProps,
|
||||
} from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
|
||||
import ExploreContainer, { DropzoneContext } from '../../ExploreContainer';
|
||||
|
||||
const defaultProps: DndSelectLabelProps = {
|
||||
name: 'Column',
|
||||
@ -33,6 +34,23 @@ const defaultProps: DndSelectLabelProps = {
|
||||
ghostButtonText: 'Drop columns here or click',
|
||||
onClickGhostButton: jest.fn(),
|
||||
};
|
||||
const MockChildren = () => {
|
||||
const [zones] = React.useContext(DropzoneContext);
|
||||
return (
|
||||
<>
|
||||
{Object.keys(zones).map(key => (
|
||||
<div key={key} data-test={`mock-result-${key}`}>
|
||||
{String(
|
||||
zones[key]({
|
||||
value: { column_name: 'test' },
|
||||
type: DndItemType.Column,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
test('renders with default props', () => {
|
||||
render(<DndSelectLabel {...defaultProps} />, { useDnd: true });
|
||||
@ -62,3 +80,25 @@ test('Handles ghost button click', () => {
|
||||
userEvent.click(screen.getByText('Drop columns here or click'));
|
||||
expect(defaultProps.onClickGhostButton).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updates dropValidator on changes', () => {
|
||||
const { getByTestId, rerender } = render(
|
||||
<ExploreContainer>
|
||||
<DndSelectLabel {...defaultProps} />
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
{ useDnd: true },
|
||||
);
|
||||
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
|
||||
'false',
|
||||
);
|
||||
rerender(
|
||||
<ExploreContainer>
|
||||
<DndSelectLabel {...defaultProps} canDrop={() => true} />
|
||||
<MockChildren />
|
||||
</ExploreContainer>,
|
||||
);
|
||||
expect(getByTestId(`mock-result-${defaultProps.name}`)).toHaveTextContent(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { ReactNode, useContext, useMemo } from 'react';
|
||||
import React, {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { t, useTheme } from '@superset-ui/core';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
@ -31,7 +37,7 @@ import {
|
||||
} from 'src/explore/components/DatasourcePanel/types';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { DndItemType } from '../../DndItemType';
|
||||
import { DraggingContext } from '../../ExploreContainer';
|
||||
import { DraggingContext, DropzoneContext } from '../../ExploreContainer';
|
||||
|
||||
export type DndSelectLabelProps = {
|
||||
name: string;
|
||||
@ -55,6 +61,14 @@ export default function DndSelectLabel({
|
||||
...props
|
||||
}: DndSelectLabelProps) {
|
||||
const theme = useTheme();
|
||||
const canDropProp = props.canDrop;
|
||||
const canDropValueProp = props.canDropValue;
|
||||
|
||||
const dropValidator = useCallback(
|
||||
(item: DatasourcePanelDndItem) =>
|
||||
canDropProp(item) && (canDropValueProp?.(item.value) ?? true),
|
||||
[canDropProp, canDropValueProp],
|
||||
);
|
||||
|
||||
const [{ isOver, canDrop }, datasourcePanelDrop] = useDrop({
|
||||
accept: isLoading ? [] : accept,
|
||||
@ -64,8 +78,7 @@ export default function DndSelectLabel({
|
||||
props.onDropValue?.(item.value);
|
||||
},
|
||||
|
||||
canDrop: (item: DatasourcePanelDndItem) =>
|
||||
props.canDrop(item) && (props.canDropValue?.(item.value) ?? true),
|
||||
canDrop: dropValidator,
|
||||
|
||||
collect: monitor => ({
|
||||
isOver: monitor.isOver(),
|
||||
@ -73,6 +86,16 @@ export default function DndSelectLabel({
|
||||
type: monitor.getItemType(),
|
||||
}),
|
||||
});
|
||||
|
||||
const dispatch = useContext(DropzoneContext)[1];
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ key: props.name, canDrop: dropValidator });
|
||||
return () => {
|
||||
dispatch({ key: props.name });
|
||||
};
|
||||
}, [dispatch, props.name, dropValidator]);
|
||||
|
||||
const isDragging = useContext(DraggingContext);
|
||||
|
||||
const values = useMemo(() => valuesRenderer(), [valuesRenderer]);
|
||||
|
Loading…
Reference in New Issue
Block a user