mirror of https://github.com/apache/superset.git
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:
parent
e8d50356a2
commit
9b5e66b728
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
||||
`;
|
|
@ -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>;
|
|
@ -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 };
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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 getDefaultScopeValue = () => ({
|
||||
rootPath: [DASHBOARD_ROOT_ID],
|
||||
excluded: [],
|
||||
});
|
||||
};
|
||||
|
||||
export const getControlItems = (
|
||||
controlConfig: { [key: string]: any } = {},
|
||||
): CustomControlItem[] =>
|
||||
(flatMapDeep(controlConfig.controlPanelSections)?.reduce(
|
||||
(acc: any, { controlSetRows = [] }: any) => [
|
||||
...acc,
|
||||
...flatMapDeep(controlSetRows),
|
||||
],
|
||||
[],
|
||||
) as CustomControlItem[]) ?? [];
|
||||
|
||||
export const isScopingAll = (scope: Scope) =>
|
||||
!scope || (scope.rootPath[0] === DASHBOARD_ROOT_ID && !scope.excluded.length);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
|
@ -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,
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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({
|
|
@ -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;
|
|
@ -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]);
|
||||
};
|
|
@ -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 };
|
|
@ -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);
|
Loading…
Reference in New Issue