Merge pull request #101 from forensic-architecture/fix/filters-architeccture

Make filters architecture more transparent
This commit is contained in:
Lachlan Kermode
2019-02-14 15:54:45 +00:00
committed by GitHub
17 changed files with 278 additions and 423 deletions

View File

@@ -171,19 +171,20 @@ export function updateDistrict (district) {
}
}
export const UPDATE_TAGFILTERS = 'UPDATE_TAGFILTERS'
export function updateTagFilters (tag) {
export const CLEAR_FILTER = 'CLEAR_FILTER'
export function clearFilter (filter) {
return {
type: UPDATE_TAGFILTERS,
tag
type: CLEAR_FILTER,
filter
}
}
export const UPDATE_CATEGORYFILTERS = 'UPDATE_CATEGORYFILTERS'
export function updateCategoryFilters (category) {
export const TOGGLE_FILTER = 'TOGGLE_FILTER'
export function toggleFilter (filter, value) {
return {
type: UPDATE_CATEGORYFILTERS,
category
type: TOGGLE_FILTER,
filter,
value
}
}
@@ -217,13 +218,6 @@ export function decrementNarrativeCurrent () {
}
}
export const RESET_ALLFILTERS = 'RESET_ALLFILTERS'
export function resetAllFilters () {
return {
type: RESET_ALLFILTERS
}
}
export const UPDATE_SOURCE = 'UPDATE_SOURCE'
export function updateSource (source) {
return {

View File

@@ -1,38 +0,0 @@
import React from 'react'
import Checkbox from './presentational/Checkbox'
import copy from '../js/data/copy.json'
export default (props) => {
function onClickCheckbox (obj, type) {
obj.active = !obj.active
props.onCategoryFilter(obj)
}
function renderCategoryTree () {
return (
<div>
{props.categories.map(cat => {
return (<li
key={cat.category.replace(/ /g, '_')}
className={'tag-filter active'}
style={{ marginLeft: '20px' }}
>
<Checkbox
label={cat.category}
isActive={cat.active}
onClickCheckbox={() => onClickCheckbox(cat, 'category')}
/>
</li>)
})}
</div>
)
}
return (
<div className='react-innertabpanel'>
<h2>{copy[props.language].toolbar.categories}</h2>
<p>{copy[props.language].toolbar.explore_by_category__description}</p>
{renderCategoryTree()}
</div>
)
}

View File

@@ -7,7 +7,7 @@ import * as actions from '../actions'
import MediaOverlay from './Overlay/Media'
import LoadingOverlay from './Overlay/Loading'
import Map from './Map.jsx'
import Toolbar from './Toolbar.jsx'
import Toolbar from './Toolbar/Layout'
import CardStack from './CardStack.jsx'
import NarrativeControls from './presentational/Narrative/Controls.js'
import InfoPopUp from './InfoPopup.jsx'
@@ -76,7 +76,11 @@ class Dashboard extends React.Component {
setNarrative (narrative) {
// only handleSelect if narrative is not null
if (narrative) { this.handleSelect([ narrative.steps[0] ]) }
if (narrative) {
this.props.actions.clearFilter('tags')
this.props.actions.clearFilter('categories')
this.handleSelect([ narrative.steps[0] ])
}
this.props.actions.updateNarrative(narrative)
}
@@ -120,8 +124,8 @@ class Dashboard extends React.Component {
<Toolbar
isNarrative={!!app.narrative}
methods={{
onTagFilter: actions.updateTagFilters,
onCategoryFilter: actions.updateCategoryFilters,
onTagFilter: tag => actions.toggleFilter('tags', tag),
onCategoryFilter: category => actions.toggleFilter('categories', category),
onSelectNarrative: this.setNarrative
}}
/>

View File

@@ -245,7 +245,7 @@ class Map extends React.Component {
{this.renderShapes()}
{this.renderNarratives()}
{this.renderEvents()}
{this.renderSelected()}
{this.renderSelected()}
</React.Fragment>
) : null
@@ -263,7 +263,7 @@ function mapStateToProps (state) {
domain: {
locations: selectors.selectLocations(state),
narratives: selectors.selectNarratives(state),
categories: selectors.selectCategories(state),
categories: selectors.getCategories(state),
sites: selectors.getSites(state),
shapes: selectors.getShapes(state)
},

View File

@@ -1,84 +0,0 @@
import React from 'react'
import Checkbox from './presentational/Checkbox'
class TagFilter extends React.Component {
isActive () {
if (this.props.isCategory) {
return this.props.categoryFilters.includes(this.props.tag.id)
}
return this.props.tagFilters.includes(this.props.tag.id)
}
onClickTag () {
if (this.isActive()) {
this.props.filter({
tags: this.props.tagFilters.filter(element => element !== this.props.tag.id)
})
} else {
this.props.filter({
tags: this.props.tagFilters.concat(this.props.tag.id)
})
}
}
onClickCategory () {
if (this.isActive()) {
this.props.filter({
categories: this.props.categoryFilters.filter(element => element !== this.props.tag.id)
})
} else {
this.props.filter({
categories: this.props.categoryFilters.concat(this.props.tag.id)
})
}
}
renderTag () {
const tag = this.props.tag
let classes = (this.isActive()) ? 'tag-filter active' : 'tag-filter'
let label = `${tag.name} ( ${tag.mentions} )`
if (this.props.isShowTree) {
label = `${tag.group} > ${tag.subgroup} > ${tag.name} ( ${tag.mentions} )`
}
return (
<li
key={this.props.tag.id}
className={classes}
>
<Checkbox
isActive={this.isActive()}
label={label}
onClickCheckbox={() => this.onClickTag()}
/>
</li>
)
}
renderCategory () {
const category = this.props.categories[this.props.tag.id]
let classes = (this.isActive()) ? 'tag-filter active' : 'tag-filter'
if (category) {
return (
<li
key={this.props.tag.id}
className={classes}
>
<Checkbox
isActive={this.isActive()}
label={`${category.name} ( ${category.counts} )`}
onClickCheckbox={() => this.onClickCategory()}
/>
</li>
)
}
return (<div />)
}
render () {
if (this.props.isCategory) return (this.renderCategory())
return (this.renderTag())
}
}
export default TagFilter

View File

@@ -1,82 +0,0 @@
import React from 'react'
import Checkbox from './presentational/Checkbox'
import copy from '../js/data/copy.json'
class TagListPanel extends React.Component {
constructor (props) {
super(props)
this.state = {
treeComponents: []
}
this.treeComponents = []
this.newTagFilters = []
}
componentDidMount () {
this.computeTree(this.props.tags)// .children[this.props.tagType]);
}
componentWillReceiveProps (nextProps) {
this.computeTree(nextProps.tags)// .children[nextProps.tagType]);
}
onClickCheckbox (obj, type) {
obj.active = !obj.active
this.props.onTagFilter(obj)
}
createNodeComponent (node, depth) {
return (
<li
key={node.key.replace(/ /g, '_')}
className={'tag-filter active'}
style={{ marginLeft: `${depth * 20}px` }}
>
<Checkbox
label={node.key}
isActive={node.active}
onClickCheckbox={() => this.onClickCheckbox(node, 'tag')}
/>
</li>
)
}
traverseNodeAndCreateComponent (node, depth) {
// add and create node component
const newComponent = this.createNodeComponent(node, depth)
this.treeComponents.push(newComponent)
depth = depth + 1
if (Object.keys(node.children).length > 0) {
Object.values(node.children).forEach((childNode) => {
this.traverseNodeAndCreateComponent(childNode, depth)
})
}
}
computeTree (node) {
this.treeComponents = []
let depth = 0
this.traverseNodeAndCreateComponent(node, depth)
this.setState({ treeComponents: this.treeComponents })
}
renderTree () {
return (
<div>
{this.state.treeComponents.map(c => c)}
</div>
)
}
render () {
return (
<div className='react-innertabpanel'>
<h2>{copy[this.props.language].toolbar.tags}</h2>
<p>{copy[this.props.language].toolbar.explore_by_tag__description}</p>
{this.renderTree()}
</div>
)
}
}
export default TagListPanel

View File

@@ -349,7 +349,7 @@ function mapStateToProps (state) {
isNarrative: !!state.app.narrative,
domain: {
datetimes: selectors.selectDatetimes(state),
categories: selectors.selectCategories(state),
categories: selectors.getCategories(state),
narratives: state.domain.narratives
},
app: {

View File

@@ -1,10 +1,10 @@
import React from 'react'
import SitesIcon from './presentational/Icons/Sites'
import CoverIcon from './presentational/Icons/Cover'
import InfoIcon from './presentational/Icons/Info'
import SitesIcon from '../presentational/Icons/Sites'
import CoverIcon from '../presentational/Icons/Cover'
import InfoIcon from '../presentational/Icons/Info'
function ToolbarBottomActions (props) {
function BottomActions (props) {
function renderToggles () {
return [
<div className='bottom-action-block'>
@@ -34,4 +34,4 @@ function ToolbarBottomActions (props) {
)
}
export default ToolbarBottomActions
export default BottomActions

View File

@@ -0,0 +1,38 @@
import React from 'react'
import Checkbox from '../presentational/Checkbox'
import copy from '../../js/data/copy.json'
export default ({
categories,
activeCategories,
onCategoryFilter,
language
}) => {
function renderCategoryTree () {
return (
<div>
{categories.map(cat => {
return (<li
key={cat.category.replace(/ /g, '_')}
className={'tag-filter active'}
style={{ marginLeft: '20px' }}
>
<Checkbox
label={cat.category}
isActive={activeCategories.includes(cat.category)}
onClickCheckbox={() => onCategoryFilter(cat.category)}
/>
</li>)
})}
</div>
)
}
return (
<div className='react-innertabpanel'>
<h2>{copy[language].toolbar.categories}</h2>
<p>{copy[language].toolbar.explore_by_category__description}</p>
{renderCategoryTree()}
</div>
)
}

View File

@@ -1,16 +1,16 @@
import React from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as actions from '../actions'
import * as selectors from '../selectors'
import * as actions from '../../actions'
import * as selectors from '../../selectors'
import { Tabs, TabPanel } from 'react-tabs'
import Search from './Search.jsx'
import TagListPanel from './TagListPanel.jsx'
import CategoriesListPanel from './CategoriesListPanel.jsx'
import ToolbarBottomActions from './ToolbarBottomActions.jsx'
import copy from '../js/data/copy.json'
import { trimAndEllipse } from '../js/utilities.js'
import Search from './Search'
import TagListPanel from './TagListPanel'
import CategoriesListPanel from './CategoriesListPanel'
import BottomActions from './BottomActions'
import copy from '../../js/data/copy.json'
import { trimAndEllipse } from '../../js/utilities.js'
class Toolbar extends React.Component {
constructor (props) {
@@ -78,7 +78,7 @@ class Toolbar extends React.Component {
<TabPanel>
<CategoriesListPanel
categories={this.props.categories}
categoryFilters={this.props.categoryFilters}
activeCategories={this.props.activeCategories}
onCategoryFilter={this.props.methods.onCategoryFilter}
language={this.props.language}
/>
@@ -94,7 +94,7 @@ class Toolbar extends React.Component {
<TabPanel>
<TagListPanel
tags={this.props.tags}
tagFilters={this.props.tagFilters}
activeTags={this.props.activeTags}
onTagFilter={this.props.methods.onTagFilter}
language={this.props.language}
/>
@@ -164,7 +164,7 @@ class Toolbar extends React.Component {
{(isCategories) ? this.renderToolbarTab(1, categoriesLabel, 'widgets') : null}
{(isTags) ? this.renderToolbarTab(2, tagsLabel, 'filter_list') : null}
</div>
<ToolbarBottomActions
<BottomActions
info={{
enabled: this.props.infoShowing,
toggle: this.props.actions.toggleInfoPopup
@@ -199,8 +199,8 @@ function mapStateToProps (state) {
categories: selectors.getCategories(state),
narratives: selectors.selectNarratives(state),
language: state.app.language,
tagFilters: selectors.selectTagList(state),
categoryFilters: selectors.selectCategories(state),
activeTags: selectors.getActiveTags(state),
activeCategories: selectors.getActiveCategories(state),
viewFilters: state.app.filters.views,
features: state.app.features,
narrative: state.app.narrative,

View File

@@ -1,7 +1,7 @@
/* global fetch */
import React from 'react'
import copy from '../js/data/copy.json'
import TagFilter from './TagFilter.jsx'
import copy from '../../js/data/copy.json'
import TagFilter from './TagFilter'
export default class Search extends React.Component {
constructor (props) {

View File

@@ -0,0 +1,82 @@
import React from 'react'
import Checkbox from '../presentational/Checkbox'
function TagFilter (props) {
function isActive () {
if (props.isCategory) {
return props.categoryFilters.includes(props.tag.id)
}
return props.tagFilters.includes(props.tag.id)
}
function onClickTag () {
if (isActive()) {
props.filter({
tags: props.tagFilters.filter(element => element !== props.tag.id)
})
} else {
props.filter({
tags: props.tagFilters.concat(props.tag.id)
})
}
}
function onClickCategory () {
if (isActive()) {
props.filter({
categories: props.categoryFilters.filter(element => element !== props.tag.id)
})
} else {
props.filter({
categories: props.categoryFilters.concat(props.tag.id)
})
}
}
function renderTag () {
const tag = props.tag
let classes = (isActive()) ? 'tag-filter active' : 'tag-filter'
let label = `${tag.name} ( ${tag.mentions} )`
if (props.isShowTree) {
label = `${tag.group} > ${tag.subgroup} > ${tag.name} ( ${tag.mentions} )`
}
return (
<li
key={props.tag.id}
className={classes}
>
<Checkbox
isActive={isActive()}
label={label}
onClickCheckbox={() => onClickTag()}
/>
</li>
)
}
function renderCategory () {
const category = props.categories[props.tag.id]
let classes = (isActive()) ? 'tag-filter active' : 'tag-filter'
if (category) {
return (
<li
key={props.tag.id}
className={classes}
>
<Checkbox
isActive={isActive()}
label={`${category.name} ( ${category.counts} )`}
onClickCheckbox={onClickCategory}
/>
</li>
)
}
return (<div />)
}
if (props.isCategory) return (renderCategory())
return (renderTag())
}
export default TagFilter

View File

@@ -0,0 +1,45 @@
import React from 'react'
import Checkbox from '../presentational/Checkbox'
import copy from '../../js/data/copy.json'
function TagListPanel ({
tags,
activeTags,
onTagFilter,
language
}) {
function createNodeComponent (node, depth) {
return (
<li
key={node.key.replace(/ /g, '_')}
className={'tag-filter active'}
style={{ marginLeft: `${depth * 20}px` }}
>
<Checkbox
label={node.key}
isActive={activeTags.includes(node.key)}
onClickCheckbox={() => onTagFilter(node.key)}
/>
</li>
)
}
function renderTree () {
/* NOTE: only render first layer of tags */
return (
<div>
{Object.values(tags.children).map(tag => createNodeComponent(tag, 1))}
</div>
)
}
return (
<div className='react-innertabpanel'>
<h2>{copy[language].toolbar.tags}</h2>
<p>{copy[language].toolbar.explore_by_tag__description}</p>
{renderTree()}
</div>
)
}
export default TagListPanel

View File

@@ -80,6 +80,7 @@ function MapEvents ({ getCategoryColor, categories, projectPoint, styleLocation,
const { x, y } = projectPoint([location.latitude, location.longitude])
// in narrative mode, only render events in narrative
// TODO: move this to a selector
if (narrative) {
const { steps } = narrative
const onlyIfInNarrative = e => steps.map(s => s.id).includes(e.id)

View File

@@ -5,8 +5,8 @@ import { parseDate, toggleFlagAC } from '../js/utilities'
import {
UPDATE_HIGHLIGHTED,
UPDATE_SELECTED,
UPDATE_TAGFILTERS,
UPDATE_CATEGORYFILTERS,
CLEAR_FILTER,
TOGGLE_FILTER,
UPDATE_TIMERANGE,
UPDATE_NARRATIVE,
INCREMENT_NARRATIVE_CURRENT,
@@ -118,45 +118,40 @@ function decrementNarrativeCurrent (appState, action) {
}
}
function updateTagFilters (appState, action) {
const tagFilters = appState.filters.tags.slice(0)
const nextActiveState = action.tag.active
function traverseNode (node) {
const tagFilter = tagFilters.find(tF => tF.key === node.key)
node.active = nextActiveState
if (!tagFilter) tagFilters.push(node)
if (node && Object.keys(node.children).length > 0) {
Object.values(node.children).forEach((childNode) => { traverseNode(childNode) })
function clearTagFilters (appState) {
return {
...appState,
filters: {
...appState.filters,
tags: []
}
}
traverseNode(action.tag)
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, {
tags: tagFilters
})
})
}
function updateCategoryFilters (appState, action) {
const categoryFilters = appState.filters.categories.slice(0)
const catFilter = categoryFilters.find(cF => cF.category === action.category.category)
if (!catFilter) {
categoryFilters.push(action.category)
function toggleFilter (appState, action) {
let newTags = appState.filters[action.filter].slice(0)
if (newTags.includes(action.value)) {
newTags = newTags.filter(s => s !== action.value)
} else {
catFilter.active = (!!action.category.active)
newTags.push(action.value)
}
return {
...appState,
filters: {
...appState.filters,
[action.filter]: newTags
}
}
}
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, {
categories: categoryFilters
})
})
function clearFilter (appState, action) {
return {
...appState,
filters: {
...appState.filters,
[action.filter]: []
}
}
}
function updateTimeRange (appState, action) { // XXX
@@ -169,20 +164,6 @@ function updateTimeRange (appState, action) { // XXX
}
}
function resetAllFilters (appState) { // XXX
return Object.assign({}, appState, {
filters: Object.assign({}, appState.filters, {
tags: [],
categories: [],
timerange: [
d3.timeParse('%Y-%m-%dT%H:%M:%S')('2014-09-25T12:00:00'),
d3.timeParse('%Y-%m-%dT%H:%M:%S')('2014-09-28T12:00:00')
]
}),
selected: []
})
}
function toggleLanguage (appState, action) {
let otherLanguage = (appState.language === 'es-MX') ? 'en-US' : 'es-MX'
return Object.assign({}, appState, {
@@ -228,10 +209,10 @@ function app (appState = initial.app, action) {
return updateHighlighted(appState, action)
case UPDATE_SELECTED:
return updateSelected(appState, action)
case UPDATE_TAGFILTERS:
return updateTagFilters(appState, action)
case UPDATE_CATEGORYFILTERS:
return updateCategoryFilters(appState, action)
case CLEAR_FILTER:
return clearFilter(appState, action)
case TOGGLE_FILTER:
return toggleFilter(appState, action)
case UPDATE_TIMERANGE:
return updateTimeRange(appState, action)
case UPDATE_NARRATIVE:

16
src/selectors/helpers.js Normal file
View File

@@ -0,0 +1,16 @@
import { parseTimestamp } from '../js/utilities'
/**
* Some handy helpers
*/
/**
* Given an event and a time range,
* returns true/false if the event falls within timeRange
*/
export function isTimeRangedIn (event, timeRange) {
const eventTime = parseTimestamp(event.timestamp)
return (
timeRange[0] < eventTime &&
eventTime < timeRange[1]
)
}

View File

@@ -1,5 +1,6 @@
import { createSelector } from 'reselect'
import { parseTimestamp, compareTimestamp, insetSourceFrom } from '../js/utilities'
import { compareTimestamp, insetSourceFrom } from '../js/utilities'
import { isTaggedIn, isNoTags, isTaggedInWithCategory, isNoCategories, isTimeRangedIn } from './helpers'
// Input selectors
export const getEvents = state => state.domain.events
@@ -22,91 +23,28 @@ export const getShapes = state => {
}
export const getNotifications = state => state.domain.notifications
export const getTagTree = state => state.domain.tags
export const getTagsFilter = state => state.app.filters.tags
export const getCategoriesFilter = state => state.app.filters.categories
export const getActiveTags = state => state.app.filters.tags
export const getActiveCategories = state => state.app.filters.categories
export const getTimeRange = state => state.app.timeline.range
export const selectNarrative = state => state.app.narrative
/**
* Some handy helpers
*/
/**
* Given an event and all tags,
* returns true/false if event has any tag that is active
*/
function isTaggedIn (event, tagFilters) {
if (event.tags) {
const isTagged = event.tags.some((tag) => {
return tagFilters.find(tF => (tF.key === tag && tF.active))
})
return isTagged
} else {
return false
}
}
/**
* Given an event and all categories,
* returns true/false if event has a category that is active
*/
function isTaggedInWithCategory (event, categories) {
if (event.category) {
if (categories.find(c => (c.category === event.category && c.active))) return true
return false
} else {
return false
}
}
/*
* Returns true if no tags are selected
*/
function isNoTags (tagFilters) {
return (
tagFilters.length === 0 ||
!process.env.features.USE_TAGS ||
tagFilters.every(t => !t.active)
)
}
/*
* Returns true if no categories are selected
*/
function isNoCategories (categories) {
return (
categories.length === 0 ||
!process.env.features.CATEGORIES_AS_TAGS ||
categories.every(c => !c.active)
)
}
/**
* Given an event and a time range,
* returns true/false if the event falls within timeRange
*/
function isTimeRangedIn (event, timeRange) {
const eventTime = parseTimestamp(event.timestamp)
return (
timeRange[0] < eventTime &&
eventTime < timeRange[1]
)
}
/**
* Of all available events, selects those that fall within the time range,
* and if TAGS are being used, select them if their tags are enabled
* Of all available events, selects those that
* 1. fall in time range
* 2. exist in an active tag
* 3. exist in an active category
*/
export const selectEvents = createSelector(
[getEvents, getTagsFilter, getCategoriesFilter, getTimeRange],
(events, tagFilters, categories, timeRange) => {
[getEvents, getActiveTags, getActiveCategories, getTimeRange],
(events, activeTags, activeCategories, timeRange) => {
return events.reduce((acc, event) => {
const isTagged = isTaggedIn(event, tagFilters) || isNoTags(tagFilters)
const isTaggedWithCategory = isTaggedInWithCategory(event, categories) || isNoCategories(categories)
const isTimeRanged = isTimeRangedIn(event, timeRange)
const isMatchingTag = event.tags.map(tag => activeTags.includes(tag)).some(s => s)
const isActiveTag = isMatchingTag || activeTags.length === 0
const isActiveCategory = activeCategories.includes(event.category) || activeCategories.length === 0
const isActiveTime = isTimeRangedIn(event, timeRange)
if (isTimeRanged && isTagged && isTaggedWithCategory) {
const eventClone = Object.assign({}, event)
acc[event.id] = eventClone
if (isActiveTime && isActiveTag && isActiveCategory) {
acc[event.id] = { ...event }
}
return acc
@@ -180,14 +118,14 @@ export const selectActiveNarrative = createSelector(
export const selectLocations = createSelector(
[selectEvents],
(events) => {
const selectedLocations = {}
const activeLocations = {}
events.forEach(event => {
const location = event.location
if (selectedLocations[location]) {
selectedLocations[location].events.push(event)
if (activeLocations[location]) {
activeLocations[location].events.push(event)
} else {
selectedLocations[location] = {
activeLocations[location] = {
label: location,
events: [event],
latitude: event.latitude,
@@ -195,7 +133,8 @@ export const selectLocations = createSelector(
}
}
})
return Object.values(selectedLocations)
return Object.values(activeLocations)
}
)
@@ -242,44 +181,3 @@ export const selectSelected = createSelector(
return selected.map(insetSourceFrom(sources))
}
)
/*
* Select categories, return them as a list
*/
export const selectCategories = createSelector(
[getCategories],
(categories) => {
categories.map(cat => {
cat.active = (!cat.hasOwnProperty('active')) ? false : cat.active
})
return categories
}
)
/**
* Given a tree of tags, return those tags as a list
* Each node has been aware of its depth, and given an 'active' flag
*/
export const selectTagList = createSelector(
[getTagTree],
(tags) => {
const tagList = []
let depth = 0
function traverseNode (node, depth) {
node.active = (!node.hasOwnProperty('active')) ? false : node.active
node.depth = depth
if (node.active) tagList.push(node)
if (Object.keys(node.children).length > 0) {
Object.values(node.children).forEach((childNode) => {
traverseNode(childNode, depth + 1)
})
}
}
if (tags && tags !== undefined) {
if (tags.key && tags.children) traverseNode(tags, depth)
}
return tagList
}
)