Merge branch 'develop'

This commit is contained in:
Maciej Ziarkowski 2020-02-09 18:11:22 +00:00
commit f68343f03e
20 changed files with 776 additions and 50 deletions

155
app/public/openapi.yml Normal file
View File

@ -0,0 +1,155 @@
openapi: 3.0.0
info:
title: Colouring London API
version: 1.0.0
servers:
- url: https://colouring.london/api
description: Production server (uses live data)
paths:
/extracts:
get:
summary: Returns a list of bulk data extracts
responses:
'200':
description: A list of bulk extracts, from newest to oldest
content:
application/json:
schema:
properties:
extracts:
type: array
items:
$ref: '#/components/schemas/BulkExtract'
example:
extracts:
- extract_id: 1
extracted_on: 2019-10-03T05:33:00.000Z
download_path: /downloads/data-extract-2019-10-03-06_33_00.zip
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/ServerError'
/history:
get:
summary: Returns a paginated list of edits (latest edits if no relevant parameters specified)
parameters:
- name: before_id
description: Returned edits will be ones made directly before the specified revision ID
in: query
schema:
$ref: '#/components/schemas/RevisionId'
required: false
- name: after_id
description: Returned edits will be ones made directly after the specified revision ID
in: query
schema:
$ref: '#/components/schemas/RevisionId'
required: false
- name: count
description: The desired number of records to return
in: query
schema:
type: number
minimum: 1
maximum: 100
default: 100
required: false
responses:
'200':
description: A list of edit history records
content:
application/json:
schema:
properties:
history:
type: array
items:
$ref: '#/components/schemas/BuildingEditHistoryEntry'
paging:
type: object
properties:
id_for_older_query:
allOf:
- $ref: '#/components/schemas/RevisionId'
- description: If older records exist - ID to use for querying them (use as before_id param), otherwise null
nullable: true
id_for_newer_query:
allOf:
- $ref: '#/components/schemas/RevisionId'
- description: If newer records exist - ID to use for querying them (use as after_id param), otherwise null
nullable: true
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/ServerError'
components:
responses:
BadRequest:
description: Invalid request submitted by user
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ServerError:
description: Unexpected server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error: Database error
schemas:
Error:
type: object
properties:
error:
type: string
description: Error message
BulkExtract:
type: object
properties:
extract_id:
type: integer
description: Unique sequential ID for the extract
extracted_on:
type: string
format: date-time
description: UTC timestamp at which the extract was generated
download_path:
type: string
description: Download path for the extract. Contains only URL path (should be used with the same hostname as the API).
RevisionId:
description: Unique sequential ID for an edit history entry (positive big integer)
type: string
pattern: ^[1-9]\d*&
BuildingEditHistoryEntry:
type: object
properties:
revision_id:
$ref: '#/components/schemas/RevisionId'
forward_patch:
type: object
description: Forward diff of the building attribute data
reverse_patch:
type: object
description: Reverse diff of the building attribute data
revision_timestamp:
type: string
format: date-time
description: UTC timestamp at which the building data was edited
username:
type: string
description: Username of the editor
building_id:
type: number
description: Unique ID of the edited building

View File

@ -2,6 +2,8 @@ import bodyParser from 'body-parser';
import express from 'express';
import * as editHistoryController from './controllers/editHistoryController';
import { ApiParamError, ApiUserError } from './errors/api';
import { DatabaseError } from './errors/general';
import buildingsRouter from './routes/buildingsRouter';
import extractsRouter from './routes/extractsRouter';
import usersRouter from './routes/usersRouter';
@ -93,14 +95,30 @@ server.get('/search', function (req, res) {
});
});
server.use((err, req, res, next) => {
server.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (res.headersSent) {
return next(err);
}
if (err != undefined) {
console.log('Global error handler: ', err);
res.status(500).send({ error: 'Server error' });
if (err instanceof ApiUserError) {
let errorMessage: string;
if(err instanceof ApiParamError) {
errorMessage = `Problem with parameter ${err.paramName}: ${err.message}`;
} else {
errorMessage = err.message;
}
res.status(400).send({ error: errorMessage });
} else if(err instanceof DatabaseError){
res.status(500).send({ error: 'Database error' });
} else {
res.status(500).send({ error: 'Server error' });
}
}
});

