chore: Improves the native filters UI/UX - iteration 1 (#14714)

This commit is contained in:
Michael S. Molina 2021-05-20 13:52:01 -03:00 committed by GitHub
parent 8cfebc0c66
commit d924223950
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 310 additions and 263 deletions

View File

@ -18,7 +18,12 @@
*/
import React from 'react';
import { Provider } from 'react-redux';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
fireEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { mockStoreWithChartsInTabsAndRoot } from 'spec/fixtures/mockStore';
import { Form, FormInstance } from 'src/common/components';
import { NativeFiltersForm } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
@ -34,7 +39,7 @@ describe('FilterScope', () => {
save,
};
const MockModal = ({ scope }: { scope: object | undefined }) => {
const MockModal = ({ scope }: { scope?: object }) => {
const [newForm] = Form.useForm<NativeFiltersForm>();
form = newForm;
if (scope) {
@ -55,56 +60,66 @@ describe('FilterScope', () => {
);
};
const getWrapper = (scope?: object) => {
render(<MockModal scope={scope} />);
};
const getTreeSwitcher = (order = 0) =>
document.querySelectorAll('.ant-tree-switcher')[order];
it('renders "apply to all" filter scope', () => {
getWrapper();
expect(screen.queryByRole('tree')).toBe(null);
render(<MockModal />);
expect(screen.queryByRole('tree')).not.toBeInTheDocument();
});
it('select tree values with 1 excluded', () => {
getWrapper();
it('select tree values with 1 excluded', async () => {
render(<MockModal />);
fireEvent.click(screen.getByText('Scoping'));
fireEvent.click(screen.getByLabelText('Apply to specific panels'));
expect(screen.getByRole('tree')).not.toBe(null);
fireEvent.click(getTreeSwitcher(2));
fireEvent.click(screen.getByText('CHART_ID2'));
expect(form.getFieldValue('filters')?.[mockedProps.filterId].scope).toEqual(
{
await waitFor(() =>
expect(
form.getFieldValue('filters')?.[mockedProps.filterId].scope,
).toEqual({
excluded: [20],
rootPath: ['ROOT_ID'],
},
}),
);
});
it('select 1 value only', () => {
getWrapper();
it('select 1 value only', async () => {
render(<MockModal />);
fireEvent.click(screen.getByText('Scoping'));
fireEvent.click(screen.getByLabelText('Apply to specific panels'));
expect(screen.getByRole('tree')).not.toBe(null);
fireEvent.click(getTreeSwitcher(2));
fireEvent.click(screen.getByText('CHART_ID2'));
fireEvent.click(screen.getByText('tab1'));
expect(form.getFieldValue('filters')?.[mockedProps.filterId].scope).toEqual(
{
await waitFor(() =>
expect(
form.getFieldValue('filters')?.[mockedProps.filterId].scope,
).toEqual({
excluded: [18, 20],
rootPath: ['ROOT_ID'],
},
}),
);
});
it('correct init tree with values', () => {
getWrapper({
rootPath: ['TAB_ID'],
excluded: [],
});
fireEvent.click(screen.getByLabelText('Apply to specific panels'));
expect(screen.getByRole('tree')).not.toBe(null);
expect(document.querySelectorAll('.ant-tree-checkbox-checked').length).toBe(
1,
it('correct init tree with values', async () => {
render(
<MockModal
scope={{
rootPath: ['TAB_ID'],
excluded: [],
}}
/>,
);
fireEvent.click(screen.getByText('Scoping'));
fireEvent.click(screen.getByLabelText('Apply to specific panels'));
await waitFor(() => {
expect(screen.getByRole('tree')).toBeInTheDocument();
expect(
document.querySelectorAll('.ant-tree-checkbox-checked').length,
).toBe(1);
});
});
});

View File

@ -64,7 +64,6 @@ const FilterScope: FC<FilterScopeProps> = ({
return (
<Wrapper>
<Typography.Title level={5}>{t('Scoping')}</Typography.Title>
<CleanFormItem
name={[...pathToFormValue, 'scoping']}
initialValue={initialScoping}

View File

@ -34,7 +34,7 @@ import {
import { FormInstance } from 'antd/lib/form';
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Checkbox, Form, Input, Typography } from 'src/common/components';
import { Checkbox, Form, Input } from 'src/common/components';
import { Select } from 'src/components/Select';
import SupersetResourceSelect, {
cachedSupersetGet,
@ -48,6 +48,7 @@ import Button from 'src/components/Button';
import { getChartDataRequest } from 'src/chart/chartAction';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import Tabs from 'src/components/Tabs';
import { ColumnSelect } from './ColumnSelect';
import { NativeFiltersForm } from '../types';
import {
@ -67,6 +68,8 @@ import {
getFiltersConfigModalTestId,
} from '../FiltersConfigModal';
const { TabPane } = Tabs;
const StyledContainer = styled.div`
display: flex;
flex-direction: row-reverse;
@ -92,6 +95,17 @@ const CleanFormItem = styled(Form.Item)`
margin-bottom: 0;
`;
const FilterTabs = {
configuration: {
key: 'configuration',
name: t('Configuration'),
},
scoping: {
key: 'scoping',
name: t('Scoping'),
},
};
export interface FiltersConfigFormProps {
filterId: string;
filterToEdit?: Filter;
@ -264,254 +278,271 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
return (
<>
<Typography.Title level={5}>{t('Settings')}</Typography.Title>
<StyledContainer>
<StyledFormItem
name={['filters', filterId, 'name']}
label={<StyledLabel>{t('Filter name')}</StyledLabel>}
initialValue={filterToEdit?.name}
rules={[{ required: !removed, message: t('Name is required') }]}
<Tabs defaultActiveKey={FilterTabs.configuration.key} centered>
<TabPane
tab={FilterTabs.configuration.name}
key={FilterTabs.configuration.key}
forceRender
>
<Input {...getFiltersConfigModalTestId('name-input')} />
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'filterType']}
rules={[{ required: !removed, message: t('Name is required') }]}
initialValue={filterToEdit?.filterType || 'filter_select'}
label={<StyledLabel>{t('Filter Type')}</StyledLabel>}
{...getFiltersConfigModalTestId('filter-type')}
>
<Select
options={nativeFilterVizTypes.map(filterType => ({
value: filterType,
// @ts-ignore
label: nativeFilterItems[filterType]?.value.name,
}))}
onChange={({ value }: { value: string }) => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
defaultDataMask: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
</StyledContainer>
{hasDataset && (
<>
<StyledFormItem
name={['filters', filterId, 'dataset']}
initialValue={{ value: initialDatasetId }}
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
rules={[{ required: !removed, message: t('Dataset is required') }]}
{...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();
}}
/>
</StyledFormItem>
{hasColumn && (
<StyledContainer>
<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={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[{ required: !removed, message: t('Field is required') }]}
data-test="field-input"
name={['filters', filterId, 'name']}
label={<StyledLabel>{t('Filter name')}</StyledLabel>}
initialValue={filterToEdit?.name}
rules={[{ required: !removed, message: t('Name is required') }]}
>
<ColumnSelect
form={form}
filterId={filterId}
datasetId={datasetId}
onChange={() => {
// We need reset default value when when column changed
<Input {...getFiltersConfigModalTestId('name-input')} />
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'filterType']}
rules={[{ required: !removed, message: t('Name is required') }]}
initialValue={filterToEdit?.filterType || 'filter_select'}
label={<StyledLabel>{t('Filter Type')}</StyledLabel>}
{...getFiltersConfigModalTestId('filter-type')}
>
<Select
options={nativeFilterVizTypes.map(filterType => ({
value: filterType,
// @ts-ignore
label: nativeFilterItems[filterType]?.value.name,
}))}
onChange={({ value }: { value: string }) => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
defaultDataMask: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
)}
{hasAdditionalFilters && (
</StyledContainer>
{hasDataset && (
<>
<StyledFormItem
name={['filters', filterId, 'adhoc_filters']}
initialValue={filterToEdit?.adhoc_filters}
name={['filters', filterId, 'dataset']}
initialValue={{ value: initialDatasetId }}
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
rules={[
{ required: !removed, message: t('Dataset is required') },
]}
{...getFiltersConfigModalTestId('datasource-input')}
>
<AdhocFilterControl
columns={
datasetDetails?.columns?.filter(
(c: ColumnMeta) => c.filterable,
) || []
}
savedMetrics={datasetDetails?.metrics || []}
datasource={datasetDetails}
onChange={(filters: AdhocFilter[]) => {
setNativeFilterFieldValues(form, filterId, {
adhoc_filters: filters,
});
forceUpdate();
}}
label={<StyledLabel>{t('Adhoc filters')}</StyledLabel>}
/>
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={filterToEdit?.time_range || 'No filter'}
>
<DateFilterControl
name="time_range"
onChange={timeRange => {
setNativeFilterFieldValues(form, filterId, {
time_range: timeRange,
});
<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();
}}
/>
</StyledFormItem>
{hasColumn && (
<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={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[
{ required: !removed, message: t('Field is required') },
]}
data-test="field-input"
>
<ColumnSelect
form={form}
filterId={filterId}
datasetId={datasetId}
onChange={() => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
)}
{hasAdditionalFilters && (
<>
<StyledFormItem
name={['filters', filterId, 'adhoc_filters']}
initialValue={filterToEdit?.adhoc_filters}
>
<AdhocFilterControl
columns={
datasetDetails?.columns?.filter(
(c: ColumnMeta) => c.filterable,
) || []
}
savedMetrics={datasetDetails?.metrics || []}
datasource={datasetDetails}
onChange={(filters: AdhocFilter[]) => {
setNativeFilterFieldValues(form, filterId, {
adhoc_filters: filters,
});
forceUpdate();
}}
label={<StyledLabel>{t('Adhoc filters')}</StyledLabel>}
/>
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={filterToEdit?.time_range || 'No filter'}
>
<DateFilterControl
name="time_range"
onChange={timeRange => {
setNativeFilterFieldValues(form, filterId, {
time_range: timeRange,
});
forceUpdate();
}}
/>
</StyledFormItem>
</>
)}
</>
)}
</>
)}
{hasFilledDataset && (
<CleanFormItem
name={['filters', filterId, 'defaultValueFormData']}
hidden
initialValue={newFormData}
/>
)}
<CleanFormItem
name={['filters', filterId, 'defaultValueQueriesData']}
hidden
initialValue={null}
/>
{isCascadingFilter && (
<StyledFormItem
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
initialValue={parentFilterOptions.find(
({ value }) => value === filterToEdit?.cascadeParentIds[0],
)}
data-test="parent-filter-input"
>
<Select
placeholder={t('None')}
options={parentFilterOptions}
isClearable
/>
</StyledFormItem>
)}
<StyledContainer>
<StyledFormItem className="bottom" label={<StyledLabel />}>
{hasDataset && hasFilledDataset && (
<Button onClick={refreshHandler}>
{isDataDirty ? t('Populate') : t('Refresh')}
</Button>
)}
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'defaultDataMask']}
initialValue={filterToEdit?.defaultDataMask}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
>
{showDefaultValue ? (
<DefaultValue
setDataMask={dataMask => {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
});
forceUpdate();
}}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
{hasFilledDataset && (
<CleanFormItem
name={['filters', filterId, 'defaultValueFormData']}
hidden
initialValue={newFormData}
/>
) : hasFilledDataset ? (
t('Click "Populate" to get "Default Value" ->')
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledFormItem>
</StyledContainer>
<StyledCheckboxFormItem
name={['filters', filterId, 'isInstant']}
initialValue={filterToEdit?.isInstant || false}
valuePropName="checked"
colon={false}
>
<Checkbox data-test="apply-changes-instantly-checkbox">
{t('Apply changes instantly')}
</Checkbox>
</StyledCheckboxFormItem>
<ControlItems
disabled={!showDefaultValue}
filterToEdit={filterToEdit}
formFilter={formFilter}
filterId={filterId}
form={form}
forceUpdate={forceUpdate}
/>
{hasMetrics && (
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'sortMetric']}
initialValue={filterToEdit?.sortMetric}
label={<StyledLabel>{t('Sort Metric')}</StyledLabel>}
data-test="field-input"
>
<SelectControl
form={form}
filterId={filterId}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={(value: string | null): void => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,
});
forceUpdate();
}
}}
<CleanFormItem
name={['filters', filterId, 'defaultValueQueriesData']}
hidden
initialValue={null}
/>
</StyledFormItem>
)}
<FilterScope
updateFormValues={(values: any) =>
setNativeFilterFieldValues(form, filterId, values)
}
pathToFormValue={['filters', filterId]}
forceUpdate={forceUpdate}
scope={filterToEdit?.scope}
formScope={formFilter?.scope}
formScoping={formFilter?.scoping}
/>
{isCascadingFilter && (
<StyledFormItem
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
initialValue={parentFilterOptions.find(
({ value }) => value === filterToEdit?.cascadeParentIds[0],
)}
data-test="parent-filter-input"
>
<Select
placeholder={t('None')}
options={parentFilterOptions}
isClearable
/>
</StyledFormItem>
)}
<StyledContainer>
<StyledFormItem className="bottom" label={<StyledLabel />}>
{hasDataset && hasFilledDataset && (
<Button onClick={refreshHandler}>
{isDataDirty ? t('Populate') : t('Refresh')}
</Button>
)}
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'defaultDataMask']}
initialValue={filterToEdit?.defaultDataMask}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
>
{showDefaultValue ? (
<DefaultValue
setDataMask={dataMask => {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
});
forceUpdate();
}}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
/>
) : hasFilledDataset ? (
t('Click "Populate" to get "Default Value" ->')
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledFormItem>
</StyledContainer>
<StyledCheckboxFormItem
name={['filters', filterId, 'isInstant']}
initialValue={filterToEdit?.isInstant || false}
valuePropName="checked"
colon={false}
>
<Checkbox data-test="apply-changes-instantly-checkbox">
{t('Apply changes instantly')}
</Checkbox>
</StyledCheckboxFormItem>
<ControlItems
disabled={!showDefaultValue}
filterToEdit={filterToEdit}
formFilter={formFilter}
filterId={filterId}
form={form}
forceUpdate={forceUpdate}
/>
{hasMetrics && (
<StyledFormItem
// don't show the column select unless we have a dataset
// style={{ display: datasetId == null ? undefined : 'none' }}
name={['filters', filterId, 'sortMetric']}
initialValue={filterToEdit?.sortMetric}
label={<StyledLabel>{t('Sort Metric')}</StyledLabel>}
data-test="field-input"
>
<SelectControl
form={form}
filterId={filterId}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={(value: string | null): void => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,
});
forceUpdate();
}
}}
/>
</StyledFormItem>
)}
</TabPane>
<TabPane
tab={FilterTabs.scoping.name}
key={FilterTabs.scoping.key}
forceRender
>
<FilterScope
updateFormValues={(values: any) =>
setNativeFilterFieldValues(form, filterId, values)
}
pathToFormValue={['filters', filterId]}
forceUpdate={forceUpdate}
scope={filterToEdit?.scope}
formScope={formFilter?.scope}
formScoping={formFilter?.scoping}
/>
</TabPane>
</Tabs>
</>
);
};

View File

@ -39,6 +39,7 @@ import { useOpenModal, useRemoveCurrentFilter } from './state';
export const StyledModalBody = styled.div`
display: flex;
height: 500px;
flex-direction: row;
.filters-list {
width: ${({ theme }) => theme.gridUnit * 50}px;

View File

@ -36,6 +36,7 @@ export function CancelConfirmationAlert({
}: ConfirmationAlertProps) {
return (
<Alert
closable={false}
type="warning"
key="alert"
message={title}
@ -46,14 +47,6 @@ export function CancelConfirmationAlert({
description={children}
action={
<div css={{ display: 'flex' }}>
<Button
key="submit"
buttonSize="small"
buttonStyle="primary"
onClick={onConfirm}
>
{t('Yes, cancel')}
</Button>
<Button
key="cancel"
buttonSize="small"
@ -62,6 +55,14 @@ export function CancelConfirmationAlert({
>
{t('Keep editing')}
</Button>
<Button
key="submit"
buttonSize="small"
buttonStyle="primary"
onClick={onConfirm}
>
{t('Yes, cancel')}
</Button>
</div>
}
/>