airbnb/caravel

View on GitHub
superset-frontend/src/SqlLab/components/TableElement/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 { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import type { Table } from 'src/SqlLab/types';
import Collapse from 'src/components/Collapse';
import Card from 'src/components/Card';
import ButtonGroup from 'src/components/ButtonGroup';
import { css, t, styled, useTheme } from '@superset-ui/core';
import { debounce } from 'lodash';

import {
  removeDataPreview,
  removeTables,
  addDangerToast,
  syncTable,
} from 'src/SqlLab/actions/sqlLab';
import {
  tableApiUtil,
  useTableExtendedMetadataQuery,
  useTableMetadataQuery,
} from 'src/hooks/apiResources';
import { Tooltip } from 'src/components/Tooltip';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { IconTooltip } from 'src/components/IconTooltip';
import ModalTrigger from 'src/components/ModalTrigger';
import Loading from 'src/components/Loading';
import useEffectEvent from 'src/hooks/useEffectEvent';
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
import ShowSQL from '../ShowSQL';

export interface Column {
  name: string;
  keys?: { type: ColumnKeyTypeType }[];
  type: string;
}

export interface TableElementProps {
  table: Table;
}

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

const Fade = styled.div`
  transition: all ${({ theme }) => theme.transitionTiming}s;
  opacity: ${(props: { hovered: boolean }) => (props.hovered ? 1 : 0)};
`;

const StyledCollapsePanel = styled(Collapse.Panel)`
  ${({ theme }) => css`
    & {
      .ws-el-controls {
        margin-right: ${-theme.gridUnit}px;
        display: flex;
      }

      .header-container {
        display: flex;
        flex: 1;
        align-items: center;
        width: 100%;

        .table-name {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          font-size: ${theme.typography.sizes.l}px;
          flex: 1;
        }

        .header-right-side {
          margin-left: auto;
          display: flex;
          align-items: center;
          margin-right: ${theme.gridUnit * 8}px;
        }
      }
    }
  `}
`;

const TableElement = ({ table, ...props }: TableElementProps) => {
  const { dbId, catalog, schema, name, expanded } = table;
  const theme = useTheme();
  const dispatch = useDispatch();
  const {
    currentData: tableMetadata,
    isSuccess: isMetadataSuccess,
    isFetching: isMetadataFetching,
    isError: hasMetadataError,
  } = useTableMetadataQuery(
    {
      dbId,
      catalog,
      schema,
      table: name,
    },
    { skip: !expanded },
  );
  const {
    currentData: tableExtendedMetadata,
    isSuccess: isExtraMetadataSuccess,
    isLoading: isExtraMetadataLoading,
    isError: hasExtendedMetadataError,
  } = useTableExtendedMetadataQuery(
    {
      dbId,
      catalog,
      schema,
      table: name,
    },
    { skip: !expanded },
  );

  useEffect(() => {
    if (hasMetadataError || hasExtendedMetadataError) {
      dispatch(
        addDangerToast(t('An error occurred while fetching table metadata')),
      );
    }
  }, [hasMetadataError, hasExtendedMetadataError, dispatch]);

  const tableData = {
    ...tableMetadata,
    ...tableExtendedMetadata,
  };

  // TODO: migrate syncTable logic by SIP-93
  const syncTableMetadata = useEffectEvent(() => {
    const { initialized } = table;
    if (!initialized) {
      dispatch(syncTable(table, tableData));
    }
  });

  useEffect(() => {
    if (isMetadataSuccess && isExtraMetadataSuccess) {
      syncTableMetadata();
    }
  }, [isMetadataSuccess, isExtraMetadataSuccess, syncTableMetadata]);

  const [sortColumns, setSortColumns] = useState(false);
  const [hovered, setHovered] = useState(false);
  const tableNameRef = useRef<HTMLInputElement>(null);

  const setHover = (hovered: boolean) => {
    debounce(() => setHovered(hovered), 100)();
  };

  const removeTable = () => {
    dispatch(removeDataPreview(table));
    dispatch(removeTables([table]));
  };

  const toggleSortColumns = () => {
    setSortColumns(prevState => !prevState);
  };

  const refreshTableMetadata = () => {
    dispatch(
      tableApiUtil.invalidateTags([{ type: 'TableMetadatas', id: name }]),
    );
    dispatch(syncTable(table, tableData));
  };

  const renderWell = () => {
    let partitions;
    let metadata;
    if (tableData.partitions) {
      let partitionQuery;
      let partitionClipBoard;
      if (tableData.partitions.partitionQuery) {
        ({ partitionQuery } = tableData.partitions);
        const tt = t('Copy partition query to clipboard');
        partitionClipBoard = (
          <CopyToClipboard
            text={partitionQuery}
            shouldShowText={false}
            tooltipText={tt}
            copyNode={<i className="fa fa-clipboard" />}
          />
        );
      }
      const latest = Object.entries(tableData.partitions?.latest || [])
        .map(([key, value]) => `${key}=${value}`)
        .join('/');

      partitions = (
        <div>
          <small>
            {t('latest partition:')} {latest}
          </small>{' '}
          {partitionClipBoard}
        </div>
      );
    }

    if (tableData.metadata) {
      metadata = Object.entries(tableData.metadata).map(([key, value]) => (
        <div>
          <small>
            <strong>{key}:</strong> {value}
          </small>
        </div>
      ));
      if (!metadata?.length) {
        // hide metadata card view
        return null;
      }
    }

    if (!partitions) {
      // hide partition card view
      return null;
    }

    return (
      <Card size="small">
        {partitions}
        {metadata}
      </Card>
    );
  };

  const renderControls = () => {
    let keyLink;
    const KEYS_FOR_TABLE_TEXT = t('Keys for table');
    if (tableData?.indexes?.length) {
      keyLink = (
        <ModalTrigger
          modalTitle={`${KEYS_FOR_TABLE_TEXT} ${name}`}
          modalBody={tableData.indexes.map((ix, i) => (
            <pre key={i}>{JSON.stringify(ix, null, '  ')}</pre>
          ))}
          triggerNode={
            <IconTooltip
              className="fa fa-key pull-left m-l-2"
              tooltip={t('View keys & indexes (%s)', tableData.indexes.length)}
            />
          }
        />
      );
    }
    return (
      <ButtonGroup
        css={css`
          display: flex;
          column-gap: ${theme.gridUnit * 1.5}px;
          margin-right: ${theme.gridUnit}px;
          & span {
            display: flex;
            justify-content: center;
            width: ${theme.gridUnit * 4}px;
          }
        `}
      >
        <IconTooltip
          className="fa fa-refresh pull-left m-l-2 pointer"
          onClick={refreshTableMetadata}
          tooltip={t('Refresh table schema')}
        />
        {keyLink}
        <IconTooltip
          className={
            `fa fa-sort-${sortColumns ? 'numeric' : 'alpha'}-asc ` +
            'pull-left sort-cols m-l-2 pointer'
          }
          onClick={toggleSortColumns}
          tooltip={
            sortColumns
              ? t('Original table column order')
              : t('Sort columns alphabetically')
          }
        />
        {tableData.selectStar && (
          <CopyToClipboard
            copyNode={
              <IconTooltip
                aria-label="Copy"
                tooltip={t('Copy SELECT statement to the clipboard')}
              >
                <i aria-hidden className="fa fa-clipboard pull-left m-l-2" />
              </IconTooltip>
            }
            text={tableData.selectStar}
            shouldShowText={false}
          />
        )}
        {tableData.view && (
          <ShowSQL
            sql={tableData.view}
            tooltipText={t('Show CREATE VIEW statement')}
            title={t('CREATE VIEW statement')}
          />
        )}
        <IconTooltip
          className="fa fa-times table-remove pull-left m-l-2 pointer"
          onClick={removeTable}
          tooltip={t('Remove table preview')}
        />
      </ButtonGroup>
    );
  };

  const renderHeader = () => {
    const element: HTMLInputElement | null = tableNameRef.current;
    let trigger: string[] = [];
    if (element && element.offsetWidth < element.scrollWidth) {
      trigger = ['hover'];
    }

    return (
      <div
        data-test="table-element-header-container"
        className="clearfix header-container"
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
      >
        <Tooltip
          id="copy-to-clipboard-tooltip"
          style={{ cursor: 'pointer' }}
          title={name}
          trigger={trigger}
        >
          <StyledSpan
            data-test="collapse"
            ref={tableNameRef}
            className="table-name"
          >
            <strong>{name}</strong>
          </StyledSpan>
        </Tooltip>

        <div className="pull-right header-right-side">
          {isMetadataFetching || isExtraMetadataLoading ? (
            <Loading position="inline" />
          ) : (
            <Fade
              data-test="fade"
              hovered={hovered}
              onClick={e => e.stopPropagation()}
            >
              {renderControls()}
            </Fade>
          )}
        </div>
      </div>
    );
  };

  const renderBody = () => {
    let cols;
    if (tableData.columns) {
      cols = tableData.columns.slice();
      if (sortColumns) {
        cols.sort((a: Column, b: Column) => {
          const colA = a.name.toUpperCase();
          const colB = b.name.toUpperCase();
          return colA < colB ? -1 : colA > colB ? 1 : 0;
        });
      }
    }

    const metadata = (
      <div
        data-test="table-element"
        onMouseEnter={() => setHover(true)}
        onMouseLeave={() => setHover(false)}
        css={{ paddingTop: 6 }}
      >
        {renderWell()}
        <div>
          {cols?.map(col => <ColumnElement column={col} key={col.name} />)}
        </div>
      </div>
    );
    return metadata;
  };

  return (
    <StyledCollapsePanel
      {...props}
      key={table.id}
      header={renderHeader()}
      className="TableElement"
      forceRender
    >
      {renderBody()}
    </StyledCollapsePanel>
  );
};

export default TableElement;