View File

@ -1,6 +1,6 @@
import express from 'express';
import { parseIntParam } from '../helpers';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building';
import * as userService from '../services/user';
@ -35,7 +35,7 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
// GET individual building, POST building updates
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingById(buildingId);
@ -63,7 +63,7 @@ const updateBuildingById = asyncController(async (req: express.Request, res: exp
});
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const buildingUpdate = req.body;
@ -84,7 +84,7 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
// GET building UPRNs
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingUPRNsById(buildingId);
@ -105,7 +105,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
return res.send({ like: false }); // not logged in, so cannot have liked
}
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
@ -118,7 +118,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
});
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const editHistory = await buildingService.getBuildingEditHistory(buildingId);
@ -134,7 +134,7 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
return res.send({ error: 'Must be logged in' });
}
const buildingId = parseIntParam(req.params.building_id);
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body;
try {

View File

@ -1,17 +1,32 @@
import express from 'express';
import { ApiParamError, ApiUserError } from '../errors/api';
import { ArgumentError } from '../errors/general';
import { checkRegexParam, parsePositiveIntParam, processParam } from '../parameters';
import asyncController from "../routes/asyncController";
import * as editHistoryService from '../services/editHistory';
const getGlobalEditHistory = asyncController(async (req: express.Request, res: express.Response) => {
const revisionIdRegex = /^[1-9]\d*$/;
const afterId: string = processParam(req.query, 'after_id', x => checkRegexParam(x, revisionIdRegex));
const beforeId: string = processParam(req.query, 'before_id', x => checkRegexParam(x, revisionIdRegex));
const count: number = processParam(req.query, 'count', parsePositiveIntParam);
if(afterId != undefined && beforeId != undefined) {
throw new ApiUserError('Cannot specify both after_id and before_id parameters');
}
try {
const result = await editHistoryService.getGlobalEditHistory();
res.send({
history: result
});
const result = await editHistoryService.getGlobalEditHistory(beforeId, afterId, count);
res.send(result);
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
if(error instanceof ArgumentError && error.argumentName === 'count') {
const apiErr = new ApiParamError(error.message, 'count');
throw apiErr;
}
throw error;
}
});

View File

@ -1,6 +1,6 @@
import express from 'express';
import { parseIntParam } from '../helpers';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as dataExtractService from '../services/dataExtract';
@ -15,8 +15,9 @@ const getAllDataExtracts = asyncController(async function(req: express.Request,
});
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
const extractId = processParam(req.params, 'extract_id', parsePositiveIntParam, true);
try {
const extractId = parseIntParam(req.params.extract_id);
const extract = await dataExtractService.getDataExtractById(extractId);
res.send({ extract: extract });
} catch (err) {

View File

@ -0,0 +1,62 @@
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
import { numAsc, numDesc } from '../../../helpers';
/**
* Create an object mocking all method of editHistory dataAccess
* The type is set to reflect the type of that module, with added methods
* used when testing
*/
const mockEditHistory =
jest.genMockFromModule('../editHistory') as typeof import('../editHistory') & {
__setHistory: (mockHistoryData: EditHistoryEntry[]) => void
};
let mockData: EditHistoryEntry[] = [];
mockEditHistory.__setHistory = function(mockHistoryData: EditHistoryEntry[]) {
mockData = mockHistoryData.sort(numDesc(x => BigInt(x.revision_id)));
};
mockEditHistory.getHistoryAfterId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
return Promise.resolve(
mockData
.filter(x => BigInt(x.revision_id) > BigInt(id))
.sort(numAsc(x => BigInt(x.revision_id)))
.slice(0, count)
.sort(numDesc(x => BigInt(x.revision_id)))
);
};
mockEditHistory.getHistoryBeforeId = function(id: string, count: number): Promise<EditHistoryEntry[]> {
let filteredData = id == undefined ? mockData : mockData.filter(x => BigInt(x.revision_id) < BigInt(id));
return Promise.resolve(
filteredData
.slice(0, count)
);
};
mockEditHistory.getIdNewerThan = async function(id: string): Promise<string> {
const historyAfterId = await mockEditHistory.getHistoryAfterId(id, 1);
return historyAfterId[historyAfterId.length - 1]?.revision_id;
};
mockEditHistory.getIdOlderThan = async function(id: string): Promise<string> {
const historyBeforeId = await mockEditHistory.getHistoryBeforeId(id, 1);
return historyBeforeId[0]?.revision_id;
};
const {
__setHistory,
getHistoryAfterId,
getHistoryBeforeId,
getIdNewerThan,
getIdOlderThan
} = mockEditHistory;
export {
__setHistory,
getHistoryAfterId,
getHistoryBeforeId,
getIdNewerThan,
getIdOlderThan
};

View File

@ -0,0 +1,88 @@
import db from '../../db';
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
import { DatabaseError } from '../errors/general';
const baseQuery = `
SELECT
log_id as revision_id,
forward_patch,
reverse_patch,
date_trunc('minute', log_timestamp) as revision_timestamp,
username,
building_id
FROM logs
JOIN users ON logs.user_id = users.user_id`;
export function getHistoryAfterId(id: string, count: number): Promise<EditHistoryEntry[]> {
/**
* SQL with lower time bound specified (records after ID).
* The outer SELECT is so that final results are sorted by descending ID
* (like the other queries). The inner select is sorted in ascending order
* so that the right rows are returned when limiting the result set.
*/
try {
return db.any(`
SELECT * FROM (
${baseQuery}
WHERE log_id > $1
ORDER BY revision_id ASC
LIMIT $2
) AS result_asc ORDER BY revision_id DESC`,
[id, count]
);
} catch(err) {
throw new DatabaseError(err);
}
}
export function getHistoryBeforeId(id: string, count: number): Promise<EditHistoryEntry[]> {
try {
if(id == undefined) {
return db.any(`
${baseQuery}
ORDER BY revision_id DESC
LIMIT $1
`, [count]);
} else {
return db.any(`
${baseQuery}
WHERE log_id < $1
ORDER BY revision_id DESC
LIMIT $2
`, [id, count]);
}
} catch(err) {
throw new DatabaseError(err);
}
}
export async function getIdOlderThan(id: string): Promise<string> {
try {
const result = await db.oneOrNone<{revision_id:string}>(`
SELECT MAX(log_id) as revision_id
FROM logs
WHERE log_id < $1
`, [id]);
return result?.revision_id;
} catch(err) {
throw new DatabaseError(err);
}
}
export async function getIdNewerThan(id: string): Promise<string> {
try {
const result = await db.oneOrNone<{revision_id:string}>(`
SELECT MIN(log_id) as revision_id
FROM logs
WHERE log_id > $1
`, [id]);
return result?.revision_id;
} catch(err) {
throw new DatabaseError(err);
}
}

44
app/src/api/errors/api.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* Note that custom errors and the instanceof operator in TS work together
* only when transpiling to ES2015 and up.
* For earier target versions (ES5), a workaround is required:
* https://stackoverflow.com/questions/41102060/typescript-extending-error-class
*/
export class ApiUserError extends Error {
constructor(message?: string) {
super(message);
this.name = 'ApiUserError';
}
}
export class ApiParamError extends ApiUserError {
public paramName: string;
constructor(message?: string, paramName?: string) {
super(message);
this.name = 'ApiParamError';
this.paramName = paramName;
}
}
export class ApiParamRequiredError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamRequiredError';
}
}
export class ApiParamOutOfBoundsError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamOutOfBoundsError';
}
}
export class ApiParamInvalidFormatError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamInvalidFormatError';
}
}

