Skip to content

Commit

Permalink
filterable flat build listing;
Browse files Browse the repository at this point in the history
this offers an alternative way to
list builds that is more flexible -
listing them in a flat (non-hierarchical)
interface that can be filtered.
The listing can easily be converted
into a table or other.

see #62 (comment)
for more details
  • Loading branch information
eharkins committed Mar 11, 2021
1 parent 9795c6f commit b364c25
Show file tree
Hide file tree
Showing 3 changed files with 403 additions and 2 deletions.
257 changes: 257 additions & 0 deletions static-site/src/components/build-pages/build-select.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import React from "react";
import Select from "react-select/lib/Async";
import { debounce, get } from 'lodash';
import { FilterBadge, Tooltip } from "./filterBadge";
import buildLink from "./build-link";

const Intersect = ({id}) => (
<span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 4px 0px 2px", cursor: 'help'}} data-tip data-for={id}>
<Tooltip id={id}>{`Groups of filters are combined by intersection`}</Tooltip>
</span>
);
const Union = () => (
<span style={{fontSize: "1.5rem", padding: "0px 3px 0px 2px"}}>
</span>
);
const openBracketBig = <span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 0px 0px 2px"}}>{'{'}</span>;
const closeBracketBig = <span style={{fontSize: "2rem", fontWeight: 300, padding: "0px 2px"}}>{'}'}</span>;
const openBracketSmall = <span style={{fontSize: "1.8rem", fontWeight: 300, padding: "0px 2px"}}>{'{'}</span>;
const closeBracketSmall = <span style={{fontSize: "1.8rem", fontWeight: 300, padding: "0px 2px"}}>{'}'}</span>;

const DEBOUNCE_TIME = 200;

