Merge pull request #546 from mz8i/feature/513-activate-land-use

Feature 513: activate land use - make editable [WIP]
This commit is contained in:
Tom Russell 2020-04-09 11:15:24 +01:00 committed by GitHub
commit 773bb993ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1035 additions and 440 deletions

View File

@ -285,10 +285,6 @@
</Rule> </Rule>
</Style> </Style>
<Style name="landuse"> <Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
<PolygonSymbolizer fill="#52403C" />
</Rule>
<Rule> <Rule>
<Filter>[current_landuse_order] = "Recreation And Leisure"</Filter> <Filter>[current_landuse_order] = "Recreation And Leisure"</Filter>
<PolygonSymbolizer fill="#ffbfbf" /> <PolygonSymbolizer fill="#ffbfbf" />
@ -317,10 +313,6 @@
<Filter>[current_landuse_order] = "Industry And Business"</Filter> <Filter>[current_landuse_order] = "Industry And Business"</Filter>
<PolygonSymbolizer fill="#f5f58f" /> <PolygonSymbolizer fill="#f5f58f" />
</Rule> </Rule>
<Rule>
<Filter>[current_landuse_order] = "Vacant And Derelict"</Filter>
<PolygonSymbolizer fill="#ffffff" />
</Rule>
<Rule> <Rule>
<Filter>[current_landuse_order] = "Defence"</Filter> <Filter>[current_landuse_order] = "Defence"</Filter>
<PolygonSymbolizer fill="#898944" /> <PolygonSymbolizer fill="#898944" />

23
app/package-lock.json generated
View File

