diff --git a/src/actions/index.js b/src/actions/index.js index 70c501d..ce01025 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -136,11 +136,11 @@ export function updateDistrict(district) { }; } -export const UPDATE_FILTERS = 'UPDATE_FILTERS'; -export function updateFilters(filters) { +export const UPDATE_TAGFILTERS = 'UPDATE_TIMEFILTERS'; +export function updateTagFilters(tag) { return { - type: UPDATE_FILTERS, - filters: filters + type: UPDATE_TAGFILTERS, + tag }; } diff --git a/src/components/Checkbox.jsx b/src/components/Checkbox.jsx index 1d186b2..2bdd8dc 100644 --- a/src/components/Checkbox.jsx +++ b/src/components/Checkbox.jsx @@ -1,9 +1,9 @@ import '../scss/main.scss'; import React from 'react'; -export default ({ label, isActive, onClickLabel, onClickCheckbox }) => ( +export default ({ label, isActive, onClickCheckbox }) => (
- onClickLabel()}>{label} + onClickCheckbox()}>{label} diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index f16a91b..2a496a4 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -21,7 +21,8 @@ class Dashboard extends React.Component { this.handleHighlight = this.handleHighlight.bind(this); this.handleSelect = this.handleSelect.bind(this); this.handleToggle = this.handleToggle.bind(this); - this.handleFilter = this.handleFilter.bind(this); + this.handleTagFilter = this.handleTagFilter.bind(this); + this.handleTimeFilter = this.handleTimeFilter.bind(this); } componentDidMount() { @@ -67,8 +68,12 @@ class Dashboard extends React.Component { } } - handleFilter(filters) { - this.props.actions.updateFilters(filters); + handleTagFilter(tag) { + this.props.actions.updateTagFilters(tag); + } + + handleTimeFilter(timeRange) { + this.props.actions.updateTimeRange(timeRange); } handleToggle( key ) { @@ -139,7 +144,7 @@ class Dashboard extends React.Component { toolbarTab={this.props.ui.components.toolbarTab} isView2d={this.props.ui.flags.isView2d} - filter={this.handleFilter} + filter={this.handleTagFilter} toggle={ (key) => this.handleToggle(key) } actions={this.props.actions} /> @@ -152,7 +157,6 @@ class Dashboard extends React.Component { isFetchingEvents={this.props.ui.flags.isFetchingEvents} highlight={this.handleHighlight} - filter={this.handleFilter} toggle={this.handleToggle} getCategoryGroup={category => this.getCategoryGroup(category)} getCategoryGroupColor={category => this.getCategoryGroupColor(category)} @@ -170,7 +174,7 @@ class Dashboard extends React.Component { dom={this.props.ui.dom} select={this.handleSelect} - filter={this.handleFilter} + filter={this.handleTimeFilter} highlight={this.handleHighlight} toggle={() => this.handleToggle('TOGGLE_CARDSTACK')} getCategoryGroup={category => this.getCategoryGroup(category)} @@ -209,7 +213,7 @@ function mapStateToProps(state) { categories: selectors.getFilteredCategories(state), categoryGroups: selectors.getCategoryGroups(state), sites: selectors.getSites(state), - tags: selectors.getTags(state), + tags: selectors.getAllTags(state), notifications: state.domain.notifications, }), diff --git a/src/components/Notification.jsx b/src/components/Notification.jsx index e718b9d..8af75e9 100644 --- a/src/components/Notification.jsx +++ b/src/components/Notification.jsx @@ -31,12 +31,12 @@ export default class Notification extends React.Component{ if (this.props.isNotification) { return (
- {this.props.notifications.map(not => ( + {this.props.notifications.map(notification => (
this.toggleDetails() }> -
{`${not.message}`}
+
{`${notification.message}`}
- {(not.items !== null) ? this.renderItems(not.items) : ''} + {(notification.items !== null) ? this.renderItems(notification.items) : ''}
))} diff --git a/src/components/TagFilter.jsx b/src/components/TagFilter.jsx index 0d91001..dda91ac 100644 --- a/src/components/TagFilter.jsx +++ b/src/components/TagFilter.jsx @@ -53,7 +53,6 @@ class TagFilter extends React.Component { this.onClickTag()} onClickCheckbox={() => this.onClickTag()} /> @@ -73,7 +72,6 @@ class TagFilter extends React.Component { this.onClickCategory()} onClickCheckbox={() => this.onClickCategory()} /> diff --git a/src/components/TagListPanel.jsx b/src/components/TagListPanel.jsx index cfcb774..da5a6a7 100644 --- a/src/components/TagListPanel.jsx +++ b/src/components/TagListPanel.jsx @@ -21,26 +21,9 @@ class TagListPanel extends React.Component { this.computeTree(nextProps.tags.children[nextProps.tagType]); } - traverseNodeAndCheckIt(node, depth, active) { - // do something to node - const tagFilter = this.newTagFilters.find(tagFilter => tagFilter.key === node.key) - tagFilter.active = (depth === 0) ? !node.active : active; - tagFilter.depth = depth; - depth = depth + 1; - - if (Object.keys(tagFilter.children).length > 0) { - Object.values(tagFilter.children).forEach((childNode) => { - this.traverseNodeAndCheckIt(childNode, depth, tagFilters, tagFilter.active); - }); - } - } - onClickCheckbox(tag) { - this.newTagFilters = this.props.tagFilters.slice(0); - let depth = 0; - if (tag.key && tag.children) this.traverseNodeAndCheckIt(tag, depth); - - this.props.filter({ tags: this.newTagFilters }); + tag.active = !tag.active + this.props.filter(tag); } createNodeComponent (node, depth) { diff --git a/src/js/timeline/timeline.js b/src/js/timeline/timeline.js index 7226b28..662b0ab 100644 --- a/src/js/timeline/timeline.js +++ b/src/js/timeline/timeline.js @@ -56,7 +56,7 @@ export default function(app, ui) { let selected = []; let range = app.range; - const filter = app.filter; + const timeFilter = app.filter; const select = app.select; const getCategoryLabel = app.getCategoryLabel; const getCategoryGroupColor = app.getCategoryGroupColor; @@ -230,9 +230,7 @@ export default function(app, ui) { }) .on('end', () => { toggleTransition(true); - filter({ - range: scale.x.domain() - }); + timeFilter(scale.x.domain()); }); /* @@ -367,9 +365,7 @@ export default function(app, ui) { const domainF = d3.timeMinute.offset(newCentralTime, zoom.duration / 2); scale.x.domain([domain0, domainF]); - filter({ - range: scale.x.domain() - }); + timeFilter(scale.x.domain()); } /** @@ -392,9 +388,7 @@ export default function(app, ui) { } scale.x.domain([domain0, domainF]); - filter({ - range: scale.x.domain() - }); + timeFilter(scale.x.domain()); } function toggleTransition(isTransition) { diff --git a/src/reducers/app.js b/src/reducers/app.js index c252f82..d9f579b 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -3,7 +3,7 @@ import initial from '../store/initial.js'; import { UPDATE_HIGHLIGHTED, UPDATE_SELECTED, - UPDATE_FILTERS, + UPDATE_TAGFILTERS, UPDATE_TIMERANGE, RESET_ALLFILTERS, TOGGLE_LANGUAGE, @@ -22,15 +22,34 @@ function updateSelected(appState, action) { }); } -function updateFilters(appState, action) { // XXX +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); }); + } + } + + traverseNode(action.tag); + return Object.assign({}, appState, { - filters: Object.assign({}, appState.filters, action.filters) + filters: Object.assign({}, appState.filters, { + tags: tagFilters + }) }); } function updateTimeRange(appState, action) { // XXX return Object.assign({}, appState, { - filters: Object.assign({}, appState.filters, action.range), + filters: Object.assign({}, appState.filters, { + range: action.range + }), }); } @@ -70,8 +89,8 @@ function app(appState = initial.app, action) { return updateHighlighted(appState, action); case UPDATE_SELECTED: return updateSelected(appState, action); - case UPDATE_FILTERS: - return updateFilters(appState, action); + case UPDATE_TAGFILTERS: + return updateTagFilters(appState, action); case UPDATE_TIMERANGE: return updateTimeRange(appState, action); case RESET_ALLFILTERS: diff --git a/src/reducers/utils/validators.js b/src/reducers/utils/validators.js index b6c38b9..6355e1b 100644 --- a/src/reducers/utils/validators.js +++ b/src/reducers/utils/validators.js @@ -18,6 +18,34 @@ function makeError(type, id, message) { } } + +const isLeaf = node => (Object.keys(node.children).length === 0); +const isDuplicate = (node, set) => { return (set.has(node.key)); }; + + +/* +* Traverse a tag tree and check its duplicates +*/ +function validateTree(node, parent, set, duplicates) { + // If it's a leaf, check that it's not duplicate + if (isLeaf(node)) { + if (isDuplicate(node, set)) { + duplicates.push({ + id: node.key, + error: makeError('Tags', node.key, 'tag was found more than once in hierarchy. Ignoring duplicate.') + }); + delete parent.children[node.key]; + } else { + set.add(node.key); + } + } else { + // If it's not a leaf, simply keep going + Object.values(node.children).forEach((childNode) => { + validateTree(childNode, node, set, duplicates); + }); + } +} + /* * Validate domain schema */ @@ -27,7 +55,7 @@ export function validate(domain) { categories: [], sites: [], notifications: domain.notifications, - tags: domain.tags + tags: {} } const discardedDomain = { @@ -59,7 +87,7 @@ export function validate(domain) { validateItem(site, 'sites', siteSchema); }); - // Message the number of failed items + // Message the number of failed items in domain Object.keys(discardedDomain).forEach(disc => { const len = discardedDomain[disc].length; if (len) { @@ -69,7 +97,22 @@ export function validate(domain) { type: 'error' }); } - }) + }); + + // Validate uniqueness of tags + const tagSet = new Set([]); + const duplicateTags = []; + validateTree(domain.tags, {}, tagSet, duplicateTags); + + // Duplicated tags + if (duplicateTags.length > 0) { + sanitizedDomain.notifications.push({ + message: `Tags are required to be unique. Ignoring duplicates for now.`, + items: duplicateTags, + type: 'error' + }); + } + sanitizedDomain.tags = domain.tags; return sanitizedDomain; } diff --git a/src/scss/notification.scss b/src/scss/notification.scss index 4b25d46..49a8280 100644 --- a/src/scss/notification.scss +++ b/src/scss/notification.scss @@ -57,15 +57,23 @@ overflow: hidden; display: flex; flex-direction: column; + border-radius: 3px; + margin-top: 10px; + padding: 10px; + background: $darkgrey; + color: $offwhite; + font-family: monospace; &.true { height: auto; - transition: height 0.4s; + transition: height 0.4s, margin 0.4s; } &.false { height: 0; - transition: height 0.4s; + padding: 0; + margin: 0; + transition: height 0.4s, margin 0.4s; } } } diff --git a/src/selectors/index.js b/src/selectors/index.js index f30edbd..cd0bb46 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -10,7 +10,7 @@ export const getSites = (state) => { if (process.env.features.USE_SITES) return state.domain.sites; return []; } -export const getTags = state => state.domain.tags; +export const getAllTags = state => state.domain.tags; export const getCategoriesFilter = state => state.app.filters.categories; export const getTagsFilter = state => state.app.filters.tags; @@ -105,20 +105,20 @@ export const getCategoryGroups = createSelector( } ) + /** * Given a tree of tags, return those tags as a list, where each node has been * aware of its depth, and given an 'active' flag */ export const getTagFilters = createSelector( - [getTags], + [getAllTags], (tags) => { - const allTags = []; + const allTagFilters = []; let depth = 0; function traverseNode(node, depth) { - // do something to node node.active = (!node.hasOwnProperty('active')) ? false : node.active; node.depth = depth; - allTags.push(node) + if (node.active) allTagFilters.push(node) depth = depth + 1; if (Object.keys(node.children).length > 0) { @@ -129,6 +129,6 @@ export const getTagFilters = createSelector( } if (tags.key && tags.children) traverseNode(tags, depth) - return allTags; + return allTagFilters; } )