Add verification for Facade Data, thread info through frontend

This commit is contained in:
Tom Russell 2020-08-04 15:54:49 +01:00
parent b30e882669
commit 2d6a18f81b
15 changed files with 214 additions and 80 deletions

View File

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

View File

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

View File

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

View File

@ -34,7 +34,7 @@ const DataEntry: React.FC<DataEntryProps> = (props) => {
value={props.value}
onChange={props.onChange}
disabled={props.mode === 'view' || props.disabled}
maxLength={props.maxLength}
placeholder={props.placeholder}
valueTransform={props.valueTransform}

View File

@ -33,7 +33,7 @@ const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props)
min={props.min}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={e =>
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ? null : parseFloat(e.target.value)

View File

@ -0,0 +1,16 @@
.verification-container {
font-size: 0.83333rem;
}
.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,57 @@
import React, { Fragment } from 'react';
import { VerifyIcon } from '../../components/icons';
import './verification.css';
interface VerificationProps {
slug: string;
onVerify: (slug: string, verify: boolean) => void;
user_verified: boolean;
user_verified_as: string;
verified_count: number;
allow_verify: boolean;
}
const Verification: React.FunctionComponent<VerificationProps> = (props) => (
<div className="verification-container">
<span
className="verification-status"
title={`Verified by ${props.verified_count} ${(props.verified_count == 1)? "person": "people"}`}
>
<VerifyIcon />
{props.verified_count}
</span>
{
props.allow_verify?
props.user_verified?
<Fragment>
Verified as
"<span>{props.user_verified_as}</span>"
<button
className="btn btn-danger"
title="Remove my verification"
onClick={(e) => {
e.preventDefault();
props.onVerify(props.slug, false)
}}>
Remove
</button>
</Fragment>
:
<Fragment>
<button
className="btn btn-success"
title="Confirm that the current value is correct"
onClick={(e) => {
e.preventDefault();
props.onVerify(props.slug, true)
}}>
Verify
</button>
</Fragment>
: null
}
</div>
);
export default Verification;

View File

@ -1,5 +1,6 @@
import React, { Fragment } from 'react';
import { NavLink, Redirect } from 'react-router-dom';
import Confetti from 'canvas-confetti';
import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box';
@ -24,6 +25,7 @@ interface DataContainerProps {
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
user_verified?: any;
selectBuilding: (building: Building) => void;
}
@ -62,6 +64,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleVerify = this.handleVerify.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
@ -167,7 +170,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
`/api/buildings/${this.props.building.building_id}/like.json`,
{like: like}
);
if (data.error) {
this.setState({error: data.error});
} else {
@ -188,7 +191,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
`/api/buildings/${this.props.building.building_id}.json`,
this.state.buildingEdits
);
if (data.error) {
this.setState({error: data.error});
} else {
@ -199,6 +202,33 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
}
}
async handleVerify(slug: string, verify: boolean) {
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({zIndex: 2000});
}
this.props.selectBuilding(this.props.building);
}
} catch(err) {
this.setState({error: err});
}
}
render() {
if (this.props.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />;
@ -284,6 +314,8 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
onVerify={this.handleVerify}
user_verified={[]}
/>
</Fragment> :
this.props.building != undefined ?
@ -330,6 +362,9 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
onVerify={this.handleVerify}
user_verified={this.props.user_verified}
user={this.props.user}
/>
</form> :
<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 SelectDataEntry from '../data-components/select-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 withCopyEdit from '../data-container';
@ -15,7 +16,7 @@ import { CategoryViewProps } from './category-view-props';
*/
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
const currentYear = new Date().getFullYear();
return (
<Fragment>
<YearDataEntry
@ -38,6 +39,15 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
max={currentYear}
tooltip={dataFields.facade_year.tooltip}
/>
<Verification
allow_verify={props.user !== undefined}
slug="facade_year"
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
title={dataFields.date_source.title}
slug="date_source"

View File

@ -13,6 +13,9 @@ interface CategoryViewProps {
copy: CopyProps;
onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void;
onVerify: (slug: string, verify: boolean) => void;
user_verified: any;
user?: any;
}
export {

View File

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

View File

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

View File

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

View File

@ -116,10 +116,10 @@ const LEGEND_CONFIG = {
disclaimer: 'All data relating to designated buildings should be checked on the National Heritage List for England or local authority websites where used for planning or development purposes',
elements: [
{ color: '#95beba', text: 'In conservation area'},
{ color: '#c72e08', text: 'Grade I listed'},
{ color: '#e75b42', text: 'Grade II* listed'},
{ color: '#c72e08', text: 'Grade I listed'},
{ color: '#e75b42', text: 'Grade II* listed'},
{ color: '#ffbea1', text: 'Grade II listed'},
{ color: '#858ed4', text: 'Locally listed'},
{ color: '#858ed4', text: 'Locally listed'},
]
},
dynamics: {
@ -228,14 +228,8 @@ class Legend extends React.Component<LegendProps, LegendState> {
return (
<li key={item.color} >
<tr>
<td>
<div className="key" style={ { background: item.color, border: item.border } }></div>
</td>
<td>
{ item.text }
</td>
</tr>
<div className="key" style={ { background: item.color, border: item.border } }></div>
{ item.text }
</li>
);

View File

@ -8,18 +8,20 @@ import {
getBuildingById,
getBuildingLikeById,
getBuildingUPRNsById,
getLatestRevisionId
getLatestRevisionId,
getUserVerifiedAttributes
} from './api/services/building';
import { getUserById } from './api/services/user';
import App from './frontend/app';
import { parseBuildingURL } from './parse';
import asyncController from './api/routes/asyncController';
// reference packed assets
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 data: any = {}; // TODO: remove any
context.status = 200;
@ -31,34 +33,39 @@ function frontendRoute(req: express.Request, res: express.Response) {
context.status = 404;
}
Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
getLatestRevisionId()
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) {
try {
let [user, building, uprns, buildingLike, userVerified, latestRevisionId] = await Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(buildingId) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
getLatestRevisionId()
]);
if (isBuilding && typeof (building) === 'undefined') {
context.status = 404;
}
data.user = user;
data.building = building;
data.building_like = buildingLike;
data.user_verified = userVerified;
if (data.building != null) {
data.building.uprns = uprns;
}
data.latestRevisionId = latestRevisionId;
renderHTML(context, data, req, res);
}).catch(error => {
} catch(error) {
console.error(error);
data.user = undefined;
data.building = undefined;
data.building_like = undefined;
data.user_verified = {}
data.latestRevisionId = 0;
context.status = 500;
renderHTML(context, data, req, res);
});
}
}
});
function renderHTML(context, data, req, res) {
const markup = renderToString(
@ -93,7 +100,7 @@ function renderHTML(context, data, req, res) {
<meta property="og:image" content="https://colouring.london/images/logo-cl-square.png" />
<link rel="manifest" href="site.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Colouring London">