blob: 8e7b4f3d651006d4325e09579f9e9d80aa7e0544 [file] [log] [blame]
/**
* 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];
return {
id,
name: formInputs.name,
filterType: formInputs.filterType,
// for now there will only ever be one target
targets: [
{
datasetId: formInputs.dataset.value,
column: {
name: formInputs.column,
},
},
],
defaultValue: formInputs.defaultValue || null,
cascadeParentIds: formInputs.parentFilter
? [formInputs.parentFilter.value]
: [],
scope: formInputs.scope,
inverseSelection: !!formInputs.inverseSelection,
isInstant: !!formInputs.isInstant,
allowsMultipleValues: !!formInputs.allowsMultipleValues,
isRequired: !!formInputs.isRequired,
};
});
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}
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>
);
}