refactor(native-filters): Refactor filters config modal module (#13268)

* refactor(native-filters): Refactor filters config modal module

* fix: fix import

* lint: fix import
This commit is contained in:
simcha90 2021-02-22 09:23:38 +02:00 committed by GitHub
parent e8d50356a2
commit 9b5e66b728
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1265 additions and 896 deletions

View File

@ -21,15 +21,15 @@ import { Provider } from 'react-redux';
import { render, screen, fireEvent } 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/FilterConfigModal/types';
import FilterConfigForm from 'src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigForm';
import { NativeFiltersForm } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import FiltersConfigForm from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm';
describe('FilterScope', () => {
const save = jest.fn();
let form: FormInstance<NativeFiltersForm>;
const mockedProps = {
filterId: 'DefaultFilterId',
restore: jest.fn(),
restoreFilter: jest.fn(),
parentFilters: [],
save,
};
@ -49,7 +49,7 @@ describe('FilterScope', () => {
return (
<Provider store={mockStoreWithChartsInTabsAndRoot}>
<Form form={form}>
<FilterConfigForm form={form} {...mockedProps} />
<FiltersConfigForm form={form} {...mockedProps} />
</Form>
</Provider>
);

View File

@ -21,10 +21,10 @@ import { styledMount as mount } from 'spec/helpers/theming';
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import { Provider } from 'react-redux';
import { FilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigModal';
import Alert from 'src/components/Alert';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { mockStore } from 'spec/fixtures/mockStore';
import { FiltersConfigModal } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
Object.defineProperty(window, 'matchMedia', {
writable: true,
@ -61,18 +61,18 @@ describe('FiltersConfigModal', () => {
initialFilterId: 'DefaultsID',
createNewOnOpen: true,
onCancel: jest.fn(),
save: jest.fn(),
onSave: jest.fn(),
};
function setup(overridesProps?: any) {
return mount(
<Provider store={mockStore}>
<FilterConfigModal {...mockedProps} {...overridesProps} />
<FiltersConfigModal {...mockedProps} {...overridesProps} />
</Provider>,
);
}
it('should be a valid react element', () => {
expect(React.isValidElement(<FilterConfigModal {...mockedProps} />)).toBe(
expect(React.isValidElement(<FiltersConfigModal {...mockedProps} />)).toBe(
true,
);
});

View File

@ -19,17 +19,13 @@
import { ExtraFormData, makeApi } from '@superset-ui/core';
import { Dispatch } from 'redux';
import {
Filter,
FilterConfiguration,
} from 'src/dashboard/components/nativeFilters/types';
import { FilterConfiguration } from 'src/dashboard/components/nativeFilters/types';
import { dashboardInfoChanged } from './dashboardInfo';
import {
CurrentFilterState,
FiltersSet,
NativeFilterState,
} from '../reducers/types';
import { SelectedValues } from '../components/nativeFilters/FilterConfigModal/types';
export const SET_FILTER_CONFIG_BEGIN = 'SET_FILTER_CONFIG_BEGIN';
export interface SetFilterConfigBegin {
@ -63,14 +59,6 @@ export interface SetFilterSetsConfigFail {
filterSetsConfig: FiltersSet[];
}
export const SET_FILTER_STATE = 'SET_FILTER_STATE';
export interface SetFilterState {
type: typeof SET_FILTER_STATE;
selectedValues: SelectedValues;
filter: Filter;
filters: FilterConfiguration;
}
interface DashboardInfo {
id: number;
json_metadata: string;
@ -176,18 +164,6 @@ export interface SetFiltersState {
filtersState: NativeFilterState;
}
export function setFilterState(
selectedValues: SelectedValues,
filter: Filter,
filters: FilterConfiguration,
) {
return {
type: SET_FILTER_STATE,
selectedValues,
filter,
filters,
};
}
/**
* Sets the selected option(s) for a given filter
* @param filterId the id of the native filter
@ -238,5 +214,4 @@ export type AnyFilterAction =
| SetFilterSetsConfigFail
| SetFiltersState
| SetExtraFormData
| SaveFilterSets
| SetFilterState;
| SaveFilterSets;

View File

@ -19,8 +19,8 @@
import { TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { NativeFiltersState } from 'src/dashboard/reducers/types';
import { getTreeCheckedItems } from '../nativeFilters/FilterConfigModal/utils';
import { Layout } from '../../types';
import { getTreeCheckedItems } from '../nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils';
export enum IndicatorStatus {
Unset = 'UNSET',

View File

@ -19,8 +19,8 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
import { FilterConfigModal } from '../FilterConfigModal/FilterConfigModal';
import { FilterConfiguration } from '../types';
import { FiltersConfigModal } from '../FiltersConfigModal/FiltersConfigModal';
export interface FCBProps {
createNewOnOpen?: boolean;
@ -46,9 +46,9 @@ export const FilterConfigurationLink: React.FC<FCBProps> = ({
<>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={() => setOpen(true)}>{children}</div>
<FilterConfigModal
<FiltersConfigModal
isOpen={isOpen}
save={submit}
onSave={submit}
onCancel={close}
createNewOnOpen={createNewOnOpen}
/>

View File

@ -1,598 +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, { useCallback, useEffect, useMemo, useState } from 'react';
import { findLastIndex, uniq } from 'lodash';
import shortid from 'shortid';
import { PlusOutlined } from '@ant-design/icons';
import Icon from 'src/components/Icon';
import { styled, t } from '@superset-ui/core';
import { Form } from 'src/common/components';
import { StyledModal } from 'src/common/components/Modal';
import Button from 'src/components/Button';
import { LineEditableTabs } from 'src/common/components/Tabs';
import { usePrevious } from 'src/common/hooks/usePrevious';
import ErrorBoundary from 'src/components/ErrorBoundary';
import { useFilterConfigMap, useFilterConfiguration } from '../state';
import FilterConfigForm from './FilterConfigForm';
import { NativeFiltersForm } from './types';
import { CancelConfirmationAlert } from './CancelConfirmationAlert';
import { FilterConfiguration } from '../types';
// how long to show the "undo" button when removing a filter
const REMOVAL_DELAY_SECS = 5;
const FILTER_WIDTH = 200;
const StyledModalBody = styled.div`
display: flex;
flex-direction: row;
.filters-list {
width: ${({ theme }) => theme.gridUnit * 50}px;
overflow: auto;
}
`;
const StyledForm = styled(Form)`
width: 100%;
`;
const StyledSpan = styled.span`
cursor: pointer;
color: ${({ theme }) => theme.colors.primary.dark1};
&:hover {
color: ${({ theme }) => theme.colors.primary.dark2};
}
`;
const FilterTabs = styled(LineEditableTabs)`
// extra selector specificity:
&.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab {
min-width: ${FILTER_WIDTH}px;
margin: 0 ${({ theme }) => theme.gridUnit * 2}px 0 0;
padding: ${({ theme }) => theme.gridUnit}px
${({ theme }) => theme.gridUnit * 2}px;
&:hover,
&-active {
color: ${({ theme }) => theme.colors.grayscale.dark1};
border-radius: ${({ theme }) => theme.borderRadius}px;
background-color: ${({ theme }) => theme.colors.secondary.light4};
.ant-tabs-tab-remove > svg {
color: ${({ theme }) => theme.colors.grayscale.base};
transition: all 0.3s;
}
}
}
.ant-tabs-tab-btn {
text-align: left;
justify-content: space-between;
text-transform: unset;
}
`;
const FilterTabTitle = styled.span`
transition: color ${({ theme }) => theme.transitionTiming}s;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
@keyframes tabTitleRemovalAnimation {
0%,
90% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
&.removed {
color: ${({ theme }) => theme.colors.warning.dark1};
transform-origin: top;
animation-name: tabTitleRemovalAnimation;
animation-duration: ${REMOVAL_DELAY_SECS}s;
}
`;
const StyledFilterTitle = styled.span`
width: ${FILTER_WIDTH}px;
white-space: normal;
color: ${({ theme }) => theme.colors.grayscale.dark1};
`;
const StyledAddFilterBox = styled.div`
color: ${({ theme }) => theme.colors.primary.dark1};
text-align: left;
padding: ${({ theme }) => theme.gridUnit * 2}px 0;
margin: ${({ theme }) => theme.gridUnit * 3}px 0 0
${({ theme }) => -theme.gridUnit * 2}px;
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light1};
&:hover {
color: ${({ theme }) => theme.colors.primary.base};
}
`;
const StyledTrashIcon = styled(Icon)`
color: ${({ theme }) => theme.colors.grayscale.light3};
`;
type FilterRemoval =
| null
| {
isPending: true; // the filter sticks around for a moment before removal is finalized
timerId: number; // id of the timer that finally removes the filter
}
| { isPending: false };
function generateFilterId() {
return `NATIVE_FILTER-${shortid.generate()}`;
}
export interface FilterConfigModalProps {
isOpen: boolean;
initialFilterId?: string;
createNewOnOpen?: boolean;
save: (filterConfig: FilterConfiguration) => Promise<void>;
onCancel: () => void;
}
const getFilterIds = (config: FilterConfiguration) =>
config.map(filter => filter.id);
/**
* This is the modal to configure all the dashboard-native filters.
* Manages modal-level state, such as what filters are in the list,
* and which filter is currently being edited.
*
* Calls the `save` callback with the new FilterConfiguration object
* when the user saves the filters.
*/
export function FilterConfigModal({
isOpen,
initialFilterId,
createNewOnOpen,
save,
onCancel,
}: FilterConfigModalProps) {
const [form] = Form.useForm<NativeFiltersForm>();
// the filter config from redux state, this does not change until modal is closed.
const filterConfig = useFilterConfiguration();
const filterConfigMap = useFilterConfigMap();
// new filter ids belong to filters have been added during
// this configuration session, and only exist in the form state until we submit.
const [newFilterIds, setNewFilterIds] = useState<string[]>([]);
// store ids of filters that have been removed with the time they were removed
// so that we can disappear them after a few secs.
// filters are still kept in state until form is submitted.
const [removedFilters, setRemovedFilters] = useState<
Record<string, FilterRemoval>
>({});
const [saveAlertVisible, setSaveAlertVisible] = useState<boolean>(false);
// brings back a filter that was previously removed ("Undo")
const restoreFilter = useCallback(
(id: string) => {
const removal = removedFilters[id];
// gotta clear the removal timeout to prevent the filter from getting deleted
if (removal?.isPending) clearTimeout(removal.timerId);
setRemovedFilters(current => ({ ...current, [id]: null }));
},
[removedFilters],
);
// The full ordered set of ((original + new) - completely removed) filter ids
// Use this as the canonical list of what filters are being configured!
// This includes filter ids that are pending removal, so check for that.
const filterIds = useMemo(
() =>
uniq([...getFilterIds(filterConfig), ...newFilterIds]).filter(
id => !removedFilters[id] || removedFilters[id]?.isPending,
),
[filterConfig, newFilterIds, removedFilters],
);
// open the first filter in the list to start
const getInitialCurrentFilterId = useCallback(
() => initialFilterId ?? filterIds[0],
[initialFilterId, filterIds],
);
const [currentFilterId, setCurrentFilterId] = useState(
getInitialCurrentFilterId,
);
// the form values are managed by the antd form, but we copy them to here
// so that we can display them (e.g. filter titles in the tab headers)
const [formValues, setFormValues] = useState<NativeFiltersForm>({
filters: {},
});
const wasOpen = usePrevious(isOpen);
useEffect(() => {
// if the currently viewed filter is fully removed, change to another tab
const currentFilterRemoved = removedFilters[currentFilterId];
if (currentFilterRemoved && !currentFilterRemoved.isPending) {
const nextFilterIndex = findLastIndex(
filterIds,
id => !removedFilters[id] && id !== currentFilterId,
);
if (nextFilterIndex !== -1)
setCurrentFilterId(filterIds[nextFilterIndex]);
}
}, [currentFilterId, removedFilters, filterIds]);
// generates a new filter id and appends it to the newFilterIds
const addFilter = useCallback(() => {
const newFilterId = generateFilterId();
setNewFilterIds([...newFilterIds, newFilterId]);
setCurrentFilterId(newFilterId);
setSaveAlertVisible(false);
}, [newFilterIds, setCurrentFilterId]);
// if this is a "create" modal rather than an "edit" modal,
// add a filter on modal open
useEffect(() => {
if (createNewOnOpen && isOpen && !wasOpen) {
addFilter();
}
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
// After this, it should be as if the modal was just opened fresh.
// Called when the modal is closed.
const resetForm = useCallback(() => {
form.resetFields();
setNewFilterIds([]);
setCurrentFilterId(getInitialCurrentFilterId());
setRemovedFilters({});
setSaveAlertVisible(false);
}, [form, getInitialCurrentFilterId]);
const completeFilterRemoval = (filterId: string) => {
// the filter state will actually stick around in the form,
// and the filterConfig/newFilterIds, but we use removedFilters
// to mark it as removed.
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: false },
}));
};
function onTabEdit(filterId: string, action: 'add' | 'remove') {
if (action === 'remove') {
// first set up the timer to completely remove it
const timerId = window.setTimeout(
() => completeFilterRemoval(filterId),
REMOVAL_DELAY_SECS * 1000,
);
// mark the filter state as "removal in progress"
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: true, timerId },
}));
setSaveAlertVisible(false);
} else if (action === 'add') {
addFilter();
}
}
function getFilterTitle(id: string) {
return (
formValues.filters[id]?.name ?? filterConfigMap[id]?.name ?? 'New filter'
);
}
function getParentFilters(id: string) {
return filterIds
.filter(filterId => filterId !== id && !removedFilters[filterId])
.map(id => ({
id,
title: getFilterTitle(id),
}));
}
const addValidationError = (
filterId: string,
field: string,
error: string,
) => {
const fieldError = {
name: ['filters', filterId, field],
errors: [error],
};
form.setFields([fieldError]);
// eslint-disable-next-line no-throw-literal
throw { errorFields: [fieldError] };
};
const validateForm = useCallback(async () => {
try {
const formValues = (await form.validateFields()) as NativeFiltersForm;
const validateInstant = (filterId: string) => {
const isInstant = formValues.filters[filterId]
? formValues.filters[filterId].isInstant
: filterConfigMap[filterId]?.isInstant;
if (!isInstant) {
addValidationError(
filterId,
'isInstant',
'For parent filters changes must be applied instantly',
);
}
};
const validateCycles = (filterId: string, trace: string[] = []) => {
if (trace.includes(filterId)) {
addValidationError(
filterId,
'parentFilter',
'Cannot create cyclic hierarchy',
);
}
const parentId = formValues.filters[filterId]
? formValues.filters[filterId].parentFilter?.value
: filterConfigMap[filterId]?.cascadeParentIds?.[0];
if (parentId) {
validateInstant(parentId);
validateCycles(parentId, [...trace, filterId]);
}
};
filterIds
.filter(id => !removedFilters[id])
.forEach(filterId => validateCycles(filterId));
return formValues;
} catch (error) {
console.warn('Filter configuration failed:', error);
if (!error.errorFields || !error.errorFields.length) return null; // not a validation error
// the name is in array format since the fields are nested
type ErrorFields = { name: ['filters', string, string] }[];
const errorFields = error.errorFields as ErrorFields;
// filter id is the second item in the field name
if (!errorFields.some(field => field.name[1] === currentFilterId)) {
// switch to the first tab that had a validation error
const filterError = errorFields.find(
field => field.name[0] === 'filters',
);
if (filterError) {
setCurrentFilterId(filterError.name[1]);
}
}
return null;
}
}, [form, currentFilterId, filterConfigMap, filterIds, removedFilters]);
const onOk = useCallback(async () => {
const values: NativeFiltersForm | null = await validateForm();
if (values == null) return;
const newFilterConfig: FilterConfiguration = filterIds
.filter(id => !removedFilters[id])
.map(id => {
// create a filter config object from the form inputs
const formInputs = values.filters[id];
// if user didn't open a filter, return the original config
if (!formInputs) return filterConfigMap[id];
let target = {};
if (formInputs.dataset && formInputs.column) {
target = {
datasetId: formInputs.dataset.value,
column: {
name: formInputs.column,
},
};
}
return {
id,
controlValues: formInputs.controlValues,
name: formInputs.name,
filterType: formInputs.filterType,
// for now there will only ever be one target
targets: [target],
defaultValue: formInputs.defaultValue || null,
cascadeParentIds: formInputs.parentFilter
? [formInputs.parentFilter.value]
: [],
scope: formInputs.scope,
isInstant: formInputs.isInstant,
};
});
await save(newFilterConfig);
resetForm();
}, [
save,
resetForm,
filterIds,
removedFilters,
filterConfigMap,
validateForm,
]);
const confirmCancel = () => {
resetForm();
onCancel();
};
const unsavedFiltersIds = newFilterIds.filter(id => !removedFilters[id]);
const getUnsavedFilterNames = (): string => {
const unsavedFiltersNames = unsavedFiltersIds.map(
id => `"${getFilterTitle(id)}"`,
);
if (unsavedFiltersNames.length === 0) {
return '';
}
if (unsavedFiltersNames.length === 1) {
return unsavedFiltersNames[0];
}
const lastFilter = unsavedFiltersNames.pop();
return `${unsavedFiltersNames.join(', ')} ${t('and')} ${lastFilter}`;
};
const handleCancel = () => {
if (unsavedFiltersIds.length > 0) {
setSaveAlertVisible(true);
} else {
confirmCancel();
}
};
const renderFooterElements = (): React.ReactNode[] => {
if (saveAlertVisible) {
return [
<CancelConfirmationAlert
title={`${unsavedFiltersIds.length} ${t('unsaved filters')}`}
onConfirm={confirmCancel}
onDismiss={() => setSaveAlertVisible(false)}
>
{t(`Are you sure you want to cancel?`)} {getUnsavedFilterNames()}{' '}
{t(`will not be saved.`)}
</CancelConfirmationAlert>,
];
}
return [
<Button
key="cancel"
buttonStyle="secondary"
data-test="native-filter-modal-cancel-button"
onClick={handleCancel}
>
{t('Cancel')}
</Button>,
<Button
key="submit"
buttonStyle="primary"
onClick={onOk}
data-test="native-filter-modal-save-button"
>
{t('Save')}
</Button>,
];
};
return (
<StyledModal
visible={isOpen}
maskClosable={false}
title={t('Filter configuration and scoping')}
width="55%"
destroyOnClose
onCancel={handleCancel}
onOk={onOk}
centered
data-test="filter-modal"
footer={renderFooterElements()}
>
<ErrorBoundary>
<StyledModalBody>
<StyledForm
preserve={false}
form={form}
onValuesChange={(changes, values: NativeFiltersForm) => {
if (
changes.filters &&
Object.values(changes.filters).some(
(filter: any) => filter.name != null,
)
) {
// we only need to set this if a name changed
setFormValues(values);
}
setSaveAlertVisible(false);
}}
layout="vertical"
>
<FilterTabs
tabPosition="left"
onChange={setCurrentFilterId}
activeKey={currentFilterId}
onEdit={onTabEdit}
addIcon={
<StyledAddFilterBox>
<PlusOutlined />{' '}
<span data-test="add-filter-button">{t('Add filter')}</span>
</StyledAddFilterBox>
}
>
{filterIds.map(id => (
<LineEditableTabs.TabPane
tab={
<FilterTabTitle
className={removedFilters[id] ? 'removed' : ''}
>
<StyledFilterTitle>
{removedFilters[id]
? t('(Removed)')
: getFilterTitle(id)}
</StyledFilterTitle>
{removedFilters[id] && (
<StyledSpan
role="button"
data-test="undo-button"
tabIndex={0}
onClick={() => restoreFilter(id)}
>
{t('Undo?')}
</StyledSpan>
)}
</FilterTabTitle>
}
key={id}
closeIcon={
removedFilters[id] ? (
<></>
) : (
<StyledTrashIcon name="trash" />
)
}
>
<FilterConfigForm
form={form}
filterId={id}
filterToEdit={filterConfigMap[id]}
removed={!!removedFilters[id]}
restore={restoreFilter}
parentFilters={getParentFilters(id)}
/>
</LineEditableTabs.TabPane>
))}
</FilterTabs>
</StyledForm>
</StyledModalBody>
</ErrorBoundary>
</StyledModal>
);
}

View File

@ -0,0 +1,180 @@
/**
* 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 { PlusOutlined } from '@ant-design/icons';
import { styled, t } from '@superset-ui/core';
import React, { FC } from 'react';
import { LineEditableTabs } from 'src/common/components/Tabs';
import Icon from 'src/components/Icon';
import { FilterRemoval } from './types';
import { REMOVAL_DELAY_SECS } from './utils';
export const FILTER_WIDTH = 200;
export const StyledSpan = styled.span`
cursor: pointer;
color: ${({ theme }) => theme.colors.primary.dark1};
&:hover {
color: ${({ theme }) => theme.colors.primary.dark2};
}
`;
export const StyledFilterTitle = styled.span`
width: ${FILTER_WIDTH}px;
white-space: normal;
color: ${({ theme }) => theme.colors.grayscale.dark1};
`;
export const StyledAddFilterBox = styled.div`
color: ${({ theme }) => theme.colors.primary.dark1};
text-align: left;
padding: ${({ theme }) => theme.gridUnit * 2}px 0;
margin: ${({ theme }) => theme.gridUnit * 3}px 0 0
${({ theme }) => -theme.gridUnit * 2}px;
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light1};
&:hover {
color: ${({ theme }) => theme.colors.primary.base};
}
`;
export const StyledTrashIcon = styled(Icon)`
color: ${({ theme }) => theme.colors.grayscale.light3};
`;
export const FilterTabTitle = styled.span`
transition: color ${({ theme }) => theme.transitionTiming}s;
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
@keyframes tabTitleRemovalAnimation {
0%,
90% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
&.removed {
color: ${({ theme }) => theme.colors.warning.dark1};
transform-origin: top;
animation-name: tabTitleRemovalAnimation;
animation-duration: ${REMOVAL_DELAY_SECS}s;
}
`;
const FilterTabsContainer = styled(LineEditableTabs)`
// extra selector specificity:
&.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab {
min-width: ${FILTER_WIDTH}px;
margin: 0 ${({ theme }) => theme.gridUnit * 2}px 0 0;
padding: ${({ theme }) => theme.gridUnit}px
${({ theme }) => theme.gridUnit * 2}px;
&:hover,
&-active {
color: ${({ theme }) => theme.colors.grayscale.dark1};
border-radius: ${({ theme }) => theme.borderRadius}px;
background-color: ${({ theme }) => theme.colors.secondary.light4};
.ant-tabs-tab-remove > svg {
color: ${({ theme }) => theme.colors.grayscale.base};
transition: all 0.3s;
}
}
}
.ant-tabs-tab-btn {
text-align: left;
justify-content: space-between;
text-transform: unset;
}
`;
type FilterTabsProps = {
onChange: (activeKey: string) => void;
getFilterTitle: (id: string) => string;
currentFilterId: string;
onEdit: (filterId: string, action: 'add' | 'remove') => void;
filterIds: string[];
removedFilters: Record<string, FilterRemoval>;
restoreFilter: Function;
children: Function;
};
const FilterTabs: FC<FilterTabsProps> = ({
onEdit,
getFilterTitle,
onChange,
currentFilterId,
filterIds = [],
removedFilters = [],
restoreFilter,
children,
}) => (
<FilterTabsContainer
tabPosition="left"
onChange={onChange}
activeKey={currentFilterId}
onEdit={onEdit}
addIcon={
<StyledAddFilterBox>
<PlusOutlined />{' '}
<span data-test="add-filter-button">{t('Add filter')}</span>
</StyledAddFilterBox>
}
>
{filterIds.map(id => (
<LineEditableTabs.TabPane
tab={
<FilterTabTitle className={removedFilters[id] ? 'removed' : ''}>
<StyledFilterTitle>
{removedFilters[id] ? t('(Removed)') : getFilterTitle(id)}
</StyledFilterTitle>
{removedFilters[id] && (
<StyledSpan
role="button"
data-test="undo-button"
tabIndex={0}
onClick={() => restoreFilter(id)}
>
{t('Undo?')}
</StyledSpan>
)}
</FilterTabTitle>
}
key={id}
closeIcon={
removedFilters[id] ? <></> : <StyledTrashIcon name="trash" />
}
>
{
// @ts-ignore
children(id)
}
</LineEditableTabs.TabPane>
))}
</FilterTabsContainer>
);
export default FilterTabs;

View File

@ -24,7 +24,7 @@ import { AsyncSelect } from 'src/components/Select';
import { useToasts } from 'src/messageToasts/enhancers/withToasts';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { cacheWrapper } from 'src/utils/cacheWrapper';
import { NativeFiltersForm } from './types';
import { NativeFiltersForm } from '../types';
type ColumnSelectValue = {
value: string;

View File

@ -0,0 +1,85 @@
/**
* 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 { CustomControlItem } from '@superset-ui/chart-controls';
import React, { FC } from 'react';
import { Checkbox } from 'src/common/components';
import { FormInstance } from 'antd/lib/form';
import { getChartControlPanelRegistry } from '@superset-ui/core';
import { getControlItems, setFilterFieldValues } from './utils';
import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
import { StyledCheckboxFormItem } from './FiltersConfigForm';
import { Filter } from '../../types';
type ControlItemsProps = {
filterId: string;
forceUpdate: Function;
filterToEdit?: Filter;
form: FormInstance<NativeFiltersForm>;
formFilter?: NativeFiltersFormItem;
};
const ControlItems: FC<ControlItemsProps> = ({
forceUpdate,
form,
filterId,
filterToEdit,
formFilter,
}) => {
const filterType = formFilter?.filterType;
if (!filterType) return null;
const controlPanelRegistry = getChartControlPanelRegistry();
const controlItems =
getControlItems(controlPanelRegistry.get(filterType)) ?? [];
return (
<>
{controlItems
.filter(
(controlItem: CustomControlItem) =>
controlItem?.config?.renderTrigger,
)
.map(controlItem => (
<StyledCheckboxFormItem
key={controlItem.name}
name={['filters', filterId, 'controlValues', controlItem.name]}
initialValue={filterToEdit?.controlValues?.[controlItem.name]}
valuePropName="checked"
colon={false}
>
<Checkbox
onChange={() => {
if (!controlItem.config.resetConfig) {
return;
}
setFilterFieldValues(form, filterId, {
defaultValue: null,
});
forceUpdate();
}}
>
{controlItem.config.label}
</Checkbox>
</StyledCheckboxFormItem>
))}
</>
);
};
export default ControlItems;

View File

@ -0,0 +1,82 @@
/**
* 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, { FC } from 'react';
import { t, SuperChart } from '@superset-ui/core';
import { FormInstance } from 'antd/lib/form';
import { setFilterFieldValues, useForceUpdate } from './utils';
import { StyledFormItem, StyledLabel } from './FiltersConfigForm';
import { Filter } from '../../types';
import { NativeFiltersForm } from '../types';
import { getFormData } from '../../utils';
type DefaultValueProps = {
filterId: string;
hasFilledDatasource: boolean;
hasDatasource: boolean;
filterToEdit?: Filter;
form: FormInstance<NativeFiltersForm>;
formData: ReturnType<typeof getFormData>;
};
const DefaultValue: FC<DefaultValueProps> = ({
filterId,
hasFilledDatasource,
hasDatasource,
filterToEdit,
form,
formData,
}) => {
const forceUpdate = useForceUpdate();
const formFilter = (form.getFieldValue('filters') || {})[filterId];
return (
<StyledFormItem
name={['filters', filterId, 'defaultValue']}
initialValue={filterToEdit?.defaultValue}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
>
{((hasFilledDatasource && formFilter?.defaultValueQueriesData) ||
!hasDatasource) && (
<SuperChart
height={25}
width={250}
formData={formData}
// For charts that don't have datasource we need workaround for empty placeholder
queriesData={
hasDatasource
? formFilter?.defaultValueQueriesData
: [{ data: [null] }]
}
chartType={formFilter?.filterType}
hooks={{
// @ts-ignore (fixed in other PR)
setExtraFormData: ({ currentState }) => {
setFilterFieldValues(form, filterId, {
defaultValue: currentState?.value,
});
forceUpdate();
},
}}
/>
)}
</StyledFormItem>
);
};
export default DefaultValue;

View File

@ -21,11 +21,12 @@ import React, { FC } from 'react';
import { t, styled } from '@superset-ui/core';
import { Radio } from 'src/common/components/Radio';
import { Form, Typography, Space, FormInstance } from 'src/common/components';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { NativeFiltersForm, Scoping } from './types';
import { NativeFiltersForm } from '../../types';
import { Filter } from '../../../types';
import { Scoping } from './types';
import ScopingTree from './ScopingTree';
import { isScopingAll, setFilterFieldValues, useForceUpdate } from './utils';
import { Filter } from '../types';
import { setFilterFieldValues, useForceUpdate } from '../utils';
import { getDefaultScopeValue, isScopingAll } from './utils';
type FilterScopeProps = {
filterId: string;
@ -33,11 +34,6 @@ type FilterScopeProps = {
form: FormInstance<NativeFiltersForm>;
};
export const getDefaultScopeValue = () => ({
rootPath: [DASHBOARD_ROOT_ID],
excluded: [],
});
const CleanFormItem = styled(Form.Item)`
margin-bottom: 0;
`;

View File

@ -21,14 +21,10 @@ import React, { FC, useMemo, useState } from 'react';
import { FormInstance, Tree } from 'src/common/components';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { useFilterScopeTree } from './state';
import {
findFilterScope,
getTreeCheckedItems,
setFilterFieldValues,
useForceUpdate,
} from './utils';
import { NativeFiltersForm } from './types';
import { Scope } from '../types';
import { setFilterFieldValues, useForceUpdate } from '../utils';
import { findFilterScope, getTreeCheckedItems } from './utils';
import { NativeFiltersForm } from '../../types';
import { Scope } from '../../../types';
type ScopingTreeProps = {
form: FormInstance<NativeFiltersForm>;

View File

@ -0,0 +1,65 @@
/**
* 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 { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { Charts, Layout, RootState } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import {
CHART_TYPE,
DASHBOARD_ROOT_TYPE,
} from 'src/dashboard/util/componentTypes';
import { TreeItem } from './types';
import { buildTree } from './utils';
// eslint-disable-next-line import/prefer-default-export
export function useFilterScopeTree(): {
treeData: [TreeItem];
layout: Layout;
} {
const layout = useSelector<RootState, Layout>(
({ dashboardLayout: { present } }) => present,
);
const charts = useSelector<RootState, Charts>(({ charts }) => charts);
const tree = {
children: [],
key: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
title: t('All panels'),
};
// We need to get only nodes that have charts as children or grandchildren
const validNodes = useMemo(
() =>
Object.values(layout).reduce<string[]>((acc, cur) => {
if (cur?.type === CHART_TYPE) {
return [...new Set([...acc, ...cur?.parents, cur.id])];
}
return acc;
}, []),
[layout],
);
useMemo(() => {
buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts, validNodes);
}, [charts, layout, tree]);
return { treeData: [tree], layout };
}

View File

@ -0,0 +1,30 @@
/**
* 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.
*/
export enum Scoping {
all,
specific,
}
/** UI Ant tree type */
export type TreeItem = {
children: TreeItem[];
key: string;
title: string;
};

View File

@ -16,25 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { flatMapDeep } from 'lodash';
import { Charts, Layout, LayoutItem } from 'src/dashboard/types';
import {
CHART_TYPE,
DASHBOARD_ROOT_TYPE,
TAB_TYPE,
} from 'src/dashboard/util/componentTypes';
import { FormInstance } from 'antd/lib/form';
import React from 'react';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import { CustomControlItem } from '@superset-ui/chart-controls';
import { TreeItem } from './types';
import { Scope } from '../types';
export const useForceUpdate = () => {
const [, updateState] = React.useState({});
return React.useCallback(() => updateState({}), []);
};
import { Scope } from '../../../types';
export const isShowTypeInTree = ({ type, meta }: LayoutItem, charts?: Charts) =>
(type === TAB_TYPE || type === CHART_TYPE || type === DASHBOARD_ROOT_TYPE) &&
@ -155,33 +145,10 @@ export const findFilterScope = (
};
};
export const setFilterFieldValues = (
form: FormInstance,
filterId: string,
values: object,
) => {
const formFilters = form.getFieldValue('filters');
form.setFieldsValue({
filters: {
...formFilters,
[filterId]: {
...formFilters[filterId],
...values,
},
},
});
};
export const getControlItems = (
controlConfig: { [key: string]: any } = {},
): CustomControlItem[] =>
(flatMapDeep(controlConfig.controlPanelSections)?.reduce(
(acc: any, { controlSetRows = [] }: any) => [
...acc,
...flatMapDeep(controlSetRows),
],
[],
) as CustomControlItem[]) ?? [];
export const getDefaultScopeValue = () => ({
rootPath: [DASHBOARD_ROOT_ID],
excluded: [],
});
export const isScopingAll = (scope: Scope) =>
!scope || (scope.rootPath[0] === DASHBOARD_ROOT_ID && !scope.excluded.length);

View File

@ -18,53 +18,31 @@
*/
import {
styled,
SuperChart,
t,
getChartControlPanelRegistry,
getChartMetadataRegistry,
Behavior,
} from '@superset-ui/core';
import { FormInstance } from 'antd/lib/form';
import React, { useCallback } from 'react';
import {
Button,
Checkbox,
Form,
Input,
Typography,
} from 'src/common/components';
import { Checkbox, Form, Input, Typography } from 'src/common/components';
import { Select } from 'src/components/Select/SupersetStyledSelect';
import SupersetResourceSelect from 'src/components/SupersetResourceSelect';
import { addDangerToast } from 'src/messageToasts/actions';
import { ClientErrorObject } from 'src/utils/getClientErrorObject';
import { CustomControlItem } from '@superset-ui/chart-controls';
import { ColumnSelect } from './ColumnSelect';
import { NativeFiltersForm } from './types';
import FilterScope from './FilterScope';
import { getControlItems, setFilterFieldValues, useForceUpdate } from './utils';
import { NativeFiltersForm } from '../types';
import {
datasetToSelectOption,
setFilterFieldValues,
useForceUpdate,
} from './utils';
import { useBackendFormUpdate } from './state';
import { getFormData } from '../utils';
import { Filter } from '../types';
type DatasetSelectValue = {
value: number;
label: string;
};
const datasetToSelectOption = (item: any): DatasetSelectValue => ({
value: item.id,
label: item.table_name,
});
const RemovedContent = styled.div`
display: flex;
flex-direction: column;
height: 400px; // arbitrary
text-align: center;
justify-content: center;
align-items: center;
color: ${({ theme }) => theme.colors.grayscale.base};
`;
import { getFormData } from '../../utils';
import { Filter } from '../../types';
import ControlItems from './ControlItems';
import FilterScope from './FilterScope/FilterScope';
import RemovedFilter from './RemovedFilter';
import DefaultValue from './DefaultValue';
const StyledContainer = styled.div`
display: flex;
@ -72,16 +50,16 @@ const StyledContainer = styled.div`
justify-content: space-between;
`;
const StyledFormItem = styled(Form.Item)`
export const StyledFormItem = styled(Form.Item)`
width: 49%;
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
`;
const StyledCheckboxFormItem = styled(Form.Item)`
export const StyledCheckboxFormItem = styled(Form.Item)`
margin-bottom: 0;
`;
const StyledLabel = styled.span`
export const StyledLabel = styled.span`
color: ${({ theme }) => theme.colors.grayscale.base};
font-size: ${({ theme }) => theme.typography.sizes.s};
text-transform: uppercase;
@ -91,11 +69,11 @@ const CleanFormItem = styled(Form.Item)`
margin-bottom: 0;
`;
export interface FilterConfigFormProps {
export interface FiltersConfigFormProps {
filterId: string;
filterToEdit?: Filter;
removed?: boolean;
restore: (filterId: string) => void;
restoreFilter: (filterId: string) => void;
form: FormInstance<NativeFiltersForm>;
parentFilters: { id: string; title: string }[];
}
@ -104,20 +82,16 @@ export interface FilterConfigFormProps {
* The configuration form for a specific filter.
* Assigns field values to `filters[filterId]` in the form.
*/
export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
filterId,
filterToEdit,
removed,
restore,
restoreFilter,
form,
parentFilters,
}) => {
const controlPanelRegistry = getChartControlPanelRegistry();
const forceUpdate = useForceUpdate();
const formFilter = (form.getFieldValue('filters') || {})[filterId];
const controlItems = getControlItems(
controlPanelRegistry.get(formFilter?.filterType),
);
const nativeFilterItems = getChartMetadataRegistry().items;
const nativeFilterVizTypes = Object.entries(nativeFilterItems)
@ -157,20 +131,7 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
);
if (removed) {
return (
<RemovedContent>
<p>{t('You have removed this filter.')}</p>
<div>
<Button
data-test="restore-filter-button"
type="primary"
onClick={() => restore(filterId)}
>
{t('Restore Filter')}
</Button>
</div>
</RemovedContent>
);
return <RemovedFilter onClick={() => restoreFilter(filterId)} />;
}
const parentFilterOptions = parentFilters.map(filter => ({
@ -273,36 +234,6 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
hidden
initialValue={null}
/>
<StyledFormItem
name={['filters', filterId, 'defaultValue']}
initialValue={filterToEdit?.defaultValue}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
>
{((hasFilledDatasource && formFilter?.defaultValueQueriesData) ||
!hasDatasource) && (
<SuperChart
height={25}
width={250}
formData={newFormData}
// For charts that don't have datasource we need workaround for empty placeholder
queriesData={
hasDatasource
? formFilter?.defaultValueQueriesData
: [{ data: [null] }]
}
chartType={formFilter?.filterType}
hooks={{
setExtraFormData: ({ currentState }) => {
setFilterFieldValues(form, filterId, {
defaultValue: currentState?.value,
});
forceUpdate();
},
}}
/>
)}
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'parentFilter']}
label={<StyledLabel>{t('Parent filter')}</StyledLabel>}
@ -317,6 +248,14 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
isClearable
/>
</StyledFormItem>
<DefaultValue
filterId={filterId}
hasFilledDatasource={hasFilledDatasource}
hasDatasource={hasDatasource}
filterToEdit={filterToEdit}
form={form}
formData={newFormData}
/>
<StyledCheckboxFormItem
name={['filters', filterId, 'isInstant']}
initialValue={filterToEdit?.isInstant}
@ -327,33 +266,13 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
{t('Apply changes instantly')}
</Checkbox>
</StyledCheckboxFormItem>
{controlItems
.filter(
(controlItem: CustomControlItem) =>
controlItem?.config?.renderTrigger,
)
.map(controlItem => (
<StyledCheckboxFormItem
name={['filters', filterId, 'controlValues', controlItem.name]}
initialValue={filterToEdit?.controlValues?.[controlItem.name]}
valuePropName="checked"
colon={false}
>
<Checkbox
onChange={() => {
if (!controlItem.config.resetConfig) {
return;
}
setFilterFieldValues(form, filterId, {
defaultValue: null,
});
forceUpdate();
}}
>
{controlItem.config.label}
</Checkbox>
</StyledCheckboxFormItem>
))}
<ControlItems
filterToEdit={filterToEdit}
formFilter={formFilter}
filterId={filterId}
form={form}
forceUpdate={forceUpdate}
/>
<FilterScope
filterId={filterId}
filterToEdit={filterToEdit}
@ -363,4 +282,4 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({
);
};
export default FilterConfigForm;
export default FiltersConfigForm;

View File

@ -0,0 +1,52 @@
/**
* 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 Button, { OnClickHandler } from 'src/components/Button';
import React, { FC } from 'react';
import { styled, t } from '@superset-ui/core';
const RemovedContent = styled.div`
display: flex;
flex-direction: column;
height: 400px; // arbitrary
text-align: center;
justify-content: center;
align-items: center;
color: ${({ theme }) => theme.colors.grayscale.base};
`;
type RemovedFilterProps = {
onClick: OnClickHandler;
};
const RemovedFilter: FC<RemovedFilterProps> = ({ onClick }) => (
<RemovedContent>
<p>{t('You have removed this filter.')}</p>
<div>
<Button
data-test="restore-filter-button"
buttonStyle="primary"
onClick={onClick}
>
{t('Restore Filter')}
</Button>
</div>
</RemovedContent>
);
export default RemovedFilter;

View File

@ -16,65 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@superset-ui/core';
import { Charts, Layout, RootState } from 'src/dashboard/types';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import {
CHART_TYPE,
DASHBOARD_ROOT_TYPE,
} from 'src/dashboard/util/componentTypes';
import { useEffect } from 'react';
import { FormInstance } from 'antd/lib/form';
import { getChartDataRequest } from 'src/chart/chartAction';
import { NativeFilterState } from 'src/dashboard/reducers/types';
import { NativeFiltersForm, TreeItem } from './types';
import { buildTree, setFilterFieldValues, useForceUpdate } from './utils';
import { Filter } from '../types';
import { getFormData } from '../utils';
export function useFiltersState() {
return useSelector<any, NativeFilterState>(
state => state.nativeFilters.filtersState,
);
}
export function useFilterScopeTree(): {
treeData: [TreeItem];
layout: Layout;
} {
const layout = useSelector<RootState, Layout>(
({ dashboardLayout: { present } }) => present,
);
const charts = useSelector<RootState, Charts>(({ charts }) => charts);
const tree = {
children: [],
key: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
title: t('All panels'),
};
// We need to get only nodes that have charts as children or grandchildren
const validNodes = useMemo(
() =>
Object.values(layout).reduce<string[]>((acc, cur) => {
if (cur?.type === CHART_TYPE) {
return [...new Set([...acc, ...cur?.parents, cur.id])];
}
return acc;
}, []),
[layout],
);
useMemo(() => {
buildTree(layout[DASHBOARD_ROOT_ID], tree, layout, charts, validNodes);
}, [charts, layout, tree]);
return { treeData: [tree], layout };
}
import { NativeFiltersForm } from '../types';
import { setFilterFieldValues, useForceUpdate } from './utils';
import { Filter } from '../../types';
import { getFormData } from '../../utils';
// When some fields in form changed we need re-fetch data for Filter defaultValue
// eslint-disable-next-line import/prefer-default-export
export const useBackendFormUpdate = (
form: FormInstance<NativeFiltersForm>,
filterId: string,

View File

@ -0,0 +1,65 @@
/**
* 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 { flatMapDeep } from 'lodash';
import { FormInstance } from 'antd/lib/form';
import React from 'react';
import { CustomControlItem } from '@superset-ui/chart-controls';
export const useForceUpdate = () => {
const [, updateState] = React.useState({});
return React.useCallback(() => updateState({}), []);
};
export const setFilterFieldValues = (
form: FormInstance,
filterId: string,
values: object,
) => {
const formFilters = form.getFieldValue('filters');
form.setFieldsValue({
filters: {
...formFilters,
[filterId]: {
...formFilters[filterId],
...values,
},
},
});
};
export const getControlItems = (
controlConfig: { [key: string]: any } = {},
): CustomControlItem[] =>
(flatMapDeep(controlConfig.controlPanelSections)?.reduce(
(acc: any, { controlSetRows = [] }: any) => [
...acc,
...flatMapDeep(controlSetRows),
],
[],
) as CustomControlItem[]) ?? [];
type DatasetSelectValue = {
value: number;
label: string;
};
export const datasetToSelectOption = (item: any): DatasetSelectValue => ({
value: item.id,
label: item.table_name,
});

View File

@ -0,0 +1,263 @@
/**
* 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, useState } from 'react';
import { uniq } from 'lodash';
import { t, styled } from '@superset-ui/core';
import { Form } from 'src/common/components';
import { StyledModal } from 'src/common/components/Modal';
import ErrorBoundary from 'src/components/ErrorBoundary';
import { useFilterConfigMap, useFilterConfiguration } from '../state';
import { FilterRemoval, NativeFiltersForm } from './types';
import { FilterConfiguration } from '../types';
import {
createHandleSave,
createHandleTabEdit,
generateFilterId,
getFilterIds,
} from './utils';
import Footer from './Footer/Footer';
import FilterTabs from './FilterTabs';
import FiltersConfigForm from './FiltersConfigForm/FiltersConfigForm';
import { useOpenModal, useRemoveCurrentFilter } from './state';
export const StyledModalBody = styled.div`
display: flex;
flex-direction: row;
.filters-list {
width: ${({ theme }) => theme.gridUnit * 50}px;
overflow: auto;
}
`;
export const StyledForm = styled(Form)`
width: 100%;
`;
export interface FiltersConfigModalProps {
isOpen: boolean;
initialFilterId?: string;
createNewOnOpen?: boolean;
onSave: (filterConfig: FilterConfiguration) => Promise<void>;
onCancel: () => void;
}
/**
* This is the modal to configure all the dashboard-native filters.
* Manages modal-level state, such as what filters are in the list,
* and which filter is currently being edited.
*
* Calls the `save` callback with the new FilterConfiguration object
* when the user saves the filters.
*/
export function FiltersConfigModal({
isOpen,
initialFilterId,
createNewOnOpen,
onSave,
onCancel,
}: FiltersConfigModalProps) {
const [form] = Form.useForm<NativeFiltersForm>();
// the filter config from redux state, this does not change until modal is closed.
const filterConfig = useFilterConfiguration();
const filterConfigMap = useFilterConfigMap();
// new filter ids belong to filters have been added during
// this configuration session, and only exist in the form state until we submit.
const [newFilterIds, setNewFilterIds] = useState<string[]>([]);
// store ids of filters that have been removed with the time they were removed
// so that we can disappear them after a few secs.
// filters are still kept in state until form is submitted.
const [removedFilters, setRemovedFilters] = useState<
Record<string, FilterRemoval>
>({});
const [saveAlertVisible, setSaveAlertVisible] = useState<boolean>(false);
// The full ordered set of ((original + new) - completely removed) filter ids
// Use this as the canonical list of what filters are being configured!
// This includes filter ids that are pending removal, so check for that.
const filterIds = useMemo(
() =>
uniq([...getFilterIds(filterConfig), ...newFilterIds]).filter(
id => !removedFilters[id] || removedFilters[id]?.isPending,
),
[filterConfig, newFilterIds, removedFilters],
);
// open the first filter in the list to start
const initialCurrentFilterId = initialFilterId ?? filterIds[0];
const [currentFilterId, setCurrentFilterId] = useState(
initialCurrentFilterId,
);
// the form values are managed by the antd form, but we copy them to here
// so that we can display them (e.g. filter titles in the tab headers)
const [formValues, setFormValues] = useState<NativeFiltersForm>({
filters: {},
});
const unsavedFiltersIds = newFilterIds.filter(id => !removedFilters[id]);
// brings back a filter that was previously removed ("Undo")
const restoreFilter = (id: string) => {
const removal = removedFilters[id];
// gotta clear the removal timeout to prevent the filter from getting deleted
if (removal?.isPending) clearTimeout(removal.timerId);
setRemovedFilters(current => ({ ...current, [id]: null }));
};
// generates a new filter id and appends it to the newFilterIds
const addFilter = useCallback(() => {
const newFilterId = generateFilterId();
setNewFilterIds([...newFilterIds, newFilterId]);
setCurrentFilterId(newFilterId);
setSaveAlertVisible(false);
}, [newFilterIds, setCurrentFilterId]);
useOpenModal(isOpen, addFilter, createNewOnOpen);
useRemoveCurrentFilter(
removedFilters,
currentFilterId,
filterIds,
setCurrentFilterId,
);
const handleTabEdit = createHandleTabEdit(
setRemovedFilters,
setSaveAlertVisible,
addFilter,
);
// After this, it should be as if the modal was just opened fresh.
// Called when the modal is closed.
const resetForm = () => {
form.resetFields();
setNewFilterIds([]);
setCurrentFilterId(initialCurrentFilterId);
setRemovedFilters({});
setSaveAlertVisible(false);
};
const getFilterTitle = (id: string) =>
formValues.filters[id]?.name ??
filterConfigMap[id]?.name ??
t('New filter');
const getParentFilters = (id: string) =>
filterIds
.filter(filterId => filterId !== id && !removedFilters[filterId])
.map(id => ({
id,
title: getFilterTitle(id),
}));
const handleSave = createHandleSave(
form,
currentFilterId,
filterConfigMap,
filterIds,
removedFilters,
setCurrentFilterId,
resetForm,
onSave,
);
const handleConfirmCancel = () => {
resetForm();
onCancel();
};
const handleCancel = () => {
if (unsavedFiltersIds.length > 0) {
setSaveAlertVisible(true);
} else {
handleConfirmCancel();
}
};
return (
<StyledModal
visible={isOpen}
maskClosable={false}
title={t('Filters configuration and scoping')}
width="55%"
destroyOnClose
onCancel={handleCancel}
onOk={handleSave}
centered
data-test="filter-modal"
footer={
<Footer
onDismiss={() => setSaveAlertVisible(false)}
onCancel={handleCancel}
getFilterTitle={getFilterTitle}
handleSave={handleSave}
saveAlertVisible={saveAlertVisible}
unsavedFiltersIds={unsavedFiltersIds}
onConfirmCancel={handleConfirmCancel}
/>
}
>
<ErrorBoundary>
<StyledModalBody>
<StyledForm
preserve={false}
form={form}
onValuesChange={(changes, values: NativeFiltersForm) => {
if (
changes.filters &&
Object.values(changes.filters).some(
(filter: any) => filter.name != null,
)
) {
// we only need to set this if a name changed
setFormValues(values);
}
setSaveAlertVisible(false);
}}
layout="vertical"
>
<FilterTabs
onEdit={handleTabEdit}
onChange={setCurrentFilterId}
getFilterTitle={getFilterTitle}
currentFilterId={currentFilterId}
filterIds={filterIds}
removedFilters={removedFilters}
restoreFilter={restoreFilter}
>
{(id: string) => (
<FiltersConfigForm
form={form}
filterId={id}
filterToEdit={filterConfigMap[id]}
removed={!!removedFilters[id]}
restoreFilter={restoreFilter}
parentFilters={getParentFilters(id)}
/>
)}
</FilterTabs>
</StyledForm>
</StyledModalBody>
</ErrorBoundary>
</StyledModal>
);
}

View File

@ -19,13 +19,13 @@
import React from 'react';
import { t } from '@superset-ui/core';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import Button, { OnClickHandler } from 'src/components/Button';
export interface ConfirmationAlertProps {
title: string;
children: React.ReactNode;
onConfirm: () => void;
onDismiss: () => void;
onConfirm: OnClickHandler;
onDismiss: OnClickHandler;
}
export function CancelConfirmationAlert({

View File

@ -0,0 +1,97 @@
/**
* 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, { FC } from 'react';
import Button, { OnClickHandler } from 'src/components/Button';
import { t } from '@superset-ui/core';
import { CancelConfirmationAlert } from './CancelConfirmationAlert';
type FooterProps = {
onCancel: OnClickHandler;
handleSave: OnClickHandler;
onConfirmCancel: OnClickHandler;
onDismiss: OnClickHandler;
saveAlertVisible: boolean;
getFilterTitle: (id: string) => string;
unsavedFiltersIds: string[];
};
const Footer: FC<FooterProps> = ({
onCancel,
handleSave,
onDismiss,
onConfirmCancel,
getFilterTitle,
unsavedFiltersIds,
saveAlertVisible,
}) => {
const getUnsavedFilterNames = (): string => {
const unsavedFiltersNames = unsavedFiltersIds.map(
id => `"${getFilterTitle(id)}"`,
);
if (unsavedFiltersNames.length === 0) {
return '';
}
if (unsavedFiltersNames.length === 1) {
return unsavedFiltersNames[0];
}
const lastFilter = unsavedFiltersNames.pop();
return `${unsavedFiltersNames.join(', ')} ${t('and')} ${lastFilter}`;
};
if (saveAlertVisible) {
return (
<CancelConfirmationAlert
key="cancel-confirm"
title={`${unsavedFiltersIds.length} ${t('unsaved filters')}`}
onConfirm={onConfirmCancel}
onDismiss={onDismiss}
>
{t(`Are you sure you want to cancel?`)} {getUnsavedFilterNames()}{' '}
{t(`will not be saved.`)}
</CancelConfirmationAlert>
);
}
return (
<>
<Button
key="cancel"
buttonStyle="secondary"
data-test="native-filter-modal-cancel-button"
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
key="submit"
buttonStyle="primary"
onClick={handleSave}
data-test="native-filter-modal-save-button"
>
{t('Save')}
</Button>
</>
);
};
export default Footer;

View File

@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { findLastIndex } from 'lodash';
import { FilterRemoval } from './types';
import { usePrevious } from '../../../../common/hooks/usePrevious';
/**
* 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.
*/
export const useRemoveCurrentFilter = (
removedFilters: Record<string, FilterRemoval>,
currentFilterId: string,
filterIds: string[],
setCurrentFilterId: Function,
) => {
useEffect(() => {
// if the currently viewed filter is fully removed, change to another tab
const currentFilterRemoved = removedFilters[currentFilterId];
if (currentFilterRemoved && !currentFilterRemoved.isPending) {
const nextFilterIndex = findLastIndex(
filterIds,
id => !removedFilters[id] && id !== currentFilterId,
);
if (nextFilterIndex !== -1)
setCurrentFilterId(filterIds[nextFilterIndex]);
}
}, [currentFilterId, removedFilters, filterIds]);
};
export const useOpenModal = (
isOpen: boolean,
addFilter: Function,
createNewOnOpen?: boolean,
) => {
const wasOpen = usePrevious(isOpen);
// if this is a "create" modal rather than an "edit" modal,
// add a filter on modal open
useEffect(() => {
if (createNewOnOpen && isOpen && !wasOpen) {
addFilter();
}
}, [createNewOnOpen, isOpen, wasOpen, addFilter]);
};

View File

@ -16,16 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryObjectFilterClause } from '@superset-ui/core';
import { Column, Scope } from '../types';
export enum Scoping {
all,
specific,
}
// Using to pass setState React callbacks directly to And components
export type AntCallback = (value1?: any, value2?: any) => void;
import { Scope } from '../types';
export interface NativeFiltersFormItem {
scope: Scope;
@ -51,20 +42,10 @@ export interface NativeFiltersForm {
filters: Record<string, NativeFiltersFormItem>;
}
export type SelectedValues = string[] | null;
export type AllFilterState = {
column: Column;
datasetId: number;
datasource: string;
id: string;
selectedValues: SelectedValues;
filterClause?: QueryObjectFilterClause;
};
/** UI Ant tree type */
export type TreeItem = {
children: TreeItem[];
key: string;
title: string;
};
export type FilterRemoval =
| null
| {
isPending: true; // the filter sticks around for a moment before removal is finalized
timerId: number; // id of the timer that finally removes the filter
}
| { isPending: false };

View File

@ -0,0 +1,205 @@
/**
* 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 { FormInstance } from 'antd/lib/form';
import shortid from 'shortid';
import { FilterRemoval, NativeFiltersForm } from './types';
import { Filter, FilterConfiguration } from '../types';
export const REMOVAL_DELAY_SECS = 5;
export const validateForm = async (
form: FormInstance<NativeFiltersForm>,
currentFilterId: string,
filterConfigMap: Record<string, Filter>,
filterIds: string[],
removedFilters: Record<string, FilterRemoval>,
setCurrentFilterId: Function,
) => {
const addValidationError = (
filterId: string,
field: string,
error: string,
) => {
const fieldError = {
name: ['filters', filterId, field],
errors: [error],
};
form.setFields([fieldError]);
// eslint-disable-next-line no-throw-literal
throw { errorFields: [fieldError] };
};
try {
const formValues = (await form.validateFields()) as NativeFiltersForm;
const validateInstant = (filterId: string) => {
const isInstant = formValues.filters[filterId]
? formValues.filters[filterId].isInstant
: filterConfigMap[filterId]?.isInstant;
if (!isInstant) {
addValidationError(
filterId,
'isInstant',
'For parent filters changes must be applied instantly',
);
}
};
const validateCycles = (filterId: string, trace: string[] = []) => {
if (trace.includes(filterId)) {
addValidationError(
filterId,
'parentFilter',
'Cannot create cyclic hierarchy',
);
}
const parentId = formValues.filters[filterId]
? formValues.filters[filterId].parentFilter?.value
: filterConfigMap[filterId]?.cascadeParentIds?.[0];
if (parentId) {
validateInstant(parentId);
validateCycles(parentId, [...trace, filterId]);
}
};
filterIds
.filter(id => !removedFilters[id])
.forEach(filterId => validateCycles(filterId));
return formValues;
} catch (error) {
console.warn('Filter configuration failed:', error);
if (!error.errorFields || !error.errorFields.length) return null; // not a validation error
// the name is in array format since the fields are nested
type ErrorFields = { name: ['filters', string, string] }[];
const errorFields = error.errorFields as ErrorFields;
// filter id is the second item in the field name
if (!errorFields.some(field => field.name[1] === currentFilterId)) {
// switch to the first tab that had a validation error
const filterError = errorFields.find(
field => field.name[0] === 'filters',
);
if (filterError) {
setCurrentFilterId(filterError.name[1]);
}
}
return null;
}
};
export const createHandleSave = (
form: FormInstance<NativeFiltersForm>,
currentFilterId: string,
filterConfigMap: Record<string, Filter>,
filterIds: string[],
removedFilters: Record<string, FilterRemoval>,
setCurrentFilterId: Function,
resetForm: Function,
saveForm: Function,
) => async () => {
const values: NativeFiltersForm | null = await validateForm(
form,
currentFilterId,
filterConfigMap,
filterIds,
removedFilters,
setCurrentFilterId,
);
if (values == null) return;
const newFilterConfig: FilterConfiguration = filterIds
.filter(id => !removedFilters[id])
.map(id => {
// create a filter config object from the form inputs
const formInputs = values.filters[id];
// if user didn't open a filter, return the original config
if (!formInputs) return filterConfigMap[id];
let target = {};
if (formInputs.dataset && formInputs.column) {
target = {
datasetId: formInputs.dataset.value,
column: {
name: formInputs.column,
},
};
}
return {
id,
controlValues: formInputs.controlValues,
name: formInputs.name,
filterType: formInputs.filterType,
// for now there will only ever be one target
targets: [target],
defaultValue: formInputs.defaultValue || null,
cascadeParentIds: formInputs.parentFilter
? [formInputs.parentFilter.value]
: [],
scope: formInputs.scope,
isInstant: formInputs.isInstant,
};
});
await saveForm(newFilterConfig);
resetForm();
};
export const createHandleTabEdit = (
setRemovedFilters: (
value:
| ((
prevState: Record<string, FilterRemoval>,
) => Record<string, FilterRemoval>)
| Record<string, FilterRemoval>,
) => void,
setSaveAlertVisible: Function,
addFilter: Function,
) => (filterId: string, action: 'add' | 'remove') => {
const completeFilterRemoval = (filterId: string) => {
// the filter state will actually stick around in the form,
// and the filterConfig/newFilterIds, but we use removedFilters
// to mark it as removed.
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: false },
}));
};
if (action === 'remove') {
// first set up the timer to completely remove it
const timerId = window.setTimeout(
() => completeFilterRemoval(filterId),
REMOVAL_DELAY_SECS * 1000,
);
// mark the filter state as "removal in progress"
setRemovedFilters(removedFilters => ({
...removedFilters,
[filterId]: { isPending: true, timerId },
}));
setSaveAlertVisible(false);
} else if (action === 'add') {
addFilter();
}
};
export const generateFilterId = () => `NATIVE_FILTER-${shortid.generate()}`;
export const getFilterIds = (config: FilterConfiguration) =>
config.map(filter => filter.id);