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 {
|
||||
getBuildingsByLocation,
|
||||
getBuildingsByReference,
|
||||
@ -154,5 +163,6 @@ export default {
|
||||
getBuildingUPRNsById,
|
||||
getBuildingLikeById,
|
||||
updateBuildingLikeById,
|
||||
getBuildingEditHistoryById
|
||||
getBuildingEditHistoryById,
|
||||
getLatestRevisionId
|
||||
};
|
||||
|
@ -14,6 +14,8 @@ router.get('/locate', buildingController.getBuildingsByLocation);
|
||||
// GET buildings by reference (UPRN/TOID or other identifier)
|
||||
router.get('/reference', buildingController.getBuildingsByReference);
|
||||
|
||||
router.get('/revision', buildingController.getLatestRevisionId);
|
||||
|
||||
router.route('/:building_id.json')
|
||||
// GET individual building
|
||||
.get(buildingController.getBuildingById)
|
||||
|
@ -19,6 +19,19 @@ const serializable = new TransactionMode({
|
||||
readOnly: false
|
||||
});
|
||||
|
||||
async function getLatestRevisionId() {
|
||||
try {
|
||||
const data = await db.oneOrNone(
|
||||
`SELECT MAX(log_id) from logs`
|
||||
);
|
||||
return data == undefined ? undefined : data.max;
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function queryBuildingsAtPoint(lng: number, lat: number) {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
@ -414,5 +427,6 @@ export {
|
||||
getBuildingUPRNsById,
|
||||
saveBuilding,
|
||||
likeBuilding,
|
||||
unlikeBuilding
|
||||
unlikeBuilding,
|
||||
getLatestRevisionId
|
||||
};
|
||||
|
@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
|
||||
|
||||
hydrate(
|
||||
<BrowserRouter>
|
||||
<App user={data.user} building={data.building} building_like={data.building_like} />
|
||||
<App
|
||||
user={data.user}
|
||||
building={data.building}
|
||||
building_like={data.building_like}
|
||||
revisionId={data.latestRevisionId}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
<App revisionId={0} />
|
||||
</MemoryRouter>,
|
||||
div
|
||||
);
|
||||
|
@ -28,6 +28,7 @@ interface AppProps {
|
||||
user?: any;
|
||||
building?: any;
|
||||
building_like?: boolean;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,6 +50,8 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
building_like: PropTypes.bool
|
||||
};
|
||||
|
||||
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
|
||||
|
||||
constructor(props: Readonly<AppProps>) {
|
||||
super(props);
|
||||
|
||||
@ -79,7 +82,14 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Header user={this.state.user} />
|
||||
<Switch>
|
||||
<Route exact path={App.mapAppPaths}>
|
||||
<Header user={this.state.user} animateLogo={false} />
|
||||
</Route>
|
||||
<Route>
|
||||
<Header user={this.state.user} animateLogo={true} />
|
||||
</Route>
|
||||
</Switch>
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path="/about.html" component={AboutPage} />
|
||||
@ -105,12 +115,13 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
<Route exact path="/data-accuracy.html" component={DataAccuracyPage} />
|
||||
<Route exact path="/data-extracts.html" component={DataExtracts} />
|
||||
<Route exact path="/contact.html" component={ContactPage} />
|
||||
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category?/:building(\\d+)?/(history)?"]} render={(props) => (
|
||||
<Route exact path={App.mapAppPaths} render={(props) => (
|
||||
<MapApp
|
||||
{...props}
|
||||
building={this.props.building}
|
||||
building_like={this.props.building_like}
|
||||
user={this.state.user}
|
||||
revisionId={this.props.revisionId}
|
||||
/>
|
||||
)} />
|
||||
<Route component={NotFound} />
|
||||
|
@ -14,18 +14,28 @@ import StreetscapeContainer from './data-containers/streetscape';
|
||||
import CommunityContainer from './data-containers/community';
|
||||
import PlanningContainer from './data-containers/planning';
|
||||
import LikeContainer from './data-containers/like';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
|
||||
interface BuildingViewProps {
|
||||
cat: string;
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
selectBuilding: (building: Building) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level container for building view/edit form
|
||||
*
|
||||
* @param props
|
||||
*/
|
||||
const BuildingView = (props) => {
|
||||
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
switch (props.cat) {
|
||||
case 'location':
|
||||
return <LocationContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Location"
|
||||
help="https://pages.colouring.london/location"
|
||||
intro="Where are the buildings? Address, location and cross-references."
|
||||
@ -33,7 +43,6 @@ const BuildingView = (props) => {
|
||||
case 'use':
|
||||
return <UseContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
inactive={true}
|
||||
title="Land Use"
|
||||
intro="How are buildings used, and how does use change over time? Coming soon…"
|
||||
@ -42,7 +51,6 @@ const BuildingView = (props) => {
|
||||
case 'type':
|
||||
return <TypeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
inactive={false}
|
||||
title="Type"
|
||||
intro="How were buildings previously used?"
|
||||
@ -51,7 +59,6 @@ const BuildingView = (props) => {
|
||||
case 'age':
|
||||
return <AgeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Age"
|
||||
help="https://pages.colouring.london/age"
|
||||
intro="Building age data can support energy analysis and help predict long-term change."
|
||||
@ -59,7 +66,6 @@ const BuildingView = (props) => {
|
||||
case 'size':
|
||||
return <SizeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Size & Shape"
|
||||
intro="How big are buildings?"
|
||||
help="https://pages.colouring.london/shapeandsize"
|
||||
@ -67,7 +73,6 @@ const BuildingView = (props) => {
|
||||
case 'construction':
|
||||
return <ConstructionContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Construction"
|
||||
intro="How are buildings built? Coming soon…"
|
||||
help="https://pages.colouring.london/construction"
|
||||
@ -76,7 +81,6 @@ const BuildingView = (props) => {
|
||||
case 'team':
|
||||
return <TeamContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Team"
|
||||
intro="Who built the buildings? Coming soon…"
|
||||
help="https://pages.colouring.london/team"
|
||||
@ -85,7 +89,6 @@ const BuildingView = (props) => {
|
||||
case 'sustainability':
|
||||
return <SustainabilityContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Sustainability"
|
||||
intro="Are buildings energy efficient?"
|
||||
help="https://pages.colouring.london/sustainability"
|
||||
@ -94,7 +97,6 @@ const BuildingView = (props) => {
|
||||
case 'streetscape':
|
||||
return <StreetscapeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Streetscape"
|
||||
intro="What's the building's context? Coming soon…"
|
||||
help="https://pages.colouring.london/streetscape"
|
||||
@ -103,7 +105,6 @@ const BuildingView = (props) => {
|
||||
case 'community':
|
||||
return <CommunityContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Community"
|
||||
intro="How does this building work for the local community?"
|
||||
help="https://pages.colouring.london/community"
|
||||
@ -112,7 +113,6 @@ const BuildingView = (props) => {
|
||||
case 'planning':
|
||||
return <PlanningContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Planning"
|
||||
intro="Planning controls relating to protection and reuse."
|
||||
help="https://pages.colouring.london/planning"
|
||||
@ -120,7 +120,6 @@ const BuildingView = (props) => {
|
||||
case 'like':
|
||||
return <LikeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Like Me!"
|
||||
intro="Do you like the building and think it contributes to the city?"
|
||||
help="https://pages.colouring.london/likeme"
|
||||
|
@ -2,8 +2,14 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface CheckboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
|
||||
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -19,7 +25,7 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
name={props.slug}
|
||||
checked={!!props.value}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
onChange={props.onChange}
|
||||
onChange={e => props.onChange(props.slug, e.target.checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={props.slug}
|
||||
@ -31,14 +37,12 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
);
|
||||
}
|
||||
|
||||
DataEntry.propTypes = {
|
||||
CheckboxDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.any,
|
||||
placeholder: PropTypes.string,
|
||||
maxLength: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
@ -47,4 +51,4 @@ DataEntry.propTypes = {
|
||||
})
|
||||
}
|
||||
|
||||
export default DataEntry;
|
||||
export default CheckboxDataEntry;
|
||||
|
@ -3,7 +3,24 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface BaseDataEntryProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // CopyProps clashes with propTypes
|
||||
mode?: 'view' | 'edit' | 'multi-edit';
|
||||
onChange?: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface DataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
valueTransform?: (string) => string
|
||||
}
|
||||
|
||||
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -20,7 +37,13 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e => {
|
||||
const transform = props.valueTransform || (x => x);
|
||||
const val = e.target.value === '' ?
|
||||
null :
|
||||
transform(e.target.value);
|
||||
props.onChange(props.slug, val);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@ -43,3 +66,6 @@ DataEntry.propTypes = {
|
||||
}
|
||||
|
||||
export default DataEntry;
|
||||
export {
|
||||
BaseDataEntryProps
|
||||
};
|
||||
|
@ -3,7 +3,13 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
|
||||
const DataTitle: React.FunctionComponent<any> = (props) => {
|
||||
|
||||
interface DataTitleProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
|
||||
return (
|
||||
<dt>
|
||||
{ props.title }
|
||||
@ -17,7 +23,16 @@ DataTitle.propTypes = {
|
||||
tooltip: PropTypes.string
|
||||
}
|
||||
|
||||
const DataTitleCopyable: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface DataTitleCopyableProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
slug: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // TODO: type should be CopyProps, but that clashes with propTypes in some obscure way
|
||||
}
|
||||
|
||||
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
|
||||
return (
|
||||
<div className="data-title">
|
||||
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
|
||||
@ -48,7 +63,8 @@ DataTitleCopyable.propTypes = {
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
toggleCopyAttribute: PropTypes.func,
|
||||
toggleCopying: PropTypes.func
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,15 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
|
||||
const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface LikeDataEntryProps {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
userLike: boolean;
|
||||
totalLikes: number;
|
||||
onLike: (userLike: boolean) => void;
|
||||
}
|
||||
|
||||
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
|
||||
const data_string = JSON.stringify({like: true});
|
||||
return (
|
||||
<Fragment>
|
||||
@ -21,20 +29,20 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
|
||||
</div>
|
||||
<p>
|
||||
{
|
||||
(props.value != null)?
|
||||
(props.value === 1)?
|
||||
`${props.value} person likes this building`
|
||||
: `${props.value} people like this building`
|
||||
(props.totalLikes != null)?
|
||||
(props.totalLikes === 1)?
|
||||
`${props.totalLikes} person likes this building`
|
||||
: `${props.totalLikes} people like this building`
|
||||
: "0 people like this building so far - you could be the first!"
|
||||
}
|
||||
</p>
|
||||
<input className="form-check-input" type="checkbox"
|
||||
id="like" name="like"
|
||||
checked={!!props.building_like}
|
||||
disabled={props.mode === 'view'}
|
||||
onChange={props.onLike}
|
||||
/>
|
||||
<label htmlFor="like" className="form-check-label">
|
||||
<label className="form-check-label">
|
||||
<input className="form-check-input" type="checkbox"
|
||||
name="like"
|
||||
checked={!!props.userLike}
|
||||
disabled={props.mode === 'view'}
|
||||
onChange={e => props.onLike(e.target.checked)}
|
||||
/>
|
||||
I like this building and think it contributes to the city!
|
||||
</label>
|
||||
</Fragment>
|
||||
@ -42,8 +50,10 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
|
||||
}
|
||||
|
||||
LikeDataEntry.propTypes = {
|
||||
value: PropTypes.any,
|
||||
user_building_like: PropTypes.bool
|
||||
}
|
||||
// mode: PropTypes.string,
|
||||
userLike: PropTypes.bool,
|
||||
totalLikes: PropTypes.number,
|
||||
onLike: PropTypes.func
|
||||
};
|
||||
|
||||
export default LikeDataEntry;
|
||||
|
@ -2,8 +2,18 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface NumericDataEntryProps extends BaseDataEntryProps {
|
||||
value?: number;
|
||||
placeholder?: string;
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -24,7 +34,12 @@ const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
|
||||
min={props.min || 0}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ? null : parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -2,8 +2,16 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface SelectDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
|
||||
const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -17,7 +25,14 @@ const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remo
|
||||
id={props.slug} name={props.slug}
|
||||
value={props.value || ''}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
onChange={props.onChange}>
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ?
|
||||
null :
|
||||
e.target.value
|
||||
)}
|
||||
>
|
||||
<option value="">{props.placeholder}</option>
|
||||
{
|
||||
props.options.map(option => (
|
||||
|
@ -2,8 +2,15 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface TextboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -18,11 +25,18 @@ const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
|
||||
id={props.slug}
|
||||
name={props.slug}
|
||||
value={props.value || ''}
|
||||
maxLength={props.max_length}
|
||||
maxLength={props.maxLength}
|
||||
rows={5}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ?
|
||||
null :
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
></textarea>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -7,6 +7,33 @@ import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { CopyControl } from './header-buttons/copy-control';
|
||||
import { ViewEditControl } from './header-buttons/view-edit-control';
|
||||
import { Building } from '../models/building';
|
||||
import { User } from '../models/user';
|
||||
import { compareObjects } from '../helpers';
|
||||
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
|
||||
|
||||
interface DataContainerProps {
|
||||
title: string;
|
||||
cat: string;
|
||||
intro: string;
|
||||
help: string;
|
||||
inactive?: boolean;
|
||||
|
||||
user: User;
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
selectBuilding: (building: Building) => void
|
||||
}
|
||||
|
||||
interface DataContainerState {
|
||||
error: string;
|
||||
copying: boolean;
|
||||
keys_to_copy: {[key: string]: boolean};
|
||||
currentBuildingId: number;
|
||||
currentBuildingRevisionId: number;
|
||||
buildingEdits: Partial<Building>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared functionality for view/edit forms
|
||||
@ -16,15 +43,14 @@ import { ViewEditControl } from './header-buttons/view-edit-control';
|
||||
*
|
||||
* @param WrappedComponent
|
||||
*/
|
||||
const withCopyEdit = (WrappedComponent) => {
|
||||
return class extends React.Component<any, any> { // TODO: add proper types
|
||||
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
|
||||
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
intro: PropTypes.string,
|
||||
help: PropTypes.string,
|
||||
inactive: PropTypes.bool,
|
||||
building_id: PropTypes.number,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
@ -32,23 +58,40 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: this.props.error || undefined,
|
||||
like: this.props.like || undefined,
|
||||
error: undefined,
|
||||
copying: false,
|
||||
keys_to_copy: {},
|
||||
building: this.props.building
|
||||
buildingEdits: {},
|
||||
currentBuildingId: undefined,
|
||||
currentBuildingRevisionId: undefined
|
||||
};
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleCheck = this.handleCheck.bind(this);
|
||||
this.handleReset = this.handleReset.bind(this);
|
||||
this.handleLike = this.handleLike.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleUpdate = this.handleUpdate.bind(this);
|
||||
|
||||
this.toggleCopying = this.toggleCopying.bind(this);
|
||||
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
|
||||
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
|
||||
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
|
||||
return {
|
||||
error: undefined,
|
||||
copying: false,
|
||||
keys_to_copy: {},
|
||||
buildingEdits: {},
|
||||
currentBuildingId: newBuildingId,
|
||||
currentBuildingRevisionId: newBuildingRevisionId
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter or exit "copying" state - allow user to select attributes to copy
|
||||
*/
|
||||
@ -64,7 +107,7 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
* @param {string} key
|
||||
*/
|
||||
toggleCopyAttribute(key: string) {
|
||||
const keys = this.state.keys_to_copy;
|
||||
const keys = {...this.state.keys_to_copy};
|
||||
if(this.state.keys_to_copy[key]){
|
||||
delete keys[key];
|
||||
} else {
|
||||
@ -75,45 +118,34 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
})
|
||||
}
|
||||
|
||||
updateBuildingState(key, value) {
|
||||
const building = {...this.state.building};
|
||||
building[key] = value;
|
||||
isEdited() {
|
||||
const edits = this.state.buildingEdits;
|
||||
// check if the edits object has any fields
|
||||
return Object.entries(edits).length !== 0;
|
||||
}
|
||||
|
||||
clearEdits() {
|
||||
this.setState({
|
||||
building: building
|
||||
buildingEdits: {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes on typical inputs
|
||||
* - e.g. input[type=text], radio, select, textare
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleChange(event) {
|
||||
const target = event.target;
|
||||
let value = (target.value === '')? null : target.value;
|
||||
const name = target.name;
|
||||
|
||||
// special transform - consider something data driven before adding 'else if's
|
||||
if (name === 'location_postcode' && value !== null) {
|
||||
value = value.toUpperCase();
|
||||
getEditedBuilding() {
|
||||
if(this.isEdited()) {
|
||||
return Object.assign({}, this.props.building, this.state.buildingEdits);
|
||||
} else {
|
||||
return {...this.props.building};
|
||||
}
|
||||
this.updateBuildingState(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes on checkboxes
|
||||
* - e.g. input[type=checkbox]
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleCheck(event) {
|
||||
const target = event.target;
|
||||
const value = target.checked;
|
||||
const name = target.name;
|
||||
updateBuildingState(key: string, value: any) {
|
||||
const newBuilding = this.getEditedBuilding();
|
||||
newBuilding[key] = value;
|
||||
const [forwardPatch] = compareObjects(this.props.building, newBuilding);
|
||||
|
||||
this.updateBuildingState(name, value);
|
||||
this.setState({
|
||||
buildingEdits: forwardPatch
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,86 +155,93 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
* @param {String} name
|
||||
* @param {*} value
|
||||
*/
|
||||
handleUpdate(name: string, value: any) {
|
||||
handleChange(name: string, value: any) {
|
||||
this.updateBuildingState(name, value);
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.clearEdits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle likes separately
|
||||
* - like/love reaction is limited to set/unset per user
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleLike(event) {
|
||||
event.preventDefault();
|
||||
const like = event.target.checked;
|
||||
async handleLike(like: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({like: like})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({like: like})
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
} else {
|
||||
this.props.selectBuilding(res);
|
||||
this.updateBuildingState('likes_total', res.likes_total);
|
||||
this.props.selectBuilding(data);
|
||||
this.updateBuildingState('likes_total', data.likes_total);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
} catch(err) {
|
||||
this.setState({error: err});
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined})
|
||||
this.setState({error: undefined});
|
||||
|
||||
fetch(`/api/buildings/${this.props.building.building_id}.json`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.state.building),
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/${this.props.building.building_id}.json`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.state.buildingEdits),
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
} else {
|
||||
this.props.selectBuilding(res);
|
||||
this.props.selectBuilding(data);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
} catch(err) {
|
||||
this.setState({error: err});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.mode === 'edit' && !this.props.user){
|
||||
if (this.props.mode === 'edit' && !this.props.user){
|
||||
return <Redirect to="/sign-up.html" />
|
||||
}
|
||||
|
||||
const currentBuilding = this.getEditedBuilding();
|
||||
|
||||
const values_to_copy = {}
|
||||
for (const key of Object.keys(this.state.keys_to_copy)) {
|
||||
values_to_copy[key] = this.state.building[key]
|
||||
values_to_copy[key] = currentBuilding[key]
|
||||
}
|
||||
const data_string = JSON.stringify(values_to_copy);
|
||||
const copy = {
|
||||
const copy: CopyProps = {
|
||||
copying: this.state.copying,
|
||||
toggleCopying: this.toggleCopying,
|
||||
toggleCopyAttribute: this.toggleCopyAttribute,
|
||||
copyingKey: (key) => this.state.keys_to_copy[key]
|
||||
copyingKey: (key: string) => this.state.keys_to_copy[key]
|
||||
}
|
||||
|
||||
const headerBackLink = `/${this.props.mode}/categories${this.props.building != undefined ? `/${this.props.building.building_id}` : ''}`;
|
||||
const edited = this.isEdited();
|
||||
|
||||
return (
|
||||
<section
|
||||
id={this.props.slug}
|
||||
id={this.props.cat}
|
||||
className="data-section">
|
||||
<ContainerHeader
|
||||
cat={this.props.cat}
|
||||
@ -248,72 +287,72 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
: null
|
||||
}
|
||||
</ContainerHeader>
|
||||
<div className="section-body">
|
||||
{
|
||||
this.props.building != undefined ?
|
||||
<form
|
||||
action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
|
||||
method="POST"
|
||||
onSubmit={this.handleSubmit}>
|
||||
{
|
||||
(this.props.inactive) ?
|
||||
<InfoBox
|
||||
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
{
|
||||
(this.props.mode === 'edit' && !this.props.inactive) ?
|
||||
<Fragment>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
{
|
||||
this.props.slug === 'like' ? // special-case for likes
|
||||
null :
|
||||
<div className="buttons-container with-space">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</Fragment>
|
||||
: null
|
||||
}
|
||||
<WrappedComponent
|
||||
building={this.state.building}
|
||||
building_like={this.props.building_like}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onCheck={this.handleCheck}
|
||||
onLike={this.handleLike}
|
||||
onUpdate={this.handleUpdate}
|
||||
/>
|
||||
</form>
|
||||
:
|
||||
<form>
|
||||
{
|
||||
(this.props.inactive)?
|
||||
<Fragment>
|
||||
this.props.inactive ?
|
||||
<Fragment>
|
||||
<InfoBox
|
||||
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
|
||||
/>
|
||||
<WrappedComponent
|
||||
intro={this.props.intro}
|
||||
building={undefined}
|
||||
building_like={undefined}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onCheck={this.handleCheck}
|
||||
onLike={this.handleLike}
|
||||
onUpdate={this.handleUpdate}
|
||||
/>
|
||||
</Fragment>
|
||||
:
|
||||
</Fragment> :
|
||||
this.props.building != undefined ?
|
||||
<form
|
||||
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
|
||||
method="POST"
|
||||
onSubmit={this.handleSubmit}>
|
||||
{
|
||||
(this.props.mode === 'edit' && !this.props.inactive) ?
|
||||
<Fragment>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
{
|
||||
this.props.cat !== 'like' ? // special-case for likes
|
||||
<div className="buttons-container with-space">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!edited}
|
||||
aria-disabled={!edited}>
|
||||
Save
|
||||
</button>
|
||||
{
|
||||
edited ?
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning"
|
||||
onClick={this.handleReset}
|
||||
>
|
||||
Discard changes
|
||||
</button> :
|
||||
null
|
||||
}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</Fragment>
|
||||
: null
|
||||
}
|
||||
<WrappedComponent
|
||||
intro={this.props.intro}
|
||||
building={currentBuilding}
|
||||
building_like={this.props.building_like}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onLike={this.handleLike}
|
||||
/>
|
||||
</form> :
|
||||
<InfoBox msg="Select a building to view data"></InfoBox>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
@ -7,11 +7,12 @@ import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import TextboxDataEntry from '../data-components/textbox-data-entry';
|
||||
import YearDataEntry from '../data-components/year-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Age view/edit section
|
||||
*/
|
||||
const AgeView = (props) => (
|
||||
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<YearDataEntry
|
||||
year={props.building.date_year}
|
||||
@ -68,7 +69,7 @@ const AgeView = (props) => (
|
||||
value={props.building.date_link}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onUpdate}
|
||||
onChange={props.onChange}
|
||||
tooltip={dataFields.date_link.tooltip}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
|
@ -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 withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Community view/edit section
|
||||
*/
|
||||
const CommunityView = (props) => (
|
||||
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul className="data-list">
|
||||
|
@ -2,17 +2,18 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import LikeDataEntry from '../data-components/like-data-entry';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Like view/edit section
|
||||
*/
|
||||
const LikeView = (props) => (
|
||||
const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<LikeDataEntry
|
||||
value={props.building.likes_total}
|
||||
userLike={props.building_like}
|
||||
totalLikes={props.building.likes_total}
|
||||
mode={props.mode}
|
||||
onLike={props.onLike}
|
||||
building_like={props.building_like}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
|
@ -6,8 +6,9 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import UPRNsDataEntry from '../data-components/uprns-data-entry';
|
||||
import InfoBox from '../../components/info-box';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const LocationView = (props) => (
|
||||
const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
|
||||
<DataEntry
|
||||
@ -64,6 +65,7 @@ const LocationView = (props) => (
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
maxLength={8}
|
||||
valueTransform={x=>x.toUpperCase()}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.ref_toid.title}
|
||||
@ -97,7 +99,7 @@ const LocationView = (props) => (
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
step={0.0001}
|
||||
placeholder={51}
|
||||
placeholder="51"
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
@ -107,7 +109,7 @@ const LocationView = (props) => (
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
step={0.0001}
|
||||
placeholder={0}
|
||||
placeholder="0"
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</Fragment>
|
||||
|
@ -6,11 +6,12 @@ import CheckboxDataEntry from '../data-components/checkbox-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Planning view/edit section
|
||||
*/
|
||||
const PlanningView = (props) => (
|
||||
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<DataEntry
|
||||
title={dataFields.planning_portal_link.title}
|
||||
|
@ -5,11 +5,12 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Size view/edit section
|
||||
*/
|
||||
const SizeView = (props) => (
|
||||
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<DataEntryGroup name="Storeys" collapsed={false}>
|
||||
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Streetscape view/edit section
|
||||
*/
|
||||
const StreetscapeView = (props) => (
|
||||
const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul className="data-list">
|
||||
|
@ -5,6 +5,7 @@ import DataEntry from '../data-components/data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
const BreeamRatingOptions = [
|
||||
@ -18,12 +19,7 @@ const BreeamRatingOptions = [
|
||||
/**
|
||||
* Sustainability view/edit section
|
||||
*/
|
||||
const SustainabilityView = (props) => {
|
||||
const dataEntryProps = {
|
||||
mode: props.mode,
|
||||
copy: props.copy,
|
||||
onChange: props.onChange
|
||||
};
|
||||
const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<SelectDataEntry
|
||||
@ -32,7 +28,9 @@ const SustainabilityView = (props) => {
|
||||
value={props.building.sust_breeam_rating}
|
||||
tooltip={dataFields.sust_breeam_rating.tooltip}
|
||||
options={BreeamRatingOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title={dataFields.sust_dec.title}
|
||||
@ -40,7 +38,9 @@ const SustainabilityView = (props) => {
|
||||
value={props.building.sust_dec}
|
||||
tooltip={dataFields.sust_dec.tooltip}
|
||||
options={EnergyCategoryOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title={dataFields.sust_aggregate_estimate_epc.title}
|
||||
@ -49,7 +49,9 @@ const SustainabilityView = (props) => {
|
||||
tooltip={dataFields.sust_aggregate_estimate_epc.tooltip}
|
||||
options={EnergyCategoryOptions}
|
||||
disabled={true}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.sust_retrofit_date.title}
|
||||
@ -59,7 +61,9 @@ const SustainabilityView = (props) => {
|
||||
step={1}
|
||||
min={1086}
|
||||
max={new Date().getFullYear()}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.sust_life_expectancy.title}
|
||||
@ -68,7 +72,9 @@ const SustainabilityView = (props) => {
|
||||
step={1}
|
||||
min={1}
|
||||
disabled={true}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -2,11 +2,12 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Team view/edit section
|
||||
*/
|
||||
const TeamView = (props) => (
|
||||
const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul>
|
||||
|
@ -5,6 +5,7 @@ import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const AttachmentFormOptions = [
|
||||
"Detached",
|
||||
@ -16,10 +17,7 @@ const AttachmentFormOptions = [
|
||||
/**
|
||||
* Type view/edit section
|
||||
*/
|
||||
const TypeView = (props) => {
|
||||
const {mode, copy, onChange} = props;
|
||||
const dataEntryProps = { mode, copy, onChange };
|
||||
|
||||
const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<SelectDataEntry
|
||||
@ -28,7 +26,9 @@ const TypeView = (props) => {
|
||||
value={props.building.building_attachment_form}
|
||||
tooltip={dataFields.building_attachment_form.tooltip}
|
||||
options={AttachmentFormOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.date_change_building_use.title}
|
||||
@ -38,12 +38,18 @@ const TypeView = (props) => {
|
||||
min={1086}
|
||||
max={new Date().getFullYear()}
|
||||
step={1}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.original_building_use.title}
|
||||
slug="original_building_use" // doesn't exist in database yet
|
||||
tooltip={dataFields.original_building_use.tooltip}
|
||||
value={undefined}
|
||||
copy={props.copy}
|
||||
mode={props.mode}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</Fragment>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Use view/edit section
|
||||
*/
|
||||
const UseView = (props) => (
|
||||
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul>
|
||||
|
@ -5,7 +5,7 @@
|
||||
order: 1;
|
||||
padding: 0 0 2em;
|
||||
background: #fff;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
@ -27,6 +27,14 @@
|
||||
text-decoration: none;
|
||||
color: #222;
|
||||
padding: 0.75rem 0.25rem 0.5rem 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.section-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
.section-header h2,
|
||||
.section-header .icon-buttons {
|
||||
@ -133,6 +141,11 @@
|
||||
/**
|
||||
* Data list sections
|
||||
*/
|
||||
|
||||
.section-body {
|
||||
margin-top: 0.75em;
|
||||
padding: 0 0.75em;
|
||||
}
|
||||
.data-section .h3 {
|
||||
margin: 0;
|
||||
}
|
||||
@ -156,9 +169,7 @@
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.data-section form {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.data-list a {
|
||||
color: #555;
|
||||
}
|
||||
|
@ -26,22 +26,22 @@ const Logo: React.FunctionComponent<LogoProps> = (props) => {
|
||||
const LogoGrid: React.FunctionComponent = () => (
|
||||
<div className="grid">
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-location"></div>
|
||||
<div className="cell background-use"></div>
|
||||
<div className="cell background-type"></div>
|
||||
<div className="cell background-age"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-size"></div>
|
||||
<div className="cell background-construction"></div>
|
||||
<div className="cell background-streetscape"></div>
|
||||
<div className="cell background-team"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-sustainability"></div>
|
||||
<div className="cell background-community"></div>
|
||||
<div className="cell background-planning"></div>
|
||||
<div className="cell background-like"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -5,14 +5,25 @@ import PropTypes from 'prop-types';
|
||||
import { Logo } from './components/logo';
|
||||
import './header.css';
|
||||
|
||||
|
||||
interface HeaderProps {
|
||||
user: any;
|
||||
animateLogo: boolean;
|
||||
}
|
||||
|
||||
interface HeaderState {
|
||||
collapseMenu: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main header using a responsive design
|
||||
*/
|
||||
class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
class Header extends React.Component<HeaderProps, HeaderState> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string
|
||||
})
|
||||
}),
|
||||
animateLogo: PropTypes.bool
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -40,7 +51,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
<nav className="navbar navbar-light navbar-expand-lg">
|
||||
<span className="navbar-brand align-self-start">
|
||||
<NavLink to="/">
|
||||
<Logo variant='animated'/>
|
||||
<Logo variant={this.props.animateLogo ? 'animated' : 'default'}/>
|
||||
</NavLink>
|
||||
</span>
|
||||
<button className="navbar-toggler navbar-toggler-right" type="button"
|
||||
|
@ -62,8 +62,21 @@ function parseDate(isoUtcDate: string): Date {
|
||||
return new Date(Date.UTC(year, month-1, day, hour, minute, second, millisecond));
|
||||
}
|
||||
|
||||
function compareObjects(objA: object, objB: object): [object, object] {
|
||||
const reverse = {}
|
||||
const forward = {}
|
||||
for (const [key, value] of Object.entries(objB)) {
|
||||
if (objA[key] !== value) {
|
||||
reverse[key] = objA[key];
|
||||
forward[key] = value;
|
||||
}
|
||||
}
|
||||
return [forward, reverse];
|
||||
}
|
||||
|
||||
export {
|
||||
sanitiseURL,
|
||||
arrayToDictionary,
|
||||
parseDate
|
||||
sanitiseURL,
|
||||
arrayToDictionary,
|
||||
parseDate,
|
||||
compareObjects
|
||||
};
|
||||
|
@ -19,9 +19,10 @@ interface MapAppRouteParams {
|
||||
}
|
||||
|
||||
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
|
||||
building: any;
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
interface MapAppState {
|
||||
@ -42,12 +43,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
constructor(props: Readonly<MapAppProps>) {
|
||||
super(props);
|
||||
|
||||
// set building revision id, default 0
|
||||
const rev = props.building != undefined ? +props.building.revision_id : 0;
|
||||
|
||||
this.state = {
|
||||
category: this.getCategory(props.match.params.category),
|
||||
revision_id: rev,
|
||||
revision_id: props.revisionId || 0,
|
||||
building: props.building,
|
||||
building_like: props.building_like
|
||||
};
|
||||
@ -64,6 +62,27 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLatestRevision();
|
||||
}
|
||||
|
||||
async fetchLatestRevision() {
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/revision`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
this.increaseRevision(data.latestRevisionId);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
getCategory(category: string) {
|
||||
if (category === 'categories') return undefined;
|
||||
|
||||
@ -78,7 +97,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
selectBuilding(building) {
|
||||
selectBuilding(building: Building) {
|
||||
const mode = this.props.match.params.mode || 'view';
|
||||
const category = this.props.match.params.category || 'age';
|
||||
|
||||
@ -201,7 +220,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const mode = this.props.match.params.mode || 'basic';
|
||||
const mode = this.props.match.params.mode;
|
||||
|
||||
let category = this.state.category || 'age';
|
||||
|
||||
@ -241,13 +260,13 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
<EditHistory building={this.state.building} />
|
||||
</Sidebar>
|
||||
</Route>
|
||||
<Route exact path="/(view|edit|multi-edit)">
|
||||
<Redirect to="/view/categories" />
|
||||
</Route>
|
||||
<Route exact path="/:mode(view|edit|multi-edit)"
|
||||
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
|
||||
/>
|
||||
</Switch>
|
||||
<ColouringMap
|
||||
building={this.state.building}
|
||||
mode={mode}
|
||||
mode={mode || 'basic'}
|
||||
category={category}
|
||||
revision_id={this.state.revision_id}
|
||||
selectBuilding={this.selectBuilding}
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { LatLngExpression } from 'leaflet';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl, GeoJSON } from 'react-leaflet-universal';
|
||||
import { GeoJsonObject } from 'geojson';
|
||||
|
||||
import '../../../node_modules/leaflet/dist/leaflet.css'
|
||||
import './map.css'
|
||||
|
||||
import { HelpIcon } from '../components/icons';
|
||||
import Legend from './legend';
|
||||
import { parseCategoryURL } from '../../parse';
|
||||
import SearchBox from './search-box';
|
||||
import ThemeSwitcher from './theme-switcher';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
|
||||
|
||||
interface ColouringMapProps {
|
||||
building: any;
|
||||
building: Building;
|
||||
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
|
||||
category: string;
|
||||
revision_id: number;
|
||||
@ -28,11 +28,12 @@ interface ColouringMapState {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
boundary: GeoJsonObject;
|
||||
}
|
||||
/**
|
||||
* Map area
|
||||
*/
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
building: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
@ -48,7 +49,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
theme: 'night',
|
||||
lat: 51.5245255,
|
||||
lng: -0.1338422,
|
||||
zoom: 16
|
||||
zoom: 16,
|
||||
boundary: undefined,
|
||||
};
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleLocate = this.handleLocate.bind(this);
|
||||
@ -100,8 +102,20 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
this.setState({theme: newTheme});
|
||||
}
|
||||
|
||||
async getBoundary() {
|
||||
const res = await fetch('/geometries/boundary-detailed.geojson');
|
||||
const data = await res.json() as GeoJsonObject;
|
||||
this.setState({
|
||||
boundary: data
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getBoundary();
|
||||
}
|
||||
|
||||
render() {
|
||||
const position: LatLngExpression = [this.state.lat, this.state.lng];
|
||||
const position: [number, number] = [this.state.lat, this.state.lng];
|
||||
|
||||
// baselayer
|
||||
const key = OS_API_KEY;
|
||||
@ -117,6 +131,11 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
|
||||
|
||||
|
||||
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
|
||||
const boundaryLayer = this.state.boundary &&
|
||||
<GeoJSON data={this.state.boundary} style={boundaryStyleFn}/>;
|
||||
|
||||
// colour-data tiles
|
||||
const cat = this.props.category;
|
||||
const tilesetByCat = {
|
||||
@ -166,6 +185,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
>
|
||||
{ baseLayer }
|
||||
{ buildingBaseLayer }
|
||||
{ boundaryLayer }
|
||||
{ dataLayer }
|
||||
{ highlightLayer }
|
||||
<ZoomControl position="topright" />
|
||||
|
@ -1,5 +1,10 @@
|
||||
interface Building {
|
||||
building_id: number;
|
||||
geometry_id: number;
|
||||
revision_id: number;
|
||||
|
||||
uprns: string[];
|
||||
// TODO: add other fields as needed
|
||||
}
|
||||
|
||||
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%;
|
||||
border-radius: 0;
|
||||
padding: 1.5em 2.5em 2.5em;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.welcome-float.jumbotron {
|
||||
background: #fff;
|
||||
|
@ -57,38 +57,38 @@
|
||||
* Category colours
|
||||
*/
|
||||
.background-location {
|
||||
background-color: #edc40b;
|
||||
background-color: #f7c625;
|
||||
}
|
||||
.background-use {
|
||||
background-color: #f0ee0c;
|
||||
background-color: #f7ec25;
|
||||
}
|
||||
.background-type {
|
||||
background-color: #ff9100;
|
||||
background-color: #f77d11;
|
||||
}
|
||||
.background-age {
|
||||
background-color: #ee5f63;
|
||||
background-color: #ff6161;
|
||||
}
|
||||
.background-size {
|
||||
background-color: #ee91bf;
|
||||
background-color: #f2a2b9;
|
||||
}
|
||||
.background-construction {
|
||||
background-color: #aa7fa7;
|
||||
background-color: #ab8fb0;
|
||||
}
|
||||
.background-streetscape {
|
||||
background-color: #6f879c;
|
||||
background-color: #718899;
|
||||
}
|
||||
.background-team {
|
||||
background-color: #5ec232;
|
||||
background-color: #7cbf39;
|
||||
}
|
||||
.background-sustainability {
|
||||
background-color: #6dbb8b;
|
||||
background-color: #57c28e;
|
||||
}
|
||||
.background-community {
|
||||
background-color: #65b7ff;
|
||||
background-color: #6bb1e3;
|
||||
}
|
||||
.background-planning {
|
||||
background-color: #a1a3a9;
|
||||
background-color: #aaaaaa;
|
||||
}
|
||||
.background-like {
|
||||
background-color: #9c896d;
|
||||
background-color: #a3916f;
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ import { getUserById } from './api/services/user';
|
||||
import {
|
||||
getBuildingById,
|
||||
getBuildingLikeById,
|
||||
getBuildingUPRNsById
|
||||
getBuildingUPRNsById,
|
||||
getLatestRevisionId
|
||||
} from './api/services/building';
|
||||
|
||||
|
||||
@ -36,8 +37,9 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
userId ? getUserById(userId) : undefined,
|
||||
isBuilding ? getBuildingById(buildingId) : undefined,
|
||||
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
|
||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
|
||||
]).then(function ([user, building, uprns, buildingLike]) {
|
||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
|
||||
getLatestRevisionId()
|
||||
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) {
|
||||
if (isBuilding && typeof (building) === 'undefined') {
|
||||
context.status = 404;
|
||||
}
|
||||
@ -47,12 +49,14 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
if (data.building != null) {
|
||||
data.building.uprns = uprns;
|
||||
}
|
||||
data.latestRevisionId = latestRevisionId;
|
||||
renderHTML(context, data, req, res);
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
data.user = undefined;
|
||||
data.building = undefined;
|
||||
data.building_like = undefined;
|
||||
data.latestRevisionId = 0;
|
||||
context.status = 500;
|
||||
renderHTML(context, data, req, res);
|
||||
});
|
||||
@ -61,7 +65,12 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
function renderHTML(context, data, req, res) {
|
||||
const markup = renderToString(
|
||||
<StaticRouter context={context} location={req.url}>
|
||||
<App user={data.user} building={data.building} building_like={data.building_like} />
|
||||
<App
|
||||
user={data.user}
|
||||
building={data.building}
|
||||
building_like={data.building_like}
|
||||
revisionId={data.latestRevisionId}
|
||||
/>
|
||||
</StaticRouter>
|
||||
);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user