Merge branch 'develop' into feature/84-show-edit-history
This commit is contained in:
commit
06eb4e53ed
7
app/public/geometries/boundary-detailed.geojson
Normal file
7
app/public/geometries/boundary-detailed.geojson
Normal file
File diff suppressed because one or more lines are too long
8
app/public/geometries/boundary.geojson
Normal file
8
app/public/geometries/boundary.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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')
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
|
@ -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} />
|
||||||
|
@ -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 & Shape"
|
title="Size & 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"
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
};
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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 => (
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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;
|
@ -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://..."
|
||||||
/>
|
/>
|
||||||
|
@ -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
|
||||||
|
};
|
@ -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">
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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}>
|
||||||
|
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
|
@ -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"
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
|
@ -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" />
|
||||||
|
@ -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 {
|
||||||
|
8
app/src/frontend/models/user.ts
Normal file
8
app/src/frontend/models/user.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
interface User {
|
||||||
|
username: string;
|
||||||
|
// TODO: add other fields as needed
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
User
|
||||||
|
};
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user