Merge branch 'master' into features/migrations_sustainability

This commit is contained in:
Tom Russell 2020-04-09 10:47:55 +01:00 committed by GitHub
commit 5d8a0dd42b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
210 changed files with 11597 additions and 5454 deletions

View File

@ -69,6 +69,16 @@
"contributions": [
"code"
]
},
{
"login": "mz8i",
"name": "mz8i",
"avatar_url": "https://avatars2.githubusercontent.com/u/36160844?v=4",
"profile": "https://github.com/mz8i",
"contributions": [
"code",
"ideas"
]
}
],
"contributorsPerLine": 7

2
.gitignore vendored
View File

@ -18,6 +18,8 @@ etl/**/*.xls
etl/**/*.xlsx
etl/**/*.zip
.DS_Store
# Cache
app/tilecache/**/*.png
app/tilecache/**/*.mbtiles

View File

@ -1,6 +1,6 @@
language: node_js
node_js:
- 8
- 12
cache: npm
before_script:
- cd $TRAVIS_BUILD_DIR/app && npm ci

View File

@ -1,5 +1,5 @@
# Colouring London
[![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors)
[![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors)
[![Build Status](https://travis-ci.com/tomalrussell/colouring-london.svg?branch=master)](https://travis-ci.com/tomalrussell/colouring-london)
How many buildings are there in London? What are their characteristics? Where
@ -76,7 +76,17 @@ Thanks goes to these wonderful people ([emoji key](https://github.com/all-contri
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore -->
<table><tr><td align="center"><a href="https://github.com/polly64"><img src="https://avatars3.githubusercontent.com/u/42236514?v=4" width="100px;" alt="polly64"/><br /><sub><b>polly64</b></sub></a><br /><a href="#design-polly64" title="Design">🎨</a> <a href="#ideas-polly64" title="Ideas, Planning, & Feedback">🤔</a> <a href="#content-polly64" title="Content">🖋</a> <a href="#fundingFinding-polly64" title="Funding Finding">🔍</a></td><td align="center"><a href="https://github.com/tomalrussell"><img src="https://avatars2.githubusercontent.com/u/2762769?v=4" width="100px;" alt="Tom Russell"/><br /><sub><b>Tom Russell</b></sub></a><br /><a href="#design-tomalrussell" title="Design">🎨</a> <a href="#ideas-tomalrussell" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/tomalrussell/colouring-london/commits?author=tomalrussell" title="Code">💻</a> <a href="https://github.com/tomalrussell/colouring-london/commits?author=tomalrussell" title="Documentation">📖</a></td><td align="center"><a href="https://dghumphrey.co.uk/"><img src="https://avatars0.githubusercontent.com/u/6041913?v=4" width="100px;" alt="dominic"/><br /><sub><b>dominic</b></sub></a><br /><a href="#ideas-dominijk" title="Ideas, Planning, & Feedback">🤔</a> <a href="#content-dominijk" title="Content">🖋</a></td><td align="center"><a href="https://github.com/adamdennett"><img src="https://avatars1.githubusercontent.com/u/5138911?v=4" width="100px;" alt="Adam Dennett"/><br /><sub><b>Adam Dennett</b></sub></a><br /><a href="#ideas-adamdennett" title="Ideas, Planning, & Feedback">🤔</a></td><td align="center"><a href="https://github.com/duncan2001"><img src="https://avatars1.githubusercontent.com/u/19817528?v=4" width="100px;" alt="Duncan Smith"/><br /><sub><b>Duncan Smith</b></sub></a><br /><a href="#ideas-duncan2001" title="Ideas, Planning, & Feedback">🤔</a></td><td align="center"><a href="https://github.com/martin-dj"><img src="https://avatars2.githubusercontent.com/u/7262550?v=4" width="100px;" alt="martin-dj"/><br /><sub><b>martin-dj</b></sub></a><br /><a href="https://github.com/tomalrussell/colouring-london/commits?author=martin-dj" title="Code">💻</a></td></tr></table>
<table>
<tr>
<td align="center"><a href="https://github.com/polly64"><img src="https://avatars3.githubusercontent.com/u/42236514?v=4" width="100px;" alt="polly64"/><br /><sub><b>polly64</b></sub></a><br /><a href="#design-polly64" title="Design">🎨</a> <a href="#ideas-polly64" title="Ideas, Planning, & Feedback">🤔</a> <a href="#content-polly64" title="Content">🖋</a> <a href="#fundingFinding-polly64" title="Funding Finding">🔍</a></td>
<td align="center"><a href="https://github.com/tomalrussell"><img src="https://avatars2.githubusercontent.com/u/2762769?v=4" width="100px;" alt="Tom Russell"/><br /><sub><b>Tom Russell</b></sub></a><br /><a href="#design-tomalrussell" title="Design">🎨</a> <a href="#ideas-tomalrussell" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/tomalrussell/colouring-london/commits?author=tomalrussell" title="Code">💻</a> <a href="https://github.com/tomalrussell/colouring-london/commits?author=tomalrussell" title="Documentation">📖</a></td>
<td align="center"><a href="https://dghumphrey.co.uk/"><img src="https://avatars0.githubusercontent.com/u/6041913?v=4" width="100px;" alt="dominic"/><br /><sub><b>dominic</b></sub></a><br /><a href="#ideas-dominijk" title="Ideas, Planning, & Feedback">🤔</a> <a href="#content-dominijk" title="Content">🖋</a></td>
<td align="center"><a href="https://github.com/adamdennett"><img src="https://avatars1.githubusercontent.com/u/5138911?v=4" width="100px;" alt="Adam Dennett"/><br /><sub><b>Adam Dennett</b></sub></a><br /><a href="#ideas-adamdennett" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/duncan2001"><img src="https://avatars1.githubusercontent.com/u/19817528?v=4" width="100px;" alt="Duncan Smith"/><br /><sub><b>Duncan Smith</b></sub></a><br /><a href="#ideas-duncan2001" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/martin-dj"><img src="https://avatars2.githubusercontent.com/u/7262550?v=4" width="100px;" alt="martin-dj"/><br /><sub><b>martin-dj</b></sub></a><br /><a href="https://github.com/tomalrussell/colouring-london/commits?author=martin-dj" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/mz8i"><img src="https://avatars2.githubusercontent.com/u/36160844?v=4" width="100px;" alt="mz8i"/><br /><sub><b>mz8i</b></sub></a><br /><a href="https://github.com/tomalrussell/colouring-london/commits?author=mz8i" title="Code">💻</a> <a href="#ideas-mz8i" title="Ideas, Planning, & Feedback">🤔</a></td>
</tr>
</table>
<!-- ALL-CONTRIBUTORS-LIST:END -->

View File

@ -35,6 +35,12 @@
</Style>
<Style name="highlight">
<Rule>
<Filter>[base_layer] = 'location' or [base_layer] = 'conservation_area'</Filter>
<LineSymbolizer stroke="#ff0000aa" stroke-width="4.5" />
<LineSymbolizer stroke="#ff0000ff" stroke-width="2.5" />
</Rule>
<Rule>
<ElseFilter />
<LineSymbolizer stroke="#00ffffaa" stroke-width="4.5" />
<LineSymbolizer stroke="#00ffffff" stroke-width="2.5" />
</Rule>
@ -83,6 +89,40 @@
<PolygonSymbolizer fill="#800026" />
</Rule>
</Style>
<Style name="size_height">
<Rule>
<Filter>[size_height] &lt; 5.55</Filter>
<PolygonSymbolizer fill="#f7f4f9" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 5.55 and [size_height] &lt; 7.73</Filter>
<PolygonSymbolizer fill="#e7e1ef" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 7.73 and [size_height] &lt; 11.38</Filter>
<PolygonSymbolizer fill="#d4b9da" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 11.38 and [size_height] &lt; 18.45</Filter>
<PolygonSymbolizer fill="#c994c7" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 18.45 and [size_height] &lt; 35.05</Filter>
<PolygonSymbolizer fill="#df65b0" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 35.05 and [size_height] &lt; 89.30</Filter>
<PolygonSymbolizer fill="#e7298a" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 89.30 and [size_height] &lt; 152</Filter>
<PolygonSymbolizer fill="#ce1256" />
</Rule>
<Rule>
<Filter>[size_height] &gt;= 152</Filter>
<PolygonSymbolizer fill="#980043" />
</Rule>
</Style>
<Style name="date_year">
<Rule>
<Filter>[date_year] &gt;= 2000</Filter>
@ -166,30 +206,128 @@
<PolygonSymbolizer fill="#73ebaf" />
</Rule>
</Style>
<Style name="sust_dec">
<Rule>
<Filter>[sust_dec] = A</Filter>
<PolygonSymbolizer fill="#007f3d" />
</Rule>
<Rule>
<Filter>[sust_dec] = B</Filter>
<PolygonSymbolizer fill="#2c9f29" />
</Rule>
<Rule>
<Filter>[sust_dec] = C</Filter>
<PolygonSymbolizer fill="#9dcb3c" />
</Rule>
<Rule>
<Filter>[sust_dec] = D</Filter>
<PolygonSymbolizer fill="#fff200" />
</Rule>
<Rule>
<Filter>[sust_dec] = E</Filter>
<PolygonSymbolizer fill="#f7af1d" />
</Rule>
<Rule>
<Filter>[sust_dec] = F</Filter>
<PolygonSymbolizer fill="#ed6823" />
</Rule>
<Rule>
<Filter>[sust_dec] = G</Filter>
<PolygonSymbolizer fill="#e31d23" />
</Rule>
</Style>
<Style name="building_attachment_form">
<Rule>
<Filter>[building_attachment_form] = "Detached"</Filter>
<PolygonSymbolizer fill="#f2a2b9" />
</Rule>
<Rule>
<Filter>[building_attachment_form] = "Semi-Detached"</Filter>
<PolygonSymbolizer fill="#ab8fb0" />
</Rule>
<Rule>
<Filter>[building_attachment_form] = "End-Terrace"</Filter>
<PolygonSymbolizer fill="#3891d1" />
</Rule>
<Rule>
<Filter>[building_attachment_form] = "Mid-Terrace"</Filter>
<PolygonSymbolizer fill="#226291" />
</Rule>
</Style>
<Style name="likes">
<Rule>
<Filter>[likes] &gt;= 10</Filter>
<Filter>[likes] &gt;= 100</Filter>
<PolygonSymbolizer fill="#bd0026" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 5 and [likes] &lt; 10</Filter>
<Filter>[likes] &gt;= 50 and [likes] &lt; 100</Filter>
<PolygonSymbolizer fill="#e31a1c" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 4 and [likes] &lt; 5</Filter>
<Filter>[likes] &gt;= 20 and [likes] &lt; 50</Filter>
<PolygonSymbolizer fill="#fc4e2a" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 3 and [likes] &lt; 4</Filter>
<Filter>[likes] &gt;= 10 and [likes] &lt; 20</Filter>
<PolygonSymbolizer fill="#fd8d3c" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 2 and [likes] &lt; 3</Filter>
<Filter>[likes] &gt;= 3 and [likes] &lt; 10</Filter>
<PolygonSymbolizer fill="#feb24c" />
</Rule>
<Rule>
<Filter>[likes] &lt; 2</Filter>
<Filter>[likes] = 2</Filter>
<PolygonSymbolizer fill="#fed976" />
</Rule>
<Rule>
<Filter>[likes] = 1</Filter>
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
</Style>
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
<PolygonSymbolizer fill="#52403C" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Recreation And Leisure"</Filter>
<PolygonSymbolizer fill="#ffbfbf" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Transport"</Filter>
<PolygonSymbolizer fill="#bee8ff" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Utilities And Infrastructure"</Filter>
<PolygonSymbolizer fill="#cccccc" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Residential"</Filter>
<PolygonSymbolizer fill="#4a54a6" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Community Services"</Filter>
<PolygonSymbolizer fill="#73ccd1" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Retail"</Filter>
<PolygonSymbolizer fill="#ff8c00" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Industry And Business"</Filter>
<PolygonSymbolizer fill="#f5f58f" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Vacant And Derelict"</Filter>
<PolygonSymbolizer fill="#ffffff" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Defence"</Filter>
<PolygonSymbolizer fill="#898944" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Mixed Use"</Filter>
<PolygonSymbolizer fill="#e5050d" />
</Rule>
</Style>
</Map>

1229
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,47 +14,51 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.21",
"@fortawesome/free-solid-svg-icons": "^5.10.1",
"@fortawesome/react-fontawesome": "^0.1.4",
"@fortawesome/react-fontawesome": "^0.1.8",
"@mapbox/sphericalmercator": "^1.1.0",
"body-parser": "^1.19.0",
"bootstrap": "^4.3.1",
"connect-pg-simple": "^5.0.0",
"bootstrap": "^4.4.1",
"connect-pg-simple": "^6.0.1",
"express": "^4.17.1",
"express-session": "^1.16.2",
"leaflet": "^1.5.1",
"express-session": "^1.17.0",
"leaflet": "^1.6.0",
"mapnik": "^4.2.1",
"node-fs": "^0.1.7",
"nodemailer": "^6.3.0",
"pg-promise": "^8.7.5",
"prop-types": "^15.7.2",
"query-string": "^6.8.2",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0",
"react-router-dom": "^4.3.1",
"serialize-javascript": "^1.7.0",
"sharp": "^0.21.3"
"react-router-dom": "^5.0.1",
"serialize-javascript": "^2.1.1",
"sharp": "^0.22.1"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/express-session": "^1.15.13",
"@types/jest": "^24.0.17",
"@types/node": "^12.7.1",
"@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5",
"@types/react-router-dom": "^4.3.4",
"@types/webpack-env": "^1.14.0",
"babel-eslint": "^10.0.2",
"@types/express": "^4.17.2",
"@types/express-session": "^1.15.16",
"@types/jest": "^24.0.23",
"@types/lodash": "^4.14.149",
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^12.12.25",
"@types/nodemailer": "^6.2.2",
"@types/react": "^16.9.16",
"@types/react-dom": "^16.9.4",
"@types/react-leaflet": "^2.5.0",
"@types/react-router-dom": "^4.3.5",
"@types/sharp": "^0.22.3",
"@types/webpack-env": "^1.14.1",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-jest": "^22.15.0",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-jest": "^22.21.0",
"eslint-plugin-react": "^7.17.0",
"razzle": "^3.0.0",
"razzle-plugin-typescript": "^3.0.0",
"ts-jest": "^24.0.2",
"tslint": "^5.18.0",
"tslint-react": "^4.0.0",
"typescript": "^3.5.3"
"ts-jest": "^24.2.0",
"tslint": "^5.20.1",
"tslint-react": "^4.1.0",
"typescript": "^3.7.3"
},
"jest": {
"transform": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
app/public/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

@ -0,0 +1,14 @@
{
"short_name": "Colouring London",
"name": "Colouring London",
"icons": [
{
"src": "icon-192x192.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": "/",
"background_color": "#ffffff",
"theme_color": "#fffff"
}

131
app/src/api/api.ts Normal file
View File

@ -0,0 +1,131 @@
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 leaderboardRouter from './routes/leaderboardRouter';
import usersRouter from './routes/usersRouter';
import { queryLocation } from './services/search';
import { authUser, getNewUserAPIKey, logout } from './services/user';
const server = express.Router();
// parse POSTed json body
server.use(bodyParser.json());
server.use('/buildings', buildingsRouter);
server.use('/users', usersRouter);
server.use('/extracts', extractsRouter);
server.use('/leaderboard', leaderboardRouter);
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
if (user.user_id) {
req.session.user_id = user.user_id;
} else {
req.session.user_id = undefined;
}
res.send(user);
}).catch(function (error) {
res.send(error);
});
});
// POST user logout
server.post('/logout', function (req, res) {
logout(req.session).then(() => {
res.send({ success: true });
}).catch(err => {
console.error(err);
res.send({ error: 'Failed to end session'});
});
});
// POST generate API key
server.post('/api/key', function (req, res) {
if (!req.session.user_id) {
res.send({ error: 'Must be logged in' });
return;
}
getNewUserAPIKey(req.session.user_id).then(function (apiKey) {
res.send(apiKey);
}).catch(function (error) {
res.send(error);
});
});
// GET search
server.get('/search', function (req, res) {
const searchTerm = req.query.q;
if (!searchTerm) {
res.send({
error: 'Please provide a search term'
});
return;
}
queryLocation(searchTerm).then((results) => {
if (typeof (results) === 'undefined') {
res.send({
error: 'Database error'
});
return;
}
res.send({
results: results.map(item => {
// map from DB results to GeoJSON Feature objects
const geom = JSON.parse(item.st_asgeojson);
return {
type: 'Feature',
attributes: {
label: item.search_str,
zoom: item.zoom || 9
},
geometry: geom
};
})
});
}).catch(function (error) {
res.send(error);
});
});
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);
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' });
}
}
});
server.use((req, res) => {
res.status(404).json({ error: 'Resource not found'});
});
export default server;

View File

