airbnb/superset

View on GitHub
superset-frontend/src/components/MetadataBar/MetadataBar.tsx

Summary

Maintainability
A
2 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { uniqWith } from 'lodash';
import { styled } from '@superset-ui/core';
import { Tooltip, TooltipPlacement } from 'src/components/Tooltip';
import { ContentType } from './ContentType';
import { config } from './ContentConfig';

export const MIN_NUMBER_ITEMS = 2;
export const MAX_NUMBER_ITEMS = 6;

const HORIZONTAL_PADDING = 12;
const VERTICAL_PADDING = 8;
const ICON_PADDING = 8;
const SPACE_BETWEEN_ITEMS = 16;
const ICON_WIDTH = 16;
const TEXT_MIN_WIDTH = 70;
const TEXT_MAX_WIDTH = 150;
const ORDER = {
  dashboards: 0,
  table: 1,
  sql: 2,
  rows: 3,
  tags: 4,
  description: 5,
  owner: 6,
  lastModified: 7,
};

const Bar = styled.div<{ count: number }>`
  ${({ theme, count }) => `
    display: flex;
    align-items: center;
    padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
    background-color: ${theme.colors.grayscale.light4};
    color: ${theme.colors.grayscale.base};
    font-size: ${theme.typography.sizes.s}px;
    min-width: ${
      HORIZONTAL_PADDING * 2 +
      (ICON_WIDTH + SPACE_BETWEEN_ITEMS) * count -
      SPACE_BETWEEN_ITEMS
    }px;
    border-radius: ${theme.borderRadius}px;
    line-height: 1;
  `}
`;

const StyledItem = styled.div<{
  collapsed: boolean;
  last: boolean;
  onClick?: () => void;
}>`
  ${({ theme, collapsed, last, onClick }) => `
    display: flex;
    align-items: center;
    max-width: ${
      ICON_WIDTH +
      ICON_PADDING +
      TEXT_MAX_WIDTH +
      (last ? 0 : SPACE_BETWEEN_ITEMS)
    }px;
    min-width: ${
      collapsed
        ? ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)
        : ICON_WIDTH +
          ICON_PADDING +
          TEXT_MIN_WIDTH +
          (last ? 0 : SPACE_BETWEEN_ITEMS)
    }px;
    padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px;
    cursor: ${onClick ? 'pointer' : 'default'};
    & .metadata-icon {
      color: ${
        onClick && collapsed
          ? theme.colors.primary.base
          : theme.colors.grayscale.base
      };
      padding-right: ${collapsed ? 0 : ICON_PADDING}px;
      & .anticon {
        line-height: 0;
      }
    }
    & .metadata-text {
      min-width: ${TEXT_MIN_WIDTH}px;
      overflow: hidden;
      text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
      white-space: nowrap;
      text-decoration: ${onClick ? 'underline' : 'none'};
      line-height: 1.4;
    }
  `}
`;

// Make sure big tootips are truncated
const TootipContent = styled.div`
  display: -webkit-box;
  -webkit-line-clamp: 20;
  -webkit-box-orient: vertical;
  overflow: hidden;
  text-overflow: ellipsis;
`;

const Item = ({
  barWidth,
  contentType,
  collapsed,
  last = false,
  tooltipPlacement,
}: {
  barWidth: number | undefined;
  contentType: ContentType;
  collapsed: boolean;
  last?: boolean;
  tooltipPlacement: TooltipPlacement;
}) => {
  const { icon, title, tooltip = title } = config(contentType);
  const [isTruncated, setIsTruncated] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const Icon = icon;
  const { type, onClick } = contentType;

  useEffect(() => {
    setIsTruncated(
      ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false,
    );
  }, [barWidth, setIsTruncated, contentType]);

  const content = (
    <StyledItem
      collapsed={collapsed}
      last={last}
      onClick={onClick ? () => onClick(type) : undefined}
      role={onClick ? 'button' : undefined}
    >
      <Icon iconSize="l" className="metadata-icon" />
      {!collapsed && (
        <span ref={ref} className="metadata-text">
          {title}
        </span>
      )}
    </StyledItem>
  );
  return isTruncated || collapsed || (tooltip && tooltip !== title) ? (
    <Tooltip
      placement={tooltipPlacement}
      title={<TootipContent>{tooltip}</TootipContent>}
    >
      {content}
    </Tooltip>
  ) : (
    content
  );
};

export interface MetadataBarProps {
  /**
   * Array of content type configurations. To see the available properties
   * for each content type, check {@link ContentType}
   */
  items: ContentType[];
  /**
   * Antd tooltip placement. To see available values, check {@link TooltipPlacement}.
   * Defaults to "top".
   */
  tooltipPlacement?: TooltipPlacement;
}

/**
 * The metadata bar component is used to display additional information about an entity.
 * Content types are predefined and consistent across the whole app. This means that
 * they will be displayed and behave in a consistent manner, keeping the same ordering,
 * information formatting, and interactions.
 * To extend the list of content types, a developer needs to request the inclusion of the new type in the design system.
 * This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
 */
const MetadataBar = ({ items, tooltipPlacement = 'top' }: MetadataBarProps) => {
  const [width, setWidth] = useState<number>();
  const [collapsed, setCollapsed] = useState(false);
  const uniqueItems = uniqWith(items, (a, b) => a.type === b.type);
  const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]);
  const count = sortedItems.length;
  if (count < MIN_NUMBER_ITEMS) {
    throw Error('The minimum number of items for the metadata bar is 2.');
  }
  if (count > MAX_NUMBER_ITEMS) {
    throw Error('The maximum number of items for the metadata bar is 6.');
  }

  const onResize = useCallback(
    width => {
      // Calculates the breakpoint width to collapse the bar.
      // The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
      const breakpoint =
        (ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) *
          count -
        SPACE_BETWEEN_ITEMS;
      setWidth(width);
      setCollapsed(Boolean(width && width < breakpoint));
    },
    [count],
  );

  const { ref } = useResizeDetector({ onResize });

  return (
    <Bar ref={ref} count={count} data-test="metadata-bar">
      {sortedItems.map((item, index) => (
        <Item
          barWidth={width}
          key={index}
          contentType={item}
          collapsed={collapsed}
          last={index === count - 1}
          tooltipPlacement={tooltipPlacement}
        />
      ))}
    </Bar>
  );
};

export default MetadataBar;