Skip to content

Commit

Permalink
Merge pull request nextstrain#302 from nextstrain/dataset-columns-v2
Browse files Browse the repository at this point in the history
Pages can define which columns are rendered
  • Loading branch information
jameshadfield authored Apr 13, 2021
2 parents 97925b6 + 0c6e2e0 commit 903ccfb
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 122 deletions.
35 changes: 8 additions & 27 deletions static-site/src/components/Datasets/dataset-select.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import React from "react";
import PropTypes from 'prop-types';
import "react-select/dist/react-select.css";
import "react-virtualized-select/styles.css";
import { get, sortBy } from 'lodash';
import { FilterSelect } from "./filter-selection";
import { ListDatasets } from "./list-datasets";
import { FilterDisplay } from "./filter-display";
import { collectAvailableFilteringOptions, computeFilterValues } from "./filter-helpers";
import { collectAvailableFilteringOptions, computeFilterValues, getFilteredDatasets } from "./filter-helpers";

/**
* <DatasetSelect> is intended to render datasets [0] and expose a filtering UI to dynamically
Expand All @@ -16,7 +15,7 @@ import { collectAvailableFilteringOptions, computeFilterValues } from "./filter-
* @prop {string | undefined} urlDefinedFilterPath slash-separated keywords which will be applied as filters
* @prop {string | undefined} intendedUri Intended URI. Browser address will be replaced with this.
* @prop {Array} datasets Available datasets. Array of Objects.
* @prop {boolean} noDates Note: will be replaced in a subsequent commit
* @prop {Array} columns Columns to be rendered by the table. See <ListDatasets> for signature.
* @prop {Array | undefined} interface What elements to render? Elements may be strings or functinos. Order is respected.
* Available strings: "FilterSelect" "FilterDisplay", "ListDatasets"
* Functions will be handed an object with key(s): `datasets` (which may be filtered), and should return a react component for rendering.
Expand All @@ -34,11 +33,11 @@ class DatasetSelect extends React.Component {
filters: {},
};
this.applyFilter = (mode, trait, values) => {
const availableFilterValues = collectAvailableFilteringOptions(this.props.datasets).map((o) => o.value);
const availableFilterValues = collectAvailableFilteringOptions(this.props.datasets, this.props.columns)
.map((o) => o.value);
const filters = computeFilterValues(this.state.filters, availableFilterValues, mode, trait, values);
if (filters) this.setState({filters});
};
this.getFilteredDatasets = this.getFilteredDatasets.bind(this);
}

componentDidMount() {
Expand All @@ -55,27 +54,9 @@ class DatasetSelect extends React.Component {
}
}

buildMatchesFilter(build, filterName, filterObjects) {
const keywordArray = get(build, "filename").replace('.json', '').split("_");
return filterObjects.every((filter) => {
if (!filter.active) return true; // inactive filter is the same as a match
return keywordArray.includes(filter.value);
});
}

getFilteredDatasets() {
// TODO this doesn't care about categories
const filtered = this.props.datasets
.filter((b) => b.url !== undefined)
.filter((b) => Object.entries(this.state.filters)
.filter((filterEntry) => filterEntry[1].length)
.every(([filterName, filterValues]) => this.buildMatchesFilter(b, filterName, filterValues)));
return sortBy(filtered, [(d) => d.filename.toLowerCase()]);
}

render() {
const childrenToRender = this.props.interface || ["FilterSelect", "FilterDisplay", "ListDatasets"];
const filteredDatasets = this.getFilteredDatasets();
const filteredDatasets = getFilteredDatasets(this.props.datasets, this.state.filters, this.props.columns);
return (
<>
{childrenToRender.map((Child) => {
Expand All @@ -84,7 +65,7 @@ class DatasetSelect extends React.Component {
return (
<FilterSelect
key={String(Object.keys(this.state.filters).length)}
datasets={this.props.datasets}
options={collectAvailableFilteringOptions(this.props.datasets, this.props.columns)}
applyFilter={this.applyFilter}
/>
);
Expand All @@ -100,8 +81,8 @@ class DatasetSelect extends React.Component {
return (
<ListDatasets
key="ListDatasets"
columns={this.props.columns}
datasets={filteredDatasets}
showDates={!this.props.noDates}
/>
);
default:
Expand All @@ -126,7 +107,7 @@ DatasetSelect.propTypes = {
urlDefinedFilterPath: PropTypes.string,
intendedUri: PropTypes.string,
interface: PropTypes.array,
noDates: PropTypes.bool,
columns: PropTypes.array.isRequired,
datasets: PropTypes.array.isRequired
};

Expand Down
75 changes: 61 additions & 14 deletions static-site/src/components/Datasets/filter-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Most code and logic was originally lifted from Auspice
*/

