airbnb/caravel

View on GitHub
superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.tsx

Summary

Maintainability
D
3 days
Test Coverage
/**
 * 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 { memo, useEffect, useCallback, useMemo, useState, useRef } from 'react';
import { uniq, isEqual, sortBy, debounce, isEmpty } from 'lodash';
import {
  Filter,
  FilterConfiguration,
  NativeFilterType,
  Divider,
  styled,
  SLOW_DEBOUNCE,
  t,
  css,
  useTheme,
} from '@superset-ui/core';
import { useDispatch } from 'react-redux';
import { AntdForm } from 'src/components';
import Icons from 'src/components/Icons';
import ErrorBoundary from 'src/components/ErrorBoundary';
import { StyledModal } from 'src/components/Modal';
import { testWithId } from 'src/utils/testUtils';
import { updateCascadeParentIds } from 'src/dashboard/actions/nativeFilters';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { useFilterConfigMap, useFilterConfiguration } from '../state';
import FilterConfigurePane from './FilterConfigurePane';
import FiltersConfigForm, {
  FilterPanels,
} from './FiltersConfigForm/FiltersConfigForm';
import Footer from './Footer/Footer';
import { useOpenModal, useRemoveCurrentFilter } from './state';
import { FilterRemoval, NativeFiltersForm } from './types';
import {
  createHandleSave,
  createHandleRemoveItem,
  generateFilterId,
  getFilterIds,
  validateForm,
  NATIVE_FILTER_DIVIDER_PREFIX,
  hasCircularDependency,
} from './utils';
import DividerConfigForm from './DividerConfigForm';

const MODAL_MARGIN = 16;
const MIN_WIDTH = 880;

const StyledModalWrapper = styled(StyledModal)<{ expanded: boolean }>`
  min-width: ${MIN_WIDTH}px;
  width: ${({ expanded }) => (expanded ? '100%' : MIN_WIDTH)} !important;

  @media (max-width: ${MIN_WIDTH + MODAL_MARGIN * 2}px) {
    width: 100% !important;
    min-width: auto;
  }

  .ant-modal-body {
    padding: 0px;
  }

  ${({ expanded }) =>
    expanded &&
    css`
      height: 100%;

      .ant-modal-body {
        flex: 1 1 auto;
      }
      .ant-modal-content {
        height: 100%;
      }
    `}
`;

export const StyledModalBody = styled.div<{ expanded: boolean }>`
  display: flex;
  height: ${({ expanded }) => (expanded ? '100%' : '700px')};
  flex-direction: row;
  flex: 1;
  .filters-list {
    width: ${({ theme }) => theme.gridUnit * 50}px;
    overflow: auto;
  }
`;

export const StyledForm = styled(AntdForm)`
  width: 100%;
`;

export const StyledExpandButtonWrapper = styled.div`
  margin-left: ${({ theme }) => theme.gridUnit * 4}px;
`;

export const FILTERS_CONFIG_MODAL_TEST_ID = 'filters-config-modal';
export const getFiltersConfigModalTestId = testWithId(
  FILTERS_CONFIG_MODAL_TEST_ID,
);

export interface FiltersConfigModalProps {
  isOpen: boolean;
  initialFilterId?: string;
  createNewOnOpen?: boolean;
  onSave: (filterConfig: FilterConfiguration) => Promise<void>;
  onCancel: () => void;
}
export const ALLOW_DEPENDENCIES = [
  'filter_range',
  'filter_select',
  'filter_time',
];

const DEFAULT_EMPTY_FILTERS: string[] = [];
const DEFAULT_REMOVED_FILTERS: Record<string, FilterRemoval> = {};
const DEFAULT_FORM_VALUES: NativeFiltersForm = {
  filters: {},
};

/**
 * 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.
 */
