diff --git a/src/js/frontend/package.json b/src/js/frontend/package.json index bfad919..30c01f6 100644 --- a/src/js/frontend/package.json +++ b/src/js/frontend/package.json @@ -20,6 +20,7 @@ "dependencies": { "@n3dst4/browser-bundle": "^1.1.0", "@n3dst4/build-stylesheets": "^1.0.1", + "axios": "^0.15.3", "babel-polyfill": "6.9.1", "browser-sync": "^2.12.3", "d3": "^4.3.0", @@ -38,7 +39,9 @@ "react-redux": "^4.4.5", "react-router": "^2.4.1", "redux": "^3.6.0", + "redux-devtools-extension": "^2.13.0", "redux-logger": "^2.6.1", + "redux-promise-middleware": "^4.2.0", "redux-storage": "^4.0.1", "redux-storage-decorator-debounce": "^1.0.1", "redux-storage-engine-localstorage": "^1.1.1", diff --git a/src/js/frontend/src/Redux/action-types.js b/src/js/frontend/src/Redux/action-types.js index e0a0eb6..c8a84ab 100644 --- a/src/js/frontend/src/Redux/action-types.js +++ b/src/js/frontend/src/Redux/action-types.js @@ -1,2 +1,8 @@ export const TOGGLE_ABOUT = "TOGGLE_ABOUT" + export const ADD_ENTITY = "ADD_ENTITY" + +export const FETCH_ENTITIES = "FETCH_ENTITIES" +export const FETCH_ENTITIES_PENDING = "FETCH_ENTITIES_PENDING" +export const FETCH_ENTITIES_FULFILLED = "FETCH_ENTITIES_FULFILLED" +export const FETCH_ENTITIES_REJECTED = "FETCH_ENTITIES_REJECTED" diff --git a/src/js/frontend/src/Redux/actions.js b/src/js/frontend/src/Redux/actions.js index 3c0b300..ab8ee2f 100644 --- a/src/js/frontend/src/Redux/actions.js +++ b/src/js/frontend/src/Redux/actions.js @@ -1,4 +1,5 @@ import * as actionTypes from "./action-types" +import entityData from "../lib/entityData" // These are action creators // http://redux.js.org/docs/basics/Actions.html#action-creators @@ -12,8 +13,22 @@ export const toggleAbout = () => { export const addEntity = (entityType, id, data) => { return { type: actionTypes.ADD_ENTITY, - entityType, + meta: { entityType }, id, data, } } + +export const fetchEntities = (entityType) => { + const request = entityData.fetchEntitiesRequest(entityType); + + // When dispatched, redux-promise-middleware will handle this and + // magically dispatch FETCH_ENTITIES_{PENDING,FULFILLED,REJECTED} + // actions according to the 3 possible stages of the conversation + // with the API endpoint. + return { + type: actionTypes.FETCH_ENTITIES, + meta: { entityType }, + payload: request + } +} diff --git a/src/js/frontend/src/Redux/reducers/dataReducer.js b/src/js/frontend/src/Redux/reducers/dataReducer.js index 97fe310..b6f81c0 100644 --- a/src/js/frontend/src/Redux/reducers/dataReducer.js +++ b/src/js/frontend/src/Redux/reducers/dataReducer.js @@ -1,30 +1,76 @@ -import {fromJS} from "immutable"; -import {ADD_ENTITY} from "../action-types"; +import {fromJS, Map} from "immutable"; -const initialState = fromJS({ - entities: { - people: {}, - organisations: {}, - "government-offices": {}, +import * as actionTypes from "../action-types"; +import entityData from "../../lib/entityData" + +const initialEntityState = { + fetching: false, // We can show a spinner if this is true + fetched: false, // We can show an entity list if this is true + byId: {}, +}; + +const initialEntitiesState = entityData.validTypes.reduce( + (obj, type) => { + obj[type] = initialEntityState; + return obj; }, + {} +); + +const initialState = fromJS({ + entities: initialEntitiesState }); +const checkActionValidEntityType = (action, state) => { + let entityType = action.meta && action.meta.entityType; + if (!entityType) return true; + let valid = state.get("entities").has(entityType); + if (!valid) { + console.error(`Received ${action.type} action with invalid entity type ${entityType}`); + } + return valid; +} + export const dataReducer = (state = initialState, action) => { - let {type, entityType, id, data} = action; - - switch (type) { - case ADD_ENTITY: - if (!state.get("entities").has(entityType)) { - console.error(`Received ADD_ENTITY action with invalid entity type ${entityType}`); - return state; - } - return state.mergeDeep({ - entities: { - [entityType]: { - [id]: data + if (!checkActionValidEntityType(action, state)) { + return state; + } + + switch (action.type) { + case actionTypes.ADD_ENTITY: + return state.setIn( + ["entities", action.meta.entityType, "byId", action.id], + fromJS(action.data) + ); + + case actionTypes.FETCH_ENTITIES_PENDING: + return state.setIn( + ["entities", action.meta.entityType, "fetching"], + true, + ); + + case actionTypes.FETCH_ENTITIES_FULFILLED: + let newState = state.setIn( + ["entities", action.meta.entityType], + Map({fetching: false, fetched: true}) + ); + + return newState.mergeIn( + ['entities', action.meta.entityType, 'byId'], + action.payload.data.data.reduce( + (obj, entity) => { + obj[entity.id] = entity.attributes; + return obj }, - }, - }); + {} + ) + ); + + case actionTypes.FETCH_ENTITIES_REJECTED: + return state.setIn( + ["entities", action.meta.entityType, "fetching"], + false, + ); default: return state; diff --git a/src/js/frontend/src/Redux/reducers/uiReducer.js b/src/js/frontend/src/Redux/reducers/uiReducer.js index a0d3b0d..bd7af28 100644 --- a/src/js/frontend/src/Redux/reducers/uiReducer.js +++ b/src/js/frontend/src/Redux/reducers/uiReducer.js @@ -2,7 +2,7 @@ import {fromJS} from "immutable"; import {TOGGLE_ABOUT} from "../action-types"; const initialState = fromJS({ - showAboutScreen: false + showAboutScreen: false }); export const uiReducer = (state = initialState, {type}) => { diff --git a/src/js/frontend/src/Redux/store.js b/src/js/frontend/src/Redux/store.js index 23518d4..7c9e70b 100644 --- a/src/js/frontend/src/Redux/store.js +++ b/src/js/frontend/src/Redux/store.js @@ -5,11 +5,13 @@ import {rootReducer, storeStructure, initialStoreState} from "./reducer" import * as storage from "redux-storage" import createEngine from "redux-storage-engine-localstorage" import storageDebounce from 'redux-storage-decorator-debounce' +import promiseMiddleware from 'redux-promise-middleware' import immutablejs from 'redux-storage-decorator-immutablejs' import merger from 'redux-storage-merger-immutablejs' //////////////////////////////////////////////////////////////////////////////// // redux stuff +import { composeWithDevTools } from 'redux-devtools-extension' // wrap our main reducer in a storage reducer - this intercepts LOAD actions and // calls the merger function to merge in the new state @@ -31,18 +33,22 @@ const loggerMiddleware = createLogger({ //predicate: (getState, action) => action.type !== CALCULATION_NEEDS_REFRESH }) -// now create our redux store, applying all our middleware -// const store = createStore(reducer, applyMiddleware( -// thunkMiddleware, // first, so function results get transformed -// loggerMiddleware, // now log everything at this state -// storageMiddleware // finally the storage middleware -// )) - +const composeEnhancers = composeWithDevTools({ + name: 'MUTI frontend', + // actionsBlacklist: ['REDUX_STORAGE_SAVE'] +}); const store = createStore( reducer, initialStoreState, - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() + composeEnhancers( + applyMiddleware( + thunkMiddleware, // first, so function results get transformed + promiseMiddleware(), +// loggerMiddleware, // now log everything at this state +// storageMiddleware, // finally the storage middleware + ) + ) ); window.store = store; diff --git a/src/js/frontend/src/components/BackToHome.js b/src/js/frontend/src/components/BackToHome.js new file mode 100644 index 0000000..b0e9c37 --- /dev/null +++ b/src/js/frontend/src/components/BackToHome.js @@ -0,0 +1,13 @@ +import React from "react" +import ReactDOM from "react-dom" +import Radium from "radium" +import { Link } from 'react-router' + +const BackToHome = (props) => + Back to home; + +BackToHome.propTypes = { + sourceName: React.PropTypes.string, +}; + +module.exports = Radium(BackToHome); diff --git a/src/js/frontend/src/components/ChartTitle.js b/src/js/frontend/src/components/ChartTitle.js index c88ae75..2d1d643 100644 --- a/src/js/frontend/src/components/ChartTitle.js +++ b/src/js/frontend/src/components/ChartTitle.js @@ -1,19 +1,22 @@ import React from "react" -import ReactDOM from "react-dom" import Radium from "radium" import { Link } from 'react-router' +import BackToHome from './BackToHome' + function ChartTitle (props) { - let description = props.sourceName ? + let description = props.sourceName && props.targetType ?

- Showing all meetings of {props.targetType} with {props.sourceName} + Showing all meetings of { + props.targetType.replace('-', ' ') + } with {props.sourceName}

: ""; return (
{description} - Back to home +
) } diff --git a/src/js/frontend/src/components/EntityList.js b/src/js/frontend/src/components/EntityList.js new file mode 100644 index 0000000..3aa1105 --- /dev/null +++ b/src/js/frontend/src/components/EntityList.js @@ -0,0 +1,66 @@ +import React from "react" +import Radium from "radium" +import { Link } from "react-router" + +import * as sorters from "../lib/sorters" + +// This is a truly horrible UI design, but good enough for testing. +const TEMPLATES = { + organisations: (id) => + + government {" "} + offices + {" "} and {" "} + people + , + people: (id) => + + government offices + {" "} and {" "} + their people, + {" "} and {" "} + organisations + {" "} and {" "} + their people + , + "government-offices": (id) => + + organisations + {" "} and {" "} + their people + +}; + +function EntityList (props) { + if (props.fetching) { + return

