Merge pull request #477 from mz8i/feature/84-show-edit-history

Feature 84: show edit history
This commit is contained in:
mz8i 2019-10-30 14:14:39 +00:00 committed by GitHub
commit 8da20a47b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 843 additions and 164 deletions

View File

@ -110,6 +110,17 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
}
});
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
const { building_id } = req.params;
try {
const editHistory = await buildingService.getBuildingEditHistory(building_id);
res.send({ history: editHistory });
} catch(error) {
res.send({ error: 'Database error' });
}
});
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
@ -152,5 +163,6 @@ export default {
getBuildingUPRNsById,
getBuildingLikeById,
updateBuildingLikeById,
getBuildingEditHistoryById,
getLatestRevisionId
};
};

View File

@ -31,4 +31,7 @@ router.route('/:building_id/like.json')
.get(buildingController.getBuildingLikeById)
.post(buildingController.updateBuildingLikeById);
export default router;
router.route('/:building_id/history.json')
.get(buildingController.getBuildingEditHistoryById);
export default router;

View File

@ -112,7 +112,8 @@ async function getBuildingEditHistory(id: number) {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id`,
WHERE building_id = $1 AND logs.user_id = users.user_id
ORDER BY log_timestamp DESC`,
[id]
);
} catch(error) {
@ -422,6 +423,7 @@ export {
queryBuildingsByReference,
getBuildingById,
getBuildingLikeById,
getBuildingEditHistory,
getBuildingUPRNsById,
saveBuilding,
likeBuilding,

View File

@ -50,7 +50,7 @@ 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+)?'];
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
constructor(props: Readonly<AppProps>) {
super(props);

View File

@ -19,7 +19,7 @@ import { Building } from '../models/building';
interface BuildingViewProps {
cat: string;
mode: 'view' | 'edit' | 'multi-edit';
mode: 'view' | 'edit';
building: Building;
building_like: boolean;
user: any;

View File

@ -4,75 +4,19 @@ import { Link, NavLink } from 'react-router-dom';
import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
interface ContainerHeaderProps {
cat: string;
mode: 'view' | 'edit' | 'multi-edit';
building: any;
cat?: string;
backLink: string;
title: string;
copy: any;
inactive?: boolean;
data_string: string;
help: string;
}
const ContainerHeader: React.FunctionComponent<ContainerHeaderProps> = (props) => (
<header className={`section-header view ${props.cat} background-${props.cat}`}>
<Link className="icon-button back" to={`/${props.mode}/categories${props.building != undefined ? `/${props.building.building_id}` : ''}`}>
<header className={`section-header view ${props.cat ? props.cat : ''} ${props.cat ? `background-${props.cat}` : ''}`}>
<Link className="icon-button back" to={props.backLink}>
<BackIcon />
</Link>
<h2 className="h2">{props.title}</h2>
<nav className="icon-buttons">
{
props.building != undefined && !props.inactive ?
props.copy.copying?
<Fragment>
<NavLink
to={`/multi-edit/${props.cat}?data=${props.data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a
className="icon-button copy"
onClick={props.copy.toggleCopying}>
Cancel
</a>
</Fragment>
:
<a
className="icon-button copy"
onClick={props.copy.toggleCopying}>
Copy
</a>
: null
}
{
props.help && !props.copy.copying?
<a
className="icon-button help"
title="Find out more"
href={props.help}>
Info
</a>
: null
}
{
props.building != undefined && !props.inactive && !props.copy.copying?
(props.mode === 'edit')?
<NavLink
className="icon-button view"
title="View data"
to={`/view/${props.cat}/${props.building.building_id}`}>
View
<ViewIcon />
</NavLink>
: <NavLink
className="icon-button edit"
title="Edit data"
to={`/edit/${props.cat}/${props.building.building_id}`}>
Edit
<EditIcon />
</NavLink>
: null
}
{props.children}
</nav>
</header>
)

View File

@ -2,6 +2,7 @@ import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import NumericDataEntry from './numeric-data-entry';
import { dataFields } from '../../data_fields';
class YearDataEntry extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
@ -35,7 +36,7 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
return (
<Fragment>
<NumericDataEntry
title="Year built (best estimate)"
title={dataFields.date_year.title}
slug="date_year"
value={props.year}
mode={props.mode}
@ -44,24 +45,24 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
// "type": "year_estimator"
/>
<NumericDataEntry
title="Latest possible start year"
title={dataFields.date_upper.title}
slug="date_upper"
value={props.upper}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="This should be the latest year in which building could have started."
tooltip={dataFields.date_upper.tooltip}
/>
<NumericDataEntry
title="Earliest possible start date"
title={dataFields.date_lower.title}
slug="date_lower"
value={props.lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="This should be the earliest year in which building could have started."
tooltip={dataFields.date_lower.tooltip}
/>
</Fragment>
)

View File

@ -1,10 +1,12 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import { Redirect, NavLink } from 'react-router-dom';
import ContainerHeader from './container-header';
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';
@ -18,7 +20,7 @@ interface DataContainerProps {
inactive?: boolean;
user: User;
mode: 'view' | 'edit' | 'multi-edit';
mode: 'view' | 'edit';
building: Building;
building_like: boolean;
selectBuilding: (building: Building) => void
@ -232,17 +234,59 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
toggleCopying: this.toggleCopying,
toggleCopyAttribute: this.toggleCopyAttribute,
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.cat}
className="data-section">
<ContainerHeader
{...this.props}
data_string={data_string}
copy={copy}
/>
cat={this.props.cat}
backLink={headerBackLink}
title={this.props.title}
>
{
this.props.help && !copy.copying?
<a
className="icon-button help"
title="Find out more"
href={this.props.help}>
Info
</a>
: null
}
{
this.props.building != undefined && !this.props.inactive ?
<>
<CopyControl
cat={this.props.cat}
data_string={data_string}
copying={copy.copying}
toggleCopying={copy.toggleCopying}
/>
{
!copy.copying ?
<>
<NavLink
className="icon-button history"
to={`/${this.props.mode}/${this.props.cat}/${this.props.building.building_id}/history`}
>History</NavLink>
<ViewEditControl
cat={this.props.cat}
mode={this.props.mode}
building={this.props.building}
/>
</>
:
null
}
</>
: null
}
</ContainerHeader>
<div className="section-body">
{
this.props.inactive ?

View File

@ -6,6 +6,7 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
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';
/**
@ -22,23 +23,23 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<NumericDataEntry
title="Facade year"
title={dataFields.facade_year.title}
slug="facade_year"
value={props.building.facade_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
tooltip="Best estimate"
tooltip={dataFields.facade_year.tooltip}
/>
<SelectDataEntry
title="Source of information"
title={dataFields.date_source.title}
slug="date_source"
value={props.building.date_source}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="Source for the main start date"
tooltip={dataFields.date_source.tooltip}
placeholder=""
options={[
"Survey of London",
@ -54,22 +55,22 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
]}
/>
<TextboxDataEntry
title="Source details"
title={dataFields.date_source_detail.title}
slug="date_source_detail"
value={props.building.date_source_detail}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="References for date source (max 500 characters)"
tooltip={dataFields.date_source_detail.tooltip}
/>
<MultiDataEntry
title="Text and Image Links"
title={dataFields.date_link.title}
slug="date_link"
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="URL for age and date reference"
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
/>
</Fragment>

View File

@ -5,24 +5,25 @@ import DataEntry from '../data-components/data-entry';
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: 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
title="Building Name"
title={dataFields.location_name.title}
slug="location_name"
value={props.building.location_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip="May not be needed for many buildings."
tooltip={dataFields.location_name.tooltip}
placeholder="Building name (if any)"
disabled={true}
/>
<NumericDataEntry
title="Building number"
title={dataFields.location_number.title}
slug="location_number"
value={props.building.location_number}
mode={props.mode}
@ -31,7 +32,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={1}
/>
<DataEntry
title="Street"
title={dataFields.location_street.title}
slug="location_street"
value={props.building.location_street}
mode={props.mode}
@ -40,7 +41,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<DataEntry
title="Address line 2"
title={dataFields.location_line_two.title}
slug="location_line_two"
value={props.building.location_line_two}
mode={props.mode}
@ -49,7 +50,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<DataEntry
title="Town"
title={dataFields.location_town.title}
slug="location_town"
value={props.building.location_town}
mode={props.mode}
@ -57,7 +58,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Postcode"
title={dataFields.location_postcode.title}
slug="location_postcode"
value={props.building.location_postcode}
mode={props.mode}
@ -67,32 +68,32 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
valueTransform={x=>x.toUpperCase()}
/>
<DataEntry
title="TOID"
title={dataFields.ref_toid.title}
slug="ref_toid"
value={props.building.ref_toid}
mode={props.mode}
copy={props.copy}
tooltip="Ordnance Survey Topography Layer ID (to be filled automatically)"
tooltip={dataFields.ref_toid.tooltip}
onChange={props.onChange}
disabled={true}
/>
<UPRNsDataEntry
title="UPRNs"
title={dataFields.uprns.title}
value={props.building.uprns}
tooltip="Unique Property Reference Numbers (to be filled automatically)"
tooltip={dataFields.uprns.tooltip}
/>
<DataEntry
title="OSM ID"
title={dataFields.ref_osm_id.title}
slug="ref_osm_id"
value={props.building.ref_osm_id}
mode={props.mode}
copy={props.copy}
tooltip="OpenStreetMap feature ID"
tooltip={dataFields.ref_osm_id.tooltip}
maxLength={20}
onChange={props.onChange}
/>
<NumericDataEntry
title="Latitude"
title={dataFields.location_latitude.title}
slug="location_latitude"
value={props.building.location_latitude}
mode={props.mode}
@ -102,7 +103,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<NumericDataEntry
title="Longitude"
title={dataFields.location_longitude.title}
slug="location_longitude"
value={props.building.location_longitude}
mode={props.mode}

View File

@ -5,6 +5,7 @@ import DataEntry from '../data-components/data-entry';
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';
/**
@ -13,7 +14,7 @@ import { CategoryViewProps } from './category-view-props';
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntry
title="Planning portal link"
title={dataFields.planning_portal_link.title}
slug="planning_portal_link"
value={props.building.planning_portal_link}
mode={props.mode}
@ -22,7 +23,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
/>
<DataEntryGroup name="Listing and protections" >
<CheckboxDataEntry
title="In a conservation area?"
title={dataFields.planning_in_conservation_area.title}
slug="planning_in_conservation_area"
value={props.building.planning_in_conservation_area}
mode={props.mode}
@ -30,7 +31,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Conservation area name"
title={dataFields.planning_conservation_area_name.title}
slug="planning_conservation_area_name"
value={props.building.planning_conservation_area_name}
mode={props.mode}
@ -38,7 +39,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Is listed on the National Heritage List for England?"
title={dataFields.planning_in_list.title}
slug="planning_in_list"
value={props.building.planning_in_list}
mode={props.mode}
@ -46,7 +47,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="National Heritage List for England list id"
title={dataFields.planning_list_id.title}
slug="planning_list_id"
value={props.building.planning_list_id}
mode={props.mode}
@ -54,7 +55,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<SelectDataEntry
title="National Heritage List for England list type"
title={dataFields.planning_list_cat.title}
slug="planning_list_cat"
value={props.building.planning_list_cat}
mode={props.mode}
@ -69,7 +70,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
]}
/>
<SelectDataEntry
title="Listing grade"
title={dataFields.planning_list_grade.title}
slug="planning_list_grade"
value={props.building.planning_list_grade}
mode={props.mode}
@ -83,7 +84,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
]}
/>
<DataEntry
title="Heritage at risk list id"
title={dataFields.planning_heritage_at_risk_id.title}
slug="planning_heritage_at_risk_id"
value={props.building.planning_heritage_at_risk_id}
mode={props.mode}
@ -91,7 +92,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="World heritage list id"
title={dataFields.planning_world_list_id.title}
slug="planning_world_list_id"
value={props.building.planning_world_list_id}
mode={props.mode}
@ -99,7 +100,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<CheckboxDataEntry
title="In the Greater London Historic Environment Record?"
title={dataFields.planning_in_glher.title}
slug="planning_in_glher"
value={props.building.planning_in_glher}
mode={props.mode}
@ -107,7 +108,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Greater London Historic Environment Record link"
title={dataFields.planning_glher_url.title}
slug="planning_glher_url"
value={props.building.planning_glher_url}
mode={props.mode}
@ -115,7 +116,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<CheckboxDataEntry
title="In an Architectural Priority Area?"
title={dataFields.planning_in_apa.title}
slug="planning_in_apa"
value={props.building.planning_in_apa}
mode={props.mode}
@ -123,7 +124,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Architectural Priority Area name"
title={dataFields.planning_apa_name.title}
slug="planning_apa_name"
value={props.building.planning_apa_name}
mode={props.mode}
@ -131,7 +132,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Architectural Priority Area tier"
title={dataFields.planning_apa_tier.title}
slug="planning_apa_tier"
value={props.building.planning_apa_tier}
mode={props.mode}
@ -139,7 +140,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Is locally listed?"
title={dataFields.planning_in_local_list.title}
slug="planning_in_local_list"
value={props.building.planning_in_local_list}
mode={props.mode}
@ -147,7 +148,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Local list link"
title={dataFields.planning_local_list_url.title}
slug="planning_local_list_url"
value={props.building.planning_local_list_url}
mode={props.mode}
@ -155,7 +156,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<CheckboxDataEntry
title="Within a historic area assessment?"
title={dataFields.planning_in_historic_area_assessment.title}
slug="planning_in_historic_area_assessment"
value={props.building.planning_in_historic_area_assessment}
mode={props.mode}
@ -163,7 +164,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
<DataEntry
title="Historic area assessment link"
title={dataFields.planning_historic_area_assessment_url.title}
slug="planning_historic_area_assessment_url"
value={props.building.planning_historic_area_assessment_url}
mode={props.mode}
@ -173,7 +174,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
</DataEntryGroup>
<DataEntryGroup name="Demolition and demolition history">
<CheckboxDataEntry
title="Is the building proposed for demolition?"
title={dataFields.planning_demolition_proposed.title}
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
@ -182,7 +183,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<CheckboxDataEntry
title="Has the building been demolished?"
title={dataFields.planning_demolition_complete.title}
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
mode={props.mode}
@ -191,7 +192,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<DataEntry
title="Dates of construction and demolition of previous buildings on site"
title={dataFields.planning_demolition_history.title}
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}

View File

@ -4,6 +4,7 @@ import withCopyEdit from '../data-container';
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';
/**
@ -14,39 +15,39 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<DataEntryGroup name="Storeys" collapsed={false}>
<NumericDataEntry
title="Core storeys"
title={dataFields.size_storeys_core.title}
slug="size_storeys_core"
value={props.building.size_storeys_core}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys between the pavement and start of roof?"
tooltip={dataFields.size_storeys_core.tooltip}
onChange={props.onChange}
step={1}
/>
<NumericDataEntry
title="Attic storeys"
title={dataFields.size_storeys_attic.title}
slug="size_storeys_attic"
value={props.building.size_storeys_attic}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys above start of roof?"
tooltip={dataFields.size_storeys_attic.tooltip}
onChange={props.onChange}
step={1}
/>
<NumericDataEntry
title="Basement storeys"
title={dataFields.size_storeys_basement.title}
slug="size_storeys_basement"
value={props.building.size_storeys_basement}
mode={props.mode}
copy={props.copy}
tooltip="How many storeys below pavement level?"
tooltip={dataFields.size_storeys_basement.tooltip}
onChange={props.onChange}
step={1}
/>
</DataEntryGroup>
<DataEntryGroup name="Height">
<NumericDataEntry
title="Height to apex (m)"
title={dataFields.size_height_apex.title}
slug="size_height_apex"
value={props.building.size_height_apex}
mode={props.mode}
@ -55,7 +56,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1}
/>
<NumericDataEntry
title="Height to eaves (m)"
title={dataFields.size_height_eaves.title}
slug="size_height_eaves"
disabled={true}
value={props.building.size_height_eaves}
@ -67,7 +68,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
</DataEntryGroup>
<DataEntryGroup name="Floor area">
<NumericDataEntry
title="Ground floor area (m²)"
title={dataFields.size_floor_area_ground.title}
slug="size_floor_area_ground"
value={props.building.size_floor_area_ground}
mode={props.mode}
@ -76,7 +77,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1}
/>
<NumericDataEntry
title="Total floor area (m²)"
title={dataFields.size_floor_area_total.title}
slug="size_floor_area_total"
value={props.building.size_floor_area_total}
mode={props.mode}
@ -86,7 +87,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
/>
</DataEntryGroup>
<NumericDataEntry
title="Frontage Width (m)"
title={dataFields.size_width_frontage.title}
slug="size_width_frontage"
value={props.building.size_width_frontage}
mode={props.mode}
@ -95,7 +96,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1}
/>
<NumericDataEntry
title="Total area of plot (m²)"
title={dataFields.size_plot_area_total.title}
slug="size_plot_area_total"
value={props.building.size_plot_area_total}
mode={props.mode}
@ -105,7 +106,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<NumericDataEntry
title="FAR ratio (percentage of plot covered by building)"
title={dataFields.size_far_ratio.title}
slug="size_far_ratio"
value={props.building.size_far_ratio}
mode={props.mode}
@ -115,7 +116,23 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
disabled={true}
/>
<SelectDataEntry
title="Roof shape"
title={dataFields.size_configuration.title}
slug="size_configuration"
value={props.building.size_configuration}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
options={[
"Detached",
"Semi-detached",
"Terrace",
"End terrace",
"Block"
]}
/>
<SelectDataEntry
title={dataFields.size_roof_shape.title}
slug="size_roof_shape"
value={props.building.size_roof_shape}
mode={props.mode}

View File

@ -4,6 +4,7 @@ import withCopyEdit from '../data-container';
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"];
@ -22,30 +23,30 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
return (
<Fragment>
<SelectDataEntry
title="BREEAM Rating"
title={dataFields.sust_breeam_rating.title}
slug="sust_breeam_rating"
value={props.building.sust_breeam_rating}
tooltip="(Building Research Establishment Environmental Assessment Method) May not be present for many buildings"
tooltip={dataFields.sust_breeam_rating.tooltip}
options={BreeamRatingOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title="DEC Rating"
title={dataFields.sust_dec.title}
slug="sust_dec"
value={props.building.sust_dec}
tooltip="(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use"
tooltip={dataFields.sust_dec.tooltip}
options={EnergyCategoryOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title="EPC Rating"
title={dataFields.sust_aggregate_estimate_epc.title}
slug="sust_aggregate_estimate_epc"
value={props.building.sust_aggregate_estimate_epc}
tooltip="(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented"
tooltip={dataFields.sust_aggregate_estimate_epc.tooltip}
options={EnergyCategoryOptions}
disabled={true}
mode={props.mode}
@ -53,10 +54,10 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
onChange={props.onChange}
/>
<NumericDataEntry
title="Last significant retrofit"
title={dataFields.sust_retrofit_date.title}
slug="sust_retrofit_date"
value={props.building.sust_retrofit_date}
tooltip="Date of last major building refurbishment"
tooltip={dataFields.sust_retrofit_date.tooltip}
step={1}
min={1086}
max={new Date().getFullYear()}
@ -65,7 +66,7 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
onChange={props.onChange}
/>
<NumericDataEntry
title="Expected lifespan for typology"
title={dataFields.sust_life_expectancy.title}
slug="sust_life_expectancy"
value={props.building.sust_life_expectancy}
step={1}

View File

@ -4,6 +4,7 @@ import withCopyEdit from '../data-container';
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 = [
@ -20,20 +21,20 @@ const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
title="Building configuration (attachment)?"
title={dataFields.building_attachment_form.title}
slug="building_attachment_form"
value={props.building.building_attachment_form}
tooltip="We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)"
tooltip={dataFields.building_attachment_form.tooltip}
options={AttachmentFormOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title="When did use change?"
title={dataFields.date_change_building_use.title}
slug="date_change_building_use"
value={props.building.date_change_building_use}
tooltip="This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened"
tooltip={dataFields.date_change_building_use.tooltip}
min={1086}
max={new Date().getFullYear()}
step={1}
@ -42,9 +43,9 @@ const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
onChange={props.onChange}
/>
<DataEntry
title="Original building use"
slug=""
tooltip="What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse"
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}

View File

@ -0,0 +1,14 @@
.edit-history-entry {
border-bottom: 1px solid black;
padding: 1em;
}
.edit-history-timestamp {
font-size: 0.9em;
padding: 0;
}
.edit-history-username {
font-size: 0.9em;
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { EditHistoryEntry } from '../models/edit-history-entry';
import { arrayToDictionary, parseDate } from '../../helpers';
import { dataFields } from '../../data_fields';
import { CategoryEditSummary } from './category-edit-summary';
import './building-edit-summary.css';
interface BuildingEditSummaryProps {
historyEntry: EditHistoryEntry
}
function formatDate(dt: Date) {
return dt.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = props => {
const entriesWithMetadata = Object
.entries(props.historyEntry.forward_patch)
.map(([key, value]) => {
const info = dataFields[key] || {};
return {
title: info.title || `Unknown field (${key})`,
category: info.category || 'Unknown category',
value: value,
oldValue: props.historyEntry.reverse_patch && props.historyEntry.reverse_patch[key]
};
});
const entriesByCategory = arrayToDictionary(entriesWithMetadata, x => x.category);
return (
<div className="edit-history-entry">
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(props.historyEntry.date_trunc))}</h2>
<h3 className="edit-history-username">By {props.historyEntry.username}</h3>
{
Object.entries(entriesByCategory).map(([category, fields]) => <CategoryEditSummary category={category} fields={fields} />)
}
</div>
);
}
export {
BuildingEditSummary
};

View File

@ -0,0 +1,28 @@
.edit-history-category-summary {
margin-top: 1.5rem;
}
.edit-history-category-summary ul {
list-style: none;
padding-left: 0.5em;
}
.edit-history-category-title {
font-size: 0.9em;
font-weight: 600;
}
.edit-history-diff {
padding: 0 0.2rem;
padding-top:0.15rem;
border-radius: 2px;
}
.edit-history-diff.old {
background-color: #f8d9bc;
color: #c24e00;
text-decoration: line-through;
}
.edit-history-diff.new {
background-color: #b6dcff;
color: #0064c2;
}

View File

@ -0,0 +1,31 @@
import React from 'react';
import './category-edit-summary.css';
import { FieldEditSummary } from './field-edit-summary';
interface CategoryEditSummaryProps {
category: string;
fields: {
title: string;
value: string;
oldValue: string;
}[];
}
const CategoryEditSummary : React.FunctionComponent<CategoryEditSummaryProps> = props => (
<div className='edit-history-category-summary'>
<h3 className='edit-history-category-title'>{props.category}:</h3>
<ul>
{
props.fields.map(x =>
<li key={x.title}>
<FieldEditSummary title={x.title} value={x.value} oldValue={x.oldValue} />
</li>)
}
</ul>
</div>
);
export {
CategoryEditSummary
};

View File

@ -0,0 +1,8 @@
.edit-history {
background-color: white;
}
.edit-history-list {
list-style: none;
padding-left: 1rem;
}

View File

@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import { EditHistoryEntry } from '../models/edit-history-entry';
import { BuildingEditSummary } from './building-edit-summary';
import './edit-history.css';
import { Building } from '../../models/building';
import ContainerHeader from '../container-header';
interface EditHistoryProps {
building: Building;
}
const EditHistory: React.FunctionComponent<EditHistoryProps> = (props) => {
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
useEffect(() => {
const fetchData = async () => {
const res = await fetch(`/api/buildings/${props.building.building_id}/history.json`);
const data = await res.json();
setHistory(data.history);
};
if (props.building != undefined) { // only call fn if there is a building provided
fetchData(); // define and call, because effect cannot return anything and an async fn always returns a Promise
}
}, [props.building]); // only re-run effect on building prop change
return (
<>
<ContainerHeader title="Edit history" backLink='.' cat='edit-history' />
<ul className="edit-history-list">
{history && history.map(entry => (
<li key={`${entry.revision_id}`} className="edit-history-list-element">
<BuildingEditSummary historyEntry={entry} />
</li>
))}
</ul>
</>
);
}
export {
EditHistory
};

View File

@ -0,0 +1,20 @@
import React from 'react';
interface FieldEditSummaryProps {
title: string;
value: any;
oldValue: any;
}
const FieldEditSummary: React.FunctionComponent<FieldEditSummaryProps> = props => (
<>
{props.title}:&nbsp;
<code title="Value before edit" className='edit-history-diff old'>{props.oldValue}</code>
&nbsp;
<code title="Value after edit" className='edit-history-diff new'>{props.value}</code>
</>
);
export {
FieldEditSummary
};

View File

@ -0,0 +1,35 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
interface CopyControlProps {
cat: string;
data_string: string;
copying: boolean;
toggleCopying: () => void;
}
const CopyControl: React.FC<CopyControlProps> = props => (
props.copying ?
<>
<NavLink
to={`/multi-edit/${props.cat}?data=${props.data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a
className="icon-button copy"
onClick={props.toggleCopying}>
Cancel
</a>
</>
:
<a
className="icon-button copy"
onClick={props.toggleCopying}>
Copy
</a>
);
export {
CopyControl
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Building } from '../../models/building';
import { NavLink } from 'react-router-dom';
import { ViewIcon, EditIcon } from '../../components/icons';
interface ViewEditControlProps {
cat: string;
mode: 'view' | 'edit';
building: Building;
}
const ViewEditControl: React.FC<ViewEditControlProps> = props => (
(props.mode === 'edit')?
<NavLink
className="icon-button view"
title="View data"
to={`/view/${props.cat}/${props.building.building_id}`}>
View
<ViewIcon />
</NavLink>
: <NavLink
className="icon-button edit"
title="Edit data"
to={`/edit/${props.cat}/${props.building.building_id}`}>
Edit
<EditIcon />
</NavLink>
);
export {
ViewEditControl
};

View File

@ -0,0 +1,7 @@
export interface EditHistoryEntry {
date_trunc: string;
username: string;
revision_id: string;
forward_patch: object;
reverse_patch: object;
}

View File

@ -7,6 +7,7 @@ import Sidebar from './sidebar';
import InfoBox from '../components/info-box';
import { BackIcon }from '../components/icons';
import DataEntry from './data-components/data-entry';
import { dataFields } from '../data_fields';
const MultiEdit = (props) => {
@ -63,9 +64,10 @@ const MultiEdit = (props) => {
<InfoBox msg='Click buildings one at a time to colour using the data below' />
{
Object.keys(data).map((key => {
const info = dataFields[key] || {};
return (
<DataEntry
title={key}
title={info.title || `Unknown field (${key})`}
slug={key}
disabled={true}
value={data[key]}

View File

@ -9,13 +9,6 @@
height: 40%;
}
.info-container h2:first-child {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
margin-left: -0.1em;
padding: 0 0.75rem;
}
@media (min-width: 768px){
.info-container {
order: 0;
@ -107,7 +100,8 @@
color: rgb(11, 225, 225);
}
.icon-button.help,
.icon-button.copy {
.icon-button.copy,
.icon-button.history {
margin-top: 4px;
}
.data-section label .icon-buttons .icon-button.copy {

View File

@ -0,0 +1,344 @@
export enum Category {
Location = 'Location',
LandUse = 'Land Use',
Type = 'Type',
Age = 'Age',
SizeShape = 'Size & Shape',
Construction = 'Construction',
Streetscape = 'Streetscape',
Team = 'Team',
Sustainability = 'Sustainability',
Community = 'Community',
Planning = 'Planning',
Like = 'Like Me!'
}
export const categoriesOrder: Category[] = [
Category.Location,
Category.LandUse,
Category.Type,
Category.Age,
Category.SizeShape,
Category.Construction,
Category.Streetscape,
Category.Team,
Category.Sustainability,
Category.Community,
Category.Planning,
Category.Like,
];
export const dataFields = {
location_name: {
category: Category.Location,
title: "Building Name",
tooltip: "May not be needed for many buildings.",
},
location_number: {
category: Category.Location,
title: "Building number",
},
location_street: {
category: Category.Location,
title: "Street",
//tooltip: ,
},
location_line_two: {
category: Category.Location,
title: "Address line 2",
//tooltip: ,
},
location_town: {
category: Category.Location,
title: "Town",
//tooltip: ,
},
location_postcode: {
category: Category.Location,
title: "Postcode",
//tooltip: ,
},
ref_toid: {
category: Category.Location,
title: "TOID",
tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)",
},
/**
* UPRNs is not part of the buildings table, but the string fields
* are included here for completeness
*/
uprns: {
category: Category.Location,
title: "UPRNs",
tooltip: "Unique Property Reference Numbers (to be filled automatically)"
},
ref_osm_id: {
category: Category.Location,
title: "OSM ID",
tooltip: "OpenStreetMap feature ID",
},
location_latitude: {
category: Category.Location,
title: "Latitude",
},
location_longitude: {
category: Category.Location,
title: "Longitude",
},
building_attachment_form: {
category: Category.Type,
title: "Building configuration (attachment)?",
tooltip: "We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)",
},
date_change_building_use: {
category: Category.Type,
title:"When did use change?",
tooltip: "This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened",
},
/**
* original_building_use does not exist in database yet.
* Slug needs to be adjusted if the db column will be named differently
*/
original_building_use: {
category: Category.Type,
title: "Original building use",
tooltip: "What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse",
},
date_year: {
category: Category.Age,
title: "Year built (best estimate)"
},
date_lower : {
category: Category.Age,
title: "Earliest possible start date",
tooltip: "This should be the earliest year in which building could have started."
},
date_upper: {
category: Category.Age,
title: "Latest possible start year",
tooltip: "This should be the latest year in which building could have started."
},
facade_year: {
category: Category.Age,
title: "Facade year",
tooltip: "Best estimate"
},
date_source: {
category: Category.Age,
title: "Source of information",
tooltip: "Source for the main start date"
},
date_source_detail: {
category: Category.Age,
title: "Source details",
tooltip: "References for date source (max 500 characters)"
},
date_link: {
category: Category.Age,
title: "Text and Image Links",
tooltip: "URL for age and date reference",
},
size_storeys_core: {
category: Category.SizeShape,
title: "Core storeys",
tooltip: "How many storeys between the pavement and start of roof?",
},
size_storeys_attic: {
category: Category.SizeShape,
title: "Attic storeys",
tooltip: "How many storeys above start of roof?",
},
size_storeys_basement: {
category: Category.SizeShape,
title: "Basement storeys",
tooltip: "How many storeys below pavement level?",
},
size_height_apex: {
category: Category.SizeShape,
title: "Height to apex (m)",
//tooltip: ,
},
size_height_eaves: {
category: Category.SizeShape,
title: "Height to eaves (m)",
//tooltip: ,
},
size_floor_area_ground: {
category: Category.SizeShape,
title: "Ground floor area (m²)",
//tooltip: ,
},
size_floor_area_total: {
category: Category.SizeShape,
title: "Total floor area (m²)",
//tooltip: ,
},
size_width_frontage: {
category: Category.SizeShape,
title: "Frontage Width (m)",
//tooltip: ,
},
size_plot_area_total: {
category: Category.SizeShape,
title: "Total area of plot (m²)",
//tooltip: ,
},
size_far_ratio: {
category: Category.SizeShape,
title: "FAR ratio (percentage of plot covered by building)",
//tooltip: ,
},
size_configuration: {
category: Category.SizeShape,
title: "Configuration (semi/detached, end/terrace)",
//tooltip: ,
},
size_roof_shape: {
category: Category.SizeShape,
title: "Roof shape",
//tooltip: ,
},
sust_breeam_rating: {
category: Category.Sustainability,
title: "BREEAM Rating",
tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings",
},
sust_dec: {
category: Category.Sustainability,
title: "DEC Rating",
tooltip: "(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use",
},
sust_aggregate_estimate_epc: {
category: Category.Sustainability,
title: "EPC Rating",
tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented",
},
sust_retrofit_date: {
category: Category.Sustainability,
title: "Last significant retrofit",
tooltip: "Date of last major building refurbishment",
},
sust_life_expectancy: {
category: Category.Sustainability,
title: "Expected lifespan for typology",
//tooltip: ,
},
planning_portal_link: {
category: Category.Planning,
title: "Planning portal link",
//tooltip: ,
},
planning_in_conservation_area: {
category: Category.Planning,
title: "In a conservation area?",
//tooltip: ,
},
planning_conservation_area_name: {
category: Category.Planning,
title: "Conservation area name",
//tooltip: ,
},
planning_in_list: {
category: Category.Planning,
title: "Is listed on the National Heritage List for England?",
//tooltip: ,
},
planning_list_id: {
category: Category.Planning,
title: "National Heritage List for England list id",
//tooltip: ,
},
planning_list_cat: {
category: Category.Planning,
title: "National Heritage List for England list type",
//tooltip: ,
},
planning_list_grade: {
category: Category.Planning,
title: "Listing grade",
//tooltip: ,
},
planning_heritage_at_risk_id: {
category: Category.Planning,
title: "Heritage at risk list id",
//tooltip: ,
},
planning_world_list_id: {
category: Category.Planning,
title: "World heritage list id",
//tooltip: ,
},
planning_in_glher: {
category: Category.Planning,
title: "In the Greater London Historic Environment Record?",
//tooltip: ,
},
planning_glher_url: {
category: Category.Planning,
title: "Greater London Historic Environment Record link",
//tooltip: ,
},
planning_in_apa: {
category: Category.Planning,
title: "In an Architectural Priority Area?",
//tooltip: ,
},
planning_apa_name: {
category: Category.Planning,
title: "Architectural Priority Area name",
//tooltip: ,
},
planning_apa_tier: {
category: Category.Planning,
title: "Architectural Priority Area tier",
//tooltip: ,
},
planning_in_local_list: {
category: Category.Planning,
title: "Is locally listed?",
//tooltip: ,
},
planning_local_list_url: {
category: Category.Planning,
title: "Local list link",
//tooltip: ,
},
planning_in_historic_area_assessment: {
category: Category.Planning,
title: "Within a historic area assessment?",
//tooltip: ,
},
planning_historic_area_assessment_url: {
category: Category.Planning,
title: "Historic area assessment link",
//tooltip: ,
},
planning_demolition_proposed: {
category: Category.Planning,
title: "Is the building proposed for demolition?",
//tooltip: ,
},
planning_demolition_complete: {
category: Category.Planning,
title: "Has the building been demolished?",
//tooltip: ,
},
planning_demolition_history: {
category: Category.Planning,
title: "Dates of construction and demolition of previous buildings on site",
//tooltip: ,
},
likes_total: {
category: Category.Like,
title: "Total number of likes"
}
};

View File

@ -36,6 +36,32 @@ function sanitiseURL(string){
return `${url_.protocol}//${url_.hostname}${url_.pathname || ''}${url_.search || ''}${url_.hash || ''}`
}
/**
* Transform an array of objects into a dictionary of arrays of objects,
* where the objects are grouped into arrays given an arbitrary key function
* that gives a key for each object.
* @param arr array of objects to group
* @param keyAccessor function returning the grouping key for each object in the original array
*/
function arrayToDictionary<T>(arr: T[], keyAccessor: (obj: T) => string): {[key: string]: T[]} {
return arr.reduce((obj, item) => {
(obj[keyAccessor(item)] = obj[keyAccessor(item)] || []).push(item);
return obj;
}, {});
}
/**
* Parse a string containing an ISO8601 formatted date
* @param isoUtcDate a date string in ISO8601 format, assuming UTC
* @returns a JS Date object with the UTC time encoded
*/
function parseDate(isoUtcDate: string): Date {
const [year, month, day, hour, minute, second, millisecond] = isoUtcDate.match(/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d).(\d{3})Z$/)
.splice(1)
.map(x => parseInt(x, 10));
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 = {}
@ -48,4 +74,9 @@ function compareObjects(objA: object, objB: object): [object, object] {
return [forward, reverse];
}
export { sanitiseURL, compareObjects }
export {
sanitiseURL,
arrayToDictionary,
parseDate,
compareObjects
};

View File

@ -9,6 +9,7 @@ import MultiEdit from './building/multi-edit';
import BuildingView from './building/building-view';
import ColouringMap from './map/map';
import { parse } from 'query-string';
import { EditHistory } from './building/edit-history/edit-history';
import { Building } from './models/building';
interface MapAppRouteParams {
@ -220,6 +221,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
render() {
const mode = this.props.match.params.mode;
const viewEditMode = mode === 'multi-edit' ? undefined : mode;
let category = this.state.category || 'age';
@ -245,7 +247,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
<Route exact path="/:mode/:cat/:building?">
<Sidebar>
<BuildingView
mode={mode}
mode={viewEditMode}
cat={category}
building={this.state.building}
building_like={this.state.building_like}
@ -254,6 +256,11 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
/>
</Sidebar>
</Route>
<Route exact path="/:mode/:cat/:building/history">
<Sidebar>
<EditHistory building={this.state.building} />
</Sidebar>
</Route>
<Route exact path="/:mode(view|edit|multi-edit)"
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
/>

View File

@ -23,7 +23,7 @@ function strictParseInt(value) {
* @returns {number|undefined}
*/
function parseBuildingURL(url) {
const re = /\/(\d+)$/;
const re = /\/(\d+)(\/history)?$/;
const matches = re.exec(url);
if (matches && matches.length >= 2) {