import {get, sortBy } from 'lodash';
import { sortBy } from 'lodash';

export function computeFilterValues(currentFilters, availableFilterValues, mode, trait, values) {
let newValues;
Expand Down Expand Up @@ -67,31 +67,78 @@ export function computeFilterValues(currentFilters, availableFilterValues, mode,
return filters;
}


/**
* Todo - this function should consider currently selected filters and exclude them
* Given a list of datasets and columns (intended for display), create available options for
* selection in the dropdown.
* Note that the first column (dataset) is special cased, and ` / `-split into keywords
*/
export function collectAvailableFilteringOptions(datasets) {
export function collectAvailableFilteringOptions(datasets, columns) {
/**
* The <Select> component needs an array of options to display (and search across). We compute this
* by looping across each filter and calculating all valid options for each. This function runs
* each time a filter is toggled on / off.
*/
// Todo - this function should consider currently selected filters and exclude them
const optionsObject = datasets
.filter((b) => b.url !== undefined)
.reduce((accumulator, dataset) => {
const filename = get(dataset, "filename");
const keywordArray = filename.replace('.json', '').split("_");
keywordArray.forEach((keyword) => {
if (accumulator.seenValues["keyword"]) {
if (accumulator.seenValues["keyword"].has(keyword)) return;
} else {
accumulator.seenValues["keyword"] = new Set([]);
const pairs = parseFiltersForDataset(dataset, columns);
pairs.forEach(([filterType, filterValue]) => {
// init `seenValues` if the `filterType` is novel
if (!accumulator.seenValues[filterType]) {
accumulator.seenValues[filterType] = new Set([]);
}
accumulator.seenValues["keyword"].add(keyword);
accumulator.options.push({label: `keyword → ${keyword}`, value: ["keyword", keyword]});
// add the filterValue to both `seenValues` and, if new, `options`
if (accumulator.seenValues[filterType].has(filterValue.toLowerCase())) return;
accumulator.seenValues[filterType].add(filterValue.toLowerCase());
accumulator.options.push({label: `${filterType}${filterValue}`, value: [filterType, filterValue]});
});
return accumulator;
}, {options: [], seenValues: {}});
return sortBy(optionsObject.options, [(o) => o.label.toLowerCase()]);
}

function parseFiltersForDataset(dataset, columns) {
const pairs = [];
columns.forEach((column, idx) => {
const filterType = idx===0 ? "keyword" : column.name;
const filterValues = (idx===0 ?
column.value(dataset).split(/\s?\/\s?/) :
[column.value(dataset)]
).filter((v) => !!v);
if (!filterValues.length) return;
filterValues.forEach((filterValue) => {
pairs.push([filterType, filterValue]);
});
});
return pairs;
}

export function getFilteredDatasets(datasets, filters, columns) {
const activeFiltersPerType = {};
Object.keys(filters).forEach((filterType) => {
const pairs = [];
filters[filterType].forEach((filterObject) => {
if (filterObject.active) {
pairs.push(`${filterType}__${filterObject.value}`);
}
});
if (pairs.length) activeFiltersPerType[filterType] = pairs;
});

const filteredDatasets = datasets
.filter((dataset) => dataset.url !== undefined)
.filter((dataset) => {
// a given dataset defines a number of filter select option pairs [filterType, filterValue].
// For the currently active filterType(s) and corresponding values, at least one value _must_ be present in
// the dataset for it to be valid (i.e. pass filtering)
const datasetSelectOptions = new Set(
parseFiltersForDataset(dataset, columns)
.map((p) => p.join('__')) // same format as used in `activeFiltersPerType`;
);
return Object.values(activeFiltersPerType)
.every((activeFilterPairList) =>
activeFilterPairList.some((filterPair) => datasetSelectOptions.has(filterPair))
);
});
return sortBy(filteredDatasets, [(d) => d.filename.toLowerCase()]); // TODO: this relies on `filename` being present
}
5 changes: 1 addition & 4 deletions static-site/src/components/Datasets/filter-selection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import ReactTooltip from 'react-tooltip';
import { FaInfoCircle } from "react-icons/fa";
import Select from "react-virtualized-select";
import * as splashStyles from "../splash/styles";
import { collectAvailableFilteringOptions } from "./filter-helpers";
import { CenteredContainer } from "./styles";

const DEBOUNCE_TIME = 200;
Expand All @@ -23,9 +22,7 @@ const StyledTooltip = styled(ReactTooltip)`
pointer-events: auto !important;
`;

export const FilterSelect = ({datasets, applyFilter}) => {

const options = collectAvailableFilteringOptions(datasets);
export const FilterSelect = ({options, applyFilter}) => {

return (
<CenteredContainer>
Expand Down
Loading

0 comments on commit 903ccfb

Please sign in to comment.