Skip to content
Snippets Groups Projects
HierarchicalFacet.js 12.5 KiB
Newer Older
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
esikkala's avatar
esikkala committed
import { has } from 'lodash';
esikkala's avatar
esikkala committed
import SortableTree, { changeNodeAtPath } from 'react-sortable-tree';
import FileExplorerTheme from 'react-sortable-tree-theme-file-explorer';
import Checkbox from '@material-ui/core/Checkbox';
import FormControlLabel from '@material-ui/core/FormControlLabel';
esikkala's avatar
esikkala committed
import CircularProgress from '@material-ui/core/CircularProgress';
import purple from '@material-ui/core/colors/purple';
import Input from '@material-ui/core/Input';
import IconButton from '@material-ui/core/IconButton';
import NavigateNextIcon from '@material-ui/icons/NavigateNext';
import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
import Typography from '@material-ui/core/Typography';

const styles = () => ({
  facetSearchContainer: {
    width: '100%',
    height: 44,
    display: 'flex',
    alignItems: 'center'
  },
  facetSearchIconButton: {
    padding: 10
  },
  treeContainer: {
esikkala's avatar
esikkala committed
    flex: 1
  },
  treeContainerWithSearchField: {
esikkala's avatar
esikkala committed
    width: '100%',
    flex: 1,
esikkala's avatar
esikkala committed
  spinnerContainer: {
    display: 'flex',
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center'
  },
esikkala's avatar
esikkala committed
  checkbox: {
    padding: 0,
    marginLeft: 6,
    marginRight: 4,
  },
  sdbmLabel: {
    color: '#00796B'
  },
  bodleyLabel: {
    color: '#F50057'
  },
  bibaleLabel: {
    color: '#F57F17'
esikkala's avatar
esikkala committed
  },
  facetLink: {
    textDecoration: 'inherit'
esikkala's avatar
esikkala committed
/*
This component is based on the React Sortable Tree example at:
https://frontend-collective.github.io/react-sortable-tree/storybook/?selectedKind=Basics&selectedStory=Search&full=0&addons=0&stories=1&panelRight=0
*/
esikkala's avatar
esikkala committed
class HierarchicalFacet extends Component {
  constructor(props) {
    super(props);
    this.state = {
esikkala's avatar
esikkala committed
      treeData: [],
      searchString: '',
      searchFocusIndex: 0,
      searchFoundCount: null,
    };
  }

esikkala's avatar
esikkala committed
  componentDidMount = () => {
esikkala's avatar
esikkala committed
    // console.log(`${this.props.facetID} mounted`);
    if (this.props.facet.filterType === 'uriFilter') {
      this.props.fetchFacet({
        facetClass: this.props.facetClass,
        facetID: this.props.facetID,
      });
    }
esikkala's avatar
esikkala committed
  }

  componentDidUpdate = prevProps => {
esikkala's avatar
esikkala committed
    if (prevProps.facetUpdateID !== this.props.facetUpdateID) {
esikkala's avatar
esikkala committed

esikkala's avatar
esikkala committed
      // update component state if the user modified this facet
esikkala's avatar
esikkala committed
      if (this.props.updatedFacet === this.props.facetID) {
        if (has(this.props.updatedFilter, 'path')) {
          const treeObj = this.props.updatedFilter;
          let newTreeData = changeNodeAtPath({
esikkala's avatar
esikkala committed
            treeData: this.state.treeData,
            getNodeKey: ({ treeIndex }) =>  treeIndex,
            path: treeObj.path,
            newNode: () => {
              const oldNode = treeObj.node;
              if (has(oldNode, 'children')) {
                return {
                  ...oldNode,
                  selected: treeObj.added ? 'true' : 'false',
                  children: this.recursiveSelect(oldNode.children, treeObj.added)
                };
              } else {
                return {
                  ...oldNode,
                  selected: treeObj.added ? 'true' : 'false',
                };
              }
            }
esikkala's avatar
esikkala committed
          });
          this.setState({ treeData: newTreeData });
        }
esikkala's avatar
esikkala committed
      }
      // else fetch new values, because some other facet was updated
      else {
        this.props.fetchFacet({
          facetClass: this.props.facetClass,
          facetID: this.props.facetID,
        });
      }
esikkala's avatar
esikkala committed
    }
esikkala's avatar
esikkala committed

    // fetch new values if the user changes the filter type or sort order
    if (prevProps.facet.filterType !== this.props.facet.filterType
      && this.props.facet.filterType === 'uriFilter') {
      this.props.fetchFacet({
        facetClass: this.props.facetClass,
        facetID: this.props.facetID,
      });
    }
    if (prevProps.facet.sortBy !== this.props.facet.sortBy) {
      this.props.fetchFacet({
        facetClass: this.props.facetClass,
        facetID: this.props.facetID,
      });
    }
esikkala's avatar
esikkala committed

    // when values have been fetched, update component's state
    if (prevProps.facet.values != this.props.facet.values) {
      this.setState({
        treeData: this.props.facet.values
      });
    }
esikkala's avatar
esikkala committed
  }

  recursiveSelect = (nodes, selected) => {
    nodes.forEach(node => {
      // if a child has been previously selected, remove it
      if (has(this.props.facet.uriFilter, node.id)) {
        this.props.updateFacetOption({
          facetClass: this.props.facetClass,
          facetID: this.props.facetID,
          option: this.props.facet.filterType,
          value: { node }
        });
      }
      node.selected = selected ? 'true' : 'false';
      node.disabled = selected ? 'true' : 'false';
      if (has(node, 'children')) {
        this.recursiveSelect(node.children, selected);
      }
    });
    return nodes;
  };

esikkala's avatar
esikkala committed
  handleCheckboxChange = treeObj => () => {
    this.props.updateFacetOption({
      facetClass: this.props.facetClass,
      facetID: this.props.facetID,
      option: this.props.facet.filterType,
esikkala's avatar
esikkala committed
      value: treeObj
esikkala's avatar
esikkala committed
    });
  handleSearchFieldOnChange = event => {
    this.setState({ searchString: event.target.value });
  }

  generateNodeProps = treeObj => {
    const { uriFilter} = this.props.facet;
    const { node } = treeObj;
    let selectedCount = uriFilter == null ? 0 : Object.keys(this.props.facet.uriFilter).length;
    let isSelected = node.selected === 'true' ? true : false;
    return {
      title: (
        <FormControlLabel
          control={
            <Checkbox
              className={this.props.classes.checkbox}
              checked={isSelected}
                /* non-hierarchical facet:
                prevent selecting values with 0 hits (which may appear based on earlier selections): */
                (this.props.facet.type !== 'hierarchical'
                && node.instanceCount == 0
                && node.selected === 'false')
                // prevent selecting unknown value:
                || node.id == 'http://ldf.fi/MISSING_VALUE'
                // prevent selecting when another facet is still updating:
                || this.props.someFacetIsFetching
                // prevent selecting all facet values:
esikkala's avatar
esikkala committed
                || (selectedCount >= this.props.facet.distinctValueCount - 1 && !isSelected)
                // prevent selecting when parent has been selected
                || node.disabled === 'true'
              onChange={this.handleCheckboxChange(treeObj)}
              value={treeObj.node.id}
              color="primary"
            />
          }
          label={this.generateLabel(treeObj.node)}
          classes={{
            label: this.generateLabelClass(this.props.classes, treeObj.node)
          }}
        />
      )
    };
  };

esikkala's avatar
esikkala committed
  generateLabel = node => {
    let count = node.totalInstanceCount == null || node.totalInstanceCount == 0 ? node.instanceCount : node.totalInstanceCount;
    let missingValue = node.id === 'http://ldf.fi/MISSING_VALUE';
esikkala's avatar
esikkala committed
    return (
      <React.Fragment>
        <Typography variant='body2'>
          {!missingValue &&
            <a
              className={this.props.classes.facetLink}
              target='_blank' rel='noopener noreferrer'
              href={node.id}
            >
              {node.prefLabel}
            </a>
          }
          {missingValue && node.prefLabel}
esikkala's avatar
esikkala committed
          <span> [{count}]</span>
esikkala's avatar
esikkala committed
      </React.Fragment>
    );
esikkala's avatar
esikkala committed
  }

  generateLabelClass = classes => {
    let labelClass = classes.label;
    // if (this.props.facetID === 'author' || this.props.facetID === 'source') {
    //   if (node.source === 'http://ldf.fi/mmm/schema/SDBM' || node.id === 'http://ldf.fi/mmm/schema/SDBM') {
    //     labelClass = classes.sdbmLabel;
    //   }
    //   if (node.source === 'http://ldf.fi/mmm/schema/Bodley' || node.id === 'http://ldf.fi/mmm/schema/Bodley') {
    //     labelClass = classes.bodleyLabel;
    //   }
    //   if (node.source === 'http://ldf.fi/mmm/schema/Bibale' || node.id === 'http://ldf.fi/mmm/schema/Bibale') {
    //     labelClass = classes.bibaleLabel;
    //   }
    // }
    return labelClass;
  }

    const { searchString, searchFocusIndex, searchFoundCount } = this.state;
    const { classes, facet } = this.props;
    const { isFetching, searchField } = facet;
esikkala's avatar
esikkala committed
    // if (this.props.facetID == 'owner') {
    //   console.log(this.state.treeData)
    // }

    // Case insensitive search of `node.title`
    const customSearchMethod = ({ node, searchQuery }) => {
      let prefLabel = Array.isArray(node.prefLabel) ? node.prefLabel[0] : node.prefLabel;
      return searchQuery.length > 2  &&
      prefLabel.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1;
    };
    const selectPrevMatch = () =>
      this.setState({
        searchFocusIndex:
          searchFocusIndex !== null
            ? (searchFoundCount + searchFocusIndex - 1) % searchFoundCount
            : searchFoundCount - 1,
      });
    const selectNextMatch = () =>
      this.setState({
        searchFocusIndex:
          searchFocusIndex !== null
            ? (searchFocusIndex + 1) % searchFoundCount
            : 0,
      });
esikkala's avatar
esikkala committed
      <React.Fragment>
        {isFetching ?
esikkala's avatar
esikkala committed
          <div className={classes.spinnerContainer}>
            <CircularProgress style={{ color: purple[500] }} thickness={5} />
          </div>
          :
          <React.Fragment>
            {searchField && facet.filterType !== 'spatialFilter' &&
              <div className={classes.facetSearchContainer}>
                <Input
                  placeholder={`Search...`}
                  onChange={this.handleSearchFieldOnChange}
                >
                </Input>
                {searchFoundCount > 0 &&
                  <React.Fragment>
                    <IconButton
                      className={classes.facetSearchIconButton}
                      aria-label="Previous"
                      onClick={selectPrevMatch}
                    >
                      <NavigateBeforeIcon />
                    </IconButton>
                    <IconButton
                      className={classes.facetSearchIconButton}
                      aria-label="Next"
                      onClick={selectNextMatch}
                    >
                      <NavigateNextIcon />
                    </IconButton>
                    <Typography>
                      {searchFoundCount > 0 ? searchFocusIndex + 1 : 0} / {searchFoundCount || 0}
                    </Typography>
                  </React.Fragment>
                }
              </div>
esikkala's avatar
esikkala committed
            }
            {facet.filterType !== 'spatialFilter' &&
              <div className={searchField ? classes.treeContainerWithSearchField : classes.treeContainer }>
                <SortableTree
                  treeData={this.state.treeData}
                  onChange={treeData => this.setState({ treeData })}
                  canDrag={false}
                  rowHeight={30}
                  searchMethod={customSearchMethod}
                  searchQuery={searchString}
                  searchFocusOffset={searchFocusIndex}
                  searchFinishCallback={matches =>
                    this.setState({
                      searchFoundCount: matches.length,
                      searchFocusIndex:
                          matches.length > 0 ? searchFocusIndex % matches.length : 0,
                    })
                  }
                  onlyExpandSearchedNodes={true}
                  theme={FileExplorerTheme}
                  generateNodeProps={this.generateNodeProps}
                />
              </div>}
            {facet.filterType === 'spatialFilter' &&
              <div className={classes.spinnerContainer}>
                <Typography>
                  Draw a bounding box on the map to filter by {this.props.facet.label.toLowerCase()}.
                </Typography>
              </div>
            }
          </React.Fragment>
esikkala's avatar
esikkala committed
        }
      </React.Fragment>
esikkala's avatar
esikkala committed
HierarchicalFacet.propTypes = {
  classes: PropTypes.object.isRequired,
  facetID: PropTypes.string.isRequired,
  facet: PropTypes.object.isRequired,
esikkala's avatar
esikkala committed
  facetClass: PropTypes.string,
  resultClass: PropTypes.string,
  fetchFacet: PropTypes.func,
  someFacetIsFetching: PropTypes.bool.isRequired,
  updateFacetOption: PropTypes.func,
esikkala's avatar
esikkala committed
  facetUpdateID: PropTypes.number,
  updatedFilter: PropTypes.oneOfType([
    PropTypes.object,
    PropTypes.string,
    PropTypes.array]),
  updatedFacet: PropTypes.string,
esikkala's avatar
esikkala committed
export default withStyles(styles)(HierarchicalFacet);