diff --git a/docs/configuration.md b/docs/configuration.md index 561f6f0..db6b268 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,7 @@ The URLs for these endpoints, as well as other configurable settings in your tim | SITES_EXT | Endpoint for sites, concatenated with SERVER_ROOT | String | Yes | | MAP_ANCHOR | Geographic coordinates for original map anchor | Array of numbers | No | | MAPBOX_TOKEN | Access token for Mapbox satellite imagery | String | No | -| features.USE_FILTERS | Enable / Disable filters | boolean | No | +| features.USE_ASSOCIATIONS | Enable / Disable filters | boolean | No | | features.USE_SEARCH | Enable / Disable search | boolean | No | | features.USE_SITES | Enable / Disable sites | boolean | No | diff --git a/example.config.js b/example.config.js index 81cd2d1..f257c79 100644 --- a/example.config.js +++ b/example.config.js @@ -4,9 +4,8 @@ module.exports = { SERVER_ROOT: 'http://localhost:4040', EVENTS_EXT: '/api/timemap_data/export_events/deeprows', CATEGORIES_EXT: '/api/timemap_data/export_categories/rows', - FILTERS_EXT: '/api/timemap_data/export_filters/tree', + ASSOCIATIONS_EXT: '/api/timemap_data/export_associations/deeprows', SOURCES_EXT: '/api/timemap_data/export_sources/deepids', - NARRATIVE_EXT: '', SITES_EXT: '', SHAPES_EXT: '', DATE_FMT: 'MM/DD/YYYY', @@ -22,13 +21,11 @@ module.exports = { // tiles: 'your-mapbox-account-name/x5678-map-id' }, features: { - USE_CATEGORIES: false, - CATEGORIES_AS_FILTERS: false, - USE_FILTERS: true, - FILTERS_AS_NARRATIVES: false, - USE_NARRATIVES: false, + USE_CATEGORIES: true, + CATEGORIES_AS_FILTERS: true, + USE_ASSOCIATIONS: true, USE_SOURCES: true, - USE_COVER: false, + USE_COVER: true, USE_SEARCH: false, USE_SITES: false, USE_SHAPES: false, diff --git a/src/actions/index.js b/src/actions/index.js index 2a61c5c..7c47e25 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -5,9 +5,8 @@ import { urlFromEnv } from '../common/utilities' // const CONFIG_URL = urlFromEnv('CONFIG_EXT') const EVENT_DATA_URL = urlFromEnv('EVENTS_EXT') const CATEGORY_URL = urlFromEnv('CATEGORIES_EXT') -const FILTERS_URL = urlFromEnv('FILTERS_EXT') +const ASSOCIATIONS_URL = urlFromEnv('ASSOCIATIONS_EXT') const SOURCES_URL = urlFromEnv('SOURCES_EXT') -const NARRATIVE_URL = urlFromEnv('NARRATIVES_EXT') const SITES_URL = urlFromEnv('SITES_EXT') const SHAPES_URL = urlFromEnv('SHAPES_EXT') @@ -50,28 +49,14 @@ export function fetchDomain () { .catch(() => handleError(domainMsg('categories'))) } - let narPromise = Promise.resolve([]) - if (features.USE_NARRATIVES) { - narPromise = fetch(NARRATIVE_URL) - .then(response => response.json()) - .catch(() => handleError(domainMsg('narratives'))) - } - - let sitesPromise = Promise.resolve([]) - if (features.USE_SITES) { - sitesPromise = fetch(SITES_URL) - .then(response => response.json()) - .catch(() => handleError(domainMsg('sites'))) - } - - let filtersPromise = Promise.resolve([]) - if (features.USE_FILTERS) { - if (!FILTERS_URL) { - filtersPromise = Promise.resolve(handleError('USE_FILTERS is true, but you have not provided a FILTERS_EXT')) + let associationsPromise = Promise.resolve([]) + if (features.USE_ASSOCIATIONS) { + if (!ASSOCIATIONS_URL) { + associationsPromise = Promise.resolve(handleError('USE_ASSOCIATIONS is true, but you have not provided a ASSOCIATIONS_EXT')) } else { - filtersPromise = fetch(FILTERS_URL) + associationsPromise = fetch(ASSOCIATIONS_URL) .then(response => response.json()) - .catch(() => handleError(domainMsg('filters'))) + .catch(() => handleError(domainMsg('associations'))) } } @@ -86,6 +71,13 @@ export function fetchDomain () { } } + let sitesPromise = Promise.resolve([]) + if (features.USE_SITES) { + sitesPromise = fetch(SITES_URL) + .then(response => response.json()) + .catch(() => handleError(domainMsg('sites'))) + } + let shapesPromise = Promise.resolve([]) if (features.USE_SHAPES) { shapesPromise = fetch(SHAPES_URL) @@ -96,21 +88,19 @@ export function fetchDomain () { return Promise.all([ eventPromise, catPromise, - narPromise, - sitesPromise, - filtersPromise, + associationsPromise, sourcesPromise, + sitesPromise, shapesPromise ]) .then(response => { const result = { events: response[0], categories: response[1], - narratives: response[2], - sites: response[3], - filters: response[4], - sources: response[5], - shapes: response[6], + associations: response[2], + sources: response[3], + sites: response[4], + shapes: response[5], notifications } if (Object.values(result).some(resp => resp.hasOwnProperty('error'))) { diff --git a/src/common/constants.js b/src/common/constants.js new file mode 100644 index 0000000..0537970 --- /dev/null +++ b/src/common/constants.js @@ -0,0 +1,2 @@ +export const FILTER_MODE = 'FILTER' +export const NARRATIVE_MODE = 'NARRATIVE' diff --git a/src/common/utilities.js b/src/common/utilities.js index 2dd4339..dac0f4a 100644 --- a/src/common/utilities.js +++ b/src/common/utilities.js @@ -74,9 +74,10 @@ export function insetSourceFrom (allSources) { if (!event.sources) { sources = [] } else { - sources = event.sources.map(id => ( - allSources.hasOwnProperty(id) ? allSources[id] : null - )) + sources = event.sources.map(src => { + const id = typeof src === 'object' ? src.id : src + return allSources.hasOwnProperty(id) ? allSources[id] : null + }) } return { ...event, @@ -199,19 +200,6 @@ export function binarySearch (ar, el, compareFn) { return -m - 1 } -export const isFilterLeaf = node => (Object.keys(node.children).length === 0) -export const isFilterDuplicate = (node, set) => { return (set.has(node.key)) } - -export function findDescriptionInFilterTree (key, node) { - if (node.key === key) return node.description - if (isFilterLeaf(node)) return false - const descs = Object.keys(node.children) - .map(c => findDescriptionInFilterTree(key, node.children[c])) - .filter(v => !!v) - if (descs.length !== 1) return false - return descs[0] -} - export function makeNiceDate (datetime) { if (datetime === null) return null // see https://stackoverflow.com/questions/3552461/how-to-format-a-javascript-date diff --git a/src/components/Card.jsx b/src/components/Card.jsx index 8fc9f56..7e0ba18 100644 --- a/src/components/Card.jsx +++ b/src/components/Card.jsx @@ -5,10 +5,8 @@ import CardCustomField from './presentational/Card/CustomField' import CardTime from './presentational/Card/Time' import CardLocation from './presentational/Card/Location' import CardCaret from './presentational/Card/Caret' -import CardFilters from './presentational/Card/Filters' import CardSummary from './presentational/Card/Summary' import CardSource from './presentational/Card/Source' -import CardNarrative from './presentational/Card/Narrative' import { makeNiceDate } from '../common/utilities' class Card extends React.Component { @@ -29,6 +27,13 @@ class Card extends React.Component { return makeNiceDate(datetime) } + handleCardSelect (e) { + if (!e.target.className.includes('arrow-down')) { + const selectedEventFormat = this.props.idx > 0 ? [this.props.event] : this.props.event + this.props.onSelect(selectedEventFormat, this.props.idx) + } + } + renderSummary () { return ( - ) - } - renderLocation () { return ( this.props.onSelect([event])} - makeTimelabel={(timestamp) => this.makeTimelabel(timestamp)} - next={links.next} - prev={links.prev} - /> - ) - } - } - renderCustomFields () { return this.props.features.CUSTOM_EVENT_FIELDS .map(field => { @@ -148,9 +126,7 @@ class Card extends React.Component { renderExtra () { return (
- {this.renderFilters()} {this.renderSources()} - {this.renderNarrative()}
) } @@ -166,17 +142,16 @@ class Card extends React.Component { render () { const { isSelected, idx } = this.props - return (
  • this.handleCardSelect(e)} > {this.renderMain()} {this.state.isOpen ? this.renderExtra() : null} - {isSelected ? this.renderCaret() : null} + {this.renderCaret()}
  • ) } diff --git a/src/components/CardStack.jsx b/src/components/CardStack.jsx index 9893190..85e3351 100644 --- a/src/components/CardStack.jsx +++ b/src/components/CardStack.jsx @@ -59,6 +59,7 @@ class CardStack extends React.Component { return events.map((event, idx) => { const thisRef = React.createRef() this.refs[idx] = thisRef + return ( this.props.onSelect(idx)} + onSelect={this.props.onSelect} + idx={idx} features={this.props.features} />) }) @@ -80,6 +81,7 @@ class CardStack extends React.Component { renderSelectedCards () { const { selected } = this.props + if (selected.length > 0) { return this.renderCards(selected) } @@ -137,7 +139,6 @@ class CardStack extends React.Component { render () { const { isCardstack, selected, narrative, timelineDims } = this.props - // TODO: make '237px', which is the narrative header, less hard-coded const height = `calc(100% - 237px - ${timelineDims.height}px)` if (selected.length > 0) { diff --git a/src/components/Layout.js b/src/components/Layout.js index a496f98..8ef21e7 100644 --- a/src/components/Layout.js +++ b/src/components/Layout.js @@ -4,6 +4,7 @@ import React from 'react' import { bindActionCreators } from 'redux' import { connect } from 'react-redux' import * as actions from '../actions' +import * as selectors from '../selectors' import MediaOverlay from './Overlay/Media' import LoadingOverlay from './Overlay/Loading' @@ -19,7 +20,7 @@ import StaticPage from './StaticPage' import TemplateCover from './TemplateCover' import colors from '../common/global' -import { binarySearch, insetSourceFrom, findDescriptionInFilterTree } from '../common/utilities' +import { binarySearch, insetSourceFrom } from '../common/utilities' import { isMobile } from 'react-device-detect' import Search from './Search.jsx' @@ -41,10 +42,11 @@ class Dashboard extends React.Component { componentDidMount () { if (!this.props.app.isMobile) { this.props.actions.fetchDomain() - .then(domain => this.props.actions.updateDomain({ - domain, - features: this.props.features - })) + .then(domain => + this.props.actions.updateDomain({ + domain, + features: this.props.features + })) } // NOTE: hack to get the timeline to always show. Not entirely sure why // this is necessary. @@ -85,7 +87,9 @@ class Dashboard extends React.Component { ptr >= 0 && (events[idx].datetime).getTime() === (events[ptr].datetime).getTime() ) { - matchedEvents.push(events[ptr]) + if (events[ptr].id !== selected.id) { + matchedEvents.push(events[ptr]) + } ptr -= 1 } // check events after @@ -95,15 +99,16 @@ class Dashboard extends React.Component { ptr < events.length && (events[idx].datetime).getTime() === (events[ptr].datetime).getTime() ) { - matchedEvents.push(events[ptr]) + if (events[ptr].id !== selected.id) { + matchedEvents.push(events[ptr]) + } ptr += 1 } - } else { // Map... + } else { // Map.. const std = { ...selected } delete std.sources Object.values(std).forEach(ev => matchedEvents.push(ev)) } - this.props.actions.updateSelected(matchedEvents) } @@ -118,15 +123,9 @@ class Dashboard extends React.Component { } } - getNarrativeLinks (event) { - const narrative = this.props.domain.narratives.find(nv => nv.id === event.narrative) - if (narrative) return narrative.byId[event.id] - return null - } - setNarrative (narrative) { - // only handleSelect if narrative is not null - if (narrative) { + // only handleSelect if narrative is not null and has associated events + if (narrative && narrative.steps.length >= 1) { this.handleSelect([ narrative.steps[0] ]) } this.props.actions.updateNarrative(narrative) @@ -134,30 +133,20 @@ class Dashboard extends React.Component { setNarrativeFromFilters (withSteps) { const { app, domain } = this.props - let activeFilters = app.filters.filters + let activeFilters = app.associations.filters if (activeFilters.length === 0) { alert('No filters selected, cant narrativise') return } - if (this.props.features.USE_FILTER_DESCRIPTIONS) { - activeFilters = activeFilters.reduce((acc, vl) => { - acc.push({ - name: vl, - description: findDescriptionInFilterTree(vl, domain.filters) - }) - return acc - }, []) - } else { - activeFilters = activeFilters.map(f => ({ name: f })) - } + activeFilters = activeFilters.map(f => ({ name: f })) const evs = domain.events.filter(ev => { let hasOne = false // add event if it has at least one matching filter for (let i = 0; i < activeFilters.length; i++) { - if (ev.filters.includes(activeFilters[i].name)) { + if (ev.associations.includes(activeFilters[i].name)) { hasOne = true break } @@ -166,6 +155,11 @@ class Dashboard extends React.Component { return false }) + if (evs.length === 0) { + alert('No associated events, cant narrativise') + return + } + const name = activeFilters.map(f => f.name).join('-') const desc = activeFilters.map(f => f.description).join('\n\n') this.setNarrative({ @@ -182,8 +176,8 @@ class Dashboard extends React.Component { if (typeof idx !== 'number') { let e = idx[0] || idx - if (this.props.app.narrative) { - const { steps } = this.props.app.narrative + if (this.props.app.associations.narrative) { + const { steps } = this.props.app.associations.narrative // choose the first event at a given location const locationEventId = e.id const narrativeIdxObj = steps.find(s => s.id === locationEventId) @@ -195,7 +189,7 @@ class Dashboard extends React.Component { } } - const { narrative } = this.props.app + const { narrative } = this.props.app.associations if (narrative === null) return if (idx < narrative.steps.length && idx >= 0) { @@ -209,18 +203,19 @@ class Dashboard extends React.Component { onKeyDown (e) { const { narrative, selected } = this.props.app const { events } = this.props.domain + const prev = idx => { if (narrative === null) { this.handleSelect(events[idx - 1], 0) } else { - this.selectNarrativeStep(this.props.app.narrativeState.current - 1) + this.selectNarrativeStep(this.props.narrativeIdx - 1) } } const next = idx => { if (narrative === null) { this.handleSelect(events[idx + 1], 0) } else { - this.selectNarrativeStep(this.props.app.narrativeState.current + 1) + this.selectNarrativeStep(this.props.narrativeIdx + 1) } } if (selected.length > 0) { @@ -267,7 +262,7 @@ class Dashboard extends React.Component { return (
    actions.toggleFilter('filters', filter), @@ -280,13 +275,13 @@ class Dashboard extends React.Component { methods={{ onSelectNarrative: this.setNarrative, getCategoryColor: this.getCategoryColor, - onSelect: app.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 1) + onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 1) }} /> this.handleSelect(ev, 0), + onSelect: app.associations.narrative ? this.selectNarrativeStep : ev => this.handleSelect(ev, 0), onUpdateTimerange: actions.updateTimeRange, getCategoryColor: this.getCategoryColor }} @@ -294,25 +289,24 @@ class Dashboard extends React.Component { actions.updateSelected([])} - getNarrativeLinks={event => this.getNarrativeLinks(event)} getCategoryColor={this.getCategoryColor} /> 0} + showing={this.props.narratives && this.props.narratives.length !== 0 && !app.associations.narrative && app.associations.filters.length > 0} timelineDims={app.timeline.dimensions} onClickHandler={this.setNarrativeFromFilters} /> this.selectNarrativeStep(this.props.app.narrativeState.current + 1), - onPrev: () => this.selectNarrativeStep(this.props.app.narrativeState.current - 1), + onNext: () => this.selectNarrativeStep(this.props.narrativeIdx + 1), + onPrev: () => this.selectNarrativeStep(this.props.narrativeIdx - 1), onSelectNarrative: this.setNarrative }} /> @@ -367,6 +361,11 @@ function mapDispatchToProps (dispatch) { } export default connect( - state => state, + state => ({ + ...state, + narrativeIdx: selectors.selectNarrativeIdx(state), + narratives: selectors.selectNarratives(state), + selected: selectors.selectSelected(state) + }), mapDispatchToProps )(Dashboard) diff --git a/src/components/Map.jsx b/src/components/Map.jsx index e74462c..ce971be 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -276,11 +276,11 @@ function mapStateToProps (state) { shapes: selectors.selectShapes(state) }, app: { - views: state.app.filters.views, + views: state.app.associations.views, selected: selectors.selectSelected(state), highlighted: state.app.highlighted, map: state.app.map, - narrative: state.app.narrative, + narrative: state.app.associations.narrative, flags: { isShowingSites: state.app.flags.isShowingSites } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index bd34538..11b9d87 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -398,7 +398,7 @@ class Timeline extends React.Component { function mapStateToProps (state) { return { dimensions: selectors.selectDimensions(state), - isNarrative: !!state.app.narrative, + isNarrative: !!state.app.associations.narrative, domain: { events: selectors.selectStackedEvents(state), projects: selectors.selectProjects(state), @@ -409,7 +409,7 @@ function mapStateToProps (state) { selected: state.app.selected, language: state.app.language, timeline: state.app.timeline, - narrative: state.app.narrative + narrative: state.app.associations.narrative }, ui: { dom: state.ui.dom, diff --git a/src/components/Toolbar/FilterListPanel.js b/src/components/Toolbar/FilterListPanel.js index 53bb31a..c3447f1 100644 --- a/src/components/Toolbar/FilterListPanel.js +++ b/src/components/Toolbar/FilterListPanel.js @@ -3,57 +3,71 @@ import Checkbox from '../presentational/Checkbox' import copy from '../../common/data/copy.json' /** recursively get an array of node keys to toggle */ -function childrenToToggle (node, activeFilters, parentOn) { - const isOn = activeFilters.includes(node.key) - if (!node.children) { - return [node.key] +function childrenToToggle (filter, activeFilters, parentOn) { + const [key, children] = filter + const isOn = activeFilters.includes(key) + if (children === {}) { + return [key] } - const childKeys = Object.values(node.children) - .flatMap(n => childrenToToggle(n, activeFilters, isOn)) + const childKeys = Object.entries(children) + .flatMap(filter => childrenToToggle(filter, activeFilters, isOn)) // NB: if turning a parent off, don't toggle off children on. // likewise if turning a parent on, don't toggle on children off if (!((!parentOn && isOn) || (parentOn && !isOn))) { - childKeys.push(node.key) + childKeys.push(key) } return childKeys } +function aggregatePaths (filters) { + function insertPath (children = {}, [headOfPath, ...remainder]) { + let childKey = Object.keys(children).find(key => key === headOfPath) + if (!childKey) children[headOfPath] = {} + if (remainder.length > 0) insertPath(children[headOfPath], remainder) + return children + } + + const allPaths = [] + filters.forEach(filterItem => allPaths.push(filterItem.filter_paths)) + + let aggregatedPaths = allPaths.reduce((children, path) => insertPath(children, path), {}) + return aggregatedPaths +} + function FilterListPanel ({ filters, activeFilters, onSelectFilter, language }) { - function createNodeComponent (node, depth) { - const matchingKeys = childrenToToggle(node, activeFilters, activeFilters.includes(node.key)) - const children = Object.values(node.children) + function createNodeComponent (filter, depth) { + const [key, children] = filter + const matchingKeys = childrenToToggle(filter, activeFilters, activeFilters.includes(key)) + return (
  • - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} onSelectFilter(matchingKeys)} /> - {children.length > 0 - ? children.map(filter => createNodeComponent(filter, depth + 1)) + {Object.keys(children).length > 0 + ? Object.entries(children).map(filter => createNodeComponent(filter, depth + 1)) : null}
  • ) } - function renderTree (children) { + function renderTree (filters) { + const aggregatedFilterPaths = aggregatePaths(filters) + return (
    - {Object.values(children).map(filter => createNodeComponent(filter, 1))} + {Object.entries(aggregatedFilterPaths).map(filter => createNodeComponent(filter, 1))}
    ) } @@ -62,7 +76,7 @@ function FilterListPanel ({

    {copy[language].toolbar.filters}

    {copy[language].toolbar.explore_by_filter__description}

    - {renderTree(filters.children)} + {renderTree(filters)}
    ) } diff --git a/src/components/Toolbar/Layout.js b/src/components/Toolbar/Layout.js index feaa8e0..cceab2a 100644 --- a/src/components/Toolbar/Layout.js +++ b/src/components/Toolbar/Layout.js @@ -62,8 +62,8 @@ class Toolbar extends React.Component { return (
    ) @@ -113,15 +113,15 @@ class Toolbar extends React.Component { } renderToolbarPanels () { - const { features } = this.props + const { features, narratives } = this.props let classes = (this.state._selected >= 0) ? 'toolbar-panels' : 'toolbar-panels folded' return (
    {this.renderClosePanel()} - {features.USE_NARRATIVES ? this.renderToolbarNarrativePanel() : null} + {narratives && narratives.length !== 0 ? this.renderToolbarNarrativePanel() : null} {features.CATEGORIES_AS_FILTERS ? this.renderToolbarCategoriesPanel() : null} - {features.USE_FILTERS ? this.renderToolbarFilterPanel() : null} + {features.USE_ASSOCIATIONS ? this.renderToolbarFilterPanel() : null}
    ) @@ -145,7 +145,8 @@ class Toolbar extends React.Component { } renderToolbarTabs () { - const { features } = this.props + const { features, narratives } = this.props + const narrativesExist = narratives && narratives.length !== 0 let title = copy[this.props.language].toolbar.title if (process.env.display_title) title = process.env.display_title const narrativesLabel = copy[this.props.language].toolbar.narratives_label @@ -153,17 +154,17 @@ class Toolbar extends React.Component { const categoriesLabel = 'Categories' // TODO: const narrativesIdx = 0 - const categoriesIdx = features.USE_NARRATIVES ? 1 : 0 - const filtersIdx = (features.USE_NARRATIVES && features.CATEGORIES_AS_FILTERS) ? 2 : ( - features.USE_NARRATIVES || features.CATEGORIES_AS_FILTERS ? 1 : 0 + const categoriesIdx = narrativesExist ? 1 : 0 + const filtersIdx = (narrativesExist && features.CATEGORIES_AS_FILTERS) ? 2 : ( + narrativesExist || features.CATEGORIES_AS_FILTERS ? 1 : 0 ) return (

    {title}

    - {features.USE_NARRATIVES ? this.renderToolbarTab(narrativesIdx, narrativesLabel, 'timeline') : null} + {narrativesExist ? this.renderToolbarTab(narrativesIdx, narrativesLabel, 'timeline') : null} {features.CATEGORIES_AS_FILTERS ? this.renderToolbarTab(categoriesIdx, categoriesLabel, 'widgets') : null} - {features.USE_FILTERS ? this.renderToolbarTab(filtersIdx, filtersLabel, 'filter_list') : null} + {features.USE_ASSOCIATIONS ? this.renderToolbarTab(filtersIdx, filtersLabel, 'filter_list') : null}
    { - const filtersLang = copy[language].cardstack.filters - const noFiltersLang = copy[language].cardstack.nofilters - - if (filters.length > 0) { - return ( -
    -

    {filtersLang}:

    -

    - {filters.map((filter, idx) => { - return ( - - {filter.name} - {(idx < filters.length - 1) - ? ',' - : ''} - - ) - })} -

    -
    - ) - } - return ( -
    -

    {filtersLang}

    -

    {noFiltersLang}

    -
    - ) -} - -export default CardFilters diff --git a/src/components/presentational/Card/Narrative.js b/src/components/presentational/Card/Narrative.js deleted file mode 100644 index 150180f..0000000 --- a/src/components/presentational/Card/Narrative.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' - -import CardNarrativeLink from './NarrativeLink' - -const CardNarrative = (props) => ( -
    -

    Connected events

    -
    -

    -

    -
    -
    -) - -export default CardNarrative diff --git a/src/components/presentational/Card/NarrativeLink.js b/src/components/presentational/Card/NarrativeLink.js deleted file mode 100644 index b292e16..0000000 --- a/src/components/presentational/Card/NarrativeLink.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react' - -const CardNarrativeLink = ({ event, makeTimelabel, select }) => { - if (event !== null) { - const timelabel = makeTimelabel(event.timestamp) - - return ( - select(event)}> - {`${timelabel} / ${event.location}`} - - ) - } - - return (None) -} - -export default CardNarrativeLink diff --git a/src/components/presentational/Map/Narratives.js b/src/components/presentational/Map/Narratives.js index 8e0aefa..cbae8c7 100644 --- a/src/components/presentational/Map/Narratives.js +++ b/src/components/presentational/Map/Narratives.js @@ -26,6 +26,8 @@ function MapNarratives ({ return styles[styleName] } + const narrativesExist = narratives && narratives.length !== 0 + function hasNoLocation (step) { return (step.latitude === '' || step.longitude === '') } @@ -141,7 +143,7 @@ function MapNarratives ({ let lastMarked = null - if (features.FILTERS_AS_NARRATIVES) { + if (narrativesExist) { for (let idx = 0; idx < n.steps.length; idx += 1) { const step = n.steps[idx] if (lastMarked) { @@ -174,7 +176,7 @@ function MapNarratives ({ function renderNarrative (n) { const narrativeId = `narrative-${n.id.replace(/ /g, '_')}` - const body = features.FILTERS_AS_NARRATIVES + const body = narrativesExist ? renderBetweenMarked(n) : (features.NARRATIVE_STEP_STYLES ? renderBetweenMarked(n) diff --git a/src/reducers/app.js b/src/reducers/app.js index c94785d..c1761f9 100644 --- a/src/reducers/app.js +++ b/src/reducers/app.js @@ -83,17 +83,19 @@ function updateNarrative (appState, action) { minTime = minTime - Math.abs((maxTime - minTime) / 10) maxTime = maxTime + Math.abs((maxTime - minTime) / 10) } - return { ...appState, - narrative: action.narrative, - narrativeState: { - current: action.narrative ? 0 : null + associations: { + ...appState.associations, + narrative: action.narrative }, - filters: { - ...appState.filters, - timerange: [minTime, maxTime], - mapBounds: (action.narrative) ? [cornerBound0, cornerBound1] : null + map: { + ...appState.map, + bounds: (action.narrative) ? [cornerBound0, cornerBound1] : null + }, + timeline: { + ...appState.timeline, + range: [minTime, maxTime] } } } @@ -112,7 +114,7 @@ function toggleFilter (appState, action) { action.value = [action.value] } - let newFilters = appState.filters[action.filter].slice(0) + let newFilters = appState.associations.filters.slice(0) action.value.forEach(vl => { if (newFilters.includes(vl)) { newFilters = newFilters.filter(s => s !== vl) @@ -123,9 +125,9 @@ function toggleFilter (appState, action) { return { ...appState, - filters: { - ...appState.filters, - [action.filter]: newFilters + associations: { + ...appState.associations, + filters: newFilters } } } diff --git a/src/reducers/validate/associationsSchema.js b/src/reducers/validate/associationsSchema.js new file mode 100644 index 0000000..4d921cc --- /dev/null +++ b/src/reducers/validate/associationsSchema.js @@ -0,0 +1,10 @@ +import Joi from 'joi' + +const associationsSchema = Joi.object().keys({ + id: Joi.string().allow('').required(), + desc: Joi.string().allow(''), + mode: Joi.string().allow('').required(), + filter_paths: Joi.array() +}) + +export default associationsSchema diff --git a/src/reducers/validate/eventSchema.js b/src/reducers/validate/eventSchema.js index 6b7cb64..312f7da 100644 --- a/src/reducers/validate/eventSchema.js +++ b/src/reducers/validate/eventSchema.js @@ -23,10 +23,8 @@ function createEventSchema (custom) { type: Joi.string().allow(''), category: Joi.string().allow(''), category_full: Joi.string().allow(''), - narratives: Joi.array(), + associations: Joi.array(), sources: Joi.array(), - filters: Joi.array().allow(''), - tags: Joi.array().allow(''), comments: Joi.string().allow(''), time_display: Joi.string().allow(''), // nested diff --git a/src/reducers/validate/narrativeSchema.js b/src/reducers/validate/narrativeSchema.js deleted file mode 100644 index 2f4c8ff..0000000 --- a/src/reducers/validate/narrativeSchema.js +++ /dev/null @@ -1,9 +0,0 @@ -import Joi from 'joi' - -const narrativeSchema = Joi.object().keys({ - id: Joi.string().required(), - description: Joi.string().allow('').required(), - label: Joi.string().required() -}) - -export default narrativeSchema diff --git a/src/reducers/validate/validators.js b/src/reducers/validate/validators.js index 05e6dbb..486458c 100644 --- a/src/reducers/validate/validators.js +++ b/src/reducers/validate/validators.js @@ -3,11 +3,11 @@ import Joi from 'joi' import createEventSchema from './eventSchema' import categorySchema from './categorySchema' import siteSchema from './siteSchema' -import narrativeSchema from './narrativeSchema' +import associationsSchema from './associationsSchema' import sourceSchema from './sourceSchema' import shapeSchema from './shapeSchema' -import { calcDatetime, capitalize, isFilterLeaf, isFilterDuplicate } from '../../common/utilities' +import { calcDatetime, capitalize } from '../../common/utilities' /* * Create an error notification object @@ -25,50 +25,20 @@ function isValidDate (d) { return d instanceof Date && !isNaN(d) } -/* -* Traverse a filter tree and check its duplicates. Also recompose as -* description if `features.USE_FILTER_DESCRIPTIONS` is true. -*/ -function validateFilterTree (node, parent, set, duplicates, hasFilterDescriptions) { - if (hasFilterDescriptions) { - if (node.key === '_root') { - node.isDescription = true // setting first set of nodes to values - } else if (!parent.isDescription) { - node.isDescription = true - } else { - node.isDescription = false - } - - if (node.isDescription && node.key !== 'root') { - parent.description = node.key - parent.children = node.children - delete parent.isDescription - } - if (isFilterLeaf(node)) { - delete parent.isDescription - } - } - - if (typeof (node) !== 'object' || typeof (node.children) !== 'object') { - return - } - // If it's a leaf, check that it's not duplicate - if (isFilterLeaf(node)) { - if (isFilterDuplicate(node, set)) { +function findDuplicateAssociations (associations) { + const seenSet = new Set([]) + const duplicates = [] + associations.forEach(item => { + if (seenSet.has(item.id)) { duplicates.push({ - id: node.key, - error: makeError('Filters', node.key, 'filter was found more than once in hierarchy. Ignoring duplicate.') + id: item.id, + error: makeError('Association', item.id, 'association was found more than once. Ignoring duplicate.') }) - delete parent.children[node.key] } else { - set.add(node.key) + seenSet.add(item.id) } - } else { - // If it's not a leaf, simply keep going - Object.values(node.children).forEach((childNode) => { - validateFilterTree(childNode, node, set, duplicates, hasFilterDescriptions) - }) - } + }) + return duplicates } /* @@ -79,9 +49,8 @@ export function validateDomain (domain, features) { events: [], categories: [], sites: [], - narratives: [], + associations: [], sources: {}, - filters: {}, shapes: [], notifications: domain ? domain.notifications : null } @@ -94,7 +63,7 @@ export function validateDomain (domain, features) { events: [], categories: [], sites: [], - narratives: [], + associations: [], sources: [], shapes: [] } @@ -114,12 +83,6 @@ export function validateDomain (domain, features) { function validateArray (items, domainKey, schema) { items.forEach(item => { - // NB: backwards compatibility with 'tags' for 'filters' - if (domainKey === 'events') { - if (!item.filters && !!item.tags) { - item.filters = item.tags - } - } validateArrayItem(item, domainKey, schema) }) } @@ -149,7 +112,7 @@ export function validateDomain (domain, features) { validateArray(domain.events, 'events', eventSchema) validateArray(domain.categories, 'categories', categorySchema) validateArray(domain.sites, 'sites', siteSchema) - validateArray(domain.narratives, 'narratives', narrativeSchema) + validateArray(domain.associations, 'associations', associationsSchema) validateObject(domain.sources, 'sources', sourceSchema) validateObject(domain.shapes, 'shapes', shapeSchema) @@ -162,20 +125,16 @@ export function validateDomain (domain, features) { }) ) - // Validate uniqueness of filters - const filterSet = new Set([]) - const duplicateFilters = [] - validateFilterTree(domain.filters, {}, filterSet, duplicateFilters, features.USE_FILTER_DESCRIPTIONS) - - // Duplicated filters - if (duplicateFilters.length > 0) { + const duplicateAssociations = findDuplicateAssociations(domain.associations) + // Duplicated associations + if (duplicateAssociations.length > 0) { sanitizedDomain.notifications.push({ - message: `Filters are required to be unique. Ignoring duplicates for now.`, - items: duplicateFilters, + message: `Associations are required to be unique. Ignoring duplicates for now.`, + items: duplicateAssociations, type: 'error' }) } - sanitizedDomain.filters = domain.filters + sanitizedDomain.associations = domain.associations // append events with datetime and sort sanitizedDomain.events = sanitizedDomain.events.filter((event, idx) => { diff --git a/src/selectors/index.js b/src/selectors/index.js index 9441594..4b5d2b6 100644 --- a/src/selectors/index.js +++ b/src/selectors/index.js @@ -1,24 +1,24 @@ import { createSelector } from 'reselect' import { insetSourceFrom, dateMin, dateMax } from '../common/utilities' import { isTimeRangedIn } from './helpers' +import { FILTER_MODE, NARRATIVE_MODE } 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.narratives -export const getActiveNarrative = state => state.app.narrative -export const getActiveStep = state => state.app.narrativeState.current +export const getNarratives = state => state.domain.associations.filter(item => item.mode === NARRATIVE_MODE) +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 getNotifications = state => state.domain.notifications -export const getFilterTree = state => state.domain.filters -export const getActiveFilters = state => state.app.filters.filters -export const getActiveCategories = state => state.app.filters.categories +export const getActiveFilters = state => state.app.associations.filters +export const getActiveCategories = state => state.app.associations.categories export const getTimeRange = state => state.app.timeline.range export const getTimelineDimensions = state => state.app.timeline.dimensions -export const selectNarrative = state => state.app.narrative +export const selectNarrative = state => state.app.associations.narrative export const getFeatures = state => state.features export const getEventRadius = state => state.ui.eventRadius @@ -49,9 +49,9 @@ export const selectEvents = createSelector( [getEvents, getActiveFilters, getActiveCategories, getTimeRange, getFeatures], (events, activeFilters, activeCategories, timeRange, features) => { return events.reduce((acc, event) => { - const isMatchingFilter = (event.filters && - event.filters.map(filter => - activeFilters.includes(filter)) + const isMatchingFilter = (event.associations && + event.associations.map(association => + activeFilters.includes(association)) .some(s => s) ) || activeFilters.length === 0 const isActiveFilter = isMatchingFilter || activeFilters.length === 0 @@ -76,7 +76,7 @@ export const selectEvents = createSelector( export const selectNarratives = createSelector( [getEvents, getNarratives, getSources, getFeatures], (events, narrativesMeta, sources, features) => { - if (!features.USE_NARRATIVES) { + if (Array.isArray(narrativesMeta) && narrativesMeta.length === 0) { return [] } const narratives = {} @@ -84,40 +84,62 @@ export const selectNarratives = createSelector( /* populate narratives dict with events */ events.forEach(evt => { - evt.narratives.forEach(narrative => { - // initialise - if (!narratives[narrative]) { narratives[narrative] = narrativeSkeleton(narrative) } - - // add evt to steps - // NB: insetSourceFrom is a 'curried' function to allow with maps - narratives[narrative].steps.push(insetSourceFrom(sources)(evt)) + evt.associations.forEach(association => { + const foundNarrative = narrativesMeta.find(narr => narr.id === association) + if (foundNarrative) { + const { id: narrId } = foundNarrative + // initialise + if (!narratives[narrId]) { narratives[narrId] = narrativeSkeleton(narrId) } + // add evt to steps + // NB: insetSourceFrom is a 'curried' function to allow with maps + narratives[narrId].steps.push(insetSourceFrom(sources)(evt)) + } }) }) - /* sort steps by time */ Object.keys(narratives).forEach(key => { const steps = narratives[key].steps steps.sort((a, b) => a.datetime - b.datetime) - if (narrativesMeta.find(n => n.id === key)) { + const existingAssociatedNarrative = narrativesMeta.find(n => n.id === key) + + if (existingAssociatedNarrative) { narratives[key] = { - ...narrativesMeta.find(n => n.id === key), + ...existingAssociatedNarrative, ...narratives[key] } } }) - // Return narratives in original order // + filter those that are undefined return narrativesMeta.map(n => narratives[n.id]).filter(d => d) }) +/** We iterate through narrative.steps and check the idx there against the selected array and we return the idx */ +export const selectNarrativeIdx = createSelector( + [getSelected, getActiveNarrative], + (selected, narrative) => { + // Only one event selected in narrative mode + if (narrative === null) return -1 + + const selectedEvent = selected[0] + let selectedIdx + + narrative.steps.forEach((step, idx) => { + if (selectedEvent.id === step.id) { + selectedIdx = idx + } + }) + return selectedIdx + } +) + /** Aggregate information about the narrative and the current step into * a single object. If narrative is null, the whole object is null. */ export const selectActiveNarrative = createSelector( - [getActiveNarrative, getActiveStep], + [getActiveNarrative, selectNarrativeIdx], (narrative, current) => narrative ? { ...narrative, current } : null @@ -245,7 +267,6 @@ export const selectSelected = createSelector( if (selected.length === 0) { return [] } - return selected.map(insetSourceFrom(sources)) } ) diff --git a/src/store/initial.js b/src/store/initial.js index 0b6f8e0..beb475b 100644 --- a/src/store/initial.js +++ b/src/store/initial.js @@ -6,17 +6,16 @@ const initial = { * The Domain or 'domain' of this state refers to the tree of data * available for render and display. * Selections and filters in the 'app' subtree will operate the domain - * in mapStateToProps of the Dashboard, and deterimne which items + * in mapStateToProps of the Dashboard, and determine which items * in the domain will get rendered by React */ domain: { events: [], - narratives: [], locations: [], categories: [], + associations: [], sources: {}, sites: [], - filters: {}, notifications: [] }, @@ -24,23 +23,20 @@ const initial = { * The 'app' subtree of this state determines the data and information to be * displayed. * It may refer to those the user interacts with, by selecting, - * fitlering and so on, which ultimately operate on the data to be displayed. + * filtering and so on, which ultimately operate on the data to be displayed. * Additionally, some of the 'app' flags are determined by the config file * or by the characteristics of the client, browser, etc. */ app: { errors: { - source: null + source: false }, highlighted: null, selected: [], source: null, - narrative: null, - narrativeState: { - current: null - }, - filters: { + associations: { filters: [], + narrative: null, categories: [], views: { events: true, @@ -137,12 +133,11 @@ const initial = { features: { USE_COVER: false, - USE_FILTERS: false, + USE_ASSOCIATIONS: false, USE_SEARCH: false, USE_SITES: false, USE_SOURCES: false, USE_SHAPES: false, - USE_NARRATIVES: false, GRAPH_NONLOCATED: false, HIGHLIGHT_GROUPS: false }