diff --git a/app/src/api/controllers/buildingController.ts b/app/src/api/controllers/buildingController.ts index d4e43b2b..40a94e03 100644 --- a/app/src/api/controllers/buildingController.ts +++ b/app/src/api/controllers/buildingController.ts @@ -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 -}; \ No newline at end of file +}; diff --git a/app/src/api/routes/buildingsRouter.ts b/app/src/api/routes/buildingsRouter.ts index 9d44cc5b..98347fe1 100644 --- a/app/src/api/routes/buildingsRouter.ts +++ b/app/src/api/routes/buildingsRouter.ts @@ -31,4 +31,7 @@ router.route('/:building_id/like.json') .get(buildingController.getBuildingLikeById) .post(buildingController.updateBuildingLikeById); -export default router; \ No newline at end of file +router.route('/:building_id/history.json') + .get(buildingController.getBuildingEditHistoryById); + +export default router; diff --git a/app/src/api/services/building.ts b/app/src/api/services/building.ts index c2dcd454..85d7df37 100644 --- a/app/src/api/services/building.ts +++ b/app/src/api/services/building.ts @@ -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, diff --git a/app/src/frontend/app.tsx b/app/src/frontend/app.tsx index 7db99c9e..d269c7ac 100644 --- a/app/src/frontend/app.tsx +++ b/app/src/frontend/app.tsx @@ -50,7 +50,7 @@ class App extends React.Component { // 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) { super(props); diff --git a/app/src/frontend/building/building-view.tsx b/app/src/frontend/building/building-view.tsx index d322d984..e66a93ee 100644 --- a/app/src/frontend/building/building-view.tsx +++ b/app/src/frontend/building/building-view.tsx @@ -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; diff --git a/app/src/frontend/building/container-header.tsx b/app/src/frontend/building/container-header.tsx index 33d6cc55..2f88322e 100644 --- a/app/src/frontend/building/container-header.tsx +++ b/app/src/frontend/building/container-header.tsx @@ -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 = (props) => ( -
- +
+

{props.title}

) diff --git a/app/src/frontend/building/data-components/year-data-entry.tsx b/app/src/frontend/building/data-components/year-data-entry.tsx index c2634708..bb5fe709 100644 --- a/app/src/frontend/building/data-components/year-data-entry.tsx +++ b/app/src/frontend/building/data-components/year-data-entry.tsx @@ -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 { // TODO: add proper types static propTypes = { // TODO: generate propTypes from TS @@ -35,7 +36,7 @@ class YearDataEntry extends Component { // TODO: add proper types return ( { // TODO: add proper types // "type": "year_estimator" /> ) diff --git a/app/src/frontend/building/data-container.tsx b/app/src/frontend/building/data-container.tsx index 306afd00..db903baa 100644 --- a/app/src/frontend/building/data-container.tsx +++ b/app/src/frontend/building/data-container.tsx @@ -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) 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 (
+ cat={this.props.cat} + backLink={headerBackLink} + title={this.props.title} + > + { + this.props.help && !copy.copying? + + Info + + : null + } + { + this.props.building != undefined && !this.props.inactive ? + <> + + { + !copy.copying ? + <> + History + + + : + null + } + + : null + } +
{ this.props.inactive ? diff --git a/app/src/frontend/building/data-containers/age.tsx b/app/src/frontend/building/data-containers/age.tsx index 51ea24a0..4cb1789c 100644 --- a/app/src/frontend/building/data-containers/age.tsx +++ b/app/src/frontend/building/data-containers/age.tsx @@ -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 = (props) => ( onChange={props.onChange} /> = (props) => ( ]} /> diff --git a/app/src/frontend/building/data-containers/location.tsx b/app/src/frontend/building/data-containers/location.tsx index ee411624..a3ecf591 100644 --- a/app/src/frontend/building/data-containers/location.tsx +++ b/app/src/frontend/building/data-containers/location.tsx @@ -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 = (props) => ( = (props) => ( step={1} /> = (props) => ( disabled={true} /> = (props) => ( disabled={true} /> = (props) => ( onChange={props.onChange} /> = (props) => ( valueTransform={x=>x.toUpperCase()} /> = (props) => ( onChange={props.onChange} /> = (props) => ( = (props) => ( /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( ]} /> = (props) => ( ]} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( onChange={props.onChange} /> = (props) => ( = (props) => ( disabled={true} /> = (props) => ( disabled={true} /> = (props) => ( = (props) => ( step={0.1} /> = (props) => ( = (props) => ( step={0.1} /> = (props) => ( /> = (props) => ( step={0.1} /> = (props) => ( disabled={true} /> = (props) => ( disabled={true} /> + = (props) = return ( = (props) = onChange={props.onChange} /> = (props) = onChange={props.onChange} /> = (props) => { return ( = (props) => { onChange={props.onChange} /> = 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 ( +
+

Edited on {formatDate(parseDate(props.historyEntry.date_trunc))}

+

By {props.historyEntry.username}

+ { + Object.entries(entriesByCategory).map(([category, fields]) => ) + } +
+ ); +} + +export { + BuildingEditSummary +}; diff --git a/app/src/frontend/building/edit-history/category-edit-summary.css b/app/src/frontend/building/edit-history/category-edit-summary.css new file mode 100644 index 00000000..3cfdb579 --- /dev/null +++ b/app/src/frontend/building/edit-history/category-edit-summary.css @@ -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; +} \ No newline at end of file diff --git a/app/src/frontend/building/edit-history/category-edit-summary.tsx b/app/src/frontend/building/edit-history/category-edit-summary.tsx new file mode 100644 index 00000000..73a0d542 --- /dev/null +++ b/app/src/frontend/building/edit-history/category-edit-summary.tsx @@ -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 = props => ( +
+

{props.category}:

+
    + { + props.fields.map(x => +
  • + +
  • ) + } +
+
+); + +export { + CategoryEditSummary +}; diff --git a/app/src/frontend/building/edit-history/edit-history.css b/app/src/frontend/building/edit-history/edit-history.css new file mode 100644 index 00000000..8aa5cfe2 --- /dev/null +++ b/app/src/frontend/building/edit-history/edit-history.css @@ -0,0 +1,8 @@ +.edit-history { + background-color: white; +} + +.edit-history-list { + list-style: none; + padding-left: 1rem; +} \ No newline at end of file diff --git a/app/src/frontend/building/edit-history/edit-history.tsx b/app/src/frontend/building/edit-history/edit-history.tsx new file mode 100644 index 00000000..772be6d3 --- /dev/null +++ b/app/src/frontend/building/edit-history/edit-history.tsx @@ -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 = (props) => { + const [history, setHistory] = useState(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 ( + <> + + +
    + {history && history.map(entry => ( +
  • + +
  • + ))} +
+ + ); +} + +export { + EditHistory +}; diff --git a/app/src/frontend/building/edit-history/field-edit-summary.tsx b/app/src/frontend/building/edit-history/field-edit-summary.tsx new file mode 100644 index 00000000..f62357bf --- /dev/null +++ b/app/src/frontend/building/edit-history/field-edit-summary.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface FieldEditSummaryProps { + title: string; + value: any; + oldValue: any; +} + +const FieldEditSummary: React.FunctionComponent = props => ( + <> + {props.title}:  + {props.oldValue} +   + {props.value} + +); + +export { + FieldEditSummary +}; diff --git a/app/src/frontend/building/header-buttons/copy-control.tsx b/app/src/frontend/building/header-buttons/copy-control.tsx new file mode 100644 index 00000000..3d6d1e60 --- /dev/null +++ b/app/src/frontend/building/header-buttons/copy-control.tsx @@ -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 = props => ( + props.copying ? + <> + + Copy selected + + + Cancel + + + : + + Copy + +); + +export { + CopyControl +}; diff --git a/app/src/frontend/building/header-buttons/view-edit-control.tsx b/app/src/frontend/building/header-buttons/view-edit-control.tsx new file mode 100644 index 00000000..1eb4801d --- /dev/null +++ b/app/src/frontend/building/header-buttons/view-edit-control.tsx @@ -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 = props => ( + (props.mode === 'edit')? + + View + + + : + Edit + + +); + +export { + ViewEditControl +}; diff --git a/app/src/frontend/building/models/edit-history-entry.ts b/app/src/frontend/building/models/edit-history-entry.ts new file mode 100644 index 00000000..a38e7e0d --- /dev/null +++ b/app/src/frontend/building/models/edit-history-entry.ts @@ -0,0 +1,7 @@ +export interface EditHistoryEntry { + date_trunc: string; + username: string; + revision_id: string; + forward_patch: object; + reverse_patch: object; +} diff --git a/app/src/frontend/building/multi-edit.tsx b/app/src/frontend/building/multi-edit.tsx index 28b675f6..a0712fc3 100644 --- a/app/src/frontend/building/multi-edit.tsx +++ b/app/src/frontend/building/multi-edit.tsx @@ -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) => { { Object.keys(data).map((key => { + const info = dataFields[key] || {}; return ( (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 +}; diff --git a/app/src/frontend/map-app.tsx b/app/src/frontend/map-app.tsx index d64266c9..7849a56d 100644 --- a/app/src/frontend/map-app.tsx +++ b/app/src/frontend/map-app.tsx @@ -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 { 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 { { /> + + + + + ()} /> diff --git a/app/src/parse.ts b/app/src/parse.ts index db2a3493..a8c680c7 100644 --- a/app/src/parse.ts +++ b/app/src/parse.ts @@ -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) {