Merge pull request #546 from mz8i/feature/513-activate-land-use
Feature 513: activate land use - make editable [WIP]
This commit is contained in:
commit
773bb993ea
@ -285,10 +285,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" />
|
||||
@ -317,10 +313,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
23
app/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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';
|
||||
@ -21,6 +22,7 @@ server.use('/users', usersRouter);
|
||||
server.use('/extracts', extractsRouter);
|
||||
|
||||
server.get('/history', editHistoryController.getGlobalEditHistory);
|
||||
server.get('/autofill', autofillController.getAutofillOptions);
|
||||
|
||||
// POST user auth
|
||||
server.post('/login', function (req, res) {
|
||||
@ -101,8 +103,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;
|
||||
|
||||
@ -112,8 +112,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' });
|
||||
|
17
app/src/api/controllers/autofillController.ts
Normal file
17
app/src/api/controllers/autofillController.ts
Normal 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
|
||||
};
|
@ -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) => {
|
||||
|
@ -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;
|
||||
|
82
app/src/api/dataAccess/building.ts
Normal file
82
app/src/api/dataAccess/building.ts
Normal 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);
|
||||
}
|
||||
}
|
49
app/src/api/dataAccess/landUse.ts
Normal file
49
app/src/api/dataAccess/landUse.ts
Normal 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);
|
||||
}
|
47
app/src/api/dataAccess/like.ts
Normal file
47
app/src/api/dataAccess/like.ts
Normal 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");
|
||||
}
|
||||
}
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
||||
|
126
app/src/api/services/__tests__/domainLogic/landUse.test.ts
Normal file
126
app/src/api/services/__tests__/domainLogic/landUse.test.ts
Normal 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');
|
||||
});
|
||||
|
||||
});
|
56
app/src/api/services/autofill.ts
Normal file
56
app/src/api/services/autofill.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -405,7 +337,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'
|
||||
]);
|
||||
|
@ -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;
|
||||
}
|
35
app/src/api/services/domainLogic/landUse.ts
Normal file
35
app/src/api/services/domainLogic/landUse.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
||||
};
|
@ -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}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
@ -0,0 +1,5 @@
|
||||
.input-group .no-entries {
|
||||
border-style: dashed;
|
||||
border-color: #aaa;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -21,3 +21,7 @@
|
||||
.tooltip .arrow {
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
.tooltip a {
|
||||
color: rgb(255, 11, 245);
|
||||
}
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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: '#ffbfbf', text: 'Recreation & Leisure' },
|
||||
{ color: '#b3de69', text: 'Transport' },
|
||||
{ color: '#cccccc', text: 'Utilities And Infrastructure' },
|
||||
{ color: '#52403C', text: 'Agriculture And Fisheries' },
|
||||
{ color: '#cccccc', text: 'Utilities & Infrastructure' },
|
||||
{ color: '#898944', text: 'Defence' },
|
||||
{ color: '#ffffff', text: 'Vacant And Derelict' },
|
||||
]
|
||||
},
|
||||
type: {
|
||||
|
@ -13,5 +13,9 @@
|
||||
"files": [
|
||||
"./src/index.ts",
|
||||
"./src/client.tsx"
|
||||
],
|
||||
"include": [
|
||||
"./src/**/*.test.*",
|
||||
"./src/**/*.spec.*"
|
||||
]
|
||||
}
|
@ -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
|
||||
|
@ -11,7 +11,6 @@ COPY (SELECT
|
||||
location_postcode,
|
||||
location_latitude,
|
||||
location_longitude,
|
||||
current_landuse_class,
|
||||
current_landuse_group,
|
||||
current_landuse_order,
|
||||
building_attachment_form,
|
||||
|
Loading…
Reference in New Issue
Block a user