/**
* <FilterData> is a (keyboard)-typing based search box intended to
* allow users to filter samples. The filtering rules are not implemented
* in this component, but are useful to spell out: we take the union of
* entries within each category and then take the intersection of those unions.
*/
class FilterData extends React.Component {
constructor(props) {
super(props);
this.state = {
filters: {}
};
this.applyFilter = this.applyFilter.bind(this);
this.createFilterBadges = this.createFilterBadges.bind(this);
this.getStyles = this.getStyles.bind(this);
this.makeOptions = this.makeOptions.bind(this);
this.selectionMade = this.selectionMade.bind(this);
this.createIndividualBadge = this.createIndividualBadge.bind(this);
this.getFilteredBuilds = this.getFilteredBuilds.bind(this);
}

applyFilter = (mode, trait, values) => {
const currentlyFilteredTraits = Reflect.ownKeys(this.state.filters);
let newValues;
switch (mode) {
case "set":
newValues = values.map((value) => ({value, active: true}));
break;
case "add":
if (currentlyFilteredTraits.indexOf(trait) === -1) {
newValues = values.map((value) => ({value, active: true}));
} else {
newValues = this.state.filters[trait].slice();
const currentItemNames = newValues.map((i) => i.value);
values.forEach((valueToAdd) => {
const idx = currentItemNames.indexOf(valueToAdd);
if (idx === -1) {
newValues.push({value: valueToAdd, active: true});
} else {
/* it's already there, ensure it's active */
newValues[idx].active = true;
}
});
}
break;
case "remove": // fallthrough
case "inactivate":
if (currentlyFilteredTraits.indexOf(trait) === -1) {
console.error(`trying to ${mode} values from an un-initialised filter!`);
return;
}
newValues = this.state.filters[trait].slice();
const currentItemNames = newValues.map((i) => i.value);
for (const item of values) {
const idx = currentItemNames.indexOf(item);
if (idx !== -1) {
if (mode==="remove") {
newValues.splice(idx, 1);
} else {
newValues[idx].active = false;
}
} else {
console.error(`trying to ${mode} filter value ${item} which was not part of the filter selection`);
}
}
break;
default:
console.error(`applyFilter called with invalid mode: ${mode}`);
return; // don't set state
}
const filters = Object.assign({}, this.state.filters, {});
filters[trait] = newValues;
this.setState({filters});
};

getStyles() {
return {
base: {
marginBottom: 0,
fontSize: 14
}
};
}
makeOptions = () => {
/**
* 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 default filterPropertyMappings?
const builds = this.props.builds.filter((b) => b.url !== undefined);
const optionsObject = builds.reduce((accumulator, build) => {
this.props.filterPropertyMappings.forEach(([propertyName, displayName]) => {
const propertyValue = get(build, propertyName);
if (propertyValue === undefined) return;
if (accumulator.seenValues[propertyName]) {
if (accumulator.seenValues[propertyName].has(propertyValue)) return;
} else {
accumulator.seenValues[propertyName] = new Set([]);
}
accumulator.seenValues[propertyName].add(propertyValue);
accumulator.options.push({label: `${displayName}${propertyValue}`, value: [propertyName, propertyValue]});
});
return accumulator;
}, {options: [], seenValues: {}});
return optionsObject.options;
}
selectionMade = (sel) => {
this.applyFilter("add", sel.value[0], [sel.value[1]]);
}
createIndividualBadge({filterName, item, label, onHoverMessage}) {
return (
<FilterBadge
key={item.value}
id={String(item.value)}
remove={() => {this.applyFilter("remove", filterName, [item.value]);}}
canMakeInactive
onHoverMessage={onHoverMessage}
active={item.active}
activate={() => {this.applyFilter("add", filterName, [item.value]);}}
inactivate={() => {this.applyFilter("inactivate", filterName, [item.value]);}}
>
{label}
</FilterBadge>
);
}
createFilterBadges(filterName) {
const nFilterValues = this.state.filters[filterName].length;
const onHoverMessage = nFilterValues === 1 ?
`Filtering datasets to this ${filterName}` :
`Filtering datasets to these ${nFilterValues} ${filterName}`;
return this.state.filters[filterName]
.sort((a, b) => a.value < b.value ? -1 : a.value > b.value ? 1 : 0)
.map((item) => {
const label = `${item.value}`;
return this.createIndividualBadge({filterName, item, label, onHoverMessage});
});
}
buildMatchesFilter(build, filterName, filterObjects) {
return filterObjects.every((filter) => {
if (!filter.active) return true; // inactive filter is the same as a match
return get(build, filterName) === filter.value; // active ones must match
});
}
getFilteredBuilds() {
// TODO this doesnt care about categories
return this.props.builds
.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)));
}
render() {
// options only need to be calculated a single time per render, and by adding a debounce
// to `loadOptions` we don't slow things down by comparing queries to a large number of options
const options = this.makeOptions();
const loadOptions = debounce((input, callback) => callback(null, {options}), DEBOUNCE_TIME);
const styles = this.getStyles();
const filtersByCategory = [];
Reflect.ownKeys(this.state.filters)
.filter((filterName) => this.state.filters[filterName].length > 0)
.forEach((filterName) => {
filtersByCategory.push({name: filterName, badges: this.createFilterBadges(filterName)});
});
const filteredBuilds = this.getFilteredBuilds();
/* When filter categories were dynamically created (via metadata drag&drop) the `options` here updated but `<Async>`
seemed to use a cached version of all values & wouldn't update. Changing the key forces a rerender, but it's not ideal */
const divKey = String(Object.keys(this.state.filters).length);
return (
<>
<div style={styles.base} key={divKey}>
<Select
async
name="filterQueryBox"
placeholder="Type filter query here..."
value={undefined}
arrowRenderer={null}
loadOptions={loadOptions}
ignoreAccents={false}
clearable={false}
searchable
multi={false}
valueKey="label"
onChange={this.selectionMade}
/>
{filtersByCategory.length ? (
<>
{"Filtered to "}
{filtersByCategory.map((filterCategory, idx) => {
const multipleFilterBadges = filterCategory.badges.length > 1;
const previousCategoriesRendered = idx!==0;
return (
<span style={{fontSize: "2rem", padding: "0px 2px"}} key={filterCategory.name}>
{previousCategoriesRendered && <Intersect id={'intersect'+idx}/>}
{multipleFilterBadges && openBracketBig} {/* multiple badges => surround with set notation */}
{filterCategory.badges.map((badge, badgeIdx) => {
if (Array.isArray(badge)) { // if `badge` is an array then we wish to render a set-within-a-set
return (
<span key={badge.map((b) => b.props.id).join("")}>
{openBracketSmall}
{badge.map((el, elIdx) => (
<span key={el.props.id}>
{el}
{elIdx!==badge.length-1 && <Union/>}
</span>
))}
{closeBracketSmall}
{badgeIdx!==filterCategory.badges.length-1 && ", "}
</span>
);
}
return (
<span key={badge.props.id}>
{badge}
{badgeIdx!==filterCategory.badges.length-1 && ", "}
</span>
);
})}
{multipleFilterBadges && closeBracketBig}
</span>
);
})}
{". "}
</>
) : null}
</div>
{filteredBuilds.map(buildLink)}
</>
);
}
}