function FiltersConfigModal({
  isOpen,
  initialFilterId,
  createNewOnOpen,
  onSave,
  onCancel,
}: FiltersConfigModalProps) {
  const dispatch = useDispatch();
  const theme = useTheme();

  const [form] = AntdForm.useForm<NativeFiltersForm>();

  const configFormRef = useRef<any>();

  // 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[]>(
    DEFAULT_EMPTY_FILTERS,
  );

  // 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>
  >(DEFAULT_REMOVED_FILTERS);

  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,
  );
  const [erroredFilters, setErroredFilters] = useState<string[]>(
    DEFAULT_EMPTY_FILTERS,
  );

  // 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>(DEFAULT_FORM_VALUES);

  const unsavedFiltersIds = newFilterIds.filter(id => !removedFilters[id]);
  // 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],
  );
  const initialFilterOrder = useMemo(
    () => Object.keys(filterConfigMap),
    [filterConfigMap],
  );

  // State for tracking the re-ordering of filters
  const [orderedFilters, setOrderedFilters] =
    useState<string[]>(initialFilterOrder);

  // State for rendered filter to improve performance
  const [renderedFilters, setRenderedFilters] = useState<string[]>([
    initialCurrentFilterId,
  ]);

  const getActiveFilterPanelKey = (filterId: string) => [
    `${filterId}-${FilterPanels.configuration.key}`,
    `${filterId}-${FilterPanels.settings.key}`,
  ];

  const [activeFilterPanelKey, setActiveFilterPanelKey] = useState<
    string | string[]
  >(getActiveFilterPanelKey(initialCurrentFilterId));

  const handleTabChange = (filterId: string) => {
    setCurrentFilterId(filterId);
    setActiveFilterPanelKey(getActiveFilterPanelKey(filterId));
  };

  // generates a new filter id and appends it to the newFilterIds
  const addFilter = useCallback(
    (type: NativeFilterType) => {
      const newFilterId = generateFilterId(type);
      setNewFilterIds([...newFilterIds, newFilterId]);
      setCurrentFilterId(newFilterId);
      setSaveAlertVisible(false);
      setOrderedFilters([...orderedFilters, newFilterId]);
      setActiveFilterPanelKey(getActiveFilterPanelKey(newFilterId));
    },
    [
      newFilterIds,
      orderedFilters,
      setCurrentFilterId,
      setOrderedFilters,
      setNewFilterIds,
    ],
  );

  useOpenModal(isOpen, addFilter, createNewOnOpen);

  useRemoveCurrentFilter(
    removedFilters,
    currentFilterId,
    orderedFilters,
    setCurrentFilterId,
  );

  const handleRemoveItem = createHandleRemoveItem(
    setRemovedFilters,
    setOrderedFilters,
    setSaveAlertVisible,
  );

  // After this, it should be as if the modal was just opened fresh.
  // Called when the modal is closed.
  const resetForm = (isSaving = false) => {
    setNewFilterIds(DEFAULT_EMPTY_FILTERS);
    setCurrentFilterId(initialCurrentFilterId);
    setRemovedFilters(DEFAULT_REMOVED_FILTERS);
    setSaveAlertVisible(false);
    setFormValues(DEFAULT_FORM_VALUES);
    setErroredFilters(DEFAULT_EMPTY_FILTERS);
    if (filterIds.length > 0) {
      setActiveFilterPanelKey(getActiveFilterPanelKey(filterIds[0]));
    }
    if (!isSaving) {
      setOrderedFilters(initialFilterOrder);
    }
    setRenderedFilters([initialCurrentFilterId]);
    form.resetFields(['filters']);
    form.setFieldsValue({ changed: false });
  };

  const getFilterTitle = useCallback(
    (id: string) => {
      const formValue = formValues.filters[id];
      const config = filterConfigMap[id];
      return (
        (formValue && 'name' in formValue && formValue.name) ||
        (formValue && 'title' in formValue && formValue.title) ||
        (config && 'name' in config && config.name) ||
        (config && 'title' in config && config.title) ||
        t('[untitled]')
      );
    },
    [filterConfigMap, formValues.filters],
  );

  const canBeUsedAsDependency = useCallback(
    (filterId: string) => {
      if (removedFilters[filterId]) {
        return false;
      }
      const component =
        form.getFieldValue('filters')?.[filterId] || filterConfigMap[filterId];
      return (
        component &&
        'filterType' in component &&
        ALLOW_DEPENDENCIES.includes(component.filterType)
      );
    },
    [filterConfigMap, form, removedFilters],
  );

  const getAvailableFilters = useCallback(
    (filterId: string) =>
      filterIds
        .filter(id => id !== filterId)
        .filter(id => canBeUsedAsDependency(id))
        .map(id => ({
          label: getFilterTitle(id),
          value: id,
          type: filterConfigMap[id]?.filterType,
        })),
    [canBeUsedAsDependency, filterIds, getFilterTitle],
  );

  const cleanDeletedParents = (values: NativeFiltersForm | null) => {
    const updatedFilterConfigMap = Object.keys(filterConfigMap).reduce(
      (acc, key) => {
        const filter = filterConfigMap[key];
        const cascadeParentIds = filter.cascadeParentIds?.filter(id =>
          canBeUsedAsDependency(id),
        );
        if (cascadeParentIds) {
          dispatch(updateCascadeParentIds(key, cascadeParentIds));
        }
        return {
          ...acc,
          [key]: {
            ...filter,
            cascadeParentIds,
          },
        };
      },
      {},
    );

    const filters = values?.filters;
    if (filters) {
      Object.keys(filters).forEach(key => {
        const filter = filters[key];
        if (!('dependencies' in filter)) {
          return;
        }
        const { dependencies } = filter;
        if (dependencies) {
          filter.dependencies = dependencies.filter(id =>
            canBeUsedAsDependency(id),
          );
        }
      });
    }
    return updatedFilterConfigMap;
  };

  const handleErroredFilters = useCallback(() => {
    // managing left pane errored filters indicators
    const formValidationFields = form.getFieldsError();
    const erroredFiltersIds: string[] = [];

    formValidationFields.forEach(field => {
      const filterId = field.name[1] as string;
      if (field.errors.length > 0 && !erroredFiltersIds.includes(filterId)) {
        erroredFiltersIds.push(filterId);
      }
    });

    // no form validation issues found, resets errored filters
    if (!erroredFiltersIds.length && erroredFilters.length > 0) {
      setErroredFilters(DEFAULT_EMPTY_FILTERS);
      return;
    }
    // form validation issues found, sets errored filters
    if (
      erroredFiltersIds.length > 0 &&
      !isEqual(sortBy(erroredFilters), sortBy(erroredFiltersIds))
    ) {
      setErroredFilters(erroredFiltersIds);
    }
  }, [form, erroredFilters]);

  const handleSave = async () => {
    const values: NativeFiltersForm | null = await validateForm(
      form,
      currentFilterId,
      setCurrentFilterId,
    );

    handleErroredFilters();

    if (values) {
      const updatedFilterConfigMap = cleanDeletedParents(values);
      createHandleSave(
        updatedFilterConfigMap,
        orderedFilters,
        removedFilters,
        onSave,
        values,
      )();
      resetForm(true);
    } else {
      configFormRef.current?.changeTab?.('configuration');
    }
  };

  const handleConfirmCancel = () => {
    resetForm();
    onCancel();
  };

  const handleCancel = () => {
    const changed = form.getFieldValue('changed');
    const didChangeOrder =
      orderedFilters.length !== initialFilterOrder.length ||
      orderedFilters.some((val, index) => val !== initialFilterOrder[index]);
    if (
      unsavedFiltersIds.length > 0 ||
      form.isFieldsTouched() ||
      changed ||
      didChangeOrder
    ) {
      setSaveAlertVisible(true);
    } else {
      handleConfirmCancel();
    }
  };
  const handleRearrange = (dragIndex: number, targetIndex: number) => {
    const newOrderedFilter = [...orderedFilters];
    const removed = newOrderedFilter.splice(dragIndex, 1)[0];
    newOrderedFilter.splice(targetIndex, 0, removed);
    setOrderedFilters(newOrderedFilter);
  };

  const buildDependencyMap = useCallback(() => {
    const dependencyMap = new Map<string, string[]>();
    const filters = form.getFieldValue('filters');
    if (filters) {
      Object.keys(filters).forEach(key => {
        const formItem = filters[key];
        const configItem = filterConfigMap[key];
        let array: string[] = [];
        if (formItem && 'dependencies' in formItem) {
          array = [...formItem.dependencies];
        } else if (configItem?.cascadeParentIds) {
          array = [...configItem.cascadeParentIds];
        }
        dependencyMap.set(key, array);
      });
    }
    return dependencyMap;
  }, [filterConfigMap, form]);

  const validateDependencies = useCallback(() => {
    const dependencyMap = buildDependencyMap();
    filterIds
      .filter(id => !removedFilters[id])
      .forEach(filterId => {
        const result = hasCircularDependency(dependencyMap, filterId);
        const field = {
          name: ['filters', filterId, 'dependencies'],
          errors: result ? [t('Cyclic dependency detected')] : [],
        };
        form.setFields([field]);
      });
    handleErroredFilters();
  }, [
    buildDependencyMap,
    filterIds,
    form,
    handleErroredFilters,
    removedFilters,
  ]);

  const getDependencySuggestion = useCallback(
    (filterId: string) => {
      const dependencyMap = buildDependencyMap();
      const possibleDependencies = orderedFilters.filter(
        key => key !== filterId && canBeUsedAsDependency(key),
      );
      const found = possibleDependencies.find(filter => {
        const dependencies = dependencyMap.get(filterId) || [];
        dependencies.push(filter);
        if (hasCircularDependency(dependencyMap, filterId)) {
          dependencies.pop();
          return false;
        }
        return true;
      });
      return found || possibleDependencies[0];
    },
    [buildDependencyMap, canBeUsedAsDependency, orderedFilters],
  );

  const [expanded, setExpanded] = useState(false);
  const toggleExpand = useEffectEvent(() => {
    setExpanded(!expanded);
  });
  const ToggleIcon = expanded
    ? Icons.FullscreenExitOutlined
    : Icons.FullscreenOutlined;

  const handleValuesChange = useMemo(
    () =>
      debounce((changes: any, values: NativeFiltersForm) => {
        const didChangeFilterName =
          changes.filters &&
          Object.values(changes.filters).some(
            (filter: any) => filter.name && filter.name !== null,
          );
        const didChangeSectionTitle =
          changes.filters &&
          Object.values(changes.filters).some(
            (filter: any) => filter.title && filter.title !== null,
          );
        if (didChangeFilterName || didChangeSectionTitle) {
          // we only need to set this if a name/title changed
          setFormValues(values);
        }
        setSaveAlertVisible(false);
        handleErroredFilters();
      }, SLOW_DEBOUNCE),
    [handleErroredFilters],
  );

  useEffect(() => {
    if (!isEmpty(removedFilters)) {
      setErroredFilters(prevErroredFilters =>
        prevErroredFilters.filter(f => !removedFilters[f]),
      );
    }
  }, [removedFilters]);

  useEffect(() => {
    if (!renderedFilters.includes(currentFilterId)) {
      setRenderedFilters([...renderedFilters, currentFilterId]);
    }
  }, [currentFilterId]);

  const handleActiveFilterPanelChange = useCallback(
    key => setActiveFilterPanelKey(key),
    [setActiveFilterPanelKey],
  );

  const formList = useMemo(
    () =>
      orderedFilters.map(id => {
        if (!renderedFilters.includes(id)) return null;
        const isDivider = id.startsWith(NATIVE_FILTER_DIVIDER_PREFIX);
        const isActive = currentFilterId === id;
        return (
          <div
            key={id}
            style={{
              height: '100%',
              overflowY: 'auto',
              display: isActive ? '' : 'none',
            }}
          >
            {isDivider ? (
              <DividerConfigForm
                componentId={id}
                divider={filterConfigMap[id] as Divider}
              />
            ) : (
              <FiltersConfigForm
                ref={configFormRef}
                form={form}
                filterId={id}
                filterToEdit={filterConfigMap[id] as Filter}
                removedFilters={removedFilters}
                restoreFilter={restoreFilter}
                getAvailableFilters={getAvailableFilters}
                key={id}
                activeFilterPanelKeys={activeFilterPanelKey}
                handleActiveFilterPanelChange={handleActiveFilterPanelChange}
                isActive={isActive}
                setErroredFilters={setErroredFilters}
                validateDependencies={validateDependencies}
                getDependencySuggestion={getDependencySuggestion}
              />
            )}
          </div>
        );
      }),
    [
      renderedFilters,
      orderedFilters,
      currentFilterId,
      filterConfigMap,
      form,
      removedFilters,
      restoreFilter,
      getAvailableFilters,
      activeFilterPanelKey,
      validateDependencies,
      getDependencySuggestion,
      handleActiveFilterPanelChange,
    ],
  );

  return (
    <StyledModalWrapper
      visible={isOpen}
      maskClosable={false}
      title={t('Add and edit filters')}
      expanded={expanded}
      destroyOnClose
      onCancel={handleCancel}
      onOk={handleSave}
      centered
      data-test="filter-modal"
      footer={
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            align-items: flex-end;
          `}
        >
          <Footer
            onDismiss={() => setSaveAlertVisible(false)}
            onCancel={handleCancel}
            handleSave={handleSave}
            canSave={!erroredFilters.length}
            saveAlertVisible={saveAlertVisible}
            onConfirmCancel={handleConfirmCancel}
          />
          <StyledExpandButtonWrapper>
            <ToggleIcon
              iconSize="l"
              iconColor={theme.colors.grayscale.dark2}
              onClick={toggleExpand}
            />
          </StyledExpandButtonWrapper>
        </div>
      }
    >
      <ErrorBoundary>
        <StyledModalBody expanded={expanded}>
          <StyledForm
            form={form}
            onValuesChange={handleValuesChange}
            layout="vertical"
          >
            <FilterConfigurePane
              erroredFilters={erroredFilters}
              onRemove={handleRemoveItem}
              onAdd={addFilter}
              onChange={handleTabChange}
              getFilterTitle={getFilterTitle}
              currentFilterId={currentFilterId}
              removedFilters={removedFilters}
              restoreFilter={restoreFilter}
              onRearrange={handleRearrange}
              filters={orderedFilters}
            >
              {formList}
            </FilterConfigurePane>
          </StyledForm>
        </StyledModalBody>
      </ErrorBoundary>
    </StyledModalWrapper>
  );
}

export default memo(FiltersConfigModal);