Merge pull request #592 from colouring-london/develop

Land-use and planning work
This commit is contained in:
Tom Russell 2020-04-09 13:26:45 +01:00 committed by GitHub
commit 0f450d994a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1221 additions and 540 deletions

View File

@ -159,87 +159,113 @@
</Style>
<Style name="date_year">
<Rule>
<Filter>[date_year] &gt;= 2000</Filter>
<PolygonSymbolizer fill="#f0eaba" />
<Filter>[date_year] &gt;= 2020</Filter>
<PolygonSymbolizer fill="#fff9b8" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1980 and [date_year] &lt; 2000</Filter>
<Filter>[date_year] &gt;= 2000 and [date_year] &lt; 2020</Filter>
<PolygonSymbolizer fill="#fae269" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1960 and [date_year] &lt; 1980</Filter>
<Filter>[date_year] &gt;= 1980 and [date_year] &lt; 2000</Filter>
<PolygonSymbolizer fill="#fbaf27" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1940 and [date_year] &lt; 1960</Filter>
<Filter>[date_year] &gt;= 1960 and [date_year] &lt; 1980</Filter>
<PolygonSymbolizer fill="#e6711d" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1940 and [date_year] &lt; 1960</Filter>
<PolygonSymbolizer fill="#cc1212" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1920 and [date_year] &lt; 1940</Filter>
<PolygonSymbolizer fill="#d73d3a" />
<PolygonSymbolizer fill="#8f0303" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1900 and [date_year] &lt; 1920</Filter>
<PolygonSymbolizer fill="#ba221c" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1880 and [date_year] &lt; 1900</Filter>
<PolygonSymbolizer fill="#bb859b" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1860 and [date_year] &lt; 1880</Filter>
<PolygonSymbolizer fill="#8b3654" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1840 and [date_year] &lt; 1860</Filter>
<PolygonSymbolizer fill="#8f5385" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1880 and [date_year] &lt; 1900</Filter>
<PolygonSymbolizer fill="#c3e1eb" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1860 and [date_year] &lt; 1880</Filter>
<PolygonSymbolizer fill="#6a9dba" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1840 and [date_year] &lt; 1860</Filter>
<PolygonSymbolizer fill="#3b74a3" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1820 and [date_year] &lt; 1840</Filter>
<PolygonSymbolizer fill="#56619b" />
<PolygonSymbolizer fill="#95ded8" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1800 and [date_year] &lt; 1820</Filter>
<PolygonSymbolizer fill="#6793b2" />
<PolygonSymbolizer fill="#68aba5" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1780 and [date_year] &lt; 1800</Filter>
<PolygonSymbolizer fill="#83c3b3" />
<Filter>[date_year] &gt;= 1750 and [date_year] &lt; 1800</Filter>
<PolygonSymbolizer fill="#acc98f" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1760 and [date_year] &lt; 1780</Filter>
<PolygonSymbolizer fill="#adc88f" />
<Filter>[date_year] &gt;= 1700 and [date_year] &lt; 1750</Filter>
<PolygonSymbolizer fill="#6d8a51" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1740 and [date_year] &lt; 1760</Filter>
<PolygonSymbolizer fill="#83a663" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1720 and [date_year] &lt; 1740</Filter>
<PolygonSymbolizer fill="#77852d" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1700 and [date_year] &lt; 1720</Filter>
<PolygonSymbolizer fill="#69814e" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1680 and [date_year] &lt; 1700</Filter>
<Filter>[date_year] &lt; 1700</Filter>
<PolygonSymbolizer fill="#d0c291" />
</Rule>
<Rule>
<Filter>[date_year] &gt;= 1660 and [date_year] &lt; 1680</Filter>
<PolygonSymbolizer fill="#918158" />
</Rule>
<Rule>
<Filter>[date_year] &lt; 1660</Filter>
<PolygonSymbolizer fill="#7a5732" />
</Rule>
</Style>
<Style name="conservation_area">
<Rule>
<PolygonSymbolizer fill="#73ebaf" />
</Rule>
</Style>
<Style name="planning_combined">
<Rule>
<Filter>[planning_in_conservation_area] = true</Filter>
<PolygonSymbolizer fill="#95beba"/>
</Rule>
<Rule>
<Filter>[listing_type] = "Grade I Listed"</Filter>
<PolygonSymbolizer fill="#c72e08"/>
</Rule>
<Rule>
<Filter>[listing_type] = "Grade II* Listed"</Filter>
<PolygonSymbolizer fill="#e75b42"/>
</Rule>
<Rule>
<Filter>[listing_type] = "Grade II Listed"</Filter>
<PolygonSymbolizer fill="#ffbea1"/>
</Rule>
<Rule>
<Filter>[listing_type] = "Locally Listed"</Filter>
<PolygonSymbolizer fill="#858ed4"/>
</Rule>
<!-- Conservation area outline -->
<Rule>
<Filter>[listing_type] != "None" and [planning_in_conservation_area] = true</Filter>
<MaxScaleDenominator>34100</MaxScaleDenominator>
<MinScaleDenominator>17061</MinScaleDenominator>
<LineSymbolizer stroke="#95beba" stroke-width="0.5"/>
</Rule>
<Rule>
<Filter>[listing_type] != "None" and [planning_in_conservation_area] = true</Filter>
<MaxScaleDenominator>17061</MaxScaleDenominator>
<MinScaleDenominator>8530</MinScaleDenominator>
<LineSymbolizer stroke="#95beba" stroke-width="1.0"/>
</Rule>
<Rule>
<Filter>[listing_type] != "None" and [planning_in_conservation_area] = true</Filter>
<MaxScaleDenominator>8530</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
<LineSymbolizer stroke="#95beba" stroke-width="2.5"/>
</Rule>
</Style>
<Style name="sust_dec">
<Rule>
<Filter>[sust_dec] = A</Filter>
@ -319,10 +345,6 @@
</Rule>
</Style>
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
<PolygonSymbolizer fill="#52403C" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Recreation And Leisure"</Filter>
<PolygonSymbolizer fill="#ffbfbf" />
@ -351,10 +373,6 @@
<Filter>[current_landuse_order] = "Industry And Business"</Filter>
<PolygonSymbolizer fill="#f5f58f" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Vacant And Derelict"</Filter>
<PolygonSymbolizer fill="#ffffff" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Defence"</Filter>
<PolygonSymbolizer fill="#898944" />

23
app/package-lock.json generated
View File

@ -1161,6 +1161,15 @@
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
"dev": true
},
"@types/lodash.isequal": {
"version": "4.5.5",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.5.tgz",
"integrity": "sha512-4IKbinG7MGP131wRfceK6W4E/Qt3qssEFLF30LnJbjYiSfHGGRU/Io8YxXrZX109ir+iDETC8hw8QsDijukUVg==",
"dev": true,
"requires": {
"@types/lodash": "*"
}
},
"@types/mapbox__sphericalmercator": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/mapbox__sphericalmercator/-/mapbox__sphericalmercator-1.1.3.tgz",
@ -2401,7 +2410,7 @@
},
"babel-plugin-syntax-object-rest-spread": {
"version": "6.13.0",
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
"integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=",
"dev": true
},
@ -9276,6 +9285,11 @@
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
"dev": true
},
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA="
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -9715,7 +9729,7 @@
},
"mkdirp": {
"version": "0.5.1",
"resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"requires": {
"minimist": "0.0.8"
@ -17138,6 +17152,11 @@
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
"dev": true
},
"use-throttle": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/use-throttle/-/use-throttle-0.0.3.tgz",
"integrity": "sha512-cgA3c+pe6V7cZ7pkLnYnWxXJub2AmksY7YTp/xiZzKesQyECJ1slWknVY2CVZOBf48avbZsAvJIDjO+aFu9+pw=="
},
"util": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

View File