@ -1,401 +0,0 @@
/**
* Building data access
*
*/
import db from '../db';
import { removeAllAtBbox } from '../tiles/cache';
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
// JavaScript numerics are 64-bit double, giving only partial coverage.
const TransactionMode = db.$config.pgp.txMode.TransactionMode;
const isolationLevel = db.$config.pgp.txMode.isolationLevel;
// Create a transaction mode (serializable, read-write):
const serializable = new TransactionMode({
tiLevel: isolationLevel.serializable,
readOnly: false
});
function queryBuildingsAtPoint(lng, lat) {
return db.manyOrNone(
`SELECT b.*
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`,
[lng, lat]
).catch(function (error) {
console.error(error);
return undefined;
});
}
function queryBuildingsByReference(key, id) {
if (key === 'toid') {
return db.manyOrNone(
`SELECT
*
FROM
buildings
WHERE
ref_toid = $1
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
if (key === 'uprn') {
return db.manyOrNone(
`SELECT
b.*
FROM
buildings as b, building_properties as p
WHERE
b.building_id = p.building_id
AND
p.uprn = $1
`,
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
}
function getBuildingById(id) {
return db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
).then((building) => {
return getBuildingEditHistory(id).then((edit_history) => {
building.edit_history = edit_history
return building
})
}).catch(function (error) {
console.error(error);
return undefined;
});
}
function getBuildingEditHistory(id) {
return 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`,
[id]
).then((data) => {
return data
}).catch(function (error) {
console.error(error);
return []
});
}
function getBuildingLikeById(buildingId, userId) {
return db.oneOrNone(
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
[buildingId, userId]
).then(res => {
return res && res.like
}).catch(function (error) {
console.error(error);
return undefined;
});
}
function getBuildingUPRNsById(id) {
return db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id]
).catch(function (error) {
console.error(error);
return undefined;
});
}
function saveBuilding(buildingId, building, userId) {
// remove read-only fields from consideration
delete building.building_id;
delete building.revision_id;
delete building.geometry_id;
// start transaction around save operation
// - select and compare to identify changeset
// - insert changeset
// - update to latest state
// commit or rollback (repeated-read sufficient? or serializable?)
return db.tx(t => {
return t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
).then(oldBuilding => {
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
console.log('Patching', buildingId, patches)
const forward = patches[0];
const reverse = patches[1];
if (Object.keys(forward).length === 0) {
return Promise.reject('No change provided')
}
return t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`,
[forward, reverse, buildingId, userId]
).then(revision => {
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets)
return t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
});
});
}).catch(function (error) {
console.error(error);
return { error: error };
});
}
function likeBuilding(buildingId, userId) {
// start transaction around save operation
// - insert building-user like
// - count total likes
// - insert changeset
// - update building to latest state
// commit or rollback (serializable - could be more compact?)
return db.tx({mode: serializable}, t => {
return t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
).then(() => {
return t.one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
).then(building => {
return t.one(
`INSERT INTO logs (
forward_patch, building_id, user_id
) VALUES (
$1:json, $2, $3
) RETURNING log_id
`,
[{ likes_total: building.likes }, buildingId, userId]
).then(revision => {
return t.one(
`UPDATE buildings
SET
revision_id = $1,
likes_total = $2
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, building.likes, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
})
});
});
}).catch(function (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined
}
});
}
function unlikeBuilding(buildingId, userId) {
// start transaction around save operation
// - insert building-user like
// - count total likes
// - insert changeset
// - update building to latest state
// commit or rollback (serializable - could be more compact?)
return db.tx({mode: serializable}, t => {
return t.none(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
).then(() => {
return t.one(
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
[buildingId]
).then(building => {
return t.one(
`INSERT INTO logs (
forward_patch, building_id, user_id
) VALUES (
$1:json, $2, $3
) RETURNING log_id
`,
[{ likes_total: building.likes }, buildingId, userId]
).then(revision => {
return t.one(
`UPDATE buildings
SET
revision_id = $1,
likes_total = $2
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, building.likes, buildingId]
).then((data) => {
expireBuildingTileCache(buildingId)
return data
})
})
});
});
}).catch(function (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined
}
});
}
function privateQueryBuildingBBOX(buildingId){
return db.one(
`SELECT
ST_XMin(envelope) as xmin,
ST_YMin(envelope) as ymin,
ST_XMax(envelope) as xmax,
ST_YMax(envelope) as ymax
FROM (
SELECT
ST_Envelope(g.geometry_geom) as envelope
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
b.building_id = $1
) as envelope`,
[buildingId]
)
}
function expireBuildingTileCache(buildingId) {
privateQueryBuildingBBOX(buildingId).then((bbox) => {
const buildingBbox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin]
removeAllAtBbox(buildingBbox);
})
}
const BUILDING_FIELD_WHITELIST = new Set([
'ref_osm_id',
// 'location_name',
'location_number',
// 'location_street',
// 'location_line_two',
'location_town',
'location_postcode',
'location_latitude',
'location_longitude',
'date_year',
'date_lower',
'date_upper',
'date_source',
'date_source_detail',
'date_link',
'facade_year',
'facade_upper',
'facade_lower',
'facade_source',
'facade_source_detail',
'size_storeys_attic',
'size_storeys_core',
'size_storeys_basement',
'size_height_apex',
'size_floor_area_ground',
'size_floor_area_total',
'size_width_frontage',
'planning_portal_link',
'planning_in_conservation_area',
'planning_conservation_area_name',
'planning_in_list',
'planning_list_id',
'planning_list_cat',
'planning_list_grade',
'planning_heritage_at_risk_id',
'planning_world_list_id',
'planning_in_glher',
'planning_glher_url',
'planning_in_apa',
'planning_apa_name',
'planning_apa_tier',
'planning_in_local_list',
'planning_local_list_url',
'planning_in_historic_area_assessment',
'planning_historic_area_assessment_url',
]);
/**
* Compare old and new data objects, generate shallow merge patch of changed fields
* - forward patch is object with {keys: new_values}
* - reverse patch is object with {keys: old_values}
*
* @param {object} oldObj
* @param {object} newObj
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj, newObj, whitelist) {
const reverse = {}
const forward = {}
for (const [key, value] of Object.entries(newObj)) {
if (oldObj[key] !== value && whitelist.has(key)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse]
}
export {
queryBuildingsAtPoint,
queryBuildingsByReference,
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById,
saveBuilding,
likeBuilding,
unlikeBuilding
};

View File

@ -0,0 +1,176 @@
import express from 'express';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building';
import * as userService from '../services/user';
// GET buildings
// not implemented - may be useful to GET all buildings, paginated
// GET buildings at point
const getBuildingsByLocation = asyncController(async (req: express.Request, res: express.Response) => {
const { lng, lat } = req.query;
try {
const result = await buildingService.queryBuildingsAtPoint(lng, lat);
res.send(result);
} catch (error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
// GET buildings by reference (UPRN/TOID or other identifier)
const getBuildingsByReference = asyncController(async (req: express.Request, res: express.Response) => {
const { key, id } = req.query;
try {
const result = await buildingService.queryBuildingsByReference(key, id);
res.send(result);
} catch (error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
// GET individual building, POST building updates
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingById(buildingId);
res.send(result);
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
if (req.session.user_id) {
await updateBuilding(req, res, req.session.user_id);
} else if (req.query.api_key) {
try {
const user = await userService.authAPIUser(req.query.api_key);
await updateBuilding(req, res, user.user_id);
} catch(err) {
console.error(err);
res.send({ error: 'Must be logged in' });
}
} else {
res.send({ error: 'Must be logged in' });
}
});
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const buildingUpdate = req.body;
try {
const building = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
if (typeof (building) === 'undefined') {
return res.send({ error: 'Database error' });
}
if (building.error) {
return res.send(building);
}
res.send(building);
} catch(err) {
res.send({ error: 'Database error' });
}
}
// GET building UPRNs
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingUPRNsById(buildingId);
if (typeof (result) === 'undefined') {
return res.send({ error: 'Database error' });
}
res.send({uprns: result});
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
// GET/POST like building
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ like: false }); // not logged in, so cannot have liked
}
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
// any value returned means like
res.send({ like: like });
} catch(error) {
res.send({ error: 'Database error' });
}
});
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const editHistory = await buildingService.getBuildingEditHistory(buildingId);
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' });
}
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body;
try {
const building = like ?
await buildingService.likeBuilding(buildingId, req.session.user_id) :
await buildingService.unlikeBuilding(buildingId, req.session.user_id);
if (building.error) {
return res.send(building);
}
if (typeof (building) === 'undefined') {
return res.send({ error: 'Database error' });
}
res.send(building);
} catch(error) {
res.send({ error: 'Database error' });
}
});
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
try {
const revisionId = await buildingService.getLatestRevisionId();
res.send({latestRevisionId: revisionId});
} catch(error) {
res.send({ error: 'Database error' });
}
});
export default {
getBuildingsByLocation,
getBuildingsByReference,
getBuildingById,
updateBuildingById,
getBuildingUPRNsById,
getBuildingLikeById,
updateBuildingLikeById,
getBuildingEditHistoryById,
getLatestRevisionId
};

View File

@ -0,0 +1,35 @@
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(beforeId, afterId, count);
res.send(result);
} catch(error) {
if(error instanceof ArgumentError && error.argumentName === 'count') {
const apiErr = new ApiParamError(error.message, 'count');
throw apiErr;
}
throw error;
}
});
export {
getGlobalEditHistory
};

View File

@ -0,0 +1,31 @@
import express from 'express';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as dataExtractService from '../services/dataExtract';
const getAllDataExtracts = asyncController(async function(req: express.Request, res: express.Response) {
try {
const dataExtracts = await dataExtractService.listDataExtracts();
res.send({ extracts: dataExtracts });
} catch (err) {
res.send({ error: 'Database error' });
}
});
const getDataExtract = asyncController(async function(req: express.Request, res: express.Response) {
const extractId = processParam(req.params, 'extract_id', parsePositiveIntParam, true);
try {
const extract = await dataExtractService.getDataExtractById(extractId);
res.send({ extract: extract });
} catch (err) {
res.send({ error: 'Database error' });
}
});
export default {
getAllDataExtracts,
getDataExtract
};

View File

@ -0,0 +1,22 @@
import express from 'express';
import asyncController from "../routes/asyncController";
import * as leadersService from '../services/leaderboard';
const getLeaders = asyncController(async (req: express.Request, res: express.Response) => {
try {
const number_limit = req.query.number_limit;
const time_limit = req.query.time_limit;
const result = await leadersService.getLeaders(number_limit, time_limit);
res.send({
leaders: result
});
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
export default{
getLeaders
};

View File

@ -0,0 +1,113 @@
import express from 'express';
import asyncController from '../routes/asyncController';
import * as passwordResetService from '../services/passwordReset';
import { TokenVerificationError } from '../services/passwordReset';
import * as userService from '../services/user';
import { ValidationError } from '../validation';
const createUser = asyncController(async (req: express.Request, res: express.Response) => {
const user = req.body;
if (req.session.user_id) {
return res.send({ error: 'Already signed in' });
}
if (user.email) {
if (user.email != user.confirm_email) {
return res.send({ error: 'Email did not match confirmation.' });
}
} else {
user.email = null;
}
try {
const result = await userService.createUser(user);
if (result.user_id) {
req.session.user_id = result.user_id;
res.send({ user_id: result.user_id });
} else {
req.session.user_id = undefined;
res.send({ error: result.error });
}
} catch(err) {
console.error(err);
res.send(err);
}
});
const getCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
try {
const user = await userService.getUserById(req.session.user_id);
res.send(user);
} catch(error) {
res.send(error);
}
});
const deleteCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
console.log(`Deleting user ${req.session.user_id}`);
try {
await userService.deleteUser(req.session.user_id);
await userService.logout(req.session);
res.send({ success: true });
} catch(err) {
res.send({ error: err });
}
});
const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
return res.send({ error: 'Expected an email address or password reset token in the request body' });
}
if(req.body.email != undefined) {
// first stage: send reset token to email address
const origin = getWebAppOrigin();
await passwordResetService.sendPasswordResetToken(req.body.email, origin);
return res.status(202).send({ success: true });
} else if (req.body.token != undefined) {
// second stage: verify token and reset password
if (req.body.password == undefined) {
return res.send({ error: 'Expected a new password' });
}
try {
await passwordResetService.resetPassword(req.body.token, req.body.password);
} catch (err) {
if (err instanceof TokenVerificationError) {
return res.send({ error: 'Could not verify token' });
} else if (err instanceof ValidationError) {
return res.send({ error: err.message});
}
throw err;
}
return res.send({ success: true });
}
});
function getWebAppOrigin() : string {
const origin = process.env.WEBAPP_ORIGIN;
if (origin == undefined) {
throw new Error('WEBAPP_ORIGIN not defined');
}
return origin;
}
export default {
createUser,
getCurrentUser,
deleteCurrentUser,
resetPassword
};

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

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,17 @@
import { NextFunction, Request, Response } from 'express';
/**
* A wrapper for controller functions that return a Promise, enabling them to be used with Express
* Without this wrapper, Promise rejections caused by an error in the controller will not be passed properly
* to subsequent middleware layers.
* @param fn the async controller function to be wrapped
* @returns controller function which handles async errors correctly
*/
function asyncController(fn: (req: Request, res: Response, next: NextFunction) => Promise<any>) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
}
export default asyncController;

View File

@ -0,0 +1,37 @@
import express from 'express';
import buildingController from '../controllers/buildingController';
const router = express.Router();
// GET buildings
// not implemented - may be useful to GET all buildings, paginated
// GET buildings at point
router.get('/locate', buildingController.getBuildingsByLocation);
// GET buildings by reference (UPRN/TOID or other identifier)
router.get('/reference', buildingController.getBuildingsByReference);
router.get('/revision', buildingController.getLatestRevisionId);
router.route('/:building_id.json')
// GET individual building
.get(buildingController.getBuildingById)
// POST building updates
.post(buildingController.updateBuildingById);
// GET building UPRNs
router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById);
// GET/POST like building
router.route('/:building_id/like.json')
.get(buildingController.getBuildingLikeById)
.post(buildingController.updateBuildingLikeById);
router.route('/:building_id/history.json')
.get(buildingController.getBuildingEditHistoryById);
export default router;

View File

@ -0,0 +1,10 @@
import express from 'express';
import extractController from '../controllers/extractController';
const router = express.Router();
router.get('/', extractController.getAllDataExtracts);
router.get('/:extract_id', extractController.getDataExtract);
export default router;

View File

@ -0,0 +1,9 @@
import express from 'express';
import leaderboardController from '../controllers/leaderboardController';
const router = express.Router();
router.get('/leaders', leaderboardController.getLeaders);
export default router;

View File

@ -0,0 +1,15 @@
import express from 'express';
import userController from '../controllers/userController';
const router = express.Router();
router.post('/', userController.createUser);
router.route('/me')
.get(userController.getCurrentUser)
.delete(userController.deleteCurrentUser);
router.put('/password', userController.resetPassword);
export default router;

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

@ -0,0 +1,447 @@
/**
* Building data access
*
*/
import * as _ from 'lodash';
import { ITask } from 'pg-promise';
import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
// JavaScript numerics are 64-bit double, giving only partial coverage.
const TransactionMode = db.$config.pgp.txMode.TransactionMode;
const isolationLevel = db.$config.pgp.txMode.isolationLevel;
// Create a transaction mode (serializable, read-write):
const serializable = new TransactionMode({
tiLevel: isolationLevel.serializable,
readOnly: false
});
async function getLatestRevisionId() {
try {
const data = await db.oneOrNone(
`SELECT MAX(log_id) from logs`
);
return data == undefined ? undefined : data.max;
} catch(err) {
console.error(err);
return undefined;
}
}
async function queryBuildingsAtPoint(lng: number, lat: number) {
try {
return await db.manyOrNone(
`SELECT b.*
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
ST_Intersects(
ST_Transform(
ST_SetSRID(ST_Point($1, $2), 4326),
3857
),
geometry_geom
)
`,
[lng, lat]
);
} catch(error) {
console.error(error);
return undefined;
}
}
async function queryBuildingsByReference(key: string, ref: string) {
try {
if (key === 'toid') {
return await db.manyOrNone(
`SELECT
*
FROM
buildings
WHERE
ref_toid = $1
`,
[ref]
);
} else if (key === 'uprn') {
return await db.manyOrNone(
`SELECT
b.*
FROM
buildings as b, building_properties as p
WHERE
b.building_id = p.building_id
AND
p.uprn = $1
`,
[ref]
);
} else {
return { error: 'Key must be UPRN or TOID' };
}
} catch(err) {
console.error(err);
return undefined;
}
}
async function getCurrentBuildingDataById(id: number) {
return db.one(
'SELECT * FROM buildings WHERE building_id = $1',
[id]
);
}
async function getBuildingById(id: number) {
try {
const building = await getCurrentBuildingDataById(id);
building.edit_history = await getBuildingEditHistory(id);
return building;
} catch(error) {
console.error(error);
return undefined;
}
}
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) as revision_timestamp, username
FROM logs, users
WHERE building_id = $1 AND logs.user_id = users.user_id
ORDER BY log_timestamp DESC`,
[id]
);
} catch(error) {
console.error(error);
return [];
}
}
async function getBuildingLikeById(buildingId: number, userId: string) {
try {
const res = await db.oneOrNone(
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
[buildingId, userId]
);
return res && res.like;
} catch(error) {
console.error(error);
return undefined;
}
}
async function getBuildingUPRNsById(id: number) {
try {
return await db.any(
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
[id]
);
} catch(error) {
console.error(error);
return undefined;
}
}
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
try {
return await updateBuildingData(buildingId, userId, async () => {
const processedBuilding = await processBuildingUpdate(buildingId, building);
// remove read-only fields from consideration
delete processedBuilding.building_id;
delete processedBuilding.revision_id;
delete processedBuilding.geometry_id;
// return whitelisted fields to update
return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST);
});
} catch(error) {
console.error(error);
return { error: error };
}
}
async function likeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
},
async (t) => {
// insert building-user like
await t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
);
},
);
} catch (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined;
}
}
}
async function unlikeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
},
async (t) => {
// remove building-user like
const result = await t.result(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
);
if (result.rowCount === 0) {
throw new Error('No change');
}
},
);
} catch(error) {
console.error(error);
if (error.message === 'No change') {
// 'No change' is thrown if user doesn't like this building
return { error: 'It looks like you have already revoked your like for that building!' };
} else {
return undefined;
}
}
}
// === Utility functions ===
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
const subObject = {};
for (let [key, value] of Object.entries(obj)) {
if(fieldWhitelist.has(key)) {
subObject[key] = value;
}
}
return subObject;
}
/**
*
* @param buildingId ID of the building to count likes for
* @param t The database context inside which the count should happen
*/
function getBuildingLikeCount(buildingId: number, t: ITask<unknown>) {
return t.one(
'SELECT count(*) as likes_total FROM building_user_likes WHERE building_id = $1;',
[buildingId]
);
}
/**
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
* All db hooks get passed a transaction.
* @param buildingId The ID of the building to update
* @param userId The ID of the user updating the data
* @param getUpdateValue Function returning the set of attribute to update for the building
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
*/
async function updateBuildingData(
buildingId: number,
userId: string,
getUpdateValue: (t: ITask<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
) {
return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t);
}
const update = await getUpdateValue(t);
const oldBuilding = await t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
);
console.log(update);
const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches);
const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) {
throw 'No change provided';
}
const revision = await t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`,
[forward, reverse, buildingId, userId]
);
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets);
const data = await t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
);
expireBuildingTileCache(buildingId);
return data;
});
}
function privateQueryBuildingBBOX(buildingId: number){
return db.one(
`SELECT
ST_XMin(envelope) as xmin,
ST_YMin(envelope) as ymin,
ST_XMax(envelope) as xmax,
ST_YMax(envelope) as ymax
FROM (
SELECT
ST_Envelope(g.geometry_geom) as envelope
FROM buildings as b, geometries as g
WHERE
b.geometry_id = g.geometry_id
AND
b.building_id = $1
) as envelope`,
[buildingId]
);
}
async function expireBuildingTileCache(buildingId: number) {
const bbox = await privateQueryBuildingBBOX(buildingId);
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
tileCache.removeAllAtBbox(buildingBbox);
}
const BUILDING_FIELD_WHITELIST = new Set([
'ref_osm_id',
// 'location_name',
'location_number',
// 'location_street',
// 'location_line_two',
'location_town',
'location_postcode',
'location_latitude',
'location_longitude',
'date_year',
'date_lower',
'date_upper',
'date_source',
'date_source_detail',
'date_link',
'facade_year',
'facade_upper',
'facade_lower',
'facade_source',
'facade_source_detail',
'size_storeys_attic',
'size_storeys_core',
'size_storeys_basement',
'size_height_apex',
'size_floor_area_ground',
'size_floor_area_total',
'size_width_frontage',
'planning_portal_link',
'planning_in_conservation_area',
'planning_conservation_area_name',
'planning_in_list',
'planning_list_id',
'planning_list_cat',
'planning_list_grade',
'planning_heritage_at_risk_id',
'planning_world_list_id',
'planning_in_glher',
'planning_glher_url',
'planning_in_apa',
'planning_apa_name',
'planning_apa_tier',
'planning_in_local_list',
'planning_local_list_url',
'planning_in_historic_area_assessment',
'planning_historic_area_assessment_url',
'sust_breeam_rating',
'sust_dec',
// 'sust_aggregate_estimate_epc',
'sust_retrofit_date',
// 'sust_life_expectancy',
'building_attachment_form',
'date_change_building_use',
'current_landuse_class',
'current_landuse_group',
'current_landuse_order'
]);
/**
* Compare old and new data objects, generate shallow merge patch of changed fields
* - forward patch is object with {keys: new_values}
* - reverse patch is object with {keys: old_values}
*
* @param {object} oldObj
* @param {object} newObj
* @param {Set} whitelist
* @returns {[object, object]}
*/
function compare(oldObj: object, newObj: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(newObj)) {
if (!_.isEqual(oldObj[key], value)) {
reverse[key] = oldObj[key];
forward[key] = value;
}
}
return [forward, reverse];
}
export {
queryBuildingsAtPoint,
queryBuildingsByReference,
getCurrentBuildingDataById,
getBuildingById,
getBuildingLikeById,
getBuildingEditHistory,
getBuildingUPRNsById,
saveBuilding,
likeBuilding,
unlikeBuilding,
getLatestRevisionId
};

View File

@ -0,0 +1,65 @@
import path from 'path';
import db from '../../db';
interface DataExtractRow {
extract_id: number;
extracted_on: Date;
extract_path: string;
}
interface DataExtract {
extract_id: number;
extracted_on: Date;
download_path: string;
}
async function listDataExtracts(): Promise<DataExtract[]> {
try {
const extractRecords = await db.manyOrNone<DataExtractRow>(
`SELECT
extract_id, extracted_on, extract_path
FROM bulk_extracts
ORDER BY extracted_on DESC`
);
return extractRecords.map(getDataExtractFromRow);
} catch (err) {
console.error('Error:', err);
return undefined;
}
}
async function getDataExtractById(extractId: number): Promise<DataExtract> {
try {
const extractRecord = await db.one<DataExtractRow>(
`SELECT
extract_id, extracted_on, extract_path
FROM bulk_extracts
WHERE extract_id = $1
`, [extractId]);
return getDataExtractFromRow(extractRecord);
} catch (err) {
console.error('Error:', err);
return undefined;
}
}
function getDataExtractFromRow(er: DataExtractRow): DataExtract {
return {
extract_id: er.extract_id,
extracted_on: er.extracted_on,
download_path: getDownloadLinkForExtract(er)
};
}
function getDownloadLinkForExtract(extract: DataExtractRow): string {
const file_name = path.basename(extract.extract_path);
return `/downloads/${file_name}`; // TODO: potentially move base path to env var
}
export {
listDataExtracts,
getDataExtractById
};

View File

@ -0,0 +1,102 @@
import * as _ from 'lodash';
import db from '../../../db';
import { isNullishOrEmpty } from '../../../helpers';
import { getCurrentBuildingDataById } from '../building';
export async function processCurrentLandUseClassifications(buildingId: number, building: any): Promise<any> {
let updateData = _.pick(await getCurrentBuildingDataById(buildingId), [
'current_landuse_class',
'current_landuse_group',
'current_landuse_order'
]);
updateData = Object.assign({}, updateData, getClearValues(building));
const updateFrom = getUpdateStartingStage(building);
if(updateFrom === 'class') {
updateData.current_landuse_class = building.current_landuse_class;
updateData.current_landuse_group = await deriveGroupFromClass(updateData.current_landuse_class);
updateData.current_landuse_order = await deriveOrderFromGroup(updateData.current_landuse_group);
} else if (updateFrom === 'group') {
if (isNullishOrEmpty(updateData.current_landuse_class)) {
updateData.current_landuse_group = building.current_landuse_group;
updateData.current_landuse_order = await deriveOrderFromGroup(building.current_landuse_group);
} else {
throw new Error('Trying to update current_landuse_group field but a more detailed field (current_landuse_class) is already filled');
}
} else if (updateFrom === 'order') {
if (isNullishOrEmpty(updateData.current_landuse_class) && isNullishOrEmpty(updateData.current_landuse_group)) {
updateData.current_landuse_order = building.current_landuse_order;
} else {
throw new Error('Trying to update current_landuse_order field but a more detailed field (current_landuse_class or current_landuse_group) is already filled');
}
}
return Object.assign({}, building, updateData);
}
function getClearValues(building) {
const clearValues: any = {};
if(building.hasOwnProperty('current_landuse_class') && isNullishOrEmpty(building.current_landuse_class)) {
clearValues.current_landuse_class = [];
}
if(building.hasOwnProperty('current_landuse_group') && isNullishOrEmpty(building.current_landuse_group)) {
clearValues.current_landuse_group = [];
}
if(building.hasOwnProperty('current_landuse_order') && isNullishOrEmpty(building.current_landuse_order)) {
clearValues.current_landuse_order = null;
}
return clearValues;
}
/**
* Choose which level of the land use classification hierarchy the update should start from.
* @param building
*/
function getUpdateStartingStage(building) {
if(building.hasOwnProperty('current_landuse_class') && !isNullishOrEmpty(building.current_landuse_class)) {
return 'class';
} else if(building.hasOwnProperty('current_landuse_group') && !isNullishOrEmpty(building.current_landuse_group)) {
return 'group';
} else if(building.hasOwnProperty('current_landuse_order') && !isNullishOrEmpty(building.current_landuse_order)) {
return 'order';
} else return 'none';
}
async function deriveGroupFromClass(classes: string[]): Promise<string[]> {
if (classes.length === 0) return [];
return (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_group AS parent
JOIN reference_tables.buildings_landuse_class AS child
ON child.parent_group_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description`,
[classes]
)).map(x => x.description);
}
async function deriveOrderFromGroup(groups: string[]): Promise<string> {
if(groups.length === 0) return null;
const orders = (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_order AS parent
JOIN reference_tables.buildings_landuse_group AS child
ON child.parent_order_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description
`,
[groups]
)).map(x => x.description);
if(orders.length === 1) {
return orders[0];
} else if (orders.length > 1) {
return 'Mixed Use';
} else return null;
}

View File

@ -0,0 +1,11 @@
import { hasAnyOwnProperty } from '../../../helpers';
import { processCurrentLandUseClassifications } from './currentLandUseClassifications';
export async function processBuildingUpdate(buildingId: number, building: any): Promise<any> {
if(hasAnyOwnProperty(building, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) {
building = await processCurrentLandUseClassifications(buildingId, building);
}
return building;
}

View File

@ -0,0 +1,39 @@
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(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

@ -0,0 +1,15 @@
import * as nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.MAIL_SERVER_HOST,
port: parseInt(process.env.MAIL_SERVER_PORT),
secure: false,
auth: {
user: process.env.MAIL_SERVER_USER,
pass: process.env.MAIL_SERVER_PASSWORD
}
});
export {
transporter
};

View File

@ -0,0 +1,39 @@
import db from '../../db';
async function getLeaders(number_limit: number, time_limit: number) {
try {
if(time_limit > 0){
return await db.manyOrNone(
`SELECT count(log_id) as number_edits, username
FROM logs, users
WHERE logs.user_id=users.user_id
AND CURRENT_TIMESTAMP::DATE - log_timestamp::DATE <= $1
AND NOT (users.username = 'casa_friendly_robot')
AND NOT (users.username = 'colouringlondon')
GROUP by users.username
ORDER BY number_edits DESC
LIMIT $2`, [time_limit, number_limit]
);
}else{
return await db.manyOrNone(
`SELECT count(log_id) as number_edits, username
FROM logs, users
WHERE logs.user_id=users.user_id
AND NOT (users.username = 'casa_friendly_robot')
AND NOT (users.username = 'colouringlondon')
GROUP by users.username
ORDER BY number_edits DESC
LIMIT $1`, [number_limit]
);
}
} catch(error) {
console.error(error);
return [];
}
}
export {
getLeaders
};

View File

@ -0,0 +1,129 @@
import nodemailer from 'nodemailer';
import { errors } from 'pg-promise';
import url, { URL } from 'url';
import db from '../../db';
import { validatePassword } from '../validation';
import { transporter } from './email';
import * as userService from './user';
/**
* Generate a password reset token for the specified account and send the password reset link by email
* @param email the email address for which to generate a password reset token
* @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london
*/
async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
const user = await userService.getUserByEmail(email);
// if no user found for email, do nothing
if (user == undefined) return;
const token = await createPasswordResetToken(user.user_id);
const message = getPasswordResetEmail(email, token, siteOrigin);
await transporter.sendMail(message);
}
async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
validatePassword(newPassword);
const userId = await verifyPasswordResetToken(passwordResetToken);
if (userId != undefined) {
await updatePasswordForUser(userId, newPassword);
//TODO: expire other tokens of the user?
} else {
throw new TokenVerificationError('Password reset token could not be verified');
}
}
class TokenVerificationError extends Error {
constructor(message: string) {
super(message);
this.name = "TokenVerificationError";
}
}
function getPasswordResetEmail(email: string, token: string, siteOrigin: string): nodemailer.SendMailOptions {
const linkUrl = new URL(siteOrigin);
linkUrl.pathname = '/password-reset.html';
linkUrl.search = `?token=${token}`;
const linkString = url.format(linkUrl);
const messageBody = `Hi there,
Someone has requested a password reset for the Colouring London account associated with this email address.
Click on the following link within the next 24 hours to reset your password:
${linkString}
`;
return {
text: messageBody,
subject: 'Reset your Colouring London password',
to: email,
from: 'no-reply@colouring.london'
};
}
async function createPasswordResetToken(userId: string) {
const { token } = await db.one(
`INSERT INTO
user_password_reset_tokens (user_id, expires_on)
VALUES
($1::uuid, now() at time zone 'utc' + INTERVAL '$2 day')
RETURNING
token
`, [userId, 1]
);
return token;
}
/**
* Verify that the password reset token is valid and expire the token
* @param token password reset token to verify and use
* @returns the UUID of the user whose token was used
*/
async function verifyPasswordResetToken(token: string): Promise<string> {
try {
// verify and deactivate the token in one operation
const usedToken = await db.one(
`UPDATE
user_password_reset_tokens
SET used = true
WHERE
token = $1::uuid
AND NOT used
AND now() at time zone 'utc' < expires_on
RETURNING *`, [token]
);
console.log('verified');
return usedToken.user_id;
} catch (err) {
if (err instanceof errors.QueryResultError) {
console.log(err.code);
if (err.code === errors.queryResultErrorCode.noData) return undefined;
}
throw err;
}
}
async function updatePasswordForUser(userId: string, newPassword: string): Promise<null> {
return db.none(
`UPDATE
users
SET
pass = crypt($1, gen_salt('bf'))
WHERE
user_id = $2::uuid
`, [newPassword, userId]);
}
export {
sendPasswordResetToken,
resetPassword,
TokenVerificationError
};

View File

@ -6,7 +6,7 @@
* - this DOES expose geometry, another reason to keep this clearly separated from building
* data
*/
import db from '../db';
import db from '../../db';
function queryLocation(term) {
const limit = 5;

View File

@ -0,0 +1,205 @@
/**
* User data access
*
*/
import { errors } from 'pg-promise';
import { promisify } from 'util';
import db from '../../db';
import { validatePassword, validateUsername, ValidationError } from '../validation';
async function createUser(user) {
try {
validateUsername(user.username);
validatePassword(user.password);
} catch(err) {
if (err instanceof ValidationError) {
throw { error: err.message };
} else throw err;
}
try {
return await db.one(
`INSERT
INTO users (
user_id,
username,
email,
pass
) VALUES (
gen_random_uuid(),
$1,
$2,
crypt($3, gen_salt('bf'))
) RETURNING user_id
`, [
user.username,
user.email,
user.password
]
);
} catch(error) {
console.error('Error:', error);
if (error.detail.includes('already exists')) {
if (error.detail.includes('username')) {
return { error: 'Username already registered' };
} else if (error.detail.includes('email')) {
return { error: 'Email already registered' };
}
}
return { error: 'Database error' };
}
}
async function authUser(username: string, password: string) {
try {
const user = await db.one(
`SELECT
user_id,
(
pass = crypt($2, pass)
) AS auth_ok,
is_blocked,
blocked_on,
blocked_reason
FROM users
WHERE
username = $1
`, [
username,
password
]
);
if (user && user.auth_ok) {
if (user.is_blocked) {
return { error: `Account temporarily blocked.${user.blocked_reason == undefined ? '' : ' Reason: '+user.blocked_reason}` };
}
return { user_id: user.user_id };
} else {
return { error: 'Username or password not recognised' };
}
} catch(err) {
if (err instanceof errors.QueryResultError) {
console.error(`Authentication failed for user ${username}`);
return { error: 'Username or password not recognised' };
}
console.error('Error:', err);
return { error: 'Database error' };
}
}
async function getUserById(id: string) {
try {
return await db.one(
`SELECT
username, email, registered, api_key
FROM
users
WHERE
user_id = $1
`, [
id
]
);
} catch(error) {
console.error('Error:', error);
return undefined;
}
}
async function getUserByEmail(email: string) {
try {
return await db.one(
`SELECT
user_id, username, email
FROM
users
WHERE
email = $1
`, [email]
);
} catch(error) {
console.error('Error:', error);
return undefined;
}
}
async function getNewUserAPIKey(id: string) {
try{
return await db.one(
`UPDATE
users
SET
api_key = gen_random_uuid()
WHERE
user_id = $1
RETURNING
api_key
`, [
id
]
);
} catch(error) {
console.error('Error:', error);
return { error: 'Failed to generate new API key.' };
}
}
async function authAPIUser(key: string) {
try {
return await db.one(
`SELECT
user_id
FROM
users
WHERE
api_key = $1
`, [
key
]
);
} catch(error) {
console.error('Error:', error);
return undefined;
}
}
async function deleteUser(id: string) {
try {
return await db.none(
`UPDATE users
SET
email = null,
pass = null,
api_key = null,
username = concat('deleted_', cast(user_id as char(13))),
is_deleted = true,
deleted_on = now() at time zone 'utc'
WHERE user_id = $1
`, [id]
);
} catch(error) {
console.error('Error:', error);
return {error: 'Database error'};
}
}
function logout(session: Express.Session): Promise<void> {
session.user_id = undefined;
return promisify(session.destroy.bind(session))();
}
export {
getUserById,
getUserByEmail,
createUser,
authUser,
getNewUserAPIKey,
authAPIUser,
deleteUser,
logout
};

View File

@ -1,125 +0,0 @@
/**
* User data access
*
*/
import db from '../db';
function createUser(user) {
if (!user.password || user.password.length < 8) {
return Promise.reject({ error: 'Password must be at least 8 characters' })
}
if (user.password.length > 70) {
return Promise.reject({ error: 'Password must be at most 70 characters' })
}
return db.one(
`INSERT
INTO users (
user_id,
username,
email,
pass
) VALUES (
gen_random_uuid(),
$1,
$2,
crypt($3, gen_salt('bf'))
) RETURNING user_id
`, [
user.username,
user.email,
user.password
]
).catch(function (error) {
console.error('Error:', error)
if (error.detail.indexOf('already exists') !== -1) {
if (error.detail.indexOf('username') !== -1) {
return { error: 'Username already registered' };
} else if (error.detail.indexOf('email') !== -1) {
return { error: 'Email already registered' };
}
}
return { error: 'Database error' }
});
}
function authUser(username, password) {
return db.one(
`SELECT
user_id,
(
pass = crypt($2, pass)
) AS auth_ok
FROM users
WHERE
username = $1
`, [
username,
password
]
).then(function (user) {
if (user && user.auth_ok) {
return { user_id: user.user_id }
} else {
return { error: 'Username or password not recognised' }
}
}).catch(function (err) {
console.error(err);
return { error: 'Username or password not recognised' };
})
}
function getUserById(id) {
return db.one(
`SELECT
username, email, registered, api_key
FROM
users
WHERE
user_id = $1
`, [
id
]
).catch(function (error) {
console.error('Error:', error)
return undefined;
});
}
function getNewUserAPIKey(id) {
return db.one(
`UPDATE
users
SET
api_key = gen_random_uuid()
WHERE
user_id = $1
RETURNING
api_key
`, [
id
]
).catch(function (error) {
console.error('Error:', error)
return { error: 'Failed to generate new API key.' };
});
}
function authAPIUser(key) {
return db.one(
`SELECT
user_id
FROM
users
WHERE
api_key = $1
`, [
key
]
).catch(function (error) {
console.error('Error:', error)
return undefined;
});
}
export { getUserById, createUser, authUser, getNewUserAPIKey, authAPIUser }

25
app/src/api/validation.ts Normal file
View File

@ -0,0 +1,25 @@
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
function validateUsername(username: string): void {
if (username == undefined) throw new ValidationError('Username is required');
if (!username.match(/^\w+$/)) throw new ValidationError('Username can only contain alphanumeric characters and underscore');
if (username.length < 4) throw new ValidationError('Username must be at least 4 characters long');
if (username.length > 30) throw new ValidationError('Username must be at most 30 characters long');
}
function validatePassword(password: string): void {
if (password == undefined) throw new ValidationError('Password is required');
if (password.length < 8) throw new ValidationError('Password must be at least 8 characters long');
if (password.length > 128) throw new ValidationError('Password must be at most 128 characters long');
}
export {
ValidationError,
validateUsername,
validatePassword
};

View File

@ -2,9 +2,9 @@
* Client-side entry point to shared frontend React App
*
*/
import BrowserRouter from 'react-router-dom/BrowserRouter';
import React from 'react';
import { hydrate } from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './frontend/app';
@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
hydrate(
<BrowserRouter>
<App user={data.user} building={data.building} building_like={data.building_like} />
<App
user={data.user}
building={data.building}
building_like={data.building_like}
revisionId={data.latestRevisionId}
/>
</BrowserRouter>,
document.getElementById('root')
);

View File

@ -0,0 +1,44 @@
type JsonReviver = (name: string, value: any) => any;
export function apiGet(path: string, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'GET', null, options);
}
export function apiPost(path: string, data?: object, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'POST', data, options);
}
export function apiDelete(path: string, options?: {
jsonReviver?: JsonReviver
}): Promise<any> {
return apiRequest(path, 'DELETE', null, options);
}
async function apiRequest(
path: string,
method: 'GET' | 'POST' | 'DELETE',
data?: object,
options?: {
jsonReviver?: JsonReviver
}
): Promise<any> {
const res = await fetch(path, {
method: method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json'
},
body: data == undefined ? null : JSON.stringify(data),
});
const reviver = options == undefined ? undefined : options.jsonReviver;
if (reviver != undefined) {
return JSON.parse(await res.text(), reviver);
} else {
return await res.json();
}
}

View File

@ -9,7 +9,7 @@ describe('<App />', () => {
const div = document.createElement('div');
ReactDOM.render(
<MemoryRouter>
<App />
<App revisionId={0} />
</MemoryRouter>,
div
);

View File

@ -1,25 +1,40 @@
import React, { Fragment } from 'react';
import { Route, Switch, Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { parse } from 'query-string';
import { Link, Route, Switch } from 'react-router-dom';
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import './app.css';
import AboutPage from './about';
import BuildingEdit from './building-edit';
import BuildingView from './building-view';
import MultiEdit from './multi-edit';
import ColouringMap from './map';
import Header from './header';
import Overview from './overview';
import Login from './login';
import MyAccountPage from './my-account';
import SignUp from './signup';
import Welcome from './welcome';
import { parseCategoryURL } from '../parse';
import PrivacyPolicyPage from './privacy-policy';
import ContributorAgreementPage from './contributor-agreement';
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';
import DataExtracts from './pages/data-extracts';
import LeaderboardPage from './pages/leaderboard';
import OrdnanceSurveyLicencePage from './pages/ordnance-survey-licence';
import OrdnanceSurveyUprnPage from './pages/ordnance-survey-uprn';
import PrivacyPolicyPage from './pages/privacy-policy';
import ForgottenPassword from './user/forgotten-password';
import Login from './user/login';
import MyAccountPage from './user/my-account';
import PasswordReset from './user/password-reset';
import SignUp from './user/signup';
interface AppProps {
user?: User;
building?: Building;
building_like?: boolean;
revisionId: number;
}
interface AppState {
user?: User;
}
/**
* App component
@ -33,35 +48,24 @@ import ContributorAgreementPage from './contributor-agreement';
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
* child components to navigate without a full page reload.
*/
class App extends React.Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
user: PropTypes.object,
building: PropTypes.object,
building_like: PropTypes.bool
}
class App extends React.Component<AppProps, AppState> {
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
constructor(props) {
constructor(props: Readonly<AppProps>) {
super(props);
// set building revision id, default 0
const rev = (props.building)? +props.building.revision_id : 0;
this.state = {
user: props.user,
building: props.building,
building_like: props.building_like,
revision_id: rev
user: props.user
};
this.login = this.login.bind(this);
this.updateUser = this.updateUser.bind(this);
this.logout = this.logout.bind(this);
this.selectBuilding = this.selectBuilding.bind(this);
this.colourBuilding = this.colourBuilding.bind(this);
this.increaseRevision = this.increaseRevision.bind(this);
}
login(user) {
if (user.error) {
this.logout();
return
return;
}
this.setState({user: user});
}
@ -74,194 +78,56 @@ class App extends React.Component<any, any> { // TODO: add proper types
this.setState({user: undefined});
}
increaseRevision(revisionId) {
revisionId = +revisionId;
// bump revision id, only ever increasing
if (revisionId > this.state.revision_id){
this.setState({revision_id: revisionId})
}
}
selectBuilding(building) {
this.increaseRevision(building.revision_id);
// get UPRNs and update
fetch(`/building/${building.building_id}/uprns.json`, {
method: 'GET',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
building.uprns = res.uprns;
this.setState({building: building});
}
}).catch((err) => {
console.error(err)
this.setState({building: building});
});
// get if liked and update
fetch(`/building/${building.building_id}/like.json`, {
method: 'GET',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then((res) => {
if (res.error) {
console.error(res);
} else {
this.setState({building_like: res.like});
}
}).catch((err) => {
console.error(err)
this.setState({building_like: false});
});
}
/**
* Colour building
*
* Used in multi-edit mode to colour buildings on map click
*
* Pulls data from URL to form update
*
* @param {object} building
*/
colourBuilding(building) {
const cat = parseCategoryURL(window.location.pathname);
const q = parse(window.location.search);
const data = (cat === 'like')? {like: true}: JSON.parse(q.data as string); // TODO: verify what happens if data is string[]
if (cat === 'like'){
this.likeBuilding(building.building_id)
} else {
this.updateBuilding(building.building_id, data)
}
}
likeBuilding(buildingId) {
fetch(`/building/${buildingId}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: true})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
console.error({error: res.error})
} else {
this.increaseRevision(res.revision_id);
}
}.bind(this)).catch(
(err) => console.error({error: err})
);
}
updateBuilding(buildingId, data){
fetch(`/building/${buildingId}.json`, {
method: 'POST',
body: JSON.stringify(data),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(res => {
if (res.error) {
console.error({error: res.error})
} else {
this.increaseRevision(res.revision_id);
}
}).catch(
(err) => console.error({error: err})
);
}
render() {
return (
<Fragment>
<Header user={this.state.user} />
<main>
<Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/view/:cat.html" render={(props) => (
<Overview
{...props}
mode='view' user={this.state.user}
/>
) } />
<Route exact path="/edit/:cat.html" render={(props) => (
<Overview
{...props}
mode='edit' user={this.state.user}
/>
) } />
<Route exact path="/multi-edit/:cat.html" render={(props) => (
<MultiEdit
{...props}
user={this.state.user}
/>
) } />
<Route exact path="/view/:cat/building/:building.html" render={(props) => (
<BuildingView
{...props}
{...this.state.building}
user={this.state.user}
building_like={this.state.building_like}
/>
) } />
<Route exact path="/edit/:cat/building/:building.html" render={(props) => (
<BuildingEdit
{...props}
{...this.state.building}
user={this.state.user}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
/>
) } />
</Switch>
<Switch>
<Route exact path="/(multi-edit.*|edit.*|view.*)?" render={(props) => (
<ColouringMap
{...props}
building={this.state.building}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
) } />
<Route exact path="/about.html" component={AboutPage} />
<Route exact path="/login.html">
<Login user={this.state.user} login={this.login} />
</Route>
<Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} />
</Route>
<Route exact path="/my-account.html">
<MyAccountPage
user={this.state.user}
updateUser={this.updateUser}
logout={this.logout}
/>
</Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route component={NotFound} />
</Switch>
</main>
<Switch>
<Route exact path={App.mapAppPaths}>
<Header user={this.state.user} animateLogo={false} />
</Route>
<Route>
<Header user={this.state.user} animateLogo={true} />
</Route>
</Switch>
<main>
<Switch>
<Route exact path="/about.html" component={AboutPage} />
<Route exact path="/login.html">
<Login user={this.state.user} login={this.login} />
</Route>
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
<Route exact path="/password-reset.html" component={PasswordReset} />
<Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} />
</Route>
<Route exact path="/my-account.html">
<MyAccountPage
user={this.state.user}
updateUser={this.updateUser}
logout={this.logout}
/>
</Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route exact path="/ordnance-survey-licence.html" component={OrdnanceSurveyLicencePage} />
<Route exact path="/ordnance-survey-uprn.html" component={OrdnanceSurveyUprnPage} />
<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="/leaderboard.html" component={LeaderboardPage} />
<Route exact path="/history.html" component={ChangesPage} />
<Route exact path={App.mapAppPaths} render={(props) => (
<MapApp
{...props}
building={this.props.building}
building_like={this.props.building_like}
user={this.state.user}
revisionId={this.props.revisionId}
/>
)} />
<Route component={NotFound} />
</Switch>
</main>
</Fragment>
);
}

