airbnb/caravel

View on GitHub
superset-frontend/src/components/Datasource/ChangeDatasourceModal.tsx

Summary

Maintainability
A
3 hrs
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 {
  FunctionComponent,
  useState,
  useRef,
  useEffect,
  useCallback,
  ChangeEvent,
} from 'react';

import Alert from 'src/components/Alert';
import {
  SupersetClient,
  t,
  styled,
  getClientErrorObject,
} from '@superset-ui/core';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import { ServerPagination, SortByType } from 'src/components/TableView/types';
import StyledModal from 'src/components/Modal';
import Button from 'src/components/Button';
import { useListViewResource } from 'src/views/CRUD/hooks';
import Dataset from 'src/types/Dataset';
import { useDebouncedEffect } from 'src/explore/exploreUtils';
import { SLOW_DEBOUNCE } from 'src/constants';
import Loading from 'src/components/Loading';
import { AntdInput } from 'src/components';
import { Input } from 'src/components/Input';
import {
  PAGE_SIZE as DATASET_PAGE_SIZE,
  SORT_BY as DATASET_SORT_BY,
} from 'src/features/datasets/constants';
import withToasts from 'src/components/MessageToasts/withToasts';
import FacePile from '../FacePile';

const CONFIRM_WARNING_MESSAGE = t(
  'Warning! Changing the dataset may break the chart if the metadata does not exist.',
);

const CHANGE_WARNING_MSG = t(
  'Changing the dataset may break the chart if the chart relies ' +
    'on columns or metadata that does not exist in the target dataset',
);

interface Datasource {
  type: string;
  id: number;
  uid: string;
}

interface ChangeDatasourceModalProps {
  addDangerToast: (msg: string) => void;
  addSuccessToast: (msg: string) => void;
  onChange: (uid: string) => void;
  onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
  onHide: () => void;
  show: boolean;
}

const Modal = styled(StyledModal)`
  .ant-modal-body {
    display: flex;
    flex-direction: column;
  }
`;

const ConfirmModalStyled = styled.div`
  .btn-container {
    display: flex;
    justify-content: flex-end;
    padding: 0px 15px;
    margin: 10px 0 0 0;
  }

  .confirm-modal-container {
    margin: 9px;
  }
`;

const StyledSpan = styled.span`
  cursor: pointer;
  color: ${({ theme }) => theme.colors.primary.dark1};
  &: hover {
    color: ${({ theme }) => theme.colors.primary.dark2};
  }
`;

