Merge pull request #686 from mz8i/feature/678-community
Community category
This commit is contained in:
commit
fb0162a967
@ -446,6 +446,68 @@
|
|||||||
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
||||||
</Rule>
|
</Rule>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style name="community_local_significance_total">
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] >= 100</Filter>
|
||||||
|
<PolygonSymbolizer fill="#bd0026" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] >= 50 and [community_local_significance_total] < 100</Filter>
|
||||||
|
<PolygonSymbolizer fill="#e31a1c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] >= 20 and [community_local_significance_total] < 50</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fc4e2a" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] >= 10 and [community_local_significance_total] < 20</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fd8d3c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] >= 3 and [community_local_significance_total] < 10</Filter>
|
||||||
|
<PolygonSymbolizer fill="#feb24c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] = 2</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fed976" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[community_local_significance_total] = 1</Filter>
|
||||||
|
<PolygonSymbolizer fill="#ffe8a9" />
|
||||||
|
</Rule>
|
||||||
|
|
||||||
|
<Rule>
|
||||||
|
<MaxScaleDenominator>17061</MaxScaleDenominator>
|
||||||
|
<MinScaleDenominator>4264</MinScaleDenominator>
|
||||||
|
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<MaxScaleDenominator>4264</MaxScaleDenominator>
|
||||||
|
<MinScaleDenominator>0</MinScaleDenominator>
|
||||||
|
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
||||||
|
</Rule>
|
||||||
|
</Style>
|
||||||
|
<Style name="community_in_public_ownership">
|
||||||
|
<Rule>
|
||||||
|
<Filter>[in_public_ownership] = true</Filter>
|
||||||
|
<PolygonSymbolizer fill="#1166ff" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[in_public_ownership] = false</Filter>
|
||||||
|
<PolygonSymbolizer fill="#ffaaa0" />
|
||||||
|
</Rule>
|
||||||
|
|
||||||
|
<Rule>
|
||||||
|
<MaxScaleDenominator>17061</MaxScaleDenominator>
|
||||||
|
<MinScaleDenominator>4264</MinScaleDenominator>
|
||||||
|
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<MaxScaleDenominator>4264</MaxScaleDenominator>
|
||||||
|
<MinScaleDenominator>0</MinScaleDenominator>
|
||||||
|
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
||||||
|
</Rule>
|
||||||
|
</Style>
|
||||||
<Style name="landuse">
|
<Style name="landuse">
|
||||||
<Rule>
|
<Rule>
|
||||||
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
|
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
|
||||||
|
72
app/package-lock.json
generated
72
app/package-lock.json
generated
@ -2031,6 +2031,12 @@
|
|||||||
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
|
"integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/pg-format": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg-format/-/pg-format-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-D3MEO6u3BObw3G4Xewjdx05MF5v/fiog78CedtrXe8BhONM8GvUz2dPfLWtI0BPRBoRd6anPHXe+sbrPReZouQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/prettier": {
|
"@types/prettier": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.3.tgz",
|
||||||
@ -11142,6 +11148,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mapnik-vector-tile/-/mapnik-vector-tile-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mapnik-vector-tile/-/mapnik-vector-tile-3.0.1.tgz",
|
||||||
"integrity": "sha512-w3/mKA8RUODR4qeVUyU8MhSqeuHqCLhCPdekqb+FBo+SRPXgxYOvffQBcu8mhEu6EK1n1Syrq26FcNcLfkYLEw=="
|
"integrity": "sha512-w3/mKA8RUODR4qeVUyU8MhSqeuHqCLhCPdekqb+FBo+SRPXgxYOvffQBcu8mhEu6EK1n1Syrq26FcNcLfkYLEw=="
|
||||||
},
|
},
|
||||||
|
"markdown-to-jsx": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-jtQ6VyT7rMT5tPV0g2EJakEnXLiPksnvlYtwQsVVZ611JsWGN8bQ1tVSDX4s6JllfEH6wmsYxNjTUAMrPmNA8w=="
|
||||||
|
},
|
||||||
"mdn-data": {
|
"mdn-data": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
|
||||||
@ -11607,9 +11618,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node-abi": {
|
"node-abi": {
|
||||||
"version": "2.21.0",
|
"version": "2.30.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.0.tgz",
|
||||||
"integrity": "sha512-smhrivuPqEM3H5LmnY3KU6HfYv0u4QklgAxfFyRNujKUzbUcYZ+Jc2EhukB9SRcD2VpqhxM7n/MIcp1Ua1/JMg==",
|
"integrity": "sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"semver": "^5.4.1"
|
"semver": "^5.4.1"
|
||||||
}
|
}
|
||||||
@ -11695,11 +11706,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.4.11.tgz",
|
||||||
"integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ=="
|
"integrity": "sha512-BVZBDi+aJV4O38rxsUh164Dk1NCqgh6Cm0rQSb9SK/DHGll/DrCMnycVDD7msJgZCnmVa8ASo8EZzR7jsgTukQ=="
|
||||||
},
|
},
|
||||||
"noop-logger": {
|
|
||||||
"version": "0.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
|
||||||
"integrity": "sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI="
|
|
||||||
},
|
|
||||||
"nopt": {
|
"nopt": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
|
||||||
@ -12561,6 +12567,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz",
|
||||||
"integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc="
|
"integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc="
|
||||||
},
|
},
|
||||||
|
"pg-format": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-format/-/pg-format-1.0.4.tgz",
|
||||||
|
"integrity": "sha1-J3NCNsKtP05QZJFaWTNOIAQKgo4="
|
||||||
|
},
|
||||||
"pg-int8": {
|
"pg-int8": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
@ -14222,9 +14233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prebuild-install": {
|
"prebuild-install": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.3.tgz",
|
||||||
"integrity": "sha512-M+cKwofFlHa5VpTWub7GLg5RLcunYIcLqtY5pKcls/u7xaAb8FrXZ520qY8rkpYy5xw90tYCyMO0MP5ggzR3Sw==",
|
"integrity": "sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"detect-libc": "^1.0.3",
|
"detect-libc": "^1.0.3",
|
||||||
"expand-template": "^2.0.3",
|
"expand-template": "^2.0.3",
|
||||||
@ -14233,7 +14244,6 @@
|
|||||||
"mkdirp-classic": "^0.5.3",
|
"mkdirp-classic": "^0.5.3",
|
||||||
"napi-build-utils": "^1.0.1",
|
"napi-build-utils": "^1.0.1",
|
||||||
"node-abi": "^2.21.0",
|
"node-abi": "^2.21.0",
|
||||||
"noop-logger": "^0.1.1",
|
|
||||||
"npmlog": "^4.0.1",
|
"npmlog": "^4.0.1",
|
||||||
"pump": "^3.0.0",
|
"pump": "^3.0.0",
|
||||||
"rc": "^1.2.7",
|
"rc": "^1.2.7",
|
||||||
@ -15035,13 +15045,6 @@
|
|||||||
"ini": "~1.3.0",
|
"ini": "~1.3.0",
|
||||||
"minimist": "^1.2.0",
|
"minimist": "^1.2.0",
|
||||||
"strip-json-comments": "~2.0.1"
|
"strip-json-comments": "~2.0.1"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"minimist": {
|
|
||||||
"version": "1.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
|
||||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"react": {
|
"react": {
|
||||||
@ -15970,14 +15973,14 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"sharp": {
|
"sharp": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.28.3.tgz",
|
||||||
"integrity": "sha512-4mCGMEN4ntaVuFGwHx7FvkJQkIgbI+S+F9a3bI7ugdvKjPr4sF7/ibvlRKhJyzhoQi+ODM+XYY1de8xs7MHbfA==",
|
"integrity": "sha512-21GEP45Rmr7q2qcmdnjDkNP04Ooh5v0laGS5FDpojOO84D1DJwUijLiSq8XNNM6e8aGXYtoYRh3sVNdm8NodMA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"color": "^3.1.3",
|
"color": "^3.1.3",
|
||||||
"detect-libc": "^1.0.3",
|
"detect-libc": "^1.0.3",
|
||||||
"node-addon-api": "^3.1.0",
|
"node-addon-api": "^3.2.0",
|
||||||
"prebuild-install": "^6.1.1",
|
"prebuild-install": "^6.1.2",
|
||||||
"semver": "^7.3.5",
|
"semver": "^7.3.5",
|
||||||
"simple-get": "^3.1.0",
|
"simple-get": "^3.1.0",
|
||||||
"tar-fs": "^2.1.1",
|
"tar-fs": "^2.1.1",
|
||||||
@ -15985,23 +15988,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color": {
|
"color": {
|
||||||
"version": "3.1.3",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
|
||||||
"integrity": "sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ==",
|
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"color-convert": "^1.9.1",
|
"color-convert": "^1.9.3",
|
||||||
"color-string": "^1.5.4"
|
"color-string": "^1.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"color-string": {
|
"color-string": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz",
|
||||||
"integrity": "sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==",
|
"integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"color-name": "^1.0.0",
|
"color-name": "^1.0.0",
|
||||||
"simple-swizzle": "^0.2.2"
|
"simple-swizzle": "^0.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node-addon-api": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A=="
|
||||||
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
"version": "7.3.5",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
|
||||||
|
@ -30,8 +30,10 @@
|
|||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mapnik": "^4.5.8",
|
"mapnik": "^4.5.8",
|
||||||
|
"markdown-to-jsx": "^7.1.3",
|
||||||
"node-fs": "^0.1.7",
|
"node-fs": "^0.1.7",
|
||||||
"nodemailer": "^6.4.11",
|
"nodemailer": "^6.4.11",
|
||||||
|
"pg-format": "^1.0.4",
|
||||||
"pg-promise": "^8.7.5",
|
"pg-promise": "^8.7.5",
|
||||||
"query-string": "^6.13.1",
|
"query-string": "^6.13.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
@ -39,7 +41,7 @@
|
|||||||
"react-leaflet": "^3.1.0",
|
"react-leaflet": "^3.1.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"serialize-javascript": "^5.0.1",
|
"serialize-javascript": "^5.0.1",
|
||||||
"sharp": "^0.28.1",
|
"sharp": "^0.28.3",
|
||||||
"use-throttle": "0.0.3"
|
"use-throttle": "0.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -53,6 +55,7 @@
|
|||||||
"@types/mapbox__sphericalmercator": "^1.1.3",
|
"@types/mapbox__sphericalmercator": "^1.1.3",
|
||||||
"@types/node": "^12.12.53",
|
"@types/node": "^12.12.53",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
|
"@types/pg-format": "^1.0.2",
|
||||||
"@types/react": "^17.0.3",
|
"@types/react": "^17.0.3",
|
||||||
"@types/react-dom": "^17.0.3",
|
"@types/react-dom": "^17.0.3",
|
||||||
"@types/react-router-dom": "^4.3.5",
|
"@types/react-router-dom": "^4.3.5",
|
||||||
|
28
app/src/api/config/aggregationsConfig.ts
Normal file
28
app/src/api/config/aggregationsConfig.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
|
||||||
|
|
||||||
|
export type AggregationMethod = 'countTrue';
|
||||||
|
|
||||||
|
export interface AggregationConfig {
|
||||||
|
aggregateFieldName: keyof typeof buildingAttributesConfig;
|
||||||
|
aggregationMethod: AggregationMethod;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for building-user attribute aggregations.
|
||||||
|
* The config defines how attributes that are collected per building, per user are aggregated into per building attributes.
|
||||||
|
* An example is the building like mechanism:
|
||||||
|
*/
|
||||||
|
export const aggregationsConfig: { [key in keyof typeof buildingUserAttributesConfig]?: AggregationConfig[]} = {
|
||||||
|
community_like: [
|
||||||
|
{
|
||||||
|
aggregateFieldName: 'likes_total',
|
||||||
|
aggregationMethod: 'countTrue'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
community_local_significance: [
|
||||||
|
{
|
||||||
|
aggregateFieldName: 'community_local_significance_total',
|
||||||
|
aggregationMethod: 'countTrue'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
@ -2,6 +2,12 @@ import { valueType } from '../../helpers';
|
|||||||
|
|
||||||
/** Configuration for a single data field */
|
/** Configuration for a single data field */
|
||||||
export interface DataFieldConfig {
|
export interface DataFieldConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default: false
|
||||||
|
*/
|
||||||
|
perUser?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow editing the field through the API?
|
* Allow editing the field through the API?
|
||||||
*/
|
*/
|
||||||
@ -41,7 +47,10 @@ export interface DataFieldConfig {
|
|||||||
sqlCast?: 'json' | 'jsonb';
|
sqlCast?: 'json' | 'jsonb';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
|
export const buildingAttributesConfig = valueType<DataFieldConfig>()({ /* eslint-disable @typescript-eslint/camelcase */
|
||||||
|
ref_toid: {
|
||||||
|
edit: false
|
||||||
|
},
|
||||||
ref_osm_id: {
|
ref_osm_id: {
|
||||||
edit: true,
|
edit: true,
|
||||||
},
|
},
|
||||||
@ -260,7 +269,54 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
|
|||||||
asJson: true,
|
asJson: true,
|
||||||
sqlCast: 'jsonb',
|
sqlCast: 'jsonb',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
likes_total: {
|
||||||
|
edit: false,
|
||||||
|
derivedEdit: true,
|
||||||
|
verify: false
|
||||||
|
},
|
||||||
|
community_local_significance_total: {
|
||||||
|
edit: false,
|
||||||
|
derivedEdit: true,
|
||||||
|
verify: false
|
||||||
|
},
|
||||||
|
community_activities: {
|
||||||
|
edit: true,
|
||||||
|
verify: false
|
||||||
|
},
|
||||||
|
community_public_ownership: {
|
||||||
|
edit: true,
|
||||||
|
verify: true
|
||||||
|
},
|
||||||
|
community_public_ownership_sources: {
|
||||||
|
edit: true,
|
||||||
|
verify: false
|
||||||
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Building = { [k in keyof typeof dataFieldsConfig]: any };
|
|
||||||
export type BuildingUpdate = Partial<Building>;
|
export const buildingUserAttributesConfig = valueType<DataFieldConfig>()({
|
||||||
|
community_like: {
|
||||||
|
perUser: true,
|
||||||
|
edit: true,
|
||||||
|
verify: false,
|
||||||
|
},
|
||||||
|
community_type_worth_keeping: {
|
||||||
|
perUser: true,
|
||||||
|
edit: true,
|
||||||
|
verify: false
|
||||||
|
},
|
||||||
|
community_type_worth_keeping_reasons: {
|
||||||
|
perUser: true,
|
||||||
|
edit: true,
|
||||||
|
verify: false
|
||||||
|
},
|
||||||
|
community_local_significance: {
|
||||||
|
perUser: true,
|
||||||
|
edit: true,
|
||||||
|
verify: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const allAttributesConfig = Object.assign({}, buildingAttributesConfig, buildingUserAttributesConfig);
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { JSONSchemaType } from 'ajv';
|
import { JSONSchemaType } from 'ajv';
|
||||||
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
|
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
|
||||||
import { dataFieldsConfig } from './dataFields';
|
import { allAttributesConfig } from './dataFields';
|
||||||
|
|
||||||
export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
|
export const fieldSchemaConfig: { [key in keyof typeof allAttributesConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
|
||||||
|
|
||||||
demolished_buildings: {
|
demolished_buildings: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@ -63,4 +63,32 @@ export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJ
|
|||||||
links: string[];
|
links: string[];
|
||||||
}[]>,
|
}[]>,
|
||||||
|
|
||||||
|
community_type_worth_keeping_reasons: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
external_design: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
internal_design: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
adaptable: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: true
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
} as JSONSchemaType<{
|
||||||
|
external_design: boolean,
|
||||||
|
internal_design: boolean,
|
||||||
|
adaptable: boolean,
|
||||||
|
other: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
|
|
||||||
import { ApiUserError } from '../errors/api';
|
import { ApiUserError } from '../errors/api';
|
||||||
import { UserError } from '../errors/general';
|
import { UserError } from '../errors/general';
|
||||||
|
import { parseBooleanExact } from '../../helpers';
|
||||||
import { parsePositiveIntParam, processParam } from '../parameters';
|
import { parsePositiveIntParam, processParam } from '../parameters';
|
||||||
import asyncController from '../routes/asyncController';
|
import asyncController from '../routes/asyncController';
|
||||||
import * as buildingService from '../services/building/base';
|
import * as buildingService from '../services/building/base';
|
||||||
@ -39,8 +40,18 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
|
|||||||
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||||
|
|
||||||
|
const returnUserAttributes = parseBooleanExact(String(req.query.user_attributes));
|
||||||
|
|
||||||
|
let userDataOptions = null;
|
||||||
|
if(returnUserAttributes) {
|
||||||
|
if(!req.session.user_id) {
|
||||||
|
return res.send({ error: 'Must be logged in' });
|
||||||
|
}
|
||||||
|
userDataOptions = { userId: req.session.user_id, userAttributes: true};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await buildingService.getBuildingById(buildingId);
|
const result = await buildingService.getBuildingById(buildingId, { userDataOptions });
|
||||||
res.send(result);
|
res.send(result);
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -50,44 +61,43 @@ const getBuildingById = asyncController(async (req: express.Request, res: expres
|
|||||||
|
|
||||||
// POST building attribute updates
|
// POST building attribute updates
|
||||||
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
let user_id;
|
let userId: string;
|
||||||
|
|
||||||
if (req.session.user_id) {
|
try {
|
||||||
user_id = req.session.user_id;
|
userId = req.session.user_id ?? (
|
||||||
} else if (req.query.api_key) {
|
req.query.api_key && await userService.authAPIUser(String(req.query.api_key))
|
||||||
try {
|
);
|
||||||
const user = await userService.authAPIUser(String(req.query.api_key));
|
} catch(error) {
|
||||||
user_id = user.user_id;
|
console.error(error);
|
||||||
} catch(err) {
|
|
||||||
console.error(err);
|
|
||||||
res.send({ error: 'Must be logged in' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res.send({ error: 'Must be logged in' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user_id) {
|
if(!userId) {
|
||||||
await updateBuilding(req, res, user_id);
|
return 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 buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||||
|
|
||||||
const buildingUpdate = req.body;
|
const {
|
||||||
|
attributes = null,
|
||||||
|
user_attributes: userAttributes = null
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
let updatedBuilding: object;
|
|
||||||
try {
|
try {
|
||||||
updatedBuilding = await buildingService.editBuilding(buildingId, buildingUpdate, userId);
|
const resultUpdate = await buildingService.editBuilding(buildingId, userId, {attributes, userAttributes});
|
||||||
|
|
||||||
|
res.send({
|
||||||
|
attributes: resultUpdate.attributes,
|
||||||
|
user_attributes: resultUpdate.userAttributes,
|
||||||
|
revision_id: resultUpdate.revisionId
|
||||||
|
});
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
if(error instanceof UserError) {
|
if(error instanceof UserError) {
|
||||||
throw new ApiUserError(error.message, error);
|
throw new ApiUserError(error.message, error);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
res.send(updatedBuilding);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET building UPRNs
|
// GET building UPRNs
|
||||||
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
|
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
@ -106,21 +116,19 @@ const getBuildingUPRNsById = asyncController(async (req: express.Request, res: e
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET whether the user likes a building
|
const getBuildingUserAttributesById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
|
if(!req.session.user_id) {
|
||||||
if (!req.session.user_id) {
|
return res.send({ error: 'Must be logged in'});
|
||||||
return res.send({ like: false }); // not logged in, so cannot have liked
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const like = await buildingService.getBuildingLikeById(buildingId, req.session.user_id);
|
const userAttributes = await buildingService.getBuildingUserAttributesById(buildingId, req.session.user_id);
|
||||||
|
|
||||||
// any value returned means like
|
res.send(userAttributes);
|
||||||
res.send({ like: like });
|
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
res.send({ error: 'Database error' });
|
res.send({ error: 'Database error'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -137,31 +145,6 @@ const getBuildingEditHistoryById = asyncController(async (req: express.Request,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST update to like/unlike building
|
|
||||||
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;
|
|
||||||
|
|
||||||
let updatedBuilding: object;
|
|
||||||
try {
|
|
||||||
updatedBuilding = like ?
|
|
||||||
await buildingService.likeBuilding(buildingId, req.session.user_id) :
|
|
||||||
await buildingService.unlikeBuilding(buildingId, req.session.user_id);
|
|
||||||
} catch(error) {
|
|
||||||
if(error instanceof UserError) {
|
|
||||||
throw new ApiUserError(error.message, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.send(updatedBuilding);
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET building attributes (and values) as verified by user
|
// GET building attributes (and values) as verified by user
|
||||||
const getUserVerifiedAttributes = asyncController(async (req: express.Request, res: express.Response) => {
|
const getUserVerifiedAttributes = asyncController(async (req: express.Request, res: express.Response) => {
|
||||||
if (!req.session.user_id) {
|
if (!req.session.user_id) {
|
||||||
@ -219,10 +202,9 @@ export default {
|
|||||||
getBuildingById,
|
getBuildingById,
|
||||||
updateBuildingById,
|
updateBuildingById,
|
||||||
getBuildingUPRNsById,
|
getBuildingUPRNsById,
|
||||||
getBuildingLikeById,
|
|
||||||
updateBuildingLikeById,
|
|
||||||
getUserVerifiedAttributes,
|
getUserVerifiedAttributes,
|
||||||
verifyBuildingAttributes,
|
verifyBuildingAttributes,
|
||||||
getBuildingEditHistoryById,
|
getBuildingEditHistoryById,
|
||||||
getLatestRevisionId
|
getLatestRevisionId,
|
||||||
|
getBuildingUserAttributesById
|
||||||
};
|
};
|
||||||
|
26
app/src/api/dataAccess/aggregate.ts
Normal file
26
app/src/api/dataAccess/aggregate.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
import format from 'pg-format';
|
||||||
|
|
||||||
|
import db from '../../db';
|
||||||
|
import { DatabaseError } from '../errors/general';
|
||||||
|
import { AggregationMethod } from '../config/aggregationsConfig';
|
||||||
|
|
||||||
|
export type AggregationMethodFunction = (buildingId: number, attributeName: string, t?: ITask<any>) => Promise<number>;
|
||||||
|
|
||||||
|
export async function aggregateCountTrue(buildingId: number, attributeName: string, t?: ITask<any>): Promise<number> {
|
||||||
|
try {
|
||||||
|
// use pg-format here instead of pg-promise parameterised queries as they don't support column name from paraemeter
|
||||||
|
// assume that there won't be more likes than Postgres int range and cast to int
|
||||||
|
// otherwise the count is returned as a bigint which has less support in node-postgres
|
||||||
|
const query = format(`SELECT count(*)::int as agg FROM building_user_attributes WHERE building_id = %L::int AND %I = true;`, buildingId, attributeName);
|
||||||
|
const { agg } = await (t || db).one(query);
|
||||||
|
|
||||||
|
return agg;
|
||||||
|
} catch(error) {
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aggregationMethods: Record<AggregationMethod, AggregationMethodFunction> = {
|
||||||
|
'countTrue': aggregateCountTrue
|
||||||
|
};
|
@ -1,15 +1,17 @@
|
|||||||
|
|
||||||
import { errors, ITask } from 'pg-promise';
|
import { errors, ITask } from 'pg-promise';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
import db from '../../db';
|
import db from '../../db';
|
||||||
import { dataFieldsConfig } from '../config/dataFields';
|
import { allAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
|
||||||
|
import { BaseBuilding, BuildingAttributes, BuildingUserAttributes } from '../models/building';
|
||||||
import { ArgumentError, DatabaseError } from '../errors/general';
|
import { ArgumentError, DatabaseError } from '../errors/general';
|
||||||
|
|
||||||
export async function getBuildingData(
|
export async function getBuildingData(
|
||||||
buildingId: number,
|
buildingId: number,
|
||||||
lockForUpdate: boolean = false,
|
lockForUpdate: boolean = false,
|
||||||
t?: ITask<any>
|
t?: ITask<any>
|
||||||
) {
|
): Promise<BaseBuilding> {
|
||||||
let buildingData;
|
let buildingData;
|
||||||
try {
|
try {
|
||||||
buildingData = await (t || db).one(
|
buildingData = await (t || db).one(
|
||||||
@ -55,7 +57,7 @@ export async function insertEditHistoryRevision(
|
|||||||
|
|
||||||
const columnConfigLookup = Object.assign(
|
const columnConfigLookup = Object.assign(
|
||||||
{},
|
{},
|
||||||
...Object.entries(dataFieldsConfig).filter(([, config]) => config.edit || config.derivedEdit).map(([key, {
|
...Object.entries(allAttributesConfig).filter(([, config]) => config.edit || config.derivedEdit).map(([key, {
|
||||||
asJson = false,
|
asJson = false,
|
||||||
sqlCast
|
sqlCast
|
||||||
}]) => ({ [key]: {
|
}]) => ({ [key]: {
|
||||||
@ -70,14 +72,14 @@ export async function updateBuildingData(
|
|||||||
forwardPatch: object,
|
forwardPatch: object,
|
||||||
revisionId: string,
|
revisionId: string,
|
||||||
t?: ITask<any>
|
t?: ITask<any>
|
||||||
): Promise<object> {
|
): Promise<BuildingAttributes> {
|
||||||
const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]);
|
const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]);
|
||||||
const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig);
|
const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig);
|
||||||
|
|
||||||
console.log('Setting', buildingId, sets);
|
console.log('Setting', buildingId, sets);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await (t || db).one(
|
const buildingRow: BaseBuilding = await (t || db).one(
|
||||||
`UPDATE
|
`UPDATE
|
||||||
buildings
|
buildings
|
||||||
SET
|
SET
|
||||||
@ -90,7 +92,132 @@ export async function updateBuildingData(
|
|||||||
`,
|
`,
|
||||||
[revisionId, sets, buildingId]
|
[revisionId, sets, buildingId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
delete buildingRow.building_id;
|
||||||
|
delete buildingRow.geometry_id;
|
||||||
|
delete buildingRow.revision_id;
|
||||||
|
|
||||||
|
return buildingRow as BuildingAttributes;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
throw new DatabaseError(error);
|
throw new DatabaseError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function that ensures a building-user attribute record exists.
|
||||||
|
* The record is created if there isn't one yet.
|
||||||
|
*/
|
||||||
|
async function ensureBuildingUserRecord(buildingId: number, userId: string, t: ITask<any>) {
|
||||||
|
try {
|
||||||
|
await t.one(
|
||||||
|
`SELECT * FROM building_user_attributes WHERE building_id = $1 AND user_id = $2;`,
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
if(error.code === errors.queryResultErrorCode.noData) {
|
||||||
|
try {
|
||||||
|
await t.none(
|
||||||
|
'INSERT INTO building_user_attributes (building_id, user_id) VALUES ($1, $2);',
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
} catch(error) {
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildingUserAttributesRow = BuildingUserAttributes & {
|
||||||
|
building_id: number;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBuildingUserData(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
forwardPatch: object,
|
||||||
|
t?: ITask<any>
|
||||||
|
) : Promise<BuildingUserAttributes> {
|
||||||
|
await ensureBuildingUserRecord(buildingId, userId, t);
|
||||||
|
|
||||||
|
const columnConfig = Object.entries(forwardPatch).map(([key]) => columnConfigLookup[key]);
|
||||||
|
const sets = db.$config.pgp.helpers.sets(forwardPatch, columnConfig);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buildingUserRow: BuildingUserAttributesRow = await (t || db).one(
|
||||||
|
`UPDATE
|
||||||
|
building_user_attributes
|
||||||
|
SET
|
||||||
|
$1:raw
|
||||||
|
WHERE
|
||||||
|
building_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
RETURNING
|
||||||
|
*
|
||||||
|
;`,
|
||||||
|
[sets, buildingId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
delete buildingUserRow.building_id;
|
||||||
|
delete buildingUserRow.user_id;
|
||||||
|
|
||||||
|
return buildingUserRow;
|
||||||
|
} catch(error) {
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildingExists(buildingId: number, t?: ITask<any>) {
|
||||||
|
return (
|
||||||
|
await (t || db).oneOrNone(
|
||||||
|
'SELECT building_id FROM buildings WHERE building_id = $1',
|
||||||
|
[buildingId]
|
||||||
|
)
|
||||||
|
) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDefaultUserData() {
|
||||||
|
return _.mapValues(buildingUserAttributesConfig, () => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBuildingUserData(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
lockForUpdate: boolean = false,
|
||||||
|
t?: ITask<any>
|
||||||
|
): Promise<BuildingUserAttributes> {
|
||||||
|
try {
|
||||||
|
const buildingUserRow = await (t || db).oneOrNone(
|
||||||
|
`SELECT
|
||||||
|
*
|
||||||
|
FROM building_user_attributes
|
||||||
|
WHERE
|
||||||
|
building_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
${lockForUpdate ? ' FOR UPDATE' : ''};
|
||||||
|
`,
|
||||||
|
[buildingId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if(buildingUserRow) {
|
||||||
|
delete buildingUserRow.building_id;
|
||||||
|
delete buildingUserRow.user_id;
|
||||||
|
|
||||||
|
return buildingUserRow;
|
||||||
|
} else {
|
||||||
|
if(await buildingExists(buildingId, t)) {
|
||||||
|
return makeDefaultUserData();
|
||||||
|
} else {
|
||||||
|
throw new ArgumentError(`Building ID ${buildingId} does not exist`, 'buildingId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch(error) {
|
||||||
|
if(error instanceof ArgumentError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new DatabaseError(error);
|
||||||
|
}
|
||||||
|
}
|
@ -1,47 +0,0 @@
|
|||||||
import { errors, ITask } from 'pg-promise';
|
|
||||||
|
|
||||||
import db from '../../db';
|
|
||||||
import { DatabaseError, InvalidOperationError } from '../errors/general';
|
|
||||||
|
|
||||||
export async function getBuildingLikeCount(buildingId: number, t?: ITask<any>): Promise<number> {
|
|
||||||
try {
|
|
||||||
const result = await (t || db).one(
|
|
||||||
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
|
|
||||||
[buildingId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.likes;
|
|
||||||
} catch(error) {
|
|
||||||
throw new DatabaseError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addBuildingUserLike(buildingId: number, userId: string, t?: ITask<any>): Promise<void> {
|
|
||||||
try {
|
|
||||||
return await (t || db).none(
|
|
||||||
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
|
|
||||||
[buildingId, userId]
|
|
||||||
);
|
|
||||||
} catch(error) {
|
|
||||||
if(error.detail?.includes('already exists')) {
|
|
||||||
throw new InvalidOperationError('User already likes this building');
|
|
||||||
}
|
|
||||||
throw new DatabaseError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeBuildingUserLike(buildingId: number, userId: string, t?: ITask<any>): Promise<void> {
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await t.result(
|
|
||||||
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
|
|
||||||
[buildingId, userId]
|
|
||||||
);
|
|
||||||
} catch(error) {
|
|
||||||
throw new DatabaseError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
|
||||||
throw new InvalidOperationError("User doesn't like the building, cannot unlike");
|
|
||||||
}
|
|
||||||
}
|
|
13
app/src/api/dataAccess/transaction.ts
Normal file
13
app/src/api/dataAccess/transaction.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
|
||||||
|
import db from '../../db';
|
||||||
|
|
||||||
|
// Create a transaction mode (serializable, read-write):
|
||||||
|
const serializableMode = new db.$config.pgp.txMode.TransactionMode({
|
||||||
|
tiLevel: db.$config.pgp.txMode.isolationLevel.serializable,
|
||||||
|
readOnly: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export function startUpdateTransaction<T>(cb: (t: ITask<any>) => Promise<T>): Promise<T> {
|
||||||
|
return db.tx({mode: serializableMode}, cb);
|
||||||
|
}
|
24
app/src/api/models/building.ts
Normal file
24
app/src/api/models/building.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { buildingAttributesConfig, buildingUserAttributesConfig } from '../config/dataFields';
|
||||||
|
|
||||||
|
export type BuildingAttributes = { [k in keyof typeof buildingAttributesConfig]: any };
|
||||||
|
export type BuildingUserAttributes = { [k in keyof typeof buildingUserAttributesConfig]: any };
|
||||||
|
|
||||||
|
export interface BuildingUpdate {
|
||||||
|
attributes?: Partial<BuildingAttributes>;
|
||||||
|
userAttributes?: Partial<BuildingUserAttributes>;
|
||||||
|
revisionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BuildingIdentifiers {
|
||||||
|
building_id: number;
|
||||||
|
geometry_id: number;
|
||||||
|
revision_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseBuilding = BuildingIdentifiers & BuildingAttributes;
|
||||||
|
|
||||||
|
export interface Building extends BaseBuilding {
|
||||||
|
user_attributes: BuildingUserAttributes;
|
||||||
|
edit_history: any[];
|
||||||
|
verified: any;
|
||||||
|
}
|
@ -26,10 +26,6 @@ router.route('/:building_id.json')
|
|||||||
// GET building UPRNs
|
// GET building UPRNs
|
||||||
router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById);
|
router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById);
|
||||||
|
|
||||||
// GET/POST like building
|
|
||||||
router.route('/:building_id/like.json')
|
|
||||||
.get(buildingController.getBuildingLikeById)
|
|
||||||
.post(buildingController.updateBuildingLikeById);
|
|
||||||
|
|
||||||
// POST verify building attribute
|
// POST verify building attribute
|
||||||
router.route('/:building_id/verify.json')
|
router.route('/:building_id/verify.json')
|
||||||
|
@ -4,56 +4,56 @@
|
|||||||
*/
|
*/
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { pickFields } from '../../../helpers';
|
|
||||||
import { dataFieldsConfig } from '../../config/dataFields';
|
|
||||||
import * as buildingDataAccess from '../../dataAccess/building';
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
|
import { Building, BuildingUserAttributes } from '../../models/building';
|
||||||
import { validateBuildingUpdate } from '../domainLogic/validateBuildingUpdate';
|
|
||||||
|
|
||||||
import { getBuildingEditHistory } from './history';
|
import { getBuildingEditHistory } from './history';
|
||||||
import { updateBuildingData } from './save';
|
|
||||||
import { getBuildingVerifications } from './verify';
|
import { getBuildingVerifications } from './verify';
|
||||||
|
|
||||||
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
// 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.
|
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
||||||
|
|
||||||
export async function getBuildingById(id: number) {
|
export interface BuildingMetadataOptions {
|
||||||
try {
|
editHistory?: boolean;
|
||||||
const building = await buildingDataAccess.getBuildingData(id);
|
verified?: boolean;
|
||||||
|
|
||||||
building.edit_history = await getBuildingEditHistory(id);
|
userDataOptions?: {
|
||||||
building.verified = await getBuildingVerifications(building);
|
userId: string;
|
||||||
|
userAttributes?: boolean;
|
||||||
return building;
|
|
||||||
} catch(error) {
|
|
||||||
console.error(error);
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getBuildingById(
|
||||||
* List of fields for which modification is allowed
|
buildingId: number,
|
||||||
* (directly by the user, or for fields that are derived from others)
|
{
|
||||||
*/
|
editHistory = true,
|
||||||
const FINAL_FIELD_EDIT_ALLOWLIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit || value.derivedEdit).map(([key]) => key));
|
verified = true,
|
||||||
|
userDataOptions
|
||||||
|
}: BuildingMetadataOptions = {}
|
||||||
|
) {
|
||||||
|
const baseBuilding = await buildingDataAccess.getBuildingData(buildingId);
|
||||||
|
const building: Partial<Building> = {...baseBuilding};
|
||||||
|
|
||||||
export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
|
if(editHistory) {
|
||||||
return await updateBuildingData(buildingId, userId, async () => {
|
building.edit_history = await getBuildingEditHistory(buildingId);
|
||||||
validateBuildingUpdate(buildingId, building);
|
}
|
||||||
const processedBuilding = await processBuildingUpdate(buildingId, building);
|
|
||||||
|
|
||||||
// remove read-only fields from consideration
|
if(verified) {
|
||||||
delete processedBuilding.building_id;
|
building.verified = await getBuildingVerifications(baseBuilding);
|
||||||
delete processedBuilding.revision_id;
|
}
|
||||||
delete processedBuilding.geometry_id;
|
|
||||||
|
|
||||||
// return whitelisted fields to update
|
if(userDataOptions && userDataOptions.userAttributes) {
|
||||||
return pickFields(processedBuilding, FINAL_FIELD_EDIT_ALLOWLIST);
|
building.user_attributes = await getBuildingUserAttributesById(buildingId, userDataOptions.userId);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return building;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getBuildingUserAttributesById(buildingId: number, userId: string): Promise<BuildingUserAttributes> {
|
||||||
|
return buildingDataAccess.getBuildingUserData(buildingId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './edit';
|
||||||
export * from './history';
|
export * from './history';
|
||||||
export * from './like';
|
|
||||||
export * from './query';
|
export * from './query';
|
||||||
export * from './uprn';
|
export * from './uprn';
|
||||||
export * from './verify';
|
export * from './verify';
|
||||||
|
106
app/src/api/services/building/edit.ts
Normal file
106
app/src/api/services/building/edit.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { BuildingAttributes, BuildingUpdate, BuildingUserAttributes } from '../../models/building';
|
||||||
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
|
import { startUpdateTransaction } from '../../dataAccess/transaction';
|
||||||
|
import { UserError } from '../../errors/general';
|
||||||
|
import { aggregateUserAttributes } from '../domainLogic/aggregateUserAttributes';
|
||||||
|
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
|
||||||
|
import { validateChangeSet } from '../domainLogic/validateUpdate';
|
||||||
|
import { expireBuildingTileCache } from './tileCache';
|
||||||
|
|
||||||
|
|
||||||
|
export async function editBuilding(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
{ attributes, userAttributes } : BuildingUpdate
|
||||||
|
): Promise<BuildingUpdate> {
|
||||||
|
// Validate externally provided attributes
|
||||||
|
if(attributes) {
|
||||||
|
validateChangeSet(attributes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate externally provided user attributes
|
||||||
|
if(userAttributes) {
|
||||||
|
validateChangeSet(userAttributes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUpdate = await startUpdateTransaction(async (t) => {
|
||||||
|
let {
|
||||||
|
attributes: processedAttributes,
|
||||||
|
userAttributes: processedUserAttributes
|
||||||
|
} = await processBuildingUpdate(buildingId, { attributes, userAttributes }, t);
|
||||||
|
|
||||||
|
let resultUserAttributes: BuildingUserAttributes = null;
|
||||||
|
|
||||||
|
if(!_.isEmpty(processedUserAttributes)) {
|
||||||
|
validateChangeSet(processedUserAttributes, false);
|
||||||
|
resultUserAttributes = await buildingDataAccess
|
||||||
|
.updateBuildingUserData(buildingId, userId, processedUserAttributes, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAttributes = await aggregateUserAttributes(
|
||||||
|
buildingId,
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
attributes: processedAttributes,
|
||||||
|
userAttributes: processedUserAttributes
|
||||||
|
},
|
||||||
|
t
|
||||||
|
);
|
||||||
|
|
||||||
|
let resultAttributes: BuildingAttributes = null;
|
||||||
|
|
||||||
|
let revisionId: string;
|
||||||
|
|
||||||
|
if(processedAttributes) {
|
||||||
|
const oldAttributes = await buildingDataAccess.getBuildingData(buildingId, true, t);
|
||||||
|
const [forwardPatch, reversePatch] = compare(oldAttributes, processedAttributes);
|
||||||
|
|
||||||
|
if(!_.isEmpty(forwardPatch)) {
|
||||||
|
revisionId = await buildingDataAccess
|
||||||
|
.insertEditHistoryRevision(buildingId, userId, forwardPatch, reversePatch, t);
|
||||||
|
resultAttributes = await buildingDataAccess
|
||||||
|
.updateBuildingData(buildingId, forwardPatch, revisionId, t);
|
||||||
|
} else {
|
||||||
|
revisionId = oldAttributes.revision_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultAttributes == null && resultUserAttributes == null) {
|
||||||
|
throw new UserError('No change provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
revisionId: revisionId,
|
||||||
|
attributes: resultAttributes,
|
||||||
|
userAttributes: resultUserAttributes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expireBuildingTileCache(buildingId);
|
||||||
|
|
||||||
|
return finalUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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];
|
||||||
|
}
|
@ -1,49 +0,0 @@
|
|||||||
import db from '../../../db';
|
|
||||||
import * as likeDataAccess from '../../dataAccess/like';
|
|
||||||
|
|
||||||
import { updateBuildingData } from './save';
|
|
||||||
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function likeBuilding(buildingId: number, userId: string) {
|
|
||||||
return await updateBuildingData(
|
|
||||||
buildingId,
|
|
||||||
userId,
|
|
||||||
async (t) => {
|
|
||||||
// return total like count after update
|
|
||||||
return {
|
|
||||||
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
(t) => {
|
|
||||||
return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unlikeBuilding(buildingId: number, userId: string) {
|
|
||||||
return await updateBuildingData(
|
|
||||||
buildingId,
|
|
||||||
userId,
|
|
||||||
async (t) => {
|
|
||||||
// return total like count after update
|
|
||||||
return {
|
|
||||||
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async (t) => {
|
|
||||||
return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
import { ITask } from 'pg-promise';
|
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
import db from '../../../db';
|
|
||||||
import * as buildingDataAccess from '../../dataAccess/building';
|
|
||||||
import { UserError } from '../../errors/general';
|
|
||||||
|
|
||||||
import { expireBuildingTileCache } from './tileCache';
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
*/
|
|
||||||
export async function updateBuildingData(
|
|
||||||
buildingId: number,
|
|
||||||
userId: string,
|
|
||||||
getUpdateValue: (t: ITask<any>) => Promise<object>,
|
|
||||||
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
|
|
||||||
): Promise<object> {
|
|
||||||
return await db.tx({mode: serializable}, async t => {
|
|
||||||
if (preUpdateDbAction != undefined) {
|
|
||||||
await preUpdateDbAction(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = await getUpdateValue(t);
|
|
||||||
|
|
||||||
const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
|
|
||||||
|
|
||||||
console.log(update);
|
|
||||||
const patches = compare(oldBuilding, update);
|
|
||||||
console.log('Patching', buildingId, patches);
|
|
||||||
const [forward, reverse] = patches;
|
|
||||||
if (Object.keys(forward).length === 0) {
|
|
||||||
throw new UserError('No change provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
|
|
||||||
|
|
||||||
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
|
|
||||||
|
|
||||||
expireBuildingTileCache(buildingId);
|
|
||||||
|
|
||||||
return updatedData;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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];
|
|
||||||
}
|
|
@ -1,9 +1,12 @@
|
|||||||
import { dataFieldsConfig } from '../../config/dataFields';
|
import { buildingAttributesConfig } from '../../config/dataFields';
|
||||||
|
import { BaseBuilding } from '../../models/building';
|
||||||
import * as buildingDataAccess from '../../dataAccess/building';
|
import * as buildingDataAccess from '../../dataAccess/building';
|
||||||
import * as verifyDataAccess from '../../dataAccess/verify';
|
import * as verifyDataAccess from '../../dataAccess/verify';
|
||||||
import { DatabaseError } from '../../errors/general';
|
import { DatabaseError } from '../../errors/general';
|
||||||
|
|
||||||
const FIELD_VERIFICATION_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.verify === true).map(([key]) => key));
|
function canVerify(key: string) {
|
||||||
|
return buildingAttributesConfig[key].verify === true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
|
export async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
|
||||||
// get current building attribute values for comparison
|
// get current building attribute values for comparison
|
||||||
@ -14,7 +17,7 @@ export async function verifyBuildingAttributes(buildingId: number, userId: strin
|
|||||||
// loop through attribute => value pairs to mark as verified
|
// loop through attribute => value pairs to mark as verified
|
||||||
for (let [key, value] of Object.entries(patch)) {
|
for (let [key, value] of Object.entries(patch)) {
|
||||||
// check key in whitelist
|
// check key in whitelist
|
||||||
if(FIELD_VERIFICATION_WHITELIST.has(key)) {
|
if(canVerify(key)) {
|
||||||
// check value against current from database - JSON.stringify as hack for "any" data type
|
// check value against current from database - JSON.stringify as hack for "any" data type
|
||||||
if (JSON.stringify(value) == JSON.stringify(building[key])) {
|
if (JSON.stringify(value) == JSON.stringify(building[key])) {
|
||||||
try {
|
try {
|
||||||
@ -49,17 +52,14 @@ export async function getUserVerifiedAttributes(buildingId: number, userId: stri
|
|||||||
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
|
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBuildingVerifications(building) {
|
export async function getBuildingVerifications(building: BaseBuilding) {
|
||||||
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
|
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
|
||||||
|
|
||||||
const verified = {};
|
const verified: Record<string, number> = {};
|
||||||
for (const element of FIELD_VERIFICATION_WHITELIST) {
|
|
||||||
verified[element] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of verifications) {
|
for (const item of verifications) {
|
||||||
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
|
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
|
||||||
verified[item.attribute] += 1
|
verified[item.attribute] = verified[item.attribute] ?? 0 + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return verified;
|
return verified;
|
||||||
|
23
app/src/api/services/domainLogic/aggregateUserAttributes.ts
Normal file
23
app/src/api/services/domainLogic/aggregateUserAttributes.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
import { BuildingAttributes, BuildingUpdate } from '../../models/building';
|
||||||
|
import { aggregationsConfig } from '../../config/aggregationsConfig';
|
||||||
|
import { aggregationMethods } from '../../dataAccess/aggregate';
|
||||||
|
|
||||||
|
export async function aggregateUserAttributes(
|
||||||
|
buildingId: number,
|
||||||
|
userId: string,
|
||||||
|
{ attributes, userAttributes } : BuildingUpdate,
|
||||||
|
t?: ITask<any>
|
||||||
|
): Promise<Partial<BuildingAttributes>> {
|
||||||
|
const derivedAttributes: Partial<BuildingAttributes> = {};
|
||||||
|
|
||||||
|
for(let [key, aggregations] of Object.entries(aggregationsConfig)) {
|
||||||
|
if(key in userAttributes) {
|
||||||
|
for(let config of aggregations) {
|
||||||
|
derivedAttributes[config.aggregateFieldName] = await aggregationMethods[config.aggregationMethod](buildingId, key, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign({}, attributes, derivedAttributes);
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { ITask } from 'pg-promise';
|
||||||
|
|
||||||
import { hasAnyOwnProperty } from '../../../helpers';
|
import { hasAnyOwnProperty } from '../../../helpers';
|
||||||
import { Building, BuildingUpdate } from '../../config/dataFields';
|
import { BaseBuilding, BuildingAttributes, BuildingUpdate } from '../../models/building';
|
||||||
import { getBuildingData } from '../../dataAccess/building';
|
import { getBuildingData } from '../../dataAccess/building';
|
||||||
import { ArgumentError } from '../../errors/general';
|
import { ArgumentError } from '../../errors/general';
|
||||||
|
|
||||||
@ -10,7 +11,11 @@ import { updateLandUse } from './landUse';
|
|||||||
/**
|
/**
|
||||||
* Process land use classifications - derive land use order from land use groups
|
* Process land use classifications - derive land use order from land use groups
|
||||||
*/
|
*/
|
||||||
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
|
async function processCurrentLandUseClassifications(
|
||||||
|
buildingId: number,
|
||||||
|
buildingUpdate: Partial<BuildingAttributes>,
|
||||||
|
t?: ITask<any>
|
||||||
|
): Promise<any> {
|
||||||
const currentBuildingData = await getBuildingData(buildingId);
|
const currentBuildingData = await getBuildingData(buildingId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -39,10 +44,14 @@ async function processCurrentLandUseClassifications(buildingId: number, building
|
|||||||
/**
|
/**
|
||||||
* Process Dynamics data - check field relationships and sort demolished buildings by construction date
|
* Process Dynamics data - check field relationships and sort demolished buildings by construction date
|
||||||
*/
|
*/
|
||||||
async function processDynamicsDemolishedBuildings(buildingId: number, buildingUpdate: BuildingUpdate): Promise<BuildingUpdate> {
|
async function processDynamicsDemolishedBuildings(
|
||||||
|
buildingId: number,
|
||||||
|
attributesUpdate: Partial<BuildingAttributes>,
|
||||||
|
t?: ITask<any>
|
||||||
|
): Promise<Partial<BuildingAttributes>> {
|
||||||
const currentBuildingData = await getBuildingData(buildingId);
|
const currentBuildingData = await getBuildingData(buildingId);
|
||||||
|
|
||||||
const afterUpdate: Building = Object.assign({}, currentBuildingData, buildingUpdate);
|
const afterUpdate: BaseBuilding = Object.assign({}, currentBuildingData, attributesUpdate);
|
||||||
|
|
||||||
const hasDemolished: boolean = afterUpdate.dynamics_has_demolished_buildings;
|
const hasDemolished: boolean = afterUpdate.dynamics_has_demolished_buildings;
|
||||||
const demolishedList: any[] = afterUpdate.demolished_buildings;
|
const demolishedList: any[] = afterUpdate.demolished_buildings;
|
||||||
@ -57,24 +66,27 @@ async function processDynamicsDemolishedBuildings(buildingId: number, buildingUp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(buildingUpdate.demolished_buildings != undefined) {
|
if(attributesUpdate.demolished_buildings != undefined) {
|
||||||
buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
|
attributesUpdate.demolished_buildings = attributesUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildingUpdate;
|
return attributesUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define any custom processing logic for specific building attributes
|
* Define any custom processing logic for specific building attributes
|
||||||
*/
|
*/
|
||||||
export async function processBuildingUpdate(buildingId: number, buildingUpdate: BuildingUpdate): Promise<any> {
|
export async function processBuildingUpdate(buildingId: number, {attributes, userAttributes}: BuildingUpdate, t?: ITask<any>): Promise<BuildingUpdate> {
|
||||||
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
|
if(hasAnyOwnProperty(attributes, ['current_landuse_group'])) {
|
||||||
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
attributes = await processCurrentLandUseClassifications(buildingId, attributes, t);
|
||||||
}
|
}
|
||||||
if(hasAnyOwnProperty(buildingUpdate, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
|
if(hasAnyOwnProperty(attributes, ['demolished_buildings', 'dynamics_has_demolished_buildings'])) {
|
||||||
buildingUpdate = await processDynamicsDemolishedBuildings(buildingId, buildingUpdate);
|
attributes = await processDynamicsDemolishedBuildings(buildingId, attributes, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildingUpdate;
|
return {
|
||||||
|
attributes,
|
||||||
|
userAttributes
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
import Ajv from 'ajv';
|
|
||||||
import addFormats from 'ajv-formats';
|
|
||||||
|
|
||||||
import { mapObject } from '../../../helpers';
|
|
||||||
import { InvalidFieldError, FieldTypeError } from '../../errors/general';
|
|
||||||
import { dataFieldsConfig } from '../../config/dataFields';
|
|
||||||
import { fieldSchemaConfig } from '../../config/fieldSchemaConfig';
|
|
||||||
|
|
||||||
const ajv = new Ajv();
|
|
||||||
addFormats(ajv);
|
|
||||||
|
|
||||||
const compiledSchemas = mapObject(fieldSchemaConfig, ([, val]) => ajv.compile(val))
|
|
||||||
|
|
||||||
const EXTERNAL_FIELD_EDIT_ALLOWLIST = new Set(Object.entries(dataFieldsConfig).filter(([, value]) => value.edit).map(([key]) => key));
|
|
||||||
|
|
||||||
export function validateBuildingUpdate(buildingId: number, building: any) {
|
|
||||||
for(const field of Object.keys(building)) {
|
|
||||||
if(!EXTERNAL_FIELD_EDIT_ALLOWLIST.has(field)) {
|
|
||||||
throw new InvalidFieldError('Field is not editable', field);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(field in compiledSchemas) {
|
|
||||||
if(!compiledSchemas[field](building[field])) {
|
|
||||||
throw new FieldTypeError('Invalid format of data sent', field);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
47
app/src/api/services/domainLogic/validateUpdate.ts
Normal file
47
app/src/api/services/domainLogic/validateUpdate.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import Ajv from 'ajv';
|
||||||
|
import addFormats from 'ajv-formats';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { InvalidFieldError, FieldTypeError } from '../../errors/general';
|
||||||
|
import { allAttributesConfig } from '../../config/dataFields';
|
||||||
|
import { BuildingAttributes, BuildingUserAttributes } from '../../models/building';
|
||||||
|
import { fieldSchemaConfig } from '../../config/fieldSchemaConfig';
|
||||||
|
|
||||||
|
const ajv = new Ajv();
|
||||||
|
addFormats(ajv);
|
||||||
|
|
||||||
|
const compiledSchemas = _.mapValues(fieldSchemaConfig, (val) => ajv.compile(val));
|
||||||
|
|
||||||
|
function isDefined(key: string) {
|
||||||
|
return allAttributesConfig[key] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEdit(key: string, allowDerived: boolean = false) {
|
||||||
|
const config = allAttributesConfig[key];
|
||||||
|
|
||||||
|
return config.edit || (allowDerived && config.derivedEdit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFieldChange(field: string, value: any, isExternal: boolean = true) {
|
||||||
|
if(!isDefined(field)) {
|
||||||
|
throw new InvalidFieldError('Field does not exist', field);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowDerived = !isExternal;
|
||||||
|
if(!canEdit(field, allowDerived)) {
|
||||||
|
throw new InvalidFieldError('Field is not editable', field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(field in compiledSchemas) {
|
||||||
|
if(!compiledSchemas[field](value)) {
|
||||||
|
throw new FieldTypeError('Invalid format of data sent', field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateChangeSet(
|
||||||
|
attributes: Partial<BuildingAttributes> | Partial<BuildingUserAttributes>,
|
||||||
|
isExternal: boolean = true
|
||||||
|
) {
|
||||||
|
_.forIn(attributes, (value, fieldKey) => validateFieldChange(fieldKey, value, isExternal));
|
||||||
|
}
|
@ -142,9 +142,9 @@ async function getNewUserAPIKey(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authAPIUser(key: string) {
|
async function authAPIUser(key: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await db.one(
|
const { user_id } = await db.one(
|
||||||
`SELECT
|
`SELECT
|
||||||
user_id
|
user_id
|
||||||
FROM
|
FROM
|
||||||
@ -155,6 +155,8 @@ async function authAPIUser(key: string) {
|
|||||||
key
|
key
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return user_id;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -15,7 +15,6 @@ hydrate(
|
|||||||
<App
|
<App
|
||||||
user={data.user}
|
user={data.user}
|
||||||
building={data.building}
|
building={data.building}
|
||||||
building_like={data.building_like}
|
|
||||||
user_verified={data.user_verified}
|
user_verified={data.user_verified}
|
||||||
revisionId={data.latestRevisionId}
|
revisionId={data.latestRevisionId}
|
||||||
/>
|
/>
|
||||||
|
48
app/src/frontend/api-data/building-update.ts
Normal file
48
app/src/frontend/api-data/building-update.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { BuildingAttributes, BuildingEdits, BuildingUserAttributes } from '../models/building';
|
||||||
|
import { apiPost } from '../apiHelpers';
|
||||||
|
import { buildingUserFields, dataFields } from '../config/data-fields-config';
|
||||||
|
|
||||||
|
export type UpdatedBuilding = Partial<BuildingAttributes> & Partial<BuildingUserAttributes> & {
|
||||||
|
revision_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeUpdateData(edits: BuildingEdits) {
|
||||||
|
const data = {
|
||||||
|
attributes: {},
|
||||||
|
user_attributes: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let [field, value] of Object.entries(edits)) {
|
||||||
|
if (dataFields[field]) {
|
||||||
|
data.attributes[field] = value;
|
||||||
|
} else if (buildingUserFields[field]) {
|
||||||
|
data.user_attributes[field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBuildingUpdate(buildingId: number, edits: BuildingEdits): Promise<UpdatedBuilding> {
|
||||||
|
const requestData = makeUpdateData(edits);
|
||||||
|
const data = await apiPost(
|
||||||
|
`/api/buildings/${buildingId}.json`,
|
||||||
|
requestData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw data.error;
|
||||||
|
} else {
|
||||||
|
const {
|
||||||
|
revision_id,
|
||||||
|
attributes,
|
||||||
|
user_attributes
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
revision_id,
|
||||||
|
...attributes,
|
||||||
|
...user_attributes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,14 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Building, BuildingAttributeVerificationCounts } from '../models/building';
|
import { Building, BuildingAttributeVerificationCounts } from '../models/building';
|
||||||
import { apiGet } from '../apiHelpers';
|
import { apiGet } from '../apiHelpers';
|
||||||
|
|
||||||
export function useBuildingData(buildingId: number, preloadedData: Building): [Building, (updatedBuilding: Building) => void, () => void] {
|
/**
|
||||||
|
*
|
||||||
|
* @param buildingId Requested building ID
|
||||||
|
* @param preloadedData Data preloaded through SSR, to return before the request is first sent
|
||||||
|
* @param includeUserAttributes Should the building-user attributes be included in the result? This requires login session cookies to be present
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function useBuildingData(buildingId: number, preloadedData: Building, includeUserAttributes: boolean = false): [Building, (updatedBuilding: Building) => void, () => void] {
|
||||||
const [buildingData, setBuildingData] = useState<Building>(preloadedData);
|
const [buildingData, setBuildingData] = useState<Building>(preloadedData);
|
||||||
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
|
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
|
||||||
|
|
||||||
@ -14,12 +21,14 @@ export function useBuildingData(buildingId: number, preloadedData: Building): [B
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const [building, buildingUprns] = await Promise.all([
|
let [building, buildingUprns] = await Promise.all([
|
||||||
apiGet(`/api/buildings/${buildingId}.json`),
|
apiGet(`/api/buildings/${buildingId}.json${includeUserAttributes ? '?user_attributes=true' : ''}`),
|
||||||
apiGet(`/api/buildings/${buildingId}/uprns.json`)
|
apiGet(`/api/buildings/${buildingId}/uprns.json`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
building.uprns = buildingUprns.uprns;
|
building.uprns = buildingUprns.uprns;
|
||||||
|
building = Object.assign(building, {...building.user_attributes});
|
||||||
|
delete building.user_attributes;
|
||||||
|
|
||||||
setBuildingData(building);
|
setBuildingData(building);
|
||||||
} catch(error) {
|
} catch(error) {
|
@ -32,7 +32,6 @@ import { NotFound } from './pages/not-found';
|
|||||||
interface AppProps {
|
interface AppProps {
|
||||||
user?: User;
|
user?: User;
|
||||||
building?: Building;
|
building?: Building;
|
||||||
building_like?: boolean;
|
|
||||||
user_verified?: UserVerified;
|
user_verified?: UserVerified;
|
||||||
revisionId: string;
|
revisionId: string;
|
||||||
}
|
}
|
||||||
@ -83,7 +82,6 @@ export const App: React.FC<AppProps> = props => {
|
|||||||
<Route exact path={mapAppPaths} >
|
<Route exact path={mapAppPaths} >
|
||||||
<MapApp
|
<MapApp
|
||||||
building={props.building}
|
building={props.building}
|
||||||
building_like={props.building_like}
|
|
||||||
user_verified={props.user_verified}
|
user_verified={props.user_verified}
|
||||||
revisionId={props.revisionId}
|
revisionId={props.revisionId}
|
||||||
/>
|
/>
|
||||||
|
@ -11,10 +11,8 @@ interface BuildingViewProps {
|
|||||||
cat: Category;
|
cat: Category;
|
||||||
mode: 'view' | 'edit';
|
mode: 'view' | 'edit';
|
||||||
building?: Building;
|
building?: Building;
|
||||||
building_like?: boolean;
|
|
||||||
user_verified?: any;
|
user_verified?: any;
|
||||||
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
|
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
|
||||||
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
|
|
||||||
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
|
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Tooltip from '../../components/tooltip';
|
import Tooltip from '../../components/tooltip';
|
||||||
@ -34,7 +35,9 @@ const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (prop
|
|||||||
<div className="data-title">
|
<div className="data-title">
|
||||||
<div className="data-title-text">
|
<div className="data-title-text">
|
||||||
<label htmlFor={`${props.slug}${props.slugModifier ?? ''}`}>
|
<label htmlFor={`${props.slug}${props.slugModifier ?? ''}`}>
|
||||||
{ props.title }
|
<Markdown>
|
||||||
|
{ props.title }
|
||||||
|
</Markdown>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="data-title-actions icon-buttons">
|
<div className="data-title-actions icon-buttons">
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
import React, { Fragment } from 'react';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Tooltip from '../../components/tooltip';
|
|
||||||
import { Category } from '../../config/categories-config';
|
|
||||||
|
|
||||||
|
|
||||||
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/${Category.Community}?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;
|
|
@ -44,6 +44,17 @@ const ToggleButton: React.FC<ToggleButtonProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ClearButton = ({
|
||||||
|
onClick,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
return <div className="btn-group btn-group-toggle">
|
||||||
|
<label>
|
||||||
|
<button type="button" className="btn btn-outline-warning" onClick={onClick} disabled={disabled}>Clear</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
interface LogicalDataEntryProps extends BaseDataEntryProps {
|
interface LogicalDataEntryProps extends BaseDataEntryProps {
|
||||||
value: boolean;
|
value: boolean;
|
||||||
disallowTrue?: boolean;
|
disallowTrue?: boolean;
|
||||||
@ -53,7 +64,11 @@ interface LogicalDataEntryProps extends BaseDataEntryProps {
|
|||||||
|
|
||||||
export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
|
export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
|
||||||
function handleValueChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleValueChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
props.onChange?.(props.slug, e.target.value === 'null' ? null : e.target.value === 'true');
|
props.onChange?.(props.slug, e.target.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
props.onChange?.(props.slug, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled = props.mode === 'view' || props.disabled;
|
const isDisabled = props.mode === 'view' || props.disabled;
|
||||||
@ -76,16 +91,6 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
|
|||||||
uncheckedClassName='btn-outline-dark'
|
uncheckedClassName='btn-outline-dark'
|
||||||
onChange={handleValueChange}
|
onChange={handleValueChange}
|
||||||
>Yes</ToggleButton>
|
>Yes</ToggleButton>
|
||||||
|
|
||||||
<ToggleButton
|
|
||||||
value="null"
|
|
||||||
checked={props.value == null}
|
|
||||||
disabled={isDisabled || props.disallowNull}
|
|
||||||
checkedClassName='btn-secondary active'
|
|
||||||
uncheckedClassName='btn-outline-dark'
|
|
||||||
onChange={handleValueChange}
|
|
||||||
>?</ToggleButton>
|
|
||||||
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
value="false"
|
value="false"
|
||||||
checked={props.value === false}
|
checked={props.value === false}
|
||||||
@ -95,6 +100,10 @@ export const LogicalDataEntry: React.FC<LogicalDataEntryProps> = (props) => {
|
|||||||
onChange={handleValueChange}
|
onChange={handleValueChange}
|
||||||
>No</ToggleButton>
|
>No</ToggleButton>
|
||||||
</div>
|
</div>
|
||||||
|
{
|
||||||
|
!isDisabled && props.value != null &&
|
||||||
|
<ClearButton onClick={handleClear} disabled={props.disallowNull}/>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
import React, { ChangeEvent } from 'react';
|
||||||
|
|
||||||
|
import { BaseDataEntryProps } from './data-entry';
|
||||||
|
import { DataTitleCopyable } from './data-title';
|
||||||
|
|
||||||
|
interface MultiSelectOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultiSelectDataEntryProps extends BaseDataEntryProps {
|
||||||
|
value: {[key: string]: boolean};
|
||||||
|
options: (MultiSelectOption)[];
|
||||||
|
showTitle?: boolean; // TODO make it an option for all input types
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MultiSelectDataEntry: React.FunctionComponent<MultiSelectDataEntryProps> = (props) => {
|
||||||
|
const slugWithModifier = props.slug + (props.slugModifier ?? '');
|
||||||
|
|
||||||
|
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const changedKey = e.target.name;
|
||||||
|
const checked = e.target.checked;
|
||||||
|
|
||||||
|
const newVal = {...props.value, [changedKey]: checked || null};
|
||||||
|
|
||||||
|
props.onChange(slugWithModifier, newVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.showTitle !== false &&
|
||||||
|
<DataTitleCopyable
|
||||||
|
slug={props.slug}
|
||||||
|
slugModifier={props.slugModifier}
|
||||||
|
title={props.title}
|
||||||
|
tooltip={props.tooltip}
|
||||||
|
disabled={props.disabled || props.value == undefined}
|
||||||
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
props.options.map(o => (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={props.mode === 'view' || props.disabled}
|
||||||
|
name={o.key}
|
||||||
|
checked={props.value && props.value[o.key]}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
{o.label}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,63 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AggregationDescriptionConfig, buildingUserFields, dataFields } from '../../config/data-fields-config';
|
||||||
|
import { CopyProps } from '../data-containers/category-view-props';
|
||||||
|
import { DataTitleCopyable } from './data-title';
|
||||||
|
|
||||||
|
|
||||||
|
interface UserOpinionEntryProps {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
mode: 'view' | 'edit' | 'multi-edit';
|
||||||
|
userValue: boolean;
|
||||||
|
aggregateValue: number;
|
||||||
|
aggregationDescriptions: AggregationDescriptionConfig;
|
||||||
|
copy: CopyProps;
|
||||||
|
onChange: (key: string, value: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserOpinionEntry: React.FunctionComponent<UserOpinionEntryProps> = (props) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTitleCopyable
|
||||||
|
slug={props.slug}
|
||||||
|
title={props.title}
|
||||||
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label">
|
||||||
|
<input className="form-check-input" type="checkbox"
|
||||||
|
name={props.slug}
|
||||||
|
checked={!!props.userValue}
|
||||||
|
disabled={props.mode === 'view'}
|
||||||
|
onChange={e => props.onChange(props.slug, e.target.checked)}
|
||||||
|
/> Yes
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<span style={{fontStyle: 'italic'}}>
|
||||||
|
{
|
||||||
|
(props.aggregateValue)?
|
||||||
|
(props.aggregateValue === 1)?
|
||||||
|
`1 person `
|
||||||
|
: `${props.aggregateValue} people `
|
||||||
|
: `0 people so far `
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
(props.aggregateValue)?
|
||||||
|
(props.aggregateValue === 1)?
|
||||||
|
props.aggregationDescriptions.one
|
||||||
|
: props.aggregationDescriptions.many
|
||||||
|
: props.aggregationDescriptions.zero
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserOpinionEntry;
|
@ -1,12 +1,14 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { NavLink, Redirect } from 'react-router-dom';
|
import { NavLink, Redirect } from 'react-router-dom';
|
||||||
import Confetti from 'canvas-confetti';
|
import Confetti from 'canvas-confetti';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { apiPost } from '../apiHelpers';
|
import { apiPost } from '../apiHelpers';
|
||||||
|
import { sendBuildingUpdate } from '../api-data/building-update';
|
||||||
import ErrorBox from '../components/error-box';
|
import ErrorBox from '../components/error-box';
|
||||||
import InfoBox from '../components/info-box';
|
import InfoBox from '../components/info-box';
|
||||||
import { compareObjects } from '../helpers';
|
import { compareObjects } from '../helpers';
|
||||||
import { Building, BuildingAttributes, UserVerified } from '../models/building';
|
import { Building, BuildingEdits, BuildingUserAttributes, UserVerified } from '../models/building';
|
||||||
import { User } from '../models/user';
|
import { User } from '../models/user';
|
||||||
|
|
||||||
import ContainerHeader from './container-header';
|
import ContainerHeader from './container-header';
|
||||||
@ -26,10 +28,8 @@ interface DataContainerProps {
|
|||||||
user?: User;
|
user?: User;
|
||||||
mode: 'view' | 'edit';
|
mode: 'view' | 'edit';
|
||||||
building?: Building;
|
building?: Building;
|
||||||
building_like?: boolean;
|
|
||||||
user_verified?: any;
|
user_verified?: any;
|
||||||
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
|
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
|
||||||
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
|
|
||||||
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
|
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ interface DataContainerState {
|
|||||||
keys_to_copy: {[key: string]: boolean};
|
keys_to_copy: {[key: string]: boolean};
|
||||||
currentBuildingId: number;
|
currentBuildingId: number;
|
||||||
currentBuildingRevisionId: number;
|
currentBuildingRevisionId: number;
|
||||||
buildingEdits: Partial<Building>;
|
buildingEdits: BuildingEdits;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataContainerType = React.ComponentType<DataContainerProps>;
|
export type DataContainerType = React.ComponentType<DataContainerProps>;
|
||||||
@ -68,7 +68,6 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
|
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
this.handleReset = this.handleReset.bind(this);
|
this.handleReset = this.handleReset.bind(this);
|
||||||
this.handleLike = this.handleLike.bind(this);
|
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.handleVerify = this.handleVerify.bind(this);
|
this.handleVerify = this.handleVerify.bind(this);
|
||||||
this.handleSaveAdd = this.handleSaveAdd.bind(this);
|
this.handleSaveAdd = this.handleSaveAdd.bind(this);
|
||||||
@ -78,7 +77,7 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
|
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromProps(props, state) {
|
static getDerivedStateFromProps(props, state): DataContainerState {
|
||||||
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
|
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
|
||||||
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
|
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
|
||||||
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
|
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
|
||||||
@ -122,9 +121,8 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
}
|
}
|
||||||
|
|
||||||
isEdited() {
|
isEdited() {
|
||||||
const edits = this.state.buildingEdits;
|
|
||||||
// check if the edits object has any fields
|
// check if the edits object has any fields
|
||||||
return Object.entries(edits).length !== 0;
|
return !_.isEmpty(this.state.buildingEdits);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEdits() {
|
clearEdits() {
|
||||||
@ -166,46 +164,15 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
this.clearEdits();
|
this.clearEdits();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async doSubmit(edits: Partial<Building & BuildingUserAttributes>) {
|
||||||
* 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 {
|
|
||||||
// like endpoint returns whole building data so we can update both
|
|
||||||
this.props.onBuildingUpdate(this.props.building.building_id, data);
|
|
||||||
this.props.onBuildingLikeUpdate(this.props.building.building_id, like);
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
this.setState({error: err});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async doSubmit(edits: Partial<BuildingAttributes>) {
|
|
||||||
this.setState({error: undefined});
|
this.setState({error: undefined});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiPost(
|
const buildingUpdate = await sendBuildingUpdate(this.props.building.building_id, edits);
|
||||||
`/api/buildings/${this.props.building.building_id}.json`,
|
const updatedBuilding = Object.assign({}, this.props.building, buildingUpdate);
|
||||||
edits
|
this.props.onBuildingUpdate(this.props.building.building_id, updatedBuilding);
|
||||||
);
|
} catch(error) {
|
||||||
|
this.setState({ error });
|
||||||
if (data.error) {
|
|
||||||
this.setState({error: data.error});
|
|
||||||
} else {
|
|
||||||
this.props.onBuildingUpdate(this.props.building.building_id, data);
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
this.setState({error: err});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -351,12 +318,10 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
<WrappedComponent
|
<WrappedComponent
|
||||||
intro={this.props.intro}
|
intro={this.props.intro}
|
||||||
building={this.props.building}
|
building={this.props.building}
|
||||||
building_like={this.props.building_like}
|
|
||||||
mode={this.props.mode}
|
mode={this.props.mode}
|
||||||
edited={false}
|
edited={false}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
onChange={undefined}
|
onChange={undefined}
|
||||||
onLike={undefined}
|
|
||||||
onVerify={undefined}
|
onVerify={undefined}
|
||||||
onSaveAdd={undefined}
|
onSaveAdd={undefined}
|
||||||
onSaveChange={undefined}
|
onSaveChange={undefined}
|
||||||
@ -404,12 +369,10 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
<WrappedComponent
|
<WrappedComponent
|
||||||
intro={this.props.intro}
|
intro={this.props.intro}
|
||||||
building={currentBuilding}
|
building={currentBuilding}
|
||||||
building_like={this.props.building_like}
|
|
||||||
mode={this.props.mode}
|
mode={this.props.mode}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
copy={copy}
|
copy={copy}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onLike={this.handleLike}
|
|
||||||
onVerify={this.handleVerify}
|
onVerify={this.handleVerify}
|
||||||
onSaveAdd={this.handleSaveAdd}
|
onSaveAdd={this.handleSaveAdd}
|
||||||
onSaveChange={this.handleSaveChange}
|
onSaveChange={this.handleSaveChange}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Building } from '../../models/building';
|
import { Building, BuildingAttributes } from '../../models/building';
|
||||||
|
|
||||||
interface CopyProps {
|
interface CopyProps {
|
||||||
copying: boolean;
|
copying: boolean;
|
||||||
@ -10,12 +10,10 @@ interface CopyProps {
|
|||||||
interface CategoryViewProps {
|
interface CategoryViewProps {
|
||||||
intro: string;
|
intro: string;
|
||||||
building: Building;
|
building: Building;
|
||||||
building_like: boolean;
|
|
||||||
mode: 'view' | 'edit' | 'multi-edit';
|
mode: 'view' | 'edit' | 'multi-edit';
|
||||||
edited: boolean;
|
edited: boolean;
|
||||||
copy: CopyProps;
|
copy: CopyProps;
|
||||||
onChange: (key: string, value: any) => void;
|
onChange: (key: string, value: any) => void;
|
||||||
onLike: (like: boolean) => void;
|
|
||||||
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
|
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
|
||||||
|
|
||||||
/* Special handler for adding and immediately saving a new item of an array-like attribute */
|
/* Special handler for adding and immediately saving a new item of an array-like attribute */
|
||||||
|
5
app/src/frontend/building/data-containers/community.css
Normal file
5
app/src/frontend/building/data-containers/community.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.community-opinion-pane {
|
||||||
|
padding-top: 0.5em;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
}
|
@ -1,41 +1,148 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import withCopyEdit from '../data-container';
|
import withCopyEdit from '../data-container';
|
||||||
import LikeDataEntry from '../data-components/like-data-entry';
|
import UserOpinionEntry from '../data-components/user-opinion-data-entry';
|
||||||
|
import { MultiSelectDataEntry } from '../data-components/multi-select-data-entry';
|
||||||
|
|
||||||
import { CategoryViewProps } from './category-view-props';
|
import { CategoryViewProps } from './category-view-props';
|
||||||
|
import InfoBox from '../../components/info-box';
|
||||||
|
import { LogicalDataEntry } from '../data-components/logical-data-entry/logical-data-entry';
|
||||||
|
import { buildingUserFields, dataFields } from '../../config/data-fields-config';
|
||||||
|
|
||||||
|
import './community.css';
|
||||||
|
import SelectDataEntry from '../data-components/select-data-entry';
|
||||||
|
import Verification from '../data-components/verification';
|
||||||
|
import { MultiDataEntry } from '../data-components/multi-data-entry/multi-data-entry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Community view/edit section
|
* Community view/edit section
|
||||||
*/
|
*/
|
||||||
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||||
<Fragment>
|
const worthKeepingReasonsNonEmpty = Object.values(props.building.community_type_worth_keeping_reasons ?? {}).some(x => x);
|
||||||
<LikeDataEntry
|
return <>
|
||||||
userLike={props.building_like}
|
<InfoBox type='warning'>
|
||||||
totalLikes={props.building.likes_total}
|
We are testing a new feature in this section! Switch between different colour maps by using the dropdown in the legend pane.
|
||||||
mode={props.mode}
|
</InfoBox>
|
||||||
onLike={props.onLike}
|
<div className='community-opinion-pane'>
|
||||||
|
<InfoBox>
|
||||||
|
Can you share your opinion on how well the building works?
|
||||||
|
</InfoBox>
|
||||||
|
<UserOpinionEntry
|
||||||
|
slug='community_like'
|
||||||
|
title={buildingUserFields.community_like.title}
|
||||||
|
|
||||||
|
userValue={props.building.community_like}
|
||||||
|
aggregateValue={props.building.likes_total}
|
||||||
|
aggregationDescriptions={dataFields.likes_total.aggregationDescriptions}
|
||||||
|
|
||||||
|
onChange={props.onSaveChange}
|
||||||
|
mode={props.mode}
|
||||||
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
<LogicalDataEntry
|
||||||
|
slug='community_type_worth_keeping'
|
||||||
|
title={buildingUserFields.community_type_worth_keeping.title}
|
||||||
|
|
||||||
|
value={props.building.community_type_worth_keeping}
|
||||||
|
disallowFalse={worthKeepingReasonsNonEmpty}
|
||||||
|
disallowNull={worthKeepingReasonsNonEmpty}
|
||||||
|
|
||||||
|
onChange={props.onSaveChange}
|
||||||
|
mode={props.mode}
|
||||||
|
copy={props.copy}
|
||||||
/>
|
/>
|
||||||
<p className="data-intro">{props.intro}</p>
|
|
||||||
<ul className="data-list">
|
|
||||||
<li>Is this a publicly owned building?</li>
|
|
||||||
{
|
{
|
||||||
// "slug": "community_publicly_owned",
|
props.building.community_type_worth_keeping !== false &&
|
||||||
// "type": "checkbox"
|
<MultiSelectDataEntry
|
||||||
|
slug='community_type_worth_keeping_reasons'
|
||||||
|
title={buildingUserFields.community_type_worth_keeping_reasons.title}
|
||||||
|
value={props.building.community_type_worth_keeping_reasons}
|
||||||
|
disabled={!props.building.community_type_worth_keeping}
|
||||||
|
onChange={props.onSaveChange}
|
||||||
|
options={
|
||||||
|
Object.entries(buildingUserFields.community_type_worth_keeping_reasons.fields)
|
||||||
|
.map(([key, definition]) => ({
|
||||||
|
key,
|
||||||
|
label: definition.title
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
mode={props.mode}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
<li>Has this building ever been used for community or public services activities?</li>
|
|
||||||
{
|
|
||||||
// "slug": "community_past_public",
|
<UserOpinionEntry
|
||||||
// "type": "checkbox"
|
slug='community_local_significance'
|
||||||
}
|
title={buildingUserFields.community_local_significance.title}
|
||||||
<li>Would you describe this building as a community asset?</li>
|
|
||||||
{
|
userValue={props.building.community_local_significance}
|
||||||
// "slug": "community_asset",
|
aggregateValue={props.building.community_local_significance_total}
|
||||||
// "type": "checkbox"
|
aggregationDescriptions={dataFields.community_local_significance_total.aggregationDescriptions}
|
||||||
}
|
|
||||||
</ul>
|
onChange={props.onSaveChange}
|
||||||
</Fragment>
|
mode={props.mode}
|
||||||
);
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoBox>Can you help add information about public ownership of the building?</InfoBox>
|
||||||
|
<LogicalDataEntry
|
||||||
|
slug='community_activities'
|
||||||
|
title={dataFields.community_activities.title}
|
||||||
|
tooltip={dataFields.community_activities.tooltip}
|
||||||
|
value={props.building.community_activities}
|
||||||
|
|
||||||
|
onChange={props.onChange}
|
||||||
|
mode={props.mode}
|
||||||
|
/>
|
||||||
|
{/* TODO: dates */}
|
||||||
|
{
|
||||||
|
// props.building.community_activities === true &&
|
||||||
|
// <FieldRow>
|
||||||
|
// <div>
|
||||||
|
// </div>
|
||||||
|
// <div>
|
||||||
|
// </div>
|
||||||
|
// </FieldRow>
|
||||||
|
}
|
||||||
|
|
||||||
|
<SelectDataEntry
|
||||||
|
slug='community_public_ownership'
|
||||||
|
title={dataFields.community_public_ownership.title}
|
||||||
|
value={props.building.community_public_ownership}
|
||||||
|
options={[
|
||||||
|
'State-owned',
|
||||||
|
'Charity-owned',
|
||||||
|
'Community-owned/cooperative',
|
||||||
|
'Owned by other non-profit body',
|
||||||
|
'Not in public/community ownership',
|
||||||
|
]}
|
||||||
|
|
||||||
|
onChange={props.onChange}
|
||||||
|
mode={props.mode}
|
||||||
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
<Verification
|
||||||
|
slug="community_public_ownership"
|
||||||
|
allow_verify={props.user !== undefined && props.building.community_public_ownership !== null && !props.edited}
|
||||||
|
onVerify={props.onVerify}
|
||||||
|
user_verified={props.user_verified.hasOwnProperty("community_public_ownership")}
|
||||||
|
user_verified_as={props.user_verified.community_public_ownership}
|
||||||
|
verified_count={props.building.verified.community_public_ownership}
|
||||||
|
/>
|
||||||
|
<MultiDataEntry
|
||||||
|
slug='community_public_ownership_sources'
|
||||||
|
title={dataFields.community_public_ownership_sources.title}
|
||||||
|
isUrl={true}
|
||||||
|
placeholder={'https://...'}
|
||||||
|
editableEntries={true}
|
||||||
|
|
||||||
|
value={props.building.community_public_ownership_sources}
|
||||||
|
onChange={props.onChange}
|
||||||
|
mode={props.mode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
};
|
||||||
const CommunityContainer = withCopyEdit(CommunityView);
|
const CommunityContainer = withCopyEdit(CommunityView);
|
||||||
|
|
||||||
export default CommunityContainer;
|
export default CommunityContainer;
|
||||||
|
@ -4,10 +4,9 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useMultiEditData } from '../hooks/use-multi-edit-data';
|
import { useMultiEditData } from '../hooks/use-multi-edit-data';
|
||||||
import ErrorBox from '../components/error-box';
|
import ErrorBox from '../components/error-box';
|
||||||
import InfoBox from '../components/info-box';
|
import InfoBox from '../components/info-box';
|
||||||
import { dataFields } from '../config/data-fields-config';
|
import { allFieldsConfig } from '../config/data-fields-config';
|
||||||
|
|
||||||
import DataEntry from './data-components/data-entry';
|
import DataEntry from './data-components/data-entry';
|
||||||
import { Category } from '../config/categories-config';
|
|
||||||
|
|
||||||
interface MultiEditProps {
|
interface MultiEditProps {
|
||||||
category: string;
|
category: string;
|
||||||
@ -16,34 +15,24 @@ interface MultiEditProps {
|
|||||||
const MultiEdit: React.FC<MultiEditProps> = (props) => {
|
const MultiEdit: React.FC<MultiEditProps> = (props) => {
|
||||||
const [data, error] = useMultiEditData();
|
const [data, error] = useMultiEditData();
|
||||||
|
|
||||||
const isLike = props.category === Category.Community;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='data-section'>
|
<section className='data-section'>
|
||||||
<header className={`section-header view ${props.category} background-${props.category}`}>
|
<header className={`section-header view ${props.category} background-${props.category}`}>
|
||||||
<h2 className="h2">{
|
<h2 className="h2">Paste {props.category} data</h2>
|
||||||
isLike ?
|
|
||||||
<>Like Me!</> :
|
|
||||||
<>Copy {props.category} data</>
|
|
||||||
}</h2>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="section-body">
|
<div className="section-body">
|
||||||
<form>
|
<form>
|
||||||
{
|
{
|
||||||
error ?
|
error ?
|
||||||
<ErrorBox msg={error} /> :
|
<ErrorBox msg={error} /> :
|
||||||
<InfoBox msg={
|
<InfoBox msg='Click buildings one at a time to colour using the data below' />
|
||||||
isLike ?
|
|
||||||
'Click all the buildings that you like and think contribute to the city!' :
|
|
||||||
'Click buildings one at a time to colour using the data below'
|
|
||||||
} />
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
!isLike && data &&
|
data &&
|
||||||
Object.keys(data).map((key => (
|
Object.keys(data).map((key => (
|
||||||
<DataEntry
|
<DataEntry
|
||||||
key={key}
|
key={key}
|
||||||
title={dataFields[key]?.title ?? `Unknown field (${key})`}
|
title={allFieldsConfig[key]?.title ?? `Unknown field (${key})`}
|
||||||
slug={key}
|
slug={key}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
value={data[key]}
|
value={data[key]}
|
||||||
|
@ -2,15 +2,16 @@ import React from 'react';
|
|||||||
|
|
||||||
interface InfoBoxProps {
|
interface InfoBoxProps {
|
||||||
msg?: string;
|
msg?: string;
|
||||||
|
type?: 'info' | 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
const InfoBox: React.FC<InfoBoxProps> = (props) => (
|
const InfoBox: React.FC<InfoBoxProps> = ({msg, children, type = 'info'}) => (
|
||||||
<>
|
<>
|
||||||
{
|
{
|
||||||
(props.msg || props.children) &&
|
(msg || children) &&
|
||||||
<div className="alert alert-info" role="alert">
|
<div className={`alert alert-${type}`} role="alert">
|
||||||
{ props.msg ?? '' }
|
{ msg ?? '' }
|
||||||
{ props.children }
|
{ children }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
|
@ -3,6 +3,7 @@ import React, { Component } from 'react';
|
|||||||
import './tooltip.css';
|
import './tooltip.css';
|
||||||
|
|
||||||
import { InfoIcon } from './icons';
|
import { InfoIcon } from './icons';
|
||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
text: string;
|
text: string;
|
||||||
@ -79,7 +80,9 @@ class Tooltip extends Component<TooltipProps, TooltipState> {
|
|||||||
<div className="tooltip bs-tooltip-bottom">
|
<div className="tooltip bs-tooltip-bottom">
|
||||||
<div className="arrow"></div>
|
<div className="arrow"></div>
|
||||||
<div className="tooltip-inner">
|
<div className="tooltip-inner">
|
||||||
{tooltipTextToComponents(this.props.text)}
|
<Markdown>
|
||||||
|
{this.props.text}
|
||||||
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -23,8 +23,8 @@ export interface CategoryMapDefinition {
|
|||||||
|
|
||||||
export const defaultMapCategory = Category.Age;
|
export const defaultMapCategory = Category.Age;
|
||||||
|
|
||||||
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} = {
|
||||||
[Category.Age]: {
|
[Category.Age]: [{
|
||||||
mapStyle: 'date_year',
|
mapStyle: 'date_year',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Age',
|
title: 'Age',
|
||||||
@ -46,8 +46,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: '#d0c291', text: '<1700' },
|
{ color: '#d0c291', text: '<1700' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Size]: {
|
[Category.Size]: [{
|
||||||
mapStyle: 'size_height',
|
mapStyle: 'size_height',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Height to apex',
|
title: 'Height to apex',
|
||||||
@ -62,15 +62,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: '#980043', text: '≥152'}
|
{ color: '#980043', text: '≥152'}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Team]: {
|
[Category.Team]: [{
|
||||||
mapStyle: undefined,
|
mapStyle: undefined,
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Team',
|
title: 'Team',
|
||||||
elements: []
|
elements: []
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Construction]: {
|
[Category.Construction]: [{
|
||||||
mapStyle: 'construction_core_material',
|
mapStyle: 'construction_core_material',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Construction',
|
title: 'Construction',
|
||||||
@ -85,8 +85,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: "#c48a85", text: "Other Man-Made Material" }
|
{ color: "#c48a85", text: "Other Man-Made Material" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Location]: {
|
[Category.Location]: [{
|
||||||
mapStyle: 'location',
|
mapStyle: 'location',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Location',
|
title: 'Location',
|
||||||
@ -99,23 +99,52 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: '#bae4bc', text: '<20%' }
|
{ color: '#bae4bc', text: '<20%' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Community]: {
|
[Category.Community]: [
|
||||||
mapStyle: 'likes',
|
{
|
||||||
legend: {
|
mapStyle: 'likes',
|
||||||
title: 'Like Me',
|
legend: {
|
||||||
elements: [
|
title: 'Like Me',
|
||||||
{ color: '#bd0026', text: '👍👍👍👍 100+' },
|
elements: [
|
||||||
{ color: '#e31a1c', text: '👍👍👍 50–99' },
|
{ color: '#bd0026', text: '👍👍👍👍 100+' },
|
||||||
{ color: '#fc4e2a', text: '👍👍 20–49' },
|
{ color: '#e31a1c', text: '👍👍👍 50–99' },
|
||||||
{ color: '#fd8d3c', text: '👍👍 10–19' },
|
{ color: '#fc4e2a', text: '👍👍 20–49' },
|
||||||
{ color: '#feb24c', text: '👍 3–9' },
|
{ color: '#fd8d3c', text: '👍👍 10–19' },
|
||||||
{ color: '#fed976', text: '👍 2' },
|
{ color: '#feb24c', text: '👍 3–9' },
|
||||||
{ color: '#ffe8a9', text: '👍 1'}
|
{ color: '#fed976', text: '👍 2' },
|
||||||
]
|
{ color: '#ffe8a9', text: '👍 1'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mapStyle: 'community_local_significance_total',
|
||||||
|
legend: {
|
||||||
|
title: 'Local Significance',
|
||||||
|
description: 'People who think the building should be locally listed',
|
||||||
|
elements: [
|
||||||
|
{ color: '#bd0026', text: '100+' },
|
||||||
|
{ color: '#e31a1c', text: '50–99' },
|
||||||
|
{ color: '#fc4e2a', text: '20–49' },
|
||||||
|
{ color: '#fd8d3c', text: '10–19' },
|
||||||
|
{ color: '#feb24c', text: '3–9' },
|
||||||
|
{ color: '#fed976', text: '2' },
|
||||||
|
{ color: '#ffe8a9', text: '1'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mapStyle: 'community_in_public_ownership',
|
||||||
|
legend: {
|
||||||
|
title: 'Public Ownership',
|
||||||
|
description: 'Is the building in some form of public/community ownership',
|
||||||
|
elements: [
|
||||||
|
{color: '#1166ff', text: 'Yes'},
|
||||||
|
{color: '#ffaaa0', text: 'No'}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
[Category.Planning]: {
|
[Category.Planning]: [{
|
||||||
mapStyle: 'planning_combined',
|
mapStyle: 'planning_combined',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Statutory protections',
|
title: 'Statutory protections',
|
||||||
@ -128,8 +157,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: '#858ed4', text: 'Locally listed'},
|
{ color: '#858ed4', text: 'Locally listed'},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Sustainability]: {
|
[Category.Sustainability]: [{
|
||||||
mapStyle: 'sust_dec',
|
mapStyle: 'sust_dec',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Sustainability',
|
title: 'Sustainability',
|
||||||
@ -144,8 +173,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: "#e31d23", text: 'G' },
|
{ color: "#e31d23", text: 'G' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Type]: {
|
[Category.Type]: [{
|
||||||
mapStyle: 'building_attachment_form',
|
mapStyle: 'building_attachment_form',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Type',
|
title: 'Type',
|
||||||
@ -156,8 +185,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: "#226291", text: "Mid-Terrace" }
|
{ color: "#226291", text: "Mid-Terrace" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.LandUse]: {
|
[Category.LandUse]: [{
|
||||||
mapStyle: 'landuse',
|
mapStyle: 'landuse',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Land Use',
|
title: 'Land Use',
|
||||||
@ -177,15 +206,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
{ color: '#ffffff', text: 'Vacant & Derelict' }
|
{ color: '#ffffff', text: 'Vacant & Derelict' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Streetscape]: {
|
[Category.Streetscape]: [{
|
||||||
mapStyle: undefined,
|
mapStyle: undefined,
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Streetscape',
|
title: 'Streetscape',
|
||||||
elements: []
|
elements: []
|
||||||
},
|
},
|
||||||
},
|
}],
|
||||||
[Category.Dynamics]: {
|
[Category.Dynamics]: [{
|
||||||
mapStyle: 'dynamics_demolished_count',
|
mapStyle: 'dynamics_demolished_count',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Dynamics',
|
title: 'Dynamics',
|
||||||
@ -218,6 +247,6 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}]
|
||||||
|
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,12 @@
|
|||||||
import { Category } from './categories-config';
|
import { Category } from './categories-config';
|
||||||
|
|
||||||
|
|
||||||
|
export interface AggregationDescriptionConfig {
|
||||||
|
zero: string;
|
||||||
|
one: string;
|
||||||
|
many: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
|
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
|
||||||
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
|
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
|
||||||
@ -35,6 +42,12 @@ export interface DataFieldDefinition {
|
|||||||
*/
|
*/
|
||||||
items?: { [key: string]: Omit<DataFieldDefinition, 'category'> };
|
items?: { [key: string]: Omit<DataFieldDefinition, 'category'> };
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the defined type is a dictionary, this describes the types of the dictionary's fields
|
||||||
|
*/
|
||||||
|
fields?: { [key: string]: Omit<DataFieldDefinition, 'category'>}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The example is used to determine the runtime type in which the attribute data is stored (e.g. number, string, object)
|
* The example is used to determine the runtime type in which the attribute data is stored (e.g. number, string, object)
|
||||||
* This gives the programmer auto-complete of all available building attributes when implementing a category view.
|
* This gives the programmer auto-complete of all available building attributes when implementing a category view.
|
||||||
@ -45,8 +58,71 @@ export interface DataFieldDefinition {
|
|||||||
* E.g. for building attachment form, you could use "Detached" as example
|
* E.g. for building attachment form, you could use "Detached" as example
|
||||||
*/
|
*/
|
||||||
example: any;
|
example: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the field is a field that has an independent value for each user.
|
||||||
|
* For example, user building likes are one of such fields.
|
||||||
|
* By default this is false - fields are treated as not user-specific.
|
||||||
|
*/
|
||||||
|
perUser?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only for fields that are aggregations of a building-user field.
|
||||||
|
* specify what text should be added to the number of users calculated by the aggregation.
|
||||||
|
* E.g. for user likes, if zero="like this building" then for a building with 0 likes,
|
||||||
|
* the result will be "0 people like this building"
|
||||||
|
*/
|
||||||
|
aggregationDescriptions?: AggregationDescriptionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const buildingUserFields = {
|
||||||
|
community_like: {
|
||||||
|
perUser: true,
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Do you like this building and think it contributes to the city?",
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
community_type_worth_keeping: {
|
||||||
|
perUser: true,
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Do you think this **type** of building is generally worth keeping?",
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
community_type_worth_keeping_reasons: {
|
||||||
|
perUser: true,
|
||||||
|
category: Category.Community,
|
||||||
|
title: 'Why is this type of building worth keeping?',
|
||||||
|
fields: {
|
||||||
|
external_design: {
|
||||||
|
title: "because the external design contributes to the streetscape"
|
||||||
|
},
|
||||||
|
internal_design: {
|
||||||
|
title: 'because the internal design works well'
|
||||||
|
},
|
||||||
|
adaptable: {
|
||||||
|
title: 'because the building is adaptable / can be reused to make the city more sustainable'
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
title: 'other'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
external_design: true,
|
||||||
|
internal_design: true,
|
||||||
|
adaptable: false,
|
||||||
|
other: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
community_local_significance: {
|
||||||
|
perUser: true,
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Do you think this building should be recorded as one of special local significance?",
|
||||||
|
example: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
||||||
location_name: {
|
location_name: {
|
||||||
category: Category.Location,
|
category: Category.Location,
|
||||||
@ -444,6 +520,47 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
|||||||
category: Category.Community,
|
category: Category.Community,
|
||||||
title: "Total number of likes",
|
title: "Total number of likes",
|
||||||
example: 100,
|
example: 100,
|
||||||
|
tooltip: "People who like the building and think it contributes to the city.",
|
||||||
|
aggregationDescriptions: {
|
||||||
|
zero: "like this building",
|
||||||
|
one: "likes this building",
|
||||||
|
many: "like this building"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
community_local_significance_total: {
|
||||||
|
category: Category.Community,
|
||||||
|
title: "People who think the building should be recorded as one of local significance",
|
||||||
|
example: 100,
|
||||||
|
aggregationDescriptions: {
|
||||||
|
zero: "think this building should be classified as a locally listed heritage asset",
|
||||||
|
one: "thinks this building should be classified as a locally listed heritage asset",
|
||||||
|
many: "think this building should be classified as a locally listed heritage asset"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
community_activities: {
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Has the building ever been used for community activities?",
|
||||||
|
tooltip: "E.g. youth club, place of worship, GP surgery, pub",
|
||||||
|
example: true
|
||||||
|
},
|
||||||
|
// community_activities_dates: {
|
||||||
|
// category: Category.Community,
|
||||||
|
// title: "When was this building used for community activities?"
|
||||||
|
// },
|
||||||
|
|
||||||
|
|
||||||
|
community_public_ownership: {
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Is the building in public/community ownership?",
|
||||||
|
example: "Not in public/community ownership"
|
||||||
|
},
|
||||||
|
|
||||||
|
community_public_ownership_sources: {
|
||||||
|
category: Category.Community,
|
||||||
|
title: "Community ownership source link",
|
||||||
|
example: ["https://example.com"]
|
||||||
},
|
},
|
||||||
|
|
||||||
dynamics_has_demolished_buildings: {
|
dynamics_has_demolished_buildings: {
|
||||||
@ -486,3 +603,5 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const allFieldsConfig = {...dataFields, ...buildingUserFields};
|
@ -8,6 +8,8 @@ export type BuildingMapTileset = 'date_year' |
|
|||||||
'construction_core_material' |
|
'construction_core_material' |
|
||||||
'location' |
|
'location' |
|
||||||
'likes' |
|
'likes' |
|
||||||
|
'community_local_significance_total' |
|
||||||
|
'community_in_public_ownership' |
|
||||||
'planning_combined' |
|
'planning_combined' |
|
||||||
'sust_dec' |
|
'sust_dec' |
|
||||||
'building_attachment_form' |
|
'building_attachment_form' |
|
||||||
|
@ -2,14 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
import loadable from '@loadable/component';
|
import loadable from '@loadable/component';
|
||||||
|
|
||||||
import { useRevisionId } from './hooks/use-revision';
|
import { useRevisionId } from './api-data/use-revision';
|
||||||
import { useBuildingData } from './hooks/use-building-data';
|
import { useBuildingData } from './api-data/use-building-data';
|
||||||
import { useBuildingLikeData } from './hooks/use-building-like-data';
|
import { useUserVerifiedData } from './api-data/use-user-verified-data';
|
||||||
import { useUserVerifiedData } from './hooks/use-user-verified-data';
|
|
||||||
import { useUrlBuildingParam } from './nav/use-url-building-param';
|
import { useUrlBuildingParam } from './nav/use-url-building-param';
|
||||||
import { useUrlCategoryParam } from './nav/use-url-category-param';
|
import { useUrlCategoryParam } from './nav/use-url-category-param';
|
||||||
import { useUrlModeParam } from './nav/use-url-mode-param';
|
import { useUrlModeParam } from './nav/use-url-mode-param';
|
||||||
import { apiPost } from './apiHelpers';
|
|
||||||
import BuildingView from './building/building-view';
|
import BuildingView from './building/building-view';
|
||||||
import Categories from './building/categories';
|
import Categories from './building/categories';
|
||||||
import { EditHistory } from './building/edit-history/edit-history';
|
import { EditHistory } from './building/edit-history/edit-history';
|
||||||
@ -22,6 +20,8 @@ import { useLastNotEmpty } from './hooks/use-last-not-empty';
|
|||||||
import { Category } from './config/categories-config';
|
import { Category } from './config/categories-config';
|
||||||
import { defaultMapCategory } from './config/category-maps-config';
|
import { defaultMapCategory } from './config/category-maps-config';
|
||||||
import { useMultiEditData } from './hooks/use-multi-edit-data';
|
import { useMultiEditData } from './hooks/use-multi-edit-data';
|
||||||
|
import { useAuth } from './auth-context';
|
||||||
|
import { sendBuildingUpdate } from './api-data/building-update';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load and render ColouringMap component on client-side only.
|
* Load and render ColouringMap component on client-side only.
|
||||||
@ -35,13 +35,12 @@ import { useMultiEditData } from './hooks/use-multi-edit-data';
|
|||||||
* to all modules that import leaflet or react-leaflet.
|
* to all modules that import leaflet or react-leaflet.
|
||||||
*/
|
*/
|
||||||
const ColouringMap = loadable(
|
const ColouringMap = loadable(
|
||||||
() => import('./map/map'),
|
async () => (await import('./map/map')).ColouringMap,
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
interface MapAppProps {
|
interface MapAppProps {
|
||||||
building?: Building;
|
building?: Building;
|
||||||
building_like?: boolean;
|
|
||||||
revisionId?: string;
|
revisionId?: string;
|
||||||
user_verified?: object;
|
user_verified?: object;
|
||||||
}
|
}
|
||||||
@ -61,6 +60,7 @@ function setOrToggle<T>(currentValue: T, newValue: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MapApp: React.FC<MapAppProps> = props => {
|
export const MapApp: React.FC<MapAppProps> = props => {
|
||||||
|
const { user } = useAuth();
|
||||||
const [categoryUrlParam] = useUrlCategoryParam();
|
const [categoryUrlParam] = useUrlCategoryParam();
|
||||||
|
|
||||||
const [currentCategory, setCategory] = useState<Category>();
|
const [currentCategory, setCategory] = useState<Category>();
|
||||||
@ -70,8 +70,7 @@ export const MapApp: React.FC<MapAppProps> = props => {
|
|||||||
|
|
||||||
const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam('view', displayCategory);
|
const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam('view', displayCategory);
|
||||||
|
|
||||||
const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building);
|
const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building, user != undefined);
|
||||||
const [buildingLike, updateBuildingLike] = useBuildingLikeData(selectedBuildingId, props.building_like);
|
|
||||||
const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified);
|
const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified);
|
||||||
|
|
||||||
const [revisionId, updateRevisionId] = useRevisionId(props.revisionId);
|
const [revisionId, updateRevisionId] = useRevisionId(props.revisionId);
|
||||||
@ -94,20 +93,9 @@ export const MapApp: React.FC<MapAppProps> = props => {
|
|||||||
const buildingId = building?.building_id;
|
const buildingId = building?.building_id;
|
||||||
|
|
||||||
if(buildingId != undefined && multiEditError == undefined) {
|
if(buildingId != undefined && multiEditError == undefined) {
|
||||||
const isLike = currentCategory === Category.Community;
|
|
||||||
const endpoint = isLike ?
|
|
||||||
`/api/buildings/${buildingId}/like.json`:
|
|
||||||
`/api/buildings/${buildingId}.json`;
|
|
||||||
|
|
||||||
const payload = isLike ? {like: true} : multiEditData;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiPost(endpoint, payload);
|
const updatedBuilding = await sendBuildingUpdate(buildingId, multiEditData);
|
||||||
if(res.error) {
|
updateRevisionId(updatedBuilding.revision_id);
|
||||||
console.error({ error: res.error });
|
|
||||||
} else {
|
|
||||||
updateRevisionId(res.revision_id);
|
|
||||||
}
|
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
console.error({ error });
|
console.error({ error });
|
||||||
}
|
}
|
||||||
@ -124,13 +112,6 @@ export const MapApp: React.FC<MapAppProps> = props => {
|
|||||||
}
|
}
|
||||||
}, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
|
}, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
|
||||||
|
|
||||||
const handleBuildingLikeUpdate = useCallback((buildingId: number, updatedData: boolean) => {
|
|
||||||
// only update current building data if the IDs match
|
|
||||||
if(buildingId === selectedBuildingId) {
|
|
||||||
updateBuildingLike(updatedData);
|
|
||||||
}
|
|
||||||
}, [selectedBuildingId, updateBuildingLike]);
|
|
||||||
|
|
||||||
const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => {
|
const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => {
|
||||||
// only update current building data if the IDs match
|
// only update current building data if the IDs match
|
||||||
if(buildingId === selectedBuildingId) {
|
if(buildingId === selectedBuildingId) {
|
||||||
@ -162,10 +143,8 @@ export const MapApp: React.FC<MapAppProps> = props => {
|
|||||||
mode={viewEditMode}
|
mode={viewEditMode}
|
||||||
cat={displayCategory}
|
cat={displayCategory}
|
||||||
building={building}
|
building={building}
|
||||||
building_like={buildingLike}
|
|
||||||
user_verified={userVerified ?? {}}
|
user_verified={userVerified ?? {}}
|
||||||
onBuildingUpdate={handleBuildingUpdate}
|
onBuildingUpdate={handleBuildingUpdate}
|
||||||
onBuildingLikeUpdate={handleBuildingLikeUpdate}
|
|
||||||
onUserVerifiedUpdate={handleUserVerifiedUpdate}
|
onUserVerifiedUpdate={handleUserVerifiedUpdate}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -56,6 +56,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-legend .style-select {
|
||||||
|
background-color: inherit;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
margin: 0.25rem 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
font-size: 18px;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.map-legend .h4,
|
.map-legend .h4,
|
||||||
.map-legend p,
|
.map-legend p,
|
||||||
.data-legend {
|
.data-legend {
|
||||||
|
@ -1,116 +1,115 @@
|
|||||||
import React from 'react';
|
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import './legend.css';
|
import './legend.css';
|
||||||
|
|
||||||
import { DownIcon, UpIcon } from '../components/icons';
|
import { DownIcon, UpIcon } from '../components/icons';
|
||||||
import { Logo } from '../components/logo';
|
import { Logo } from '../components/logo';
|
||||||
import { LegendConfig } from '../config/category-maps-config';
|
import { CategoryMapDefinition, LegendConfig } from '../config/category-maps-config';
|
||||||
|
import { BuildingMapTileset } from '../config/tileserver-config';
|
||||||
|
|
||||||
interface LegendProps {
|
interface LegendProps {
|
||||||
legendConfig: LegendConfig;
|
mapColourScaleDefinitions: CategoryMapDefinition[];
|
||||||
|
mapColourScale: BuildingMapTileset;
|
||||||
|
onMapColourScale: (x: BuildingMapTileset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegendState {
|
export const Legend : FC<LegendProps> = ({
|
||||||
collapseList: boolean;
|
mapColourScaleDefinitions,
|
||||||
}
|
mapColourScale,
|
||||||
|
onMapColourScale
|
||||||
|
}) => {
|
||||||
|
const [collapseList, setCollapseList] = useState(false);
|
||||||
|
|
||||||
class Legend extends React.Component<LegendProps, LegendState> {
|
const handleToggle = useCallback(() => {
|
||||||
constructor(props) {
|
setCollapseList(!collapseList);
|
||||||
super(props);
|
}, [collapseList]);
|
||||||
this.state = {collapseList: false};
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
this.onResize= this.onResize.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const onResize = useCallback(({target}) => {
|
||||||
|
setCollapseList((target.outerHeight < 670 || target.outerWidth < 768))
|
||||||
|
}, []);
|
||||||
|
|
||||||
handleClick() {
|
useEffect(() => {
|
||||||
this.setState(state => ({
|
window.addEventListener('resize', onResize);
|
||||||
collapseList: !state.collapseList
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if(window?.outerHeight) {
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
window.addEventListener('resize', this.onResize);
|
|
||||||
if (window && window.outerHeight) {
|
|
||||||
// if we're in the browser, pass in as though from event to initialise
|
// if we're in the browser, pass in as though from event to initialise
|
||||||
this.onResize({target: window});
|
onResize({target: window});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
}
|
||||||
|
}, [onResize]);
|
||||||
|
|
||||||
|
const legendConfig = mapColourScaleDefinitions.find(def => def.mapStyle === mapColourScale)?.legend;
|
||||||
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
const {
|
||||||
window.removeEventListener('resize', this.onResize);
|
title = undefined,
|
||||||
}
|
elements = [],
|
||||||
|
description = undefined,
|
||||||
|
disclaimer = undefined
|
||||||
|
} = legendConfig ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
onResize(e) {
|
<div className="map-legend">
|
||||||
this.setState({collapseList: (e.target.outerHeight < 670 || e.target.outerWidth < 768)}); // magic number needs to be consistent with CSS expander-button media query
|
<Logo variant="default" />
|
||||||
}
|
{
|
||||||
|
mapColourScaleDefinitions.length > 1 ?
|
||||||
render() {
|
<select className='style-select' onChange={e => onMapColourScale(e.target.value as BuildingMapTileset)}>
|
||||||
const {
|
{
|
||||||
title = undefined,
|
mapColourScaleDefinitions.map(def =>
|
||||||
elements = [],
|
<option key={def.mapStyle} value={def.mapStyle}>{def.legend.title}</option>
|
||||||
description = undefined,
|
)
|
||||||
disclaimer = undefined
|
}
|
||||||
} = this.props.legendConfig ?? {};
|
</select> :
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="map-legend">
|
|
||||||
<Logo variant="default" />
|
|
||||||
{
|
|
||||||
title && <h4 className="h4">{title}</h4>
|
title && <h4 className="h4">{title}</h4>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
elements.length > 0 &&
|
elements.length > 0 &&
|
||||||
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
|
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={handleToggle} >
|
||||||
{
|
{
|
||||||
this.state.collapseList ?
|
collapseList ?
|
||||||
<UpIcon /> :
|
<UpIcon /> :
|
||||||
<DownIcon />
|
<DownIcon />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
description && <p>{description}</p>
|
description && <p>{description}</p>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
elements.length === 0 ?
|
elements.length === 0 ?
|
||||||
<p className="data-intro">Coming soon…</p> :
|
<p className="data-intro">Coming soon…</p> :
|
||||||
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
|
<ul className={collapseList ? 'collapse data-legend' : 'data-legend'} >
|
||||||
{
|
{
|
||||||
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
|
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
elements.map((item) => {
|
elements.map((item) => {
|
||||||
let key: string,
|
let key: string,
|
||||||
content: React.ReactElement;
|
content: React.ReactElement;
|
||||||
|
|
||||||
if('subtitle' in item) {
|
if('subtitle' in item) {
|
||||||
key = item.subtitle;
|
key = item.subtitle;
|
||||||
content = <h6>{item.subtitle}</h6>;
|
content = <h6>{item.subtitle}</h6>;
|
||||||
} else {
|
} else {
|
||||||
key = `${item.text}-${item.color}`;
|
key = `${item.text}-${item.color}`;
|
||||||
content = <>
|
content = <>
|
||||||
<div className="key" style={ { background: item.color, border: item.border } } />
|
<div className="key" style={ { background: item.color, border: item.border } } />
|
||||||
{ item.text }
|
{ item.text }
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
{content}
|
{content}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Legend;
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { Component, Fragment, useEffect } from 'react';
|
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AttributionControl, MapContainer, ZoomControl, useMapEvent, Pane, useMap } from 'react-leaflet';
|
import { AttributionControl, MapContainer, ZoomControl, useMapEvent, Pane, useMap } from 'react-leaflet';
|
||||||
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
@ -18,9 +18,10 @@ import { BuildingDataLayer } from './layers/building-data-layer';
|
|||||||
import { BuildingNumbersLayer } from './layers/building-numbers-layer';
|
import { BuildingNumbersLayer } from './layers/building-numbers-layer';
|
||||||
import { BuildingHighlightLayer } from './layers/building-highlight-layer';
|
import { BuildingHighlightLayer } from './layers/building-highlight-layer';
|
||||||
|
|
||||||
import Legend from './legend';
|
import { Legend } from './legend';
|
||||||
import SearchBox from './search-box';
|
import SearchBox from './search-box';
|
||||||
import ThemeSwitcher from './theme-switcher';
|
import ThemeSwitcher from './theme-switcher';
|
||||||
|
import { BuildingMapTileset } from '../config/tileserver-config';
|
||||||
|
|
||||||
interface ColouringMapProps {
|
interface ColouringMapProps {
|
||||||
selectedBuildingId: number;
|
selectedBuildingId: number;
|
||||||
@ -30,124 +31,125 @@ interface ColouringMapProps {
|
|||||||
onBuildingAction: (building: Building) => void;
|
onBuildingAction: (building: Building) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ColouringMapState {
|
export const ColouringMap : FC<ColouringMapProps> = ({
|
||||||
theme: MapTheme;
|
category,
|
||||||
position: [number, number];
|
mode,
|
||||||
zoom: number;
|
revisionId,
|
||||||
}
|
onBuildingAction,
|
||||||
|
selectedBuildingId,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
|
||||||
/**
|
const [theme, setTheme] = useState<MapTheme>('light');
|
||||||
* Map area
|
const [position, setPosition] = useState(initialMapViewport.position);
|
||||||
*/
|
const [zoom, setZoom] = useState(initialMapViewport.zoom);
|
||||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
theme: 'light',
|
|
||||||
...initialMapViewport
|
|
||||||
};
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
this.handleLocate = this.handleLocate.bind(this);
|
|
||||||
this.themeSwitch = this.themeSwitch.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLocate(lat: number, lng: number, zoom: number){
|
const [mapColourScale, setMapColourScale] = useState<BuildingMapTileset>();
|
||||||
this.setState({
|
|
||||||
position: [lat, lng],
|
|
||||||
zoom: zoom
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e) {
|
const handleLocate = useCallback(
|
||||||
const { lat, lng } = e.latlng;
|
(lat: number, lng: number, zoom: number) => {
|
||||||
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
|
setPosition([lat, lng]);
|
||||||
.then(data => {
|
setZoom(zoom);
|
||||||
const building = data?.[0];
|
},
|
||||||
this.props.onBuildingAction(building);
|
[]
|
||||||
}).catch(err => console.error(err));
|
);
|
||||||
}
|
|
||||||
|
|
||||||
themeSwitch(e) {
|
const handleClick = useCallback(
|
||||||
e.preventDefault();
|
async (e) => {
|
||||||
const newTheme = (this.state.theme === 'light')? 'night' : 'light';
|
const {lat, lng} = e.latlng;
|
||||||
this.setState({theme: newTheme});
|
const data = await apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`);
|
||||||
}
|
const building = data?.[0];
|
||||||
|
onBuildingAction(building);
|
||||||
|
},
|
||||||
|
[onBuildingAction],
|
||||||
|
)
|
||||||
|
|
||||||
render() {
|
const themeSwitch = useCallback(
|
||||||
const categoryMapDefinition = categoryMapsConfig[this.props.category];
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newTheme = (theme === 'light')? 'night' : 'light';
|
||||||
|
setTheme(newTheme);
|
||||||
|
},
|
||||||
|
[theme],
|
||||||
|
)
|
||||||
|
|
||||||
const tileset = categoryMapDefinition.mapStyle;
|
const categoryMapDefinitions = useMemo(() => categoryMapsConfig[category], [category]);
|
||||||
|
|
||||||
const hasSelection = this.props.selectedBuildingId != undefined;
|
useEffect(() => {
|
||||||
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
|
if(!categoryMapDefinitions.some(def => def.mapStyle === mapColourScale)) {
|
||||||
|
setMapColourScale(categoryMapDefinitions[0].mapStyle);
|
||||||
|
}
|
||||||
|
}, [categoryMapDefinitions, mapColourScale]);
|
||||||
|
|
||||||
return (
|
const hasSelection = selectedBuildingId != undefined;
|
||||||
<div className="map-container">
|
const isEdit = ['edit', 'multi-edit'].includes(mode);
|
||||||
<MapContainer
|
|
||||||
center={initialMapViewport.position}
|
return (
|
||||||
zoom={initialMapViewport.zoom}
|
<div className="map-container">
|
||||||
minZoom={9}
|
<MapContainer
|
||||||
maxZoom={18}
|
center={initialMapViewport.position}
|
||||||
doubleClickZoom={false}
|
zoom={initialMapViewport.zoom}
|
||||||
zoomControl={false}
|
minZoom={9}
|
||||||
attributionControl={false}
|
maxZoom={18}
|
||||||
|
doubleClickZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
attributionControl={false}
|
||||||
|
>
|
||||||
|
<ClickHandler onClick={handleClick} />
|
||||||
|
<MapBackgroundColor theme={theme} />
|
||||||
|
<MapViewport position={position} zoom={zoom} />
|
||||||
|
|
||||||
|
<Pane
|
||||||
|
key={theme}
|
||||||
|
name={'cc-base-pane'}
|
||||||
|
style={{zIndex: 50}}
|
||||||
>
|
>
|
||||||
<ClickHandler onClick={this.handleClick} />
|
<CityBaseMapLayer theme={theme} />
|
||||||
<MapBackgroundColor theme={this.state.theme} />
|
<BuildingBaseLayer theme={theme} />
|
||||||
<MapViewport position={this.state.position} zoom={this.state.zoom} />
|
</Pane>
|
||||||
|
|
||||||
<Pane
|
{
|
||||||
key={this.state.theme}
|
mapColourScale &&
|
||||||
name={'cc-base-pane'}
|
<BuildingDataLayer
|
||||||
style={{zIndex: 50}}
|
tileset={mapColourScale}
|
||||||
>
|
revisionId={revisionId}
|
||||||
<CityBaseMapLayer theme={this.state.theme} />
|
/>
|
||||||
<BuildingBaseLayer theme={this.state.theme} />
|
}
|
||||||
</Pane>
|
|
||||||
|
|
||||||
|
<Pane
|
||||||
|
name='cc-overlay-pane'
|
||||||
|
style={{zIndex: 300}}
|
||||||
|
>
|
||||||
|
<CityBoundaryLayer />
|
||||||
|
<BuildingNumbersLayer revisionId={revisionId} />
|
||||||
{
|
{
|
||||||
tileset &&
|
selectedBuildingId &&
|
||||||
<BuildingDataLayer
|
<BuildingHighlightLayer
|
||||||
tileset={tileset}
|
selectedBuildingId={selectedBuildingId}
|
||||||
revisionId={this.props.revisionId}
|
baseTileset={mapColourScale}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
</Pane>
|
||||||
|
|
||||||
<Pane
|
<ZoomControl position="topright" />
|
||||||
name='cc-overlay-pane'
|
<AttributionControl prefix=""/>
|
||||||
style={{zIndex: 300}}
|
</MapContainer>
|
||||||
>
|
{
|
||||||
<CityBoundaryLayer />
|
mode !== 'basic' &&
|
||||||
<BuildingNumbersLayer revisionId={this.props.revisionId} />
|
<>
|
||||||
{
|
{
|
||||||
this.props.selectedBuildingId &&
|
!hasSelection &&
|
||||||
<BuildingHighlightLayer
|
<div className="map-notice">
|
||||||
selectedBuildingId={this.props.selectedBuildingId}
|
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
|
||||||
baseTileset={tileset}
|
</div>
|
||||||
/>
|
}
|
||||||
}
|
<Legend mapColourScaleDefinitions={categoryMapDefinitions} mapColourScale={mapColourScale} onMapColourScale={setMapColourScale}/>
|
||||||
</Pane>
|
{/* <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={theme} /> */}
|
||||||
|
<SearchBox onLocate={handleLocate} />
|
||||||
<ZoomControl position="topright" />
|
</>
|
||||||
<AttributionControl prefix=""/>
|
}
|
||||||
</MapContainer>
|
</div>
|
||||||
{
|
);
|
||||||
this.props.mode !== 'basic' &&
|
|
||||||
<Fragment>
|
|
||||||
{
|
|
||||||
!hasSelection &&
|
|
||||||
<div className="map-notice">
|
|
||||||
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<Legend legendConfig={categoryMapDefinition?.legend} />
|
|
||||||
{/* <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} /> */}
|
|
||||||
<SearchBox onLocate={this.handleLocate} />
|
|
||||||
</Fragment>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClickHandler({ onClick }: {onClick: (e) => void}) {
|
function ClickHandler({ onClick }: {onClick: (e) => void}) {
|
||||||
@ -180,5 +182,3 @@ function MapViewport({
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ColouringMap;
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { dataFields } from '../config/data-fields-config';
|
import { buildingUserFields, dataFields } from '../config/data-fields-config';
|
||||||
|
|
||||||
|
type AttributesBasedOnExample<T extends Record<string, {example: any}>> = {[key in keyof T]: T[key]['example']};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type representing the types of a building's attributes.
|
* A type representing the types of a building's attributes.
|
||||||
@ -6,16 +8,19 @@ import { dataFields } from '../config/data-fields-config';
|
|||||||
* If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here,
|
* If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here,
|
||||||
* that means an example field is most probably missing on one of the config definitions in dataFieldsConfig.
|
* that means an example field is most probably missing on one of the config definitions in dataFieldsConfig.
|
||||||
*/
|
*/
|
||||||
export type BuildingAttributes = {[key in keyof typeof dataFields]: (typeof dataFields)[key]['example']};
|
export type BuildingAttributes = AttributesBasedOnExample<typeof dataFields>;
|
||||||
|
export type BuildingUserAttributes = AttributesBasedOnExample<typeof buildingUserFields>;
|
||||||
|
|
||||||
export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number};
|
export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number};
|
||||||
|
|
||||||
export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]};
|
export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]};
|
||||||
|
|
||||||
export interface Building extends BuildingAttributes {
|
export interface Building extends BuildingAttributes, BuildingUserAttributes {
|
||||||
building_id: number;
|
building_id: number;
|
||||||
geometry_id: number;
|
geometry_id: number;
|
||||||
revision_id: string;
|
revision_id: string;
|
||||||
|
|
||||||
verified: BuildingAttributeVerificationCounts;
|
verified: BuildingAttributeVerificationCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BuildingEdits = Partial<BuildingAttributes & BuildingUserAttributes>;
|
@ -6,7 +6,6 @@ import serialize from 'serialize-javascript';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getBuildingById,
|
getBuildingById,
|
||||||
getBuildingLikeById,
|
|
||||||
getBuildingUPRNsById,
|
getBuildingUPRNsById,
|
||||||
getLatestRevisionId,
|
getLatestRevisionId,
|
||||||
getUserVerifiedAttributes
|
getUserVerifiedAttributes
|
||||||
@ -34,11 +33,13 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let [user, building, uprns, buildingLike, userVerified, latestRevisionId] = await Promise.all([
|
let [user, building, uprns, userVerified, latestRevisionId] = await Promise.all([
|
||||||
userId ? getUserById(userId) : undefined,
|
userId ? getUserById(userId) : undefined,
|
||||||
isBuilding ? getBuildingById(buildingId) : undefined,
|
isBuilding ? getBuildingById(
|
||||||
|
buildingId,
|
||||||
|
{ userDataOptions: userId ? { userId, userAttributes: true } : null }
|
||||||
|
) : undefined,
|
||||||
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
|
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
|
||||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
|
|
||||||
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
|
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
|
||||||
getLatestRevisionId()
|
getLatestRevisionId()
|
||||||
]);
|
]);
|
||||||
@ -48,7 +49,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
|
|||||||
}
|
}
|
||||||
data.user = user;
|
data.user = user;
|
||||||
data.building = building;
|
data.building = building;
|
||||||
data.building_like = buildingLike;
|
|
||||||
data.user_verified = userVerified;
|
data.user_verified = userVerified;
|
||||||
if (data.building != null) {
|
if (data.building != null) {
|
||||||
data.building.uprns = uprns;
|
data.building.uprns = uprns;
|
||||||
@ -59,7 +59,6 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
data.user = undefined;
|
data.user = undefined;
|
||||||
data.building = undefined;
|
data.building = undefined;
|
||||||
data.building_like = undefined;
|
|
||||||
data.user_verified = {}
|
data.user_verified = {}
|
||||||
data.latestRevisionId = 0;
|
data.latestRevisionId = 0;
|
||||||
context.status = 500;
|
context.status = 500;
|
||||||
@ -73,7 +72,6 @@ function renderHTML(context, data, req, res) {
|
|||||||
<App
|
<App
|
||||||
user={data.user}
|
user={data.user}
|
||||||
building={data.building}
|
building={data.building}
|
||||||
building_like={data.building_like}
|
|
||||||
revisionId={data.latestRevisionId}
|
revisionId={data.latestRevisionId}
|
||||||
user_verified={data.userVerified}
|
user_verified={data.userVerified}
|
||||||
/>
|
/>
|
||||||
|
@ -84,6 +84,27 @@ const LAYER_QUERIES = {
|
|||||||
buildings
|
buildings
|
||||||
WHERE
|
WHERE
|
||||||
likes_total > 0`,
|
likes_total > 0`,
|
||||||
|
community_local_significance_total: `
|
||||||
|
SELECT
|
||||||
|
geometry_id,
|
||||||
|
community_local_significance_total
|
||||||
|
FROM
|
||||||
|
buildings
|
||||||
|
WHERE
|
||||||
|
community_local_significance_total > 0
|
||||||
|
`,
|
||||||
|
community_in_public_ownership: `
|
||||||
|
SELECT
|
||||||
|
geometry_id,
|
||||||
|
CASE
|
||||||
|
WHEN community_public_ownership = 'Not in public/community ownership' THEN false
|
||||||
|
ELSE true
|
||||||
|
END AS in_public_ownership
|
||||||
|
FROM
|
||||||
|
buildings
|
||||||
|
WHERE
|
||||||
|
community_public_ownership IS NOT NULL
|
||||||
|
`,
|
||||||
planning_combined: `
|
planning_combined: `
|
||||||
SELECT
|
SELECT
|
||||||
geometry_id,
|
geometry_id,
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
-- Remove community fields
|
|
||||||
|
|
||||||
-- Ownership type, enumerate type from:
|
|
||||||
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type;
|
|
||||||
|
|
||||||
-- Ownerhsip perception, would you describe this as a community asset?
|
|
||||||
-- Boolean yes / no
|
|
||||||
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception;
|
|
||||||
|
|
||||||
-- Historic ownership type / perception
|
|
||||||
-- Has this building ever been used for community or public services activities?
|
|
||||||
-- Boolean yes / no
|
|
||||||
ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic;
|
|
@ -1,28 +0,0 @@
|
|||||||
-- Remove community fields
|
|
||||||
|
|
||||||
-- Ownership type, enumerate type from:
|
|
||||||
--
|
|
||||||
|
|
||||||
CREATE TYPE ownership_type
|
|
||||||
AS ENUM ('Private individual',
|
|
||||||
'Private company',
|
|
||||||
'Private offshore ownership',
|
|
||||||
'Publicly owned',
|
|
||||||
'Institutionally owned');
|
|
||||||
|
|
||||||
ALTER TABLE buildings
|
|
||||||
ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual';
|
|
||||||
|
|
||||||
-- Ownerhsip perception, would you describe this as a community asset?
|
|
||||||
-- Boolean yes / no
|
|
||||||
-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which
|
|
||||||
|
|
||||||
ALTER TABLE buildings
|
|
||||||
ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null;
|
|
||||||
|
|
||||||
-- Historic ownership type / perception
|
|
||||||
-- Has this building ever been used for community or public services activities?
|
|
||||||
-- Boolean yes / no
|
|
||||||
|
|
||||||
ALTER TABLE buildings
|
|
||||||
ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null;
|
|
1
migrations/021.building-user-attributes.down.sql
Normal file
1
migrations/021.building-user-attributes.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS building_user_attributes;
|
15
migrations/021.building-user-attributes.up.sql
Normal file
15
migrations/021.building-user-attributes.up.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE public.building_user_attributes (
|
||||||
|
building_id INTEGER REFERENCES buildings,
|
||||||
|
user_id UUID REFERENCES users,
|
||||||
|
PRIMARY KEY (building_id, user_id),
|
||||||
|
|
||||||
|
community_like BOOLEAN NOT NULL DEFAULT(FALSE)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_attrib_building_id_idx ON building_user_attributes (building_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS user_attrib_building_id_user_id_idx ON building_user_attributes (building_id, user_id);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO building_user_attributes (building_id, user_id, community_like)
|
||||||
|
select building_id, user_id, TRUE as community_like
|
||||||
|
from building_user_likes;
|
37
migrations/022.community.down.sql
Normal file
37
migrations/022.community.down.sql
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
-- -- Remove community fields
|
||||||
|
|
||||||
|
-- -- Ownership type, enumerate type from:
|
||||||
|
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_type;
|
||||||
|
|
||||||
|
-- -- Ownerhsip perception, would you describe this as a community asset?
|
||||||
|
-- -- Boolean yes / no
|
||||||
|
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_perception;
|
||||||
|
|
||||||
|
-- -- Historic ownership type / perception
|
||||||
|
-- -- Has this building ever been used for community or public services activities?
|
||||||
|
-- -- Boolean yes / no
|
||||||
|
-- ALTER TABLE buildings DROP COLUMN IF EXISTS ownership_historic;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
DROP COLUMN IF EXISTS community_type_worth_keeping;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
DROP COLUMN IF EXISTS community_type_worth_keeping_reasons;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
DROP COLUMN IF EXISTS community_local_significance;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
DROP COLUMN IF EXISTS community_local_significance_total;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
DROP COLUMN IF EXISTS community_activities;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
DROP COLUMN IF EXISTS community_public_ownership;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS public_ownership_type;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
DROP COLUMN IF EXISTS community_public_ownership_sources;
|
||||||
|
|
58
migrations/022.community.up.sql
Normal file
58
migrations/022.community.up.sql
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
-- Remove community fields
|
||||||
|
|
||||||
|
-- Ownership type, enumerate type from:
|
||||||
|
--
|
||||||
|
|
||||||
|
-- CREATE TYPE ownership_type
|
||||||
|
-- AS ENUM ('Private individual',
|
||||||
|
-- 'Private company',
|
||||||
|
-- 'Private offshore ownership',
|
||||||
|
-- 'Publicly owned',
|
||||||
|
-- 'Institutionally owned');
|
||||||
|
|
||||||
|
-- ALTER TABLE buildings
|
||||||
|
-- ADD COLUMN IF NOT EXISTS ownership_type ownership_type DEFAULT 'Private individual';
|
||||||
|
|
||||||
|
-- Ownerhsip perception, would you describe this as a community asset?
|
||||||
|
-- Boolean yes / no
|
||||||
|
-- Below accepts t/f, yes/no, y/n, 0/1 as valid inputs all of which
|
||||||
|
|
||||||
|
-- ALTER TABLE buildings
|
||||||
|
-- ADD COLUMN IF NOT EXISTS ownership_perception boolean DEFAULT null;
|
||||||
|
|
||||||
|
-- Historic ownership type / perception
|
||||||
|
-- Has this building ever been used for community or public services activities?
|
||||||
|
-- Boolean yes / no
|
||||||
|
|
||||||
|
-- ALTER TABLE buildings
|
||||||
|
-- ADD COLUMN IF NOT EXISTS ownership_historic boolean DEFAULT null;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
ADD COLUMN community_type_worth_keeping BOOLEAN NULL;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
ADD COLUMN community_type_worth_keeping_reasons JSONB DEFAULT '{}'::JSONB;
|
||||||
|
|
||||||
|
ALTER TABLE building_user_attributes
|
||||||
|
ADD COLUMN community_local_significance BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
ADD COLUMN community_local_significance_total INT DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
ADD COLUMN community_activities BOOLEAN NULL;
|
||||||
|
|
||||||
|
CREATE TYPE public_ownership_type
|
||||||
|
AS ENUM (
|
||||||
|
'State-owned',
|
||||||
|
'Charity-owned',
|
||||||
|
'Community-owned/cooperative',
|
||||||
|
'Owned by other non-profit body',
|
||||||
|
'Not in public/community ownership'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
ADD COLUMN community_public_ownership public_ownership_type;
|
||||||
|
|
||||||
|
ALTER TABLE buildings
|
||||||
|
ADD COLUMN community_public_ownership_sources VARCHAR[];
|
Loading…
Reference in New Issue
Block a user