@ -5,6 +5,7 @@
"private": true,
"license": "MIT",
"scripts": {
"clean": "rm -rf build",
"start": "razzle start",
"build": "razzle build",
"test": "razzle test --env=jsdom",
@ -22,6 +23,7 @@
"express": "^4.17.1",
"express-session": "^1.17.0",
"leaflet": "^1.6.0",
"lodash.isequal": "^4.5.0",
"mapnik": "^4.2.1",
"node-fs": "^0.1.7",
"nodemailer": "^6.3.0",
@ -33,13 +35,15 @@
"react-leaflet-universal": "^1.2.0",
"react-router-dom": "^5.0.1",
"serialize-javascript": "^2.1.1",
"sharp": "^0.22.1"
"sharp": "^0.22.1",
"use-throttle": "0.0.3"
},
"devDependencies": {
"@types/express": "^4.17.2",
"@types/express-session": "^1.15.16",
"@types/jest": "^24.0.23",
"@types/lodash": "^4.14.149",
"@types/lodash.isequal": "^4.5.5",
"@types/mapbox__sphericalmercator": "^1.1.3",
"@types/node": "^12.12.25",
"@types/nodemailer": "^6.2.2",

View File

@ -1,6 +1,7 @@
import bodyParser from 'body-parser';
import express from 'express';
import autofillController from './controllers/autofillController';
import * as editHistoryController from './controllers/editHistoryController';
import { ApiParamError, ApiUserError } from './errors/api';
import { DatabaseError } from './errors/general';
@ -22,6 +23,7 @@ server.use('/extracts', extractsRouter);
server.use('/leaderboard', leaderboardRouter);
server.get('/history', editHistoryController.getGlobalEditHistory);
server.get('/autofill', autofillController.getAutofillOptions);
// POST user auth
server.post('/login', function (req, res) {
@ -102,8 +104,6 @@ server.use((err: any, req: express.Request, res: express.Response, next: express
}
if (err != undefined) {
console.log('Global error handler: ', err);
if (err instanceof ApiUserError) {
let errorMessage: string;
@ -113,8 +113,13 @@ server.use((err: any, req: express.Request, res: express.Response, next: express
errorMessage = err.message;
}
res.status(400).send({ error: errorMessage });
} else if(err instanceof DatabaseError){
return res.status(400).send({ error: errorMessage });
}
// we need to log the error only if it's not an api user error
console.log('Global error handler: ', err);
if(err instanceof DatabaseError){
res.status(500).send({ error: 'Database error' });
} else {
res.status(500).send({ error: 'Server error' });

View File

@ -0,0 +1,17 @@
import { parseBooleanParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as autofillService from '../services/autofill';
const getAutofillOptions = asyncController(async (req, res) => {
const fieldName = processParam(req.query, 'field_name', x => x, true);
const { field_value: fieldValue } = req.query;
const allValues = processParam(req.query, 'all_values', parseBooleanParam);
const options = await autofillService.getAutofillOptions(fieldName, fieldValue, allValues);
res.send({ options: options });
});
export default {
getAutofillOptions
};

View File

@ -1,5 +1,7 @@
import express from 'express';
import { ApiUserError } from '../errors/api';
import { UserError } from '../errors/general';
import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building';
@ -67,19 +69,17 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
const buildingUpdate = req.body;
let updatedBuilding: object;
try {
const building = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
updatedBuilding = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
} catch(error) {
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
if (typeof (building) === 'undefined') {
return res.send({ error: 'Database error' });
}
if (building.error) {
return res.send(building);
}
res.send(building);
} catch(err) {
res.send({ error: 'Database error' });
}
res.send(updatedBuilding);
}
// GET building UPRNs
@ -137,21 +137,20 @@ const updateBuildingLikeById = asyncController(async (req: express.Request, res:
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body;
let updatedBuilding: object;
try {
const building = like ?
updatedBuilding = like ?
await buildingService.likeBuilding(buildingId, req.session.user_id) :
await buildingService.unlikeBuilding(buildingId, req.session.user_id);
if (building.error) {
return res.send(building);
}
if (typeof (building) === 'undefined') {
return res.send({ error: 'Database error' });
}
res.send(building);
} catch(error) {
res.send({ error: 'Database error' });
if(error instanceof UserError) {
throw new ApiUserError(error.message, error);
}
throw error;
}
res.send(updatedBuilding);
});
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {

View File

@ -22,8 +22,7 @@ const getGlobalEditHistory = asyncController(async (req: express.Request, res: e
res.send(result);
} catch(error) {
if(error instanceof ArgumentError && error.argumentName === 'count') {
const apiErr = new ApiParamError(error.message, 'count');
throw apiErr;
throw new ApiParamError(error.message, error, 'count');
}
throw error;

View File

@ -0,0 +1,82 @@
import { errors, ITask } from 'pg-promise';
import db from '../../db';
import { ArgumentError, DatabaseError } from '../errors/general';
export async function getBuildingData(
buildingId: number,
lockForUpdate: boolean = false,
t?: ITask<any>
) {
let buildingData;
try {
buildingData = await (t || db).one(
`SELECT * FROM buildings WHERE building_id = $1${lockForUpdate ? ' FOR UPDATE' : ''};`,
[buildingId]
);
} catch(error) {
if(
error instanceof errors.QueryResultError &&
error.code === errors.queryResultErrorCode.noData
) {
throw new ArgumentError(`Building ID ${buildingId} does not exist`, 'buildingId');
}
throw new DatabaseError(error);
}
return buildingData;
}
export async function insertEditHistoryRevision(
buildingId: number,
userId: string,
forwardPatch: object,
reversePatch: object,
t?: ITask<any>
): Promise<string> {
try {
const { log_id } = await (t || db).one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`,
[forwardPatch, reversePatch, buildingId, userId]
);
return log_id;
} catch(error) {
throw new DatabaseError(error);
}
}
export async function updateBuildingData(
buildingId: number,
forwardPatch: object,
revisionId: string,
t?: ITask<any>
): Promise<object> {
const sets = db.$config.pgp.helpers.sets(forwardPatch);
console.log('Setting', buildingId, sets);
try {
return await (t || db).one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revisionId, sets, buildingId]
);
} catch(error) {
throw new DatabaseError(error);
}
}

View File

@ -0,0 +1,49 @@
import db from '../../db';
export async function getLandUseGroupFromClass(classes: string[]): Promise<string[]> {
if (classes.length === 0) return [];
return (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_group AS parent
JOIN reference_tables.buildings_landuse_class AS child
ON child.parent_group_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description`,
[classes]
)).map(x => x.description);
}
export async function getLandUseOrderFromGroup(groups: string[]): Promise<string> {
if(groups.length === 0) return null;
const orders = (await db.many(
`
SELECT DISTINCT parent.description
FROM reference_tables.buildings_landuse_order AS parent
JOIN reference_tables.buildings_landuse_group AS child
ON child.parent_order_id = parent.landuse_id
WHERE child.description IN ($1:csv)
ORDER BY parent.description
`,
[groups]
)).map(x => x.description);
if(orders.length === 1) {
return orders[0];
} else if (orders.length > 1) {
return 'Mixed Use';
} else return null;
}
export async function isLandUseGroupAllowed(group: string): Promise<boolean> {
let groupResult = await db.oneOrNone(`
SELECT landuse_id
FROM reference_tables.buildings_landuse_group
WHERE description = $1
`, [group]
);
return (groupResult != undefined);
}

View File

@ -0,0 +1,47 @@
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");
}
}

View File

@ -6,39 +6,34 @@
*/
export class ApiUserError extends Error {
constructor(message?: string) {
public originalError: Error;
constructor(message?: string, originalError?: Error) {
super(message);
this.name = 'ApiUserError';
this.originalError = originalError;
}
}
export class ApiParamError extends ApiUserError {
public paramName: string;
constructor(message?: string, paramName?: string) {
super(message);
constructor(message?: string, originalError?: Error, paramName?: string) {
super(message, originalError);
this.name = 'ApiParamError';
this.paramName = paramName;
}
}
export class ApiParamRequiredError extends ApiParamError {
constructor(message?: string) {
super(message);
constructor(message?: string, originalError?: Error) {
super(message, originalError);
this.name = 'ApiParamRequiredError';
}
}
export class ApiParamOutOfBoundsError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamOutOfBoundsError';
}
}
export class ApiParamInvalidFormatError extends ApiParamError {
constructor(message?: string) {
super(message);
constructor(message?: string, originalError?: Error) {
super(message, originalError);
this.name = 'ApiParamInvalidFormatError';
}
}

View File

@ -1,4 +1,11 @@
export class ArgumentError extends Error {
export class UserError extends Error {
constructor(message?: string) {
super(message);
this.name = 'UserError';
}
}
export class ArgumentError extends UserError {
public argumentName: string;
constructor(message?: string, argumentName?: string) {
super(message);
@ -7,6 +14,13 @@ export class ArgumentError extends Error {
}
}
export class InvalidOperationError extends UserError {
constructor(message?: string) {
super(message);
this.name = 'InvalidOperationError';
}
}
export class DatabaseError extends Error {
public detail: any;
constructor(detail?: string) {

View File

@ -33,6 +33,15 @@ export function parsePositiveIntParam(param: string) {
return result;
}
export function parseBooleanParam(param: string) {
if(param == undefined) return undefined;
if(param === 'true') return true;
if(param === 'false') return false;
throw new ApiParamInvalidFormatError('Invalid format: not a true/false value');
}
export function checkRegexParam(param: string, regex: RegExp): string {
if(param == undefined) return undefined;

View File

@ -0,0 +1,126 @@
import * as _ from 'lodash';
import { ArgumentError } from '../../../errors/general';
import { LandUseState, updateLandUse } from '../../domainLogic/landUse';
const testGroupToOrder = {
'Agriculture': 'Agriculture And Fisheries',
'Fisheries': 'Agriculture And Fisheries',
'Manufacturing': 'Industry And Business',
'Offices': 'Industry And Business'
};
const testAllowedGroups = Object.keys(testGroupToOrder);
jest.mock('../../../dataAccess/landUse', () => ({
getLandUseOrderFromGroup: jest.fn((groups: string[]) => {
const orders = _.chain(groups).map(g => testGroupToOrder[g]).uniq().value();
let result: string;
if(orders.length == 0) result = null;
else if(orders.length == 1) result = orders[0];
else result = 'Mixed Use';
return Promise.resolve(result);
}),
isLandUseGroupAllowed: jest.fn((group: string) => {
return testAllowedGroups.includes(group);
})
}));
describe('updateLandUse()', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([
[{
landUseGroup: [],
landUseOrder: null
}, {
landUseGroup: ['Agriculture']
}, {
landUseGroup: ['Agriculture'],
landUseOrder: 'Agriculture And Fisheries'
}],
[{
landUseGroup: ['Agriculture'],
landUseOrder: 'Agriculture And Fisheries'
}, {
landUseGroup: ['Fisheries']
}, {
landUseGroup: ['Fisheries'],
landUseOrder: 'Agriculture And Fisheries'
}],
[{
landUseGroup: ['Agriculture'],
landUseOrder: 'Agriculture And Fisheries'
}, {
landUseGroup: ['Agriculture', 'Offices'],
}, {
landUseGroup: ['Agriculture', 'Offices'],
landUseOrder: 'Mixed Use'
}]
])('Should derive land use order from group',
async (landUse: LandUseState, landUseUpdate: Partial<LandUseState>, expectedUpdate: LandUseState) => {
const result = await updateLandUse(landUse, landUseUpdate);
expect(result).toEqual(expectedUpdate);
}
);
it.each([
[{
landUseGroup: ['Agriculture'],
landUseOrder: 'Agriculture And Fisheries'
}, {
landUseGroup: []
}, {
landUseGroup: [],
landUseOrder: null
}],
[{
landUseGroup: ['Agriculture', 'Offices'],
landUseOrder: 'Mixed Use',
}, {
landUseGroup: ['Agriculture'],
}, {
landUseGroup: ['Agriculture'],
landUseOrder: 'Agriculture And Fisheries'
}]
])('Should remove derived land use order when land use group is removed',
async (landUse: LandUseState, landUseUpdate: Partial<LandUseState>, expectedUpdate: LandUseState) => {
const result = await updateLandUse(landUse, landUseUpdate);
expect(result).toEqual(expectedUpdate);
}
);
it.each([
[['Blah'], "'Blah' is not a valid Land Use Group"],
[['Agriculture', 'Zonk'], "'Zonk' is not a valid Land Use Group"],
[['Zonk', 'Blah'], "'Zonk' is not a valid Land Use Group"]
])('Should throw ArgumentError when invalid land use group supplied', async (groups: string[], message: string) => {
const resultPromise = updateLandUse({landUseGroup: [], landUseOrder: null}, { landUseGroup: groups});
await expect(resultPromise).rejects.toBeInstanceOf(ArgumentError);
await expect(resultPromise).rejects.toHaveProperty('argumentName', 'landUseUpdate');
await expect(resultPromise).rejects.toHaveProperty('message', message);
});
it('Should throw ArgumentError when duplicate land use groups supplied', async () => {
const resultPromise = updateLandUse(
{landUseGroup: [], landUseOrder: null},
{ landUseGroup: ['Agriculture', 'Agriculture']}
);
await expect(resultPromise).rejects.toBeInstanceOf(ArgumentError);
await expect(resultPromise).rejects.toHaveProperty('argumentName', 'landUseUpdate');
await expect(resultPromise).rejects.toHaveProperty('message', 'Cannot supply duplicate Land Use Groups');
});
});

View File

@ -0,0 +1,56 @@
import db from '../../db';
interface AutofillOption {
id: string;
value: string;
similarity?: number;
}
type GetAutofillOptionsFn = (value: string, all?: boolean) => Promise<AutofillOption[]>;
const autofillFunctionMap : { [fieldName: string] : GetAutofillOptionsFn } = {
current_landuse_group: getLanduseGroupOptions,
};
function getLanduseGroupOptions(value: string, all: boolean = false) {
if(all) {
return db.manyOrNone(`
SELECT
landuse_id AS id,
description AS value
FROM reference_tables.buildings_landuse_group
`
);
}
let query = buildPartialMatchQuery(value);
return db.manyOrNone(`
SELECT
landuse_id AS id,
description AS value,
ts_rank(to_tsvector('simple', description), to_tsquery('simple', $1)) AS similarity
FROM reference_tables.buildings_landuse_group
WHERE to_tsvector('simple', description) @@ to_tsquery('simple', $1)
ORDER BY similarity DESC, description
`, [query]
);
}
function buildPartialMatchQuery(value: string) {
return tokenizeValue(value).map(x => `${x}:*`).join(' & ');
}
function tokenizeValue(value: string) {
return value.split(/[^\w]+/).filter(x => x !== '');
}
export function getAutofillOptions(fieldName: string, fieldValue: any, allValues: boolean) {
const optionsFn = autofillFunctionMap[fieldName];
if (optionsFn == undefined) {
throw new Error(`Autofill options not available for field '${fieldName}'`);
}
return optionsFn(fieldValue, allValues);
}

View File

@ -8,6 +8,9 @@ import { ITask } from 'pg-promise';
import db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types';
import * as buildingDataAccess from '../dataAccess/building';
import * as likeDataAccess from '../dataAccess/like';
import { UserError } from '../errors/general';
import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
@ -155,8 +158,7 @@ async function getBuildingUPRNsById(id: number) {
}
}
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
try {
async function saveBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
return await updateBuildingData(buildingId, userId, async () => {
const processedBuilding = await processBuildingUpdate(buildingId, building);
@ -168,70 +170,38 @@ async function saveBuilding(buildingId: number, building: any, userId: string) {
// return whitelisted fields to update
return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST);
});
} catch(error) {
console.error(error);
return { error: error };
}
}
async function likeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
return {
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
};
},
async (t) => {
// insert building-user like
await t.none(
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
[buildingId, userId]
);
(t) => {
return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
},
);
} catch (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined;
}
}
}
async function unlikeBuilding(buildingId: number, userId: string) {
try {
return await updateBuildingData(
buildingId,
userId,
async (t) => {
// return total like count after update
return getBuildingLikeCount(buildingId, t);
return {
likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
};
},
async (t) => {
// remove building-user like
const result = await t.result(
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
[buildingId, userId]
);
if (result.rowCount === 0) {
throw new Error('No change');
}
return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
},
);
} catch(error) {
console.error(error);
if (error.message === 'No change') {
// 'No change' is thrown if user doesn't like this building
return { error: 'It looks like you have already revoked your like for that building!' };
} else {
return undefined;
}
}
}
// === Utility functions ===
@ -246,18 +216,6 @@ function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
return subObject;
}
/**
*
* @param buildingId ID of the building to count likes for
* @param t The database context inside which the count should happen
*/
function getBuildingLikeCount(buildingId: number, t: ITask<unknown>) {
return t.one(
'SELECT count(*) as likes_total FROM building_user_likes WHERE building_id = $1;',
[buildingId]
);
}
/**
* Carry out an update of the buildings data. Allows for running any custom database operations before the main update.
* All db hooks get passed a transaction.
@ -271,7 +229,7 @@ async function updateBuildingData(
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);
@ -279,49 +237,23 @@ async function updateBuildingData(
const update = await getUpdateValue(t);
const oldBuilding = await t.one(
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
);
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 'No change provided';
throw new UserError('No change provided');
}
const revision = await t.one(
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`,
[forward, reverse, buildingId, userId]
);
const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
const sets = db.$config.pgp.helpers.sets(forward);
console.log('Setting', buildingId, sets);
const data = await t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
);
const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
expireBuildingTileCache(buildingId);
return data;
return updatedData;
});
}
@ -408,7 +340,7 @@ const BUILDING_FIELD_WHITELIST = new Set([
'building_attachment_form',
'date_change_building_use',
'current_landuse_class',
// 'current_landuse_class',
'current_landuse_group',
'current_landuse_order'
]);

View File

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

View File

@ -0,0 +1,35 @@
import _ from 'lodash';
import * as landUseDataAccess from '../../dataAccess/landUse';
import { ArgumentError } from '../../errors/general';
export interface LandUseState {
landUseGroup: string[];
landUseOrder: string;
}
export async function updateLandUse(landUse: LandUseState, landUseUpdate: Partial<LandUseState>): Promise<LandUseState> {
const landUseGroupUpdate = landUseUpdate.landUseGroup;
for(let group of landUseGroupUpdate) {
const isGroupValid = await landUseDataAccess.isLandUseGroupAllowed(group);
if(!isGroupValid) {
throw new ArgumentError(`'${group}' is not a valid Land Use Group`, 'landUseUpdate');
}
}
if(hasDuplicates(landUseGroupUpdate)) {
throw new ArgumentError('Cannot supply duplicate Land Use Groups', 'landUseUpdate');
}
const landUseOrderUpdate = await landUseDataAccess.getLandUseOrderFromGroup(landUseGroupUpdate);
return {
landUseGroup: landUseGroupUpdate,
landUseOrder: landUseOrderUpdate
};
}
function hasDuplicates(array: any[]) {
return (new Set(array).size !== array.length);
}

View File

@ -1,11 +1,40 @@
import * as _ from 'lodash';
import { hasAnyOwnProperty } from '../../../helpers';
import { ArgumentError } from '../../errors/general';
import { getCurrentBuildingDataById } from '../building';
import { processCurrentLandUseClassifications } from './currentLandUseClassifications';
import { updateLandUse } from './landUse';
export async function processBuildingUpdate(buildingId: number, building: any): Promise<any> {
if(hasAnyOwnProperty(building, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) {
building = await processCurrentLandUseClassifications(buildingId, building);
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
}
return building;
return buildingUpdate;
}
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
const currentBuildingData = await getCurrentBuildingDataById(buildingId);
try {
const currentLandUseUpdate = await updateLandUse(
{
landUseGroup: currentBuildingData.current_landuse_group,
landUseOrder: currentBuildingData.current_landuse_order
}, {
landUseGroup: buildingUpdate.current_landuse_group
}
);
return Object.assign({}, buildingUpdate, {
current_landuse_group: currentLandUseUpdate.landUseGroup,
current_landuse_order: currentLandUseUpdate.landUseOrder,
});
} catch (error) {
if(error instanceof ArgumentError && error.argumentName === 'landUseUpdate') {
error.argumentName = 'buildingUpdate';
}
throw error;
}
}

View File

@ -0,0 +1,26 @@
.autofill-dropdown {
position: absolute;
top: 100%;
z-index: 1000;
max-height: 6em;
width: 100%;
overflow-x: hidden;
overflow-y: scroll;
box-sizing: content-box;
padding: 0.2em;
border: 1px solid #ddd;
background-color: white;
box-shadow: 2px 2px 2px gray;
}
.autofill-dropdown div {
padding-left: 0.3em;
}
.autofill-dropdown div:hover {
background-color: aliceblue;
cursor: pointer;
}

View File

@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { useThrottle } from 'use-throttle';
import './autofill-dropdown.css';
import { apiGet } from '../../../apiHelpers';
interface AutofillDropdownProps {
fieldName: string;
fieldValue: string;
showAllOptionsOnEmpty?: boolean;
editing: boolean;
onSelect: (val: string) => void;
onClose: () => void;
}
interface AutofillOption {
id: string;
value: string;
similarity: number;
}
export const AutofillDropdown: React.FC<AutofillDropdownProps> = props => {
const [options, setOptions] = useState<AutofillOption[]>(null);
// use both throttled and debounced field value as trigger for update
const throttledFieldValue = useThrottle(props.fieldValue, 1000);
useEffect(() => {
const doAsync = async () => {
if (!props.editing) return setOptions(null);
let valueParam: string;
if(props.fieldValue == null) {
if(!props.showAllOptionsOnEmpty) {
if(options == null) return setOptions(null);
} else {
valueParam = 'all_values=true';
}
} else {
valueParam = `field_value=${props.fieldValue}`;
}
const url = `/api/autofill?field_name=${props.fieldName}&${valueParam}`;
const { options: newOptions } = await apiGet(url);
if (!props.editing) return;
setOptions(newOptions);
};
doAsync();
}, [props.editing, props.fieldName, throttledFieldValue]);
if (!props.editing || options == undefined) return null;
return (
<div className="autofill-dropdown">
{
options.map(option =>
<div
key={option.id}
onMouseDown={e => /* prevent input blur */ e.preventDefault()}
onClick={e => {
props.onSelect(option.value);
// close dropdown manually rather than through input blur
props.onClose();
}}
>
{option.value} ({option.id})
</div>)
}
</div>
);
};

View File

@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { AutofillDropdown } from './autofill-dropdown/autofill-dropdown';
export interface TextDataEntryInputProps {
slug: string;
name?: string;
id?: string;
onChange?: (key: string, val: any) => void;
onConfirm?: (key: string, val: any) => void;
maxLength?: number;
disabled?: boolean;
placeholder?: string;
valueTransform?: (val: string) => string;
confirmOnEnter?: boolean;
autofill?: boolean;
showAllOptionsOnEmpty?: boolean;
confirmOnAutofillSelect?: boolean;
}
export const DataEntryInput: React.FC<TextDataEntryInputProps & {value?: string}> = props => {
const [isEditing, setEditing] = useState(false);
const nameAttr = props.name || props.slug;
const idAttr = props.id || props.slug;
const transformValue = (value: string) => {
const transform = props.valueTransform || (x => x);
const transformedValue = value === '' ?
null :
transform(value);
return transformedValue;
};
const handleChange = (value: string) => {
props.onChange?.(props.slug, transformValue(value));
};
const handleConfirm = () => {
props.onConfirm?.(props.slug, props.value);
};
const handleAutofillSelect = (value: string) => {
const transformedValue = transformValue(value);
if(props.confirmOnAutofillSelect) {
props.onConfirm?.(props.slug, transformedValue);
} else {
props.onChange?.(props.slug, transformedValue);
}
};
return (
<>
<input className="form-control" type="text"
id={idAttr}
name={nameAttr}
value={props.value || ''}
maxLength={props.maxLength}
disabled={props.disabled}
placeholder={props.placeholder}
onKeyDown={e => {
if(e.keyCode === 13) {
// prevent form submit on enter
e.preventDefault();
if(props.confirmOnEnter) {
handleConfirm();
}
}
}}
onChange={e => handleChange(e.target.value)}
onInput={e => setEditing(true)}
onFocus={e => setEditing(true)}
onBlur={e => setEditing(false)}
/>
{
props.autofill &&
<AutofillDropdown
editing={isEditing}
onSelect={handleAutofillSelect}
onClose={() => setEditing(false)}
fieldName={props.slug}
fieldValue={props.value}
showAllOptionsOnEmpty={props.showAllOptionsOnEmpty}
/>
}
</>
);
};

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
import { CopyProps } from '../data-containers/category-view-props';
import { DataEntryInput, TextDataEntryInputProps } from './data-entry-input';
import { DataTitleCopyable } from './data-title';
interface BaseDataEntryProps {
@ -14,14 +15,11 @@ interface BaseDataEntryProps {
onChange?: (key: string, value: any) => void;
}
interface DataEntryProps extends BaseDataEntryProps {
interface DataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps {
value?: string;
maxLength?: number;
placeholder?: string;
valueTransform?: (string) => string;
}
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
const DataEntry: React.FC<DataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -31,20 +29,15 @@ const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
disabled={props.disabled || props.value == undefined || props.value == ''}
copy={props.copy}
/>
<input className="form-control" type="text"
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.maxLength}
<DataEntryInput
slug={props.slug}
value={props.value}
onChange={props.onChange}
disabled={props.mode === 'view' || props.disabled}
maxLength={props.maxLength}
placeholder={props.placeholder}
onChange={e => {
const transform = props.valueTransform || (x => x);
const val = e.target.value === '' ?
null :
transform(e.target.value);
props.onChange(props.slug, val);
}}
valueTransform={props.valueTransform}
/>
</Fragment>
);

View File

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

View File

@ -0,0 +1,5 @@
.input-group .no-entries {
border-style: dashed;
border-color: #aaa;
margin-bottom: 0.4em;
}

View File

@ -0,0 +1,162 @@
import React, { Component, Fragment } from 'react';
import './multi-data-entry.css';
import { BaseDataEntryProps } from '../data-entry';
import { DataEntryInput, TextDataEntryInputProps } from '../data-entry-input';
import { DataTitleCopyable } from '../data-title';
interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps {
value: string[];
editableEntries: boolean;
confirmOnEnter: boolean;
addOnAutofillSelect: boolean;
acceptAutofillValuesOnly: boolean;
}
interface MultiDataEntryState {
newValue: string;
}
class MultiDataEntry extends Component<MultiDataEntryProps, MultiDataEntryState> {
static defaultProps = {
editableEntries: false,
confirmOnEnter: true,
addOnAutofillSelect: false,
acceptAutofillValuesOnly: false
};
constructor(props) {
super(props);
this.state = {
newValue: null
};
this.setNewValue = this.setNewValue.bind(this);
this.edit = this.edit.bind(this);
this.addNew = this.addNew.bind(this);
this.remove = this.remove.bind(this);
}
getValues() {
return this.props.value == undefined ? [] : this.props.value;
}
cloneValues() {
return this.getValues().slice();
}
setNewValue(value: string) {
this.setState({newValue: value});
}
edit(index: number, value: string) {
let values = this.cloneValues();
values.splice(index, 1, value);
this.props.onChange(this.props.slug, values);
}
addNew(newValue?: string) {
// accept a newValue parameter to handle cases where the value is set and submitted at the same time
// (like with autofill select enabled) - but otherwise use the current newValue saved in state
const val = newValue ?? this.state.newValue;
if (val == undefined) return;
const values = this.cloneValues().concat(val);
this.setState({newValue: null});
this.props.onChange(this.props.slug, values);
}
remove(index: number){
const values = this.cloneValues();
values.splice(index, 1);
this.props.onChange(this.props.slug, values);
}
render() {
const values = this.getValues();
const props = this.props;
const isEditing = props.mode === 'edit';
const isDisabled = !isEditing || props.disabled;
return <Fragment>
<DataTitleCopyable
slug={props.slug}
title={props.title}
tooltip={props.tooltip}
disabled={props.disabled || props.value == undefined || props.value.length === 0}
/>
<div id={`${props.slug}-wrapper`}>
<ul className="data-link-list">
{
values.length === 0 && !isEditing &&
<div className="input-group">
<input className="form-control no-entries" type="text" value="No entries" disabled={true} />
</div>
}
{
values.map((val, i) => (
<li className="input-group" key={i /* i as key prevents input component recreation on edit */}>
<DataEntryInput
slug={props.slug}
name={`${props.slug}-${i}`}
id={`${props.slug}-${i}`}
value={val}
disabled={!props.editableEntries || isDisabled}
onChange={(key, val) => this.edit(i, val)}
maxLength={props.maxLength}
valueTransform={props.valueTransform}
autofill={props.autofill}
showAllOptionsOnEmpty={props.showAllOptionsOnEmpty}
/>
{
!isDisabled &&
<div className="input-group-append">
<button type="button" onClick={e => this.remove(i)}
title="Remove"
data-index={i} className="btn btn-outline-dark"></button>
</div>
}
</li>
))
}
</ul>
{
!isDisabled &&
<div className="input-group">
<DataEntryInput
slug={props.slug}
name={`${props.slug}`}
id={`${props.slug}`}
value={this.state.newValue}
disabled={props.disabled}
onChange={(key, val) => this.setNewValue(val)}
onConfirm={(key, val) => this.addNew(val)}
maxLength={props.maxLength}
placeholder={props.placeholder}
valueTransform={props.valueTransform}
confirmOnEnter={props.confirmOnEnter}
autofill={props.autofill}
showAllOptionsOnEmpty={props.showAllOptionsOnEmpty}
confirmOnAutofillSelect={true}
/>
<div className="input-group-append">
<button type="button"
className="btn btn-outline-dark"
title="Add to list"
onClick={() => this.addNew()}
disabled={this.state.newValue == undefined}
>+</button>
</div>
</div>
}
</div>
</Fragment>;
}
}
export default MultiDataEntry;

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields';
import MultiDataEntry from '../data-components/multi-data-entry';
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';
@ -82,6 +82,7 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
onChange={props.onChange}
tooltip={dataFields.date_link.tooltip}
placeholder="https://..."
editableEntries={true}
/>
</Fragment>
);

