airbnb/caravel

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

Summary

Maintainability
B
4 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 {
  CSSProperties,
  ReactNode,
  useEffect,
  useMemo,
  useRef,
  useState,
  PropsWithChildren,
  RefObject,
  FC,
} from 'react';

import { t, isFeatureEnabled, FeatureFlag, css } from '@superset-ui/core';
import ImageLoader from 'src/components/ListViewCard/ImageLoader';
import { usePluginContext } from 'src/components/DynamicPlugins';
import { Tooltip } from 'src/components/Tooltip';
import { GenericLink } from 'src/components/GenericLink/GenericLink';
import { Theme } from '@emotion/react';

const FALLBACK_THUMBNAIL_URL = '/static/assets/images/chart-card-fallback.svg';

const TruncatedTextWithTooltip = ({
  children,
  tooltipText,
  ...props
}: PropsWithChildren<{
  tooltipText?: string;
}>) => {
  // Uses React.useState for testing purposes
  const [isTruncated, setIsTruncated] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    setIsTruncated(
      ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false,
    );
  }, [children]);

  const div = (
    <div
      {...props}
      ref={ref}
      css={css`
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        display: block;
      `}
    >
      {children}
    </div>
  );

  return isTruncated ? (
    <Tooltip title={tooltipText || children}>{div}</Tooltip>
  ) : (
    div
  );
};

const MetadataItem: FC<{
  label: ReactNode;
  value: ReactNode;
  tooltipText?: string;
}> = ({ label, value, tooltipText }) => (
  <div
    css={(theme: Theme) => css`
      font-size: ${theme.typography.sizes.s}px;
      display: flex;
      justify-content: space-between;

      &:not(:last-child) {
        margin-bottom: ${theme.gridUnit}px;
      }
    `}
  >
    <span
      css={(theme: Theme) => css`
        margin-right: ${theme.gridUnit * 4}px;
        color: ${theme.colors.grayscale.base};
      `}
    >
      {label}
    </span>
    <span
      css={css`
        min-width: 0;
      `}
    >
      <TruncatedTextWithTooltip tooltipText={tooltipText}>
        {value}
      </TruncatedTextWithTooltip>
    </span>
  </div>
);

const SliceAddedBadgePlaceholder: FC<{
  showThumbnails?: boolean;
  placeholderRef: (element: HTMLDivElement) => void;
}> = ({ showThumbnails, placeholderRef }) => (
  <div
    ref={placeholderRef}
    css={(theme: Theme) => css`
      /* Display styles */
      border: 1px solid ${theme.colors.primary.dark1};
      border-radius: ${theme.gridUnit}px;
      color: ${theme.colors.primary.dark1};
      font-size: ${theme.typography.sizes.xs}px;
      letter-spacing: 0.02em;
      padding: ${theme.gridUnit / 2}px ${theme.gridUnit * 2}px;
      margin-left: ${theme.gridUnit * 4}px;
      pointer-events: none;

      /* Position styles */
      visibility: hidden;
      position: ${showThumbnails ? 'absolute' : 'unset'};
      top: ${showThumbnails ? '72px' : 'unset'};
      left: ${showThumbnails ? '84px' : 'unset'};
    `}
  >
    {t('Added')}
  </div>
);

const SliceAddedBadge: FC<{ placeholder?: HTMLDivElement }> = ({
  placeholder,
}) => (
  <div
    css={(theme: Theme) => css`
      /* Display styles */
      border: 1px solid ${theme.colors.primary.dark1};
      border-radius: ${theme.gridUnit}px;
      color: ${theme.colors.primary.dark1};
      font-size: ${theme.typography.sizes.xs}px;
      letter-spacing: 0.02em;
      padding: ${theme.gridUnit / 2}px ${theme.gridUnit * 2}px;
      margin-left: ${theme.gridUnit * 4}px;
      pointer-events: none;

      /* Position styles */
      display: ${placeholder ? 'unset' : 'none'};
      position: absolute;
      top: ${placeholder ? `${placeholder.offsetTop}px` : 'unset'};
      left: ${placeholder ? `${placeholder.offsetLeft - 2}px` : 'unset'};
    `}
  >
    {t('Added')}
  </div>
);

