airbnb/caravel

View on GitHub
superset-frontend/src/dashboard/components/SliceAdder.jsx

Summary

Maintainability
C
1 day
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.
 */
/* eslint-env browser */
import { Component } from 'react';
import PropTypes from 'prop-types';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { createFilter } from 'react-search-input';
import { t, styled, css } from '@superset-ui/core';
import { Input } from 'src/components/Input';
import { Select } from 'src/components';
import Loading from 'src/components/Loading';
import Button from 'src/components/Button';
import Icons from 'src/components/Icons';
import {
  LocalStorageKeys,
  getItem,
  setItem,
} from 'src/utils/localStorageHelpers';
import {
  CHART_TYPE,
  NEW_COMPONENT_SOURCE_TYPE,
} from 'src/dashboard/util/componentTypes';
import {
  NEW_CHART_ID,
  NEW_COMPONENTS_SOURCE_ID,
} from 'src/dashboard/util/constants';
import { slicePropShape } from 'src/dashboard/util/propShapes';
import { debounce, pickBy } from 'lodash';
import Checkbox from 'src/components/Checkbox';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import DragDroppable from './dnd/DragDroppable';

const propTypes = {
  fetchSlices: PropTypes.func.isRequired,
  updateSlices: PropTypes.func.isRequired,
  isLoading: PropTypes.bool.isRequired,
  slices: PropTypes.objectOf(slicePropShape).isRequired,
  lastUpdated: PropTypes.number.isRequired,
  errorMessage: PropTypes.string,
  userId: PropTypes.number.isRequired,
  selectedSliceIds: PropTypes.arrayOf(PropTypes.number),
  editMode: PropTypes.bool,
  dashboardId: PropTypes.number,
};

const defaultProps = {
  selectedSliceIds: [],
  editMode: false,
  errorMessage: '',
};

const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
  slice_name: t('name'),
  viz_type: t('viz type'),
  datasource_name: t('dataset'),
  changed_on: t('recent'),
};

export const DEFAULT_SORT_KEY = 'changed_on';

const DEFAULT_CELL_HEIGHT = 128;

const Controls = styled.div`
  ${({ theme }) => `
    display: flex;
    flex-direction: row;
    padding:
      ${theme.gridUnit * 4}px
      ${theme.gridUnit * 3}px
      ${theme.gridUnit * 4}px
      ${theme.gridUnit * 3}px;
  `}
`;

const StyledSelect = styled(Select)`
  margin-left: ${({ theme }) => theme.gridUnit * 2}px;
  min-width: 150px;
`;

const NewChartButtonContainer = styled.div`
  ${({ theme }) => css`
    display: flex;
    justify-content: flex-end;
    padding-right: ${theme.gridUnit * 2}px;
  `}
`;

const NewChartButton = styled(Button)`
  ${({ theme }) => css`
    height: auto;
    & > .anticon + span {
      margin-left: 0;
    }
    & > [role='img']:first-of-type {
      margin-right: ${theme.gridUnit}px;
      padding-bottom: 1px;
      line-height: 0;
    }
  `}
`;

export const ChartList = styled.div`
  flex-grow: 1;
  min-height: 0;
`;

class SliceAdder extends Component {
  static sortByComparator(attr) {
    const desc = attr === 'changed_on' ? -1 : 1;

    return (a, b) => {
      if (a[attr] < b[attr]) {
        return -1 * desc;
      }
      if (a[attr] > b[attr]) {
        return 1 * desc;
      }
      return 0;
    };
  }

  constructor(props) {
    super(props);
    this.state = {
      filteredSlices: [],
      searchTerm: '',
      sortBy: DEFAULT_SORT_KEY,
      selectedSliceIdsSet: new Set(props.selectedSliceIds),
      showOnlyMyCharts: getItem(
        LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
        true,
      ),
    };
    this.rowRenderer = this.rowRenderer.bind(this);
    this.searchUpdated = this.searchUpdated.bind(this);
    this.handleSelect = this.handleSelect.bind(this);
    this.userIdForFetch = this.userIdForFetch.bind(this);
    this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
  }

  userIdForFetch() {
    return this.state.showOnlyMyCharts ? this.props.userId : undefined;
  }

  componentDidMount() {
    this.slicesRequest = this.props.fetchSlices(this.userIdForFetch());
  }

  UNSAFE_componentWillReceiveProps(nextProps) {
    const nextState = {};
    if (nextProps.lastUpdated !== this.props.lastUpdated) {
      nextState.filteredSlices = this.getFilteredSortedSlices(
        nextProps.slices,
        this.state.searchTerm,
        this.state.sortBy,
        this.state.showOnlyMyCharts,
      );
    }

    if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
      nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
    }