@ -1161,6 +1161,15 @@
"integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==",
"dev": true "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": { "@types/mapbox__sphericalmercator": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/mapbox__sphericalmercator/-/mapbox__sphericalmercator-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/mapbox__sphericalmercator/-/mapbox__sphericalmercator-1.1.3.tgz",
@ -2401,7 +2410,7 @@
}, },
"babel-plugin-syntax-object-rest-spread": { "babel-plugin-syntax-object-rest-spread": {
"version": "6.13.0", "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=", "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=",
"dev": true "dev": true
}, },
@ -9276,6 +9285,11 @@
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
"dev": true "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": { "lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -9715,7 +9729,7 @@
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.1", "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=", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"requires": { "requires": {
"minimist": "0.0.8" "minimist": "0.0.8"
@ -17138,6 +17152,11 @@
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
"dev": true "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": { "util": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",

View File

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

View File

@ -1,6 +1,7 @@
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import express from 'express'; import express from 'express';
import autofillController from './controllers/autofillController';
import * as editHistoryController from './controllers/editHistoryController'; import * as editHistoryController from './controllers/editHistoryController';
import { ApiParamError, ApiUserError } from './errors/api'; import { ApiParamError, ApiUserError } from './errors/api';
import { DatabaseError } from './errors/general'; import { DatabaseError } from './errors/general';
@ -21,6 +22,7 @@ server.use('/users', usersRouter);
server.use('/extracts', extractsRouter); server.use('/extracts', extractsRouter);
server.get('/history', editHistoryController.getGlobalEditHistory); server.get('/history', editHistoryController.getGlobalEditHistory);
server.get('/autofill', autofillController.getAutofillOptions);
// POST user auth // POST user auth
server.post('/login', function (req, res) { server.post('/login', function (req, res) {
@ -101,19 +103,22 @@ server.use((err: any, req: express.Request, res: express.Response, next: express
} }
if (err != undefined) { if (err != undefined) {
console.log('Global error handler: ', err);
if (err instanceof ApiUserError) { if (err instanceof ApiUserError) {
let errorMessage: string; let errorMessage: string;
if(err instanceof ApiParamError) { if(err instanceof ApiParamError) {
errorMessage = `Problem with parameter ${err.paramName}: ${err.message}`; errorMessage = `Problem with parameter ${err.paramName}: ${err.message}`;
} else { } else {
errorMessage = err.message; errorMessage = err.message;
} }
res.status(400).send({ error: errorMessage }); return res.status(400).send({ error: errorMessage });
} else if(err instanceof DatabaseError){ }
// 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' }); res.status(500).send({ error: 'Database error' });
} else { } else {
res.status(500).send({ error: 'Server error' }); 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 express from 'express';
import { ApiUserError } from '../errors/api';
import { UserError } from '../errors/general';
import { parsePositiveIntParam, processParam } from '../parameters'; import { parsePositiveIntParam, processParam } from '../parameters';
import asyncController from '../routes/asyncController'; import asyncController from '../routes/asyncController';
import * as buildingService from '../services/building'; import * as buildingService from '../services/building';
@ -67,19 +69,17 @@ async function updateBuilding(req: express.Request, res: express.Response, userI
const buildingUpdate = req.body; const buildingUpdate = req.body;
let updatedBuilding: object;
try { try {
const building = await buildingService.saveBuilding(buildingId, buildingUpdate, userId); updatedBuilding = await buildingService.saveBuilding(buildingId, buildingUpdate, userId);
} catch(error) {
if (typeof (building) === 'undefined') { if(error instanceof UserError) {
return res.send({ error: 'Database error' }); throw new ApiUserError(error.message, error);
} }
if (building.error) { throw error;
return res.send(building);
}
res.send(building);
} catch(err) {
res.send({ error: 'Database error' });
} }
res.send(updatedBuilding);
} }
// GET building UPRNs // 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 buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
const { like } = req.body; const { like } = req.body;
let updatedBuilding: object;
try { try {
const building = like ? updatedBuilding = like ?
await buildingService.likeBuilding(buildingId, req.session.user_id) : await buildingService.likeBuilding(buildingId, req.session.user_id) :
await buildingService.unlikeBuilding(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) { } 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) => { 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); res.send(result);
} catch(error) { } catch(error) {
if(error instanceof ArgumentError && error.argumentName === 'count') { if(error instanceof ArgumentError && error.argumentName === 'count') {
const apiErr = new ApiParamError(error.message, 'count'); throw new ApiParamError(error.message, error, 'count');
throw apiErr;
} }
throw error; 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 { export class ApiUserError extends Error {
constructor(message?: string) { public originalError: Error;
constructor(message?: string, originalError?: Error) {
super(message); super(message);
this.name = 'ApiUserError'; this.name = 'ApiUserError';
this.originalError = originalError;
} }
} }
export class ApiParamError extends ApiUserError { export class ApiParamError extends ApiUserError {
public paramName: string; public paramName: string;
constructor(message?: string, paramName?: string) { constructor(message?: string, originalError?: Error, paramName?: string) {
super(message); super(message, originalError);
this.name = 'ApiParamError'; this.name = 'ApiParamError';
this.paramName = paramName; this.paramName = paramName;
} }
} }
export class ApiParamRequiredError extends ApiParamError { export class ApiParamRequiredError extends ApiParamError {
constructor(message?: string) { constructor(message?: string, originalError?: Error) {
super(message); super(message, originalError);
this.name = 'ApiParamRequiredError'; this.name = 'ApiParamRequiredError';
} }
} }
export class ApiParamOutOfBoundsError extends ApiParamError {
constructor(message?: string) {
super(message);
this.name = 'ApiParamOutOfBoundsError';
}
}
export class ApiParamInvalidFormatError extends ApiParamError { export class ApiParamInvalidFormatError extends ApiParamError {
constructor(message?: string) { constructor(message?: string, originalError?: Error) {
super(message); super(message, originalError);
this.name = 'ApiParamInvalidFormatError'; 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; public argumentName: string;
constructor(message?: string, argumentName?: string) { constructor(message?: string, argumentName?: string) {
super(message); 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 { export class DatabaseError extends Error {
public detail: any; public detail: any;
constructor(detail?: string) { constructor(detail?: string) {

View File

@ -33,6 +33,15 @@ export function parsePositiveIntParam(param: string) {
return result; 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 { export function checkRegexParam(param: string, regex: RegExp): string {
if(param == undefined) return undefined; 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 db from '../../db';
import { tileCache } from '../../tiles/rendererDefinition'; import { tileCache } from '../../tiles/rendererDefinition';
import { BoundingBox } from '../../tiles/types'; 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'; import { processBuildingUpdate } from './domainLogic/processBuildingUpdate';
@ -155,83 +158,50 @@ async function getBuildingUPRNsById(id: number) {
} }
} }
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type async function saveBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
try { return await updateBuildingData(buildingId, userId, async () => {
return await updateBuildingData(buildingId, userId, async () => { const processedBuilding = await processBuildingUpdate(buildingId, building);
const processedBuilding = await processBuildingUpdate(buildingId, building);
// remove read-only fields from consideration
// remove read-only fields from consideration delete processedBuilding.building_id;
delete processedBuilding.building_id; delete processedBuilding.revision_id;
delete processedBuilding.revision_id; delete processedBuilding.geometry_id;
delete processedBuilding.geometry_id;
// return whitelisted fields to update // return whitelisted fields to update
return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST); return pickAttributesToUpdate(processedBuilding, BUILDING_FIELD_WHITELIST);
}); });
} catch(error) {
console.error(error);
return { error: error };
}
} }
async function likeBuilding(buildingId: number, userId: string) { async function likeBuilding(buildingId: number, userId: string) {
try { return await updateBuildingData(
return await updateBuildingData( buildingId,
buildingId, userId,
userId, async (t) => {
async (t) => { // return total like count after update
// return total like count after update return {
return getBuildingLikeCount(buildingId, t); likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
}, };
async (t) => { },
// insert building-user like (t) => {
await t.none( return likeDataAccess.addBuildingUserLike(buildingId, userId, t);
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);', },
[buildingId, userId] );
);
},
);
} catch (error) {
console.error(error);
if (error.detail && error.detail.includes('already exists')) {
// 'already exists' is thrown if user already liked it
return { error: 'It looks like you already like that building!' };
} else {
return undefined;
}
}
} }
async function unlikeBuilding(buildingId: number, userId: string) { async function unlikeBuilding(buildingId: number, userId: string) {
try { return await updateBuildingData(
return await updateBuildingData( buildingId,
buildingId, userId,
userId, async (t) => {
async (t) => { // return total like count after update
// return total like count after update return {
return getBuildingLikeCount(buildingId, t); likes_total: await likeDataAccess.getBuildingLikeCount(buildingId, t)
}, };
async (t) => { },
// remove building-user like async (t) => {
const result = await t.result( return likeDataAccess.removeBuildingUserLike(buildingId, userId, t);
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;', },
[buildingId, userId] );
);
if (result.rowCount === 0) {
throw new Error('No change');
}
},
);
} catch(error) {
console.error(error);
if (error.message === 'No change') {
// 'No change' is thrown if user doesn't like this building
return { error: 'It looks like you have already revoked your like for that building!' };
} else {
return undefined;
}
}
} }
// === Utility functions === // === Utility functions ===
@ -246,18 +216,6 @@ function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
return subObject; 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. * 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. * All db hooks get passed a transaction.
@ -271,7 +229,7 @@ async function updateBuildingData(
userId: string, userId: string,
getUpdateValue: (t: ITask<any>) => Promise<object>, getUpdateValue: (t: ITask<any>) => Promise<object>,
preUpdateDbAction?: (t: ITask<any>) => Promise<void>, preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
) { ): Promise<object> {
return await db.tx({mode: serializable}, async t => { return await db.tx({mode: serializable}, async t => {
if (preUpdateDbAction != undefined) { if (preUpdateDbAction != undefined) {
await preUpdateDbAction(t); await preUpdateDbAction(t);
@ -279,49 +237,23 @@ async function updateBuildingData(
const update = await getUpdateValue(t); const update = await getUpdateValue(t);
const oldBuilding = await t.one( const oldBuilding = await buildingDataAccess.getBuildingData(buildingId, true, t);
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
[buildingId]
);
console.log(update); console.log(update);
const patches = compare(oldBuilding, update); const patches = compare(oldBuilding, update);
console.log('Patching', buildingId, patches); console.log('Patching', buildingId, patches);
const [forward, reverse] = patches; const [forward, reverse] = patches;
if (Object.keys(forward).length === 0) { if (Object.keys(forward).length === 0) {
throw 'No change provided'; throw new UserError('No change provided');
} }
const revision = await t.one( const revisionId = await buildingDataAccess.insertEditHistoryRevision(buildingId, userId, forward, reverse, t);
`INSERT INTO logs (
forward_patch, reverse_patch, building_id, user_id
) VALUES (
$1:json, $2:json, $3, $4
) RETURNING log_id
`,
[forward, reverse, buildingId, userId]
);
const sets = db.$config.pgp.helpers.sets(forward); const updatedData = await buildingDataAccess.updateBuildingData(buildingId, forward, revisionId, t);
console.log('Setting', buildingId, sets);
const data = await t.one(
`UPDATE
buildings
SET
revision_id = $1,
$2:raw
WHERE
building_id = $3
RETURNING
*
`,
[revision.log_id, sets, buildingId]
);
expireBuildingTileCache(buildingId); expireBuildingTileCache(buildingId);
return data; return updatedData;
}); });
} }
@ -405,7 +337,7 @@ const BUILDING_FIELD_WHITELIST = new Set([
'building_attachment_form', 'building_attachment_form',
'date_change_building_use', 'date_change_building_use',
'current_landuse_class', // 'current_landuse_class',
'current_landuse_group', 'current_landuse_group',
'current_landuse_order' '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 { 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> { export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
if(hasAnyOwnProperty(building, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) { if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
building = await processCurrentLandUseClassifications(buildingId, building); 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 { CopyProps } from '../data-containers/category-view-props';
import { DataEntryInput, TextDataEntryInputProps } from './data-entry-input';
import { DataTitleCopyable } from './data-title'; import { DataTitleCopyable } from './data-title';
interface BaseDataEntryProps { interface BaseDataEntryProps {
@ -14,14 +15,11 @@ interface BaseDataEntryProps {
onChange?: (key: string, value: any) => void; onChange?: (key: string, value: any) => void;
} }
interface DataEntryProps extends BaseDataEntryProps { interface DataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps {
value?: string; value?: string;
maxLength?: number;
placeholder?: string;
valueTransform?: (string) => string;
} }
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => { const DataEntry: React.FC<DataEntryProps> = (props) => {
return ( return (
<Fragment> <Fragment>
<DataTitleCopyable <DataTitleCopyable
@ -31,20 +29,15 @@ const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
disabled={props.disabled || props.value == undefined || props.value == ''} disabled={props.disabled || props.value == undefined || props.value == ''}
copy={props.copy} copy={props.copy}
/> />
<input className="form-control" type="text" <DataEntryInput
id={props.slug} slug={props.slug}
name={props.slug} value={props.value}
value={props.value || ''} onChange={props.onChange}
maxLength={props.maxLength}
disabled={props.mode === 'view' || props.disabled} disabled={props.mode === 'view' || props.disabled}
maxLength={props.maxLength}
placeholder={props.placeholder} placeholder={props.placeholder}
onChange={e => { valueTransform={props.valueTransform}
const transform = props.valueTransform || (x => x);
const val = e.target.value === '' ?
null :
transform(e.target.value);
props.onChange(props.slug, val);
}}
/> />
</Fragment> </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 React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; 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 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';
@ -82,6 +82,7 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => {
onChange={props.onChange} onChange={props.onChange}
tooltip={dataFields.date_link.tooltip} tooltip={dataFields.date_link.tooltip}
placeholder="https://..." placeholder="https://..."
editableEntries={true}
/> />
</Fragment> </Fragment>
); );

View File

@ -3,7 +3,7 @@ import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box'; import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../data_fields';
import DataEntry from '../data-components/data-entry'; 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 withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props'; import { CategoryViewProps } from './category-view-props';
@ -13,32 +13,31 @@ import { CategoryViewProps } from './category-view-props';
*/ */
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => ( const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment> <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 <MultiDataEntry
title={dataFields.current_landuse_group.title} title={dataFields.current_landuse_group.title}
slug="current_landuse_group" slug="current_landuse_group"
value={props.building.current_landuse_group} value={props.building.current_landuse_group}
mode="view" mode={props.mode}
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
// tooltip={dataFields.current_landuse_class.tooltip} confirmOnEnter={true}
placeholder="New land use group..." 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 <DataEntry
title={dataFields.current_landuse_order.title} title={dataFields.current_landuse_order.title}
tooltip={dataFields.current_landuse_order.tooltip}
slug="current_landuse_order" slug="current_landuse_order"
value={props.building.current_landuse_order} value={props.building.current_landuse_order}
mode="view" mode={props.mode}
disabled={true}
copy={props.copy} copy={props.copy}
onChange={props.onChange} onChange={props.onChange}
/> />

View File

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

View File

@ -12,28 +12,64 @@ interface TooltipState {
active: boolean; 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> { class Tooltip extends Component<TooltipProps, TooltipState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
active: false 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({ this.setState(state => ({
active: !this.state.active active: !state.active
}); }));
}
handleBlur(event) {
if(!event.currentTarget.contains(event.relatedTarget)) {
this.setState({
active: false
});
}
} }
render() { render() {
return ( return (
<div className="tooltip-wrap"> <div className="tooltip-wrap" tabIndex={0} onBlur={this.handleBlur}>
<button className={(this.state.active? 'active ': '') + 'tooltip-hint icon-button'} <button type="button" className={(this.state.active? 'active ': '') + 'tooltip-hint icon-button'}
title={this.props.text} onClick={this.toggleVisible}>
onClick={this.handleClick}>
Hint Hint
<InfoIcon /> <InfoIcon />
</button> </button>
@ -42,7 +78,9 @@ class Tooltip extends Component<TooltipProps, TooltipState> {
( (
<div className="tooltip bs-tooltip-bottom"> <div className="tooltip bs-tooltip-bottom">
<div className="arrow"></div> <div className="arrow"></div>
<div className="tooltip-inner">{this.props.text}</div> <div className="tooltip-inner">
{tooltipTextToComponents(this.props.text)}
</div>
</div> </div>
) )
: null : null

View File

@ -153,17 +153,15 @@ export const dataFields = {
title: "Longitude", title: "Longitude",
}, },
current_landuse_class: {
category: Category.LandUse,
title: "Current Land Use Class"
},
current_landuse_group: { current_landuse_group: {
category: Category.LandUse, 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: { current_landuse_order: {
category: Category.LandUse, 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: { building_attachment_form: {

View File

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

View File

@ -23,14 +23,12 @@ const LEGEND_CONFIG = {
{ color: '#4a54a6', text: 'Residential' }, { color: '#4a54a6', text: 'Residential' },
{ color: '#e5050d', text: 'Mixed Use' }, { color: '#e5050d', text: 'Mixed Use' },
{ color: '#ff8c00', text: 'Retail' }, { color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry And Business' }, { color: '#f5f58f', text: 'Industry & Business' },
{ color: '#73ccd1', text: 'Community Services' }, { color: '#73ccd1', text: 'Community Services' },
{ color: '#ffbfbf', text: 'Recreation And Leisure' }, { color: '#ffbfbf', text: 'Recreation & Leisure' },
{ color: '#b3de69', text: 'Transport' }, { color: '#b3de69', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities And Infrastructure' }, { color: '#cccccc', text: 'Utilities & Infrastructure' },
{ color: '#52403C', text: 'Agriculture And Fisheries' },
{ color: '#898944', text: 'Defence' }, { color: '#898944', text: 'Defence' },
{ color: '#ffffff', text: 'Vacant And Derelict' },
] ]
}, },
type: { type: {

View File

@ -13,5 +13,9 @@
"files": [ "files": [
"./src/index.ts", "./src/index.ts",
"./src/client.tsx" "./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_postcode`: postcode
- `location_latitude`: latitude - `location_latitude`: latitude
- `location_longitude`: longitude - `location_longitude`: longitude
- `current_landuse_class`: current land use class
- `current_landuse_group`: current land use group - `current_landuse_group`: current land use group
- `current_landuse_order`: current land use order - `current_landuse_order`: current land use order
- `building_attachment_form`: building attachment form - `building_attachment_form`: building attachment form

View File

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