const AddSliceCard: FC<{
  datasourceUrl?: string;
  datasourceName?: string;
  innerRef?: RefObject<HTMLDivElement>;
  isSelected?: boolean;
  lastModified?: string;
  sliceName: string;
  style?: CSSProperties;
  thumbnailUrl?: string;
  visType: string;
}> = ({
  datasourceUrl,
  datasourceName = '-',
  innerRef,
  isSelected = false,
  lastModified,
  sliceName,
  style = {},
  thumbnailUrl,
  visType,
}) => {
  const showThumbnails = isFeatureEnabled(FeatureFlag.Thumbnails);
  const [sliceAddedBadge, setSliceAddedBadge] = useState<HTMLDivElement>();
  const { mountedPluginMetadata } = usePluginContext();
  const vizName = useMemo(
    () => mountedPluginMetadata[visType]?.name || t('Unknown type'),
    [mountedPluginMetadata, visType],
  );

  return (
    <div ref={innerRef} style={style}>
      <div
        data-test="chart-card"
        css={(theme: Theme) => css`
          border: 1px solid ${theme.colors.grayscale.light2};
          border-radius: ${theme.gridUnit}px;
          background: ${theme.colors.grayscale.light5};
          padding: ${theme.gridUnit * 4}px;
          margin: 0 ${theme.gridUnit * 3}px ${theme.gridUnit * 3}px
            ${theme.gridUnit * 3}px;
          position: relative;
          cursor: ${isSelected ? 'not-allowed' : 'move'};
          white-space: nowrap;
          overflow: hidden;
          line-height: 1.3;
          color: ${theme.colors.grayscale.dark1};

          &:hover {
            background: ${theme.colors.grayscale.light4};
          }

          opacity: ${isSelected ? 0.4 : 'unset'};
        `}
      >
        <div
          css={css`
            display: flex;
          `}
        >
          {showThumbnails ? (
            <div
              data-test="thumbnail"
              css={css`
                width: 146px;
                height: 82px;
                flex-shrink: 0;
                margin-right: 16px;
              `}
            >
              <ImageLoader
                src={thumbnailUrl || ''}
                fallback={FALLBACK_THUMBNAIL_URL}
                position="top"
              />
              {isSelected && showThumbnails ? (
                <SliceAddedBadgePlaceholder
                  placeholderRef={setSliceAddedBadge}
                  showThumbnails={showThumbnails}
                />
              ) : null}
            </div>
          ) : null}
          <div
            css={css`
              flex-grow: 1;
              min-width: 0;
            `}
          >
            <div
              data-test="card-title"
              css={(theme: Theme) => css`
                margin-bottom: ${theme.gridUnit * 2}px;
                font-weight: ${theme.typography.weights.bold};
                display: flex;
                justify-content: space-between;
                align-items: center;
              `}
            >
              <TruncatedTextWithTooltip>{sliceName}</TruncatedTextWithTooltip>
              {isSelected && !showThumbnails ? (
                <SliceAddedBadgePlaceholder
                  placeholderRef={setSliceAddedBadge}
                />
              ) : null}
            </div>
            <div
              css={css`
                display: flex;
                flex-direction: column;
              `}
            >
              <MetadataItem label={t('Viz type')} value={vizName} />
              <MetadataItem
                label={t('Dataset')}
                value={
                  datasourceUrl ? (
                    <GenericLink to={datasourceUrl}>
                      {datasourceName}
                    </GenericLink>
                  ) : (
                    datasourceName
                  )
                }
                tooltipText={datasourceName}
              />
              <MetadataItem label={t('Modified')} value={lastModified} />
            </div>
          </div>
        </div>
      </div>
      <SliceAddedBadge placeholder={sliceAddedBadge} />
    </div>
  );
};

export default AddSliceCard;