Merge pull request #503 from mz8i/feature/499-global-edit-history

Global edit history
This commit is contained in:
Maciej Ziarkowski 2019-11-14 16:49:12 +00:00 committed by GitHub
commit 07c2fe0933
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 286 additions and 35 deletions

View File

@ -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

View 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
};

View 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
};

View File

@ -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}

View File

@ -11,4 +11,8 @@
.edit-history-username {
font-size: 0.9em;
}
.edit-history-building-id {
font-size: 0.9em;
}

View File

@ -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>
);

View File

@ -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

View File

@ -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,

View File

@ -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;

View File

@ -4,4 +4,5 @@ export interface EditHistoryEntry {
revision_id: string;
forward_patch: object;
reverse_patch: object;
building_id?: number;
}

View 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;