    if (Object.keys(nextState).length) {
      this.setState(nextState);
    }
  }

  componentWillUnmount() {
    // Clears the redux store keeping only selected items
    const selectedSlices = pickBy(this.props.slices, value =>
      this.state.selectedSliceIdsSet.has(value.slice_id),
    );
    this.props.updateSlices(selectedSlices);
    if (this.slicesRequest && this.slicesRequest.abort) {
      this.slicesRequest.abort();
    }
  }

  getFilteredSortedSlices(slices, searchTerm, sortBy, showOnlyMyCharts) {
    return Object.values(slices)
      .filter(slice =>
        showOnlyMyCharts
          ? (slice.owners &&
              slice.owners.find(owner => owner.id === this.props.userId)) ||
            (slice.created_by && slice.created_by.id === this.props.userId)
          : true,
      )
      .filter(createFilter(searchTerm, KEYS_TO_FILTERS))
      .sort(SliceAdder.sortByComparator(sortBy));
  }

  handleChange = debounce(value => {
    this.searchUpdated(value);
    this.slicesRequest = this.props.fetchSlices(
      this.userIdForFetch(),
      value,
      this.state.sortBy,
    );
  }, 300);

  searchUpdated(searchTerm) {
    this.setState(prevState => ({
      searchTerm,
      filteredSlices: this.getFilteredSortedSlices(
        this.props.slices,
        searchTerm,
        prevState.sortBy,
        prevState.showOnlyMyCharts,
      ),
    }));
  }

  handleSelect(sortBy) {
    this.setState(prevState => ({
      sortBy,
      filteredSlices: this.getFilteredSortedSlices(
        this.props.slices,
        prevState.searchTerm,
        sortBy,
        prevState.showOnlyMyCharts,
      ),
    }));
    this.slicesRequest = this.props.fetchSlices(
      this.userIdForFetch(),
      this.state.searchTerm,
      sortBy,
    );
  }

  rowRenderer({ key, index, style }) {
    const { filteredSlices, selectedSliceIdsSet } = this.state;
    const cellData = filteredSlices[index];
    const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
    const type = CHART_TYPE;
    const id = NEW_CHART_ID;

    const meta = {
      chartId: cellData.slice_id,
      sliceName: cellData.slice_name,
    };
    return (
      <DragDroppable
        key={key}
        component={{ type, id, meta }}
        parentComponent={{
          id: NEW_COMPONENTS_SOURCE_ID,
          type: NEW_COMPONENT_SOURCE_TYPE,
        }}
        index={index}
        depth={0}
        disableDragDrop={isSelected}
        editMode={this.props.editMode}
        // we must use a custom drag preview within the List because
        // it does not seem to work within a fixed-position container
        useEmptyDragPreview
        // List library expect style props here
        // actual style should be applied to nested AddSliceCard component
        style={{}}
      >
        {({ dragSourceRef }) => (
          <AddSliceCard
            innerRef={dragSourceRef}
            style={style}
            sliceName={cellData.slice_name}
            lastModified={cellData.changed_on_humanized}
            visType={cellData.viz_type}
            datasourceUrl={cellData.datasource_url}
            datasourceName={cellData.datasource_name}
            thumbnailUrl={cellData.thumbnail_url}
            isSelected={isSelected}
          />
        )}
      </DragDroppable>
    );
  }

  onShowOnlyMyCharts(showOnlyMyCharts) {
    if (!showOnlyMyCharts) {
      this.slicesRequest = this.props.fetchSlices(
        undefined,
        this.state.searchTerm,
        this.state.sortBy,
      );
    }
    this.setState(prevState => ({
      showOnlyMyCharts,
      filteredSlices: this.getFilteredSortedSlices(
        this.props.slices,
        prevState.searchTerm,
        prevState.sortBy,
        showOnlyMyCharts,
      ),
    }));
    setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
  }

  render() {
    return (
      <div
        css={css`
          height: 100%;
          display: flex;
          flex-direction: column;
        `}
      >
        <NewChartButtonContainer>
          <NewChartButton
            buttonStyle="link"
            buttonSize="xsmall"
            onClick={() =>
              window.open(
                `/chart/add?dashboard_id=${this.props.dashboardId}`,
                '_blank',
                'noopener noreferrer',
              )
            }
          >
            <Icons.PlusSmall />
            {t('Create new chart')}
          </NewChartButton>
        </NewChartButtonContainer>
        <Controls>
          <Input
            placeholder={
              this.state.showOnlyMyCharts
                ? t('Filter your charts')
                : t('Filter charts')
            }
            className="search-input"
            onChange={ev => this.handleChange(ev.target.value)}
            data-test="dashboard-charts-filter-search-input"
          />
          <StyledSelect
            id="slice-adder-sortby"
            value={this.state.sortBy}
            onChange={this.handleSelect}
            options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
              label: t('Sort by %s', label),
              value: key,
            }))}
            placeholder={t('Sort by')}
          />
        </Controls>
        <div
          css={theme => css`
            display: flex;
            flex-direction: row;
            justify-content: flex-start;
            align-items: center;
            gap: ${theme.gridUnit}px;
            padding: 0 ${theme.gridUnit * 3}px ${theme.gridUnit * 4}px
              ${theme.gridUnit * 3}px;
          `}
        >
          <Checkbox
            onChange={this.onShowOnlyMyCharts}
            checked={this.state.showOnlyMyCharts}
          />
          {t('Show only my charts')}
          <InfoTooltipWithTrigger
            placement="top"
            tooltip={t(
              `You can choose to display all charts that you have access to or only the ones you own.
              Your filter selection will be saved and remain active until you choose to change it.`,
            )}
          />
        </div>
        {this.props.isLoading && <Loading />}
        {!this.props.isLoading && this.state.filteredSlices.length > 0 && (
          <ChartList>
            <AutoSizer>
              {({ height, width }) => (
                <List
                  width={width}
                  height={height}
                  itemCount={this.state.filteredSlices.length}
                  itemSize={DEFAULT_CELL_HEIGHT}
                  searchTerm={this.state.searchTerm}
                  sortBy={this.state.sortBy}
                  selectedSliceIds={this.props.selectedSliceIds}
                >
                  {this.rowRenderer}
                </List>
              )}
            </AutoSizer>
          </ChartList>
        )}
        {this.props.errorMessage && (
          <div
            css={css`
              padding: 16px;
            `}
          >
            {this.props.errorMessage}
          </div>
        )}
        {/* Drag preview is just a single fixed-position element */}
        <AddSliceDragPreview slices={this.state.filteredSlices} />
      </div>
    );
  }
}

SliceAdder.propTypes = propTypes;
SliceAdder.defaultProps = defaultProps;

export default SliceAdder;