mirror of
https://github.com/bellingcat/ukraine-timemap.git
synced 2026-06-11 12:58:35 +03:00
Merge pull request #14 from forensic-architecture/tag-selection-fix
Tag selection fix closes #12
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import '../scss/main.scss';
|
||||
import React from 'react';
|
||||
|
||||
export default ({ label, isActive, onClickLabel, onClickCheckbox }) => (
|
||||
export default ({ label, isActive, onClickCheckbox }) => (
|
||||
<div className={(isActive) ? 'item active' : 'item'}>
|
||||
<span onClick={() => onClickLabel()}>{label}</span>
|
||||
<span onClick={() => onClickCheckbox()}>{label}</span>
|
||||
<button onClick={() => onClickCheckbox()}>
|
||||
<div className="checkbox" />
|
||||
</button>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -31,12 +31,12 @@ export default class Notification extends React.Component{
|
||||
if (this.props.isNotification) {
|
||||
return (
|
||||
<div className={`notification-wrapper`}>
|
||||
{this.props.notifications.map(not => (
|
||||
{this.props.notifications.map(notification => (
|
||||
<div className='notification' onClick={() => this.toggleDetails() }>
|
||||
<button onClick={() => this.props.toggle()} className="side-menu-burg over-white is-active"><span /></button>
|
||||
<div className={`message ${not.type}`}>{`${not.message}`}</div>
|
||||
<div className={`message ${notification.type}`}>{`${notification.message}`}</div>
|
||||
<div className={`details ${this.state.isExtended}`}>
|
||||
{(not.items !== null) ? this.renderItems(not.items) : ''}
|
||||
{(notification.items !== null) ? this.renderItems(notification.items) : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -53,7 +53,6 @@ class TagFilter extends React.Component {
|
||||
<Checkbox
|
||||
isActive={this.isActive()}
|
||||
label={label}
|
||||
onClickLabel={() => this.onClickTag()}
|
||||
onClickCheckbox={() => this.onClickTag()}
|
||||
/>
|
||||
</li>
|
||||
@@ -73,7 +72,6 @@ class TagFilter extends React.Component {
|
||||
<Checkbox
|
||||
isActive={this.isActive()}
|
||||
label={`${category.name} ( ${category.counts} )`}
|
||||
onClickLabel={() => this.onClickCategory()}
|
||||
onClickCheckbox={() => this.onClickCategory()}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user