View File

@ -1,709 +0,0 @@
import React, { Component, Fragment } from 'react';
import { Link, NavLink, Redirect } from 'react-router-dom';
import PropTypes from 'prop-types';
import ErrorBox from './error-box';
import InfoBox from './info-box';
import Sidebar from './sidebar';
import Tooltip from './tooltip';
import { SaveIcon } from './icons';
import CONFIG from './fields-config.json';
const BuildingEdit = (props) => {
if (!props.user){
return <Redirect to="/sign-up.html" />
}
const cat = props.match.params.cat;
if (!props.building_id){
return (
<Sidebar title="Building Not Found" back={`/edit/${cat}.html`}>
<InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container ml-3 mr-3">
<Link to={`/edit/${cat}.html`} className="btn btn-secondary">Back to maps</Link>
</div>
</Sidebar>
);
}
return (
<Sidebar
key={props.building_id}
title={'You are editing'}
back={`/edit/${cat}.html`}>
{
CONFIG.map((section) => {
return <EditForm
{...section} {...props}
cat={cat} key={section.slug} />
})
}
</Sidebar>
);
}
BuildingEdit.propTypes = {
user: PropTypes.object,
match: PropTypes.object,
building_id: PropTypes.number
}
class EditForm extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
cat: PropTypes.string,
help: PropTypes.string,
error: PropTypes.object,
like: PropTypes.bool,
building_like: PropTypes.bool,
selectBuilding: PropTypes.func,
building_id: PropTypes.number,
inactive: PropTypes.bool,
fields: PropTypes.array
};
constructor(props) {
super(props);
// create object and spread into state to avoid TS complaining about modifying readonly state
let fieldsObj = {};
for (const field of props.fields) {
fieldsObj[field.slug] = props[field.slug];
}
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
copying: false,
keys_to_copy: {},
...fieldsObj
}
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleUpdate = this.handleUpdate.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* Note that we track keys only - values are already held in state
*
* @param {string} key
*/
toggleCopyAttribute(key) {
const keys = this.state.keys_to_copy;
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
keys[key] = true;
}
this.setState({
keys_to_copy: keys
})
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {DocumentEvent} event
*/
handleChange(event) {
const target = event.target;
let value = (target.value === '')? null : target.value;
const name = target.name;
// special transform - consider something data driven before adding 'else if's
if (name === 'location_postcode' && value !== null) {
value = value.toUpperCase();
}
this.setState({
[name]: value
});
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {DocumentEvent} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
this.setState({
[name]: value
});
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} key
* @param {*} value
*/
handleUpdate(key, value) {
this.setState({
[key]: value
});
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {DocumentEvent} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/building/${this.props.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
this.setState({
likes_total: res.likes_total
})
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
fetch(`/building/${this.props.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() {
const match = this.props.cat === this.props.slug;
const cat = this.props.cat;
const buildingLike = this.props.building_like;
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state[key]
}
const data_string = JSON.stringify(values_to_copy);
return (
<section className={(this.props.inactive)? 'data-section inactive': 'data-section'}>
<header className={`section-header edit ${this.props.slug} ${(match? 'active' : '')}`}>
<NavLink
to={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
title={(this.props.inactive)? 'Coming soon… Click the ? for more info.' :
(match)? 'Hide details' : 'Show details'}
isActive={() => match}>
<h3 className="h3">{this.props.title}</h3>
</NavLink>
<nav className="icon-buttons">
{
(match && !this.props.inactive && this.props.slug !== 'like')?
this.state.copying?
<Fragment>
<NavLink
to={`/multi-edit/${this.props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a className="icon-button copy" onClick={this.toggleCopying}>Cancel</a>
</Fragment>
:
<a className="icon-button copy" onClick={this.toggleCopying}>Copy</a>
: null
}
{
(match && this.props.slug === 'like')?
<NavLink
to={`/multi-edit/${this.props.cat}.html`}
className="icon-button copy">
Copy
</NavLink>
: null
}
{
this.props.help && !this.state.copying?
<a className="icon-button help" title="Find out more" href={this.props.help}>
Info
</a>
: null
}
{
(match && !this.state.copying && !this.props.inactive && this.props.slug !== 'like')? // special-case for likes
<NavLink className="icon-button save" title="Save Changes"
onClick={this.handleSubmit}
to={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}>
Save
<SaveIcon />
</NavLink>
: null
}
</nav>
</header>
{
match? (
!this.props.inactive?
<form action={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
method="GET" onSubmit={this.handleSubmit}>
{
this.props.slug === 'location'?
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
: null
}
<ErrorBox msg={this.state.error} />
{
this.props.fields.map((props) => {
switch (props.type) {
case 'text':
return <TextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_list':
return <TextListInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_long':
return <LongTextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'number':
return <NumberInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'year_estimator':
return <YearEstimator {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_multi':
return <MultiTextInput {...props} handleChange={this.handleUpdate}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'checkbox':
return <CheckboxInput {...props} handleChange={this.handleCheck}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'like':
return <LikeButton {...props} handleLike={this.handleLike}
building_like={buildingLike}
value={this.state[props.slug]} key={props.slug} cat={cat} />
default:
return null
}
})
}
<InfoBox msg="Colouring may take a few seconds - try zooming the map or hitting refresh after saving (we're working on making this smoother)." />
{
(this.props.slug === 'like')? // special-case for likes
null :
<div className="buttons-container">
<button type="submit" className="btn btn-primary">Save</button>
</div>
}
</form>
: <form>
<InfoBox msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`} />
</form>
) : null
}
</section>
)
}
}
const TextInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy}
cat={props.cat}
disabled={props.disabled} />
<input className="form-control" type="text"
id={props.slug} name={props.slug}
value={props.value || ''}
maxLength={props.max_length}
disabled={props.disabled}
placeholder={props.placeholder}
onChange={props.handleChange}
/>
</Fragment>
);
TextInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.string,
max_length: PropTypes.number,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
handleChange: PropTypes.func
}
const LongTextInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} cat={props.cat}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy}
disabled={props.disabled} tooltip={props.tooltip} />
<textarea className="form-control"
id={props.slug} name={props.slug}
disabled={props.disabled}
placeholder={props.placeholder}
onChange={props.handleChange}
value={props.value || ''}></textarea>
</Fragment>
)
LongTextInput.propTypes = {
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.string,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
handleChange: PropTypes.func
}
class MultiTextInput extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string),
placeholder: PropTypes.string,
disabled: PropTypes.bool,
handleChange: PropTypes.func,
copy: PropTypes.bool,
toggleCopyAttribute: PropTypes.func,
copying: PropTypes.bool
};
constructor(props) {
super(props);
this.edit = this.edit.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
this.getValues = this.getValues.bind(this);
}
getValues() {
return (this.props.value && this.props.value.length)? this.props.value : [null];
}
edit(event) {
const editIndex = +event.target.dataset.index;
const editItem = event.target.value;
const oldValues = this.getValues();
const values = oldValues.map((item, i) => {
return i === editIndex ? editItem : item;
});
this.props.handleChange(this.props.slug, values);
}
add(event) {
event.preventDefault();
const values = this.getValues().concat('');
this.props.handleChange(this.props.slug, values);
}
remove(event){
const removeIndex = +event.target.dataset.index;
const values = this.getValues().filter((_, i) => {
return i !== removeIndex;
});
this.props.handleChange(this.props.slug, values);
}
render() {
const values = this.getValues();
return (
<Fragment>
<Label slug={this.props.slug} title={this.props.title} tooltip={this.props.tooltip}
cat={this.props.cat}
copying={this.props.copying}
disabled={this.props.disabled}
toggleCopyAttribute={this.props.toggleCopyAttribute}
copy={this.props.copy} />
{
values.map((item, i) => (
<div className="input-group" key={i}>
<input className="form-control" type="text"
key={`${this.props.slug}-${i}`} name={`${this.props.slug}-${i}`}
data-index={i}
value={item || ''}
placeholder={this.props.placeholder}
disabled={this.props.disabled}
onChange={this.edit}
/>
<div className="input-group-append">
<button type="button" onClick={this.remove}
title="Remove"
data-index={i} className="btn btn-outline-dark"></button>
</div>
</div>
))
}
<button type="button" title="Add" onClick={this.add}
className="btn btn-outline-dark">+</button>
</Fragment>
)
}
}
const TextListInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<select className="form-control"
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.disabled}
// list={`${props.slug}_suggestions`} TODO: investigate whether this was needed
onChange={props.handleChange}>
<option value="">Select a source</option>
{
props.options.map(option => (
<option key={option} value={option}>{option}</option>
))
}
</select>
</Fragment>
)
TextListInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.string),
value: PropTypes.string,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
const NumberInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<input className="form-control" type="number" step={props.step}
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.disabled}
onChange={props.handleChange}
/>
</Fragment>
);
NumberInput.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
step: PropTypes.number,
value: PropTypes.number,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
class YearEstimator extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
date_year: PropTypes.number,
date_upper: PropTypes.number,
date_lower: PropTypes.number,
value: PropTypes.number,
disabled: PropTypes.bool,
handleChange: PropTypes.func,
copy: PropTypes.bool,
toggleCopyAttribute: PropTypes.func,
copying: PropTypes.bool
};
constructor(props) {
super(props);
this.state = {
year: props.date_year,
upper: props.date_upper,
lower: props.date_lower,
decade: Math.floor(props.date_year / 10) * 10,
century: Math.floor(props.date_year / 100) * 100
}
}
// TODO add dropdown for decade, century
// TODO roll in first/last year estimate
// TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() {
return (
<NumberInput {...this.props} handleChange={this.props.handleChange}
value={this.props.value} key={this.props.slug} />
)
}
}
const CheckboxInput = (props) => (
<Fragment>
<Label slug={props.slug} title={props.title} tooltip={props.tooltip}
cat={props.cat} disabled={props.disabled}
copying={props.copying}
toggleCopyAttribute={props.toggleCopyAttribute}
copy={props.copy} />
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug} name={props.slug}
checked={!!props.value}
disabled={props.disabled}
onChange={props.handleChange}
/>
<label htmlFor={props.slug} className="form-check-label">
{props.title}
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
</div>
</Fragment>
)
CheckboxInput.propTypes = {
slug: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.bool,
disabled: PropTypes.bool,
handleChange: PropTypes.func
}
const LikeButton = (props) => (
<Fragment>
<p className="likes">{(props.value)? props.value : 0} likes</p>
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug} name={props.slug}
checked={!!props.building_like}
disabled={props.disabled}
onChange={props.handleLike}
/>
<label htmlFor={props.slug} className="form-check-label">
I like this building and think it contributes to the city!
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
</div>
<p>
<NavLink
to={`/multi-edit/${props.cat}.html`}>
Like more buildings
</NavLink>
</p>
</Fragment>
);
LikeButton.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.number,
building_like: PropTypes.bool,
disabled: PropTypes.bool,
handleLike: PropTypes.func
}
const Label: React.SFC<any> = (props) => { // TODO: remove any
return (
<label htmlFor={props.slug}>
{props.title}
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div> : null
}
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</label>
);
}
Label.propTypes = {
slug: PropTypes.string,
cat: PropTypes.string,
title: PropTypes.string,
disabled: PropTypes.bool,
tooltip: PropTypes.string
}
export default BuildingEdit;

View File

@ -1,368 +0,0 @@
import React, { Fragment } from 'react';
import { Link, NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import Tooltip from './tooltip';
import InfoBox from './info-box';
import { EditIcon } from './icons';
import { sanitiseURL } from './helpers';
import CONFIG from './fields-config.json';
const BuildingView = (props) => {
if (!props.building_id){
return (
<Sidebar title="Building Not Found">
<InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container with-space">
<Link to="/view/age.html" className="btn btn-secondary">Back to maps</Link>
</div>
</Sidebar>
);
}
const cat = props.match.params.cat;
return (
<Sidebar title={'Data available for this building'} back={`/view/${cat}.html`}>
{
CONFIG.map(section => (
<DataSection
key={section.slug} cat={cat}
building_id={props.building_id}
{...section} {...props} />
))
}
</Sidebar>
);
}
BuildingView.propTypes = {
building_id: PropTypes.number,
match: PropTypes.object,
uprns: PropTypes.arrayOf(PropTypes.shape({
uprn: PropTypes.string.isRequired,
parent_uprn: PropTypes.string
})),
building_like: PropTypes.bool
}
class DataSection extends React.Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
cat: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
building_id: PropTypes.number,
children: PropTypes.node
};
constructor(props) {
super(props);
this.state = {
copying: false,
values_to_copy: {}
};
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* @param {string} key
*/
toggleCopyAttribute(key) {
const value = this.props[key];
const values = this.state.values_to_copy;
if(Object.keys(this.state.values_to_copy).includes(key)){
delete values[key];
} else {
values[key] = value;
}
this.setState({
values_to_copy: values
})
}
render() {
const props = this.props;
const match = props.cat === props.slug;
const data_string = JSON.stringify(this.state.values_to_copy);
return (
<section id={props.slug} className={(props.inactive)? 'data-section inactive': 'data-section'}>
<header className={`section-header view ${props.slug} ${(match? 'active' : '')}`}>
<NavLink
to={`/view/${props.slug}/building/${props.building_id}.html`}
title={(props.inactive)? 'Coming soon… Click the ? for more info.' :
(match)? 'Hide details' : 'Show details'}
isActive={() => match}>
<h3 className="h3">{props.title}</h3>
</NavLink>
<nav className="icon-buttons">
{
(match && !props.inactive)?
this.state.copying?
<Fragment>
<NavLink
to={`/multi-edit/${props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a className="icon-button copy" onClick={this.toggleCopying}>Cancel</a>
</Fragment>
:
<a className="icon-button copy" onClick={this.toggleCopying}>Copy</a>
: null
}
{
props.help && !this.state.copying?
<a className="icon-button help" title="Find out more" href={props.help}>
Info
</a>
: null
}
{
!props.inactive && !this.state.copying?
<NavLink className="icon-button edit" title="Edit data"
to={`/edit/${props.slug}/building/${props.building_id}.html`}>
Edit
<EditIcon />
</NavLink>
: null
}
</nav>
</header>
{
match?
!props.inactive?
<dl className="data-list">
{
props.fields.map(field => {
switch (field.type) {
case 'uprn_list':
return <UPRNsDataEntry
key={field.slug}
title={field.title}
value={props.uprns}
tooltip={field.tooltip} />
case 'text_multi':
return <MultiDataEntry
key={field.slug}
slug={field.slug}
disabled={field.disabled}
cat={props.cat}
title={field.title}
value={props[field.slug]}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={Object.keys(this.state.values_to_copy).includes(field.slug)}
tooltip={field.tooltip} />
case 'like':
return <LikeDataEntry
key={field.slug}
title={field.title}
value={props[field.slug]}
user_building_like={props.building_like}
tooltip={field.tooltip} />
default:
return <DataEntry
key={field.slug}
slug={field.slug}
disabled={field.disabled}
cat={props.cat}
title={field.title}
value={props[field.slug]}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={Object.keys(this.state.values_to_copy).includes(field.slug)}
tooltip={field.tooltip} />
}
})
}
</dl>
: <p className="data-intro">{props.intro}</p>
: null
}
</section>
);
}
}
const DataEntry: React.SFC<any> = (props) => { // TODO: remove any
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
</dt>
<dd>{
(props.value != null && props.value !== '')?
(typeof(props.value) === 'boolean')?
(props.value)? 'Yes' : 'No'
: props.value
: '\u00A0'}</dd>
</Fragment>
);
}
DataEntry.propTypes = {
title: PropTypes.string,
cat: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any
}
const LikeDataEntry: React.SFC<any> = (props) => { // TODO: remove any
const data_string = JSON.stringify({like: true});
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
<div className="icon-buttons">
<NavLink
to={`/multi-edit/${props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy
</NavLink>
</div>
</dt>
<dd>
{
(props.value != null)?
(props.value === 1)?
`${props.value} person likes this building`
: `${props.value} people like this building`
: '\u00A0'
}
</dd>
{
(props.user_building_like)? <dd>&hellip;including you!</dd> : ''
}
</Fragment>
);
}
LikeDataEntry.propTypes = {
title: PropTypes.string,
cat: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.any,
user_building_like: PropTypes.bool
}
const MultiDataEntry: React.SFC<any> = (props) => { // TODO: remove any
let content;
if (props.value && props.value.length) {
content = <ul>{
props.value.map((item, index) => {
return <li key={index}><a href={sanitiseURL(item)}>{item}</a></li>
})
}</ul>
} else {
content = '\u00A0'
}
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copying && props.cat && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input type="checkbox" checked={props.copy}
onChange={() => props.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
</dt>
<dd>{ content }</dd>
</Fragment>
);
}
MultiDataEntry.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.string)
}
const UPRNsDataEntry = (props) => {
const uprns = props.value || [];
const noParent = uprns.filter(uprn => uprn.parent_uprn == null);
const withParent = uprns.filter(uprn => uprn.parent_uprn != null);
return (
<Fragment>
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</dt>
<dd><ul className="uprn-list">
<Fragment>{
noParent.length?
noParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn}</li>
))
: '\u00A0'
}</Fragment>
{
withParent.length?
<details>
<summary>Children</summary>
{
withParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn} (child of {uprn.parent_uprn})</li>
))
}
</details>
: null
}
</ul></dd>
</Fragment>
)
}
UPRNsDataEntry.propTypes = {
title: PropTypes.string,
tooltip: PropTypes.string,
value: PropTypes.arrayOf(PropTypes.shape({
uprn: PropTypes.string.isRequired,
parent_uprn: PropTypes.string
}))
}
export default BuildingView;

View File

@ -0,0 +1,20 @@
import React, { Fragment } from 'react';
import { Link } from 'react-router-dom';
import InfoBox from '../components/info-box';
interface BuildingNotFoundProps {
mode: string;
}
const BuildingNotFound: React.FunctionComponent<BuildingNotFoundProps> = (props) => (
<Fragment>
<InfoBox msg="We can't find that one anywhere - try the map again?" />
<div className="buttons-container ml-3 mr-3">
<Link to={`/${props.mode}/categories`} className="btn btn-secondary">Back to categories</Link>
</div>
</Fragment>
);
export default BuildingNotFound;

View File

@ -0,0 +1,132 @@
import React from 'react';
import { Building } from '../models/building';
import BuildingNotFound from './building-not-found';
import AgeContainer from './data-containers/age';
import CommunityContainer from './data-containers/community';
import ConstructionContainer from './data-containers/construction';
import LikeContainer from './data-containers/like';
import LocationContainer from './data-containers/location';
import PlanningContainer from './data-containers/planning';
import SizeContainer from './data-containers/size';
import StreetscapeContainer from './data-containers/streetscape';
import SustainabilityContainer from './data-containers/sustainability';
import TeamContainer from './data-containers/team';
import TypeContainer from './data-containers/type';
import UseContainer from './data-containers/use';
interface BuildingViewProps {
cat: string;
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
user?: any;
selectBuilding: (building: Building) => void;
}
/**
* Top-level container for building view/edit form
*
* @param props
*/
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
switch (props.cat) {
case 'location':
return <LocationContainer
{...props}
title="Location"
help="https://pages.colouring.london/location"
intro="Where are the buildings? Address, location and cross-references."
/>;
case 'use':
return <UseContainer
{...props}
inactive={false}
title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
help="https://pages.colouring.london/use"
/>;
case 'type':
return <TypeContainer
{...props}
inactive={false}
title="Type"
intro="How were buildings previously used?"
help="https://www.pages.colouring.london/buildingtypology"
/>;
case 'age':
return <AgeContainer
{...props}
title="Age"
help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change."
/>;
case 'size':
return <SizeContainer
{...props}
title="Size &amp; Shape"
intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize"
/>;
case 'construction':
return <ConstructionContainer
{...props}
title="Construction"
intro="How are buildings built? Coming soon…"
help="https://pages.colouring.london/construction"
inactive={true}
/>;
case 'team':
return <TeamContainer
{...props}
title="Team"
intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team"
inactive={true}
/>;
case 'sustainability':
return <SustainabilityContainer
{...props}
title="Sustainability"
intro="Are buildings energy efficient?"
help="https://pages.colouring.london/sustainability"
inactive={false}
/>;
case 'streetscape':
return <StreetscapeContainer
{...props}
title="Streetscape"
intro="What's the building's context? Coming soon…"
help="https://pages.colouring.london/streetscape"
inactive={true}
/>;
case 'community':
return <CommunityContainer
{...props}
title="Community"
intro="How does this building work for the local community?"
help="https://pages.colouring.london/community"
inactive={true}
/>;
case 'planning':
return <PlanningContainer
{...props}
title="Planning"
intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning"
/>;
case 'like':
return <LikeContainer
{...props}
title="Like Me!"
intro="Do you like the building and think it contributes to the city?"
help="https://pages.colouring.london/likeme"
/>;
default:
return <BuildingNotFound mode="view" />;
}
};
export default BuildingView;

View File

@ -0,0 +1,51 @@
/**
* Data categories
*/
.data-category-list {
padding: 0 0 0.75rem;
text-align: center;
list-style: none;
margin: 0 0 0 0.2rem;
text-align: center;
}
.data-category-list li {
position: relative;
display: inline-block;
vertical-align: bottom;
width: 10rem;
height: 10rem;
margin: 0.375rem;
box-shadow: 0 0 2px 5px #ffffff;
transition: box-shadow 0.2s;
}
.data-category-list li:hover {
box-shadow: 0 0 2px 5px #00ffff;
}
.data-category-list a {
color: #222;
text-decoration: none;
}
.data-category-list .category-link {
display: flex;
justify-content: center;
align-items: center;
padding: 0.1em;
width: 100%;
height: 100%;
}
.data-category-list .category-link:hover,
.data-category-list .category-link:active,
.data-category-list .category-link:focus {
color: #222;
}
.data-category-list .category {
text-align: center;
font-size: 1.4em;
margin: 0 0 0.5em;
}
.data-category-list .description {
text-align: center;
font-size: 0.9em;
margin: 0;
}

View File

@ -0,0 +1,157 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import './categories.css';
interface CategoriesProps {
mode: 'view' | 'edit' | 'multi-edit';
building_id?: number;
}
const Categories: React.FC<CategoriesProps> = (props) => (
<ol className="data-category-list">
<Category
title="Location"
desc="Where's the building?"
slug="location"
help="https://pages.colouring.london/location"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Land Use"
desc="What's it used for?"
slug="use"
help="https://pages.colouring.london/use"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Type"
desc="Building type"
slug="type"
help="https://pages.colouring.london/buildingtypology"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Age"
desc="Age & history"
slug="age"
help="https://pages.colouring.london/age"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Size &amp; Shape"
desc="Form & scale"
slug="size"
help="https://pages.colouring.london/shapeandsize"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Construction"
desc="Methods & materials"
slug="construction"
help="https://pages.colouring.london/construction"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Streetscape"
desc="Environment"
slug="streetscape"
help="https://pages.colouring.london/greenery"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Team"
desc="Builder & designer"
slug="team"
help="https://pages.colouring.london/team"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Sustainability"
desc="Performance"
slug="sustainability"
help="https://pages.colouring.london/sustainability"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Community"
desc="Public asset?"
slug="community"
help="https://pages.colouring.london/community"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Planning"
desc="Special controls?"
slug="planning"
help="https://pages.colouring.london/planning"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<Category
title="Like Me?"
desc="Adds to the city?"
slug="like"
help="https://pages.colouring.london/likeme"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
</ol>
);
interface CategoryProps {
mode: 'view' | 'edit' | 'multi-edit';
building_id?: number;
slug: string;
title: string;
desc: string;
help: string;
inactive: boolean;
}
const Category: React.FC<CategoryProps> = (props) => {
let categoryLink = `/${props.mode}/${props.slug}`;
if (props.building_id != undefined) categoryLink += `/${props.building_id}`;
return (
<li className={`category-block ${props.slug} background-${props.slug}`}>
<NavLink
className="category-link"
to={categoryLink}
title={
(props.inactive)?
'Coming soon… Click more info for details.'
: 'View/Edit Map'
}>
<div className="category-title-container">
<h3 className="category">{props.title}</h3>
<p className="description">{props.desc}</p>
</div>
</NavLink>
</li>
);
};
export default Categories;

View File

@ -0,0 +1,24 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { BackIcon }from '../components/icons';
interface ContainerHeaderProps {
cat?: string;
backLink: string;
title: string;
}
const ContainerHeader: React.FunctionComponent<ContainerHeaderProps> = (props) => (
<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.children}
</nav>
</header>
);
export default ContainerHeader;

View File

@ -0,0 +1,38 @@
import React, { Fragment } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface CheckboxDataEntryProps extends BaseDataEntryProps {
value: boolean;
}
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<div className="form-check">
<input className="form-check-input" type="checkbox"
id={props.slug}
name={props.slug}
checked={!!props.value}
disabled={props.mode === 'view' || props.disabled}
onChange={e => props.onChange(props.slug, e.target.checked)}
/>
<label
htmlFor={props.slug}
className="form-check-label">
{props.title}
</label>
</div>
</Fragment>
);
};
export default CheckboxDataEntry;

View File

@ -0,0 +1,17 @@
.data-entry-group-header {
cursor: pointer;
position: relative;
}
.data-entry-group-header .data-entry-group-title {
position: absolute;
left: 1rem;
}
.data-entry-group-count {
font-size: 0.8em;
color: gray;
}
.data-entry-group-body {
padding-left: 1rem;
}

View File

@ -0,0 +1,43 @@
import React, { Fragment, useState } from "react";
import './data-entry-group.css';
import { DownIcon, RightIcon } from "../../components/icons";
interface DataEntryGroupProps {
/** Name of the group */
name: string;
/** Whether the group should be collapsed initially */
collapsed?: boolean;
}
const DataEntryGroup: React.FunctionComponent<DataEntryGroupProps> = (props) => {
const {collapsed: initialCollapsed = true} = props;
const [collapsed, setCollapsed] = useState(initialCollapsed);
return (
<Fragment>
<div className='data-entry-group-header' onClick={() => setCollapsed(!collapsed)}>
<CollapseIcon collapsed={collapsed} />
<span className='data-entry-group-title'>
{props.name}
<span className='data-entry-group-count'>{` (${React.Children.count(props.children)} attributes)`}</span>
</span>
</div>
<div className={`data-entry-group-body ${collapsed ? 'collapse' : ''}`}>
{props.children}
</div>
</Fragment>
);
};
const CollapseIcon: React.FunctionComponent<{collapsed: boolean}> = (props) => (
<span className="collapse-icon">
{props.collapsed ? <RightIcon/> : <DownIcon/>}
</span>
);
export {
DataEntryGroup
};

View File

@ -0,0 +1,56 @@
import React, { Fragment } from 'react';
import { CopyProps } from '../data-containers/category-view-props';
import { DataTitleCopyable } from './data-title';
interface BaseDataEntryProps {
slug: string;
title: string;
tooltip?: string;
disabled?: boolean;
copy?: CopyProps; // CopyProps clashes with propTypes
mode?: 'view' | 'edit' | 'multi-edit';
onChange?: (key: string, value: any) => void;
}
interface DataEntryProps extends BaseDataEntryProps {
value?: string;
maxLength?: number;
placeholder?: string;
valueTransform?: (string) => string;
}
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined || props.value == ''}
copy={props.copy}
/>
<input className="form-control" type="text"
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.maxLength}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={e => {
const transform = props.valueTransform || (x => x);
const val = e.target.value === '' ?
null :
transform(e.target.value);
props.onChange(props.slug, val);
}}
/>
</Fragment>
);
};
export default DataEntry;
export {
BaseDataEntryProps
};

View File

@ -0,0 +1,54 @@
import React from 'react';
import Tooltip from '../../components/tooltip';
import { CopyProps } from '../data-containers/category-view-props';
interface DataTitleProps {
title: string;
tooltip: string;
}
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
return (
<dt>
{ props.title }
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
</dt>
);
};
interface DataTitleCopyableProps {
title: string;
tooltip?: string;
slug: string;
disabled?: boolean;
copy?: CopyProps;
}
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
return (
<div className="data-title">
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
{ (props.copy && props.copy.copying && props.slug && !props.disabled)?
<div className="icon-buttons">
<label className="icon-button copy">
Copy
<input
type="checkbox"
checked={props.copy.copyingKey(props.slug)}
onChange={() => props.copy.toggleCopyAttribute(props.slug)}/>
</label>
</div>
: null
}
<label htmlFor={props.slug}>
{ props.title }
</label>
</div>
);
};
export default DataTitle;
export { DataTitleCopyable };

View File

@ -0,0 +1,51 @@
import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import Tooltip from '../../components/tooltip';
interface LikeDataEntryProps {
mode: 'view' | 'edit' | 'multi-edit';
userLike: boolean;
totalLikes: number;
onLike: (userLike: boolean) => void;
}
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
const data_string = JSON.stringify({like: true});
return (
<Fragment>
<div className="data-title">
<Tooltip text="People who like the building and think it contributes to the city." />
<div className="icon-buttons">
<NavLink
to={`/multi-edit/like?data=${data_string}`}
className="icon-button like">
Like more
</NavLink>
</div>
<label>Number of likes</label>
</div>
<p>
{
(props.totalLikes != null)?
(props.totalLikes === 1)?
`${props.totalLikes} person likes this building`
: `${props.totalLikes} people like this building`
: "0 people like this building so far - you could be the first!"
}
</p>
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name="like"
checked={!!props.userLike}
disabled={props.mode === 'view'}
onChange={e => props.onLike(e.target.checked)}
/>
I like this building and think it contributes to the city!
</label>
</Fragment>
);
};
export default LikeDataEntry;

View File

@ -0,0 +1,105 @@
import React, { Component, Fragment } from 'react';
import { sanitiseURL } from '../../helpers';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface MultiDataEntryProps extends BaseDataEntryProps {
value: string[];
placeholder: string;
}
class MultiDataEntry extends Component<MultiDataEntryProps> {
constructor(props) {
super(props);
this.edit = this.edit.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
this.getValues = this.getValues.bind(this);
}
getValues() {
return (this.props.value && this.props.value.length)? this.props.value : [null];
}
edit(event) {
const editIndex = +event.target.dataset.index;
const editItem = event.target.value;
const oldValues = this.getValues();
const values = oldValues.map((item, i) => {
return i === editIndex ? editItem : item;
});
this.props.onChange(this.props.slug, values);
}
add(event) {
event.preventDefault();
const values = this.getValues().concat('');
this.props.onChange(this.props.slug, values);
}
remove(event){
const removeIndex = +event.target.dataset.index;
const values = this.getValues().filter((_, i) => {
return i !== removeIndex;
});
this.props.onChange(this.props.slug, values);
}
render() {
const values = this.getValues();
const props = this.props;
return <Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined || props.value.length === 0}
/>
{
(props.mode === 'view')?
(props.value && props.value.length)?
<ul className="data-link-list">
{
props.value.map((item, index) => {
return <li
key={index}
className="form-control">
<a href={sanitiseURL(item)}>{item}</a>
</li>;
})
}
</ul>
:'\u00A0'
: values.map((item, i) => (
<div className="input-group" key={i}>
<input className="form-control" type="text"
key={`${props.slug}-${i}`} name={`${props.slug}-${i}`}
data-index={i}
value={item || ''}
placeholder={props.placeholder}
disabled={props.disabled}
onChange={this.edit}
/>
<div className="input-group-append">
<button type="button" onClick={this.remove}
title="Remove"
data-index={i} className="btn btn-outline-dark"></button>
</div>
</div>
))
}
<button
type="button"
title="Add"
onClick={this.add}
disabled={props.mode === 'view'}
className="btn btn-outline-dark">+</button>
</Fragment>;
}
}
export default MultiDataEntry;

View File

@ -0,0 +1,47 @@
import React, { Fragment } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface NumericDataEntryProps extends BaseDataEntryProps {
value?: number;
placeholder?: string;
step?: number;
min?: number;
max?: number;
}
const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<input
className="form-control"
type="number"
id={props.slug}
name={props.slug}
value={props.value == undefined ? '' : props.value}
step={props.step == undefined ? 1 : props.step}
max={props.max}
min={props.min}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ? null : parseFloat(e.target.value)
)
}
/>
</Fragment>
);
};
export default NumericDataEntry;

View File

@ -0,0 +1,46 @@
import React, { Fragment } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface SelectDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
options: string[];
}
const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<select className="form-control"
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.mode === 'view' || props.disabled}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)}
>
<option value="">{props.placeholder}</option>
{
props.options.map(option => (
<option key={option} value={option}>{option}</option>
))
}
</select>
</Fragment>
);
};
export default SelectDataEntry;

View File

@ -0,0 +1,44 @@
import React, { Fragment } from 'react';
import { BaseDataEntryProps } from './data-entry';
import { DataTitleCopyable } from './data-title';
interface TextboxDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
maxLength?: number;
}
const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined}
copy={props.copy}
/>
<textarea
className="form-control"
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.maxLength}
rows={5}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)
}
></textarea>
</Fragment>
);
};
export default TextboxDataEntry;

View File

@ -0,0 +1,59 @@
import React, { Fragment } from 'react';
import DataTitle from './data-title';
interface UPRNsDataEntryProps {
title: string;
tooltip: string;
value: {
uprn: string;
parent_uprn?: string;
}[];
}
const UPRNsDataEntry: React.FC<UPRNsDataEntryProps> = (props) => {
const uprns = props.value || [];
const noParent = uprns.filter(uprn => uprn.parent_uprn == null);
const withParent = uprns.filter(uprn => uprn.parent_uprn != null);
return (
<Fragment>
<DataTitle
title={props.title}
tooltip={props.tooltip}
/>
<dd>
{
noParent.length?
<ul className="uprn-list">
{
noParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn}</li>
))
}
</ul>
: '\u00A0'
}
{
withParent.length?
<details>
<summary>Children</summary>
<ul className="uprn-list">
{
withParent.map(uprn => (
<li key={uprn.uprn}>{uprn.uprn} (child of {uprn.parent_uprn})</li>
))
}
</ul>
</details>
: null
}
</dd>
</Fragment>
);
};
export default UPRNsDataEntry;

View File

@ -0,0 +1,77 @@
import React, { Component, Fragment } from 'react';
import { dataFields } from '../../data_fields';
import { CopyProps } from '../data-containers/category-view-props';
import NumericDataEntry from './numeric-data-entry';
interface YearDataEntryProps {
year: number;
upper: number;
lower: number;
copy?: CopyProps;
mode?: 'view' | 'edit' | 'multi-edit';
onChange?: (key: string, value: any) => void;
}
class YearDataEntry extends Component<YearDataEntryProps, any> {
constructor(props) {
super(props);
this.state = {
year: props.year,
upper: props.upper,
lower: props.lower,
decade: Math.floor(props.year / 10) * 10,
century: Math.floor(props.year / 100) * 100
};
}
// TODO add dropdown for decade, century
// TODO roll in first/last year estimate
// TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() {
const props = this.props;
const currentYear = new Date().getFullYear();
return (
<Fragment>
<NumericDataEntry
title={dataFields.date_year.title}
slug="date_year"
value={props.year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
min={1}
max={currentYear}
// "type": "year_estimator"
/>
<NumericDataEntry
title={dataFields.date_upper.title}
slug="date_upper"
value={props.upper}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.date_upper.tooltip}
/>
<NumericDataEntry
title={dataFields.date_lower.title}
slug="date_lower"
value={props.lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.date_lower.tooltip}
/>
</Fragment>
);
}
}
export default YearDataEntry;

View File

@ -0,0 +1,344 @@
import React, { Fragment } from 'react';
import { NavLink, Redirect } from 'react-router-dom';
import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers';
import { Building } from '../models/building';
import { User } from '../models/user';
import ContainerHeader from './container-header';
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
import { CopyControl } from './header-buttons/copy-control';
import { ViewEditControl } from './header-buttons/view-edit-control';
interface DataContainerProps {
title: string;
cat: string;
intro: string;
help: string;
inactive?: boolean;
user?: User;
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
selectBuilding: (building: Building) => void;
}
interface DataContainerState {
error: string;
copying: boolean;
keys_to_copy: {[key: string]: boolean};
currentBuildingId: number;
currentBuildingRevisionId: number;
buildingEdits: Partial<Building>;
}
/**
* Shared functionality for view/edit forms
*
* See React Higher-order-component docs for the pattern
* - https://reactjs.org/docs/higher-order-components.html
*
* @param WrappedComponent
*/
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
constructor(props) {
super(props);
this.state = {
error: undefined,
copying: false,
keys_to_copy: {},
buildingEdits: {},
currentBuildingId: undefined,
currentBuildingRevisionId: undefined
};
this.handleChange = this.handleChange.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
static getDerivedStateFromProps(props, state) {
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
return {
error: undefined,
copying: false,
keys_to_copy: {},
buildingEdits: {},
currentBuildingId: newBuildingId,
currentBuildingRevisionId: newBuildingRevisionId
};
}
return null;
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
});
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* @param {string} key
*/
toggleCopyAttribute(key: string) {
const keys = {...this.state.keys_to_copy};
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
keys[key] = true;
}
this.setState({
keys_to_copy: keys
});
}
isEdited() {
const edits = this.state.buildingEdits;
// check if the edits object has any fields
return Object.entries(edits).length !== 0;
}
clearEdits() {
this.setState({
buildingEdits: {}
});
}
getEditedBuilding() {
if(this.isEdited()) {
return Object.assign({}, this.props.building, this.state.buildingEdits);
} else {
return {...this.props.building};
}
}
updateBuildingState(key: string, value: any) {
const newBuilding = this.getEditedBuilding();
newBuilding[key] = value;
const [forwardPatch] = compareObjects(this.props.building, newBuilding);
this.setState({
buildingEdits: forwardPatch
});
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} name
* @param {*} value
*/
handleChange(name: string, value: any) {
this.updateBuildingState(name, value);
}
handleReset() {
this.clearEdits();
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {*} event
*/
async handleLike(like: boolean) {
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}/like.json`,
{like: like}
);
if (data.error) {
this.setState({error: data.error});
} else {
this.props.selectBuilding(data);
this.updateBuildingState('likes_total', data.likes_total);
}
} catch(err) {
this.setState({error: err});
}
}
async handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined});
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}.json`,
this.state.buildingEdits
);
if (data.error) {
this.setState({error: data.error});
} else {
this.props.selectBuilding(data);
}
} catch(err) {
this.setState({error: err});
}
}
render() {
if (this.props.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />;
}
const currentBuilding = this.getEditedBuilding();
const values_to_copy = {};
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = currentBuilding[key];
}
const data_string = JSON.stringify(values_to_copy);
const copy: CopyProps = {
copying: this.state.copying,
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
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 ?
<Fragment>
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
<WrappedComponent
intro={this.props.intro}
building={undefined}
building_like={undefined}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
/>
</Fragment> :
this.props.building != undefined ?
<form
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<ErrorBox msg={this.state.error} />
{
this.props.cat !== 'like' ? // special-case for likes
<div className="buttons-container with-space">
<button
type="submit"
className="btn btn-primary"
disabled={!edited}
aria-disabled={!edited}>
Save
</button>
{
edited ?
<button
type="button"
className="btn btn-warning"
onClick={this.handleReset}
>
Discard changes
</button> :
null
}
</div> :
null
}
</Fragment>
: null
}
<WrappedComponent
intro={this.props.intro}
building={currentBuilding}
building_like={this.props.building_like}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
/>
</form> :
<InfoBox msg="Select a building to view data"></InfoBox>
}
</div>
</section>
);
}
};
};
export default withCopyEdit;

View File

@ -0,0 +1,91 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import MultiDataEntry from '../data-components/multi-data-entry';
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 withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Age view/edit section
*/
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
const currentYear = new Date().getFullYear();
return (
<Fragment>
<YearDataEntry
year={props.building.date_year}
upper={props.building.date_upper}
lower={props.building.date_lower}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.facade_year.title}
slug="facade_year"
value={props.building.facade_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
max={currentYear}
tooltip={dataFields.facade_year.tooltip}
/>
<SelectDataEntry
title={dataFields.date_source.title}
slug="date_source"
value={props.building.date_source}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_source.tooltip}
placeholder=""
options={[
"Expert knowledge of building",
"Expert estimate from image",
"Survey of London",
"Pevsner Guides",
"Victoria County History",
"Local history publication",
"Other publication",
"National Heritage List for England",
"Other database or gazetteer",
"Historical map",
"Other archive document",
"Film/Video",
"Other website",
"Other"
]}
/>
<TextboxDataEntry
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={dataFields.date_source_detail.tooltip}
/>
<MultiDataEntry
title={dataFields.date_link.title}
slug="date_link"
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
/>
</Fragment>
);
};
const AgeContainer = withCopyEdit(AgeView);
export default AgeContainer;

View File

@ -0,0 +1,21 @@
interface CopyProps {
copying: boolean;
toggleCopying: () => void;
toggleCopyAttribute: (key: string) => void;
copyingKey: (key: string) => boolean;
}
interface CategoryViewProps {
intro: string;
building: any; // TODO: add Building type with all fields
building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit';
copy: CopyProps;
onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void;
}
export {
CategoryViewProps,
CopyProps
};

View File

@ -0,0 +1,34 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Community view/edit section
*/
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
<li>Is this a publicly owned building?</li>
{
// "slug": "community_publicly_owned",
// "type": "checkbox"
}
<li>Has this building ever been used for community or public services activities?</li>
{
// "slug": "community_past_public",
// "type": "checkbox"
}
<li>Would you describe this building as a community asset?</li>
{
// "slug": "community_asset",
// "type": "checkbox"
}
</ul>
</Fragment>
);
const CommunityContainer = withCopyEdit(CommunityView);
export default CommunityContainer;

View File

@ -0,0 +1,55 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
/**
* Construction view/edit section
*/
const ConstructionView = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Construction system</li>
{
// "disabled": true,
// "slug": "construction_system",
// "type": "text"
}
<li>Primary materials</li>
{
// "disabled": true,
// "slug": "construction_primary_material",
// "type": "text"
}
<li>Secondary materials</li>
{
// "disabled": true,
// "slug": "construction_secondary_material",
// "type": "text"
}
<li>Roofing material</li>
{
// "disabled": true,
// "slug": "construction_roofing_material",
// "type": "text"
}
<li>Percentage of facade glazed</li>
{
// "disabled": true,
// "slug": "construction_facade_percentage_glazed",
// "type": "number",
// "step": 5
}
<li>BIM reference or link</li>
{
// "disabled": true,
// "slug": "construction_bim_reference",
// "type": "text",
// "placeholder": "https://..."
}
</ul>
</Fragment>
);
const ConstructionContainer = withCopyEdit(ConstructionView);
export default ConstructionContainer;

View File

@ -0,0 +1,23 @@
import React, { Fragment } from 'react';
import LikeDataEntry from '../data-components/like-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Like view/edit section
*/
const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<LikeDataEntry
userLike={props.building_like}
totalLikes={props.building.likes_total}
mode={props.mode}
onLike={props.onLike}
/>
</Fragment>
);
const LikeContainer = withCopyEdit(LikeView);
export default LikeContainer;

View File

@ -0,0 +1,125 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry';
import withCopyEdit from '../data-container';
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={dataFields.location_name.title}
slug="location_name"
value={props.building.location_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
tooltip={dataFields.location_name.tooltip}
placeholder="Building name (if any)"
disabled={true}
/>
<NumericDataEntry
title={dataFields.location_number.title}
slug="location_number"
value={props.building.location_number}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={1}
min={1}
/>
<DataEntry
title={dataFields.location_street.title}
slug="location_street"
value={props.building.location_street}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title={dataFields.location_line_two.title}
slug="location_line_two"
value={props.building.location_line_two}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title={dataFields.location_town.title}
slug="location_town"
value={props.building.location_town}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.location_postcode.title}
slug="location_postcode"
value={props.building.location_postcode}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
maxLength={8}
valueTransform={x=>x.toUpperCase()}
/>
<DataEntry
title={dataFields.ref_toid.title}
slug="ref_toid"
value={props.building.ref_toid}
mode={props.mode}
copy={props.copy}
tooltip={dataFields.ref_toid.tooltip}
onChange={props.onChange}
disabled={true}
/>
<UPRNsDataEntry
title={dataFields.uprns.title}
value={props.building.uprns}
tooltip={dataFields.uprns.tooltip}
/>
<DataEntry
title={dataFields.ref_osm_id.title}
slug="ref_osm_id"
value={props.building.ref_osm_id}
mode={props.mode}
copy={props.copy}
tooltip={dataFields.ref_osm_id.tooltip}
maxLength={20}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.location_latitude.title}
slug="location_latitude"
value={props.building.location_latitude}
mode={props.mode}
copy={props.copy}
step={0.00001}
min={-90}
max={90}
placeholder="Latitude, e.g. 51.5467"
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.location_longitude.title}
slug="location_longitude"
value={props.building.location_longitude}
mode={props.mode}
copy={props.copy}
step={0.00001}
min={-180}
max={180}
placeholder="Longitude, e.g. -0.0586"
onChange={props.onChange}
/>
</Fragment>
);
const LocationContainer = withCopyEdit(LocationView);
export default LocationContainer;

View File

@ -0,0 +1,209 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import SelectDataEntry from '../data-components/select-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Planning view/edit section
*/
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntry
title={dataFields.planning_portal_link.title}
slug="planning_portal_link"
value={props.building.planning_portal_link}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntryGroup name="Listing and protections" >
<CheckboxDataEntry
title={dataFields.planning_in_conservation_area.title}
slug="planning_in_conservation_area"
value={props.building.planning_in_conservation_area}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_conservation_area_name.title}
slug="planning_conservation_area_name"
value={props.building.planning_conservation_area_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title={dataFields.planning_in_list.title}
slug="planning_in_list"
value={props.building.planning_in_list}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_list_id.title}
slug="planning_list_id"
value={props.building.planning_list_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title={dataFields.planning_list_cat.title}
slug="planning_list_cat"
value={props.building.planning_list_cat}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
options={[
"Listed Building",
"Scheduled Monument",
"World Heritage Site",
"Building Preservation Notice",
"None"
]}
/>
<SelectDataEntry
title={dataFields.planning_list_grade.title}
slug="planning_list_grade"
value={props.building.planning_list_grade}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
options={[
"I",
"II*",
"II",
"None"
]}
/>
<DataEntry
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}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_world_list_id.title}
slug="planning_world_list_id"
value={props.building.planning_world_list_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title={dataFields.planning_in_glher.title}
slug="planning_in_glher"
value={props.building.planning_in_glher}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_glher_url.title}
slug="planning_glher_url"
value={props.building.planning_glher_url}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title={dataFields.planning_in_apa.title}
slug="planning_in_apa"
value={props.building.planning_in_apa}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_apa_name.title}
slug="planning_apa_name"
value={props.building.planning_apa_name}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_apa_tier.title}
slug="planning_apa_tier"
value={props.building.planning_apa_tier}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
title={dataFields.planning_in_local_list.title}
slug="planning_in_local_list"
value={props.building.planning_in_local_list}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title={dataFields.planning_local_list_url.title}
slug="planning_local_list_url"
value={props.building.planning_local_list_url}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<CheckboxDataEntry
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}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
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}
copy={props.copy}
onChange={props.onChange}
/>
</DataEntryGroup>
<DataEntryGroup name="Demolition and demolition history">
<CheckboxDataEntry
title={dataFields.planning_demolition_proposed.title}
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<CheckboxDataEntry
title={dataFields.planning_demolition_complete.title}
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title={dataFields.planning_demolition_history.title}
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
</DataEntryGroup>
</Fragment>
);
const PlanningContainer = withCopyEdit(PlanningView);
export default PlanningContainer;

View File

@ -0,0 +1,163 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import { DataEntryGroup } from '../data-components/data-entry-group';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Size view/edit section
*/
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntryGroup name="Storeys">
<NumericDataEntry
title={dataFields.size_storeys_core.title}
slug="size_storeys_core"
value={props.building.size_storeys_core}
mode={props.mode}
copy={props.copy}
tooltip={dataFields.size_storeys_core.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_storeys_attic.title}
slug="size_storeys_attic"
value={props.building.size_storeys_attic}
mode={props.mode}
copy={props.copy}
tooltip={dataFields.size_storeys_attic.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_storeys_basement.title}
slug="size_storeys_basement"
value={props.building.size_storeys_basement}
mode={props.mode}
copy={props.copy}
tooltip={dataFields.size_storeys_basement.tooltip}
onChange={props.onChange}
step={1}
min={0}
/>
</DataEntryGroup>
<DataEntryGroup name="Height" collapsed={false}>
<NumericDataEntry
title={dataFields.size_height_apex.title}
slug="size_height_apex"
value={props.building.size_height_apex}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_height_eaves.title}
slug="size_height_eaves"
disabled={true}
value={props.building.size_height_eaves}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
</DataEntryGroup>
<DataEntryGroup name="Floor area">
<NumericDataEntry
title={dataFields.size_floor_area_ground.title}
slug="size_floor_area_ground"
value={props.building.size_floor_area_ground}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_floor_area_total.title}
slug="size_floor_area_total"
value={props.building.size_floor_area_total}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
</DataEntryGroup>
<NumericDataEntry
title={dataFields.size_width_frontage.title}
slug="size_width_frontage"
value={props.building.size_width_frontage}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
/>
<NumericDataEntry
title={dataFields.size_plot_area_total.title}
slug="size_plot_area_total"
value={props.building.size_plot_area_total}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
disabled={true}
/>
<NumericDataEntry
title={dataFields.size_far_ratio.title}
slug="size_far_ratio"
value={props.building.size_far_ratio}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
step={0.1}
min={0}
disabled={true}
/>
<SelectDataEntry
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}
copy={props.copy}
onChange={props.onChange}
disabled={true}
options={[
"Flat",
"Pitched",
"Other"
]}
/>
</Fragment>
);
const SizeContainer = withCopyEdit(SizeView);
export default SizeContainer;

View File

@ -0,0 +1,25 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Streetscape view/edit section
*/
const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">
<li>Gardens</li>
<li>Trees</li>
<li>Green walls</li>
<li>Green roof</li>
<li>Proximity to parks and open greenspace</li>
<li>Building shading</li>
</ul>
</Fragment>
);
const StreetscapeContainer = withCopyEdit(StreetscapeView);
export default StreetscapeContainer;

View File

@ -0,0 +1,84 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
const BreeamRatingOptions = [
'Outstanding',
'Excellent',
'Very good',
'Good',
'Pass',
'Unclassified'
];
/**
* Sustainability view/edit section
*/
const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
title={dataFields.sust_breeam_rating.title}
slug="sust_breeam_rating"
value={props.building.sust_breeam_rating}
tooltip={dataFields.sust_breeam_rating.tooltip}
options={BreeamRatingOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title={dataFields.sust_dec.title}
slug="sust_dec"
value={props.building.sust_dec}
tooltip={dataFields.sust_dec.tooltip}
options={EnergyCategoryOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title={dataFields.sust_aggregate_estimate_epc.title}
slug="sust_aggregate_estimate_epc"
value={props.building.sust_aggregate_estimate_epc}
tooltip={dataFields.sust_aggregate_estimate_epc.tooltip}
options={EnergyCategoryOptions}
disabled={true}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.sust_retrofit_date.title}
slug="sust_retrofit_date"
value={props.building.sust_retrofit_date}
tooltip={dataFields.sust_retrofit_date.tooltip}
step={1}
min={1086}
max={new Date().getFullYear()}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.sust_life_expectancy.title}
slug="sust_life_expectancy"
value={props.building.sust_life_expectancy}
step={1}
min={1}
disabled={true}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
</Fragment>
);
};
const SustainabilityContainer = withCopyEdit(SustainabilityView);
export default SustainabilityContainer;

View File

@ -0,0 +1,31 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Team view/edit section
*/
const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>
<li>Construction and design team (original building)</li>
{
// "disabled": true,
// "slug": "team_original",
// "type": "text"
}
<li>Construction and design team (significant additional works)</li>
{
// "disabled": true,
// "slug": "team_after_original",
// "type": "text_multi"
}
</ul>
</Fragment>
);
const TeamContainer = withCopyEdit(TeamView);
export default TeamContainer;

View File

@ -0,0 +1,61 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
const AttachmentFormOptions = [
"Detached",
"Semi-Detached",
"End-Terrace",
"Mid-Terrace"
];
/**
* Type view/edit section
*/
const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
title={dataFields.building_attachment_form.title}
slug="building_attachment_form"
value={props.building.building_attachment_form}
tooltip={dataFields.building_attachment_form.tooltip}
options={AttachmentFormOptions}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title={dataFields.date_change_building_use.title}
slug="date_change_building_use"
value={props.building.date_change_building_use}
tooltip={dataFields.date_change_building_use.tooltip}
min={1086}
max={new Date().getFullYear()}
step={1}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
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}
onChange={props.onChange}
disabled={true}
/>
</Fragment>
);
};
const TypeContainer = withCopyEdit(TypeView);
export default TypeContainer;

View File

@ -0,0 +1,49 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry';
import MultiDataEntry from '../data-components/multi-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Use view/edit section
*/
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<InfoBox msg="This category is currently read-only. We are working on enabling its editing soon." />
<MultiDataEntry
title={dataFields.current_landuse_class.title}
slug="current_landuse_class"
value={props.building.current_landuse_class}
mode="view"
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use class..."
/>
<MultiDataEntry
title={dataFields.current_landuse_group.title}
slug="current_landuse_group"
value={props.building.current_landuse_group}
mode="view"
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use group..."
/>
<DataEntry
title={dataFields.current_landuse_order.title}
slug="current_landuse_order"
value={props.building.current_landuse_order}
mode="view"
copy={props.copy}
onChange={props.onChange}
/>
</Fragment>
);
const UseContainer = withCopyEdit(UseView);
export default UseContainer;

View File

@ -0,0 +1,18 @@
.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;
}
.edit-history-building-id {
font-size: 0.9em;
}

View File

@ -0,0 +1,81 @@
import React from 'react';
import { Link } from 'react-router-dom';
import './building-edit-summary.css';
import { Category, DataFieldDefinition, dataFields } from '../../data_fields';
import { arrayToDictionary, parseDate } from '../../helpers';
import { EditHistoryEntry } from '../../models/edit-history-entry';
import { CategoryEditSummary } from './category-edit-summary';
interface BuildingEditSummaryProps {
historyEntry: EditHistoryEntry;
showBuildingId?: boolean;
hyperlinkCategories?: boolean;
}
function formatDate(dt: Date) {
return dt.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
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(historyEntry.revision_timestamp))}</h2>
<h3 className="edit-history-username">By {historyEntry.username}</h3>
{
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>
);
};
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,50 @@
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: keyof typeof Category; // https://github.com/microsoft/TypeScript/issues/14106
fields: {
title: string;
value: any;
oldValue: any;
}[];
hyperlinkCategory: boolean;
hyperlinkTemplate?: string;
}
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

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

View File

@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import './edit-history.css';
import { apiGet } from '../../apiHelpers';
import { Building } from '../../models/building';
import { EditHistoryEntry } from '../../models/edit-history-entry';
import ContainerHeader from '../container-header';
import { BuildingEditSummary } from './building-edit-summary';
interface EditHistoryProps {
building: Building;
}
const EditHistory: React.FunctionComponent<EditHistoryProps> = (props) => {
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
useEffect(() => {
const fetchData = async () => {
const {history} = await apiGet(`/api/buildings/${props.building.building_id}/history.json`);
setHistory(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,30 @@
import React from 'react';
interface FieldEditSummaryProps {
title: string;
value: any;
oldValue: any;
}
function formatValue(value: any) {
if(typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if(Array.isArray(value)) {
return value.map(v => `"${v}"`).join(', ');
}
return value;
}
const FieldEditSummary: React.FunctionComponent<FieldEditSummaryProps> = props => (
<>
{props.title}:&nbsp;
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
&nbsp;
<code title="Value after edit" className='edit-history-diff new'>{formatValue(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,33 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { EditIcon, ViewIcon } from '../../components/icons';
import { Building } from '../../models/building';
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,95 @@
import { parse } from 'query-string';
import React from 'react';
import { Link, Redirect, RouteComponentProps } from 'react-router-dom';
import { parseJsonOrDefault } from '../../helpers';
import ErrorBox from '../components/error-box';
import { BackIcon } from '../components/icons';
import InfoBox from '../components/info-box';
import { dataFields } from '../data_fields';
import { User } from '../models/user';
import DataEntry from './data-components/data-entry';
import Sidebar from './sidebar';
interface MultiEditProps {
user?: User;
category: string;
dataString: string;
}
const MultiEdit: React.FC<MultiEditProps> = (props) => {
if (!props.user){
return <Redirect to="/sign-up.html" />;
}
if (props.category === 'like') {
// special case for likes
return (
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">Like me!</h2>
</header>
<form className='buttons-container'>
<InfoBox msg='Click all the buildings that you like and think contribute to the city!' />
<Link to='/view/like' className='btn btn-secondary'>Back to view</Link>
<Link to='/edit/like' className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>
);
}
let data = parseJsonOrDefault(props.dataString);
let error: string;
if(data == null) {
error = 'Invalid parameters supplied';
data = {};
} else if(Object.values(data).some(x => x == undefined)) {
error = 'Cannot copy empty values';
data = {};
}
return (
<Sidebar>
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<Link
className="icon-button back"
to={`/edit/${props.category}`}>
<BackIcon />
</Link>
<h2 className="h2">Copy {props.category} data</h2>
</header>
<form>
{
error ?
<ErrorBox msg={error} /> :
<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={info.title || `Unknown field (${key})`}
slug={key}
disabled={true}
value={data[key]}
/>
);
}))
}
</form>
<form className='buttons-container'>
<Link to={`/view/${props.category}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${props.category}`} className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>
);
};
export default MultiEdit;

View File

@ -0,0 +1,215 @@
/**
* Sidebar layout
*/
.info-container {
order: 1;
padding: 0 0 2em;
background: #fff;
overflow-y: auto;
height: 40%;
}
@media (min-width: 768px){
.info-container {
order: 0;
height: unset;
width: 23rem;
}
}
/**
* Data section headers
*/
.section-header {
display: block;
position: relative;
clear: both;
text-decoration: none;
color: #222;
padding: 0.75rem 0.25rem 0.5rem 0;
z-index: 1000;
}
@media (min-width: 768px) {
.section-header {
position: sticky;
top: 0;
}
}
.section-header h2,
.section-header .icon-buttons {
display: inline-block;
}
.section-header .icon-buttons {
position: absolute;
right: 0;
top: 7px;
padding: 0.7rem 0.5rem 0.5rem 0;
}
.icon-buttons .icon-button {
margin-left: 7px;
}
/**
* Icon buttons
*/
.icon-button {
display: inline-block;
background-color: transparent;
font-size: 0.8333rem;
outline: none;
border: none;
color: #222;
vertical-align: top;
}
.icon-button:hover {
color: #222;
text-decoration: none;
cursor: pointer;
}
.icon-button.tooltip-hint {
padding: 0;
}
.icon-button svg {
background-color: transparent;
transition: background-color color 0.2s;
display: inline-block;
color: #222;
margin-top: 2px;
width: 30px;
height: 30px;
padding: 6px;
border-radius: 15px;
margin: 0 0.05rem;
display: inline-block;
vertical-align: middle;
}
.svg-inline--fa.fa-w-11,
.svg-inline--fa.fa-w-16,
.svg-inline--fa.fa-w-18,
.svg-inline--fa.fa-w-8 {
width: 30px;
}
.icon-button.edit:active svg,
.icon-button.edit:hover svg,
.icon-button.view:active svg,
.icon-button.view:hover svg {
color: rgb(11, 225, 225);
}
.icon-button.help,
.icon-button.copy,
.icon-button.history {
margin-top: 4px;
}
.data-section label .icon-buttons .icon-button.copy {
margin-top: 0px;
}
.icon-button.copy:hover,
.icon-button.help:hover {
color: rgb(0, 81, 255)
}
.icon-button.tooltip-hint.active svg,
.icon-button.tooltip-hint:hover svg {
color: rgb(255, 11, 245);
}
.icon-button.close-edit svg {
margin-top: -1px;
}
.icon-button.close-edit:hover svg {
color: rgb(255, 72, 11)
}
.icon-button.save:hover svg {
color: rgb(11, 225, 72);
}
.data-title .icon-buttons {
float: right;
}
/* Back button */
.icon-button.back,
.icon-button.back:hover {
padding: 5px 1px;
background-color: transparent;
}
.icon-button.back:hover svg {
background-color: transparent;
}
/**
* Data list sections
*/
.section-body {
margin-top: 0.75em;
padding: 0 0.75em;
}
.data-section .h3 {
margin: 0;
}
.data-intro {
padding: 0 0.5rem 0 2.25rem;
margin-top: 0.5rem;
}
.data-section p {
font-size: 1rem;
margin: 0.5rem 0;
}
.data-section ul {
padding-left: 3.333rem;
font-size: 1rem;
}
.data-section li {
margin-bottom: 0.3rem;
}
.data-list {
margin: 0;
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.data-list a {
color: #555;
}
.data-list a:focus,
.data-list a:active,
.data-list a:hover {
color: #222;
}
.data-list dt,
.data-section label {
display: block;
margin: 0.5em 0 0;
font-size: 0.8333rem;
font-weight: normal;
color: #555;
}
.data-section input,
.data-section textarea,
.data-section select {
margin: 0 0 0.5em 0;
}
.data-list dd {
margin: 0 0 0.5rem;
line-height: 1.5;
white-space: pre;
}
.data-list .no-data {
color: #999;
}
.data-list dd ul {
list-style: none;
padding-left: 0;
}
.data-section .data-link-list {
padding: 0;
list-style: none;
margin-bottom: 0;
margin-top: 0.5rem;
}
.data-link-list li {
border-color: #6c757d;
border-radius: 0;
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import './sidebar.css';
const Sidebar: React.FC<{}> = (props) => (
<div id="sidebar" className="info-container">
{ props.children }
</div>
);
export default Sidebar;

View File

@ -0,0 +1,7 @@
.modal.modal-show {
display: block;
}
.modal.modal-hide {
display: none;
}

View File

@ -0,0 +1,61 @@
import React from 'react';
import './confirmation-modal.css';
interface ConfirmationModalProps {
show: boolean;
title: string;
description: string;
confirmButtonText?: string;
confirmButtonClass?: string;
cancelButtonClass?: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmationModal: React.FunctionComponent<ConfirmationModalProps> = ({
confirmButtonText = 'OK',
confirmButtonClass = 'btn-primary',
cancelButtonClass = '',
...props
}) => {
const modalShowClass = props.show ? 'modal-show': 'modal-hide';
return (
<div className={`modal ${modalShowClass}`} tabIndex={-1} role="dialog">
<div className="modal-backdrop">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{props.title}</h5>
<button
type="button"
className="close"
aria-label="Close"
onClick={() => props.onCancel()}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<p>{props.description}</p>
</div>
<div className="modal-footer">
<button
type="button"
className={`btn btn-block ${confirmButtonClass}`}
onClick={() => props.onConfirm()}
>{confirmButtonText}</button>
<button
type="button"
className={`btn btn-block ${cancelButtonClass}`}
onClick={() => props.onCancel()}
>Cancel</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ConfirmationModal;

View File

@ -1,7 +1,10 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
function ErrorBox(props){
interface ErrorBoxProps {
msg: string;
}
const ErrorBox: React.FC<ErrorBoxProps> = (props) => {
if (props.msg) {
console.error(props.msg);
}
@ -12,7 +15,7 @@ function ErrorBox(props){
(
<div className="alert alert-danger" role="alert">
{
(typeof props.msg === 'string' || props.msg instanceof String)?
typeof props.msg === 'string' ?
props.msg
: 'Unexpected error'
}
@ -21,10 +24,6 @@ function ErrorBox(props){
}
</Fragment>
);
}
ErrorBox.propTypes = {
msg: PropTypes.string
}
};
export default ErrorBox;

View File

@ -1,11 +1,11 @@
/**
* Mini-library of icons
*/
import React from 'react'
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
faAngleLeft, faCaretDown, faSearch } from '@fortawesome/free-solid-svg-icons'
import { library } from '@fortawesome/fontawesome-svg-core';
import { faAngleLeft, faCaretDown, faCaretRight, faCaretUp, faCheck, faCheckDouble,
faEye, faInfoCircle, faPaintBrush, faQuestionCircle, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
library.add(
faQuestionCircle,
@ -16,7 +16,10 @@ library.add(
faCheckDouble,
faAngleLeft,
faCaretDown,
faSearch
faCaretUp,
faCaretRight,
faSearch,
faEye
);
const HelpIcon = () => (
@ -31,6 +34,10 @@ const EditIcon = () => (
<FontAwesomeIcon icon="paint-brush" />
);
const ViewIcon = () => (
<FontAwesomeIcon icon="eye" />
);
const CloseIcon = () => (
<FontAwesomeIcon icon="times" />
);
@ -51,8 +58,29 @@ const DownIcon = () => (
<FontAwesomeIcon icon="caret-down" />
);
const UpIcon = () => (
<FontAwesomeIcon icon="caret-up" />
);
const RightIcon = () => (
<FontAwesomeIcon icon="caret-right" />
);
const SearchIcon = () => (
<FontAwesomeIcon icon="search" />
);
export { HelpIcon, InfoIcon, EditIcon, CloseIcon, SaveIcon, SaveDoneIcon, BackIcon, DownIcon, SearchIcon };
export {
HelpIcon,
InfoIcon,
EditIcon,
ViewIcon,
CloseIcon,
SaveIcon,
SaveDoneIcon,
BackIcon,
DownIcon,
UpIcon,
RightIcon,
SearchIcon
};

View File

@ -1,14 +1,17 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
const InfoBox = (props) => (
interface InfoBoxProps {
msg: string;
}
const InfoBox: React.FC<InfoBoxProps> = (props) => (
<Fragment>
{
(props.msg || props.children)?
(
<div className="alert alert-info" role="alert">
{
(typeof props.msg === 'string' || props.msg instanceof String)?
typeof props.msg === 'string' ?
props.msg
: 'Enjoy the colouring! Usual service should resume shortly.'
}
@ -21,9 +24,4 @@ const InfoBox = (props) => (
</Fragment>
);
InfoBox.propTypes = {
msg: PropTypes.string,
children: PropTypes.node
}
export default InfoBox;

View File

@ -5,8 +5,7 @@
font-family: 'glacial_cl', sans-serif;
text-transform: uppercase;
}
.logo,
.logo.navbar-brand {
.logo {
display: block;
padding: 0;
color: #000;
@ -27,19 +26,7 @@
font-size: 0.625em;
letter-spacing: 0;
}
.map-legend .logo {
padding: 0 0.5rem 0.5rem;
}
.map-legend .logo .logotype {
font-size: 1.9rem;
margin-bottom: -2px;
}
.map-legend .logo .row .cell {
background-color: #ccc;
height: 12px;
width: 12px;
animation: none !important;
}
.logo .grid {
position: relative;
top: -1px;
@ -60,51 +47,56 @@
height: 14px;
margin: 0 3px 0 0;
}
.logo .row:nth-child(1) .cell:nth-child(1) {
.logo.gray .cell {
background-color: #ccc;
}
.logo.animated .row:nth-child(1) .cell:nth-child(1) {
animation: pulse 87s infinite;
animation-delay: -1.5s;
}
.logo .row:nth-child(1) .cell:nth-child(2) {
.logo.animated .row:nth-child(1) .cell:nth-child(2) {
animation: pulse 52s infinite;
animation-delay: -0.5s;
}
.logo .row:nth-child(1) .cell:nth-child(3) {
.logo.animated .row:nth-child(1) .cell:nth-child(3) {
animation: pulse 79s infinite;
animation-delay: -6s;
}
.logo .row:nth-child(1) .cell:nth-child(4) {
.logo.animated .row:nth-child(1) .cell:nth-child(4) {
animation: pulse 55s infinite;
animation-delay: -10s;
}
.logo .row:nth-child(2) .cell:nth-child(1) {
.logo.animated .row:nth-child(2) .cell:nth-child(1) {
animation: pulse 64s infinite;
animation-delay: -7.2s;
}
.logo .row:nth-child(2) .cell:nth-child(2) {
.logo.animated .row:nth-child(2) .cell:nth-child(2) {
animation: pulse 98s infinite;
animation-delay: -25s;
}
.logo .row:nth-child(2) .cell:nth-child(3) {
.logo.animated .row:nth-child(2) .cell:nth-child(3) {
animation: pulse 51s infinite;
animation-delay: -35s;
}
.logo .row:nth-child(2) .cell:nth-child(4) {
.logo.animated .row:nth-child(2) .cell:nth-child(4) {
animation: pulse 76s infinite;
animation-delay: -20s;
}
.logo .row:nth-child(3) .cell:nth-child(1) {
.logo.animated .row:nth-child(3) .cell:nth-child(1) {
animation: pulse 52s infinite;
animation-delay: -3.5s;
}
.logo .row:nth-child(3) .cell:nth-child(2) {
.logo.animated .row:nth-child(3) .cell:nth-child(2) {
animation: pulse 79s infinite;
animation-delay: -8.5s;
}
.logo .row:nth-child(3) .cell:nth-child(3) {
.logo.animated .row:nth-child(3) .cell:nth-child(3) {
animation: pulse 65s infinite;
animation-delay: -4s;
}
.logo .row:nth-child(3) .cell:nth-child(4) {
.logo.animated .row:nth-child(3) .cell:nth-child(4) {
animation: pulse 54s infinite;
animation-delay: -17s;
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import './logo.css';
interface LogoProps {
variant: 'default' | 'animated' | 'gray';
}
/**
* Logo
*
* As link to homepage, used in top header
*/
const Logo: React.FunctionComponent<LogoProps> = (props) => {
const variantClass = props.variant === 'default' ? '' : props.variant;
return (
<div className={`logo ${variantClass}`} >
<LogoGrid />
<h1 className="logotype">
<span>Colouring</span>
<span>London</span>
</h1>
</div>
);
};
const LogoGrid: React.FunctionComponent = () => (
<div className="grid">
<div className="row">
<div className="cell background-location"></div>
<div className="cell background-use"></div>
<div className="cell background-type"></div>
<div className="cell background-age"></div>
</div>
<div className="row">
<div className="cell background-size"></div>
<div className="cell background-construction"></div>
<div className="cell background-streetscape"></div>
<div className="cell background-team"></div>
</div>
<div className="row">
<div className="cell background-sustainability"></div>
<div className="cell background-community"></div>
<div className="cell background-planning"></div>
<div className="cell background-like"></div>
</div>
</div>
);
export { Logo };

Some files were not shown because too many files have changed in this diff Show More