View File

@ -0,0 +1,17 @@
export class ArgumentError extends Error {
public argumentName: string;
constructor(message?: string, argumentName?: string) {
super(message);
this.name = 'ArgumentError';
this.argumentName = argumentName;
}
}
export class DatabaseError extends Error {
public detail: any;
constructor(detail?: string) {
super();
this.name = 'DatabaseError';
this.detail = detail;
}
}

View File

@ -1,9 +0,0 @@
import { strictParseInt } from '../parse';
export function parseIntParam(param: string) {
const result = strictParseInt(param);
if (isNaN(result)) {
throw new Error('Invalid parameter format: not an integer');
}
return result;
}

44
app/src/api/parameters.ts Normal file
View File

@ -0,0 +1,44 @@
import { strictParseInt } from '../parse';
import { ApiParamError, ApiParamInvalidFormatError, ApiParamRequiredError } from './errors/api';
export function processParam<T>(params: object, paramName: string, processingFn: (x: string) => T, required: boolean = false) {
const stringValue = params[paramName];
if(stringValue == undefined && required) {
const err = new ApiParamRequiredError('Parameter required but not supplied');
err.paramName = paramName;
throw err;
}
try {
return processingFn(stringValue);
} catch(error) {
if(error instanceof ApiParamError) {
error.paramName = paramName;
}
throw error;
}
}
export function parsePositiveIntParam(param: string) {
if(param == undefined) return undefined;
const result = strictParseInt(param);
if (isNaN(result)) {
throw new ApiParamInvalidFormatError('Invalid format: not a positive integer');
}
return result;
}
export function checkRegexParam(param: string, regex: RegExp): string {
if(param == undefined) return undefined;
if(param.match(regex) == undefined) {
throw new ApiParamInvalidFormatError(`Invalid format: does not match regular expression ${regex}`);
}
return param;
}

