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 { export default {
getBuildingsByLocation, getBuildingsByLocation,
getBuildingsByReference, getBuildingsByReference,
@ -154,5 +163,6 @@ export default {
getBuildingUPRNsById, getBuildingUPRNsById,
getBuildingLikeById, getBuildingLikeById,
updateBuildingLikeById, updateBuildingLikeById,
getBuildingEditHistoryById getBuildingEditHistoryById,
getLatestRevisionId
}; };

View File

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

View File

@ -19,6 +19,19 @@ const serializable = new TransactionMode({
readOnly: false 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) { async function queryBuildingsAtPoint(lng: number, lat: number) {
try { try {
return await db.manyOrNone( return await db.manyOrNone(
@ -414,5 +427,6 @@ export {
getBuildingUPRNsById, getBuildingUPRNsById,
saveBuilding, saveBuilding,
likeBuilding, likeBuilding,
unlikeBuilding unlikeBuilding,
getLatestRevisionId
}; };

View File

@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
hydrate( hydrate(
<BrowserRouter> <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>, </BrowserRouter>,
document.getElementById('root') document.getElementById('root')
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,13 @@ import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip'; import Tooltip from '../../components/tooltip';
const DataTitle: React.FunctionComponent<any> = (props) => {
interface DataTitleProps {
title: string;
tooltip: string;
}
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
return ( return (
<dt> <dt>
{ props.title } { props.title }
@ -17,7 +23,16 @@ DataTitle.propTypes = {
tooltip: PropTypes.string 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 ( return (
<div className="data-title"> <div className="data-title">
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null } { props.tooltip? <Tooltip text={ props.tooltip } /> : null }
@ -48,7 +63,8 @@ DataTitleCopyable.propTypes = {
copy: PropTypes.shape({ copy: PropTypes.shape({
copying: PropTypes.bool, copying: PropTypes.bool,
copyingKey: PropTypes.func, 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'; 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}); const data_string = JSON.stringify({like: true});
return ( return (
<Fragment> <Fragment>
@ -21,20 +29,20 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
</div> </div>
<p> <p>
{ {
(props.value != null)? (props.totalLikes != null)?
(props.value === 1)? (props.totalLikes === 1)?
`${props.value} person likes this building` `${props.totalLikes} person likes this building`
: `${props.value} people like this building` : `${props.totalLikes} people like this building`
: "0 people like this building so far - you could be the first!" : "0 people like this building so far - you could be the first!"
} }
</p> </p>
<input className="form-check-input" type="checkbox" <label className="form-check-label">
id="like" name="like" <input className="form-check-input" type="checkbox"
checked={!!props.building_like} name="like"
disabled={props.mode === 'view'} checked={!!props.userLike}
onChange={props.onLike} disabled={props.mode === 'view'}
/> 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! I like this building and think it contributes to the city!
</label> </label>
</Fragment> </Fragment>
@ -42,8 +50,10 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
} }
LikeDataEntry.propTypes = { LikeDataEntry.propTypes = {
value: PropTypes.any, // mode: PropTypes.string,
user_building_like: PropTypes.bool userLike: PropTypes.bool,
} totalLikes: PropTypes.number,
onLike: PropTypes.func
};
export default LikeDataEntry; export default LikeDataEntry;

View File

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

View File

@ -2,8 +2,16 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title'; 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 ( return (
<Fragment> <Fragment>
<DataTitleCopyable <DataTitleCopyable
@ -17,7 +25,14 @@ const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remo
id={props.slug} name={props.slug} id={props.slug} name={props.slug}
value={props.value || ''} value={props.value || ''}
disabled={props.mode === 'view' || props.disabled} 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> <option value="">{props.placeholder}</option>
{ {
props.options.map(option => ( props.options.map(option => (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
order: 1; order: 1;
padding: 0 0 2em; padding: 0 0 2em;
background: #fff; background: #fff;
overflow-y: scroll; overflow-y: auto;
height: 40%; height: 40%;
} }
@ -27,6 +27,14 @@
text-decoration: none; text-decoration: none;
color: #222; color: #222;
padding: 0.75rem 0.25rem 0.5rem 0; 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 h2,
.section-header .icon-buttons { .section-header .icon-buttons {
@ -133,6 +141,11 @@
/** /**
* Data list sections * Data list sections
*/ */
.section-body {
margin-top: 0.75em;
padding: 0 0.75em;
}
.data-section .h3 { .data-section .h3 {
margin: 0; margin: 0;
} }
@ -156,9 +169,7 @@
padding-left: 0.75rem; padding-left: 0.75rem;
padding-right: 0.75rem; padding-right: 0.75rem;
} }
.data-section form {
padding: 0 0.75rem;
}
.data-list a { .data-list a {
color: #555; color: #555;
} }

View File

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

View File

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

View File

@ -19,9 +19,10 @@ interface MapAppRouteParams {
} }
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> { interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: any; building: Building;
building_like: boolean; building_like: boolean;
user: any; user: any;
revisionId: number;
} }
interface MapAppState { interface MapAppState {
@ -42,12 +43,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
constructor(props: Readonly<MapAppProps>) { constructor(props: Readonly<MapAppProps>) {
super(props); super(props);
// set building revision id, default 0
const rev = props.building != undefined ? +props.building.revision_id : 0;
this.state = { this.state = {
category: this.getCategory(props.match.params.category), category: this.getCategory(props.match.params.category),
revision_id: rev, revision_id: props.revisionId || 0,
building: props.building, building: props.building,
building_like: props.building_like 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) { getCategory(category: string) {
if (category === 'categories') return undefined; 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 mode = this.props.match.params.mode || 'view';
const category = this.props.match.params.category || 'age'; const category = this.props.match.params.category || 'age';
@ -201,7 +220,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
} }
render() { render() {
const mode = this.props.match.params.mode || 'basic'; const mode = this.props.match.params.mode;
let category = this.state.category || 'age'; let category = this.state.category || 'age';
@ -241,13 +260,13 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
<EditHistory building={this.state.building} /> <EditHistory building={this.state.building} />
</Sidebar> </Sidebar>
</Route> </Route>
<Route exact path="/(view|edit|multi-edit)"> <Route exact path="/:mode(view|edit|multi-edit)"
<Redirect to="/view/categories" /> render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
</Route> />
</Switch> </Switch>
<ColouringMap <ColouringMap
building={this.state.building} building={this.state.building}
mode={mode} mode={mode || 'basic'}
category={category} category={category}
revision_id={this.state.revision_id} revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding} selectBuilding={this.selectBuilding}

View File

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

View File

@ -1,5 +1,10 @@
interface Building { interface Building {
building_id: number; building_id: number;
geometry_id: number;
revision_id: number;
uprns: string[];
// TODO: add other fields as needed
} }
export { 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%; max-height: 100%;
border-radius: 0; border-radius: 0;
padding: 1.5em 2.5em 2.5em; padding: 1.5em 2.5em 2.5em;
overflow-y: scroll; overflow-y: auto;
} }
.welcome-float.jumbotron { .welcome-float.jumbotron {
background: #fff; background: #fff;

View File

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

View File

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