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>
|
</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
23
app/package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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' });
|
||||||
|
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 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) => {
|
||||||
|
@ -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;
|
||||||
|
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 {
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
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 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'
|
||||||
]);
|
]);
|
||||||
|
@ -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 { 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 { 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>
|
||||||
);
|
);
|
||||||
|
@ -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 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>
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -21,3 +21,7 @@
|
|||||||
.tooltip .arrow {
|
.tooltip .arrow {
|
||||||
right: 7px;
|
right: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tooltip a {
|
||||||
|
color: rgb(255, 11, 245);
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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: {
|
||||||
|
@ -13,5 +13,9 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"./src/index.ts",
|
"./src/index.ts",
|
||||||
"./src/client.tsx"
|
"./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_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
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user