View File

@ -0,0 +1,176 @@
import { EditHistoryEntry } from '../../../frontend/models/edit-history-entry';
import * as editHistoryData from '../../dataAccess/editHistory'; // manually mocked
import { ArgumentError } from '../../errors/general';
import { getGlobalEditHistory } from '../editHistory';
jest.mock('../../dataAccess/editHistory');
const mockedEditHistoryData = editHistoryData as typeof import('../../dataAccess/__mocks__/editHistory');
function generateHistory(n: number, firstId: number = 100) {
return [...Array(n).keys()].map<EditHistoryEntry>(i => ({
revision_id: (firstId + i) + '',
revision_timestamp: new Date(2019, 10, 1, 17, 20 + i).toISOString(),
username: 'testuser',
building_id: 1234567,
forward_patch: {},
reverse_patch: {}
}));
}
describe('getGlobalEditHistory()', () => {
beforeEach(() => mockedEditHistoryData.__setHistory(generateHistory(20)));
afterEach(() => jest.clearAllMocks());
it.each([
[null, null],
['100', null],
[null, '100']
])('Should error when requesting non-positive number of records', async (beforeId: string, afterId: string) => {
let resultPromise = getGlobalEditHistory(beforeId, afterId, 0);
await expect(resultPromise).rejects.toBeInstanceOf(ArgumentError);
await expect(resultPromise).rejects.toHaveProperty('argumentName', 'count');
});
describe('getting history before a point', () => {
it('should return latest history if no ID specified', async () => {
const result = await getGlobalEditHistory(null, null, 5);
expect(result.history.map(x => x.revision_id)).toEqual(['119', '118', '117', '116', '115']);
});
it.each(
[
[null, 3, ['119', '118', '117']],
[null, 6, ['119', '118', '117', '116', '115', '114']],
['118', 1, ['117']],
['104', 10, ['103', '102','101', '100']],
['100', 2, []]
]
)('should return the N records before the specified ID in descending order [beforeId: %p, count: %p]', async (
beforeId: string, count: number, ids: string[]
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.history.map(h => h.revision_id)).toEqual(ids);
});
it.each([
[null, 4, null],
[null, 10, null],
[null, 20, null],
[null, 30, null],
['50', 10, '99'],
['100', 10, '99'],
['130', 10, null],
['105', 2, '104'],
['120', 20, null],
])('should detect if there are any newer records left [beforeId: %p, count: %p]', async (
beforeId: string, count: number, idForNewerQuery: string
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
});
it.each([
[null, 4, '116'],
[null, 10, '110'],
[null, 20, null],
[null, 30, null],
['50', 10, null],
['100', 10, null],
['130', 10, '110'],
['105', 2, '103'],
['120', 20, null],
])('should detect if there are any older records left [beforeId: %p, count: %p]', async (
beforeId: string, count: number, idForOlderQuery: string
) => {
const result = await getGlobalEditHistory(beforeId, null, count);
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
});
});
describe('getting history after a point', () => {
it.each([
['100', 7, ['107', '106', '105', '104', '103', '102', '101']],
['115', 3, ['118', '117', '116']],
['120', 10, []]
])('should return N records after requested ID in descending order [afterId: %p, count: %p]', async (
afterId: string, count: number, expected: string[]
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.history.map(x => x.revision_id)).toEqual(expected);
});
it.each([
['99', 10, '109'],
['110', 5, '115'],
['119', 20, null],
['99', 20, null],
])('should detect if there are any newer records left [afterId: %p, count: %p]', async (
afterId: string, count: number, idForNewerQuery: string
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.paging.id_for_newer_query).toBe(idForNewerQuery);
});
it.each([
['99', 10, null],
['110', 5, '111'],
['119', 20, '120'],
['99', 20, null],
])('should detect if there are any older records left [afterId: %p, count: %p]', async (
afterId: string, count: number, idForOlderQuery: string
) => {
const result = await getGlobalEditHistory(null, afterId, count);
expect(result.paging.id_for_older_query).toBe(idForOlderQuery);
});
});
describe('result count limit', () => {
it.each([
[null, null],
[null, '100'],
['300', null]
])('should not return more than 100 entries (beforeId: %p, afterId: %p)', async (
beforeId: string, afterId: string
) => {
mockedEditHistoryData.__setHistory(
generateHistory(200)
);
const result = await getGlobalEditHistory(beforeId, afterId, 200);
expect(result.history.length).toBe(100);
});
it.each([
[null, null],
[null, '100'],
['300', null]
])('should default to 100 entries', async (
beforeId: string, afterId: string
) => {
mockedEditHistoryData.__setHistory(
generateHistory(200)
);
const result = await getGlobalEditHistory(beforeId, afterId);
expect(result.history.length).toBe(100);
});
});
});

