Merge pull request #625 from colouring-london/feature/verification

Feature/verification
This commit is contained in:
Tom Russell 2020-08-07 14:53:17 +01:00 committed by GitHub
commit 488a5da1bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2209 additions and 4277 deletions

5538
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,57 +13,58 @@
"start:prod": "NODE_ENV=production node build/server.js" "start:prod": "NODE_ENV=production node build/server.js"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.28", "@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-solid-svg-icons": "^5.13.0", "@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.9", "@fortawesome/react-fontawesome": "^0.1.11",
"@mapbox/sphericalmercator": "^1.1.0", "@mapbox/sphericalmercator": "^1.1.0",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bootstrap": "^4.4.1", "bootstrap": "^4.5.0",
"canvas-confetti": "^1.2.0",
"connect-pg-simple": "^6.1.0", "connect-pg-simple": "^6.1.0",
"express": "^4.17.1", "express": "^4.17.1",
"express-session": "^1.17.0", "express-session": "^1.17.1",
"leaflet": "^1.6.0", "leaflet": "^1.6.0",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"mapnik": "^4.4.0", "mapnik": "^4.4.0",
"node-fs": "^0.1.7", "node-fs": "^0.1.7",
"nodemailer": "^6.4.6", "nodemailer": "^6.4.11",
"pg-promise": "^8.7.5", "pg-promise": "^8.7.5",
"query-string": "^6.12.0", "query-string": "^6.13.1",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-leaflet": "^1.0.1", "react-leaflet": "^1.0.1",
"react-leaflet-universal": "^1.2.0", "react-leaflet-universal": "^2.2.1",
"react-router-dom": "^5.0.1", "react-router-dom": "^5.2.0",
"serialize-javascript": "^2.1.1", "serialize-javascript": "^2.1.1",
"sharp": "^0.22.1", "sharp": "^0.22.1",
"use-throttle": "0.0.3" "use-throttle": "0.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.5", "@types/express": "^4.17.7",
"@types/express-session": "^1.17.0", "@types/express-session": "^1.17.0",
"@types/jest": "^24.9.1", "@types/jest": "^24.9.1",
"@types/lodash": "^4.14.149", "@types/lodash": "^4.14.158",
"@types/lodash.isequal": "^4.5.5", "@types/lodash.isequal": "^4.5.5",
"@types/mapbox__sphericalmercator": "^1.1.3", "@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^12.12.35", "@types/node": "^12.12.53",
"@types/nodemailer": "^6.4.0", "@types/nodemailer": "^6.4.0",
"@types/react": "^16.9.33", "@types/react": "^16.9.44",
"@types/react-dom": "^16.9.6", "@types/react-dom": "^16.9.8",
"@types/react-leaflet": "^2.5.1", "@types/react-leaflet": "^2.5.2",
"@types/react-router-dom": "^4.3.5", "@types/react-router-dom": "^4.3.5",
"@types/sharp": "^0.22.3", "@types/sharp": "^0.22.3",
"@types/webpack-env": "^1.15.1", "@types/webpack-env": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^2.27.0", "@typescript-eslint/eslint-plugin": "^2.34.0",
"@typescript-eslint/parser": "^2.27.0", "@typescript-eslint/parser": "^2.34.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-plugin-jest": "^22.21.0", "eslint-plugin-jest": "^22.21.0",
"eslint-plugin-react": "^7.19.0", "eslint-plugin-react": "^7.20.5",
"razzle": "^3.1.3", "razzle": "^3.1.6",
"razzle-plugin-typescript": "^3.0.0", "razzle-plugin-typescript": "^3.1.6",
"ts-jest": "^24.3.0", "ts-jest": "^24.3.0",
"typescript": "^3.8.3" "typescript": "^3.9.7"
}, },
"jest": { "jest": {
"transform": { "transform": {

View File

@ -35,7 +35,7 @@ const getBuildingsByReference = asyncController(async (req: express.Request, res
} }
}); });
// GET individual building, POST building updates // GET individual building
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);
@ -48,6 +48,7 @@ const getBuildingById = asyncController(async (req: express.Request, res: expres
} }
}); });
// 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 user_id;
@ -105,7 +106,7 @@ const getBuildingUPRNsById = asyncController(async (req: express.Request, res: e
} }
}); });
// GET/POST like building // GET whether the user likes a building
const getBuildingLikeById = 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({ like: false }); // not logged in, so cannot have liked return res.send({ like: false }); // not logged in, so cannot have liked
@ -123,6 +124,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
} }
}); });
// GET building edit history
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => { const getBuildingEditHistoryById = 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);
@ -135,6 +137,7 @@ const getBuildingEditHistoryById = asyncController(async (req: express.Request,
} }
}); });
// POST update to like/unlike building
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => { const updateBuildingLikeById = 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({ error: 'Must be logged in' });
@ -159,6 +162,48 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
res.send(updatedBuilding); res.send(updatedBuilding);
}); });
// GET building attributes (and values) as verified by user
const getUserVerifiedAttributes = asyncController(async (req: express.Request, res: express.Response) => {
if (!req.session.user_id) {
return res.send({error: "Not logged in"}); // not logged in, so send empty object as no attributes verified
}
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const verifiedAttributes = await buildingService.getUserVerifiedAttributes(buildingId, req.session.user_id);
res.send(verifiedAttributes);
} catch (error) {
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
});
// POST update to verify building attribute
const verifyBuildingAttributes = 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 verifiedAttributes = req.body;
try {
const success = await buildingService.verifyBuildingAttributes(buildingId, req.session.user_id, verifiedAttributes);
res.send(success);
} catch (error) {
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
});
// GET latest revision id of any building
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => { const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
try { try {
const revisionId = await buildingService.getLatestRevisionId(); const revisionId = await buildingService.getLatestRevisionId();
@ -176,6 +221,8 @@ export default {
getBuildingUPRNsById, getBuildingUPRNsById,
getBuildingLikeById, getBuildingLikeById,
updateBuildingLikeById, updateBuildingLikeById,
getUserVerifiedAttributes,
verifyBuildingAttributes,
getBuildingEditHistoryById, getBuildingEditHistoryById,
getLatestRevisionId getLatestRevisionId
}; };

View File

@ -0,0 +1,103 @@
import db from '../../db';
import { DatabaseError, InvalidOperationError } from '../errors/general';
export async function getBuildingVerifiedAttributes(buildingId: number): Promise<any[]> {
try {
return await (db).manyOrNone(
`SELECT
attribute,
verified_value
FROM
building_verification
WHERE
building_id = $1;
`,
[buildingId]
);
} catch(error) {
throw new DatabaseError(error.detail);
}
}
export async function getBuildingUserVerifiedAttributes(buildingId: number, userId: string): Promise<any> {
try {
const verifications = await (db).manyOrNone(
`SELECT
attribute,
verified_value,
date_trunc('minute', verification_timestamp) as verification_timestamp
FROM
building_verification
WHERE
building_id = $1
AND user_id = $2;
`,
[buildingId, userId]
);
return asVerified(verifications)
} catch(error) {
throw new DatabaseError(error.detail);
}
}
function asVerified(verifications){
const user_verified = {};
for (const item of verifications) {
user_verified[item.attribute] = item.verified_value
}
return user_verified;
}
export async function updateBuildingUserVerifiedAttribute(buildingId: number, userId: string, attribute: string, value: any): Promise<void> {
console.log(typeof value, value)
try {
if (typeof value === 'string'){
// cast strings to text explicitly - otherwise Postgres fails to cast to jsonb directly
await (db).none(
`INSERT INTO
building_verification
( building_id, user_id, attribute, verified_value )
VALUES
($1, $2, $3, to_jsonb($4::text));
`,
[buildingId, userId, attribute, value]
);
} else {
await (db).none(
`INSERT INTO
building_verification
( building_id, user_id, attribute, verified_value )
VALUES
($1, $2, $3, to_jsonb($4));
`,
[buildingId, userId, attribute, value]
);
}
} catch(error) {
console.error(error)
if(error.detail?.includes('already exists')) {
const msg = 'User already verified that attribute for this building'
throw new InvalidOperationError(msg);
} else {
throw new DatabaseError(error.detail);
}
}
}
export async function removeBuildingUserVerifiedAttribute(buildingId: number, userId: string, attribute: string) : Promise<null> {
try {
return await (db).none(
`DELETE FROM
building_verification
WHERE
building_id = $1
AND user_id = $2
AND attribute = $3;
`,
[buildingId, userId, attribute]
);
} catch(error) {
throw new DatabaseError(error.detail);
}
}

View File

@ -31,6 +31,11 @@ router.route('/:building_id/like.json')
.get(buildingController.getBuildingLikeById) .get(buildingController.getBuildingLikeById)
.post(buildingController.updateBuildingLikeById); .post(buildingController.updateBuildingLikeById);
// POST verify building attribute
router.route('/:building_id/verify.json')
.get(buildingController.getUserVerifiedAttributes)
.post(buildingController.verifyBuildingAttributes);
router.route('/:building_id/history.json') router.route('/:building_id/history.json')
.get(buildingController.getBuildingEditHistoryById); .get(buildingController.getBuildingEditHistoryById);

View File

@ -10,7 +10,8 @@ import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types'; import { BoundingBox } from '../../tiles/types';
import * as buildingDataAccess from '../dataAccess/building'; import * as buildingDataAccess from '../dataAccess/building';
import * as likeDataAccess from '../dataAccess/like'; import * as likeDataAccess from '../dataAccess/like';
import { UserError } from '../errors/general'; import * as verifyDataAccess from '../dataAccess/verify';
import { UserError, DatabaseError } from '../errors/general';
import { processBuildingUpdate } from './domainLogic/processBuildingUpdate'; import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
@ -110,6 +111,7 @@ async function getBuildingById(id: number) {
const building = await getCurrentBuildingDataById(id); const building = await getCurrentBuildingDataById(id);
building.edit_history = await getBuildingEditHistory(id); building.edit_history = await getBuildingEditHistory(id);
building.verified = await getBuildingVerifications(building);
return building; return building;
} catch(error) { } catch(error) {
@ -204,6 +206,66 @@ async function unlikeBuilding(buildingId: number, userId: string) {
); );
} }
async function verifyBuildingAttributes(buildingId: number, userId: string, patch: object) {
// get current building attribute values for comparison
const building = await getCurrentBuildingDataById(buildingId);
// keep track of attributes and values verified
const verified = {}
// loop through attribute => value pairs to mark as verified
for (let [key, value] of Object.entries(patch)) {
// check key in whitelist
if(BUILDING_FIELD_WHITELIST.has(key)) {
// check value against current from database - JSON.stringify as hack for "any" data type
if (JSON.stringify(value) == JSON.stringify(building[key])) {
try {
await verifyDataAccess.updateBuildingUserVerifiedAttribute(buildingId, userId, key, building[key]);
verified[key] = building[key];
} catch (error) {
// possible reasons:
// - not a building
// - not a user
// - user already verified this attribute for this building
throw new DatabaseError(error);
}
} else {
if (value === null) {
await verifyDataAccess.removeBuildingUserVerifiedAttribute(buildingId, userId, key);
} else {
// not verifying current value
const msg = `Attribute "${key}" with value "${value}" did not match latest saved value "${building[key]}"`;
throw new DatabaseError(msg);
}
}
} else {
// not a valid attribute
const msg = `Attribute ${key} not recognised.`;
throw new DatabaseError(msg);
}
}
return verified;
}
async function getUserVerifiedAttributes(buildingId: number, userId: string) {
return await verifyDataAccess.getBuildingUserVerifiedAttributes(buildingId, userId);
}
async function getBuildingVerifications(building) {
const verifications = await verifyDataAccess.getBuildingVerifiedAttributes(building.building_id);
const verified = {};
for (const element of BUILDING_FIELD_WHITELIST) {
verified[element] = 0;
}
for (const item of verifications) {
if (JSON.stringify(building[item.attribute]) == JSON.stringify(item.verified_value)) {
verified[item.attribute] += 1
}
}
return verified;
}
// === Utility functions === // === Utility functions ===
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) { function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
@ -285,10 +347,10 @@ async function expireBuildingTileCache(buildingId: number) {
const BUILDING_FIELD_WHITELIST = new Set([ const BUILDING_FIELD_WHITELIST = new Set([
'ref_osm_id', 'ref_osm_id',
// 'location_name', 'location_name',
'location_number', 'location_number',
// 'location_street', 'location_street',
// 'location_line_two', 'location_line_two',
'location_town', 'location_town',
'location_postcode', 'location_postcode',
'location_latitude', 'location_latitude',
@ -378,5 +440,7 @@ export {
saveBuilding, saveBuilding,
likeBuilding, likeBuilding,
unlikeBuilding, unlikeBuilding,
getLatestRevisionId getLatestRevisionId,
verifyBuildingAttributes,
getUserVerifiedAttributes
}; };

View File

@ -89,7 +89,7 @@ async function getUserById(id: string) {
try { try {
return await db.one( return await db.one(
`SELECT `SELECT
username, email, registered, api_key username, email, date_trunc('minute', registered) as registered, api_key
FROM FROM
users users
WHERE WHERE

View File

@ -16,6 +16,7 @@ hydrate(
user={data.user} user={data.user}
building={data.building} building={data.building}
building_like={data.building_like} building_like={data.building_like}
user_verified={data.user_verified}
revisionId={data.latestRevisionId} revisionId={data.latestRevisionId}
/> />
</BrowserRouter>, </BrowserRouter>,

View File

@ -30,6 +30,7 @@ interface AppProps {
user?: User; user?: User;
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user_verified?: object;
revisionId: number; revisionId: number;
} }
@ -122,6 +123,7 @@ class App extends React.Component<AppProps, AppState> {
{...props} {...props}
building={this.props.building} building={this.props.building}
building_like={this.props.building_like} building_like={this.props.building_like}
user_verified={this.props.user_verified}
user={this.state.user} user={this.state.user}
revisionId={this.props.revisionId} revisionId={this.props.revisionId}
/> />

View File

@ -24,6 +24,7 @@ interface BuildingViewProps {
building_like?: boolean; building_like?: boolean;
user?: any; user?: any;
selectBuilding: (building: Building) => void; selectBuilding: (building: Building) => void;
user_verified?: any;
} }
/** /**

View File

@ -18,6 +18,9 @@ const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (prop
copy={props.copy} copy={props.copy}
/> />
<div className="form-check"> <div className="form-check">
<label
htmlFor={props.slug}
className="form-check-label">
<input className="form-check-input" type="checkbox" <input className="form-check-input" type="checkbox"
id={props.slug} id={props.slug}
name={props.slug} name={props.slug}
@ -25,10 +28,12 @@ const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (prop
disabled={props.mode === 'view' || props.disabled} disabled={props.mode === 'view' || props.disabled}
onChange={e => props.onChange(props.slug, e.target.checked)} onChange={e => props.onChange(props.slug, e.target.checked)}
/> />
<label {
htmlFor={props.slug} props.value?
className="form-check-label"> <span><strong>Yes</strong>/No</span>
{props.title} :
<span>Yes/<strong>No</strong></span>
}
</label> </label>
</div> </div>
</Fragment> </Fragment>

View File

@ -0,0 +1,17 @@
.verification-container {
font-size: 0.83333rem;
margin-bottom: 1em;
}
.verification-status {
padding-right: 0.5em;
cursor: default;
}
.verification-container .verification-status .fa-w-16 {
width: 20px;
}
.verification-container .btn {
margin: 0 0.5em;
padding: 0.125em 0.75em;
vertical-align: baseline;
font-size: 0.83333rem;
}

View File

@ -0,0 +1,75 @@
import React, { Component, Fragment } from 'react';
import { VerifyIcon } from '../../components/icons';
import './verification.css';
interface VerificationProps {
slug: string;
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
user_verified: boolean;
user_verified_as: string;
verified_count: number;
allow_verify: boolean;
}
class Verification extends Component<VerificationProps, any> {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this)
}
handleClick(verify) {
return (e) => {
e.preventDefault();
const x = e.clientX / document.body.clientWidth;
const y = e.clientY / document.body.clientHeight;
this.props.onVerify(this.props.slug, verify, x, y);
}
}
render() {
const props = this.props;
let user_verified_as = props.user_verified_as;
if (typeof user_verified_as === 'boolean') {
user_verified_as = user_verified_as? 'Yes': 'No';
}
return (
<div className="verification-container">
<span
className="verification-status"
title={`Verified by ${props.verified_count} ${(props.verified_count == 1)? "person": "people"}`}
>
<VerifyIcon />
{props.verified_count || 0}
</span>
{
props.user_verified?
<Fragment>
Verified as
"<span>{user_verified_as}</span>"
<button
className="btn btn-danger"
title="Remove my verification"
disabled={!props.allow_verify}
onClick={this.handleClick(false)}>
Remove
</button>
</Fragment>
:
<Fragment>
<button
className="btn btn-success"
title="Confirm that the current value is correct"
disabled={!props.allow_verify}
onClick={this.handleClick(true)}>
Verify
</button>
</Fragment>
}
</div>
);
}
}
export default Verification;

View File

@ -1,5 +1,6 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import Verification from './verification';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../data_fields';
import { CopyProps } from '../data-containers/category-view-props'; import { CopyProps } from '../data-containers/category-view-props';
@ -12,6 +13,12 @@ interface YearDataEntryProps {
copy?: CopyProps; copy?: CopyProps;
mode?: 'view' | 'edit' | 'multi-edit'; mode?: 'view' | 'edit' | 'multi-edit';
onChange?: (key: string, value: any) => void; onChange?: (key: string, value: any) => void;
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
user_verified: boolean;
user_verified_as: string;
verified_count: number;
allow_verify: boolean;
} }
class YearDataEntry extends Component<YearDataEntryProps, any> { class YearDataEntry extends Component<YearDataEntryProps, any> {
@ -45,6 +52,15 @@ class YearDataEntry extends Component<YearDataEntryProps, any> {
max={currentYear} max={currentYear}
// "type": "year_estimator" // "type": "year_estimator"
/> />
<Verification
allow_verify={props.allow_verify}
slug="date_year"
onVerify={props.onVerify}
user_verified={props.user_verified}
user_verified_as={props.user_verified_as}
verified_count={props.verified_count}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.date_upper.title} title={dataFields.date_upper.title}
slug="date_upper" slug="date_upper"

View File

@ -1,5 +1,6 @@
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 { apiPost } from '../apiHelpers'; import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box'; import ErrorBox from '../components/error-box';
@ -24,6 +25,7 @@ interface DataContainerProps {
mode: 'view' | 'edit'; mode: 'view' | 'edit';
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user_verified?: any;
selectBuilding: (building: Building) => void; selectBuilding: (building: Building) => void;
} }
@ -62,6 +64,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
this.handleReset = this.handleReset.bind(this); this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.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.toggleCopying = this.toggleCopying.bind(this); this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this); this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
@ -199,6 +202,38 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
} }
} }
async handleVerify(slug: string, verify: boolean, x: number, y: number) {
const verifyPatch = {};
if (verify) {
verifyPatch[slug] = this.props.building[slug];
} else {
verifyPatch[slug] = null;
}
try {
const data = await apiPost(
`/api/buildings/${this.props.building.building_id}/verify.json`,
verifyPatch
);
if (data.error) {
this.setState({error: data.error});
} else {
if (verify) {
Confetti({
angle: 60,
disableForReducedMotion: true,
origin: {x, y},
zIndex: 2000
});
}
this.props.selectBuilding(this.props.building);
}
} catch(err) {
this.setState({error: err});
}
}
render() { render() {
if (this.props.mode === 'edit' && !this.props.user){ if (this.props.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />; return <Redirect to="/sign-up.html" />;
@ -281,9 +316,12 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
building={undefined} building={undefined}
building_like={undefined} building_like={undefined}
mode={this.props.mode} mode={this.props.mode}
edited={false}
copy={copy} copy={copy}
onChange={this.handleChange} onChange={this.handleChange}
onLike={this.handleLike} onLike={this.handleLike}
onVerify={this.handleVerify}
user_verified={[]}
/> />
</Fragment> : </Fragment> :
this.props.building != undefined ? this.props.building != undefined ?
@ -327,9 +365,13 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
building={currentBuilding} building={currentBuilding}
building_like={this.props.building_like} building_like={this.props.building_like}
mode={this.props.mode} mode={this.props.mode}
edited={edited}
copy={copy} copy={copy}
onChange={this.handleChange} onChange={this.handleChange}
onLike={this.handleLike} onLike={this.handleLike}
onVerify={this.handleVerify}
user_verified={this.props.user_verified}
user={this.props.user}
/> />
</form> : </form> :
<InfoBox msg="Select a building to view data"></InfoBox> <InfoBox msg="Select a building to view data"></InfoBox>

View File

@ -5,6 +5,7 @@ import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import TextboxDataEntry from '../data-components/textbox-data-entry'; import TextboxDataEntry from '../data-components/textbox-data-entry';
import Verification from '../data-components/verification';
import YearDataEntry from '../data-components/year-data-entry'; import YearDataEntry from '../data-components/year-data-entry';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
@ -25,6 +26,12 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
mode={props.mode} mode={props.mode}
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
allow_verify={props.user !== undefined && props.building.date_year !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("date_year")}
user_verified_as={props.user_verified.date_year}
verified_count={props.building.verified.date_year}
/> />
<NumericDataEntry <NumericDataEntry
title={dataFields.facade_year.title} title={dataFields.facade_year.title}
@ -38,6 +45,15 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
max={currentYear} max={currentYear}
tooltip={dataFields.facade_year.tooltip} tooltip={dataFields.facade_year.tooltip}
/> />
<Verification
slug="facade_year"
allow_verify={props.user !== undefined && props.building.facade_year !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("facade_year")}
user_verified_as={props.user_verified.facade_year}
verified_count={props.building.verified.facade_year}
/>
<SelectDataEntry <SelectDataEntry
title={dataFields.date_source.title} title={dataFields.date_source.title}
slug="date_source" slug="date_source"

View File

@ -10,9 +10,13 @@ interface CategoryViewProps {
building: any; // TODO: add Building type with all fields building: any; // TODO: add Building type with all fields
building_like: boolean; building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit'; mode: 'view' | 'edit' | 'multi-edit';
edited: boolean;
copy: CopyProps; copy: CopyProps;
onChange: (key: string, value: any) => void; onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void; onLike: (like: boolean) => void;
onVerify: (slug: string, verify: boolean, x: number, y: number) => void;
user_verified: any;
user?: any;
} }
export { export {

View File

@ -5,6 +5,7 @@ import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry'; import UPRNsDataEntry from '../data-components/uprns-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -21,8 +22,16 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange} onChange={props.onChange}
tooltip={dataFields.location_name.tooltip} tooltip={dataFields.location_name.tooltip}
placeholder="Building name (if any)" placeholder="Building name (if any)"
disabled={true}
/> />
<Verification
slug="location_name"
allow_verify={props.user !== undefined && props.building.location_name !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("location_name")}
user_verified_as={props.user_verified.location_name}
verified_count={props.building.verified.location_name}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.location_number.title} title={dataFields.location_number.title}
slug="location_number" slug="location_number"
@ -33,6 +42,15 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={1} step={1}
min={1} min={1}
/> />
<Verification
slug="location_number"
allow_verify={props.user !== undefined && props.building.location_number !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("location_number")}
user_verified_as={props.user_verified.location_number}
verified_count={props.building.verified.location_number}
/>
<DataEntry <DataEntry
title={dataFields.location_street.title} title={dataFields.location_street.title}
slug="location_street" slug="location_street"
@ -40,8 +58,16 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
mode={props.mode} mode={props.mode}
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
disabled={true}
/> />
<Verification
slug="location_street"
allow_verify={props.user !== undefined && props.building.location_street !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("location_street")}
user_verified_as={props.user_verified.location_street}
verified_count={props.building.verified.location_street}
/>
<DataEntry <DataEntry
title={dataFields.location_line_two.title} title={dataFields.location_line_two.title}
slug="location_line_two" slug="location_line_two"

View File

@ -6,6 +6,7 @@ import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group'; import { DataEntryGroup } from '../data-components/data-entry-group';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -23,6 +24,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_portal_link"
allow_verify={props.user !== undefined && props.building.planning_portal_link !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_portal_link")}
user_verified_as={props.user_verified.planning_portal_link}
verified_count={props.building.verified.planning_portal_link}
/>
<DataEntryGroup name="Planning Status"> <DataEntryGroup name="Planning Status">
<CheckboxDataEntry <CheckboxDataEntry
title="Is a planning application live for this site?" title="Is a planning application live for this site?"
@ -84,6 +94,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_in_conservation_area"
allow_verify={props.user !== undefined && props.building.planning_in_conservation_area !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_in_conservation_area")}
user_verified_as={props.user_verified.planning_in_conservation_area}
verified_count={props.building.verified.planning_in_conservation_area}
/>
<DataEntry <DataEntry
title={dataFields.planning_conservation_area_name.title} title={dataFields.planning_conservation_area_name.title}
slug="planning_conservation_area_name" slug="planning_conservation_area_name"
@ -92,6 +110,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_conservation_area_name"
allow_verify={props.user !== undefined && props.building.planning_conservation_area_name !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_conservation_area_name")}
user_verified_as={props.user_verified.planning_conservation_area_name}
verified_count={props.building.verified.planning_conservation_area_name}
/>
<CheckboxDataEntry <CheckboxDataEntry
title={dataFields.planning_in_list.title} title={dataFields.planning_in_list.title}
slug="planning_in_list" slug="planning_in_list"
@ -149,6 +176,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_heritage_at_risk_id"
allow_verify={props.user !== undefined && props.building.planning_heritage_at_risk_id !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_heritage_at_risk_id")}
user_verified_as={props.user_verified.planning_heritage_at_risk_id}
verified_count={props.building.verified.planning_heritage_at_risk_id}
/>
<DataEntry <DataEntry
title={dataFields.planning_world_list_id.title} title={dataFields.planning_world_list_id.title}
slug="planning_world_list_id" slug="planning_world_list_id"
@ -157,6 +193,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_world_list_id"
allow_verify={props.user !== undefined && props.building.planning_world_list_id !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_world_list_id")}
user_verified_as={props.user_verified.planning_world_list_id}
verified_count={props.building.verified.planning_world_list_id}
/>
<CheckboxDataEntry <CheckboxDataEntry
title={dataFields.planning_in_glher.title} title={dataFields.planning_in_glher.title}
slug="planning_in_glher" slug="planning_in_glher"
@ -165,6 +210,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_in_glher"
allow_verify={props.user !== undefined && props.building.planning_in_glher !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_in_glher")}
user_verified_as={props.user_verified.planning_in_glher}
verified_count={props.building.verified.planning_in_glher}
/>
<DataEntry <DataEntry
title={dataFields.planning_glher_url.title} title={dataFields.planning_glher_url.title}
slug="planning_glher_url" slug="planning_glher_url"
@ -173,6 +226,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_glher_url"
allow_verify={props.user !== undefined && props.building.planning_glher_url !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_glher_url")}
user_verified_as={props.user_verified.planning_glher_url}
verified_count={props.building.verified.planning_glher_url}
/>
<CheckboxDataEntry <CheckboxDataEntry
title={dataFields.planning_in_apa.title} title={dataFields.planning_in_apa.title}
slug="planning_in_apa" slug="planning_in_apa"
@ -181,6 +243,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_in_apa"
allow_verify={props.user !== undefined && props.building.planning_in_apa !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_in_apa")}
user_verified_as={props.user_verified.planning_in_apa}
verified_count={props.building.verified.planning_in_apa}
/>
<DataEntry <DataEntry
title={dataFields.planning_apa_name.title} title={dataFields.planning_apa_name.title}
slug="planning_apa_name" slug="planning_apa_name"
@ -189,6 +259,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_apa_name"
allow_verify={props.user !== undefined && props.building.planning_apa_name !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_apa_name")}
user_verified_as={props.user_verified.planning_apa_name}
verified_count={props.building.verified.planning_apa_name}
/>
<DataEntry <DataEntry
title={dataFields.planning_apa_tier.title} title={dataFields.planning_apa_tier.title}
slug="planning_apa_tier" slug="planning_apa_tier"
@ -197,6 +275,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_apa_tier"
allow_verify={props.user !== undefined && props.building.planning_apa_tier !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_apa_tier")}
user_verified_as={props.user_verified.planning_apa_tier}
verified_count={props.building.verified.planning_apa_tier}
/>
<CheckboxDataEntry <CheckboxDataEntry
title={dataFields.planning_in_local_list.title} title={dataFields.planning_in_local_list.title}
slug="planning_in_local_list" slug="planning_in_local_list"
@ -205,6 +292,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_in_local_list"
allow_verify={props.user !== undefined && props.building.planning_in_local_list !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_in_local_list")}
user_verified_as={props.user_verified.planning_in_local_list}
verified_count={props.building.verified.planning_in_local_list}
/>
<DataEntry <DataEntry
title={dataFields.planning_local_list_url.title} title={dataFields.planning_local_list_url.title}
slug="planning_local_list_url" slug="planning_local_list_url"
@ -213,6 +308,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_local_list_url"
allow_verify={props.user !== undefined && props.building.planning_local_list_url !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_local_list_url")}
user_verified_as={props.user_verified.planning_local_list_url}
verified_count={props.building.verified.planning_local_list_url}
/>
<CheckboxDataEntry <CheckboxDataEntry
title={dataFields.planning_in_historic_area_assessment.title} title={dataFields.planning_in_historic_area_assessment.title}
slug="planning_in_historic_area_assessment" slug="planning_in_historic_area_assessment"
@ -221,6 +325,14 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_in_historic_area_assessment"
allow_verify={props.user !== undefined && props.building.planning_in_historic_area_assessment !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_in_historic_area_assessment")}
user_verified_as={props.user_verified.planning_in_historic_area_assessment}
verified_count={props.building.verified.planning_in_historic_area_assessment}
/>
<DataEntry <DataEntry
title={dataFields.planning_historic_area_assessment_url.title} title={dataFields.planning_historic_area_assessment_url.title}
slug="planning_historic_area_assessment_url" slug="planning_historic_area_assessment_url"
@ -229,6 +341,15 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="planning_historic_area_assessment_url"
allow_verify={props.user !== undefined && props.building.planning_historic_area_assessment_url !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_historic_area_assessment_url")}
user_verified_as={props.user_verified.planning_historic_area_assessment_url}
verified_count={props.building.verified.planning_historic_area_assessment_url}
/>
</DataEntryGroup> </DataEntryGroup>
</Fragment> </Fragment>
); );

View File

@ -4,6 +4,7 @@ import { dataFields } from '../../data_fields';
import { DataEntryGroup } from '../data-components/data-entry-group'; import { DataEntryGroup } from '../data-components/data-entry-group';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -14,7 +15,6 @@ import { CategoryViewProps } from './category-view-props';
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => ( const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment> <Fragment>
<DataEntryGroup name="Storeys"> <DataEntryGroup name="Storeys">
<NumericDataEntry <NumericDataEntry
title={dataFields.size_storeys_core.title} title={dataFields.size_storeys_core.title}
slug="size_storeys_core" slug="size_storeys_core"
@ -26,6 +26,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={1} step={1}
min={0} min={0}
/> />
<Verification
slug="size_storeys_core"
allow_verify={props.user !== undefined && props.building.size_storeys_core !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_storeys_core")}
user_verified_as={props.user_verified.size_storeys_core}
verified_count={props.building.verified.size_storeys_core}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_storeys_attic.title} title={dataFields.size_storeys_attic.title}
slug="size_storeys_attic" slug="size_storeys_attic"
@ -37,6 +46,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={1} step={1}
min={0} min={0}
/> />
<Verification
slug="size_storeys_attic"
allow_verify={props.user !== undefined && props.building.size_storeys_attic !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_storeys_attic")}
user_verified_as={props.user_verified.size_storeys_attic}
verified_count={props.building.verified.size_storeys_attic}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_storeys_basement.title} title={dataFields.size_storeys_basement.title}
slug="size_storeys_basement" slug="size_storeys_basement"
@ -48,6 +66,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={1} step={1}
min={0} min={0}
/> />
<Verification
slug="size_storeys_basement"
allow_verify={props.user !== undefined && props.building.size_storeys_basement !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_storeys_basement")}
user_verified_as={props.user_verified.size_storeys_basement}
verified_count={props.building.verified.size_storeys_basement}
/>
</DataEntryGroup> </DataEntryGroup>
<DataEntryGroup name="Height" collapsed={false}> <DataEntryGroup name="Height" collapsed={false}>
<NumericDataEntry <NumericDataEntry
@ -60,6 +87,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1} step={0.1}
min={0} min={0}
/> />
<Verification
slug="size_height_apex"
allow_verify={props.user !== undefined && props.building.size_height_apex !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_height_apex")}
user_verified_as={props.user_verified.size_height_apex}
verified_count={props.building.verified.size_height_apex}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_height_eaves.title} title={dataFields.size_height_eaves.title}
slug="size_height_eaves" slug="size_height_eaves"
@ -83,6 +119,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1} step={0.1}
min={0} min={0}
/> />
<Verification
slug="size_floor_area_ground"
allow_verify={props.user !== undefined && props.building.size_floor_area_ground !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_floor_area_ground")}
user_verified_as={props.user_verified.size_floor_area_ground}
verified_count={props.building.verified.size_floor_area_ground}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_floor_area_total.title} title={dataFields.size_floor_area_total.title}
slug="size_floor_area_total" slug="size_floor_area_total"
@ -93,6 +138,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1} step={0.1}
min={0} min={0}
/> />
<Verification
slug="size_floor_area_total"
allow_verify={props.user !== undefined && props.building.size_floor_area_total !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_floor_area_total")}
user_verified_as={props.user_verified.size_floor_area_total}
verified_count={props.building.verified.size_floor_area_total}
/>
</DataEntryGroup> </DataEntryGroup>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_width_frontage.title} title={dataFields.size_width_frontage.title}
@ -104,6 +158,15 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
step={0.1} step={0.1}
min={0} min={0}
/> />
<Verification
slug="size_width_frontage"
allow_verify={props.user !== undefined && props.building.size_width_frontage !== null}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("size_width_frontage")}
user_verified_as={props.user_verified.size_width_frontage}
verified_count={props.building.verified.size_width_frontage}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.size_plot_area_total.title} title={dataFields.size_plot_area_total.title}
slug="size_plot_area_total" slug="size_plot_area_total"

View File

@ -3,6 +3,7 @@ import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../data_fields';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -32,6 +33,15 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="sust_breeam_rating"
allow_verify={props.user !== undefined && props.building.sust_breeam_rating !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("sust_breeam_rating")}
user_verified_as={props.user_verified.sust_breeam_rating}
verified_count={props.building.verified.sust_breeam_rating}
/>
<SelectDataEntry <SelectDataEntry
title={dataFields.sust_dec.title} title={dataFields.sust_dec.title}
slug="sust_dec" slug="sust_dec"
@ -42,6 +52,15 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="sust_dec"
allow_verify={props.user !== undefined && props.building.sust_dec !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("sust_dec")}
user_verified_as={props.user_verified.sust_dec}
verified_count={props.building.verified.sust_dec}
/>
<SelectDataEntry <SelectDataEntry
title={dataFields.sust_aggregate_estimate_epc.title} title={dataFields.sust_aggregate_estimate_epc.title}
slug="sust_aggregate_estimate_epc" slug="sust_aggregate_estimate_epc"
@ -53,6 +72,7 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<NumericDataEntry <NumericDataEntry
title={dataFields.sust_retrofit_date.title} title={dataFields.sust_retrofit_date.title}
slug="sust_retrofit_date" slug="sust_retrofit_date"
@ -65,6 +85,15 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="sust_retrofit_date"
allow_verify={props.user !== undefined && props.building.sust_retrofit_date !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("sust_retrofit_date")}
user_verified_as={props.user_verified.sust_retrofit_date}
verified_count={props.building.verified.sust_retrofit_date}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.sust_life_expectancy.title} title={dataFields.sust_life_expectancy.title}
slug="sust_life_expectancy" slug="sust_life_expectancy"

View File

@ -4,6 +4,7 @@ import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -31,6 +32,15 @@ const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />
<Verification
slug="building_attachment_form"
allow_verify={props.user !== undefined && props.building.building_attachment_form !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("building_attachment_form")}
user_verified_as={props.user_verified.building_attachment_form}
verified_count={props.building.verified.building_attachment_form}
/>
<NumericDataEntry <NumericDataEntry
title={dataFields.date_change_building_use.title} title={dataFields.date_change_building_use.title}
slug="date_change_building_use" slug="date_change_building_use"

View File

@ -7,6 +7,7 @@ import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
import Verification from '../data-components/verification';
/** /**
* Use view/edit section * Use view/edit section
@ -29,6 +30,14 @@ const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
showAllOptionsOnEmpty={true} showAllOptionsOnEmpty={true}
addOnAutofillSelect={true} addOnAutofillSelect={true}
/> />
<Verification
slug="current_landuse_group"
allow_verify={props.user !== undefined && props.building.current_landuse_group !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("current_landuse_group")}
user_verified_as={props.user_verified.current_landuse_group && props.user_verified.current_landuse_group.join(", ")}
verified_count={props.building.verified.current_landuse_group}
/>
{ {
props.mode != 'view' && props.mode != 'view' &&
<InfoBox msg="Land use order, shown below, is automatically derived from the land use groups"></InfoBox> <InfoBox msg="Land use order, shown below, is automatically derived from the land use groups"></InfoBox>

View File

@ -2,8 +2,22 @@
* Mini-library of icons * Mini-library of icons
*/ */
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { faAngleLeft, faAngleRight, faCaretDown, faCaretRight, faCaretUp, faCheck, faCheckDouble, import {
faEye, faInfoCircle, faPaintBrush, faQuestionCircle, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; faAngleLeft,
faAngleRight,
faCaretDown,
faCaretRight,
faCaretUp,
faCheck,
faCheckCircle,
faCheckDouble,
faEye,
faInfoCircle,
faPaintBrush,
faQuestionCircle,
faSearch,
faTimes
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
@ -13,6 +27,7 @@ library.add(
faPaintBrush, faPaintBrush,
faTimes, faTimes,
faCheck, faCheck,
faCheckCircle,
faCheckDouble, faCheckDouble,
faAngleLeft, faAngleLeft,
faAngleRight, faAngleRight,
@ -51,6 +66,10 @@ const SaveDoneIcon = () => (
<FontAwesomeIcon icon="check-double" /> <FontAwesomeIcon icon="check-double" />
); );
const VerifyIcon = () => (
<FontAwesomeIcon icon="check-circle" />
)
const BackIcon = () => ( const BackIcon = () => (
<FontAwesomeIcon icon="angle-left" /> <FontAwesomeIcon icon="angle-left" />
); );
@ -88,5 +107,6 @@ export {
DownIcon, DownIcon,
UpIcon, UpIcon,
RightIcon, RightIcon,
SearchIcon SearchIcon,
VerifyIcon
}; };

View File

@ -26,6 +26,7 @@ interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building_like?: boolean; building_like?: boolean;
user?: any; user?: any;
revisionId?: number; revisionId?: number;
user_verified?: object;
} }
interface MapAppState { interface MapAppState {
@ -33,6 +34,7 @@ interface MapAppState {
revision_id: number; revision_id: number;
building: Building; building: Building;
building_like: boolean; building_like: boolean;
user_verified: object;
} }
class MapApp extends React.Component<MapAppProps, MapAppState> { class MapApp extends React.Component<MapAppProps, MapAppState> {
@ -43,7 +45,8 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
category: this.getCategory(props.match.params.category), category: this.getCategory(props.match.params.category),
revision_id: props.revisionId || 0, revision_id: props.revisionId || 0,
building: props.building, building: props.building,
building_like: props.building_like building_like: props.building_like,
user_verified: props.user_verified || {}
}; };
this.selectBuilding = this.selectBuilding.bind(this); this.selectBuilding = this.selectBuilding.bind(this);
@ -60,7 +63,10 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
componentDidMount() { componentDidMount() {
this.fetchLatestRevision(); this.fetchLatestRevision();
this.fetchBuildingData();
if(this.props.match.params.building != undefined && this.props.building == undefined) {
this.fetchBuildingData(strictParseInt(this.props.match.params.building));
}
} }
async fetchLatestRevision() { async fetchLatestRevision() {
@ -77,29 +83,31 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
* Fetches building data if a building is selected but no data provided through * Fetches building data if a building is selected but no data provided through
* props (from server-side rendering) * props (from server-side rendering)
*/ */
async fetchBuildingData() { async fetchBuildingData(buildingId: number) {
if(this.props.match.params.building != undefined && this.props.building == undefined) {
try { try {
// TODO: simplify API calls, create helpers for fetching data // TODO: simplify API calls, create helpers for fetching data
const buildingId = strictParseInt(this.props.match.params.building); let [building, building_uprns, building_like, user_verified] = await Promise.all([
let [building, building_uprns, building_like] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json`), apiGet(`/api/buildings/${buildingId}.json`),
apiGet(`/api/buildings/${buildingId}/uprns.json`), apiGet(`/api/buildings/${buildingId}/uprns.json`),
apiGet(`/api/buildings/${buildingId}/like.json`) apiGet(`/api/buildings/${buildingId}/like.json`),
apiGet(`/api/buildings/${buildingId}/verify.json`)
]); ]);
building.uprns = building_uprns.uprns; building.uprns = building_uprns.uprns;
this.setState({ this.setState({
building: building, building: building,
building_like: building_like.like building_like: building_like.like,
user_verified: user_verified
}); });
this.increaseRevision(building.revision_id);
} catch(error) { } catch(error) {
console.error(error); console.error(error);
// TODO: add UI for API errors // TODO: add UI for API errors
} }
} }
}
getCategory(category: string) { getCategory(category: string) {
if (category === 'categories') return undefined; if (category === 'categories') return undefined;
@ -132,35 +140,10 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
return; return;
} }
this.increaseRevision(building.revision_id); this.fetchBuildingData(building.building_id);
// get UPRNs and update
apiGet(`/api/buildings/${building.building_id}/uprns.json`)
.then((res) => {
if (res.error) {
console.error(res);
} else {
building.uprns = res.uprns;
this.setState({ building: building });
}
}).catch((err) => {
console.error(err);
this.setState({ building: building });
});
// get if liked and update
apiGet(`/api/buildings/${building.building_id}/like.json`)
.then((res) => {
if (res.error) {
console.error(res);
} else {
this.setState({ building_like: res.like });
this.props.history.push(`/${mode}/${category}/${building.building_id}`); this.props.history.push(`/${mode}/${category}/${building.building_id}`);
} }
}).catch((err) => {
console.error(err);
this.setState({ building_like: false });
});
}
/** /**
* Colour building * Colour building
@ -248,6 +231,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
cat={category} cat={category}
building={this.state.building} building={this.state.building}
building_like={this.state.building_like} building_like={this.state.building_like}
user_verified={this.state.user_verified}
selectBuilding={this.selectBuilding} selectBuilding={this.selectBuilding}
user={this.props.user} user={this.props.user}
/> />

View File

@ -76,6 +76,10 @@
flex: 1; flex: 1;
} }
.data-legend li {
white-space: nowrap;
}
.data-legend .key { .data-legend .key {
display: inline-block; display: inline-block;
width: 1.3rem; width: 1.3rem;

View File

@ -228,14 +228,8 @@ class Legend extends React.Component<LegendProps, LegendState> {
return ( return (
<li key={item.color} > <li key={item.color} >
<tr>
<td>
<div className="key" style={ { background: item.color, border: item.border } }></div> <div className="key" style={ { background: item.color, border: item.border } }></div>
</td>
<td>
{ item.text } { item.text }
</td>
</tr>
</li> </li>
); );

View File

@ -8,18 +8,20 @@ import {
getBuildingById, getBuildingById,
getBuildingLikeById, getBuildingLikeById,
getBuildingUPRNsById, getBuildingUPRNsById,
getLatestRevisionId getLatestRevisionId,
getUserVerifiedAttributes
} from './api/services/building'; } from './api/services/building';
import { getUserById } from './api/services/user'; import { getUserById } from './api/services/user';
import App from './frontend/app'; import App from './frontend/app';
import { parseBuildingURL } from './parse'; import { parseBuildingURL } from './parse';
import asyncController from './api/routes/asyncController';
// reference packed assets // reference packed assets
const assets = require(process.env.RAZZLE_ASSETS_MANIFEST); const assets = require(process.env.RAZZLE_ASSETS_MANIFEST);
function frontendRoute(req: express.Request, res: express.Response) { const frontendRoute = asyncController(async (req: express.Request, res: express.Response) => {
const context: any = {}; // TODO: remove any const context: any = {}; // TODO: remove any
const data: any = {}; // TODO: remove any const data: any = {}; // TODO: remove any
context.status = 200; context.status = 200;
@ -31,34 +33,39 @@ function frontendRoute(req: express.Request, res: express.Response) {
context.status = 404; context.status = 404;
} }
Promise.all([ try {
let [user, building, uprns, buildingLike, userVerified, latestRevisionId] = await Promise.all([
userId ? getUserById(userId) : undefined, userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined, isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined, isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false, (isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
getLatestRevisionId() getLatestRevisionId()
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) { ]);
if (isBuilding && typeof (building) === 'undefined') { if (isBuilding && typeof (building) === 'undefined') {
context.status = 404; context.status = 404;
} }
data.user = user; data.user = user;
data.building = building; data.building = building;
data.building_like = buildingLike; data.building_like = buildingLike;
data.user_verified = userVerified;
if (data.building != null) { if (data.building != null) {
data.building.uprns = uprns; data.building.uprns = uprns;
} }
data.latestRevisionId = latestRevisionId; data.latestRevisionId = latestRevisionId;
renderHTML(context, data, req, res); renderHTML(context, data, req, res);
}).catch(error => { } catch(error) {
console.error(error); console.error(error);
data.user = undefined; data.user = undefined;
data.building = undefined; data.building = undefined;
data.building_like = undefined; data.building_like = undefined;
data.user_verified = {}
data.latestRevisionId = 0; data.latestRevisionId = 0;
context.status = 500; context.status = 500;
renderHTML(context, data, req, res); renderHTML(context, data, req, res);
}); }
} });
function renderHTML(context, data, req, res) { function renderHTML(context, data, req, res) {
const markup = renderToString( const markup = renderToString(

View File

@ -0,0 +1,2 @@
-- Building verification
DROP TABLE IF EXISTS building_verification;

View File

@ -0,0 +1,28 @@
-- Building verification
-- Users can verify the correctness of individual building attribute values.
-- For a building, it's most useful to know the count of verifications of each
-- attribute with current (or past) values.
-- For a user, it's useful to show which attributes they have already verified
-- on a given building.
-- Store user-building-attribute verification
CREATE TABLE IF NOT EXISTS building_verification (
verification_id serial PRIMARY KEY,
verification_timestamp TIMESTAMP default NOW(),
building_id integer REFERENCES buildings,
user_id uuid REFERENCES users,
attribute varchar, -- bit of a hack to refer to any `buildings` table column name
verified_value jsonb -- bit of a hack to include "any" value
);
CREATE INDEX building_verification_idx ON building_verification ( building_id );
CREATE INDEX user_verification_idx ON building_verification ( user_id );
CREATE INDEX building_user_verification_idx ON building_verification ( building_id, user_id );
-- Enforce that a user only has one opinion about the correct value of an
-- attribute for a given building (don't need to allow multiple verified_values)
ALTER TABLE building_verification ADD CONSTRAINT verify_building_attribute_once
UNIQUE ( building_id, user_id, attribute );

View File

@ -47,6 +47,8 @@ GRANT USAGE ON ALL SEQUENCES IN SCHEMA public to appusername;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO appusername; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO appusername;
-- read map search locations -- read map search locations
GRANT SELECT ON TABLE search_locations to appusername; GRANT SELECT ON TABLE search_locations to appusername;
-- add/save user building attribute verification
GRANT SELECT, INSERT, DELETE ON TABLE building_verification TO appusername;
``` ```
Set or update passwords: Set or update passwords: