Merge branch 'master' into features/migrations_sustainability
This commit is contained in:
commit
5d8a0dd42b
@ -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
2
.gitignore
vendored
@ -18,6 +18,8 @@ etl/**/*.xls
|
||||
etl/**/*.xlsx
|
||||
etl/**/*.zip
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Cache
|
||||
app/tilecache/**/*.png
|
||||
app/tilecache/**/*.mbtiles
|
||||
|
@ -1,6 +1,6 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 8
|
||||
- 12
|
||||
cache: npm
|
||||
before_script:
|
||||
- cd $TRAVIS_BUILD_DIR/app && npm ci
|
||||
|
14
README.md
14
README.md
@ -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 -->
|
||||
|
||||
|
@ -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] < 5.55</Filter>
|
||||
<PolygonSymbolizer fill="#f7f4f9" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 5.55 and [size_height] < 7.73</Filter>
|
||||
<PolygonSymbolizer fill="#e7e1ef" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 7.73 and [size_height] < 11.38</Filter>
|
||||
<PolygonSymbolizer fill="#d4b9da" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 11.38 and [size_height] < 18.45</Filter>
|
||||
<PolygonSymbolizer fill="#c994c7" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 18.45 and [size_height] < 35.05</Filter>
|
||||
<PolygonSymbolizer fill="#df65b0" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 35.05 and [size_height] < 89.30</Filter>
|
||||
<PolygonSymbolizer fill="#e7298a" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 89.30 and [size_height] < 152</Filter>
|
||||
<PolygonSymbolizer fill="#ce1256" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[size_height] >= 152</Filter>
|
||||
<PolygonSymbolizer fill="#980043" />
|
||||
</Rule>
|
||||
</Style>
|
||||
<Style name="date_year">
|
||||
<Rule>
|
||||
<Filter>[date_year] >= 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] >= 10</Filter>
|
||||
<Filter>[likes] >= 100</Filter>
|
||||
<PolygonSymbolizer fill="#bd0026" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 5 and [likes] < 10</Filter>
|
||||
<Filter>[likes] >= 50 and [likes] < 100</Filter>
|
||||
<PolygonSymbolizer fill="#e31a1c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 4 and [likes] < 5</Filter>
|
||||
<Filter>[likes] >= 20 and [likes] < 50</Filter>
|
||||
<PolygonSymbolizer fill="#fc4e2a" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 3 and [likes] < 4</Filter>
|
||||
<Filter>[likes] >= 10 and [likes] < 20</Filter>
|
||||
<PolygonSymbolizer fill="#fd8d3c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 2 and [likes] < 3</Filter>
|
||||
<Filter>[likes] >= 3 and [likes] < 10</Filter>
|
||||
<PolygonSymbolizer fill="#feb24c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] < 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
1229
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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": {
|
||||
|
7
app/public/geometries/boundary-detailed.geojson
Normal file
7
app/public/geometries/boundary-detailed.geojson
Normal file
File diff suppressed because one or more lines are too long
8
app/public/geometries/boundary.geojson
Normal file
8
app/public/geometries/boundary.geojson
Normal file
File diff suppressed because one or more lines are too long
BIN
app/public/icon-192x192.png
Normal file
BIN
app/public/icon-192x192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
BIN
app/public/images/logo-cl-square.png
Normal file
BIN
app/public/images/logo-cl-square.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
BIN
app/public/images/logo-cl.png
Normal file
BIN
app/public/images/logo-cl.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
155
app/public/openapi.yml
Normal file
155
app/public/openapi.yml
Normal 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
|
14
app/public/site.webmanifest
Normal file
14
app/public/site.webmanifest
Normal 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
131
app/src/api/api.ts
Normal 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;
|
@ -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
|
||||
};
|
176
app/src/api/controllers/buildingController.ts
Normal file
176
app/src/api/controllers/buildingController.ts
Normal 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
|
||||
};
|
35
app/src/api/controllers/editHistoryController.ts
Normal file
35
app/src/api/controllers/editHistoryController.ts
Normal 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
|
||||
};
|
31
app/src/api/controllers/extractController.ts
Normal file
31
app/src/api/controllers/extractController.ts
Normal 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
|
||||
};
|
22
app/src/api/controllers/leaderboardController.ts
Normal file
22
app/src/api/controllers/leaderboardController.ts
Normal 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
|
||||
};
|
113
app/src/api/controllers/userController.ts
Normal file
113
app/src/api/controllers/userController.ts
Normal 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
|
||||
};
|
62
app/src/api/dataAccess/__mocks__/editHistory.ts
Normal file
62
app/src/api/dataAccess/__mocks__/editHistory.ts
Normal 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
|
||||
};
|
88
app/src/api/dataAccess/editHistory.ts
Normal file
88
app/src/api/dataAccess/editHistory.ts
Normal 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
44
app/src/api/errors/api.ts
Normal 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';
|
||||
}
|
||||
}
|
17
app/src/api/errors/general.ts
Normal file
17
app/src/api/errors/general.ts
Normal 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
44
app/src/api/parameters.ts
Normal 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;
|
||||
}
|
17
app/src/api/routes/asyncController.ts
Normal file
17
app/src/api/routes/asyncController.ts
Normal 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;
|
37
app/src/api/routes/buildingsRouter.ts
Normal file
37
app/src/api/routes/buildingsRouter.ts
Normal 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;
|
10
app/src/api/routes/extractsRouter.ts
Normal file
10
app/src/api/routes/extractsRouter.ts
Normal 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;
|
9
app/src/api/routes/leaderboardRouter.ts
Normal file
9
app/src/api/routes/leaderboardRouter.ts
Normal 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;
|
15
app/src/api/routes/usersRouter.ts
Normal file
15
app/src/api/routes/usersRouter.ts
Normal 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;
|
176
app/src/api/services/__tests__/editHistory.test.ts
Normal file
176
app/src/api/services/__tests__/editHistory.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
447
app/src/api/services/building.ts
Normal file
447
app/src/api/services/building.ts
Normal 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
|
||||
};
|
65
app/src/api/services/dataExtract.ts
Normal file
65
app/src/api/services/dataExtract.ts
Normal 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
|
||||
};
|
@ -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;
|
||||
}
|
11
app/src/api/services/domainLogic/processBuildingUpdate.ts
Normal file
11
app/src/api/services/domainLogic/processBuildingUpdate.ts
Normal 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;
|
||||
}
|
39
app/src/api/services/editHistory.ts
Normal file
39
app/src/api/services/editHistory.ts
Normal 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
|
||||
};
|
15
app/src/api/services/email.ts
Normal file
15
app/src/api/services/email.ts
Normal 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
|
||||
};
|
39
app/src/api/services/leaderboard.ts
Normal file
39
app/src/api/services/leaderboard.ts
Normal 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
|
||||
};
|
129
app/src/api/services/passwordReset.ts
Normal file
129
app/src/api/services/passwordReset.ts
Normal 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
|
||||
};
|
@ -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;
|
205
app/src/api/services/user.ts
Normal file
205
app/src/api/services/user.ts
Normal 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
|
||||
};
|
@ -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
25
app/src/api/validation.ts
Normal 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
|
||||
};
|
@ -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')
|
||||
);
|
||||
|
44
app/src/frontend/apiHelpers.ts
Normal file
44
app/src/frontend/apiHelpers.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
<App revisionId={0} />
|
||||
</MemoryRouter>,
|
||||
div
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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>…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;
|
20
app/src/frontend/building/building-not-found.tsx
Normal file
20
app/src/frontend/building/building-not-found.tsx
Normal 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;
|
132
app/src/frontend/building/building-view.tsx
Normal file
132
app/src/frontend/building/building-view.tsx
Normal 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 & 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;
|
51
app/src/frontend/building/categories.css
Normal file
51
app/src/frontend/building/categories.css
Normal 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;
|
||||
}
|
157
app/src/frontend/building/categories.tsx
Normal file
157
app/src/frontend/building/categories.tsx
Normal 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 & 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;
|
24
app/src/frontend/building/container-header.tsx
Normal file
24
app/src/frontend/building/container-header.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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
|
||||
};
|
56
app/src/frontend/building/data-components/data-entry.tsx
Normal file
56
app/src/frontend/building/data-components/data-entry.tsx
Normal 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
|
||||
};
|
54
app/src/frontend/building/data-components/data-title.tsx
Normal file
54
app/src/frontend/building/data-components/data-title.tsx
Normal 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 };
|
@ -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;
|
105
app/src/frontend/building/data-components/multi-data-entry.tsx
Normal file
105
app/src/frontend/building/data-components/multi-data-entry.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
344
app/src/frontend/building/data-container.tsx
Normal file
344
app/src/frontend/building/data-container.tsx
Normal 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;
|
91
app/src/frontend/building/data-containers/age.tsx
Normal file
91
app/src/frontend/building/data-containers/age.tsx
Normal 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;
|
@ -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
|
||||
};
|
34
app/src/frontend/building/data-containers/community.tsx
Normal file
34
app/src/frontend/building/data-containers/community.tsx
Normal 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;
|
55
app/src/frontend/building/data-containers/construction.tsx
Normal file
55
app/src/frontend/building/data-containers/construction.tsx
Normal 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;
|
23
app/src/frontend/building/data-containers/like.tsx
Normal file
23
app/src/frontend/building/data-containers/like.tsx
Normal 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;
|
125
app/src/frontend/building/data-containers/location.tsx
Normal file
125
app/src/frontend/building/data-containers/location.tsx
Normal 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;
|
209
app/src/frontend/building/data-containers/planning.tsx
Normal file
209
app/src/frontend/building/data-containers/planning.tsx
Normal 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;
|
163
app/src/frontend/building/data-containers/size.tsx
Normal file
163
app/src/frontend/building/data-containers/size.tsx
Normal 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;
|
25
app/src/frontend/building/data-containers/streetscape.tsx
Normal file
25
app/src/frontend/building/data-containers/streetscape.tsx
Normal 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;
|
84
app/src/frontend/building/data-containers/sustainability.tsx
Normal file
84
app/src/frontend/building/data-containers/sustainability.tsx
Normal 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;
|
31
app/src/frontend/building/data-containers/team.tsx
Normal file
31
app/src/frontend/building/data-containers/team.tsx
Normal 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;
|
61
app/src/frontend/building/data-containers/type.tsx
Normal file
61
app/src/frontend/building/data-containers/type.tsx
Normal 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;
|
49
app/src/frontend/building/data-containers/use.tsx
Normal file
49
app/src/frontend/building/data-containers/use.tsx
Normal 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;
|
@ -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;
|
||||
}
|
@ -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
|
||||
};
|
@ -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;
|
||||
}
|
@ -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
|
||||
};
|
8
app/src/frontend/building/edit-history/edit-history.css
Normal file
8
app/src/frontend/building/edit-history/edit-history.css
Normal file
@ -0,0 +1,8 @@
|
||||
.edit-history {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.edit-history-list {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
}
|
48
app/src/frontend/building/edit-history/edit-history.tsx
Normal file
48
app/src/frontend/building/edit-history/edit-history.tsx
Normal 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
|
||||
};
|
@ -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}:
|
||||
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
|
||||
|
||||
<code title="Value after edit" className='edit-history-diff new'>{formatValue(props.value)}</code>
|
||||
</>
|
||||
);
|
||||
|
||||
export {
|
||||
FieldEditSummary
|
||||
};
|
35
app/src/frontend/building/header-buttons/copy-control.tsx
Normal file
35
app/src/frontend/building/header-buttons/copy-control.tsx
Normal 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
|
||||
};
|
@ -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
|
||||
};
|
95
app/src/frontend/building/multi-edit.tsx
Normal file
95
app/src/frontend/building/multi-edit.tsx
Normal 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;
|
215
app/src/frontend/building/sidebar.css
Normal file
215
app/src/frontend/building/sidebar.css
Normal 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;
|
||||
}
|
11
app/src/frontend/building/sidebar.tsx
Normal file
11
app/src/frontend/building/sidebar.tsx
Normal 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;
|
7
app/src/frontend/components/confirmation-modal.css
Normal file
7
app/src/frontend/components/confirmation-modal.css
Normal file
@ -0,0 +1,7 @@
|
||||
.modal.modal-show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.modal.modal-hide {
|
||||
display: none;
|
||||
}
|
61
app/src/frontend/components/confirmation-modal.tsx
Normal file
61
app/src/frontend/components/confirmation-modal.tsx
Normal 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">×</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;
|
@ -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;
|
@ -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
|
||||
};
|
@ -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;
|
@ -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;
|
||||
}
|
50
app/src/frontend/components/logo.tsx
Normal file
50
app/src/frontend/components/logo.tsx
Normal 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
Loading…
Reference in New Issue
Block a user