const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
  addDangerToast,
  addSuccessToast,
  onChange,
  onDatasourceSave,
  onHide,
  show,
}) => {
  const [filter, setFilter] = useState<any>(undefined);
  const [pageIndex, setPageIndex] = useState<number>(0);
  const [sortBy, setSortBy] = useState<SortByType>(DATASET_SORT_BY);
  const [confirmChange, setConfirmChange] = useState(false);
  const [confirmedDataset, setConfirmedDataset] = useState<Datasource>();
  const searchRef = useRef<AntdInput>(null);

  const {
    state: { loading, resourceCollection, resourceCount },
    fetchData,
  } = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);

  const selectDatasource = useCallback((datasource: Datasource) => {
    setConfirmChange(true);
    setConfirmedDataset(datasource);
  }, []);

  const fetchDatasetPayload = {
    pageIndex,
    pageSize: DATASET_PAGE_SIZE,
    filters: [],
    sortBy,
  };

  useDebouncedEffect(
    () => {
      fetchData({
        ...fetchDatasetPayload,
        ...(filter && {
          filters: [
            {
              id: 'table_name',
              operator: 'ct',
              value: filter,
            },
          ],
        }),
      });
    },
    SLOW_DEBOUNCE,
    [filter, pageIndex, sortBy],
  );

  useEffect(() => {
    const onEnterModal = async () => {
      setTimeout(() => searchRef?.current?.focus(), 200);
    };

    if (show) {
      onEnterModal();
    }
  }, [
    addDangerToast,
    fetchData,
    onChange,
    onDatasourceSave,
    onHide,
    selectDatasource,
    show,
  ]);

  const changeSearch = (event: ChangeEvent<HTMLInputElement>) => {
    const searchValue = event.target.value ?? '';
    setFilter(searchValue);
    setPageIndex(0);
  };

  const handleChangeConfirm = () => {
    SupersetClient.get({
      endpoint: `/api/v1/dataset/${confirmedDataset?.id}`,
    })
      .then(({ json }) => {
        // eslint-disable-next-line no-param-reassign
        json.result.type = 'table';
        onDatasourceSave(json.result);
        onChange(`${confirmedDataset?.id}__table`);
      })
      .catch(response => {
        getClientErrorObject(response).then(
          ({ error, message }: { error: any; message: string }) => {
            const errorMessage = error
              ? error.error || error.statusText || error
              : message;
            addDangerToast(errorMessage);
          },
        );
      });
    onHide();
    addSuccessToast(t('Successfully changed dataset!'));
  };

  const handlerCancelConfirm = () => {
    setConfirmChange(false);
  };

  const columns = [
    {
      Cell: ({ row: { original } }: any) => (
        <StyledSpan
          role="button"
          tabIndex={0}
          data-test="datasource-link"
          onClick={() => selectDatasource({ type: 'table', ...original })}
        >
          {original?.table_name}
        </StyledSpan>
      ),
      Header: t('Name'),
      accessor: 'table_name',
    },
    {
      Header: t('Type'),
      accessor: 'kind',
      disableSortBy: true,
    },
    {
      Header: t('Schema'),
      accessor: 'schema',
    },
    {
      Header: t('Connection'),
      accessor: 'database.database_name',
      disableSortBy: true,
    },
    {
      Cell: ({
        row: {
          original: { owners = [] },
        },
      }: any) => <FacePile users={owners} />,
      Header: t('Owners'),
      id: 'owners',
      disableSortBy: true,
    },
  ];

  const onServerPagination = (args: ServerPagination) => {
    setPageIndex(args.pageIndex);
    if (args.sortBy) {
      // ensure default sort by
      setSortBy(args.sortBy.length > 0 ? args.sortBy : DATASET_SORT_BY);
    }
  };

  return (
    <Modal
      show={show}
      onHide={onHide}
      responsive
      title={t('Swap dataset')}
      width={confirmChange ? '432px' : ''}
      height={confirmChange ? 'auto' : '540px'}
      hideFooter={!confirmChange}
      footer={
        <>
          {confirmChange && (
            <ConfirmModalStyled>
              <div className="btn-container">
                <Button onClick={handlerCancelConfirm}>{t('Cancel')}</Button>
                <Button
                  className="proceed-btn"
                  buttonStyle="primary"
                  onClick={handleChangeConfirm}
                >
                  {t('Proceed')}
                </Button>
              </div>
            </ConfirmModalStyled>
          )}
        </>
      }
    >
      <>
        {!confirmChange && (
          <>
            <Alert
              roomBelow
              type="warning"
              css={theme => ({ marginBottom: theme.gridUnit * 4 })}
              message={
                <>
                  <strong>{t('Warning!')}</strong> {CHANGE_WARNING_MSG}
                </>
              }
            />
            <Input
              ref={searchRef}
              type="text"
              value={filter}
              placeholder={t('Search / Filter')}
              onChange={changeSearch}
            />
            {loading && <Loading />}
            {!loading && (
              <TableView
                columns={columns}
                data={resourceCollection}
                pageSize={DATASET_PAGE_SIZE}
                initialPageIndex={pageIndex}
                initialSortBy={sortBy}
                totalCount={resourceCount}
                onServerPagination={onServerPagination}
                className="table-condensed"
                emptyWrapperType={EmptyWrapperType.Small}
                serverPagination
                isPaginationSticky
                scrollTable
              />
            )}
          </>
        )}
        {confirmChange && <>{CONFIRM_WARNING_MESSAGE}</>}
      </>
    </Modal>
  );
};

export default withToasts(ChangeDatasourceModal);