Merge pull request #503 from mz8i/feature/499-global-edit-history
Global edit history
This commit is contained in:
commit
07c2fe0933
@ -1,6 +1,7 @@
|
||||
import bodyParser from 'body-parser';
|
||||
import express from 'express';
|
||||
|
||||
import * as editHistoryController from './controllers/editHistoryController';
|
||||
import buildingsRouter from './routes/buildingsRouter';
|
||||
import extractsRouter from './routes/extractsRouter';
|
||||
import usersRouter from './routes/usersRouter';
|
||||
@ -17,6 +18,8 @@ server.use('/buildings', buildingsRouter);
|
||||
server.use('/users', usersRouter);
|
||||
server.use('/extracts', extractsRouter);
|
||||
|
||||
server.get('/history', editHistoryController.getGlobalEditHistory);
|
||||
|
||||
// POST user auth
|
||||
server.post('/login', function (req, res) {
|
||||
authUser(req.body.username, req.body.password).then(function (user: any) { // TODO: remove any
|
||||
|
20
app/src/api/controllers/editHistoryController.ts
Normal file
20
app/src/api/controllers/editHistoryController.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
|
||||
import asyncController from "../routes/asyncController";
|
||||
import * as editHistoryService from '../services/editHistory';
|
||||
|
||||
const getGlobalEditHistory = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const result = await editHistoryService.getGlobalEditHistory();
|
||||
res.send({
|
||||
history: result
|
||||
});
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
getGlobalEditHistory
|
||||
};
|
21
app/src/api/services/editHistory.ts
Normal file
21
app/src/api/services/editHistory.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import db from '../../db';
|
||||
|
||||
async function getGlobalEditHistory() {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username, building_id
|
||||
FROM logs, users
|
||||
WHERE logs.user_id = users.user_id
|
||||
AND log_timestamp >= now() - interval '21 days'
|
||||
ORDER BY log_timestamp DESC`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
getGlobalEditHistory
|
||||
};
|
@ -9,6 +9,7 @@ import MapApp from './map-app';
|
||||
import { Building } from './models/building';
|
||||
import { User } from './models/user';
|
||||
import AboutPage from './pages/about';
|
||||
import ChangesPage from './pages/changes';
|
||||
import ContactPage from './pages/contact';
|
||||
import ContributorAgreementPage from './pages/contributor-agreement';
|
||||
import DataAccuracyPage from './pages/data-accuracy';
|
||||
@ -112,6 +113,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<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="/history.html" component={ChangesPage} />
|
||||
<Route exact path={App.mapAppPaths} render={(props) => (
|
||||
<MapApp
|
||||
{...props}
|
||||
|
@ -11,4 +11,8 @@
|
||||
|
||||
.edit-history-username {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.edit-history-building-id {
|
||||
font-size: 0.9em;
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import './building-edit-summary.css';
|
||||
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { Category, DataFieldDefinition, dataFields } from '../../data_fields';
|
||||
import { arrayToDictionary, parseDate } from '../../helpers';
|
||||
import { EditHistoryEntry } from '../../models/edit-history-entry';
|
||||
|
||||
@ -10,6 +11,8 @@ import { CategoryEditSummary } from './category-edit-summary';
|
||||
|
||||
interface BuildingEditSummaryProps {
|
||||
historyEntry: EditHistoryEntry;
|
||||
showBuildingId?: boolean;
|
||||
hyperlinkCategories?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dt: Date) {
|
||||
@ -23,27 +26,51 @@ function formatDate(dt: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
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]
|
||||
};
|
||||
});
|
||||
function enrichHistoryEntries(forwardPatch: object, reversePatch: object) {
|
||||
return Object
|
||||
.entries(forwardPatch)
|
||||
.map(([key, value]) => {
|
||||
const info = dataFields[key] || {} as DataFieldDefinition;
|
||||
return {
|
||||
title: info.title || `Unknown field (${key})`,
|
||||
category: info.category || Category.Unknown,
|
||||
value: value,
|
||||
oldValue: reversePatch && reversePatch[key]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = ({
|
||||
historyEntry,
|
||||
showBuildingId = false,
|
||||
hyperlinkCategories = false
|
||||
}) => {
|
||||
const entriesWithMetadata = enrichHistoryEntries(historyEntry.forward_patch, historyEntry.reverse_patch);
|
||||
const entriesByCategory = arrayToDictionary(entriesWithMetadata, x => x.category);
|
||||
|
||||
const categoryHyperlinkTemplate = hyperlinkCategories && historyEntry.building_id != undefined ?
|
||||
`/edit/$category/${historyEntry.building_id}` :
|
||||
undefined;
|
||||
|
||||
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>
|
||||
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.date_trunc))}</h2>
|
||||
<h3 className="edit-history-username">By {historyEntry.username}</h3>
|
||||
{
|
||||
Object.entries(entriesByCategory).map(([category, fields]) => <CategoryEditSummary category={category} fields={fields} />)
|
||||
showBuildingId && historyEntry.building_id != undefined &&
|
||||
<h3 className="edit-history-building-id">
|
||||
Building <Link to={`/edit/categories/${historyEntry.building_id}`}>{historyEntry.building_id}</Link>
|
||||
</h3>
|
||||
}
|
||||
{
|
||||
Object.entries(entriesByCategory).map(([category, fields]) =>
|
||||
<CategoryEditSummary
|
||||
category={category as keyof typeof Category} // https://github.com/microsoft/TypeScript/issues/14106
|
||||
fields={fields}
|
||||
hyperlinkCategory={hyperlinkCategories}
|
||||
hyperlinkTemplate={categoryHyperlinkTemplate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,31 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import './category-edit-summary.css';
|
||||
|
||||
import { categories, Category } from '../../data_fields';
|
||||
|
||||
import { FieldEditSummary } from './field-edit-summary';
|
||||
|
||||
interface CategoryEditSummaryProps {
|
||||
category: string;
|
||||
category: keyof typeof Category; // https://github.com/microsoft/TypeScript/issues/14106
|
||||
fields: {
|
||||
title: string;
|
||||
value: string;
|
||||
oldValue: string;
|
||||
value: any;
|
||||
oldValue: any;
|
||||
}[];
|
||||
hyperlinkCategory: boolean;
|
||||
hyperlinkTemplate?: 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>
|
||||
);
|
||||
const CategoryEditSummary : React.FunctionComponent<CategoryEditSummaryProps> = props => {
|
||||
const category = Category[props.category];
|
||||
const categoryInfo = categories[category] || {name: undefined, slug: undefined};
|
||||
const categoryName = categoryInfo.name || 'Unknown category';
|
||||
const categorySlug = categoryInfo.slug || 'categories';
|
||||
|
||||
return (
|
||||
<div className='edit-history-category-summary'>
|
||||
<h3 className='edit-history-category-title'>
|
||||
{
|
||||
props.hyperlinkCategory && props.hyperlinkTemplate != undefined ?
|
||||
<Link to={props.hyperlinkTemplate.replace(/\$category/, categorySlug)}>{categoryName}</Link> :
|
||||
categoryName
|
||||
}:
|
||||
</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
|
||||
|
@ -1,18 +1,71 @@
|
||||
export enum Category {
|
||||
Location = 'Location',
|
||||
LandUse = 'Land Use',
|
||||
LandUse = 'LandUse',
|
||||
Type = 'Type',
|
||||
Age = 'Age',
|
||||
SizeShape = 'Size & Shape',
|
||||
SizeShape = 'SizeShape',
|
||||
Construction = 'Construction',
|
||||
Streetscape = 'Streetscape',
|
||||
Team = 'Team',
|
||||
Sustainability = 'Sustainability',
|
||||
Community = 'Community',
|
||||
Planning = 'Planning',
|
||||
Like = 'Like Me!'
|
||||
Like = 'Like',
|
||||
|
||||
Unknown = 'Unknown'
|
||||
}
|
||||
|
||||
export const categories = {
|
||||
[Category.Location]: {
|
||||
slug: 'location',
|
||||
name: 'Location'
|
||||
},
|
||||
[Category.LandUse]: {
|
||||
slug: 'use',
|
||||
name: 'Land Use'
|
||||
},
|
||||
[Category.Type]: {
|
||||
slug: 'type',
|
||||
name: 'Type'
|
||||
},
|
||||
[Category.Age]: {
|
||||
slug: 'age',
|
||||
name: 'Age'
|
||||
},
|
||||
[Category.SizeShape]: {
|
||||
slug: 'size',
|
||||
name: 'Size & Shape'
|
||||
},
|
||||
[Category.Construction]: {
|
||||
slug: 'construction',
|
||||
name: 'Construction'
|
||||
},
|
||||
[Category.Streetscape]: {
|
||||
slug: 'streetscape',
|
||||
name: 'Streetscape'
|
||||
},
|
||||
[Category.Team]: {
|
||||
slug: 'team',
|
||||
name: 'Team'
|
||||
},
|
||||
[Category.Sustainability]: {
|
||||
slug: 'sustainability',
|
||||
name: 'Sustainability'
|
||||
},
|
||||
[Category.Community]: {
|
||||
slug: 'community',
|
||||
name: 'Community'
|
||||
},
|
||||
[Category.Planning]: {
|
||||
slug: 'planning',
|
||||
name: 'Planning'
|
||||
},
|
||||
[Category.Like]: {
|
||||
slug: 'like',
|
||||
name: 'Like Me!'
|
||||
}
|
||||
};
|
||||
|
||||
export const categoriesOrder: Category[] = [
|
||||
Category.Location,
|
||||
Category.LandUse,
|
||||
@ -28,6 +81,18 @@ export const categoriesOrder: Category[] = [
|
||||
Category.Like,
|
||||
];
|
||||
|
||||
/**
|
||||
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
|
||||
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
|
||||
* because then we wouldn't have type-checking for whether a given key exists on dataFields,
|
||||
* e.g. dataFields.foo_bar would not be highlighted as an error.
|
||||
*/
|
||||
export interface DataFieldDefinition {
|
||||
category: Category;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const dataFields = {
|
||||
location_name: {
|
||||
category: Category.Location,
|
||||
|
@ -2,6 +2,8 @@ import { parse } from 'query-string';
|
||||
import React, { Fragment } from 'react';
|
||||
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
|
||||
|
||||
import { strictParseInt } from '../parse';
|
||||
|
||||
import BuildingView from './building/building-view';
|
||||
import Categories from './building/categories';
|
||||
import { EditHistory } from './building/edit-history/edit-history';
|
||||
@ -56,6 +58,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLatestRevision();
|
||||
this.fetchBuildingData();
|
||||
}
|
||||
|
||||
async fetchLatestRevision() {
|
||||
@ -75,6 +78,52 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches building data if a building is selected but no data provided through
|
||||
* props (from server-side rendering)
|
||||
*/
|
||||
async fetchBuildingData() {
|
||||
if(this.props.match.params.building != undefined && this.props.building == undefined) {
|
||||
try {
|
||||
// TODO: simplify API calls, create helpers for fetching data
|
||||
const buildingId = strictParseInt(this.props.match.params.building);
|
||||
let [building, building_uprns, building_like] = await Promise.all([
|
||||
fetch(`/api/buildings/${buildingId}.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/buildings/${buildingId}/uprns.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/buildings/${buildingId}/like.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json())
|
||||
]);
|
||||
|
||||
building.uprns = building_uprns.uprns;
|
||||
|
||||
this.setState({
|
||||
building: building,
|
||||
building_like: building_like.like
|
||||
});
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
// TODO: add UI for API errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCategory(category: string) {
|
||||
if (category === 'categories') return undefined;
|
||||
|
||||
|
@ -4,4 +4,5 @@ export interface EditHistoryEntry {
|
||||
revision_id: string;
|
||||
forward_patch: object;
|
||||
reverse_patch: object;
|
||||
building_id?: number;
|
||||
}
|
||||
|
41
app/src/frontend/pages/changes.tsx
Normal file
41
app/src/frontend/pages/changes.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { BuildingEditSummary } from '../building/edit-history/building-edit-summary';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
|
||||
const ChangesPage = () => {
|
||||
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const res = await fetch(`/api/history`);
|
||||
const data = await res.json();
|
||||
|
||||
setHistory(data.history);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1>Global edit history</h1>
|
||||
|
||||
<ul className="edit-history-list">
|
||||
{history && history.map(entry => (
|
||||
<li key={`${entry.revision_id}`} className="edit-history-list-element">
|
||||
<BuildingEditSummary
|
||||
historyEntry={entry}
|
||||
showBuildingId={true}
|
||||
hyperlinkCategories={true}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangesPage;
|
Loading…
Reference in New Issue
Block a user