chore: Improves the Select component UI/UX - iteration 4 (#15480)

This commit is contained in:
Michael S. Molina 2021-07-20 13:29:42 -03:00 committed by GitHub
parent 2aa889944d
commit ad773ffe79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 379 additions and 457 deletions

View File

@ -62,10 +62,13 @@ export function findValue<OptionType extends OptionTypeBase>(
export function hasOption(search: string, options: AntdOptionsType) {
const searchOption = search.trim().toLowerCase();
return options.find(
opt =>
opt.value.toLowerCase().includes(searchOption) ||
(typeof opt.label === 'string' &&
opt.label.toLowerCase().includes(searchOption)),
);
return options.find(opt => {
const { label, value } = opt;
const labelText = String(label);
const valueText = String(value);
return (
valueText.toLowerCase().includes(searchOption) ||
labelText.toLowerCase().includes(searchOption)
);
});
}

View File

@ -1,64 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import SupersetResourceSelect from '.';
const mockedProps = {
resource: 'dataset',
searchColumn: 'table_name',
onError: () => {},
};
fetchMock.get('glob:*/api/v1/dataset/?q=*', {});
test('should render', () => {
const { container } = render(<SupersetResourceSelect {...mockedProps} />);
expect(container).toBeInTheDocument();
});
test('should render the Select... placeholder', () => {
render(<SupersetResourceSelect {...mockedProps} />);
expect(screen.getByText('Select...')).toBeInTheDocument();
});
test('should render the Loading... message', () => {
render(<SupersetResourceSelect {...mockedProps} />);
const select = screen.getByText('Select...');
userEvent.click(select);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('should render the No options message', async () => {
render(<SupersetResourceSelect {...mockedProps} />);
const select = screen.getByText('Select...');
userEvent.click(select);
expect(await screen.findByText('No options')).toBeInTheDocument();
});
test('should render the typed text', async () => {
render(<SupersetResourceSelect {...mockedProps} />);
const select = screen.getByText('Select...');
userEvent.click(select);
userEvent.type(select, 'typed text');
expect(await screen.findByText('typed text')).toBeInTheDocument();
});

View File

@ -1,121 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect } from 'react';
import rison from 'rison';
import { SupersetClient } from '@superset-ui/core';
import { AsyncSelect } from 'src/components/Select';
import {
ClientErrorObject,
getClientErrorObject,
} from 'src/utils/getClientErrorObject';
import { cacheWrapper } from 'src/utils/cacheWrapper';
export type Value<V> = { value: V; label: string };
export interface SupersetResourceSelectProps<T = unknown, V = string> {
value?: Value<V> | null;
initialId?: number | string;
onChange?: (value?: Value<V>) => void;
isMulti?: boolean;
searchColumn?: string;
resource?: string; // e.g. "dataset", "dashboard/related/owners"
transformItem?: (item: T) => Value<V>;
onError: (error: ClientErrorObject) => void;
defaultOptions?: { value: number; label: string }[] | boolean;
}
/**
* This is a special-purpose select component for when you're selecting
* items from one of the standard Superset resource APIs.
* Such as selecting a datasource, a chart, or users.
*
* If you're selecting a "related" resource (such as dashboard/related/owners),
* leave the searchColumn prop unset.
* The api doesn't do columns on related resources for some reason.
*
* If you're doing anything more complex than selecting a standard resource,
* we'll all be better off if you use AsyncSelect directly instead.
*/
const localCache = new Map<string, any>();
export const cachedSupersetGet = cacheWrapper(
SupersetClient.get,
localCache,
({ endpoint }) => endpoint || '',
);
export default function SupersetResourceSelect<T, V>({
value,
initialId,
onChange,
isMulti,
resource,
searchColumn,
transformItem,
onError,
defaultOptions = true,
}: SupersetResourceSelectProps<T, V>) {
useEffect(() => {
if (initialId == null) return;
cachedSupersetGet({
endpoint: `/api/v1/${resource}/${initialId}`,
})
.then(response => {
const { result } = response.json;
const value = transformItem ? transformItem(result) : result;
if (onChange) onChange(value);
})
.catch(response => {
if (response?.status === 404 && onChange) onChange(undefined);
});
}, [resource, initialId]); // eslint-disable-line react-hooks/exhaustive-deps
function loadOptions(input: string) {
const query = searchColumn
? rison.encode({
filters: [{ col: searchColumn, opr: 'ct', value: input }],
})
: rison.encode({ filter: value });
return cachedSupersetGet({
endpoint: `/api/v1/${resource}/?q=${query}`,
}).then(
response =>
response.json.result
.map(transformItem)
.sort((a: Value<V>, b: Value<V>) => a.label.localeCompare(b.label)),
async badResponse => {
onError(await getClientErrorObject(badResponse));
return [];
},
);
}
return (
<AsyncSelect
value={value}
onChange={onChange}
isMulti={isMulti}
loadOptions={loadOptions}
defaultOptions={defaultOptions} // true - load options on render
cacheOptions
filterOption={null} // options are filtered at the api
/>
);
}

View File

@ -20,7 +20,7 @@ import React, { useCallback, useState, useMemo, useEffect } from 'react';
import { FormInstance } from 'antd/lib/form';
import { Column, ensureIsArray, SupersetClient, t } from '@superset-ui/core';
import { useChangeEffect } from 'src/common/hooks/useChangeEffect';
import { Select } from 'src/common/components';
import { Select } from 'src/components';
import { useToasts } from 'src/messageToasts/enhancers/withToasts';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { cacheWrapper } from 'src/utils/cacheWrapper';
@ -36,7 +36,7 @@ interface ColumnSelectProps {
datasetId?: number;
value?: string | string[];
onChange?: (value: string) => void;
mode?: 'multiple' | 'tags';
mode?: 'multiple';
}
const localCache = new Map<string, any>();
@ -128,6 +128,7 @@ export function ColumnSelect({
<Select
mode={mode}
value={mode === 'multiple' ? value || [] : value}
ariaLabel={t('Column select')}
onChange={onChange}
options={options}
placeholder={t('Select a column')}

View File

@ -0,0 +1,125 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useMemo } from 'react';
import rison from 'rison';
import { t, SupersetClient } from '@superset-ui/core';
import { Select } from 'src/components';
import { cacheWrapper } from 'src/utils/cacheWrapper';
import {
ClientErrorObject,
getClientErrorObject,
} from 'src/utils/getClientErrorObject';
import { datasetToSelectOption } from './utils';
const PAGE_SIZE = 50;
const localCache = new Map<string, any>();
const cachedSupersetGet = cacheWrapper(
SupersetClient.get,
localCache,
({ endpoint }) => endpoint || '',
);
interface DatasetSelectProps {
datasetDetails: Record<string, any> | undefined;
datasetId: number;
onChange: (value: number) => void;
value?: { value: number | undefined };
}
const DatasetSelect = ({
datasetDetails,
datasetId,
onChange,
value,
}: DatasetSelectProps) => {
const getErrorMessage = useCallback(
({ error, message }: ClientErrorObject) => {
let errorText = message || error || t('An error has occurred');
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
return errorText;
},
[],
);
// TODO Change offset and limit to page and pageSize
const loadDatasetOptions = async (
search: string,
offset: number,
limit: number, // eslint-disable-line @typescript-eslint/no-unused-vars
) => {
const searchColumn = 'table_name';
const query = rison.encode({
filters: [{ col: searchColumn, opr: 'ct', value: search }],
page: Math.floor(offset / PAGE_SIZE),
page_size: PAGE_SIZE,
order_column: searchColumn,
order_direction: 'asc',
});
return cachedSupersetGet({
endpoint: `/api/v1/dataset/?q=${query}`,
})
.then(response => {
const data: {
label: string;
value: string | number;
}[] = response.json.result
.map(datasetToSelectOption)
.sort((a: { label: string }, b: { label: string }) =>
a.label.localeCompare(b.label),
);
if (!search) {
const found = data.find(element => element.value === datasetId);
if (!found && datasetDetails?.table_name) {
data.push({
label: datasetDetails.table_name,
value: datasetId,
});
}
}
return {
data,
totalCount: response.json.count,
};
})
.catch(async error => {
const errorMessage = getErrorMessage(await getClientErrorObject(error));
throw new Error(errorMessage);
});
};
return (
<Select
ariaLabel={t('Dataset')}
value={value?.value}
pageSize={PAGE_SIZE}
options={loadDatasetOptions}
onChange={onChange}
/>
);
};
const MemoizedSelect = (props: DatasetSelectProps) =>
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => <DatasetSelect {...props} />, []);
export default MemoizedSelect;

View File

@ -27,6 +27,7 @@ import {
styled,
SupersetApiError,
t,
SupersetClient,
} from '@superset-ui/core';
import {
ColumnMeta,
@ -45,15 +46,12 @@ import React, {
import { useSelector } from 'react-redux';
import { FormItem } from 'src/components/Form';
import { Input } from 'src/common/components';
import { Select } from 'src/components/Select';
import SupersetResourceSelect, {
cachedSupersetGet,
} from 'src/components/SupersetResourceSelect';
import { Select } from 'src/components';
import { cacheWrapper } from 'src/utils/cacheWrapper';
import AdhocFilterControl from 'src/explore/components/controls/FilterControl/AdhocFilterControl';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import { addDangerToast } from 'src/messageToasts/actions';
import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import SelectControl from 'src/explore/components/controls/SelectControl';
import Collapse from 'src/components/Collapse';
import { getChartDataRequest } from 'src/chart/chartAction';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
@ -61,6 +59,7 @@ import { waitForAsyncData } from 'src/middleware/asyncEvent';
import Tabs from 'src/components/Tabs';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import { Radio } from 'src/components/Radio';
import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
import {
Chart,
@ -68,14 +67,15 @@ import {
DatasourcesState,
RootState,
} from 'src/dashboard/types';
import Loading from 'src/components/Loading';
import { ColumnSelect } from './ColumnSelect';
import { NativeFiltersForm } from '../types';
import {
datasetToSelectOption,
FILTER_SUPPORTED_TYPES,
hasTemporalColumns,
setNativeFilterFieldValues,
useForceUpdate,
mostUsedDataset,
} from './utils';
import { useBackendFormUpdate, useDefaultValue } from './state';
import { getFormData } from '../../utils';
@ -89,6 +89,7 @@ import {
CASCADING_FILTERS,
getFiltersConfigModalTestId,
} from '../FiltersConfigModal';
import DatasetSelect from './DatasetSelect';
const { TabPane } = Tabs;
@ -282,6 +283,14 @@ const FILTER_TYPE_NAME_MAPPING = {
[t('Group By')]: t('Group by'),
};
const localCache = new Map<string, any>();
const cachedSupersetGet = cacheWrapper(
SupersetClient.get,
localCache,
({ endpoint }) => endpoint || '',
);
/**
* The configuration form for a specific filter.
* Assigns field values to `filters[filterId]` in the form.
@ -323,6 +332,7 @@ const FiltersConfigForm = (
const loadedDatasets = useSelector<RootState, DatasourcesState>(
({ datasources }) => datasources,
);
const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
const doLoadedDatasetsHaveTemporalColumns = useMemo(
@ -481,14 +491,10 @@ const FiltersConfigForm = (
[filterId, forceUpdate, form, formFilter, hasDataset],
);
const defaultDatasetSelectOptions = Object.values(loadedDatasets).map(
datasetToSelectOption,
);
const initialDatasetId =
filterToEdit?.targets[0]?.datasetId ??
(defaultDatasetSelectOptions.length === 1
? defaultDatasetSelectOptions[0].value
: undefined);
mostUsedDataset(loadedDatasets, charts);
const newFormData = getFormData({
datasetId,
groupby: hasColumn ? formFilter?.column : undefined,
@ -515,17 +521,6 @@ const FiltersConfigForm = (
refreshHandler,
]);
const onDatasetSelectError = useCallback(
({ error, message }: ClientErrorObject) => {
let errorText = message || error || t('An error has occurred');
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
addDangerToast(errorText);
},
[],
);
const updateFormValues = useCallback(
(values: any) => setNativeFilterFieldValues(form, filterId, values),
[filterId, form],
@ -548,6 +543,11 @@ const FiltersConfigForm = (
const hasSorting =
typeof filterToEdit?.controlValues?.sortAscending === 'boolean';
let sort = filterToEdit?.controlValues?.sortAscending;
if (typeof formFilter?.controlValues?.sortAscending === 'boolean') {
sort = formFilter.controlValues.sortAscending;
}
const showDefaultValue =
!hasDataset ||
(!isDataDirty && hasFilledDataset) ||
@ -625,6 +625,22 @@ const FiltersConfigForm = (
JSON.stringify(loadedDatasets),
]);
const ParentSelect = ({
value,
...rest
}: {
value?: { value: string | number };
}) => (
<Select
ariaLabel={t('Parent filter')}
placeholder={t('None')}
options={parentFilterOptions}
allowClear
value={value?.value}
{...rest}
/>
);
if (removed) {
return <RemovedFilter onClick={() => restoreFilter(filterId)} />;
}
@ -657,6 +673,7 @@ const FiltersConfigForm = (
{...getFiltersConfigModalTestId('filter-type')}
>
<Select
ariaLabel={t('Filter type')}
options={nativeFilterVizTypes.map(filterType => {
// @ts-ignore
const name = nativeFilterItems[filterType]?.value.name;
@ -680,10 +697,10 @@ const FiltersConfigForm = (
) : (
mappedName || name
),
isDisabled,
disabled: isDisabled,
};
})}
onChange={({ value }: { value: string }) => {
onChange={value => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
defaultDataMask: null,
@ -705,27 +722,25 @@ const FiltersConfigForm = (
]}
{...getFiltersConfigModalTestId('datasource-input')}
>
<SupersetResourceSelect
initialId={initialDatasetId}
resource="dataset"
searchColumn="table_name"
transformItem={datasetToSelectOption}
isMulti={false}
onError={onDatasetSelectError}
defaultOptions={Object.values(loadedDatasets).map(
datasetToSelectOption,
)}
onChange={e => {
// We need reset column when dataset changed
if (datasetId && e?.value !== datasetId) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
column: null,
});
}
forceUpdate();
}}
/>
{!datasetId || !hasColumn || datasetDetails ? (
<DatasetSelect
datasetDetails={datasetDetails}
datasetId={initialDatasetId}
onChange={(value: number) => {
// We need to reset the column when the dataset has changed
if (value !== datasetId) {
setNativeFilterFieldValues(form, filterId, {
dataset: { value },
defaultDataMask: null,
column: null,
});
}
forceUpdate();
}}
/>
) : (
<Loading position="inline-centered" />
)}
</StyledFormItem>
{hasDataset &&
Object.keys(mainControlItems).map(
@ -851,6 +866,7 @@ const FiltersConfigForm = (
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
initialValue={parentFilter}
normalize={value => (value ? { value } : undefined)}
data-test="parent-filter-input"
required
rules={[
@ -860,11 +876,7 @@ const FiltersConfigForm = (
},
]}
>
<Select
placeholder={t('None')}
options={parentFilterOptions}
isClearable
/>
<ParentSelect />
</StyledRowSubFormItem>
</CollapsibleControl>
)}
@ -986,27 +998,17 @@ const FiltersConfigForm = (
'controlValues',
'sortAscending',
]}
initialValue={filterToEdit?.controlValues?.sortAscending}
initialValue={sort}
label={<StyledLabel>{t('Sort type')}</StyledLabel>}
>
<Select
form={form}
filterId={filterId}
name="sortAscending"
options={[
{
value: true,
label: t('Sort ascending'),
},
{
value: false,
label: t('Sort descending'),
},
]}
onChange={({ value }: { value: boolean }) =>
onSortChanged(value)
}
/>
<Radio.Group
onChange={value => {
onSortChanged(value.target.value);
}}
>
<Radio value>{t('Sort ascending')}</Radio>
<Radio value={false}>{t('Sort descending')}</Radio>
</Radio.Group>
</StyledRowFormItem>
{hasMetrics && (
<StyledRowSubFormItem
@ -1025,15 +1027,15 @@ const FiltersConfigForm = (
}
data-test="field-input"
>
<SelectControl
form={form}
filterId={filterId}
<Select
allowClear
ariaLabel={t('Sort metric')}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={(value: string | null): void => {
onChange={value => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,

View File

@ -101,7 +101,6 @@ export default function getControlItemsMap({
/>
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'column']}
initialValue={initColumn}
label={

View File

@ -21,6 +21,7 @@ import { FormInstance } from 'antd/lib/form';
import React from 'react';
import { CustomControlItem, DatasourceMeta } from '@superset-ui/chart-controls';
import { Column, ensureIsArray, GenericDataType } from '@superset-ui/core';
import { DatasourcesState, ChartsState } from 'src/dashboard/types';
const FILTERS_FIELD_NAME = 'filters';
@ -99,3 +100,28 @@ export const doesColumnMatchFilterType = (filterType: string, column: Column) =>
!column.type_generic ||
!(filterType in FILTER_SUPPORTED_TYPES) ||
FILTER_SUPPORTED_TYPES[filterType]?.includes(column.type_generic);
export const mostUsedDataset = (
datasets: DatasourcesState,
charts: ChartsState,
) => {
const map = new Map<string, number>();
let mostUsedDataset = '';
let maxCount = 0;
Object.values(charts).forEach(chart => {
const { formData } = chart;
if (formData) {
const { datasource } = formData;
const count = (map.get(datasource) || 0) + 1;
map.set(datasource, count);
if (count > maxCount) {
maxCount = count;
mostUsedDataset = datasource;
}
}
});
return datasets[mostUsedDataset]?.id;
};

View File

@ -29,6 +29,7 @@ import {
} from 'src/filters/components';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import mockDatasource, { datasourceId } from 'spec/fixtures/mockDatasource';
import chartQueries from 'spec/fixtures/mockChartQueries';
import {
FiltersConfigModal,
FiltersConfigModalProps,
@ -49,19 +50,25 @@ class MainPreset extends Preset {
}
}
const initialStoreState = {
datasources: mockDatasource,
};
const defaultState = () => ({
datasources: { ...mockDatasource },
charts: chartQueries,
});
const storeWithDatasourceWithoutTemporalColumns = {
...initialStoreState,
datasources: {
...initialStoreState.datasources,
[datasourceId]: {
...initialStoreState.datasources[datasourceId],
column_types: [0, 1],
const noTemporalColumnsState = () => {
const state = defaultState();
return {
charts: {
...state.charts,
},
},
datasources: {
...state.datasources,
[datasourceId]: {
...state.datasources[datasourceId],
column_types: [0, 1],
},
},
};
};
fetchMock.get('glob:*/api/v1/dataset/1', {
@ -138,11 +145,8 @@ beforeAll(() => {
new MainPreset().register();
});
function defaultRender(
overrides?: Partial<FiltersConfigModalProps>,
initialState = initialStoreState,
) {
return render(<FiltersConfigModal {...props} {...overrides} />, {
function defaultRender(initialState = defaultState()) {
return render(<FiltersConfigModal {...props} />, {
useRedux: true,
initialState,
});
@ -178,11 +182,13 @@ test('renders a value filter type', () => {
expect(getCheckbox(MULTIPLE_REGEX)).toBeChecked();
});
test('renders a numerical range filter type', () => {
test('renders a numerical range filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(NUMERICAL_RANGE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(NUMERICAL_RANGE_REGEX)));
userEvent.click(screen.getByText(ADVANCED_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
@ -202,11 +208,12 @@ test('renders a numerical range filter type', () => {
expect(queryCheckbox(SORT_REGEX)).not.toBeInTheDocument();
});
test('renders a time range filter type', () => {
test('renders a time range filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_RANGE_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_RANGE_REGEX)));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@ -218,11 +225,12 @@ test('renders a time range filter type', () => {
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('renders a time column filter type', () => {
test('renders a time column filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_COLUMN_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_COLUMN_REGEX)));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@ -234,11 +242,12 @@ test('renders a time column filter type', () => {
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('renders a time grain filter type', () => {
test('renders a time grain filter type', async () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_GRAIN_REGEX));
await waitFor(() => userEvent.click(screen.getByText(TIME_GRAIN_REGEX)));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
@ -250,18 +259,19 @@ test('renders a time grain filter type', () => {
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('render time filter types as disabled if there are no temporal columns in the dataset', () => {
defaultRender(undefined, storeWithDatasourceWithoutTemporalColumns);
test('render time filter types as disabled if there are no temporal columns in the dataset', async () => {
defaultRender(noTemporalColumnsState());
userEvent.click(screen.getByText(VALUE_REGEX));
expect(screen.getByText(TIME_RANGE_REGEX).closest('div')).toHaveClass(
'Select__option--is-disabled',
);
expect(screen.getByText(TIME_GRAIN_REGEX).closest('div')).toHaveClass(
'Select__option--is-disabled',
);
expect(screen.getByText(TIME_COLUMN_REGEX).closest('div')).toHaveClass(
'Select__option--is-disabled',
);
const timeRange = await screen.findByText(TIME_RANGE_REGEX);
const timeGrain = await screen.findByText(TIME_GRAIN_REGEX);
const timeColumn = await screen.findByText(TIME_COLUMN_REGEX);
const disabledClass = '.ant-select-item-option-disabled';
expect(timeRange.closest(disabledClass)).toBeInTheDocument();
expect(timeGrain.closest(disabledClass)).toBeInTheDocument();
expect(timeColumn.closest(disabledClass)).toBeInTheDocument();
});
test('validates the name', async () => {
@ -278,7 +288,7 @@ test('validates the column', async () => {
// eslint-disable-next-line jest/no-disabled-tests
test.skip('validates the default value', async () => {
defaultRender(undefined, initialStoreState);
defaultRender(noTemporalColumnsState());
expect(await screen.findByText('birth_names')).toBeInTheDocument();
userEvent.type(screen.getByRole('combobox'), `Column A${specialChars.enter}`);
userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
@ -309,12 +319,14 @@ test('validates the pre-filter value', async () => {
});
test("doesn't render time range pre-filter if there are no temporal columns in datasource", async () => {
defaultRender(undefined, storeWithDatasourceWithoutTemporalColumns);
defaultRender(noTemporalColumnsState());
userEvent.click(screen.getByText(ADVANCED_REGEX));
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
expect(
screen.queryByText(TIME_RANGE_PREFILTER_REGEX),
).not.toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByText(TIME_RANGE_PREFILTER_REGEX),
).not.toBeInTheDocument(),
);
});
/*
TODO

View File

@ -18,13 +18,11 @@
*/
import { ensureIsArray, ExtraFormData, t, tn } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { Select } from 'src/components';
import { FilterPluginStyle, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterGroupByProps } from './types';
const { Option } = Select;
export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
const {
data,
@ -90,13 +88,22 @@ export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
</StatusMessage>
);
}
const options = columns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const { column_name: columnName, verbose_name: verboseName } = row;
return {
label: verboseName ?? columnName,
value: columnName,
};
},
);
return (
<Styles height={height} width={width}>
<FilterPluginStyle height={height} width={width}>
<StyledFormItem
validateStatus={filterState.validateStatus}
{...formItemData}
>
<StyledSelect
<Select
allowClear
value={value}
placeholder={placeholderText}
@ -106,22 +113,9 @@ export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
>
{columns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const {
column_name: columnName,
verbose_name: verboseName,
} = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
options={options}
/>
</StyledFormItem>
</Styles>
</FilterPluginStyle>
);
}

View File

@ -27,7 +27,7 @@ import { Slider } from 'src/common/components';
import { rgba } from 'emotion-rgba';
import { FormItemProps } from 'antd/lib/form';
import { PluginFilterRangeProps } from './types';
import { StatusMessage, StyledFormItem, Styles } from '../common';
import { StatusMessage, StyledFormItem, FilterPluginStyle } from '../common';
import { getRangeExtraFormData } from '../../utils';
const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>`
@ -169,7 +169,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
);
}
return (
<Styles height={height} width={width}>
<FilterPluginStyle height={height} width={width}>
{Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
<h4>{t('Chosen non-numeric column')}</h4>
) : (
@ -196,6 +196,6 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
</Wrapper>
</StyledFormItem>
)}
</Styles>
</FilterPluginStyle>
);
}

View File

@ -20,7 +20,6 @@
import {
AppSection,
DataMask,
DataRecord,
ensureIsArray,
ExtraFormData,
GenericDataType,
@ -29,27 +28,16 @@ import {
t,
tn,
} from '@superset-ui/core';
import React, {
RefObject,
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { Select } from 'src/common/components';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { Select } from 'src/components';
import debounce from 'lodash/debounce';
import { SLOW_DEBOUNCE } from 'src/constants';
import { useImmerReducer } from 'use-immer';
import Icons from 'src/components/Icons';
import { usePrevious } from 'src/common/hooks/usePrevious';
import { FormItemProps } from 'antd/lib/form';
import { PluginFilterSelectProps, SelectValue } from './types';
import { StyledFormItem, StyledSelect, Styles, StatusMessage } from '../common';
import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
const { Option } = Select;
type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject }
| {
@ -107,25 +95,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const groupby = ensureIsArray<string>(formData.groupby);
const [col] = groupby;
const [initialColtypeMap] = useState(coltypeMap);
const [selectedValues, setSelectedValues] = useState<SelectValue>(
filterState.value,
);
const sortedData = useMemo(() => {
const firstData: DataRecord[] = [];
const restData: DataRecord[] = [];
data.forEach(row => {
// @ts-ignore
if (selectedValues?.includes(row[col])) {
firstData.push(row);
} else {
restData.push(row);
}
});
return [...firstData, ...restData];
}, [col, selectedValues, data]);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const wasDropdownVisible = usePrevious(isDropdownVisible);
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
extraFormData: {},
filterState,
@ -171,9 +140,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
);
useEffect(() => {
if (!isDropdownVisible) {
setSelectedValues(filterState.value);
}
updateDataMask(filterState.value);
}, [JSON.stringify(filterState.value)]);
@ -197,11 +163,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
if (searchAllOptions) {
debouncedOwnStateFunc(val);
}
setCurrentSuggestionSearch(val);
};
const clearSuggestionSearch = () => {
setCurrentSuggestionSearch('');
if (searchAllOptions) {
dispatchDataMask({
type: 'ownState',
@ -216,13 +180,16 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const handleBlur = () => {
clearSuggestionSearch();
unsetFocusedFilter();
setSelectedValues(filterState.value);
};
const datatype: GenericDataType = coltypeMap[col];
const labelFormatter = getDataRecordFormatter({
timeFormatter: smartDateDetailedFormatter,
});
const labelFormatter = useMemo(
() =>
getDataRecordFormatter({
timeFormatter: smartDateDetailedFormatter,
}),
[],
);
const handleChange = (value?: SelectValue | number | string) => {
const values = ensureIsArray(value);
@ -271,7 +238,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
data.length === 0
? t('No data')
: tn('%s option', '%s options', data.length, data.length);
const Icon = inverseSelection ? Icons.StopOutlined : Icons.CheckOutlined;
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
@ -282,32 +248,36 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
);
}
const options = useMemo(() => {
const options: { label: string; value: string | number }[] = [];
data.forEach(row => {
const [value] = groupby.map(col => row[col]);
options.push({
label: labelFormatter(value, datatype),
value: typeof value === 'number' ? value : String(value),
});
});
return options;
}, [data, datatype, groupby, labelFormatter]);
return (
<Styles height={height} width={width}>
<FilterPluginStyle height={height} width={width}>
<StyledFormItem
validateStatus={filterState.validateStatus}
{...formItemData}
>
<StyledSelect
<Select
allowClear
allowNewOptions
// @ts-ignore
value={filterState.value || []}
disabled={isDisabled}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : undefined}
mode={multiSelect ? 'multiple' : 'single'}
placeholder={placeholderText}
onSearch={searchWrapper}
onSelect={clearSuggestionSearch}
onBlur={handleBlur}
onDropdownVisibleChange={setIsDropdownVisible}
dropdownRender={(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (isDropdownVisible && !wasDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return originNode;
}}
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
// @ts-ignore
@ -315,27 +285,10 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
ref={inputRef}
loading={isRefreshing}
maxTagCount={5}
menuItemSelectedIcon={<Icon iconSize="m" />}
>
{sortedData.map(row => {
const [value] = groupby.map(col => row[col]);
return (
// @ts-ignore
<Option key={`${value}`} value={value}>
{labelFormatter(value, datatype)}
</Option>
);
})}
{currentSuggestionSearch &&
!ensureIsArray(filterState.value).some(
suggestion => suggestion === currentSuggestionSearch,
) && (
<Option value={currentSuggestionSearch}>
{`${t('Create "%s"', currentSuggestionSearch)}`}
</Option>
)}
</StyledSelect>
invertSelection={inverseSelection}
options={options}
/>
</StyledFormItem>
</Styles>
</FilterPluginStyle>
);
}

View File

@ -21,9 +21,9 @@ import React, { useEffect } from 'react';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import { NO_TIME_RANGE } from 'src/explore/constants';
import { PluginFilterTimeProps } from './types';
import { Styles } from '../common';
import { FilterPluginStyle } from '../common';
const TimeFilterStyles = styled(Styles)`
const TimeFilterStyles = styled(FilterPluginStyle)`
overflow-x: auto;
`;

View File

@ -24,13 +24,11 @@ import {
tn,
} from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Select } from 'src/components';
import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { FilterPluginStyle, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterTimeColumnProps } from './types';
const { Option } = Select;
export default function PluginFilterTimeColumn(
props: PluginFilterTimeColumnProps,
) {
@ -91,13 +89,24 @@ export default function PluginFilterTimeColumn(
</StatusMessage>
);
}
const options = timeColumns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const { column_name: columnName, verbose_name: verboseName } = row;
return {
label: verboseName ?? columnName,
value: columnName,
};
},
);
return (
<Styles height={height} width={width}>
<FilterPluginStyle height={height} width={width}>
<StyledFormItem
validateStatus={filterState.validateStatus}
{...formItemData}
>
<StyledSelect
<Select
allowClear
value={value}
placeholder={placeholderText}
@ -106,22 +115,9 @@ export default function PluginFilterTimeColumn(
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
ref={inputRef}
>
{timeColumns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const {
column_name: columnName,
verbose_name: verboseName,
} = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
options={options}
/>
</StyledFormItem>
</Styles>
</FilterPluginStyle>
);
}

View File

@ -24,13 +24,11 @@ import {
tn,
} from '@superset-ui/core';
import React, { useEffect, useMemo, useState } from 'react';
import { Select } from 'src/common/components';
import { Select } from 'src/components';
import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { FilterPluginStyle, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterTimeGrainProps } from './types';
const { Option } = Select;
export default function PluginFilterTimegrain(
props: PluginFilterTimeGrainProps,
) {
@ -101,13 +99,24 @@ export default function PluginFilterTimegrain(
</StatusMessage>
);
}
const options = (data || []).map(
(row: { name: string; duration: string }) => {
const { name, duration } = row;
return {
label: name,
value: duration,
};
},
);
return (
<Styles height={height} width={width}>
<FilterPluginStyle height={height} width={width}>
<StyledFormItem
validateStatus={filterState.validateStatus}
{...formItemData}
>
<StyledSelect
<Select
allowClear
value={value}
placeholder={placeholderText}
@ -116,17 +125,9 @@ export default function PluginFilterTimegrain(
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
ref={inputRef}
>
{(data || []).map((row: { name: string; duration: string }) => {
const { name, duration } = row;
return (
<Option key={duration} value={duration}>
{name}
</Option>
);
})}
</StyledSelect>
options={options}
/>
</StyledFormItem>
</Styles>
</FilterPluginStyle>
);
}

View File

@ -17,19 +17,14 @@
* under the License.
*/
import { styled } from '@superset-ui/core';
import { Select } from 'src/common/components';
import { PluginFilterStylesProps } from './types';
import FormItem from '../../components/Form/FormItem';
export const Styles = styled.div<PluginFilterStylesProps>`
export const FilterPluginStyle = styled.div<PluginFilterStylesProps>`
min-height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
`;
export const StyledSelect = styled(Select)`
width: 100%;
`;
export const StyledFormItem = styled(FormItem)`
&.ant-row.ant-form-item {
margin: 0;