export default FilterData;
135 changes: 135 additions & 0 deletions static-site/src/components/build-pages/filterBadge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React from "react";
import styled from 'styled-components';
import { FaTrash, FaRegEyeSlash, FaRegEye } from "react-icons/fa";
import ReactTooltip from 'react-tooltip';

/**
* React Components for the badges displayed when a filter is selected
*/

const BaseContainer = styled.div`
color: #5097BA;
cursor: pointer;
font-weight: 300;
padding: 0px 2px 0px 2px;
`;

const TextContainer = styled(BaseContainer)`
display: inline-block;
cursor: help;
margin: 0px 0px 0px 2px;
`;

const IconContainer = styled.div`
cursor: pointer;
color: #5097BA;
/* left vertical border */
border-width: 0px 0px 0px 1px;
border-style: solid;
border-color: #BDD8E5; */
min-width: 20px;
padding: 0px 1px 0px 5px;
display: inline-block;
text-align: center;
&:hover, &:focus {
& > svg {
color: #0071e6;
}
}
& > svg {
transform: translate(-2px, 2px);
}
`;

const UnselectedFilterTextContainer = styled(BaseContainer)`
margin: 1px 2px 1px 2px;
padding: 0px 2px 0px 2px;
&:hover {
text-decoration: underline;
}
`;

const SelectedFilterTextContainer = styled(BaseContainer)`
background-color: #E9F2F6;
border-radius: 2px;
border: 1px solid #BDD8E5;
border-width: 1;
border-radius: 2px;
margin: 0px 2px 0px 0px;
&:hover {
text-decoration: none;
}
`;


const BadgeContainer = styled.div`
background-color: #E9F2F6;
${(props) => props.striped ? 'background: repeating-linear-gradient(135deg, #E9F2F6, #E9F2F6 5px, transparent 5px, transparent 10px);' : ''};
display: inline-block;
font-size: 14px;
border-radius: 2px;
border-width: 1px;
border-style: solid;
border-color: #BDD8E5;
`;


const StyledTooltip = styled(ReactTooltip)`
max-width: 30vh;
font-weight: 400;
white-space: normal;
line-height: 1.2;
padding: 4px !important; /* override ReactTooltip's internal styling */
& > br {
margin-bottom: 10px;
}
`;

export const Tooltip = ({id, children}) => (
<StyledTooltip place="bottom" type="dark" effect="solid" delayShow={300} id={id}>
{children}
</StyledTooltip>
);


/**
* React component to display a selected filter with associated
* icons to remove filter. More functionality to be added!
*/
export const FilterBadge = ({remove, canMakeInactive, active, activate, inactivate, children, id, onHoverMessage="The visible data is being filtered by this"}) => {
return (
<BadgeContainer striped={canMakeInactive && !active}>
<TextContainer active={canMakeInactive ? active : true} data-tip data-for={id}>
{children}
</TextContainer>
<Tooltip id={id}>
{canMakeInactive && !active ? `This filter is currently inactive` : onHoverMessage}
</Tooltip>
{canMakeInactive && (
<IconContainer onClick={active ? inactivate : activate} role="button" tabIndex={0} data-tip data-for={id+'active'}>
{active ? <FaRegEye/> : <FaRegEyeSlash/>}
<Tooltip id={id+'active'}>{active ? 'Inactivate this filter' : 'Re-activate this filter'}</Tooltip>
</IconContainer>
)}
<IconContainer onClick={remove} role="button" tabIndex={0}>
<FaTrash data-tip data-for={id+'remove'}/>
<Tooltip id={id+'remove'}>{'Remove this filter'}</Tooltip>
</IconContainer>
</BadgeContainer>
);
};


/**
* A simpler version of <FilterBadge> with no icons
*/
export const SimpleFilter = ({onClick, extraStyles={}, active, children}) => {
const Container = active ? SelectedFilterTextContainer : UnselectedFilterTextContainer;
return (
<div style={{display: "inline-block", ...extraStyles}}>
<Container onClick={onClick}>
{children}
</Container>
</div>
);
};

0 comments on commit b364c25

Please sign in to comment.