Still fetching list of {props.entityType} ...

; + } + + if (!props.fetched) { + return

Didn't fetch list of {props.entityType} yet

; // ' + } + + let sortedEntities = props.entitiesById.sortBy(data => data.get("name")); + + let template = TEMPLATES[props.entityType]; + let listItems = sortedEntities.map((data, id) => +
  • + {data.get("name")} - meetings with {template(id)} +
  • + ).toArray(); + + return ( + + ) +} + +EntityList.propTypes = { + entityType: React.PropTypes.string.isRequired, + fetching: React.PropTypes.bool.isRequired, + fetched: React.PropTypes.bool.isRequired, + entitiesById: React.PropTypes.object.isRequired, +}; + +module.exports = Radium(EntityList); diff --git a/src/js/frontend/src/components/Header.js b/src/js/frontend/src/components/Header.js index 63fa0db..5c11c62 100644 --- a/src/js/frontend/src/components/Header.js +++ b/src/js/frontend/src/components/Header.js @@ -13,6 +13,7 @@ class Header extends React.Component {
    @@ -26,7 +27,6 @@ let shadow = 'inset #F2E9E9 0px -2px 0px 0px'; let styles = { base: { // backgroundColor: '#F5F5ED', - height: "12vh", borderBottom: '5px solid white', WebkitBoxShadow: shadow, MozBoxShadow: shadow, diff --git a/src/js/frontend/src/components/Home.js b/src/js/frontend/src/components/Home.js index 040807a..bcc78bb 100644 --- a/src/js/frontend/src/components/Home.js +++ b/src/js/frontend/src/components/Home.js @@ -2,13 +2,9 @@ import React from "react" import Radium from "radium" import { Link } from 'react-router' -class OrgList extends React.Component { +class Home extends React.Component { render () { - return
    -

    - Eventually this home page will allow you to choose from dynamic lists - of organisations, people, government offices etc. -

    + return
    • @@ -17,34 +13,20 @@ class OrgList extends React.Component {
    -

    - Here are sample URLs you can use to visualize parts of the - data set relating to a given organisation. They assume that - the organisation BAE Systems has id 369 in the database, that - David Cameron has id 755, and that the FCO has id 437, so - currently you will have to manually adjust the URLs to point - to the right ids, but we'll hopefully fix that soon. -

    • - - See which ministers met with BAE Systems the most + + See all organisations
    • - - See which government offices met with BAE Systems the most + + See all government offices
    • - - See which organisations met with David Cameron the most - -
    • -
    • - - See which organisations met with the Foreign & Commonwealth - Office the most + + See all people
    @@ -52,4 +34,4 @@ class OrgList extends React.Component { } } -export default Radium(OrgList); +export default Radium(Home); diff --git a/src/js/frontend/src/containers/ChartContainer.js b/src/js/frontend/src/containers/ChartContainer.js index 3347413..57a6c08 100644 --- a/src/js/frontend/src/containers/ChartContainer.js +++ b/src/js/frontend/src/containers/ChartContainer.js @@ -9,22 +9,20 @@ import * as actions from "../Redux/actions" class ChartContainer extends React.Component { componentWillMount () { - if (this.props.route.sourceType !== "demo") { + if (this.props.route.sourceType !== "demo" && !this.props.entityName) { this.getEntityName() } } render () { + // This comes from the element in main.js let sourceType = this.props.route.sourceType; + let targetType = this.props.params.targetType; let id = this.props.params.id; - let entities = this.props.entities; // comes in via mapStateToProps - let sourceData = entities && entities.get(sourceType); - let entityData = sourceData && sourceData.get(id); - let entityName = entityData && entityData.get("name"); return
    - { +const mapStateToProps = (state, ownProps) => { + if (ownProps.route.sourceType === "demo") { + return { entityName: "demo" }; + } + + let entities = state.data.getIn(["entities", ownProps.route.sourceType]); + let entity = entities.getIn(["byId", ownProps.params.id]); return { - entities: data.get('entities'), + entityName: entity && entity.get("name"), }; }; diff --git a/src/js/frontend/src/containers/EntityListContainer.js b/src/js/frontend/src/containers/EntityListContainer.js new file mode 100644 index 0000000..cce6477 --- /dev/null +++ b/src/js/frontend/src/containers/EntityListContainer.js @@ -0,0 +1,69 @@ +import React from "react" +import Radium from "radium" +import { connect } from 'react-redux'; + +import * as actions from "../Redux/actions" +import entityData from "../lib/entityData" +import EntityList from "../components/EntityList" + +class EntityListContainer extends React.Component { + componentWillMount () { + if (!this.props.fetched) { + this.props.fetchEntities(this.props.route.entityType); + } + } + + render () { + let entityType = this.props.route.entityType; + + if (! entityData.isValidType(entityType)) { + return
    + Invalid entity type {entityType}. + Must be one of: {entityData.validTypes.join(", ")} +
    + } + + return
    + +
    + } +} + +const mapStateToProps = (state, ownProps) => { + if (!state.data) { + console.warn("No data in store yet?!"); + return {}; + } + + let entities = state.data.getIn(["entities", ownProps.route.entityType]); + return { + fetching: entities.get("fetching"), + fetched: entities.get("fetched"), + entitiesById: entities.get("byId"), + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + fetchEntities: (entityType) => { + dispatch(actions.fetchEntities(entityType)); + } + } +} + +let styles = { + "entity-list": { + overflowY: "auto", + height: "100%", + } +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Radium(EntityListContainer)); + diff --git a/src/js/frontend/src/containers/PageLayout.js b/src/js/frontend/src/containers/PageLayout.js index cade820..f1a4711 100644 --- a/src/js/frontend/src/containers/PageLayout.js +++ b/src/js/frontend/src/containers/PageLayout.js @@ -5,6 +5,8 @@ import Header from "../components/Header"; import About from "../components/About"; import { toggleAbout } from '../Redux/actions'; +const HEADER_HEIGHT = 12; + class PageLayout extends React.Component { constructor(props) { super(props); @@ -23,9 +25,11 @@ class PageLayout extends React.Component { styles.base, ]} className="app-container"> -
    +
    - {this.props.children} +
    + {this.props.children} +
    ) } @@ -33,15 +37,18 @@ class PageLayout extends React.Component { let styles = { base: { - height:"100vh", + height: "100vh", overflow: 'hidden', - } + }, + main: { + height: (100 - HEADER_HEIGHT) + "vh", + }, } const mapStateToProps = (state) => { - return { - showAboutScreen: state.showAboutScreen, - }; + return { + showAboutScreen: state.ui.get("showAboutScreen"), + }; }; const mapDispatchToProps = (dispatch) => { diff --git a/src/js/frontend/src/lib/api.js b/src/js/frontend/src/lib/api.js index 89e3afc..b47cadc 100644 --- a/src/js/frontend/src/lib/api.js +++ b/src/js/frontend/src/lib/api.js @@ -8,6 +8,10 @@ let api = { URL: function (path) { return this.protocol + "://" + this.host + ":" + this.port + this.apiPathPrefix + path; + }, + + fetchEntitiesURL: function (entityType) { + return this.URL(entityType); } } diff --git a/src/js/frontend/src/lib/entityData.js b/src/js/frontend/src/lib/entityData.js index 66c4625..b929f50 100644 --- a/src/js/frontend/src/lib/entityData.js +++ b/src/js/frontend/src/lib/entityData.js @@ -1,7 +1,21 @@ let d3 = require('d3'); let api = require('./api'); +import axios from "axios" let entityData = { + apiURL: function(entityType, entityId) { + return api.URL(entityType + "/" + entityId); + }, + + validTypes: [ + "organisations", "people", "government-offices" + ], + + isValidType: function(entityType) { + return this.validTypes.indexOf(entityType) >= 0; + }, + + // FIXME: retire this in favour of fetchEntitiesRequest() below fetch: function(entityType, entityId, onSuccess) { let url = this.apiURL(entityType, entityId); console.debug("Fetching from " + url); @@ -12,10 +26,6 @@ let entityData = { .get(this.dataLoadedHandler(onSuccess, url, entityType, entityId)); }, - apiURL: function(entityType, entityId) { - return api.URL(entityType + "/" + entityId); - }, - dataLoadedHandler: function(onSuccess, url, entityType, entityId) { let self = this; @@ -39,6 +49,15 @@ let entityData = { onSuccess(json); }; }, + + fetchEntitiesRequest: function (entityType) { + return axios.get(api.fetchEntitiesURL(entityType), { + headers: { + "Content-Type": "application/json", + "Accept": "application/vnd.api+json" + }, + }); + } } module.exports = entityData; diff --git a/src/js/frontend/src/lib/sorters.js b/src/js/frontend/src/lib/sorters.js new file mode 100644 index 0000000..299029b --- /dev/null +++ b/src/js/frontend/src/lib/sorters.js @@ -0,0 +1,9 @@ +let sorters = { + lexically: (a, b) => { + var x = a.toLowerCase(); + var y = b.toLowerCase(); + return x < y ? -1 : x > y ? 1 : 0; + }, +} + +module.exports = sorters; diff --git a/src/js/frontend/src/main.js b/src/js/frontend/src/main.js index b0dca48..6dd12a6 100644 --- a/src/js/frontend/src/main.js +++ b/src/js/frontend/src/main.js @@ -9,6 +9,7 @@ import App from "./app" import PageLayout from "./containers/PageLayout" import Home from "./components/Home" import ChartContainer from "./containers/ChartContainer" +import EntityListContainer from "./containers/EntityListContainer" // now everything is set up, create a loader and use it to load the store const load = storage.createLoader(engine) @@ -25,13 +26,19 @@ loaded.then((newState) => { + + + + + - -