-
Notifications
You must be signed in to change notification settings - Fork 49
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
3 changed files
with
403 additions
and
2 deletions.
There are no files selected for viewing
257 changes: 257 additions & 0 deletions
257
static-site/src/components/build-pages/build-select.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.