diff --git a/src/actions/index.js b/src/actions/index.js index 7c47e256..66ba1756 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -4,7 +4,7 @@ import { urlFromEnv } from '../common/utilities' // TODO: relegate these URLs entirely to environment variables // const CONFIG_URL = urlFromEnv('CONFIG_EXT') const EVENT_DATA_URL = urlFromEnv('EVENTS_EXT') -const CATEGORY_URL = urlFromEnv('CATEGORIES_EXT') +// const CATEGORY_URL = urlFromEnv('CATEGORIES_EXT') const ASSOCIATIONS_URL = urlFromEnv('ASSOCIATIONS_EXT') const SOURCES_URL = urlFromEnv('SOURCES_EXT') const SITES_URL = urlFromEnv('SITES_EXT') @@ -42,13 +42,6 @@ export function fetchDomain () { ) ).then(results => results.flatMap(t => t)) - let catPromise = Promise.resolve([]) - if (features.USE_CATEGORIES) { - catPromise = fetch(CATEGORY_URL) - .then(response => response.json()) - .catch(() => handleError(domainMsg('categories'))) - } - let associationsPromise = Promise.resolve([]) if (features.USE_ASSOCIATIONS) { if (!ASSOCIATIONS_URL) { @@ -87,7 +80,6 @@ export function fetchDomain () { return Promise.all([ eventPromise, - catPromise, associationsPromise, sourcesPromise, sitesPromise, @@ -96,17 +88,17 @@ export function fetchDomain () { .then(response => { const result = { events: response[0], - categories: response[1], - associations: response[2], - sources: response[3], - sites: response[4], - shapes: response[5], + associations: response[1], + sources: response[2], + sites: response[3], + shapes: response[4], notifications } if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) { throw new Error('Some URLs returned negative. If you are in development, check the server is running') } dispatch(toggleFetchingDomain()) + dispatch(setInitialCategories(result.associations)) return result }) .catch(err => { @@ -212,6 +204,14 @@ export function setNotLoading () { } } +export const SET_INITIAL_CATEGORIES = 'SET_INITIAL_CATEGORIES' +export function setInitialCategories (values) { + return { + type: SET_INITIAL_CATEGORIES, + values + } +} + export const UPDATE_TIMERANGE = 'UPDATE_TIMERANGE' export function updateTimeRange (timerange) { return { diff --git a/src/common/constants.js b/src/common/constants.js index 0537970a..12703680 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -1,2 +1,5 @@ -export const FILTER_MODE = 'FILTER' -export const NARRATIVE_MODE = 'NARRATIVE' +export const ASSOCIATION_MODES = { + CATEGORY: 'CATEGORY', + NARRATIVE: 'NARRATIVE', + FILTER: 'FILTER' +} diff --git a/src/common/utilities.js b/src/common/utilities.js index dac0f4ab..989c3702 100644 --- a/src/common/utilities.js +++ b/src/common/utilities.js @@ -62,6 +62,18 @@ export function trimAndEllipse (string, stringNum) { return string } +export function getEventCategories (event, categories) { + const matchedCategories = [] + if (event.associations && event.associations.length > 0) { + event.associations.reduce((acc, val) => { + const foundCategory = categories.find(cat => cat.id === val) + if (foundCategory) acc.push(foundCategory) + return acc + }, matchedCategories) + } + return matchedCategories +} + /** * Inset the full source represenation from 'allSources' into an event. The * function is 'curried' to allow easy use with maps. To use for a single diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 61e91aa9..5a0f5eff 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -78,7 +78,7 @@ class Timeline extends React.Component { makeScaleY (categories, trackHeight, marginTop) { const { features } = this.props if (features.GRAPH_NONLOCATED && features.GRAPH_NONLOCATED.categories) { - categories = categories.filter(cat => !features.GRAPH_NONLOCATED.categories.includes(cat.category)) + categories = categories.filter(cat => !features.GRAPH_NONLOCATED.categories.includes(cat.id)) } const catHeight = trackHeight / (categories.length) const shiftUp = trackHeight / (categories.length) / 2 @@ -87,7 +87,8 @@ class Timeline extends React.Component { const catsYpos = categories.map((g, i) => { return ((i + 1) * catHeight) - shiftUp + marginShift + manualAdjustment }) - const catMap = categories.map(c => c.category) + const catMap = categories.map(c => c.id) + return (cat) => { const idx = catMap.indexOf(cat) return catsYpos[idx] @@ -268,11 +269,16 @@ class Timeline extends React.Component { getY (event) { const { features, domain } = this.props - const { USE_CATEGORIES, GRAPH_NONLOCATED } = features + const { categories } = domain + + const categoriesExist = categories && categories.length > 0 + + const { GRAPH_NONLOCATED } = features - if (!USE_CATEGORIES) { return this.state.dims.trackHeight / 2 } + if (!categoriesExist) { return this.state.dims.trackHeight / 2 } const { category, project } = event + if (GRAPH_NONLOCATED && GRAPH_NONLOCATED.categories.includes(category)) { return this.state.dims.marginTop + domain.projects[project].offset + this.props.ui.eventRadius } @@ -336,7 +342,7 @@ class Timeline extends React.Component { onDragStart={() => { this.onDragStart() }} onDrag={() => { this.onDrag() }} onDragEnd={() => { this.onDragEnd() }} - categories={this.props.domain.categories} + categories={this.props.app.activeCategories} features={this.props.features} /> - - {category} + + {cat} ) } render () { - const { dims } = this.props - const categories = this.props.features.USE_CATEGORIES + const { dims, categories } = this.props + const categoriesExist = categories && categories.length > 0 + const renderedCategories = categoriesExist ? this.props.categories.map((cat, idx) => this.renderCategory(cat, idx)) : this.renderCategory('Events', 0) return ( - {categories} + {renderedCategories} {categories.map(cat => { return (
  • onCategoryFilter(cat.category)} + label={cat.id} + isActive={activeCategories.includes(cat.id)} + onClickCheckbox={() => onCategoryFilter(cat.id)} />
  • ) })} diff --git a/src/components/presentational/Timeline/Events.js b/src/components/presentational/Timeline/Events.js index 86a1b734..58983848 100644 --- a/src/components/presentational/Timeline/Events.js +++ b/src/components/presentational/Timeline/Events.js @@ -4,7 +4,7 @@ import DatetimeBar from './DatetimeBar' import DatetimeSquare from './DatetimeSquare' import DatetimeStar from './DatetimeStar' import Project from './Project' -import { calcOpacity } from '../../../common/utilities' +import { calcOpacity, getEventCategories } from '../../../common/utilities' function renderDot (event, styles, props) { return { const narIds = narrative ? narrative.steps.map(s => s.id) : [] - function renderEvent (event) { + function renderEvent (aggregated, event) { if (narrative) { if (!(narIds.includes(event.id))) { return null } } - const isDot = (!!event.location && !!event.longitude) || (features.GRAPH_NONLOCATED && event.projectOffset !== -1) + let renderShape = isDot ? renderDot : renderBar if (event.shape) { if (event.shape === 'bar') { @@ -96,23 +97,33 @@ const TimelineEvents = ({ } } - const eventY = getY(event) - let colour = event.colour ? event.colour : getCategoryColor(event.category) - const styles = { - fill: colour, - fillOpacity: eventY > 0 ? calcOpacity(1) : 0, - transition: `transform ${transitionDuration / 1000}s ease` - } + const relatedCategories = getEventCategories(event, categories) - return renderShape(event, styles, { - x: getDatetimeX(event.datetime), - y: eventY, - eventRadius, - onSelect: () => onSelect(event), - dims, - highlights: features.HIGHLIGHT_GROUPS ? getHighlights(event.filters[features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup]) : [], - features - }) + if (relatedCategories && relatedCategories.length > 0) { + relatedCategories.forEach(cat => { + const eventY = getY({ ...event, category: cat.id }) + + let colour = event.colour ? event.colour : getCategoryColor(cat.id) + const styles = { + fill: colour, + fillOpacity: eventY > 0 ? calcOpacity(1) : 0, + transition: `transform ${transitionDuration / 1000}s ease` + } + + aggregated.push( + renderShape(event, styles, { + x: getDatetimeX(event.datetime), + y: eventY, + eventRadius, + onSelect: () => onSelect(event), + dims, + highlights: features.HIGHLIGHT_GROUPS ? getHighlights(event.filters[features.HIGHLIGHT_GROUPS.filterIndexIndicatingGroup]) : [], + features + }) + ) + }) + } + return aggregated } let renderProjects = () => null @@ -136,7 +147,7 @@ const TimelineEvents = ({ clipPath={'url(#clip)'} > {renderProjects()} - {events.map(event => renderEvent(event))} + {events.reduce(renderEvent, [])}
    ) } diff --git a/src/reducers/app.js b/src/reducers/app.js index c1761f98..d87f029d 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -1,4 +1,5 @@ import initial from '../store/initial.js' +import { ASSOCIATION_MODES } from '../common/constants' import { toggleFlagAC } from '../common/utilities' import { @@ -22,6 +23,7 @@ import { FETCH_SOURCE_ERROR, SET_LOADING, SET_NOT_LOADING, + SET_INITIAL_CATEGORIES, UPDATE_SEARCH_QUERY } from '../actions' @@ -113,13 +115,14 @@ function toggleFilter (appState, action) { if (!(action.value instanceof Array)) { action.value = [action.value] } + const { filter: associationType } = action - let newFilters = appState.associations.filters.slice(0) + let newAssociations = appState.associations[associationType].slice(0) action.value.forEach(vl => { - if (newFilters.includes(vl)) { - newFilters = newFilters.filter(s => s !== vl) + if (newAssociations.includes(vl)) { + newAssociations = newAssociations.filter(s => s !== vl) } else { - newFilters.push(vl) + newAssociations.push(vl) } }) @@ -127,7 +130,7 @@ function toggleFilter (appState, action) { ...appState, associations: { ...appState.associations, - filters: newFilters + [associationType]: newAssociations } } } @@ -218,6 +221,21 @@ function setNotLoading (appState) { } } +function setInitialCategories (appState, action) { + const categories = action.values.reduce((acc, val) => { + if (val.mode === ASSOCIATION_MODES.CATEGORY) acc.push(val.id) + return acc + }, []) + + return { + ...appState, + associations: { + ...appState.associations, + categories: categories + } + } +} + function updateSearchQuery (appState, action) { return { ...appState, @@ -269,6 +287,8 @@ function app (appState = initial.app, action) { return setLoading(appState) case SET_NOT_LOADING: return setNotLoading(appState) + case SET_INITIAL_CATEGORIES: + return setInitialCategories(appState, action) case UPDATE_SEARCH_QUERY: return updateSearchQuery(appState, action) default: diff --git a/src/reducers/validate/categorySchema.js b/src/reducers/validate/categorySchema.js deleted file mode 100644 index 4eb7bf46..00000000 --- a/src/reducers/validate/categorySchema.js +++ /dev/null @@ -1,9 +0,0 @@ -import Joi from 'joi' - -const categorySchema = Joi.object().keys({ - category: Joi.string().required(), - description: Joi.string(), - group: Joi.string() -}) - -export default categorySchema diff --git a/src/reducers/validate/eventSchema.js b/src/reducers/validate/eventSchema.js index 312f7da2..918f5fb2 100644 --- a/src/reducers/validate/eventSchema.js +++ b/src/reducers/validate/eventSchema.js @@ -23,7 +23,7 @@ function createEventSchema (custom) { type: Joi.string().allow(''), category: Joi.string().allow(''), category_full: Joi.string().allow(''), - associations: Joi.array(), + associations: Joi.array().required().default([]), sources: Joi.array(), comments: Joi.string().allow(''), time_display: Joi.string().allow(''), diff --git a/src/reducers/validate/validators.js b/src/reducers/validate/validators.js index 486458cc..a6c24c43 100644 --- a/src/reducers/validate/validators.js +++ b/src/reducers/validate/validators.js @@ -1,7 +1,6 @@ import Joi from 'joi' import createEventSchema from './eventSchema' -import categorySchema from './categorySchema' import siteSchema from './siteSchema' import associationsSchema from './associationsSchema' import sourceSchema from './sourceSchema' @@ -47,7 +46,6 @@ function findDuplicateAssociations (associations) { export function validateDomain (domain, features) { const sanitizedDomain = { events: [], - categories: [], sites: [], associations: [], sources: {}, @@ -61,7 +59,6 @@ export function validateDomain (domain, features) { const discardedDomain = { events: [], - categories: [], sites: [], associations: [], sources: [], @@ -110,7 +107,6 @@ export function validateDomain (domain, features) { const eventSchema = createEventSchema(features.CUSTOM_EVENT_FIELDS) validateArray(domain.events, 'events', eventSchema) - validateArray(domain.categories, 'categories', categorySchema) validateArray(domain.sites, 'sites', siteSchema) validateArray(domain.associations, 'associations', associationsSchema) validateObject(domain.sources, 'sources', sourceSchema) diff --git a/src/selectors/index.js b/src/selectors/index.js index 4b5d2b64..98770abd 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -1,18 +1,18 @@ import { createSelector } from 'reselect' import { insetSourceFrom, dateMin, dateMax } from '../common/utilities' import { isTimeRangedIn } from './helpers' -import { FILTER_MODE, NARRATIVE_MODE } from '../common/constants' +import { ASSOCIATION_MODES } from '../common/constants' // Input selectors export const getEvents = state => state.domain.events -export const getCategories = state => state.domain.categories -export const getNarratives = state => state.domain.associations.filter(item => item.mode === NARRATIVE_MODE) +export const getCategories = state => state.domain.associations.filter(item => item.mode === ASSOCIATION_MODES.CATEGORY) +export const getNarratives = state => state.domain.associations.filter(item => item.mode === ASSOCIATION_MODES.NARRATIVE) export const getActiveNarrative = state => state.app.associations.narrative export const getSelected = state => state.app.selected export const getSites = state => state.domain.sites export const getSources = state => state.domain.sources export const getShapes = state => state.domain.shapes -export const getFilters = state => state.domain.associations.filter(item => item.mode === FILTER_MODE) +export const getFilters = state => state.domain.associations.filter(item => item.mode === ASSOCIATION_MODES.FILTER) export const getNotifications = state => state.domain.notifications export const getActiveFilters = state => state.app.associations.filters export const getActiveCategories = state => state.app.associations.categories @@ -55,7 +55,11 @@ export const selectEvents = createSelector( .some(s => s) ) || activeFilters.length === 0 const isActiveFilter = isMatchingFilter || activeFilters.length === 0 - const isActiveCategory = activeCategories.includes(event.category) || activeCategories.length === 0 + const isActiveCategory = (event.associations && + event.associations.map(association => + activeCategories.includes(association)) + .some(s => s) + ) || activeCategories.length === 0 let isActiveTime = isTimeRangedIn(event, timeRange) isActiveTime = features.GRAPH_NONLOCATED ? ((!event.latitude && !event.longitude) || isActiveTime) diff --git a/src/store/initial.js b/src/store/initial.js index 1b9ab6d9..2ed221d4 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -12,7 +12,6 @@ const initial = { domain: { events: [], locations: [], - categories: [], associations: [], sources: {}, sites: [],