View File

@ -118,7 +118,7 @@ async function getBuildingById(id: number) {
async function getBuildingEditHistory(id: number) {
try {
return await db.manyOrNone(
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp) as revision_timestamp, username
FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id
ORDER BY log_timestamp DESC`,

View File

@ -1,20 +1,38 @@
import db from '../../db';
import { EditHistoryEntry } from '../../frontend/models/edit-history-entry';
import { decBigInt, incBigInt } from '../../helpers';
import { getHistoryAfterId, getHistoryBeforeId, getIdNewerThan, getIdOlderThan } from '../dataAccess/editHistory';
import { ArgumentError } from '../errors/general';
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 '7 days'
ORDER BY log_timestamp DESC`
);
} catch (error) {
console.error(error);
return [];
async function getGlobalEditHistory(beforeId?: string, afterId?: string, count: number = 100) {
if(count <= 0) throw new ArgumentError('cannot request less than 1 history record', 'count');
if(count > 100) count = 100;
// limited set of records. Expected to be already ordered from newest to oldest
let editHistoryRecords: EditHistoryEntry[];
if(afterId != undefined) {
editHistoryRecords = await getHistoryAfterId(afterId, count);
} else {
editHistoryRecords = await getHistoryBeforeId(beforeId, count);
}
}
const currentBatchMaxId = editHistoryRecords[0]?.revision_id ?? decBigInt(beforeId);
const newer = currentBatchMaxId && await getIdNewerThan(currentBatchMaxId);
const currentBatchMinId = editHistoryRecords[editHistoryRecords.length-1]?.revision_id ?? incBigInt(afterId);
const older = currentBatchMinId && await getIdOlderThan(currentBatchMinId);
const idForOlderQuery = older != undefined ? incBigInt(older) : null;
const idForNewerQuery = newer != undefined ? decBigInt(newer) : null;
return {
history: editHistoryRecords,
paging: {
id_for_newer_query: idForNewerQuery,
id_for_older_query: idForOlderQuery
}
};
}
export {
getGlobalEditHistory

View File

@ -54,7 +54,7 @@ const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = (
return (
<div className="edit-history-entry">
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.date_trunc))}</h2>
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.revision_timestamp))}</h2>
<h3 className="edit-history-username">By {historyEntry.username}</h3>
{
showBuildingId && historyEntry.building_id != undefined &&

View File

@ -1,5 +1,5 @@
export interface EditHistoryEntry {
date_trunc: string;
revision_timestamp: string;
username: string;
revision_id: string;
forward_patch: object;

View File

@ -0,0 +1,7 @@
.edit-history-link {
margin-top: 4em;
}
.edit-history-latest-link {
float: right;
}

View File

@ -1,31 +1,88 @@
import { parse } from 'query-string';
import React, { useEffect, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import './changes.css';
import { apiGet } from '../apiHelpers';
import { BuildingEditSummary } from '../building/edit-history/building-edit-summary';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { EditHistoryEntry } from '../models/edit-history-entry';
const ChangesPage = () => {
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
interface PagingInfo {
id_for_older_query: string;
id_for_newer_query: string;
}
const recordsPerPage = 20;
const ChangesPage = (props: RouteComponentProps) => {
const { after_id, before_id } = parse(props.location.search);
const [history, setHistory] = useState<EditHistoryEntry[]>();
const [paging, setPaging] = useState<PagingInfo>();
const [error, setError] = useState();
useEffect(() => {
const fetchData = async () => {
const {history} = await apiGet(`/api/history`);
setHistory(null);
setPaging(null);
let url = `/api/history?count=${recordsPerPage}`;
if(after_id) {
url = `${url}&after_id=${after_id}`;
}
if (before_id) {
url = `${url}&before_id=${before_id}`;
}
try {
const {history, paging, error} = await apiGet(url);
if(error) {
setError(error);
} else {
setHistory(history);
setPaging(paging);
}
} catch (err) {
setError('Connection problem. Please try again later...');
}
setHistory(history);
};
fetchData();
}, []);
}, [props.location.search]);
return (
<article>
<section className="main-col">
<h1>Global edit history</h1>
{
paging?.id_for_newer_query &&
<Link className='edit-history-link' to={`?after_id=${paging.id_for_newer_query}`}>Show more recent changes</Link>
}
{
(after_id || before_id) &&
<Link className='edit-history-latest-link' to='?'>Show latest changes</Link>
}
<ul className="edit-history-list">
{(history == undefined || history.length == 0) ?
<InfoBox msg="No changes in the last week"></InfoBox> :
{
error &&
<ErrorBox msg={error}></ErrorBox>
}
{
error == undefined && history == undefined &&
<InfoBox msg="Loading history..."></InfoBox>
}
{
(history?.length === 0) &&
<InfoBox msg="No changes so far"></InfoBox>
}
{
(history != undefined && history.length > 0) &&
history.map(entry => (
<li key={`${entry.revision_id}`} className="edit-history-list-element">
<BuildingEditSummary
@ -37,6 +94,10 @@ const ChangesPage = () => {
))
}
</ul>
{
paging?.id_for_older_query &&
<Link to={`?before_id=${paging.id_for_older_query}`}>Show older changes</Link>
}
</section>
</article>
);

View File

@ -49,6 +49,11 @@ article .color-block {
padding-left: 1em;
padding-right: 1em;
}
@media(min-width: 768px) {
.main-col {
min-width: 48em;
}
}
hr {
display: block;
height: 1px;

View File

@ -34,3 +34,27 @@ export function isNullishOrEmpty(obj: any) {
export function isEmptyArray(obj: any) {
return Array.isArray(obj) && obj.length === 0;
}
type AccessorFunction<T, V> = (obj: T) => V;
type CompareFunction<T> = (a: T, b: T) => number;
export function numAsc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T>{
return (a: T, b: T) => Number(accessor(a) - accessor(b));
}
export function numDesc<T, V extends number | bigint>(accessor: AccessorFunction<T, V>): CompareFunction<T> {
return (a: T, b: T) => Number(accessor(b) - accessor(a));
}
/**
* As of Jan 2020, bigint literals e.g. 1n can only be used with TS target esnext
* which then doesn't transpile the null conditional/coalescing operators and breaks on Node 12
* So BigInt(1) needs to be used here
* */
export function incBigInt(bigStr: string): string {
return bigStr == undefined ? bigStr : String(BigInt(bigStr) + BigInt(1));
}
export function decBigInt(bigStr: string): string {
return bigStr == undefined ? bigStr : String(BigInt(bigStr) - BigInt(1));
}