View File

@ -1,5 +1,6 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import DataEntry from '../data-components/data-entry';
@ -22,7 +23,59 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
copy={props.copy}
onChange={props.onChange}
/>
<DataEntryGroup name="Listing and protections" >
<DataEntryGroup name="Planning Status">
<CheckboxDataEntry
title="Is a planning application live for this site?"
slug="planning_live_application"
value={null}
disabled={true}
/>
<CheckboxDataEntry
title={dataFields.planning_demolition_proposed.title}
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<CheckboxDataEntry
title="Has this application recently been been approved/refused?"
slug="planning_recent_outcome"
value={null}
disabled={true}
/>
<CheckboxDataEntry
title="Has the work been carried out?"
slug="planning_carried_out"
value={null}
disabled={true}
/>
<InfoBox msg="For historical planning applications see Planning Portal link" />
{/*
Move to Demolition:
<CheckboxDataEntry
title={dataFields.planning_demolition_complete.title}
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title={dataFields.planning_demolition_history.title}
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
*/}
</DataEntryGroup>
<DataEntryGroup name="Designation and Protection" collapsed={false} >
<CheckboxDataEntry
title={dataFields.planning_in_conservation_area.title}
slug="planning_in_conservation_area"
@ -44,6 +97,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
slug="planning_in_list"
value={props.building.planning_in_list}
mode={props.mode}
disabled={true}
copy={props.copy}
onChange={props.onChange}
/>
@ -52,6 +106,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
slug="planning_list_id"
value={props.building.planning_list_id}
mode={props.mode}
disabled={true}
copy={props.copy}
onChange={props.onChange}
/>
@ -60,6 +115,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
slug="planning_list_cat"
value={props.building.planning_list_cat}
mode={props.mode}
disabled={true}
copy={props.copy}
onChange={props.onChange}
options={[
@ -75,6 +131,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
slug="planning_list_grade"
value={props.building.planning_list_grade}
mode={props.mode}
disabled={true}
copy={props.copy}
onChange={props.onChange}
options={[
@ -173,35 +230,6 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
onChange={props.onChange}
/>
</DataEntryGroup>
<DataEntryGroup name="Demolition and demolition history">
<CheckboxDataEntry
title={dataFields.planning_demolition_proposed.title}
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<CheckboxDataEntry
title={dataFields.planning_demolition_complete.title}
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<DataEntry
title={dataFields.planning_demolition_history.title}
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
</DataEntryGroup>
</Fragment>
);
const PlanningContainer = withCopyEdit(PlanningView);

View File

@ -3,7 +3,7 @@ import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry';
import MultiDataEntry from '../data-components/multi-data-entry';
import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
@ -13,32 +13,31 @@ import { CategoryViewProps } from './category-view-props';
*/
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<InfoBox msg="This category is currently read-only. We are working on enabling its editing soon." />
<MultiDataEntry
title={dataFields.current_landuse_class.title}
slug="current_landuse_class"
value={props.building.current_landuse_class}
mode="view"
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use class..."
/>
<MultiDataEntry
title={dataFields.current_landuse_group.title}
slug="current_landuse_group"
value={props.building.current_landuse_group}
mode="view"
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip}
placeholder="New land use group..."
confirmOnEnter={true}
tooltip={dataFields.current_landuse_group.tooltip}
placeholder="Type new land use group here"
autofill={true}
showAllOptionsOnEmpty={true}
addOnAutofillSelect={true}
/>
{
props.mode != 'view' &&
<InfoBox msg="Land use order is automatically derived from the land use groups"></InfoBox>
}
<DataEntry
title={dataFields.current_landuse_order.title}
tooltip={dataFields.current_landuse_order.tooltip}
slug="current_landuse_order"
value={props.building.current_landuse_order}
mode="view"
mode={props.mode}
disabled={true}
copy={props.copy}
onChange={props.onChange}
/>

View File

@ -21,3 +21,7 @@
.tooltip .arrow {
right: 7px;
}
.tooltip a {
color: rgb(255, 11, 245);
}

View File

@ -12,28 +12,64 @@ interface TooltipState {
active: boolean;
}
const nonCaptureLingRegex = /\[[^[]+?\]\([^(]+?\)/;
const linkRegex = /\[([^[]+?)\]\(([^(]+?)\)/;
function markdownLinkToAnchor(link: string) {
const m = link.match(linkRegex);
return (<a href={m[2]} target="_blank">{m[1]}</a>);
}
function interweave(arr1: any[], arr2: any[]): any[] {
const commonLen = Math.min(arr1.length, arr2.length);
const arr = [];
for(let i=0; i<commonLen; i++) {
arr.push(arr1[i], arr2[i]);
}
arr.push(...arr1.slice(commonLen), ...arr2.slice(commonLen));
return arr;
}
function tooltipTextToComponents(text: string): any[] {
let betweenLinks = text.split(nonCaptureLingRegex);
if(betweenLinks.length <= 1) return [text];
let links = text.match(new RegExp(linkRegex, 'g')).map(markdownLinkToAnchor);
return interweave(betweenLinks, links);
}
class Tooltip extends Component<TooltipProps, TooltipState> {
constructor(props) {
super(props);
this.state = {
active: false
};
this.handleClick = this.handleClick.bind(this);
this.toggleVisible = this.toggleVisible.bind(this);
this.handleBlur = this.handleBlur.bind(this);
}
handleClick(event) {
event.preventDefault();
toggleVisible() {
this.setState(state => ({
active: !state.active
}));
}
handleBlur(event) {
if(!event.currentTarget.contains(event.relatedTarget)) {
this.setState({
active: !this.state.active
active: false
});
}
}
render() {
return (
<div className="tooltip-wrap">
<button className={(this.state.active? 'active ': '') + 'tooltip-hint icon-button'}
title={this.props.text}
onClick={this.handleClick}>
<div className="tooltip-wrap" tabIndex={0} onBlur={this.handleBlur}>
<button type="button" className={(this.state.active? 'active ': '') + 'tooltip-hint icon-button'}
onClick={this.toggleVisible}>
Hint
<InfoIcon />
</button>
@ -42,7 +78,9 @@ class Tooltip extends Component<TooltipProps, TooltipState> {
(
<div className="tooltip bs-tooltip-bottom">
<div className="arrow"></div>
<div className="tooltip-inner">{this.props.text}</div>
<div className="tooltip-inner">
{tooltipTextToComponents(this.props.text)}
</div>
</div>
)
: null

View File

@ -153,17 +153,15 @@ export const dataFields = {
title: "Longitude",
},
current_landuse_class: {
category: Category.LandUse,
title: "Current Land Use Class"
},
current_landuse_group: {
category: Category.LandUse,
title: "Current Land Use Group"
title: "Current Land Use (Group)",
tooltip: "Land use Groups as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)"
},
current_landuse_order: {
category: Category.LandUse,
title: "Current Land Use Order"
title: "Current Land Use (Order)",
tooltip: "Land use Order as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)"
},
building_attachment_form: {

View File

@ -1,3 +1,4 @@
import isEqual from 'lodash.isequal';
import urlapi from 'url';
function sanitiseURL(string){
@ -66,7 +67,7 @@ function compareObjects(objA: object, objB: object): [object, object] {
const reverse = {};
const forward = {};
for (const [key, value] of Object.entries(objB)) {
if (objA[key] !== value) {
if (!isEqual(objA[key], value)) {
reverse[key] = objA[key];
forward[key] = value;
}

View File

@ -7,7 +7,8 @@
right: 10px;
z-index: 1000;
min-width: 12rem;
min-width: 14rem;
max-width: 14rem;
max-height: 60%;
display: flex;
@ -63,6 +64,11 @@
.map-legend p {
margin: 0.25rem 0 0.5rem;
}
.map-legend .legend-disclaimer {
font-size: 0.8em;
padding:0;
}
.data-legend {
overflow: auto;
list-style: none;

View File

@ -23,14 +23,12 @@ const LEGEND_CONFIG = {
{ color: '#4a54a6', text: 'Residential' },
{ color: '#e5050d', text: 'Mixed Use' },
{ color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry And Business' },
{ color: '#f5f58f', text: 'Industry & Business' },
{ color: '#73ccd1', text: 'Community Services' },
{ color: '#ffbfbf', text: 'Recreation And Leisure' },
{ color: '#bee8ff', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities And Infrastructure' },
{ color: '#52403C', text: 'Agriculture And Fisheries' },
{ color: '#ffbfbf', text: 'Recreation & Leisure' },
{ color: '#b3de69', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities & Infrastructure' },
{ color: '#898944', text: 'Defence' },
{ color: '#ffffff', text: 'Vacant And Derelict' },
]
},
type: {
@ -45,25 +43,21 @@ const LEGEND_CONFIG = {
age: {
title: 'Age',
elements: [
{ color: '#f0eaba', text: '≥2000' },
{ color: '#fae269', text: '19802000' },
{ color: '#fbaf27', text: '19601980' },
{ color: '#e6711d', text: '19401960' },
{ color: '#d73d3a', text: '19201940' },
{ color: '#ba221c', text: '19001920' },
{ color: '#bb859b', text: '18801900' },
{ color: '#8b3654', text: '18601880' },
{ color: '#8f5385', text: '18401860' },
{ color: '#56619b', text: '18201840' },
{ color: '#6793b2', text: '18001820' },
{ color: '#83c3b3', text: '17801800' },
{ color: '#adc88f', text: '17601780' },
{ color: '#83a663', text: '17401760' },
{ color: '#77852d', text: '17201740' },
{ color: '#69814e', text: '17001720' },
{ color: '#d0c291', text: '16801700' },
{ color: '#918158', text: '16601680' },
{ color: '#7a5732', text: '<1660' },
{ color: '#fff9b8', text: '>2020' },
{ color: '#fae269', text: '2000-2019' },
{ color: '#fbaf27', text: '1980-1999' },
{ color: '#e6711d', text: '1960-1979' },
{ color: '#cc1212', text: '1940-1959' },
{ color: '#8f0303', text: '1920-1939' },
{ color: '#8f5385', text: '1900-1919' },
{ color: '#c3e1eb', text: '1880-1899' },
{ color: '#6a9dba', text: '1860-1879' },
{ color: '#3b74a3', text: '1840-1859' },
{ color: '#95ded8', text: '1820-1839' },
{ color: '#68aba5', text: '1800-1819' },
{ color: '#acc98f', text: '1750-1799' },
{ color: '#6d8a51', text: '1700-1749' },
{ color: '#d0c291', text: '<1700' },
]
},
size: {
@ -114,9 +108,14 @@ const LEGEND_CONFIG = {
elements: []
},
planning: {
title: 'Planning',
title: 'Statutory protections',
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: '#73ebaf', text: 'within conservation area' },
{ color: '#95beba', text: 'In conservation area'},
{ color: '#c72e08', text: 'Grade I listed'},
{ color: '#e75b42', text: 'Grade II* listed'},
{ color: '#ffbea1', text: 'Grade II listed'},
{ color: '#858ed4', text: 'Locally listed'},
]
},
community: {
@ -211,11 +210,15 @@ class Legend extends React.Component<LegendProps, LegendState> {
{
elements.length?
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
{
details.disclaimer &&
<p className='legend-disclaimer'>{details.disclaimer}</p>
}
{
elements.map((item) => (
<li key={item.color} >
<span className="key" style={ { background: item.color } }>-</span>
<span className="key" style={ { background: item.color, border: item.border } }>-</span>
{ item.text }
</li>

View File

@ -134,7 +134,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
construction: 'construction_core_material',
location: 'location',
like: 'likes',
planning: 'conservation_area',
planning: 'planning_combined',
sustainability: 'sust_dec',
type: 'building_attachment_form',
use: 'landuse'

View File

@ -96,6 +96,27 @@ const BUILDING_LAYER_DEFINITIONS = {
g.geometry_id = b.geometry_id
AND b.likes_total > 0
) as likes`,
planning_combined: `(
SELECT
g.geometry_geom,
(
CASE
WHEN b.planning_list_cat = 'Listed Building' and b.planning_list_grade = 'I' THEN 'Grade I Listed'
WHEN b.planning_list_cat = 'Listed Building' and b.planning_list_grade = 'II*' THEN 'Grade II* Listed'
WHEN b.planning_list_cat = 'Listed Building' and b.planning_list_grade = 'II' THEN 'Grade II Listed'
WHEN b.planning_in_local_list THEN 'Locally Listed'
ELSE 'None'
END
) as listing_type,
b.planning_in_conservation_area
FROM geometries as g
JOIN buildings as b
ON g.geometry_id = b.geometry_id
WHERE
b.planning_in_conservation_area
OR b.planning_in_local_list
OR b.planning_list_cat is not null
) as planning_combined`,
conservation_area: `(
SELECT
g.geometry_geom

View File

@ -13,5 +13,9 @@
"files": [
"./src/index.ts",
"./src/client.tsx"
],
"include": [
"./src/**/*.test.*",
"./src/**/*.spec.*"
]
}

View File

@ -41,7 +41,6 @@ This is the main table, containing almost all data collected by Colouring London
- `location_postcode`: postcode
- `location_latitude`: latitude
- `location_longitude`: longitude
- `current_landuse_class`: current land use class
- `current_landuse_group`: current land use group
- `current_landuse_order`: current land use order
- `building_attachment_form`: building attachment form

View File

@ -11,7 +11,6 @@ COPY (SELECT
location_postcode,
location_latitude,
location_longitude,
current_landuse_class,
current_landuse_group,
current_landuse_order,
building_attachment_form,