diff --git a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/FilterScope_spec.tsx b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/FilterScope_spec.tsx index d1c89499ba..ab19250882 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/FilterScope_spec.tsx +++ b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/FilterScope_spec.tsx @@ -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; const mockedProps = { filterId: 'DefaultFilterId', - restore: jest.fn(), + restoreFilter: jest.fn(), parentFilters: [], save, }; @@ -49,7 +49,7 @@ describe('FilterScope', () => { return (
- +
); diff --git a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx index c871a6a7be..4b672b4a84 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx +++ b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx @@ -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( - + , ); } it('should be a valid react element', () => { - expect(React.isValidElement()).toBe( + expect(React.isValidElement()).toBe( true, ); }); diff --git a/superset-frontend/src/dashboard/actions/nativeFilters.ts b/superset-frontend/src/dashboard/actions/nativeFilters.ts index 6b9c4971cc..985c22985b 100644 --- a/superset-frontend/src/dashboard/actions/nativeFilters.ts +++ b/superset-frontend/src/dashboard/actions/nativeFilters.ts @@ -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; diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts b/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts index ea022829d1..2a42d044d5 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts +++ b/superset-frontend/src/dashboard/components/FiltersBadge/selectors.ts @@ -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', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink.tsx index 2e0b8bd8bd..875cbfc1ae 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink.tsx @@ -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 = ({ <> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
setOpen(true)}>{children}
- diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigModal.tsx deleted file mode 100644 index 87fa81e91b..0000000000 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigModal.tsx +++ /dev/null @@ -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; - 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(); - - // 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([]); - - // 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 - >({}); - - const [saveAlertVisible, setSaveAlertVisible] = useState(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({ - 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 [ - setSaveAlertVisible(false)} - > - {t(`Are you sure you want to cancel?`)} {getUnsavedFilterNames()}{' '} - {t(`will not be saved.`)} - , - ]; - } - - return [ - , - , - ]; - }; - - return ( - - - - { - 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" - > - - {' '} - {t('Add filter')} - - } - > - {filterIds.map(id => ( - - - {removedFilters[id] - ? t('(Removed)') - : getFilterTitle(id)} - - {removedFilters[id] && ( - restoreFilter(id)} - > - {t('Undo?')} - - )} - - } - key={id} - closeIcon={ - removedFilters[id] ? ( - <> - ) : ( - - ) - } - > - - - ))} - - - - - - ); -} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx new file mode 100644 index 0000000000..13f6a5d0fa --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterTabs.tsx @@ -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; + restoreFilter: Function; + children: Function; +}; + +const FilterTabs: FC = ({ + onEdit, + getFilterTitle, + onChange, + currentFilterId, + filterIds = [], + removedFilters = [], + restoreFilter, + children, +}) => ( + + {' '} + {t('Add filter')} + + } + > + {filterIds.map(id => ( + + + {removedFilters[id] ? t('(Removed)') : getFilterTitle(id)} + + {removedFilters[id] && ( + restoreFilter(id)} + > + {t('Undo?')} + + )} + + } + key={id} + closeIcon={ + removedFilters[id] ? <> : + } + > + { + // @ts-ignore + children(id) + } + + ))} + +); + +export default FilterTabs; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ColumnSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx similarity index 98% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ColumnSelect.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx index 22bd15c443..a45d3016d4 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ColumnSelect.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ColumnSelect.tsx @@ -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; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ControlItems.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ControlItems.tsx new file mode 100644 index 0000000000..12c6b23f7a --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/ControlItems.tsx @@ -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; + formFilter?: NativeFiltersFormItem; +}; + +const ControlItems: FC = ({ + 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 => ( + + { + if (!controlItem.config.resetConfig) { + return; + } + setFilterFieldValues(form, filterId, { + defaultValue: null, + }); + forceUpdate(); + }} + > + {controlItem.config.label} + + + ))} + + ); +}; +export default ControlItems; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx new file mode 100644 index 0000000000..797b1b51a3 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DefaultValue.tsx @@ -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; + formData: ReturnType; +}; + +const DefaultValue: FC = ({ + filterId, + hasFilledDatasource, + hasDatasource, + filterToEdit, + form, + formData, +}) => { + const forceUpdate = useForceUpdate(); + const formFilter = (form.getFieldValue('filters') || {})[filterId]; + return ( + {t('Default Value')}} + > + {((hasFilledDatasource && formFilter?.defaultValueQueriesData) || + !hasDatasource) && ( + { + setFilterFieldValues(form, filterId, { + defaultValue: currentState?.value, + }); + forceUpdate(); + }, + }} + /> + )} + + ); +}; + +export default DefaultValue; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterScope.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx similarity index 89% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterScope.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx index e3c94e8be0..e24dafe92e 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterScope.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/FilterScope.tsx @@ -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; }; -export const getDefaultScopeValue = () => ({ - rootPath: [DASHBOARD_ROOT_ID], - excluded: [], -}); - const CleanFormItem = styled(Form.Item)` margin-bottom: 0; `; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ScopingTree.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx similarity index 91% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ScopingTree.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx index 7e7d471a96..7c67adefdf 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/ScopingTree.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/ScopingTree.tsx @@ -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; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts new file mode 100644 index 0000000000..d4a420d90d --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/state.ts @@ -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( + ({ dashboardLayout: { present } }) => present, + ); + + const charts = useSelector(({ 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((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 }; +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts new file mode 100644 index 0000000000..cb804bf6d6 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/types.ts @@ -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; +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts similarity index 82% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/utils.ts rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts index 18991c651b..9f7d67486f 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/utils.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts @@ -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); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx similarity index 68% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigForm.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index 082f64457c..b97785a595 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/FilterConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -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; 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 = ({ +export const FiltersConfigForm: React.FC = ({ 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 = ({ ); if (removed) { - return ( - -

{t('You have removed this filter.')}

-
- -
-
- ); + return restoreFilter(filterId)} />; } const parentFilterOptions = parentFilters.map(filter => ({ @@ -273,36 +234,6 @@ export const FilterConfigForm: React.FC = ({ hidden initialValue={null} /> - {t('Default Value')}} - > - {((hasFilledDatasource && formFilter?.defaultValueQueriesData) || - !hasDatasource) && ( - { - setFilterFieldValues(form, filterId, { - defaultValue: currentState?.value, - }); - forceUpdate(); - }, - }} - /> - )} - {t('Parent filter')}} @@ -317,6 +248,14 @@ export const FilterConfigForm: React.FC = ({ isClearable /> + = ({ {t('Apply changes instantly')} - {controlItems - .filter( - (controlItem: CustomControlItem) => - controlItem?.config?.renderTrigger, - ) - .map(controlItem => ( - - { - if (!controlItem.config.resetConfig) { - return; - } - setFilterFieldValues(form, filterId, { - defaultValue: null, - }); - forceUpdate(); - }} - > - {controlItem.config.label} - - - ))} + = ({ ); }; -export default FilterConfigForm; +export default FiltersConfigForm; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/RemovedFilter.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/RemovedFilter.tsx new file mode 100644 index 0000000000..42483e83fa --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/RemovedFilter.tsx @@ -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 = ({ onClick }) => ( + +

{t('You have removed this filter.')}

+
+ +
+
+); + +export default RemovedFilter; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/state.ts similarity index 61% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/state.ts rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/state.ts index fc4ddfe663..242d049f25 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/state.ts @@ -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( - state => state.nativeFilters.filtersState, - ); -} - -export function useFilterScopeTree(): { - treeData: [TreeItem]; - layout: Layout; -} { - const layout = useSelector( - ({ dashboardLayout: { present } }) => present, - ); - - const charts = useSelector(({ 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((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, filterId: string, diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts new file mode 100644 index 0000000000..8a04a368f1 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts @@ -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, +}); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx new file mode 100644 index 0000000000..165abd579b --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx @@ -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; + 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(); + + // 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([]); + + // 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 + >({}); + + const [saveAlertVisible, setSaveAlertVisible] = useState(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({ + 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 ( + setSaveAlertVisible(false)} + onCancel={handleCancel} + getFilterTitle={getFilterTitle} + handleSave={handleSave} + saveAlertVisible={saveAlertVisible} + unsavedFiltersIds={unsavedFiltersIds} + onConfirmCancel={handleConfirmCancel} + /> + } + > + + + { + 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" + > + + {(id: string) => ( + + )} + + + + + + ); +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/CancelConfirmationAlert.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/CancelConfirmationAlert.tsx similarity index 93% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/CancelConfirmationAlert.tsx rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/CancelConfirmationAlert.tsx index 875e631ca3..5e2e8965ce 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/CancelConfirmationAlert.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/CancelConfirmationAlert.tsx @@ -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({ diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx new file mode 100644 index 0000000000..47f36bd014 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/Footer/Footer.tsx @@ -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 = ({ + 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 ( + + {t(`Are you sure you want to cancel?`)} {getUnsavedFilterNames()}{' '} + {t(`will not be saved.`)} + + ); + } + + return ( + <> + + + + ); +}; + +export default Footer; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts new file mode 100644 index 0000000000..d8fe5c1804 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/state.ts @@ -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, + 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]); +}; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/types.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts similarity index 66% rename from superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/types.ts rename to superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts index 1cde74023c..a9b6f4443a 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal/types.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/types.ts @@ -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; } -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 }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts new file mode 100644 index 0000000000..f3815fa978 --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/utils.ts @@ -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, + currentFilterId: string, + filterConfigMap: Record, + filterIds: string[], + removedFilters: Record, + 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, + currentFilterId: string, + filterConfigMap: Record, + filterIds: string[], + removedFilters: Record, + 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, + ) => Record) + | Record, + ) => 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);