airbnb/caravel

View on GitHub
superset-frontend/src/pages/AnnotationList/index.tsx

Summary

Maintainability
D
2 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 { useMemo, useState, useEffect, useCallback } from 'react';
import { useParams, Link, useHistory } from 'react-router-dom';
import {
  css,
  t,
  styled,
  SupersetClient,
  getClientErrorObject,
} from '@superset-ui/core';
import moment from 'moment';
import rison from 'rison';

import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import DeleteModal from 'src/components/DeleteModal';
import ListView, { ListViewProps } from 'src/components/ListView';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';

import { AnnotationObject } from 'src/features/annotations/types';
import AnnotationModal from 'src/features/annotations/AnnotationModal';

const PAGE_SIZE = 25;

interface AnnotationListProps {
  addDangerToast: (msg: string) => void;
  addSuccessToast: (msg: string) => void;
}

const StyledHeader = styled.div`
  ${({ theme }) => css`
    display: flex;
    flex-direction: row;

    a,
    Link {
      margin-left: ${theme.gridUnit * 4}px;
      font-size: ${theme.typography.sizes.s}px;
      font-weight: ${theme.typography.weights.normal};
      text-decoration: underline;
    }
  `}
`;

function AnnotationList({
  addDangerToast,
  addSuccessToast,
}: AnnotationListProps) {
  const { annotationLayerId }: any = useParams();
  const {
    state: {
      loading,
      resourceCount: annotationsCount,
      resourceCollection: annotations,
      bulkSelectEnabled,
    },
    fetchData,
    refreshData,
    toggleBulkSelect,
  } = useListViewResource<AnnotationObject>(
    `annotation_layer/${annotationLayerId}/annotation`,
    t('annotation'),
    addDangerToast,
    false,
  );
  const [annotationModalOpen, setAnnotationModalOpen] =
    useState<boolean>(false);
  const [annotationLayerName, setAnnotationLayerName] = useState<string>('');
  const [currentAnnotation, setCurrentAnnotation] =
    useState<AnnotationObject | null>(null);
  const [annotationCurrentlyDeleting, setAnnotationCurrentlyDeleting] =
    useState<AnnotationObject | null>(null);
  const handleAnnotationEdit = (annotation: AnnotationObject | null) => {
    setCurrentAnnotation(annotation);
    setAnnotationModalOpen(true);
  };

  const fetchAnnotationLayer = useCallback(
    async function fetchAnnotationLayer() {
      try {
        const response = await SupersetClient.get({
          endpoint: `/api/v1/annotation_layer/${annotationLayerId}`,
        });
        setAnnotationLayerName(response.json.result.name);
      } catch (response) {
        await getClientErrorObject(response).then(({ error }: any) => {
          addDangerToast(error.error || error.statusText || error);
        });
      }
    },
    [annotationLayerId],
  );

  const handleAnnotationDelete = ({ id, short_descr }: AnnotationObject) => {
    SupersetClient.delete({
      endpoint: `/api/v1/annotation_layer/${annotationLayerId}/annotation/${id}`,
    }).then(
      () => {
        refreshData();
        setAnnotationCurrentlyDeleting(null);
        addSuccessToast(t('Deleted: %s', short_descr));
      },
      createErrorHandler(errMsg =>
        addDangerToast(
          t('There was an issue deleting %s: %s', short_descr, errMsg),
        ),
      ),
    );
  };

  const handleBulkAnnotationsDelete = (
    annotationsToDelete: AnnotationObject[],
  ) => {
    SupersetClient.delete({
      endpoint: `/api/v1/annotation_layer/${annotationLayerId}/annotation/?q=${rison.encode(
        annotationsToDelete.map(({ id }) => id),
      )}`,
    }).then(
      ({ json = {} }) => {
        refreshData();
        addSuccessToast(json.message);
      },
      createErrorHandler(errMsg =>
        addDangerToast(
          t('There was an issue deleting the selected annotations: %s', errMsg),
        ),
      ),
    );
  };

  // get the Annotation Layer
  useEffect(() => {
    fetchAnnotationLayer();
  }, [fetchAnnotationLayer]);

  const initialSort = [{ id: 'short_descr', desc: true }];
  const columns = useMemo(
    () => [
      {
        accessor: 'short_descr',
        Header: t('Name'),
      },
      {
        accessor: 'long_descr',
        Header: t('Description'),
      },
      {
        Cell: ({
          row: {
            original: { start_dttm: startDttm },
          },
        }: any) => moment(new Date(startDttm)).format('ll'),
        Header: t('Start'),
        accessor: 'start_dttm',
      },
      {
        Cell: ({
          row: {
            original: { end_dttm: endDttm },
          },
        }: any) => moment(new Date(endDttm)).format('ll'),
        Header: t('End'),
        accessor: 'end_dttm',
      },
      {
        Cell: ({ row: { original } }: any) => {
          const handleEdit = () => handleAnnotationEdit(original);
          const handleDelete = () => setAnnotationCurrentlyDeleting(original);
          const actions = [
            {
              label: 'edit-action',
              tooltip: t('Edit annotation'),
              placement: 'bottom',
              icon: 'Edit',
              onClick: handleEdit,
            },
            {
              label: 'delete-action',
              tooltip: t('Delete annotation'),
              placement: 'bottom',
              icon: 'Trash',
              onClick: handleDelete,
            },
          ];
          return <ActionsBar actions={actions as ActionProps[]} />;
        },
        Header: t('Actions'),
        id: 'actions',
        disableSortBy: true,
      },
    ],
    [true, true],
  );

  const subMenuButtons: SubMenuProps['buttons'] = [];

  subMenuButtons.push({
    name: (
      <>
        <i className="fa fa-plus" /> {t('Annotation')}
      </>
    ),
    buttonStyle: 'primary',
    onClick: () => {
      handleAnnotationEdit(null);
    },
  });

  subMenuButtons.push({
    name: t('Bulk select'),
    onClick: toggleBulkSelect,
    buttonStyle: 'secondary',
    'data-test': 'annotation-bulk-select',
  });

  let hasHistory = true;

  try {
    useHistory();
  } catch (err) {
    // If error is thrown, we know not to use <Link> in render
    hasHistory = false;
  }

  const emptyState = {
    title: t('No annotation yet'),
    image: 'filter-results.svg',
    buttonAction: () => {
      handleAnnotationEdit(null);
    },
    buttonText: (
      <>
        <i className="fa fa-plus" /> {t('Annotation')}
      </>
    ),
  };

  return (
    <>
      <SubMenu
        name={
          <StyledHeader>
            <span>{t('Annotation Layer %s', annotationLayerName)}</span>
            <span>
              {hasHistory ? (
                <Link to="/annotationlayer/list/">{t('Back to all')}</Link>
              ) : (
                <a href="/annotationlayer/list/">{t('Back to all')}</a>
              )}
            </span>
          </StyledHeader>
        }
        buttons={subMenuButtons}
      />
      <AnnotationModal
        addDangerToast={addDangerToast}
        addSuccessToast={addSuccessToast}
        annotation={currentAnnotation}
        show={annotationModalOpen}
        onAnnotationAdd={() => refreshData()}
        annotationLayerId={annotationLayerId}
        onHide={() => setAnnotationModalOpen(false)}
      />
      {annotationCurrentlyDeleting && (
        <DeleteModal
          description={t(
            'Are you sure you want to delete %s?',
            annotationCurrentlyDeleting?.short_descr,
          )}
          onConfirm={() => {
            if (annotationCurrentlyDeleting) {
              handleAnnotationDelete(annotationCurrentlyDeleting);
            }
          }}
          onHide={() => setAnnotationCurrentlyDeleting(null)}
          open
          title={t('Delete Annotation?')}
        />
      )}
      <ConfirmStatusChange
        title={t('Please confirm')}
        description={t(
          'Are you sure you want to delete the selected annotations?',
        )}
        onConfirm={handleBulkAnnotationsDelete}
      >
        {confirmDelete => {
          const bulkActions: ListViewProps['bulkActions'] = [
            {
              key: 'delete',
              name: t('Delete'),
              onSelect: confirmDelete,
              type: 'danger',
            },
          ];

          return (
            <ListView<AnnotationObject>
              className="annotations-list-view"
              bulkActions={bulkActions}
              bulkSelectEnabled={bulkSelectEnabled}
              columns={columns}
              count={annotationsCount}
              data={annotations}
              disableBulkSelect={toggleBulkSelect}
              emptyState={emptyState}
              fetchData={fetchData}
              addDangerToast={addDangerToast}
              addSuccessToast={addSuccessToast}
              refreshData={refreshData}
              initialSort={initialSort}
              loading={loading}
              pageSize={PAGE_SIZE}
            />
          );
        }}
      </ConfirmStatusChange>
    </>
  );
}

export default withToasts(AnnotationList);