Merge branch 'develop' into feature/84-show-edit-history

This commit is contained in:
mz8i 2019-10-29 17:58:01 +00:00 committed by GitHub
commit 06eb4e53ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 611 additions and 277 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -146,6 +146,15 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
}
});
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
try {
const revisionId = await buildingService.getLatestRevisionId();
res.send({latestRevisionId: revisionId});
} catch(error) {
res.send({ error: 'Database error' });
}
});
export default {
getBuildingsByLocation,
getBuildingsByReference,
@ -154,5 +163,6 @@ export default {
getBuildingUPRNsById,
getBuildingLikeById,
updateBuildingLikeById,
getBuildingEditHistoryById
getBuildingEditHistoryById,
getLatestRevisionId
};

View File

@ -14,6 +14,8 @@ router.get('/locate', buildingController.getBuildingsByLocation);
// GET buildings by reference (UPRN/TOID or other identifier)
router.get('/reference', buildingController.getBuildingsByReference);
router.get('/revision', buildingController.getLatestRevisionId);
router.route('/:building_id.json')
// GET individual building
.get(buildingController.getBuildingById)

View File

@ -19,6 +19,19 @@ const serializable = new TransactionMode({
readOnly: false
});
async function getLatestRevisionId() {
try {
const data = await db.oneOrNone(
`SELECT MAX(log_id) from logs`
);
return data == undefined ? undefined : data.max;
} catch(err) {
console.error(err);
return undefined;
}
}
async function queryBuildingsAtPoint(lng: number, lat: number) {
try {
return await db.manyOrNone(
@ -414,5 +427,6 @@ export {
getBuildingUPRNsById,
saveBuilding,
likeBuilding,
unlikeBuilding
unlikeBuilding,
getLatestRevisionId
};

View File

@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
hydrate(
<BrowserRouter>
<App user={data.user} building={data.building} building_like={data.building_like} />
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
/>
</BrowserRouter>,
document.getElementById('root')
);

View File

@ -9,7 +9,7 @@ describe('<App />', () => {
const div = document.createElement('div');
ReactDOM.render(
<MemoryRouter>
<App />
<App revisionId={0} />
</MemoryRouter>,
div
);

View File

@ -28,6 +28,7 @@ interface AppProps {
user?: any;
building?: any;
building_like?: boolean;
revisionId: number;
}
/**
@ -49,6 +50,8 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
building_like: PropTypes.bool
};
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
constructor(props: Readonly<AppProps>) {
super(props);
@ -79,7 +82,14 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
render() {
return (
<Fragment>
<Header user={this.state.user} />
<Switch>
<Route exact path={App.mapAppPaths}>
<Header user={this.state.user} animateLogo={false} />
</Route>
<Route>
<Header user={this.state.user} animateLogo={true} />
</Route>
</Switch>
<main>
<Switch>
<Route exact path="/about.html" component={AboutPage} />
@ -105,12 +115,13 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
<Route exact path="/data-accuracy.html" component={DataAccuracyPage} />
<Route exact path="/data-extracts.html" component={DataExtracts} />
<Route exact path="/contact.html" component={ContactPage} />
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category?/:building(\\d+)?/(history)?"]} render={(props) => (
<Route exact path={App.mapAppPaths} render={(props) => (
<MapApp
{...props}
building={this.props.building}
building_like={this.props.building_like}
user={this.state.user}
revisionId={this.props.revisionId}
/>
)} />
<Route component={NotFound} />

View File

@ -14,18 +14,28 @@ import StreetscapeContainer from './data-containers/streetscape';
import CommunityContainer from './data-containers/community';
import PlanningContainer from './data-containers/planning';
import LikeContainer from './data-containers/like';
import { Building } from '../models/building';
interface BuildingViewProps {
cat: string;
mode: 'view' | 'edit' | 'multi-edit';
building: Building;
building_like: boolean;
user: any;
selectBuilding: (building: Building) => void
}
/**
* Top-level container for building view/edit form
*
* @param props
*/
const BuildingView = (props) => {
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
switch (props.cat) {
case 'location':
return <LocationContainer
{...props}
key={props.building && props.building.building_id}
title="Location"
help="https://pages.colouring.london/location"
intro="Where are the buildings? Address, location and cross-references."
@ -33,7 +43,6 @@ const BuildingView = (props) => {
case 'use':
return <UseContainer
{...props}
key={props.building && props.building.building_id}
inactive={true}
title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
@ -42,7 +51,6 @@ const BuildingView = (props) => {
case 'type':
return <TypeContainer
{...props}
key={props.building && props.building.building_id}
inactive={false}
title="Type"
intro="How were buildings previously used?"
@ -51,7 +59,6 @@ const BuildingView = (props) => {
case 'age':
return <AgeContainer
{...props}
key={props.building && props.building.building_id}
title="Age"
help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change."
@ -59,7 +66,6 @@ const BuildingView = (props) => {
case 'size':
return <SizeContainer
{...props}
key={props.building && props.building.building_id}
title="Size &amp; Shape"
intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize"
@ -67,7 +73,6 @@ const BuildingView = (props) => {
case 'construction':
return <ConstructionContainer
{...props}
key={props.building && props.building.building_id}
title="Construction"
intro="How are buildings built? Coming soon…"
help="https://pages.colouring.london/construction"
@ -76,7 +81,6 @@ const BuildingView = (props) => {
case 'team':
return <TeamContainer
{...props}
key={props.building && props.building.building_id}
title="Team"
intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team"
@ -85,7 +89,6 @@ const BuildingView = (props) => {
case 'sustainability':
return <SustainabilityContainer
{...props}
key={props.building && props.building.building_id}
title="Sustainability"
intro="Are buildings energy efficient?"
help="https://pages.colouring.london/sustainability"
@ -94,7 +97,6 @@ const BuildingView = (props) => {
case 'streetscape':
return <StreetscapeContainer
{...props}
key={props.building && props.building.building_id}
title="Streetscape"
intro="What's the building's context? Coming soon…"
help="https://pages.colouring.london/streetscape"
@ -103,7 +105,6 @@ const BuildingView = (props) => {
case 'community':
return <CommunityContainer
{...props}
key={props.building && props.building.building_id}
title="Community"
intro="How does this building work for the local community?"
help="https://pages.colouring.london/community"
@ -112,7 +113,6 @@ const BuildingView = (props) => {
case 'planning':
return <PlanningContainer
{...props}
key={props.building && props.building.building_id}
title="Planning"
intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning"
@ -120,7 +120,6 @@ const BuildingView = (props) => {
case 'like':
return <LikeContainer
{...props}
key={props.building && props.building.building_id}
title="Like Me!"
intro="Do you like the building and think it contributes to the city?"
help="https://pages.colouring.london/likeme"

View File

@ -2,8 +2,14 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface CheckboxDataEntryProps extends BaseDataEntryProps {
value: boolean;
}
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -19,7 +25,7 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
name={props.slug}
checked={!!props.value}
disabled={props.mode === 'view' || props.disabled}
onChange={props.onChange}
onChange={e => props.onChange(props.slug, e.target.checked)}
/>
<label
htmlFor={props.slug}
@ -31,14 +37,12 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
);
}
DataEntry.propTypes = {
CheckboxDataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
maxLength: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
@ -47,4 +51,4 @@ DataEntry.propTypes = {
})
}
export default DataEntry;
export default CheckboxDataEntry;

View File

@ -3,7 +3,24 @@ import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface BaseDataEntryProps {
slug: string;
title: string;
tooltip?: string;
disabled?: boolean;
copy?: any; // CopyProps clashes with propTypes
mode?: 'view' | 'edit' | 'multi-edit';
onChange?: (key: string, value: any) => void;
}
interface DataEntryProps extends BaseDataEntryProps {
value: string;
maxLength?: number;
placeholder?: string;
valueTransform?: (string) => string
}
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -20,7 +37,13 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
maxLength={props.maxLength}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e => {
const transform = props.valueTransform || (x => x);
const val = e.target.value === '' ?
null :
transform(e.target.value);
props.onChange(props.slug, val);
}}
/>
</Fragment>
);
@ -43,3 +66,6 @@ DataEntry.propTypes = {
}
export default DataEntry;
export {
BaseDataEntryProps
};

View File

@ -3,7 +3,13 @@ import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const DataTitle: React.FunctionComponent<any> = (props) => {
interface DataTitleProps {
title: string;
tooltip: string;
}
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
return (
<dt>
{ props.title }
@ -17,7 +23,16 @@ DataTitle.propTypes = {
tooltip: PropTypes.string
}
const DataTitleCopyable: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface DataTitleCopyableProps {
title: string;
tooltip: string;
slug: string;
disabled?: boolean;
copy?: any; // TODO: type should be CopyProps, but that clashes with propTypes in some obscure way
}
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
return (
<div className="data-title">
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
@ -48,7 +63,8 @@ DataTitleCopyable.propTypes = {
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
toggleCopyAttribute: PropTypes.func,
toggleCopying: PropTypes.func
})
}

View File

@ -4,7 +4,15 @@ import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface LikeDataEntryProps {
mode: 'view' | 'edit' | 'multi-edit';
userLike: boolean;
totalLikes: number;
onLike: (userLike: boolean) => void;
}
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
const data_string = JSON.stringify({like: true});
return (
<Fragment>
@ -21,20 +29,20 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
</div>
<p>
{
(props.value != null)?
(props.value === 1)?
`${props.value} person likes this building`
: `${props.value} people like this building`
(props.totalLikes != null)?
(props.totalLikes === 1)?
`${props.totalLikes} person likes this building`
: `${props.totalLikes} people like this building`
: "0 people like this building so far - you could be the first!"
}
</p>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
id="like" name="like"
checked={!!props.building_like}
name="like"
checked={!!props.userLike}
disabled={props.mode === 'view'}
onChange={props.onLike}
onChange={e => props.onLike(e.target.checked)}
/>
<label htmlFor="like" className="form-check-label">
I like this building and think it contributes to the city!
</label>
</Fragment>
@ -42,8 +50,10 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
}
LikeDataEntry.propTypes = {
value: PropTypes.any,
user_building_like: PropTypes.bool
}
// mode: PropTypes.string,
userLike: PropTypes.bool,
totalLikes: PropTypes.number,
onLike: PropTypes.func
};
export default LikeDataEntry;

View File

@ -2,8 +2,18 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface NumericDataEntryProps extends BaseDataEntryProps {
value?: number;
placeholder?: string;
step?: number;
min?: number;
max?: number;
}
const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -24,7 +34,12 @@ const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
min={props.min || 0}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ? null : parseFloat(e.target.value)
)
}
/>
</Fragment>
);

View File

@ -2,8 +2,16 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface SelectDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
options: string[];
}
const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -17,7 +25,14 @@ const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remo
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.mode === 'view' || props.disabled}
onChange={props.onChange}>
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)}
>
<option value="">{props.placeholder}</option>
{
props.options.map(option => (

View File

@ -2,8 +2,15 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface TextboxDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
maxLength?: number;
}
const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -18,11 +25,18 @@ const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.max_length}
maxLength={props.maxLength}
rows={5}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)
}
></textarea>
</Fragment>
);

View File

@ -7,6 +7,33 @@ import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { CopyControl } from './header-buttons/copy-control';
import { ViewEditControl } from './header-buttons/view-edit-control';
import { Building } from '../models/building';
import { User } from '../models/user';
import { compareObjects } from '../helpers';
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
interface DataContainerProps {
title: string;
cat: string;
intro: string;
help: string;
inactive?: boolean;
user: User;
mode: 'view' | 'edit' | 'multi-edit';
building: Building;
building_like: boolean;
selectBuilding: (building: Building) => void
}
interface DataContainerState {
error: string;
copying: boolean;
keys_to_copy: {[key: string]: boolean};
currentBuildingId: number;
currentBuildingRevisionId: number;
buildingEdits: Partial<Building>;
}
/**
* Shared functionality for view/edit forms
@ -16,15 +43,14 @@ import { ViewEditControl } from './header-buttons/view-edit-control';
*
* @param WrappedComponent
*/
const withCopyEdit = (WrappedComponent) => {
return class extends React.Component<any, any> { // TODO: add proper types
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
building_id: PropTypes.number,
children: PropTypes.node
};
@ -32,23 +58,40 @@ const withCopyEdit = (WrappedComponent) => {
super(props);
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
error: undefined,
copying: false,
keys_to_copy: {},
building: this.props.building
buildingEdits: {},
currentBuildingId: undefined,
currentBuildingRevisionId: undefined
};
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleUpdate = this.handleUpdate.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
static getDerivedStateFromProps(props, state) {
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
return {
error: undefined,
copying: false,
keys_to_copy: {},
buildingEdits: {},
currentBuildingId: newBuildingId,
currentBuildingRevisionId: newBuildingRevisionId
};
}
return null;
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
@ -64,7 +107,7 @@ const withCopyEdit = (WrappedComponent) => {
* @param {string} key
*/
toggleCopyAttribute(key: string) {
const keys = this.state.keys_to_copy;
const keys = {...this.state.keys_to_copy};
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
@ -75,45 +118,34 @@ const withCopyEdit = (WrappedComponent) => {
})
}
updateBuildingState(key, value) {
const building = {...this.state.building};
building[key] = value;
isEdited() {
const edits = this.state.buildingEdits;
// check if the edits object has any fields
return Object.entries(edits).length !== 0;
}
clearEdits() {
this.setState({
building: building
buildingEdits: {}
});
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {*} event
*/
handleChange(event) {
const target = event.target;
let value = (target.value === '')? null : target.value;
const name = target.name;
// special transform - consider something data driven before adding 'else if's
if (name === 'location_postcode' && value !== null) {
value = value.toUpperCase();
getEditedBuilding() {
if(this.isEdited()) {
return Object.assign({}, this.props.building, this.state.buildingEdits);
} else {
return {...this.props.building};
}
this.updateBuildingState(name, value);
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {*} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
updateBuildingState(key: string, value: any) {
const newBuilding = this.getEditedBuilding();
newBuilding[key] = value;
const [forwardPatch] = compareObjects(this.props.building, newBuilding);
this.updateBuildingState(name, value);
this.setState({
buildingEdits: forwardPatch
});
}
/**
@ -123,86 +155,93 @@ const withCopyEdit = (WrappedComponent) => {
* @param {String} name
* @param {*} value
*/
handleUpdate(name: string, value: any) {
handleChange(name: string, value: any) {
this.updateBuildingState(name, value);
}
handleReset() {
this.clearEdits();
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {*} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
async handleLike(like: boolean) {
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
});
const data = await res.json();
if (data.error) {
this.setState({error: data.error})
} else {
this.props.selectBuilding(res);
this.updateBuildingState('likes_total', res.likes_total);
this.props.selectBuilding(data);
this.updateBuildingState('likes_total', data.likes_total);
}
} catch(err) {
this.setState({error: err});
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
async handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
this.setState({error: undefined});
fetch(`/api/buildings/${this.props.building.building_id}.json`, {
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state.building),
body: JSON.stringify(this.state.buildingEdits),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
});
const data = await res.json();
if (data.error) {
this.setState({error: data.error})
} else {
this.props.selectBuilding(res);
this.props.selectBuilding(data);
}
} catch(err) {
this.setState({error: err});
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() {
if (this.state.mode === 'edit' && !this.props.user){
if (this.props.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />
}
const currentBuilding = this.getEditedBuilding();
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state.building[key]
values_to_copy[key] = currentBuilding[key]
}
const data_string = JSON.stringify(values_to_copy);
const copy = {
const copy: CopyProps = {
copying: this.state.copying,
toggleCopying: this.toggleCopying,
toggleCopyAttribute: this.toggleCopyAttribute,
copyingKey: (key) => this.state.keys_to_copy[key]
copyingKey: (key: string) => this.state.keys_to_copy[key]
}
const headerBackLink = `/${this.props.mode}/categories${this.props.building != undefined ? `/${this.props.building.building_id}` : ''}`;
const edited = this.isEdited();
return (
<section
id={this.props.slug}
id={this.props.cat}
className="data-section">
<ContainerHeader
cat={this.props.cat}
@ -248,72 +287,72 @@ const withCopyEdit = (WrappedComponent) => {
: null
}
</ContainerHeader>
<div className="section-body">
{
this.props.building != undefined ?
<form
action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
{
(this.props.inactive) ?
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
: null
}
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<ErrorBox msg={this.state.error} />
{
this.props.slug === 'like' ? // special-case for likes
null :
<div className="buttons-container with-space">
<button
type="submit"
className="btn btn-primary">
Save
</button>
</div>
}
</Fragment>
: null
}
<WrappedComponent
building={this.state.building}
building_like={this.props.building_like}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onCheck={this.handleCheck}
onLike={this.handleLike}
onUpdate={this.handleUpdate}
/>
</form>
:
<form>
{
(this.props.inactive)?
this.props.inactive ?
<Fragment>
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
<WrappedComponent
intro={this.props.intro}
building={undefined}
building_like={undefined}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onCheck={this.handleCheck}
onLike={this.handleLike}
onUpdate={this.handleUpdate}
/>
</Fragment> :
this.props.building != undefined ?
<form
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<ErrorBox msg={this.state.error} />
{
this.props.cat !== 'like' ? // special-case for likes
<div className="buttons-container with-space">
<button
type="submit"
className="btn btn-primary"
disabled={!edited}
aria-disabled={!edited}>
Save
</button>
{
edited ?
<button
type="button"
className="btn btn-warning"
onClick={this.handleReset}
>
Discard changes
</button> :
null
}
</div> :
null
}
</Fragment>
:
: null
}
<WrappedComponent
intro={this.props.intro}
building={currentBuilding}
building_like={this.props.building_like}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
/>
</form> :
<InfoBox msg="Select a building to view data"></InfoBox>
}
</form>
}
</div>
</section>
);
}

View File

@ -7,11 +7,12 @@ import SelectDataEntry from '../data-components/select-data-entry';
import TextboxDataEntry from '../data-components/textbox-data-entry';
import YearDataEntry from '../data-components/year-data-entry';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
/**
* Age view/edit section
*/
const AgeView = (props) => (
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<YearDataEntry
year={props.building.date_year}
@ -68,7 +69,7 @@ const AgeView = (props) => (
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onUpdate}
onChange={props.onChange}
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
/>

View File

@ -0,0 +1,21 @@
interface CopyProps {
copying: boolean;
toggleCopying: () => void;
toggleCopyAttribute: (key: string) => void;
copyingKey: (key: string) => boolean;
}
interface CategoryViewProps {
intro: string;
building: any; // TODO: add Building type with all fields
building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit';
copy: CopyProps;
onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void;
}
export {
CategoryViewProps,
CopyProps
};

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Community view/edit section
*/
const CommunityView = (props) => (
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">

View File

@ -2,17 +2,18 @@ import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import LikeDataEntry from '../data-components/like-data-entry';
import { CategoryViewProps } from './category-view-props';
/**
* Like view/edit section
*/
const LikeView = (props) => (
const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<LikeDataEntry
value={props.building.likes_total}
userLike={props.building_like}
totalLikes={props.building.likes_total}
mode={props.mode}
onLike={props.onLike}
building_like={props.building_like}
/>
</Fragment>
)

View File

@ -6,8 +6,9 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
const LocationView = (props) => (
const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
<DataEntry
@ -64,6 +65,7 @@ const LocationView = (props) => (
copy={props.copy}
onChange={props.onChange}
maxLength={8}
valueTransform={x=>x.toUpperCase()}
/>
<DataEntry
title={dataFields.ref_toid.title}
@ -97,7 +99,7 @@ const LocationView = (props) => (
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={51}
placeholder="51"
onChange={props.onChange}
/>
<NumericDataEntry
@ -107,7 +109,7 @@ const LocationView = (props) => (
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={0}
placeholder="0"
onChange={props.onChange}
/>
</Fragment>

View File

@ -6,11 +6,12 @@ import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
/**
* Planning view/edit section
*/
const PlanningView = (props) => (
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntry
title={dataFields.planning_portal_link.title}

View File

@ -5,11 +5,12 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
/**
* Size view/edit section
*/
const SizeView = (props) => (
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntryGroup name="Storeys" collapsed={false}>

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Streetscape view/edit section
*/
const StreetscapeView = (props) => (
const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">

View File

@ -5,6 +5,7 @@ import DataEntry from '../data-components/data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
const BreeamRatingOptions = [
@ -18,12 +19,7 @@ const BreeamRatingOptions = [
/**
* Sustainability view/edit section
*/
const SustainabilityView = (props) => {
const dataEntryProps = {
mode: props.mode,
copy: props.copy,
onChange: props.onChange
};
const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
@ -32,7 +28,9 @@ const SustainabilityView = (props) => {
value={props.building.sust_breeam_rating}
tooltip={dataFields.sust_breeam_rating.tooltip}
options={BreeamRatingOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title={dataFields.sust_dec.title}
@ -40,7 +38,9 @@ const SustainabilityView = (props) => {
value={props.building.sust_dec}
tooltip={dataFields.sust_dec.tooltip}
options={EnergyCategoryOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title={dataFields.sust_aggregate_estimate_epc.title}
@ -49,7 +49,9 @@ const SustainabilityView = (props) => {
tooltip={dataFields.sust_aggregate_estimate_epc.tooltip}
options={EnergyCategoryOptions}
disabled={true}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.sust_retrofit_date.title}
@ -59,7 +61,9 @@ const SustainabilityView = (props) => {
step={1}
min={1086}
max={new Date().getFullYear()}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.sust_life_expectancy.title}
@ -68,7 +72,9 @@ const SustainabilityView = (props) => {
step={1}
min={1}
disabled={true}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
</Fragment>
);

View File

@ -2,11 +2,12 @@ import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
import { CategoryViewProps } from './category-view-props';
/**
* Team view/edit section
*/
const TeamView = (props) => (
const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>

View File

@ -5,6 +5,7 @@ import SelectDataEntry from '../data-components/select-data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import DataEntry from '../data-components/data-entry';
import { dataFields } from '../../data_fields';
import { CategoryViewProps } from './category-view-props';
const AttachmentFormOptions = [
"Detached",
@ -16,10 +17,7 @@ const AttachmentFormOptions = [
/**
* Type view/edit section
*/
const TypeView = (props) => {
const {mode, copy, onChange} = props;
const dataEntryProps = { mode, copy, onChange };
const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
@ -28,7 +26,9 @@ const TypeView = (props) => {
value={props.building.building_attachment_form}
tooltip={dataFields.building_attachment_form.tooltip}
options={AttachmentFormOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.date_change_building_use.title}
@ -38,12 +38,18 @@ const TypeView = (props) => {
min={1086}
max={new Date().getFullYear()}
step={1}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.original_building_use.title}
slug="original_building_use" // doesn't exist in database yet
tooltip={dataFields.original_building_use.tooltip}
value={undefined}
copy={props.copy}
mode={props.mode}
onChange={props.onChange}
disabled={true}
/>
</Fragment>

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Use view/edit section
*/
const UseView = (props) => (
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>

View File

@ -5,7 +5,7 @@
order: 1;
padding: 0 0 2em;
background: #fff;
overflow-y: scroll;
overflow-y: auto;
height: 40%;
}
@ -27,6 +27,14 @@
text-decoration: none;
color: #222;
padding: 0.75rem 0.25rem 0.5rem 0;
z-index: 1000;
}
@media (min-width: 768px) {
.section-header {
position: sticky;
top: 0;
}
}
.section-header h2,
.section-header .icon-buttons {
@ -133,6 +141,11 @@
/**
* Data list sections
*/
.section-body {
margin-top: 0.75em;
padding: 0 0.75em;
}
.data-section .h3 {
margin: 0;
}
@ -156,9 +169,7 @@
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.data-section form {
padding: 0 0.75rem;
}
.data-list a {
color: #555;
}

View File

@ -26,22 +26,22 @@ const Logo: React.FunctionComponent<LogoProps> = (props) => {
const LogoGrid: React.FunctionComponent = () => (
<div className="grid">
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-location"></div>
<div className="cell background-use"></div>
<div className="cell background-type"></div>
<div className="cell background-age"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-size"></div>
<div className="cell background-construction"></div>
<div className="cell background-streetscape"></div>
<div className="cell background-team"></div>
</div>
<div className="row">
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell"></div>
<div className="cell background-sustainability"></div>
<div className="cell background-community"></div>
<div className="cell background-planning"></div>
<div className="cell background-like"></div>
</div>
</div>
)

View File

@ -5,14 +5,25 @@ import PropTypes from 'prop-types';
import { Logo } from './components/logo';
import './header.css';
interface HeaderProps {
user: any;
animateLogo: boolean;
}
interface HeaderState {
collapseMenu: boolean;
}
/**
* Render the main header using a responsive design
*/
class Header extends React.Component<any, any> { // TODO: add proper types
class Header extends React.Component<HeaderProps, HeaderState> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
user: PropTypes.shape({
username: PropTypes.string
})
}),
animateLogo: PropTypes.bool
};
constructor(props) {
@ -40,7 +51,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
<nav className="navbar navbar-light navbar-expand-lg">
<span className="navbar-brand align-self-start">
<NavLink to="/">
<Logo variant='animated'/>
<Logo variant={this.props.animateLogo ? 'animated' : 'default'}/>
</NavLink>
</span>
<button className="navbar-toggler navbar-toggler-right" type="button"

View File

@ -62,8 +62,21 @@ function parseDate(isoUtcDate: string): Date {
return new Date(Date.UTC(year, month-1, day, hour, minute, second, millisecond));
}
function compareObjects(objA: object, objB: object): [object, object] {
const reverse = {}
const forward = {}
for (const [key, value] of Object.entries(objB)) {
if (objA[key] !== value) {
reverse[key] = objA[key];
forward[key] = value;
}
}
return [forward, reverse];
}
export {
sanitiseURL,
arrayToDictionary,
parseDate
parseDate,
compareObjects
};

View File

@ -19,9 +19,10 @@ interface MapAppRouteParams {
}
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: any;
building: Building;
building_like: boolean;
user: any;
revisionId: number;
}
interface MapAppState {
@ -42,12 +43,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
constructor(props: Readonly<MapAppProps>) {
super(props);
// set building revision id, default 0
const rev = props.building != undefined ? +props.building.revision_id : 0;
this.state = {
category: this.getCategory(props.match.params.category),
revision_id: rev,
revision_id: props.revisionId || 0,
building: props.building,
building_like: props.building_like
};
@ -64,6 +62,27 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
}
componentDidMount() {
this.fetchLatestRevision();
}
async fetchLatestRevision() {
try {
const res = await fetch(`/api/buildings/revision`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const data = await res.json();
this.increaseRevision(data.latestRevisionId);
} catch(error) {
console.error(error);
}
}
getCategory(category: string) {
if (category === 'categories') return undefined;
@ -78,7 +97,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
}
selectBuilding(building) {
selectBuilding(building: Building) {
const mode = this.props.match.params.mode || 'view';
const category = this.props.match.params.category || 'age';
@ -201,7 +220,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
render() {
const mode = this.props.match.params.mode || 'basic';
const mode = this.props.match.params.mode;
let category = this.state.category || 'age';
@ -241,13 +260,13 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
<EditHistory building={this.state.building} />
</Sidebar>
</Route>
<Route exact path="/(view|edit|multi-edit)">
<Redirect to="/view/categories" />
</Route>
<Route exact path="/:mode(view|edit|multi-edit)"
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
/>
</Switch>
<ColouringMap
building={this.state.building}
mode={mode}
mode={mode || 'basic'}
category={category}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}

View File

@ -1,21 +1,21 @@
import { LatLngExpression } from 'leaflet';
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
import { Map, TileLayer, ZoomControl, AttributionControl, GeoJSON } from 'react-leaflet-universal';
import { GeoJsonObject } from 'geojson';
import '../../../node_modules/leaflet/dist/leaflet.css'
import './map.css'
import { HelpIcon } from '../components/icons';
import Legend from './legend';
import { parseCategoryURL } from '../../parse';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import { Building } from '../models/building';
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
interface ColouringMapProps {
building: any;
building: Building;
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: string;
revision_id: number;
@ -28,11 +28,12 @@ interface ColouringMapState {
lat: number;
lng: number;
zoom: number;
boundary: GeoJsonObject;
}
/**
* Map area
*/
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
static propTypes = { // TODO: generate propTypes from TS
building: PropTypes.object,
mode: PropTypes.string,
@ -48,7 +49,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
theme: 'night',
lat: 51.5245255,
lng: -0.1338422,
zoom: 16
zoom: 16,
boundary: undefined,
};
this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this);
@ -100,8 +102,20 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
this.setState({theme: newTheme});
}
async getBoundary() {
const res = await fetch('/geometries/boundary-detailed.geojson');
const data = await res.json() as GeoJsonObject;
this.setState({
boundary: data
});
}
componentDidMount() {
this.getBoundary();
}
render() {
const position: LatLngExpression = [this.state.lat, this.state.lng];
const position: [number, number] = [this.state.lat, this.state.lng];
// baselayer
const key = OS_API_KEY;
@ -117,6 +131,11 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
const boundaryLayer = this.state.boundary &&
<GeoJSON data={this.state.boundary} style={boundaryStyleFn}/>;
// colour-data tiles
const cat = this.props.category;
const tilesetByCat = {
@ -166,6 +185,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
>
{ baseLayer }
{ buildingBaseLayer }
{ boundaryLayer }
{ dataLayer }
{ highlightLayer }
<ZoomControl position="topright" />

View File

@ -1,5 +1,10 @@
interface Building {
building_id: number;
geometry_id: number;
revision_id: number;
uprns: string[];
// TODO: add other fields as needed
}
export {

View File

@ -0,0 +1,8 @@
interface User {
username: string;
// TODO: add other fields as needed
}
export {
User
};

View File

@ -9,7 +9,7 @@
max-height: 100%;
border-radius: 0;
padding: 1.5em 2.5em 2.5em;
overflow-y: scroll;
overflow-y: auto;
}
.welcome-float.jumbotron {
background: #fff;

View File

@ -57,38 +57,38 @@
* Category colours
*/
.background-location {
background-color: #edc40b;
background-color: #f7c625;
}
.background-use {
background-color: #f0ee0c;
background-color: #f7ec25;
}
.background-type {
background-color: #ff9100;
background-color: #f77d11;
}
.background-age {
background-color: #ee5f63;
background-color: #ff6161;
}
.background-size {
background-color: #ee91bf;
background-color: #f2a2b9;
}
.background-construction {
background-color: #aa7fa7;
background-color: #ab8fb0;
}
.background-streetscape {
background-color: #6f879c;
background-color: #718899;
}
.background-team {
background-color: #5ec232;
background-color: #7cbf39;
}
.background-sustainability {
background-color: #6dbb8b;
background-color: #57c28e;
}
.background-community {
background-color: #65b7ff;
background-color: #6bb1e3;
}
.background-planning {
background-color: #a1a3a9;
background-color: #aaaaaa;
}
.background-like {
background-color: #9c896d;
background-color: #a3916f;
}

View File

@ -12,7 +12,8 @@ import { getUserById } from './api/services/user';
import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById
getBuildingUPRNsById,
getLatestRevisionId
} from './api/services/building';
@ -36,8 +37,9 @@ function frontendRoute(req: express.Request, res: express.Response) {
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
]).then(function ([user, building, uprns, buildingLike]) {
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
getLatestRevisionId()
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) {
if (isBuilding && typeof (building) === 'undefined') {
context.status = 404;
}
@ -47,12 +49,14 @@ function frontendRoute(req: express.Request, res: express.Response) {
if (data.building != null) {
data.building.uprns = uprns;
}
data.latestRevisionId = latestRevisionId;
renderHTML(context, data, req, res);
}).catch(error => {
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
data.latestRevisionId = 0;
context.status = 500;
renderHTML(context, data, req, res);
});
@ -61,7 +65,12 @@ function frontendRoute(req: express.Request, res: express.Response) {
function renderHTML(context, data, req, res) {
const markup = renderToString(
<StaticRouter context={context} location={req.url}>
<App user={data.user} building={data.building} building_like={data.building_like} />
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
/>
</StaticRouter>
);