Merge branch 'master' into fix/375-attribution
This commit is contained in:
commit
59ec2f7ecb
@ -222,28 +222,32 @@
|
||||
</Style>
|
||||
<Style name="likes">
|
||||
<Rule>
|
||||
<Filter>[likes] >= 10</Filter>
|
||||
<Filter>[likes] >= 100</Filter>
|
||||
<PolygonSymbolizer fill="#bd0026" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 5 and [likes] < 10</Filter>
|
||||
<Filter>[likes] >= 50 and [likes] < 100</Filter>
|
||||
<PolygonSymbolizer fill="#e31a1c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 4 and [likes] < 5</Filter>
|
||||
<Filter>[likes] >= 20 and [likes] < 50</Filter>
|
||||
<PolygonSymbolizer fill="#fc4e2a" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 3 and [likes] < 4</Filter>
|
||||
<Filter>[likes] >= 10 and [likes] < 20</Filter>
|
||||
<PolygonSymbolizer fill="#fd8d3c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] >= 2 and [likes] < 3</Filter>
|
||||
<Filter>[likes] >= 3 and [likes] < 10</Filter>
|
||||
<PolygonSymbolizer fill="#feb24c" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] < 2</Filter>
|
||||
<Filter>[likes] = 2</Filter>
|
||||
<PolygonSymbolizer fill="#fed976" />
|
||||
</Rule>
|
||||
<Rule>
|
||||
<Filter>[likes] = 1</Filter>
|
||||
<PolygonSymbolizer fill="#ffe8a9" />
|
||||
</Rule>
|
||||
</Style>
|
||||
</Map>
|
||||
|
7
app/public/geometries/boundary-detailed.geojson
Normal file
7
app/public/geometries/boundary-detailed.geojson
Normal file
File diff suppressed because one or more lines are too long
8
app/public/geometries/boundary.geojson
Normal file
8
app/public/geometries/boundary.geojson
Normal file
File diff suppressed because one or more lines are too long
BIN
app/public/images/logo-cl-square.png
Normal file
BIN
app/public/images/logo-cl-square.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.7 KiB |
@ -1,147 +1,159 @@
|
||||
import express from 'express';
|
||||
import * as buildingService from '../services/building';
|
||||
import * as userService from '../services/user';
|
||||
import asyncController from '../routes/asyncController';
|
||||
|
||||
|
||||
// GET buildings
|
||||
// not implemented - may be useful to GET all buildings, paginated
|
||||
|
||||
// GET buildings at point
|
||||
function getBuildingsByLocation(req, res) {
|
||||
const getBuildingsByLocation = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const { lng, lat } = req.query;
|
||||
buildingService.queryBuildingsAtPoint(lng, lat).then(function (result) {
|
||||
try {
|
||||
const result = await buildingService.queryBuildingsAtPoint(lng, lat);
|
||||
res.send(result);
|
||||
}).catch(function (error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' })
|
||||
})
|
||||
}
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET buildings by reference (UPRN/TOID or other identifier)
|
||||
function getBuildingsByReference(req, res) {
|
||||
const getBuildingsByReference = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const { key, id } = req.query;
|
||||
buildingService.queryBuildingsByReference(key, id).then(function (result) {
|
||||
try {
|
||||
const result = await buildingService.queryBuildingsByReference(key, id);
|
||||
res.send(result);
|
||||
}).catch(function (error) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' })
|
||||
})
|
||||
}
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET individual building, POST building updates
|
||||
function getBuildingById(req, res) {
|
||||
const getBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const { building_id } = req.params;
|
||||
buildingService.getBuildingById(building_id).then(function (result) {
|
||||
try {
|
||||
const result = await buildingService.getBuildingById(building_id);
|
||||
res.send(result);
|
||||
}).catch(function (error) {
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' })
|
||||
})
|
||||
}
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
function updateBuildingById(req, res) {
|
||||
const updateBuildingById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
if (req.session.user_id) {
|
||||
updateBuilding(req, res, req.session.user_id);
|
||||
await updateBuilding(req, res, req.session.user_id);
|
||||
} else if (req.query.api_key) {
|
||||
userService.authAPIUser(req.query.api_key)
|
||||
.then(function (user) {
|
||||
updateBuilding(req, res, user.user_id)
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error(err);
|
||||
res.send({ error: 'Must be logged in' });
|
||||
});
|
||||
try {
|
||||
const user = await userService.authAPIUser(req.query.api_key);
|
||||
await updateBuilding(req, res, user.user_id);
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
} else {
|
||||
res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateBuilding(req, res, userId) {
|
||||
async function updateBuilding(req: express.Request, res: express.Response, userId: string) {
|
||||
const { building_id } = req.params;
|
||||
const building = req.body;
|
||||
buildingService.saveBuilding(building_id, building, userId).then(building => {
|
||||
if (building.error) {
|
||||
res.send(building)
|
||||
return
|
||||
}
|
||||
const buildingUpdate = req.body;
|
||||
|
||||
try {
|
||||
const building = await buildingService.saveBuilding(building_id, buildingUpdate, userId);
|
||||
|
||||
if (typeof (building) === 'undefined') {
|
||||
res.send({ error: 'Database error' })
|
||||
return
|
||||
return res.send({ error: 'Database error' });
|
||||
}
|
||||
res.send(building)
|
||||
}).catch(
|
||||
() => res.send({ error: 'Database error' })
|
||||
)
|
||||
if (building.error) {
|
||||
return res.send(building);
|
||||
}
|
||||
res.send(building);
|
||||
} catch(err) {
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
}
|
||||
|
||||
// GET building UPRNs
|
||||
function getBuildingUPRNsById(req, res) {
|
||||
const getBuildingUPRNsById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const { building_id } = req.params;
|
||||
buildingService.getBuildingUPRNsById(building_id).then(function (result) {
|
||||
try {
|
||||
const result = await buildingService.getBuildingUPRNsById(building_id);
|
||||
|
||||
if (typeof (result) === 'undefined') {
|
||||
res.send({ error: 'Database error' })
|
||||
return
|
||||
return res.send({ error: 'Database error' });
|
||||
}
|
||||
res.send({
|
||||
uprns: result
|
||||
});
|
||||
}).catch(function (error) {
|
||||
res.send({uprns: result});
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' })
|
||||
})
|
||||
}
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET/POST like building
|
||||
function getBuildingLikeById(req, res) {
|
||||
const getBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
if (!req.session.user_id) {
|
||||
res.send({ like: false }); // not logged in, so cannot have liked
|
||||
return
|
||||
return res.send({ like: false }); // not logged in, so cannot have liked
|
||||
}
|
||||
const { building_id } = req.params;
|
||||
buildingService.getBuildingLikeById(building_id, req.session.user_id).then(like => {
|
||||
// any value returned means like
|
||||
res.send({ like: like })
|
||||
}).catch(
|
||||
() => res.send({ error: 'Database error' })
|
||||
)
|
||||
}
|
||||
try {
|
||||
const like = await buildingService.getBuildingLikeById(building_id, req.session.user_id);
|
||||
|
||||
function updateBuildingLikeById(req, res) {
|
||||
if (!req.session.user_id) {
|
||||
res.send({ error: 'Must be logged in' });
|
||||
return
|
||||
// any value returned means like
|
||||
res.send({ like: like });
|
||||
} catch(error) {
|
||||
res.send({ error: 'Database error' })
|
||||
}
|
||||
});
|
||||
|
||||
const getBuildingEditHistoryById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const { building_id } = req.params;
|
||||
try {
|
||||
const editHistory = await buildingService.getBuildingEditHistory(building_id);
|
||||
|
||||
res.send({ history: editHistory });
|
||||
} catch(error) {
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
const updateBuildingLikeById = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
if (!req.session.user_id) {
|
||||
return res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
|
||||
const { building_id } = req.params;
|
||||
const { like } = req.body;
|
||||
if (like) {
|
||||
buildingService.likeBuilding(building_id, req.session.user_id).then(building => {
|
||||
if (building.error) {
|
||||
res.send(building)
|
||||
return
|
||||
}
|
||||
if (typeof (building) === 'undefined') {
|
||||
res.send({ error: 'Database error' })
|
||||
return
|
||||
}
|
||||
res.send(building)
|
||||
}).catch(
|
||||
() => res.send({ error: 'Database error' })
|
||||
)
|
||||
} else {
|
||||
buildingService.unlikeBuilding(building_id, req.session.user_id).then(building => {
|
||||
if (building.error) {
|
||||
res.send(building)
|
||||
return
|
||||
}
|
||||
if (typeof (building) === 'undefined') {
|
||||
res.send({ error: 'Database error' })
|
||||
return
|
||||
}
|
||||
res.send(building)
|
||||
}).catch(
|
||||
() => res.send({ error: 'Database error' })
|
||||
)
|
||||
|
||||
try {
|
||||
const building = like ?
|
||||
await buildingService.likeBuilding(building_id, req.session.user_id) :
|
||||
await buildingService.unlikeBuilding(building_id, 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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getLatestRevisionId = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const revisionId = await buildingService.getLatestRevisionId();
|
||||
res.send({latestRevisionId: revisionId});
|
||||
} catch(error) {
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
getBuildingsByLocation,
|
||||
@ -150,5 +162,7 @@ export default {
|
||||
updateBuildingById,
|
||||
getBuildingUPRNsById,
|
||||
getBuildingLikeById,
|
||||
updateBuildingLikeById
|
||||
};
|
||||
updateBuildingLikeById,
|
||||
getBuildingEditHistoryById,
|
||||
getLatestRevisionId
|
||||
};
|
||||
|
@ -8,23 +8,22 @@ import { TokenVerificationError } from '../services/passwordReset';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import { ValidationError } from '../validation';
|
||||
|
||||
function createUser(req, res) {
|
||||
const createUser = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
const user = req.body;
|
||||
if (req.session.user_id) {
|
||||
res.send({ error: 'Already signed in' });
|
||||
return;
|
||||
return res.send({ error: 'Already signed in' });
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
if (user.email != user.confirm_email) {
|
||||
res.send({ error: 'Email did not match confirmation.' });
|
||||
return;
|
||||
return res.send({ error: 'Email did not match confirmation.' });
|
||||
}
|
||||
} else {
|
||||
user.email = null;
|
||||
}
|
||||
|
||||
userService.createUser(user).then(function (result) {
|
||||
try {
|
||||
const result = await userService.createUser(user);
|
||||
if (result.user_id) {
|
||||
req.session.user_id = result.user_id;
|
||||
res.send({ user_id: result.user_id });
|
||||
@ -32,39 +31,40 @@ function createUser(req, res) {
|
||||
req.session.user_id = undefined;
|
||||
res.send({ error: result.error });
|
||||
}
|
||||
}).catch(function (err) {
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
res.send(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getCurrentUser(req, res) {
|
||||
const getCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
if (!req.session.user_id) {
|
||||
res.send({ error: 'Must be logged in' });
|
||||
return;
|
||||
return res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
|
||||
userService.getUserById(req.session.user_id).then(function (user) {
|
||||
try {
|
||||
const user = await userService.getUserById(req.session.user_id);
|
||||
res.send(user);
|
||||
}).catch(function (error) {
|
||||
} catch(error) {
|
||||
res.send(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function deleteCurrentUser(req, res) {
|
||||
const deleteCurrentUser = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
if (!req.session.user_id) {
|
||||
return res.send({ error: 'Must be logged in' });
|
||||
}
|
||||
console.log(`Deleting user ${req.session.user_id}`);
|
||||
|
||||
userService.deleteUser(req.session.user_id).then(
|
||||
() => userService.logout(req.session)
|
||||
).then(() => {
|
||||
try {
|
||||
await userService.deleteUser(req.session.user_id);
|
||||
await userService.logout(req.session);
|
||||
|
||||
res.send({ success: true });
|
||||
}).catch(err => {
|
||||
} catch(err) {
|
||||
res.send({ error: err });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const resetPassword = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
|
||||
|
@ -14,6 +14,8 @@ router.get('/locate', buildingController.getBuildingsByLocation);
|
||||
// GET buildings by reference (UPRN/TOID or other identifier)
|
||||
router.get('/reference', buildingController.getBuildingsByReference);
|
||||
|
||||
router.get('/revision', buildingController.getLatestRevisionId);
|
||||
|
||||
router.route('/:building_id.json')
|
||||
// GET individual building
|
||||
.get(buildingController.getBuildingById)
|
||||
@ -29,4 +31,7 @@ router.route('/:building_id/like.json')
|
||||
.get(buildingController.getBuildingLikeById)
|
||||
.post(buildingController.updateBuildingLikeById);
|
||||
|
||||
export default router;
|
||||
router.route('/:building_id/history.json')
|
||||
.get(buildingController.getBuildingEditHistoryById);
|
||||
|
||||
export default router;
|
||||
|
@ -5,6 +5,7 @@
|
||||
import db from '../../db';
|
||||
import { tileCache } from '../../tiles/rendererDefinition';
|
||||
import { BoundingBox } from '../../tiles/types';
|
||||
import { ITask } from 'pg-promise';
|
||||
|
||||
// data type note: PostgreSQL bigint (64-bit) is handled as string in JavaScript, because of
|
||||
// JavaScript numerics are 64-bit double, giving only partial coverage.
|
||||
@ -18,282 +19,303 @@ const serializable = new TransactionMode({
|
||||
readOnly: false
|
||||
});
|
||||
|
||||
function queryBuildingsAtPoint(lng, lat) {
|
||||
return db.manyOrNone(
|
||||
`SELECT b.*
|
||||
FROM buildings as b, geometries as g
|
||||
WHERE
|
||||
b.geometry_id = g.geometry_id
|
||||
AND
|
||||
ST_Intersects(
|
||||
ST_Transform(
|
||||
ST_SetSRID(ST_Point($1, $2), 4326),
|
||||
3857
|
||||
),
|
||||
geometry_geom
|
||||
)
|
||||
`,
|
||||
[lng, lat]
|
||||
).catch(function (error) {
|
||||
console.error(error);
|
||||
async function getLatestRevisionId() {
|
||||
try {
|
||||
const data = await db.oneOrNone(
|
||||
`SELECT MAX(log_id) from logs`
|
||||
);
|
||||
return data == undefined ? undefined : data.max;
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queryBuildingsByReference(key, id) {
|
||||
if (key === 'toid') {
|
||||
return db.manyOrNone(
|
||||
`SELECT
|
||||
*
|
||||
FROM
|
||||
buildings
|
||||
|
||||
async function queryBuildingsAtPoint(lng: number, lat: number) {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
`SELECT b.*
|
||||
FROM buildings as b, geometries as g
|
||||
WHERE
|
||||
ref_toid = $1
|
||||
`,
|
||||
[id]
|
||||
).catch(function (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
if (key === 'uprn') {
|
||||
return db.manyOrNone(
|
||||
`SELECT
|
||||
b.*
|
||||
FROM
|
||||
buildings as b, building_properties as p
|
||||
WHERE
|
||||
b.building_id = p.building_id
|
||||
b.geometry_id = g.geometry_id
|
||||
AND
|
||||
p.uprn = $1
|
||||
ST_Intersects(
|
||||
ST_Transform(
|
||||
ST_SetSRID(ST_Point($1, $2), 4326),
|
||||
3857
|
||||
),
|
||||
geometry_geom
|
||||
)
|
||||
`,
|
||||
[id]
|
||||
).catch(function (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
[lng, lat]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
}
|
||||
return Promise.resolve({ error: 'Key must be UPRN or TOID' });
|
||||
}
|
||||
|
||||
function getBuildingById(id) {
|
||||
return db.one(
|
||||
'SELECT * FROM buildings WHERE building_id = $1',
|
||||
[id]
|
||||
).then((building) => {
|
||||
return getBuildingEditHistory(id).then((edit_history) => {
|
||||
building.edit_history = edit_history
|
||||
return building
|
||||
})
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function getBuildingEditHistory(id) {
|
||||
return db.manyOrNone(
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
|
||||
FROM logs, users
|
||||
WHERE building_id = $1 AND logs.user_id = users.user_id`,
|
||||
[id]
|
||||
).then((data) => {
|
||||
return data
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
return []
|
||||
});
|
||||
}
|
||||
|
||||
function getBuildingLikeById(buildingId, userId) {
|
||||
return db.oneOrNone(
|
||||
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
||||
[buildingId, userId]
|
||||
).then(res => {
|
||||
return res && res.like
|
||||
}).catch(function (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function getBuildingUPRNsById(id) {
|
||||
return db.any(
|
||||
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
||||
[id]
|
||||
).catch(function (error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
function saveBuilding(buildingId, building, userId) {
|
||||
// remove read-only fields from consideration
|
||||
delete building.building_id;
|
||||
delete building.revision_id;
|
||||
delete building.geometry_id;
|
||||
|
||||
// start transaction around save operation
|
||||
// - select and compare to identify changeset
|
||||
// - insert changeset
|
||||
// - update to latest state
|
||||
// commit or rollback (repeated-read sufficient? or serializable?)
|
||||
return db.tx(t => {
|
||||
return t.one(
|
||||
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
|
||||
[buildingId]
|
||||
).then(oldBuilding => {
|
||||
const patches = compare(oldBuilding, building, BUILDING_FIELD_WHITELIST);
|
||||
console.log('Patching', buildingId, patches)
|
||||
const forward = patches[0];
|
||||
const reverse = patches[1];
|
||||
if (Object.keys(forward).length === 0) {
|
||||
return Promise.reject('No change provided')
|
||||
}
|
||||
return t.one(
|
||||
`INSERT INTO logs (
|
||||
forward_patch, reverse_patch, building_id, user_id
|
||||
) VALUES (
|
||||
$1:json, $2:json, $3, $4
|
||||
) RETURNING log_id
|
||||
async function queryBuildingsByReference(key: string, ref: string) {
|
||||
try {
|
||||
if (key === 'toid') {
|
||||
return await db.manyOrNone(
|
||||
`SELECT
|
||||
*
|
||||
FROM
|
||||
buildings
|
||||
WHERE
|
||||
ref_toid = $1
|
||||
`,
|
||||
[forward, reverse, buildingId, userId]
|
||||
).then(revision => {
|
||||
const sets = db.$config.pgp.helpers.sets(forward);
|
||||
console.log('Setting', buildingId, sets)
|
||||
return t.one(
|
||||
`UPDATE
|
||||
buildings
|
||||
SET
|
||||
revision_id = $1,
|
||||
$2:raw
|
||||
WHERE
|
||||
building_id = $3
|
||||
RETURNING
|
||||
*
|
||||
`,
|
||||
[revision.log_id, sets, buildingId]
|
||||
).then((data) => {
|
||||
expireBuildingTileCache(buildingId)
|
||||
return data
|
||||
})
|
||||
});
|
||||
[ref]
|
||||
);
|
||||
} else if (key === 'uprn') {
|
||||
return await db.manyOrNone(
|
||||
`SELECT
|
||||
b.*
|
||||
FROM
|
||||
buildings as b, building_properties as p
|
||||
WHERE
|
||||
b.building_id = p.building_id
|
||||
AND
|
||||
p.uprn = $1
|
||||
`,
|
||||
[ref]
|
||||
);
|
||||
} else {
|
||||
return { error: 'Key must be UPRN or TOID' };
|
||||
}
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBuildingById(id: number) {
|
||||
try {
|
||||
const building = await db.one(
|
||||
'SELECT * FROM buildings WHERE building_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
building.edit_history = await getBuildingEditHistory(id);
|
||||
|
||||
return building;
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBuildingEditHistory(id: number) {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username
|
||||
FROM logs, users
|
||||
WHERE building_id = $1 AND logs.user_id = users.user_id
|
||||
ORDER BY log_timestamp DESC`,
|
||||
[id]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getBuildingLikeById(buildingId: number, userId: string) {
|
||||
try {
|
||||
const res = await db.oneOrNone(
|
||||
'SELECT true as like FROM building_user_likes WHERE building_id = $1 and user_id = $2 LIMIT 1',
|
||||
[buildingId, userId]
|
||||
);
|
||||
return res && res.like;
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getBuildingUPRNsById(id: number) {
|
||||
try {
|
||||
return await db.any(
|
||||
'SELECT uprn, parent_uprn FROM building_properties WHERE building_id = $1',
|
||||
[id]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBuilding(buildingId: number, building: any, userId: string) { // TODO add proper building type
|
||||
try {
|
||||
return await updateBuildingData(buildingId, userId, async () => {
|
||||
// remove read-only fields from consideration
|
||||
delete building.building_id;
|
||||
delete building.revision_id;
|
||||
delete building.geometry_id;
|
||||
|
||||
// return whitelisted fields to update
|
||||
return pickAttributesToUpdate(building, BUILDING_FIELD_WHITELIST);
|
||||
});
|
||||
}).catch(function (error) {
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
return { error: error };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function likeBuilding(buildingId, userId) {
|
||||
// start transaction around save operation
|
||||
// - insert building-user like
|
||||
// - count total likes
|
||||
// - insert changeset
|
||||
// - update building to latest state
|
||||
// commit or rollback (serializable - could be more compact?)
|
||||
return db.tx({mode: serializable}, t => {
|
||||
return t.none(
|
||||
'INSERT INTO building_user_likes ( building_id, user_id ) VALUES ($1, $2);',
|
||||
[buildingId, userId]
|
||||
).then(() => {
|
||||
return t.one(
|
||||
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
|
||||
[buildingId]
|
||||
).then(building => {
|
||||
return t.one(
|
||||
`INSERT INTO logs (
|
||||
forward_patch, building_id, user_id
|
||||
) VALUES (
|
||||
$1:json, $2, $3
|
||||
) RETURNING log_id
|
||||
`,
|
||||
[{ likes_total: building.likes }, buildingId, userId]
|
||||
).then(revision => {
|
||||
return t.one(
|
||||
`UPDATE buildings
|
||||
SET
|
||||
revision_id = $1,
|
||||
likes_total = $2
|
||||
WHERE
|
||||
building_id = $3
|
||||
RETURNING
|
||||
*
|
||||
`,
|
||||
[revision.log_id, building.likes, buildingId]
|
||||
).then((data) => {
|
||||
expireBuildingTileCache(buildingId)
|
||||
return data
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}).catch(function (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);
|
||||
},
|
||||
async (t) => {
|
||||
// insert building-user like
|
||||
await t.none(
|
||||
'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
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function unlikeBuilding(buildingId, userId) {
|
||||
// start transaction around save operation
|
||||
// - insert building-user like
|
||||
// - count total likes
|
||||
// - insert changeset
|
||||
// - update building to latest state
|
||||
// commit or rollback (serializable - could be more compact?)
|
||||
return db.tx({mode: serializable}, t => {
|
||||
return t.none(
|
||||
'DELETE FROM building_user_likes WHERE building_id = $1 AND user_id = $2;',
|
||||
[buildingId, userId]
|
||||
).then(() => {
|
||||
return t.one(
|
||||
'SELECT count(*) as likes FROM building_user_likes WHERE building_id = $1;',
|
||||
[buildingId]
|
||||
).then(building => {
|
||||
return t.one(
|
||||
`INSERT INTO logs (
|
||||
forward_patch, building_id, user_id
|
||||
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);
|
||||
},
|
||||
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');
|
||||
}
|
||||
},
|
||||
);
|
||||
} 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 ===
|
||||
|
||||
function pickAttributesToUpdate(obj: any, fieldWhitelist: Set<string>) {
|
||||
const subObject = {};
|
||||
for (let [key, value] of Object.entries(obj)) {
|
||||
if(fieldWhitelist.has(key)) {
|
||||
subObject[key] = value;
|
||||
}
|
||||
}
|
||||
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.
|
||||
* @param buildingId The ID of the building to update
|
||||
* @param userId The ID of the user updating the data
|
||||
* @param getUpdateValue Function returning the set of attribute to update for the building
|
||||
* @param preUpdateDbAction Any db operations to carry out before updating the buildings table (mostly intended for updating the user likes table)
|
||||
*/
|
||||
async function updateBuildingData(
|
||||
buildingId: number,
|
||||
userId: string,
|
||||
getUpdateValue: (t: ITask<any>) => Promise<object>,
|
||||
preUpdateDbAction?: (t: ITask<any>) => Promise<void>,
|
||||
) {
|
||||
return await db.tx({mode: serializable}, async t => {
|
||||
if (preUpdateDbAction != undefined) {
|
||||
await preUpdateDbAction(t);
|
||||
}
|
||||
|
||||
const update = await getUpdateValue(t);
|
||||
|
||||
const oldBuilding = await t.one(
|
||||
'SELECT * FROM buildings WHERE building_id = $1 FOR UPDATE;',
|
||||
[buildingId]
|
||||
);
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
const revision = await t.one(
|
||||
`INSERT INTO logs (
|
||||
forward_patch, reverse_patch, building_id, user_id
|
||||
) VALUES (
|
||||
$1:json, $2, $3
|
||||
$1:json, $2:json, $3, $4
|
||||
) RETURNING log_id
|
||||
`,
|
||||
[{ likes_total: building.likes }, buildingId, userId]
|
||||
).then(revision => {
|
||||
return t.one(
|
||||
`UPDATE buildings
|
||||
SET
|
||||
revision_id = $1,
|
||||
likes_total = $2
|
||||
WHERE
|
||||
building_id = $3
|
||||
RETURNING
|
||||
*
|
||||
`,
|
||||
[revision.log_id, building.likes, buildingId]
|
||||
).then((data) => {
|
||||
expireBuildingTileCache(buildingId)
|
||||
return data
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}).catch(function (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
|
||||
}
|
||||
[forward, reverse, buildingId, userId]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
expireBuildingTileCache(buildingId);
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function privateQueryBuildingBBOX(buildingId){
|
||||
function privateQueryBuildingBBOX(buildingId: number){
|
||||
return db.one(
|
||||
`SELECT
|
||||
ST_XMin(envelope) as xmin,
|
||||
@ -310,14 +332,13 @@ function privateQueryBuildingBBOX(buildingId){
|
||||
b.building_id = $1
|
||||
) as envelope`,
|
||||
[buildingId]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function expireBuildingTileCache(buildingId) {
|
||||
privateQueryBuildingBBOX(buildingId).then((bbox) => {
|
||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||
tileCache.removeAllAtBbox(buildingBbox);
|
||||
})
|
||||
async function expireBuildingTileCache(buildingId: number) {
|
||||
const bbox = await privateQueryBuildingBBOX(buildingId)
|
||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||
tileCache.removeAllAtBbox(buildingBbox);
|
||||
}
|
||||
|
||||
const BUILDING_FIELD_WHITELIST = new Set([
|
||||
@ -385,16 +406,16 @@ const BUILDING_FIELD_WHITELIST = new Set([
|
||||
* @param {Set} whitelist
|
||||
* @returns {[object, object]}
|
||||
*/
|
||||
function compare(oldObj, newObj, whitelist) {
|
||||
const reverse = {}
|
||||
const forward = {}
|
||||
function compare(oldObj: object, newObj: object): [object, object] {
|
||||
const reverse = {};
|
||||
const forward = {};
|
||||
for (const [key, value] of Object.entries(newObj)) {
|
||||
if (oldObj[key] !== value && whitelist.has(key)) {
|
||||
if (oldObj[key] != value) {
|
||||
reverse[key] = oldObj[key];
|
||||
forward[key] = value;
|
||||
}
|
||||
}
|
||||
return [forward, reverse]
|
||||
return [forward, reverse];
|
||||
}
|
||||
|
||||
export {
|
||||
@ -402,8 +423,10 @@ export {
|
||||
queryBuildingsByReference,
|
||||
getBuildingById,
|
||||
getBuildingLikeById,
|
||||
getBuildingEditHistory,
|
||||
getBuildingUPRNsById,
|
||||
saveBuilding,
|
||||
likeBuilding,
|
||||
unlikeBuilding
|
||||
unlikeBuilding,
|
||||
getLatestRevisionId
|
||||
};
|
||||
|
@ -6,173 +6,185 @@ import { errors } from 'pg-promise';
|
||||
|
||||
import db from '../../db';
|
||||
import { validateUsername, ValidationError, validatePassword } from '../validation';
|
||||
import { promisify } from 'util';
|
||||
|
||||
function createUser(user) {
|
||||
|
||||
async function createUser(user) {
|
||||
try {
|
||||
validateUsername(user.username);
|
||||
validatePassword(user.password);
|
||||
} catch(err) {
|
||||
if (err instanceof ValidationError) {
|
||||
return Promise.reject({ error: err.message });
|
||||
throw { error: err.message };
|
||||
} else throw err;
|
||||
}
|
||||
|
||||
return db.one(
|
||||
`INSERT
|
||||
INTO users (
|
||||
user_id,
|
||||
username,
|
||||
email,
|
||||
pass
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
$1,
|
||||
$2,
|
||||
crypt($3, gen_salt('bf'))
|
||||
) RETURNING user_id
|
||||
`, [
|
||||
user.username,
|
||||
user.email,
|
||||
user.password
|
||||
]
|
||||
).catch(function (error) {
|
||||
console.error('Error:', error)
|
||||
try {
|
||||
return await db.one(
|
||||
`INSERT
|
||||
INTO users (
|
||||
user_id,
|
||||
username,
|
||||
email,
|
||||
pass
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
$1,
|
||||
$2,
|
||||
crypt($3, gen_salt('bf'))
|
||||
) RETURNING user_id
|
||||
`, [
|
||||
user.username,
|
||||
user.email,
|
||||
user.password
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error);
|
||||
|
||||
if (error.detail.indexOf('already exists') !== -1) {
|
||||
if (error.detail.indexOf('username') !== -1) {
|
||||
if (error.detail.includes('already exists')) {
|
||||
if (error.detail.includes('username')) {
|
||||
return { error: 'Username already registered' };
|
||||
} else if (error.detail.indexOf('email') !== -1) {
|
||||
} else if (error.detail.includes('email')) {
|
||||
return { error: 'Email already registered' };
|
||||
}
|
||||
}
|
||||
return { error: 'Database error' }
|
||||
});
|
||||
return { error: 'Database error' };
|
||||
}
|
||||
}
|
||||
|
||||
function authUser(username, password) {
|
||||
return db.one(
|
||||
`SELECT
|
||||
user_id,
|
||||
(
|
||||
pass = crypt($2, pass)
|
||||
) AS auth_ok
|
||||
FROM users
|
||||
WHERE
|
||||
username = $1
|
||||
`, [
|
||||
username,
|
||||
password
|
||||
]
|
||||
).then(function (user) {
|
||||
async function authUser(username: string, password: string) {
|
||||
try {
|
||||
const user = await db.one(
|
||||
`SELECT
|
||||
user_id,
|
||||
(
|
||||
pass = crypt($2, pass)
|
||||
) AS auth_ok
|
||||
FROM users
|
||||
WHERE
|
||||
username = $1
|
||||
`, [
|
||||
username,
|
||||
password
|
||||
]
|
||||
);
|
||||
|
||||
if (user && user.auth_ok) {
|
||||
return { user_id: user.user_id }
|
||||
} else {
|
||||
return { error: 'Username or password not recognised' }
|
||||
}
|
||||
}).catch(function (err) {
|
||||
} catch(err) {
|
||||
if (err instanceof errors.QueryResultError) {
|
||||
console.error(`Authentication failed for user ${username}`);
|
||||
return { error: 'Username or password not recognised' };
|
||||
}
|
||||
console.error('Error:', err);
|
||||
return { error: 'Database error' };
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id) {
|
||||
return db.one(
|
||||
`SELECT
|
||||
username, email, registered, api_key
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
user_id = $1
|
||||
`, [
|
||||
id
|
||||
]
|
||||
).catch(function (error) {
|
||||
async function getUserById(id: string) {
|
||||
try {
|
||||
return await db.one(
|
||||
`SELECT
|
||||
username, email, registered, api_key
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
user_id = $1
|
||||
`, [
|
||||
id
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getUserByEmail(email: string) {
|
||||
return db.one(
|
||||
`SELECT
|
||||
user_id, username, email
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
email = $1
|
||||
`, [email]
|
||||
).catch(function(error) {
|
||||
async function getUserByEmail(email: string) {
|
||||
try {
|
||||
return db.one(
|
||||
`SELECT
|
||||
user_id, username, email
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
email = $1
|
||||
`, [email]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getNewUserAPIKey(id) {
|
||||
return db.one(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
api_key = gen_random_uuid()
|
||||
WHERE
|
||||
user_id = $1
|
||||
RETURNING
|
||||
api_key
|
||||
`, [
|
||||
id
|
||||
]
|
||||
).catch(function (error) {
|
||||
async function getNewUserAPIKey(id: string) {
|
||||
try{
|
||||
return db.one(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
api_key = gen_random_uuid()
|
||||
WHERE
|
||||
user_id = $1
|
||||
RETURNING
|
||||
api_key
|
||||
`, [
|
||||
id
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
return { error: 'Failed to generate new API key.' };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function authAPIUser(key) {
|
||||
return db.one(
|
||||
`SELECT
|
||||
user_id
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
api_key = $1
|
||||
`, [
|
||||
key
|
||||
]
|
||||
).catch(function (error) {
|
||||
async function authAPIUser(key: string) {
|
||||
try {
|
||||
return await db.one(
|
||||
`SELECT
|
||||
user_id
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
api_key = $1
|
||||
`, [
|
||||
key
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteUser(id) {
|
||||
return db.none(
|
||||
`UPDATE users
|
||||
SET
|
||||
email = null,
|
||||
pass = null,
|
||||
api_key = null,
|
||||
username = concat('deleted_', cast(user_id as char(13))),
|
||||
is_deleted = true,
|
||||
deleted_on = now() at time zone 'utc'
|
||||
WHERE user_id = $1
|
||||
`, [id]
|
||||
).catch((error) => {
|
||||
async function deleteUser(id: string) {
|
||||
try {
|
||||
return await db.none(
|
||||
`UPDATE users
|
||||
SET
|
||||
email = null,
|
||||
pass = null,
|
||||
api_key = null,
|
||||
username = concat('deleted_', cast(user_id as char(13))),
|
||||
is_deleted = true,
|
||||
deleted_on = now() at time zone 'utc'
|
||||
WHERE user_id = $1
|
||||
`, [id]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error);
|
||||
return {error: 'Database error'};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function logout(session: Express.Session) {
|
||||
return new Promise((resolve, reject) => {
|
||||
session.user_id = undefined;
|
||||
session.destroy(err => {
|
||||
if (err) return reject(err);
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
function logout(session: Express.Session): Promise<void> {
|
||||
session.user_id = undefined;
|
||||
|
||||
return promisify(session.destroy.bind(session))();
|
||||
}
|
||||
|
||||
export {
|
||||
|
@ -12,7 +12,12 @@ const data = (window as any).__PRELOADED_STATE__; // TODO: remove any
|
||||
|
||||
hydrate(
|
||||
<BrowserRouter>
|
||||
<App user={data.user} building={data.building} building_like={data.building_like} />
|
||||
<App
|
||||
user={data.user}
|
||||
building={data.building}
|
||||
building_like={data.building_like}
|
||||
revisionId={data.latestRevisionId}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
<App revisionId={0} />
|
||||
</MemoryRouter>,
|
||||
div
|
||||
);
|
||||
|
@ -20,12 +20,15 @@ import PasswordReset from './user/password-reset';
|
||||
import MapApp from './map-app';
|
||||
import ContactPage from './pages/contact';
|
||||
import DataAccuracyPage from './pages/data-accuracy';
|
||||
import OrdnanceSurveyLicencePage from './pages/ordnance-survey-licence';
|
||||
import OrdnanceSurveyUprnPage from './pages/ordnance-survey-uprn';
|
||||
|
||||
|
||||
interface AppProps {
|
||||
user?: any;
|
||||
building?: any;
|
||||
building_like?: boolean;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,6 +50,8 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
building_like: PropTypes.bool
|
||||
};
|
||||
|
||||
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
|
||||
|
||||
constructor(props: Readonly<AppProps>) {
|
||||
super(props);
|
||||
|
||||
@ -77,7 +82,14 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
render() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Header user={this.state.user} />
|
||||
<Switch>
|
||||
<Route exact path={App.mapAppPaths}>
|
||||
<Header user={this.state.user} animateLogo={false} />
|
||||
</Route>
|
||||
<Route>
|
||||
<Header user={this.state.user} animateLogo={true} />
|
||||
</Route>
|
||||
</Switch>
|
||||
<main>
|
||||
<Switch>
|
||||
<Route exact path="/about.html" component={AboutPage} />
|
||||
@ -98,15 +110,18 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
</Route>
|
||||
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
|
||||
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
|
||||
<Route exact path="/ordnance-survey-licence.html" component={OrdnanceSurveyLicencePage} />
|
||||
<Route exact path="/ordnance-survey-uprn.html" component={OrdnanceSurveyUprnPage} />
|
||||
<Route exact path="/data-accuracy.html" component={DataAccuracyPage} />
|
||||
<Route exact path="/data-extracts.html" component={DataExtracts} />
|
||||
<Route exact path="/contact.html" component={ContactPage} />
|
||||
<Route exact path={["/", "/:mode(view|edit|multi-edit)/:category/:building(\\d+)?"]} render={(props) => (
|
||||
<Route exact path={App.mapAppPaths} render={(props) => (
|
||||
<MapApp
|
||||
{...props}
|
||||
building={this.props.building}
|
||||
building_like={this.props.building_like}
|
||||
user={this.state.user}
|
||||
revisionId={this.props.revisionId}
|
||||
/>
|
||||
)} />
|
||||
<Route component={NotFound} />
|
||||
|
@ -14,18 +14,28 @@ import StreetscapeContainer from './data-containers/streetscape';
|
||||
import CommunityContainer from './data-containers/community';
|
||||
import PlanningContainer from './data-containers/planning';
|
||||
import LikeContainer from './data-containers/like';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
|
||||
interface BuildingViewProps {
|
||||
cat: string;
|
||||
mode: 'view' | 'edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
selectBuilding: (building: Building) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level container for building view/edit form
|
||||
*
|
||||
* @param props
|
||||
*/
|
||||
const BuildingView = (props) => {
|
||||
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
switch (props.cat) {
|
||||
case 'location':
|
||||
return <LocationContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Location"
|
||||
help="https://pages.colouring.london/location"
|
||||
intro="Where are the buildings? Address, location and cross-references."
|
||||
@ -33,7 +43,6 @@ const BuildingView = (props) => {
|
||||
case 'use':
|
||||
return <UseContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
inactive={true}
|
||||
title="Land Use"
|
||||
intro="How are buildings used, and how does use change over time? Coming soon…"
|
||||
@ -42,7 +51,6 @@ const BuildingView = (props) => {
|
||||
case 'type':
|
||||
return <TypeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
inactive={false}
|
||||
title="Type"
|
||||
intro="How were buildings previously used?"
|
||||
@ -51,7 +59,6 @@ const BuildingView = (props) => {
|
||||
case 'age':
|
||||
return <AgeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Age"
|
||||
help="https://pages.colouring.london/age"
|
||||
intro="Building age data can support energy analysis and help predict long-term change."
|
||||
@ -59,7 +66,6 @@ const BuildingView = (props) => {
|
||||
case 'size':
|
||||
return <SizeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Size & Shape"
|
||||
intro="How big are buildings?"
|
||||
help="https://pages.colouring.london/shapeandsize"
|
||||
@ -67,7 +73,6 @@ const BuildingView = (props) => {
|
||||
case 'construction':
|
||||
return <ConstructionContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Construction"
|
||||
intro="How are buildings built? Coming soon…"
|
||||
help="https://pages.colouring.london/construction"
|
||||
@ -76,7 +81,6 @@ const BuildingView = (props) => {
|
||||
case 'team':
|
||||
return <TeamContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Team"
|
||||
intro="Who built the buildings? Coming soon…"
|
||||
help="https://pages.colouring.london/team"
|
||||
@ -85,7 +89,6 @@ const BuildingView = (props) => {
|
||||
case 'sustainability':
|
||||
return <SustainabilityContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Sustainability"
|
||||
intro="Are buildings energy efficient?"
|
||||
help="https://pages.colouring.london/sustainability"
|
||||
@ -94,7 +97,6 @@ const BuildingView = (props) => {
|
||||
case 'streetscape':
|
||||
return <StreetscapeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Streetscape"
|
||||
intro="What's the building's context? Coming soon…"
|
||||
help="https://pages.colouring.london/streetscape"
|
||||
@ -103,7 +105,6 @@ const BuildingView = (props) => {
|
||||
case 'community':
|
||||
return <CommunityContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Community"
|
||||
intro="How does this building work for the local community?"
|
||||
help="https://pages.colouring.london/community"
|
||||
@ -112,7 +113,6 @@ const BuildingView = (props) => {
|
||||
case 'planning':
|
||||
return <PlanningContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Planning"
|
||||
intro="Planning controls relating to protection and reuse."
|
||||
help="https://pages.colouring.london/planning"
|
||||
@ -120,7 +120,6 @@ const BuildingView = (props) => {
|
||||
case 'like':
|
||||
return <LikeContainer
|
||||
{...props}
|
||||
key={props.building && props.building.building_id}
|
||||
title="Like Me!"
|
||||
intro="Do you like the building and think it contributes to the city?"
|
||||
help="https://pages.colouring.london/likeme"
|
||||
|
@ -64,7 +64,7 @@ const Categories = (props) => (
|
||||
title="Streetscape"
|
||||
desc="Environment"
|
||||
slug="streetscape"
|
||||
help="https://pages.colouring.london/streetscape"
|
||||
help="https://pages.colouring.london/greenery"
|
||||
inactive={true}
|
||||
mode={props.mode}
|
||||
building_id={props.building_id}
|
||||
@ -125,7 +125,7 @@ Categories.propTypes = {
|
||||
const Category = (props) => {
|
||||
let categoryLink = `/${props.mode}/${props.slug}`;
|
||||
if (props.building_id != undefined) categoryLink += `/${props.building_id}`;
|
||||
|
||||
|
||||
return (
|
||||
<li className={`category-block ${props.slug} background-${props.slug}`}>
|
||||
<NavLink
|
||||
|
@ -3,66 +3,20 @@ import { Link, NavLink } from 'react-router-dom';
|
||||
|
||||
import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
|
||||
|
||||
interface ContainerHeaderProps {
|
||||
cat?: string;
|
||||
backLink: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ContainerHeader: React.FunctionComponent<any> = (props) => (
|
||||
<header className={`section-header view ${props.cat} background-${props.cat}`}>
|
||||
<Link className="icon-button back" to={`/${props.mode}/categories${props.building != undefined ? `/${props.building.building_id}` : ''}`}>
|
||||
const ContainerHeader: React.FunctionComponent<ContainerHeaderProps> = (props) => (
|
||||
<header className={`section-header view ${props.cat ? props.cat : ''} ${props.cat ? `background-${props.cat}` : ''}`}>
|
||||
<Link className="icon-button back" to={props.backLink}>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<h2 className="h2">{props.title}</h2>
|
||||
<nav className="icon-buttons">
|
||||
{
|
||||
props.building != undefined && !props.inactive ?
|
||||
props.copy.copying?
|
||||
<Fragment>
|
||||
<NavLink
|
||||
to={`/multi-edit/${props.cat}?data=${props.data_string}`}
|
||||
className="icon-button copy">
|
||||
Copy selected
|
||||
</NavLink>
|
||||
<a
|
||||
className="icon-button copy"
|
||||
onClick={props.copy.toggleCopying}>
|
||||
Cancel
|
||||
</a>
|
||||
</Fragment>
|
||||
:
|
||||
<a
|
||||
className="icon-button copy"
|
||||
onClick={props.copy.toggleCopying}>
|
||||
Copy
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
{
|
||||
props.help && !props.copy.copying?
|
||||
<a
|
||||
className="icon-button help"
|
||||
title="Find out more"
|
||||
href={props.help}>
|
||||
Info
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
{
|
||||
props.building != undefined && !props.inactive && !props.copy.copying?
|
||||
(props.mode === 'edit')?
|
||||
<NavLink
|
||||
className="icon-button view"
|
||||
title="View data"
|
||||
to={`/view/${props.cat}/${props.building.building_id}`}>
|
||||
View
|
||||
<ViewIcon />
|
||||
</NavLink>
|
||||
: <NavLink
|
||||
className="icon-button edit"
|
||||
title="Edit data"
|
||||
to={`/edit/${props.cat}/${props.building.building_id}`}>
|
||||
Edit
|
||||
<EditIcon />
|
||||
</NavLink>
|
||||
: null
|
||||
}
|
||||
{props.children}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
@ -2,8 +2,14 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface CheckboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
|
||||
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -19,7 +25,7 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
name={props.slug}
|
||||
checked={!!props.value}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
onChange={props.onChange}
|
||||
onChange={e => props.onChange(props.slug, e.target.checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={props.slug}
|
||||
@ -31,14 +37,12 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
);
|
||||
}
|
||||
|
||||
DataEntry.propTypes = {
|
||||
CheckboxDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.any,
|
||||
placeholder: PropTypes.string,
|
||||
maxLength: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
@ -47,4 +51,4 @@ DataEntry.propTypes = {
|
||||
})
|
||||
}
|
||||
|
||||
export default DataEntry;
|
||||
export default CheckboxDataEntry;
|
||||
|
@ -0,0 +1,17 @@
|
||||
.data-entry-group-header {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.data-entry-group-header .data-entry-group-title {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.data-entry-group-count {
|
||||
font-size: 0.8em;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.data-entry-group-body {
|
||||
padding-left: 1rem;
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
|
||||
import './data-entry-group.css';
|
||||
import { RightIcon, DownIcon } from "../../components/icons";
|
||||
|
||||
interface DataEntryGroupProps {
|
||||
/** Name of the group */
|
||||
name: string;
|
||||
/** Whether the group should be collapsed initially */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const DataEntryGroup: React.FunctionComponent<DataEntryGroupProps> = (props) => {
|
||||
const {collapsed: initialCollapsed = true} = props;
|
||||
|
||||
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='data-entry-group-header' onClick={() => setCollapsed(!collapsed)}>
|
||||
<CollapseIcon collapsed={collapsed} />
|
||||
<span className='data-entry-group-title'>
|
||||
{props.name}
|
||||
<span className='data-entry-group-count'>{` (${React.Children.count(props.children)} attributes)`}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`data-entry-group-body ${collapsed ? 'collapse' : ''}`}>
|
||||
{props.children}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const CollapseIcon: React.FunctionComponent<{collapsed: boolean}> = (props) => (
|
||||
<span className="collapse-icon">
|
||||
{props.collapsed ? <RightIcon/> : <DownIcon/>}
|
||||
</span>
|
||||
);
|
||||
|
||||
export {
|
||||
DataEntryGroup
|
||||
};
|
@ -3,7 +3,24 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface BaseDataEntryProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // CopyProps clashes with propTypes
|
||||
mode?: 'view' | 'edit' | 'multi-edit';
|
||||
onChange?: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface DataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
valueTransform?: (string) => string
|
||||
}
|
||||
|
||||
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -20,7 +37,13 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e => {
|
||||
const transform = props.valueTransform || (x => x);
|
||||
const val = e.target.value === '' ?
|
||||
null :
|
||||
transform(e.target.value);
|
||||
props.onChange(props.slug, val);
|
||||
}}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
@ -43,3 +66,6 @@ DataEntry.propTypes = {
|
||||
}
|
||||
|
||||
export default DataEntry;
|
||||
export {
|
||||
BaseDataEntryProps
|
||||
};
|
||||
|
@ -3,7 +3,13 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
|
||||
const DataTitle: React.FunctionComponent<any> = (props) => {
|
||||
|
||||
interface DataTitleProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
|
||||
return (
|
||||
<dt>
|
||||
{ props.title }
|
||||
@ -17,7 +23,16 @@ DataTitle.propTypes = {
|
||||
tooltip: PropTypes.string
|
||||
}
|
||||
|
||||
const DataTitleCopyable: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface DataTitleCopyableProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
slug: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // TODO: type should be CopyProps, but that clashes with propTypes in some obscure way
|
||||
}
|
||||
|
||||
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
|
||||
return (
|
||||
<div className="data-title">
|
||||
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
|
||||
@ -48,7 +63,8 @@ DataTitleCopyable.propTypes = {
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
toggleCopyAttribute: PropTypes.func,
|
||||
toggleCopying: PropTypes.func
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,15 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
|
||||
const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface LikeDataEntryProps {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
userLike: boolean;
|
||||
totalLikes: number;
|
||||
onLike: (userLike: boolean) => void;
|
||||
}
|
||||
|
||||
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
|
||||
const data_string = JSON.stringify({like: true});
|
||||
return (
|
||||
<Fragment>
|
||||
@ -21,20 +29,20 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
|
||||
</div>
|
||||
<p>
|
||||
{
|
||||
(props.value != null)?
|
||||
(props.value === 1)?
|
||||
`${props.value} person likes this building`
|
||||
: `${props.value} people like this building`
|
||||
(props.totalLikes != null)?
|
||||
(props.totalLikes === 1)?
|
||||
`${props.totalLikes} person likes this building`
|
||||
: `${props.totalLikes} people like this building`
|
||||
: "0 people like this building so far - you could be the first!"
|
||||
}
|
||||
</p>
|
||||
<input className="form-check-input" type="checkbox"
|
||||
id="like" name="like"
|
||||
checked={!!props.building_like}
|
||||
disabled={props.mode === 'view'}
|
||||
onChange={props.onLike}
|
||||
/>
|
||||
<label htmlFor="like" className="form-check-label">
|
||||
<label className="form-check-label">
|
||||
<input className="form-check-input" type="checkbox"
|
||||
name="like"
|
||||
checked={!!props.userLike}
|
||||
disabled={props.mode === 'view'}
|
||||
onChange={e => props.onLike(e.target.checked)}
|
||||
/>
|
||||
I like this building and think it contributes to the city!
|
||||
</label>
|
||||
</Fragment>
|
||||
@ -42,8 +50,10 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
|
||||
}
|
||||
|
||||
LikeDataEntry.propTypes = {
|
||||
value: PropTypes.any,
|
||||
user_building_like: PropTypes.bool
|
||||
}
|
||||
// mode: PropTypes.string,
|
||||
userLike: PropTypes.bool,
|
||||
totalLikes: PropTypes.number,
|
||||
onLike: PropTypes.func
|
||||
};
|
||||
|
||||
export default LikeDataEntry;
|
||||
|
@ -2,8 +2,18 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
|
||||
interface NumericDataEntryProps extends BaseDataEntryProps {
|
||||
value?: number;
|
||||
placeholder?: string;
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -24,7 +34,12 @@ const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
|
||||
min={props.min || 0}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ? null : parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -2,8 +2,16 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface SelectDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
|
||||
const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -17,7 +25,14 @@ const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remo
|
||||
id={props.slug} name={props.slug}
|
||||
value={props.value || ''}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
onChange={props.onChange}>
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ?
|
||||
null :
|
||||
e.target.value
|
||||
)}
|
||||
>
|
||||
<option value="">{props.placeholder}</option>
|
||||
{
|
||||
props.options.map(option => (
|
||||
|
@ -2,8 +2,15 @@ import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
|
||||
const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
|
||||
interface TextboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<DataTitleCopyable
|
||||
@ -18,11 +25,18 @@ const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
|
||||
id={props.slug}
|
||||
name={props.slug}
|
||||
value={props.value || ''}
|
||||
maxLength={props.max_length}
|
||||
maxLength={props.maxLength}
|
||||
rows={5}
|
||||
disabled={props.mode === 'view' || props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
onChange={e =>
|
||||
props.onChange(
|
||||
props.slug,
|
||||
e.target.value === '' ?
|
||||
null :
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
></textarea>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -2,6 +2,7 @@ import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import NumericDataEntry from './numeric-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
|
||||
class YearDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
@ -35,7 +36,7 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
return (
|
||||
<Fragment>
|
||||
<NumericDataEntry
|
||||
title="Year built (best estimate)"
|
||||
title={dataFields.date_year.title}
|
||||
slug="date_year"
|
||||
value={props.year}
|
||||
mode={props.mode}
|
||||
@ -44,24 +45,24 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
// "type": "year_estimator"
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Latest possible start year"
|
||||
title={dataFields.date_upper.title}
|
||||
slug="date_upper"
|
||||
value={props.upper}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
tooltip="This should be the latest year in which building could have started."
|
||||
tooltip={dataFields.date_upper.tooltip}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Earliest possible start date"
|
||||
title={dataFields.date_lower.title}
|
||||
slug="date_lower"
|
||||
value={props.lower}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
tooltip="This should be the earliest year in which building could have started."
|
||||
tooltip={dataFields.date_lower.tooltip}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
|
@ -1,10 +1,39 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Redirect, NavLink } from 'react-router-dom';
|
||||
|
||||
import ContainerHeader from './container-header';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { CopyControl } from './header-buttons/copy-control';
|
||||
import { ViewEditControl } from './header-buttons/view-edit-control';
|
||||
import { Building } from '../models/building';
|
||||
import { User } from '../models/user';
|
||||
import { compareObjects } from '../helpers';
|
||||
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
|
||||
|
||||
interface DataContainerProps {
|
||||
title: string;
|
||||
cat: string;
|
||||
intro: string;
|
||||
help: string;
|
||||
inactive?: boolean;
|
||||
|
||||
user: User;
|
||||
mode: 'view' | 'edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
selectBuilding: (building: Building) => void
|
||||
}
|
||||
|
||||
interface DataContainerState {
|
||||
error: string;
|
||||
copying: boolean;
|
||||
keys_to_copy: {[key: string]: boolean};
|
||||
currentBuildingId: number;
|
||||
currentBuildingRevisionId: number;
|
||||
buildingEdits: Partial<Building>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared functionality for view/edit forms
|
||||
@ -14,15 +43,14 @@ import InfoBox from '../components/info-box';
|
||||
*
|
||||
* @param WrappedComponent
|
||||
*/
|
||||
const withCopyEdit = (WrappedComponent) => {
|
||||
return class extends React.Component<any, any> { // TODO: add proper types
|
||||
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
|
||||
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
intro: PropTypes.string,
|
||||
help: PropTypes.string,
|
||||
inactive: PropTypes.bool,
|
||||
building_id: PropTypes.number,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
@ -30,23 +58,40 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: this.props.error || undefined,
|
||||
like: this.props.like || undefined,
|
||||
error: undefined,
|
||||
copying: false,
|
||||
keys_to_copy: {},
|
||||
building: this.props.building
|
||||
buildingEdits: {},
|
||||
currentBuildingId: undefined,
|
||||
currentBuildingRevisionId: undefined
|
||||
};
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleCheck = this.handleCheck.bind(this);
|
||||
this.handleReset = this.handleReset.bind(this);
|
||||
this.handleLike = this.handleLike.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleUpdate = this.handleUpdate.bind(this);
|
||||
|
||||
this.toggleCopying = this.toggleCopying.bind(this);
|
||||
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
|
||||
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
|
||||
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
|
||||
return {
|
||||
error: undefined,
|
||||
copying: false,
|
||||
keys_to_copy: {},
|
||||
buildingEdits: {},
|
||||
currentBuildingId: newBuildingId,
|
||||
currentBuildingRevisionId: newBuildingRevisionId
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter or exit "copying" state - allow user to select attributes to copy
|
||||
*/
|
||||
@ -62,7 +107,7 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
* @param {string} key
|
||||
*/
|
||||
toggleCopyAttribute(key: string) {
|
||||
const keys = this.state.keys_to_copy;
|
||||
const keys = {...this.state.keys_to_copy};
|
||||
if(this.state.keys_to_copy[key]){
|
||||
delete keys[key];
|
||||
} else {
|
||||
@ -73,45 +118,34 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
})
|
||||
}
|
||||
|
||||
updateBuildingState(key, value) {
|
||||
const building = {...this.state.building};
|
||||
building[key] = value;
|
||||
isEdited() {
|
||||
const edits = this.state.buildingEdits;
|
||||
// check if the edits object has any fields
|
||||
return Object.entries(edits).length !== 0;
|
||||
}
|
||||
|
||||
clearEdits() {
|
||||
this.setState({
|
||||
building: building
|
||||
buildingEdits: {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes on typical inputs
|
||||
* - e.g. input[type=text], radio, select, textare
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleChange(event) {
|
||||
const target = event.target;
|
||||
let value = (target.value === '')? null : target.value;
|
||||
const name = target.name;
|
||||
|
||||
// special transform - consider something data driven before adding 'else if's
|
||||
if (name === 'location_postcode' && value !== null) {
|
||||
value = value.toUpperCase();
|
||||
getEditedBuilding() {
|
||||
if(this.isEdited()) {
|
||||
return Object.assign({}, this.props.building, this.state.buildingEdits);
|
||||
} else {
|
||||
return {...this.props.building};
|
||||
}
|
||||
this.updateBuildingState(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle changes on checkboxes
|
||||
* - e.g. input[type=checkbox]
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleCheck(event) {
|
||||
const target = event.target;
|
||||
const value = target.checked;
|
||||
const name = target.name;
|
||||
updateBuildingState(key: string, value: any) {
|
||||
const newBuilding = this.getEditedBuilding();
|
||||
newBuilding[key] = value;
|
||||
const [forwardPatch] = compareObjects(this.props.building, newBuilding);
|
||||
|
||||
this.updateBuildingState(name, value);
|
||||
this.setState({
|
||||
buildingEdits: forwardPatch
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -121,160 +155,208 @@ const withCopyEdit = (WrappedComponent) => {
|
||||
* @param {String} name
|
||||
* @param {*} value
|
||||
*/
|
||||
handleUpdate(name: string, value: any) {
|
||||
handleChange(name: string, value: any) {
|
||||
this.updateBuildingState(name, value);
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.clearEdits();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle likes separately
|
||||
* - like/love reaction is limited to set/unset per user
|
||||
*
|
||||
* @param {*} event
|
||||
*/
|
||||
handleLike(event) {
|
||||
event.preventDefault();
|
||||
const like = event.target.checked;
|
||||
|
||||
fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({like: like})
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
async handleLike(like: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
|
||||
method: 'POST',
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({like: like})
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
} else {
|
||||
this.props.selectBuilding(res);
|
||||
this.updateBuildingState('likes_total', res.likes_total);
|
||||
this.props.selectBuilding(data);
|
||||
this.updateBuildingState('likes_total', data.likes_total);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
} catch(err) {
|
||||
this.setState({error: err});
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined})
|
||||
this.setState({error: undefined});
|
||||
|
||||
fetch(`/api/buildings/${this.props.building.building_id}.json`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.state.building),
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/${this.props.building.building_id}.json`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.state.buildingEdits),
|
||||
headers:{
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
} else {
|
||||
this.props.selectBuilding(res);
|
||||
this.props.selectBuilding(data);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
);
|
||||
} catch(err) {
|
||||
this.setState({error: err});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.mode === 'edit' && !this.props.user){
|
||||
if (this.props.mode === 'edit' && !this.props.user){
|
||||
return <Redirect to="/sign-up.html" />
|
||||
}
|
||||
|
||||
const currentBuilding = this.getEditedBuilding();
|
||||
|
||||
const values_to_copy = {}
|
||||
for (const key of Object.keys(this.state.keys_to_copy)) {
|
||||
values_to_copy[key] = this.state.building[key]
|
||||
values_to_copy[key] = currentBuilding[key]
|
||||
}
|
||||
const data_string = JSON.stringify(values_to_copy);
|
||||
const copy = {
|
||||
const copy: CopyProps = {
|
||||
copying: this.state.copying,
|
||||
toggleCopying: this.toggleCopying,
|
||||
toggleCopyAttribute: this.toggleCopyAttribute,
|
||||
copyingKey: (key) => this.state.keys_to_copy[key]
|
||||
copyingKey: (key: string) => this.state.keys_to_copy[key]
|
||||
}
|
||||
|
||||
const headerBackLink = `/${this.props.mode}/categories${this.props.building != undefined ? `/${this.props.building.building_id}` : ''}`;
|
||||
const edited = this.isEdited();
|
||||
|
||||
return (
|
||||
<section
|
||||
id={this.props.slug}
|
||||
id={this.props.cat}
|
||||
className="data-section">
|
||||
<ContainerHeader
|
||||
{...this.props}
|
||||
data_string={data_string}
|
||||
copy={copy}
|
||||
/>
|
||||
cat={this.props.cat}
|
||||
backLink={headerBackLink}
|
||||
title={this.props.title}
|
||||
>
|
||||
{
|
||||
this.props.building != undefined ?
|
||||
<form
|
||||
action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
|
||||
method="POST"
|
||||
onSubmit={this.handleSubmit}>
|
||||
{
|
||||
(this.props.inactive) ?
|
||||
<InfoBox
|
||||
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
{
|
||||
(this.props.mode === 'edit' && !this.props.inactive) ?
|
||||
<Fragment>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
{
|
||||
this.props.slug === 'like' ? // special-case for likes
|
||||
null :
|
||||
<div className="buttons-container with-space">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</Fragment>
|
||||
: null
|
||||
}
|
||||
<WrappedComponent
|
||||
building={this.state.building}
|
||||
building_like={this.props.building_like}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onCheck={this.handleCheck}
|
||||
onLike={this.handleLike}
|
||||
onUpdate={this.handleUpdate}
|
||||
/>
|
||||
</form>
|
||||
:
|
||||
<form>
|
||||
{
|
||||
(this.props.inactive)?
|
||||
<Fragment>
|
||||
this.props.help && !copy.copying?
|
||||
<a
|
||||
className="icon-button help"
|
||||
title="Find out more"
|
||||
href={this.props.help}>
|
||||
Info
|
||||
</a>
|
||||
: null
|
||||
}
|
||||
{
|
||||
this.props.building != undefined && !this.props.inactive ?
|
||||
<>
|
||||
<CopyControl
|
||||
cat={this.props.cat}
|
||||
data_string={data_string}
|
||||
copying={copy.copying}
|
||||
toggleCopying={copy.toggleCopying}
|
||||
/>
|
||||
{
|
||||
!copy.copying ?
|
||||
<>
|
||||
<NavLink
|
||||
className="icon-button history"
|
||||
to={`/${this.props.mode}/${this.props.cat}/${this.props.building.building_id}/history`}
|
||||
>History</NavLink>
|
||||
<ViewEditControl
|
||||
cat={this.props.cat}
|
||||
mode={this.props.mode}
|
||||
building={this.props.building}
|
||||
/>
|
||||
</>
|
||||
:
|
||||
null
|
||||
}
|
||||
</>
|
||||
: null
|
||||
}
|
||||
</ContainerHeader>
|
||||
<div className="section-body">
|
||||
{
|
||||
this.props.inactive ?
|
||||
<Fragment>
|
||||
<InfoBox
|
||||
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
|
||||
/>
|
||||
<WrappedComponent
|
||||
intro={this.props.intro}
|
||||
building={undefined}
|
||||
building_like={undefined}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onCheck={this.handleCheck}
|
||||
onLike={this.handleLike}
|
||||
onUpdate={this.handleUpdate}
|
||||
/>
|
||||
</Fragment>
|
||||
:
|
||||
</Fragment> :
|
||||
this.props.building != undefined ?
|
||||
<form
|
||||
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
|
||||
method="POST"
|
||||
onSubmit={this.handleSubmit}>
|
||||
{
|
||||
(this.props.mode === 'edit' && !this.props.inactive) ?
|
||||
<Fragment>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
{
|
||||
this.props.cat !== 'like' ? // special-case for likes
|
||||
<div className="buttons-container with-space">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!edited}
|
||||
aria-disabled={!edited}>
|
||||
Save
|
||||
</button>
|
||||
{
|
||||
edited ?
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-warning"
|
||||
onClick={this.handleReset}
|
||||
>
|
||||
Discard changes
|
||||
</button> :
|
||||
null
|
||||
}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</Fragment>
|
||||
: null
|
||||
}
|
||||
<WrappedComponent
|
||||
intro={this.props.intro}
|
||||
building={currentBuilding}
|
||||
building_like={this.props.building_like}
|
||||
mode={this.props.mode}
|
||||
copy={copy}
|
||||
onChange={this.handleChange}
|
||||
onLike={this.handleLike}
|
||||
/>
|
||||
</form> :
|
||||
<InfoBox msg="Select a building to view data"></InfoBox>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withCopyEdit;
|
||||
export default withCopyEdit;
|
@ -6,37 +6,40 @@ import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import TextboxDataEntry from '../data-components/textbox-data-entry';
|
||||
import YearDataEntry from '../data-components/year-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Age view/edit section
|
||||
*/
|
||||
const AgeView = (props) => (
|
||||
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<YearDataEntry
|
||||
year={props.building.date_year}
|
||||
upper={props.building.date_upper}
|
||||
lower={props.building.date_lower}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Facade year"
|
||||
title={dataFields.facade_year.title}
|
||||
slug="facade_year"
|
||||
value={props.building.facade_year}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
tooltip="Best estimate"
|
||||
tooltip={dataFields.facade_year.tooltip}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="Source of information"
|
||||
title={dataFields.date_source.title}
|
||||
slug="date_source"
|
||||
value={props.building.date_source}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
tooltip="Source for the main start date"
|
||||
tooltip={dataFields.date_source.tooltip}
|
||||
placeholder=""
|
||||
options={[
|
||||
"Survey of London",
|
||||
@ -52,22 +55,22 @@ const AgeView = (props) => (
|
||||
]}
|
||||
/>
|
||||
<TextboxDataEntry
|
||||
title="Source details"
|
||||
title={dataFields.date_source_detail.title}
|
||||
slug="date_source_detail"
|
||||
value={props.building.date_source_detail}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
tooltip="References for date source (max 500 characters)"
|
||||
tooltip={dataFields.date_source_detail.tooltip}
|
||||
/>
|
||||
<MultiDataEntry
|
||||
title="Text and Image Links"
|
||||
title={dataFields.date_link.title}
|
||||
slug="date_link"
|
||||
value={props.building.date_link}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onUpdate}
|
||||
tooltip="URL for age and date reference"
|
||||
onChange={props.onChange}
|
||||
tooltip={dataFields.date_link.tooltip}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Fragment>
|
||||
|
@ -0,0 +1,21 @@
|
||||
interface CopyProps {
|
||||
copying: boolean;
|
||||
toggleCopying: () => void;
|
||||
toggleCopyAttribute: (key: string) => void;
|
||||
copyingKey: (key: string) => boolean;
|
||||
}
|
||||
|
||||
interface CategoryViewProps {
|
||||
intro: string;
|
||||
building: any; // TODO: add Building type with all fields
|
||||
building_like: boolean;
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
copy: CopyProps;
|
||||
onChange: (key: string, value: any) => void;
|
||||
onLike: (like: boolean) => void;
|
||||
}
|
||||
|
||||
export {
|
||||
CategoryViewProps,
|
||||
CopyProps
|
||||
};
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Community view/edit section
|
||||
*/
|
||||
const CommunityView = (props) => (
|
||||
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul className="data-list">
|
||||
|
@ -2,17 +2,18 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import LikeDataEntry from '../data-components/like-data-entry';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Like view/edit section
|
||||
*/
|
||||
const LikeView = (props) => (
|
||||
const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<LikeDataEntry
|
||||
value={props.building.likes_total}
|
||||
userLike={props.building_like}
|
||||
totalLikes={props.building.likes_total}
|
||||
mode={props.mode}
|
||||
onLike={props.onLike}
|
||||
building_like={props.building_like}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
|
@ -5,23 +5,25 @@ import DataEntry from '../data-components/data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import UPRNsDataEntry from '../data-components/uprns-data-entry';
|
||||
import InfoBox from '../../components/info-box';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const LocationView = (props) => (
|
||||
const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
|
||||
<DataEntry
|
||||
title="Building Name"
|
||||
title={dataFields.location_name.title}
|
||||
slug="location_name"
|
||||
value={props.building.location_name}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
tooltip="May not be needed for many buildings."
|
||||
tooltip={dataFields.location_name.tooltip}
|
||||
placeholder="Building name (if any)"
|
||||
disabled={true}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Building number"
|
||||
title={dataFields.location_number.title}
|
||||
slug="location_number"
|
||||
value={props.building.location_number}
|
||||
mode={props.mode}
|
||||
@ -30,7 +32,7 @@ const LocationView = (props) => (
|
||||
step={1}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Street"
|
||||
title={dataFields.location_street.title}
|
||||
slug="location_street"
|
||||
value={props.building.location_street}
|
||||
mode={props.mode}
|
||||
@ -39,7 +41,7 @@ const LocationView = (props) => (
|
||||
disabled={true}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Address line 2"
|
||||
title={dataFields.location_line_two.title}
|
||||
slug="location_line_two"
|
||||
value={props.building.location_line_two}
|
||||
mode={props.mode}
|
||||
@ -48,7 +50,7 @@ const LocationView = (props) => (
|
||||
disabled={true}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Town"
|
||||
title={dataFields.location_town.title}
|
||||
slug="location_town"
|
||||
value={props.building.location_town}
|
||||
mode={props.mode}
|
||||
@ -56,57 +58,58 @@ const LocationView = (props) => (
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Postcode"
|
||||
title={dataFields.location_postcode.title}
|
||||
slug="location_postcode"
|
||||
value={props.building.location_postcode}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
maxLength={8}
|
||||
valueTransform={x=>x.toUpperCase()}
|
||||
/>
|
||||
<DataEntry
|
||||
title="TOID"
|
||||
title={dataFields.ref_toid.title}
|
||||
slug="ref_toid"
|
||||
value={props.building.ref_toid}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip="Ordnance Survey Topography Layer ID (to be filled automatically)"
|
||||
tooltip={dataFields.ref_toid.tooltip}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<UPRNsDataEntry
|
||||
title="UPRNs"
|
||||
title={dataFields.uprns.title}
|
||||
value={props.building.uprns}
|
||||
tooltip="Unique Property Reference Numbers (to be filled automatically)"
|
||||
tooltip={dataFields.uprns.tooltip}
|
||||
/>
|
||||
<DataEntry
|
||||
title="OSM ID"
|
||||
title={dataFields.ref_osm_id.title}
|
||||
slug="ref_osm_id"
|
||||
value={props.building.ref_osm_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip="OpenStreetMap feature ID"
|
||||
tooltip={dataFields.ref_osm_id.tooltip}
|
||||
maxLength={20}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Latitude"
|
||||
title={dataFields.location_latitude.title}
|
||||
slug="location_latitude"
|
||||
value={props.building.location_latitude}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
step={0.0001}
|
||||
placeholder={51}
|
||||
placeholder="51"
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Longitude"
|
||||
title={dataFields.location_longitude.title}
|
||||
slug="location_longitude"
|
||||
value={props.building.location_longitude}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
step={0.0001}
|
||||
placeholder={0}
|
||||
placeholder="0"
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</Fragment>
|
||||
|
@ -4,196 +4,203 @@ import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Planning view/edit section
|
||||
*/
|
||||
const PlanningView = (props) => (
|
||||
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<DataEntry
|
||||
title="Planning portal link"
|
||||
title={dataFields.planning_portal_link.title}
|
||||
slug="planning_portal_link"
|
||||
value={props.building.planning_portal_link}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="In a conservation area?"
|
||||
slug="planning_in_conservation_area"
|
||||
value={props.building.planning_in_conservation_area}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Conservation area name"
|
||||
slug="planning_conservation_area_name"
|
||||
value={props.building.planning_conservation_area_name}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="Is listed on the National Heritage List for England?"
|
||||
slug="planning_in_list"
|
||||
value={props.building.planning_in_list}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="National Heritage List for England list id"
|
||||
slug="planning_list_id"
|
||||
value={props.building.planning_list_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="National Heritage List for England list type"
|
||||
slug="planning_list_cat"
|
||||
value={props.building.planning_list_cat}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
options={[
|
||||
"Listed Building",
|
||||
"Scheduled Monument",
|
||||
"World Heritage Site",
|
||||
"Building Preservation Notice",
|
||||
"None"
|
||||
]}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="Listing grade"
|
||||
slug="planning_list_grade"
|
||||
value={props.building.planning_list_grade}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
options={[
|
||||
"I",
|
||||
"II*",
|
||||
"II",
|
||||
"None"
|
||||
]}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Heritage at risk list id"
|
||||
slug="planning_heritage_at_risk_id"
|
||||
value={props.building.planning_heritage_at_risk_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="World heritage list id"
|
||||
slug="planning_world_list_id"
|
||||
value={props.building.planning_world_list_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="In the Greater London Historic Environment Record?"
|
||||
slug="planning_in_glher"
|
||||
value={props.building.planning_in_glher}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Greater London Historic Environment Record link"
|
||||
slug="planning_glher_url"
|
||||
value={props.building.planning_glher_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="In an Architectural Priority Area?"
|
||||
slug="planning_in_apa"
|
||||
value={props.building.planning_in_apa}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Architectural Priority Area name"
|
||||
slug="planning_apa_name"
|
||||
value={props.building.planning_apa_name}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Architectural Priority Area tier"
|
||||
slug="planning_apa_tier"
|
||||
value={props.building.planning_apa_tier}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="Is locally listed?"
|
||||
slug="planning_in_local_list"
|
||||
value={props.building.planning_in_local_list}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Local list link"
|
||||
slug="planning_local_list_url"
|
||||
value={props.building.planning_local_list_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="Within a historic area assessment?"
|
||||
slug="planning_in_historic_area_assessment"
|
||||
value={props.building.planning_in_historic_area_assessment}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Historic area assessment link"
|
||||
slug="planning_historic_area_assessment_url"
|
||||
value={props.building.planning_historic_area_assessment_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="Is the building proposed for demolition?"
|
||||
slug="planning_demolition_proposed"
|
||||
value={props.building.planning_demolition_proposed}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title="Has the building been demolished?"
|
||||
slug="planning_demolition_complete"
|
||||
value={props.building.planning_demolition_complete}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Dates of construction and demolition of previous buildings on site"
|
||||
slug="planning_demolition_history"
|
||||
value={props.building.planning_demolition_history}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<DataEntryGroup name="Listing and protections" >
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_conservation_area.title}
|
||||
slug="planning_in_conservation_area"
|
||||
value={props.building.planning_in_conservation_area}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_conservation_area_name.title}
|
||||
slug="planning_conservation_area_name"
|
||||
value={props.building.planning_conservation_area_name}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_list.title}
|
||||
slug="planning_in_list"
|
||||
value={props.building.planning_in_list}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_list_id.title}
|
||||
slug="planning_list_id"
|
||||
value={props.building.planning_list_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title={dataFields.planning_list_cat.title}
|
||||
slug="planning_list_cat"
|
||||
value={props.building.planning_list_cat}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
options={[
|
||||
"Listed Building",
|
||||
"Scheduled Monument",
|
||||
"World Heritage Site",
|
||||
"Building Preservation Notice",
|
||||
"None"
|
||||
]}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title={dataFields.planning_list_grade.title}
|
||||
slug="planning_list_grade"
|
||||
value={props.building.planning_list_grade}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
options={[
|
||||
"I",
|
||||
"II*",
|
||||
"II",
|
||||
"None"
|
||||
]}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_heritage_at_risk_id.title}
|
||||
slug="planning_heritage_at_risk_id"
|
||||
value={props.building.planning_heritage_at_risk_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_world_list_id.title}
|
||||
slug="planning_world_list_id"
|
||||
value={props.building.planning_world_list_id}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_glher.title}
|
||||
slug="planning_in_glher"
|
||||
value={props.building.planning_in_glher}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_glher_url.title}
|
||||
slug="planning_glher_url"
|
||||
value={props.building.planning_glher_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_apa.title}
|
||||
slug="planning_in_apa"
|
||||
value={props.building.planning_in_apa}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_apa_name.title}
|
||||
slug="planning_apa_name"
|
||||
value={props.building.planning_apa_name}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_apa_tier.title}
|
||||
slug="planning_apa_tier"
|
||||
value={props.building.planning_apa_tier}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_local_list.title}
|
||||
slug="planning_in_local_list"
|
||||
value={props.building.planning_in_local_list}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_local_list_url.title}
|
||||
slug="planning_local_list_url"
|
||||
value={props.building.planning_local_list_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_in_historic_area_assessment.title}
|
||||
slug="planning_in_historic_area_assessment"
|
||||
value={props.building.planning_in_historic_area_assessment}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_historic_area_assessment_url.title}
|
||||
slug="planning_historic_area_assessment_url"
|
||||
value={props.building.planning_historic_area_assessment_url}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
<DataEntryGroup name="Demolition and demolition history">
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_demolition_proposed.title}
|
||||
slug="planning_demolition_proposed"
|
||||
value={props.building.planning_demolition_proposed}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<CheckboxDataEntry
|
||||
title={dataFields.planning_demolition_complete.title}
|
||||
slug="planning_demolition_complete"
|
||||
value={props.building.planning_demolition_complete}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
<DataEntry
|
||||
title={dataFields.planning_demolition_history.title}
|
||||
slug="planning_demolition_history"
|
||||
value={props.building.planning_demolition_history}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
</Fragment>
|
||||
)
|
||||
const PlanningContainer = withCopyEdit(PlanningView);
|
||||
|
@ -3,81 +3,91 @@ import React, { Fragment } from 'react';
|
||||
import withCopyEdit from '../data-container';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Size view/edit section
|
||||
*/
|
||||
const SizeView = (props) => (
|
||||
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<DataEntryGroup name="Storeys" collapsed={false}>
|
||||
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_storeys_core.title}
|
||||
slug="size_storeys_core"
|
||||
value={props.building.size_storeys_core}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip={dataFields.size_storeys_core.tooltip}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_storeys_attic.title}
|
||||
slug="size_storeys_attic"
|
||||
value={props.building.size_storeys_attic}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip={dataFields.size_storeys_attic.tooltip}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_storeys_basement.title}
|
||||
slug="size_storeys_basement"
|
||||
value={props.building.size_storeys_basement}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip={dataFields.size_storeys_basement.tooltip}
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
<DataEntryGroup name="Height">
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_height_apex.title}
|
||||
slug="size_height_apex"
|
||||
value={props.building.size_height_apex}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_height_eaves.title}
|
||||
slug="size_height_eaves"
|
||||
disabled={true}
|
||||
value={props.building.size_height_eaves}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
<DataEntryGroup name="Floor area">
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_floor_area_ground.title}
|
||||
slug="size_floor_area_ground"
|
||||
value={props.building.size_floor_area_ground}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title={dataFields.size_floor_area_total.title}
|
||||
slug="size_floor_area_total"
|
||||
value={props.building.size_floor_area_total}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
<NumericDataEntry
|
||||
title="Core storeys"
|
||||
slug="size_storeys_core"
|
||||
value={props.building.size_storeys_core}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip="How many storeys between the pavement and start of roof?"
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Attic storeys"
|
||||
slug="size_storeys_attic"
|
||||
value={props.building.size_storeys_attic}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip="How many storeys above start of roof?"
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Basement storeys"
|
||||
slug="size_storeys_basement"
|
||||
value={props.building.size_storeys_basement}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
tooltip="How many storeys below pavement level?"
|
||||
onChange={props.onChange}
|
||||
step={1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Height to apex (m)"
|
||||
slug="size_height_apex"
|
||||
value={props.building.size_height_apex}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Height to eaves (m)"
|
||||
slug="size_height_eaves"
|
||||
disabled={true}
|
||||
value={props.building.size_height_eaves}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Ground floor area (m²)"
|
||||
slug="size_floor_area_ground"
|
||||
value={props.building.size_floor_area_ground}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Total floor area (m²)"
|
||||
slug="size_floor_area_total"
|
||||
value={props.building.size_floor_area_total}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Frontage Width (m)"
|
||||
title={dataFields.size_width_frontage.title}
|
||||
slug="size_width_frontage"
|
||||
value={props.building.size_width_frontage}
|
||||
mode={props.mode}
|
||||
@ -86,7 +96,7 @@ const SizeView = (props) => (
|
||||
step={0.1}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Total area of plot (m²)"
|
||||
title={dataFields.size_plot_area_total.title}
|
||||
slug="size_plot_area_total"
|
||||
value={props.building.size_plot_area_total}
|
||||
mode={props.mode}
|
||||
@ -96,7 +106,7 @@ const SizeView = (props) => (
|
||||
disabled={true}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="FAR ratio (percentage of plot covered by building)"
|
||||
title={dataFields.size_far_ratio.title}
|
||||
slug="size_far_ratio"
|
||||
value={props.building.size_far_ratio}
|
||||
mode={props.mode}
|
||||
@ -106,7 +116,7 @@ const SizeView = (props) => (
|
||||
disabled={true}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="Configuration (semi/detached, end/terrace)"
|
||||
title={dataFields.size_configuration.title}
|
||||
slug="size_configuration"
|
||||
value={props.building.size_configuration}
|
||||
mode={props.mode}
|
||||
@ -122,7 +132,7 @@ const SizeView = (props) => (
|
||||
]}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="Roof shape"
|
||||
title={dataFields.size_roof_shape.title}
|
||||
slug="size_roof_shape"
|
||||
value={props.building.size_roof_shape}
|
||||
mode={props.mode}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Streetscape view/edit section
|
||||
*/
|
||||
const StreetscapeView = (props) => (
|
||||
const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul className="data-list">
|
||||
|
@ -4,6 +4,8 @@ import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
const BreeamRatingOptions = [
|
||||
@ -17,57 +19,62 @@ const BreeamRatingOptions = [
|
||||
/**
|
||||
* Sustainability view/edit section
|
||||
*/
|
||||
const SustainabilityView = (props) => {
|
||||
const dataEntryProps = {
|
||||
mode: props.mode,
|
||||
copy: props.copy,
|
||||
onChange: props.onChange
|
||||
};
|
||||
const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<SelectDataEntry
|
||||
title="BREEAM Rating"
|
||||
title={dataFields.sust_breeam_rating.title}
|
||||
slug="sust_breeam_rating"
|
||||
value={props.building.sust_breeam_rating}
|
||||
tooltip="(Building Research Establishment Environmental Assessment Method) May not be present for many buildings"
|
||||
tooltip={dataFields.sust_breeam_rating.tooltip}
|
||||
options={BreeamRatingOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="DEC Rating"
|
||||
title={dataFields.sust_dec.title}
|
||||
slug="sust_dec"
|
||||
value={props.building.sust_dec}
|
||||
tooltip="(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use"
|
||||
tooltip={dataFields.sust_dec.tooltip}
|
||||
options={EnergyCategoryOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<SelectDataEntry
|
||||
title="EPC Rating"
|
||||
title={dataFields.sust_aggregate_estimate_epc.title}
|
||||
slug="sust_aggregate_estimate_epc"
|
||||
value={props.building.sust_aggregate_estimate_epc}
|
||||
tooltip="(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented"
|
||||
tooltip={dataFields.sust_aggregate_estimate_epc.tooltip}
|
||||
options={EnergyCategoryOptions}
|
||||
disabled={true}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Last significant retrofit"
|
||||
title={dataFields.sust_retrofit_date.title}
|
||||
slug="sust_retrofit_date"
|
||||
value={props.building.sust_retrofit_date}
|
||||
tooltip="Date of last major building refurbishment"
|
||||
tooltip={dataFields.sust_retrofit_date.tooltip}
|
||||
step={1}
|
||||
min={1086}
|
||||
max={new Date().getFullYear()}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="Expected lifespan for typology"
|
||||
title={dataFields.sust_life_expectancy.title}
|
||||
slug="sust_life_expectancy"
|
||||
value={props.building.sust_life_expectancy}
|
||||
step={1}
|
||||
min={1}
|
||||
disabled={true}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
|
@ -2,11 +2,12 @@ import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Team view/edit section
|
||||
*/
|
||||
const TeamView = (props) => (
|
||||
const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul>
|
||||
|
@ -4,6 +4,8 @@ import withCopyEdit from '../data-container';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const AttachmentFormOptions = [
|
||||
"Detached",
|
||||
@ -15,33 +17,39 @@ const AttachmentFormOptions = [
|
||||
/**
|
||||
* Type view/edit section
|
||||
*/
|
||||
const TypeView = (props) => {
|
||||
const {mode, copy, onChange} = props;
|
||||
const dataEntryProps = { mode, copy, onChange };
|
||||
|
||||
const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<SelectDataEntry
|
||||
title="Building configuration (attachment)?"
|
||||
title={dataFields.building_attachment_form.title}
|
||||
slug="building_attachment_form"
|
||||
value={props.building.building_attachment_form}
|
||||
tooltip="We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)"
|
||||
tooltip={dataFields.building_attachment_form.tooltip}
|
||||
options={AttachmentFormOptions}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<NumericDataEntry
|
||||
title="When did use change?"
|
||||
title={dataFields.date_change_building_use.title}
|
||||
slug="date_change_building_use"
|
||||
value={props.building.date_change_building_use}
|
||||
tooltip="This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened"
|
||||
tooltip={dataFields.date_change_building_use.tooltip}
|
||||
min={1086}
|
||||
max={new Date().getFullYear()}
|
||||
step={1}
|
||||
{...dataEntryProps}
|
||||
mode={props.mode}
|
||||
copy={props.copy}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<DataEntry
|
||||
title="Original building use"
|
||||
tooltip="What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse"
|
||||
title={dataFields.original_building_use.title}
|
||||
slug="original_building_use" // doesn't exist in database yet
|
||||
tooltip={dataFields.original_building_use.tooltip}
|
||||
value={undefined}
|
||||
copy={props.copy}
|
||||
mode={props.mode}
|
||||
onChange={props.onChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</Fragment>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
* Use view/edit section
|
||||
*/
|
||||
const UseView = (props) => (
|
||||
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<Fragment>
|
||||
<p className="data-intro">{props.intro}</p>
|
||||
<ul>
|
||||
|
@ -0,0 +1,14 @@
|
||||
.edit-history-entry {
|
||||
border-bottom: 1px solid black;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
|
||||
.edit-history-timestamp {
|
||||
font-size: 0.9em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.edit-history-username {
|
||||
font-size: 0.9em;
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
import { arrayToDictionary, parseDate } from '../../helpers';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CategoryEditSummary } from './category-edit-summary';
|
||||
|
||||
import './building-edit-summary.css';
|
||||
|
||||
interface BuildingEditSummaryProps {
|
||||
historyEntry: EditHistoryEntry
|
||||
}
|
||||
|
||||
function formatDate(dt: Date) {
|
||||
return dt.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = props => {
|
||||
const entriesWithMetadata = Object
|
||||
.entries(props.historyEntry.forward_patch)
|
||||
.map(([key, value]) => {
|
||||
const info = dataFields[key] || {};
|
||||
return {
|
||||
title: info.title || `Unknown field (${key})`,
|
||||
category: info.category || 'Unknown category',
|
||||
value: value,
|
||||
oldValue: props.historyEntry.reverse_patch && props.historyEntry.reverse_patch[key]
|
||||
};
|
||||
});
|
||||
const entriesByCategory = arrayToDictionary(entriesWithMetadata, x => x.category);
|
||||
|
||||
|
||||
return (
|
||||
<div className="edit-history-entry">
|
||||
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(props.historyEntry.date_trunc))}</h2>
|
||||
<h3 className="edit-history-username">By {props.historyEntry.username}</h3>
|
||||
{
|
||||
Object.entries(entriesByCategory).map(([category, fields]) => <CategoryEditSummary category={category} fields={fields} />)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
BuildingEditSummary
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
.edit-history-category-summary {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.edit-history-category-summary ul {
|
||||
list-style: none;
|
||||
padding-left: 0.5em;
|
||||
}
|
||||
|
||||
.edit-history-category-title {
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-history-diff {
|
||||
padding: 0 0.2rem;
|
||||
padding-top:0.15rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.edit-history-diff.old {
|
||||
background-color: #f8d9bc;
|
||||
color: #c24e00;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.edit-history-diff.new {
|
||||
background-color: #b6dcff;
|
||||
color: #0064c2;
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import './category-edit-summary.css';
|
||||
import { FieldEditSummary } from './field-edit-summary';
|
||||
|
||||
interface CategoryEditSummaryProps {
|
||||
category: string;
|
||||
fields: {
|
||||
title: string;
|
||||
value: string;
|
||||
oldValue: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const CategoryEditSummary : React.FunctionComponent<CategoryEditSummaryProps> = props => (
|
||||
<div className='edit-history-category-summary'>
|
||||
<h3 className='edit-history-category-title'>{props.category}:</h3>
|
||||
<ul>
|
||||
{
|
||||
props.fields.map(x =>
|
||||
<li key={x.title}>
|
||||
<FieldEditSummary title={x.title} value={x.value} oldValue={x.oldValue} />
|
||||
</li>)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
export {
|
||||
CategoryEditSummary
|
||||
};
|
8
app/src/frontend/building/edit-history/edit-history.css
Normal file
8
app/src/frontend/building/edit-history/edit-history.css
Normal file
@ -0,0 +1,8 @@
|
||||
.edit-history {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.edit-history-list {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
}
|
46
app/src/frontend/building/edit-history/edit-history.tsx
Normal file
46
app/src/frontend/building/edit-history/edit-history.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
import { BuildingEditSummary } from './building-edit-summary';
|
||||
|
||||
import './edit-history.css';
|
||||
import { Building } from '../../models/building';
|
||||
import ContainerHeader from '../container-header';
|
||||
|
||||
interface EditHistoryProps {
|
||||
building: Building;
|
||||
}
|
||||
|
||||
const EditHistory: React.FunctionComponent<EditHistoryProps> = (props) => {
|
||||
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const res = await fetch(`/api/buildings/${props.building.building_id}/history.json`);
|
||||
const data = await res.json();
|
||||
|
||||
setHistory(data.history);
|
||||
};
|
||||
|
||||
if (props.building != undefined) { // only call fn if there is a building provided
|
||||
fetchData(); // define and call, because effect cannot return anything and an async fn always returns a Promise
|
||||
}
|
||||
}, [props.building]); // only re-run effect on building prop change
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContainerHeader title="Edit history" backLink='.' cat='edit-history' />
|
||||
|
||||
<ul className="edit-history-list">
|
||||
{history && history.map(entry => (
|
||||
<li key={`${entry.revision_id}`} className="edit-history-list-element">
|
||||
<BuildingEditSummary historyEntry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
EditHistory
|
||||
};
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface FieldEditSummaryProps {
|
||||
title: string;
|
||||
value: any;
|
||||
oldValue: any;
|
||||
}
|
||||
|
||||
function formatValue(value: any) {
|
||||
if(typeof value === 'boolean') {
|
||||
return value ? 'Yes' : 'No';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const FieldEditSummary: React.FunctionComponent<FieldEditSummaryProps> = props => (
|
||||
<>
|
||||
{props.title}:
|
||||
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
|
||||
|
||||
<code title="Value after edit" className='edit-history-diff new'>{formatValue(props.value)}</code>
|
||||
</>
|
||||
);
|
||||
|
||||
export {
|
||||
FieldEditSummary
|
||||
};
|
35
app/src/frontend/building/header-buttons/copy-control.tsx
Normal file
35
app/src/frontend/building/header-buttons/copy-control.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
interface CopyControlProps {
|
||||
cat: string;
|
||||
data_string: string;
|
||||
copying: boolean;
|
||||
toggleCopying: () => void;
|
||||
}
|
||||
|
||||
const CopyControl: React.FC<CopyControlProps> = props => (
|
||||
props.copying ?
|
||||
<>
|
||||
<NavLink
|
||||
to={`/multi-edit/${props.cat}?data=${props.data_string}`}
|
||||
className="icon-button copy">
|
||||
Copy selected
|
||||
</NavLink>
|
||||
<a
|
||||
className="icon-button copy"
|
||||
onClick={props.toggleCopying}>
|
||||
Cancel
|
||||
</a>
|
||||
</>
|
||||
:
|
||||
<a
|
||||
className="icon-button copy"
|
||||
onClick={props.toggleCopying}>
|
||||
Copy
|
||||
</a>
|
||||
);
|
||||
|
||||
export {
|
||||
CopyControl
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Building } from '../../models/building';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ViewIcon, EditIcon } from '../../components/icons';
|
||||
|
||||
interface ViewEditControlProps {
|
||||
cat: string;
|
||||
mode: 'view' | 'edit';
|
||||
building: Building;
|
||||
}
|
||||
|
||||
const ViewEditControl: React.FC<ViewEditControlProps> = props => (
|
||||
(props.mode === 'edit')?
|
||||
<NavLink
|
||||
className="icon-button view"
|
||||
title="View data"
|
||||
to={`/view/${props.cat}/${props.building.building_id}`}>
|
||||
View
|
||||
<ViewIcon />
|
||||
</NavLink>
|
||||
: <NavLink
|
||||
className="icon-button edit"
|
||||
title="Edit data"
|
||||
to={`/edit/${props.cat}/${props.building.building_id}`}>
|
||||
Edit
|
||||
<EditIcon />
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export {
|
||||
ViewEditControl
|
||||
};
|
7
app/src/frontend/building/models/edit-history-entry.ts
Normal file
7
app/src/frontend/building/models/edit-history-entry.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface EditHistoryEntry {
|
||||
date_trunc: string;
|
||||
username: string;
|
||||
revision_id: string;
|
||||
forward_patch: object;
|
||||
reverse_patch: object;
|
||||
}
|
@ -5,9 +5,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import Sidebar from './sidebar';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { sanitiseURL } from '../helpers';
|
||||
import { BackIcon }from '../components/icons';
|
||||
import DataEntry from './data-components/data-entry';
|
||||
import { dataFields } from '../data_fields';
|
||||
|
||||
const CONFIG = [];
|
||||
|
||||
const MultiEdit = (props) => {
|
||||
if (!props.user){
|
||||
@ -19,8 +20,8 @@ const MultiEdit = (props) => {
|
||||
return (
|
||||
<Sidebar>
|
||||
<section className='data-section'>
|
||||
<header className={`section-header view ${cat} active`}>
|
||||
<a><h3 className="h3">Like me!</h3></a>
|
||||
<header className={`section-header view ${cat} background-${cat}`}>
|
||||
<h2 className="h2">Like me!</h2>
|
||||
</header>
|
||||
<form className='buttons-container'>
|
||||
<InfoBox msg='Click all the buildings that you like and think contribute to the city!' />
|
||||
@ -34,25 +35,48 @@ const MultiEdit = (props) => {
|
||||
}
|
||||
|
||||
const q = parse(props.location.search);
|
||||
const data = JSON.parse(q.data as string) // TODO: verify what happens when data is string[]
|
||||
const title = sectionTitleFromCat(cat);
|
||||
|
||||
let data: object;
|
||||
if (cat === 'like'){
|
||||
data = { like: true }
|
||||
} else {
|
||||
try {
|
||||
// TODO: verify what happens if data is string[]
|
||||
data = JSON.parse(q.data as string);
|
||||
} catch (error) {
|
||||
console.error(error, q)
|
||||
data = {}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<section className='data-section'>
|
||||
<header className={`section-header view ${cat} active`}>
|
||||
<a><h3 className="h3">{title}</h3></a>
|
||||
<header className={`section-header view ${cat} background-${cat}`}>
|
||||
<Link
|
||||
className="icon-button back"
|
||||
to={`/edit/${cat}`}>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<h2 className="h2">Copy {cat} data</h2>
|
||||
</header>
|
||||
<Fragment>
|
||||
<form>
|
||||
<InfoBox msg='Click buildings one at a time to colour using the data below' />
|
||||
{
|
||||
Object.keys(data).map((key => {
|
||||
const label = fieldTitleFromSlug(key);
|
||||
return <DataEntry key={key} label={label} value={data[key]}/>
|
||||
const info = dataFields[key] || {};
|
||||
return (
|
||||
<DataEntry
|
||||
title={info.title || `Unknown field (${key})`}
|
||||
slug={key}
|
||||
disabled={true}
|
||||
value={data[key]}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
}
|
||||
</Fragment>
|
||||
</form>
|
||||
<form className='buttons-container'>
|
||||
<InfoBox msg='Click buildings to colour using the data above' />
|
||||
|
||||
<Link to={`/view/${cat}`} className='btn btn-secondary'>Back to view</Link>
|
||||
<Link to={`/edit/${cat}`} className='btn btn-secondary'>Back to edit</Link>
|
||||
</form>
|
||||
@ -67,63 +91,4 @@ MultiEdit.propTypes = {
|
||||
location: PropTypes.object
|
||||
}
|
||||
|
||||
const DataEntry = (props) => {
|
||||
let content;
|
||||
|
||||
if (props.value != null && props.value !== '') {
|
||||
if (typeof(props.value) === 'boolean') {
|
||||
content = (props.value)? 'Yes' : 'No'
|
||||
} else if (Array.isArray(props.value)) {
|
||||
if (props.value.length) {
|
||||
content = <ul>{
|
||||
props.value.map((item, index) => {
|
||||
return <li key={index}><a href={sanitiseURL(item)}>{item}</a></li>
|
||||
})
|
||||
}</ul>
|
||||
} else {
|
||||
content = '\u00A0'
|
||||
}
|
||||
} else {
|
||||
content = props.value
|
||||
}
|
||||
} else {
|
||||
content = '\u00A0'
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<dt>{props.label}</dt>
|
||||
<dd>{content}</dd>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function sectionTitleFromCat(cat) {
|
||||
for (let index = 0; index < CONFIG.length; index++) {
|
||||
const section = CONFIG[index];
|
||||
if (section.slug === cat) {
|
||||
return section.title
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fieldTitleFromSlug(slug) {
|
||||
const fields = CONFIG.reduce(
|
||||
(prev, section) => {
|
||||
const el = prev.concat(
|
||||
section.fields.filter(
|
||||
(field: any) => field.slug === slug // TODO: remove any
|
||||
)
|
||||
)
|
||||
return el
|
||||
}, []
|
||||
)
|
||||
if (fields.length === 1 && fields[0].title) {
|
||||
return fields[0].title
|
||||
} else {
|
||||
console.error('Expected single match, got', fields)
|
||||
}
|
||||
}
|
||||
|
||||
export default MultiEdit;
|
||||
|
@ -5,17 +5,10 @@
|
||||
order: 1;
|
||||
padding: 0 0 2em;
|
||||
background: #fff;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
.info-container h2:first-child {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: -0.1em;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px){
|
||||
.info-container {
|
||||
order: 0;
|
||||
@ -34,6 +27,14 @@
|
||||
text-decoration: none;
|
||||
color: #222;
|
||||
padding: 0.75rem 0.25rem 0.5rem 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.section-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
.section-header h2,
|
||||
.section-header .icon-buttons {
|
||||
@ -99,7 +100,8 @@
|
||||
color: rgb(11, 225, 225);
|
||||
}
|
||||
.icon-button.help,
|
||||
.icon-button.copy {
|
||||
.icon-button.copy,
|
||||
.icon-button.history {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.data-section label .icon-buttons .icon-button.copy {
|
||||
@ -139,6 +141,11 @@
|
||||
/**
|
||||
* Data list sections
|
||||
*/
|
||||
|
||||
.section-body {
|
||||
margin-top: 0.75em;
|
||||
padding: 0 0.75em;
|
||||
}
|
||||
.data-section .h3 {
|
||||
margin: 0;
|
||||
}
|
||||
@ -162,9 +169,7 @@
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.data-section form {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.data-list a {
|
||||
color: #555;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import React from 'react'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuestionCircle, faPaintBrush, faInfoCircle, faTimes, faCheck, faCheckDouble,
|
||||
faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||
faAngleLeft, faCaretDown, faSearch, faEye, faCaretUp, faCaretRight } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(
|
||||
faQuestionCircle,
|
||||
@ -17,6 +17,7 @@ library.add(
|
||||
faAngleLeft,
|
||||
faCaretDown,
|
||||
faCaretUp,
|
||||
faCaretRight,
|
||||
faSearch,
|
||||
faEye
|
||||
);
|
||||
@ -61,6 +62,10 @@ const UpIcon = () => (
|
||||
<FontAwesomeIcon icon="caret-up" />
|
||||
);
|
||||
|
||||
const RightIcon = () => (
|
||||
<FontAwesomeIcon icon="caret-right" />
|
||||
);
|
||||
|
||||
const SearchIcon = () => (
|
||||
<FontAwesomeIcon icon="search" />
|
||||
);
|
||||
@ -76,5 +81,6 @@ export {
|
||||
BackIcon,
|
||||
DownIcon,
|
||||
UpIcon,
|
||||
RightIcon,
|
||||
SearchIcon
|
||||
};
|
||||
|
@ -26,22 +26,22 @@ const Logo: React.FunctionComponent<LogoProps> = (props) => {
|
||||
const LogoGrid: React.FunctionComponent = () => (
|
||||
<div className="grid">
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-location"></div>
|
||||
<div className="cell background-use"></div>
|
||||
<div className="cell background-type"></div>
|
||||
<div className="cell background-age"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-size"></div>
|
||||
<div className="cell background-construction"></div>
|
||||
<div className="cell background-streetscape"></div>
|
||||
<div className="cell background-team"></div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell"></div>
|
||||
<div className="cell background-sustainability"></div>
|
||||
<div className="cell background-community"></div>
|
||||
<div className="cell background-planning"></div>
|
||||
<div className="cell background-like"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
344
app/src/frontend/data_fields.ts
Normal file
344
app/src/frontend/data_fields.ts
Normal file
@ -0,0 +1,344 @@
|
||||
export enum Category {
|
||||
Location = 'Location',
|
||||
LandUse = 'Land Use',
|
||||
Type = 'Type',
|
||||
Age = 'Age',
|
||||
SizeShape = 'Size & Shape',
|
||||
Construction = 'Construction',
|
||||
Streetscape = 'Streetscape',
|
||||
Team = 'Team',
|
||||
Sustainability = 'Sustainability',
|
||||
Community = 'Community',
|
||||
Planning = 'Planning',
|
||||
Like = 'Like Me!'
|
||||
}
|
||||
|
||||
export const categoriesOrder: Category[] = [
|
||||
Category.Location,
|
||||
Category.LandUse,
|
||||
Category.Type,
|
||||
Category.Age,
|
||||
Category.SizeShape,
|
||||
Category.Construction,
|
||||
Category.Streetscape,
|
||||
Category.Team,
|
||||
Category.Sustainability,
|
||||
Category.Community,
|
||||
Category.Planning,
|
||||
Category.Like,
|
||||
];
|
||||
|
||||
export const dataFields = {
|
||||
location_name: {
|
||||
category: Category.Location,
|
||||
title: "Building Name",
|
||||
tooltip: "May not be needed for many buildings.",
|
||||
},
|
||||
location_number: {
|
||||
category: Category.Location,
|
||||
title: "Building number",
|
||||
},
|
||||
location_street: {
|
||||
category: Category.Location,
|
||||
title: "Street",
|
||||
//tooltip: ,
|
||||
},
|
||||
location_line_two: {
|
||||
category: Category.Location,
|
||||
title: "Address line 2",
|
||||
//tooltip: ,
|
||||
},
|
||||
location_town: {
|
||||
category: Category.Location,
|
||||
title: "Town",
|
||||
//tooltip: ,
|
||||
},
|
||||
location_postcode: {
|
||||
category: Category.Location,
|
||||
title: "Postcode",
|
||||
//tooltip: ,
|
||||
},
|
||||
ref_toid: {
|
||||
category: Category.Location,
|
||||
title: "TOID",
|
||||
tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)",
|
||||
},
|
||||
|
||||
/**
|
||||
* UPRNs is not part of the buildings table, but the string fields
|
||||
* are included here for completeness
|
||||
*/
|
||||
uprns: {
|
||||
category: Category.Location,
|
||||
title: "UPRNs",
|
||||
tooltip: "Unique Property Reference Numbers (to be filled automatically)"
|
||||
},
|
||||
|
||||
ref_osm_id: {
|
||||
category: Category.Location,
|
||||
title: "OSM ID",
|
||||
tooltip: "OpenStreetMap feature ID",
|
||||
},
|
||||
location_latitude: {
|
||||
category: Category.Location,
|
||||
title: "Latitude",
|
||||
},
|
||||
location_longitude: {
|
||||
category: Category.Location,
|
||||
title: "Longitude",
|
||||
},
|
||||
|
||||
building_attachment_form: {
|
||||
category: Category.Type,
|
||||
title: "Building configuration (attachment)?",
|
||||
tooltip: "We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)",
|
||||
},
|
||||
date_change_building_use: {
|
||||
category: Category.Type,
|
||||
title:"When did use change?",
|
||||
tooltip: "This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened",
|
||||
},
|
||||
/**
|
||||
* original_building_use does not exist in database yet.
|
||||
* Slug needs to be adjusted if the db column will be named differently
|
||||
*/
|
||||
original_building_use: {
|
||||
category: Category.Type,
|
||||
title: "Original building use",
|
||||
tooltip: "What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse",
|
||||
},
|
||||
|
||||
date_year: {
|
||||
category: Category.Age,
|
||||
title: "Year built (best estimate)"
|
||||
},
|
||||
date_lower : {
|
||||
category: Category.Age,
|
||||
title: "Earliest possible start date",
|
||||
tooltip: "This should be the earliest year in which building could have started."
|
||||
},
|
||||
date_upper: {
|
||||
category: Category.Age,
|
||||
title: "Latest possible start year",
|
||||
tooltip: "This should be the latest year in which building could have started."
|
||||
},
|
||||
facade_year: {
|
||||
category: Category.Age,
|
||||
title: "Facade year",
|
||||
tooltip: "Best estimate"
|
||||
},
|
||||
date_source: {
|
||||
category: Category.Age,
|
||||
title: "Source of information",
|
||||
tooltip: "Source for the main start date"
|
||||
},
|
||||
date_source_detail: {
|
||||
category: Category.Age,
|
||||
title: "Source details",
|
||||
tooltip: "References for date source (max 500 characters)"
|
||||
},
|
||||
date_link: {
|
||||
category: Category.Age,
|
||||
title: "Text and Image Links",
|
||||
tooltip: "URL for age and date reference",
|
||||
},
|
||||
|
||||
size_storeys_core: {
|
||||
category: Category.SizeShape,
|
||||
title: "Core storeys",
|
||||
tooltip: "How many storeys between the pavement and start of roof?",
|
||||
},
|
||||
size_storeys_attic: {
|
||||
category: Category.SizeShape,
|
||||
title: "Attic storeys",
|
||||
tooltip: "How many storeys above start of roof?",
|
||||
},
|
||||
size_storeys_basement: {
|
||||
category: Category.SizeShape,
|
||||
title: "Basement storeys",
|
||||
tooltip: "How many storeys below pavement level?",
|
||||
},
|
||||
size_height_apex: {
|
||||
category: Category.SizeShape,
|
||||
title: "Height to apex (m)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_height_eaves: {
|
||||
category: Category.SizeShape,
|
||||
title: "Height to eaves (m)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_floor_area_ground: {
|
||||
category: Category.SizeShape,
|
||||
title: "Ground floor area (m²)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_floor_area_total: {
|
||||
category: Category.SizeShape,
|
||||
title: "Total floor area (m²)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_width_frontage: {
|
||||
category: Category.SizeShape,
|
||||
title: "Frontage Width (m)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_plot_area_total: {
|
||||
category: Category.SizeShape,
|
||||
title: "Total area of plot (m²)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_far_ratio: {
|
||||
category: Category.SizeShape,
|
||||
title: "FAR ratio (percentage of plot covered by building)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_configuration: {
|
||||
category: Category.SizeShape,
|
||||
title: "Configuration (semi/detached, end/terrace)",
|
||||
//tooltip: ,
|
||||
},
|
||||
size_roof_shape: {
|
||||
category: Category.SizeShape,
|
||||
title: "Roof shape",
|
||||
//tooltip: ,
|
||||
},
|
||||
|
||||
sust_breeam_rating: {
|
||||
category: Category.Sustainability,
|
||||
title: "BREEAM Rating",
|
||||
tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings",
|
||||
},
|
||||
sust_dec: {
|
||||
category: Category.Sustainability,
|
||||
title: "DEC Rating",
|
||||
tooltip: "(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use",
|
||||
},
|
||||
sust_aggregate_estimate_epc: {
|
||||
category: Category.Sustainability,
|
||||
title: "EPC Rating",
|
||||
tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented",
|
||||
},
|
||||
sust_retrofit_date: {
|
||||
category: Category.Sustainability,
|
||||
title: "Last significant retrofit",
|
||||
tooltip: "Date of last major building refurbishment",
|
||||
},
|
||||
sust_life_expectancy: {
|
||||
category: Category.Sustainability,
|
||||
title: "Expected lifespan for typology",
|
||||
//tooltip: ,
|
||||
},
|
||||
|
||||
planning_portal_link: {
|
||||
category: Category.Planning,
|
||||
title: "Planning portal link",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_conservation_area: {
|
||||
category: Category.Planning,
|
||||
title: "In a conservation area?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_conservation_area_name: {
|
||||
category: Category.Planning,
|
||||
title: "Conservation area name",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_list: {
|
||||
category: Category.Planning,
|
||||
title: "Is listed on the National Heritage List for England?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_list_id: {
|
||||
category: Category.Planning,
|
||||
title: "National Heritage List for England list id",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_list_cat: {
|
||||
category: Category.Planning,
|
||||
title: "National Heritage List for England list type",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_list_grade: {
|
||||
category: Category.Planning,
|
||||
title: "Listing grade",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_heritage_at_risk_id: {
|
||||
category: Category.Planning,
|
||||
title: "Heritage at risk list id",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_world_list_id: {
|
||||
category: Category.Planning,
|
||||
title: "World heritage list id",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_glher: {
|
||||
category: Category.Planning,
|
||||
title: "In the Greater London Historic Environment Record?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_glher_url: {
|
||||
category: Category.Planning,
|
||||
title: "Greater London Historic Environment Record link",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_apa: {
|
||||
category: Category.Planning,
|
||||
title: "In an Architectural Priority Area?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_apa_name: {
|
||||
category: Category.Planning,
|
||||
title: "Architectural Priority Area name",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_apa_tier: {
|
||||
category: Category.Planning,
|
||||
title: "Architectural Priority Area tier",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_local_list: {
|
||||
category: Category.Planning,
|
||||
title: "Is locally listed?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_local_list_url: {
|
||||
category: Category.Planning,
|
||||
title: "Local list link",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_in_historic_area_assessment: {
|
||||
category: Category.Planning,
|
||||
title: "Within a historic area assessment?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_historic_area_assessment_url: {
|
||||
category: Category.Planning,
|
||||
title: "Historic area assessment link",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_demolition_proposed: {
|
||||
category: Category.Planning,
|
||||
title: "Is the building proposed for demolition?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_demolition_complete: {
|
||||
category: Category.Planning,
|
||||
title: "Has the building been demolished?",
|
||||
//tooltip: ,
|
||||
},
|
||||
planning_demolition_history: {
|
||||
category: Category.Planning,
|
||||
title: "Dates of construction and demolition of previous buildings on site",
|
||||
//tooltip: ,
|
||||
},
|
||||
|
||||
likes_total: {
|
||||
category: Category.Like,
|
||||
title: "Total number of likes"
|
||||
}
|
||||
|
||||
};
|
@ -5,14 +5,25 @@ import PropTypes from 'prop-types';
|
||||
import { Logo } from './components/logo';
|
||||
import './header.css';
|
||||
|
||||
|
||||
interface HeaderProps {
|
||||
user: any;
|
||||
animateLogo: boolean;
|
||||
}
|
||||
|
||||
interface HeaderState {
|
||||
collapseMenu: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main header using a responsive design
|
||||
*/
|
||||
class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
class Header extends React.Component<HeaderProps, HeaderState> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string
|
||||
})
|
||||
}),
|
||||
animateLogo: PropTypes.bool
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
@ -40,7 +51,7 @@ class Header extends React.Component<any, any> { // TODO: add proper types
|
||||
<nav className="navbar navbar-light navbar-expand-lg">
|
||||
<span className="navbar-brand align-self-start">
|
||||
<NavLink to="/">
|
||||
<Logo variant='animated'/>
|
||||
<Logo variant={this.props.animateLogo ? 'animated' : 'default'}/>
|
||||
</NavLink>
|
||||
</span>
|
||||
<button className="navbar-toggler navbar-toggler-right" type="button"
|
||||
|
@ -36,4 +36,47 @@ function sanitiseURL(string){
|
||||
return `${url_.protocol}//${url_.hostname}${url_.pathname || ''}${url_.search || ''}${url_.hash || ''}`
|
||||
}
|
||||
|
||||
export { sanitiseURL }
|
||||
/**
|
||||
* Transform an array of objects into a dictionary of arrays of objects,
|
||||
* where the objects are grouped into arrays given an arbitrary key function
|
||||
* that gives a key for each object.
|
||||
* @param arr array of objects to group
|
||||
* @param keyAccessor function returning the grouping key for each object in the original array
|
||||
*/
|
||||
function arrayToDictionary<T>(arr: T[], keyAccessor: (obj: T) => string): {[key: string]: T[]} {
|
||||
return arr.reduce((obj, item) => {
|
||||
(obj[keyAccessor(item)] = obj[keyAccessor(item)] || []).push(item);
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string containing an ISO8601 formatted date
|
||||
* @param isoUtcDate a date string in ISO8601 format, assuming UTC
|
||||
* @returns a JS Date object with the UTC time encoded
|
||||
*/
|
||||
function parseDate(isoUtcDate: string): Date {
|
||||
const [year, month, day, hour, minute, second, millisecond] = isoUtcDate.match(/^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d).(\d{3})Z$/)
|
||||
.splice(1)
|
||||
.map(x => parseInt(x, 10));
|
||||
return new Date(Date.UTC(year, month-1, day, hour, minute, second, millisecond));
|
||||
}
|
||||
|
||||
function compareObjects(objA: object, objB: object): [object, object] {
|
||||
const reverse = {}
|
||||
const forward = {}
|
||||
for (const [key, value] of Object.entries(objB)) {
|
||||
if (objA[key] !== value) {
|
||||
reverse[key] = objA[key];
|
||||
forward[key] = value;
|
||||
}
|
||||
}
|
||||
return [forward, reverse];
|
||||
}
|
||||
|
||||
export {
|
||||
sanitiseURL,
|
||||
arrayToDictionary,
|
||||
parseDate,
|
||||
compareObjects
|
||||
};
|
||||
|
@ -9,6 +9,8 @@ import MultiEdit from './building/multi-edit';
|
||||
import BuildingView from './building/building-view';
|
||||
import ColouringMap from './map/map';
|
||||
import { parse } from 'query-string';
|
||||
import { EditHistory } from './building/edit-history/edit-history';
|
||||
import { Building } from './models/building';
|
||||
|
||||
interface MapAppRouteParams {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
@ -17,15 +19,16 @@ interface MapAppRouteParams {
|
||||
}
|
||||
|
||||
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
|
||||
building: any;
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
interface MapAppState {
|
||||
category: string;
|
||||
revision_id: number;
|
||||
building: any;
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
}
|
||||
|
||||
@ -40,12 +43,9 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
constructor(props: Readonly<MapAppProps>) {
|
||||
super(props);
|
||||
|
||||
// set building revision id, default 0
|
||||
const rev = props.building != undefined ? +props.building.revision_id : 0;
|
||||
|
||||
this.state = {
|
||||
category: this.getCategory(props.match.params.category),
|
||||
revision_id: rev,
|
||||
revision_id: props.revisionId || 0,
|
||||
building: props.building,
|
||||
building_like: props.building_like
|
||||
};
|
||||
@ -62,6 +62,27 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLatestRevision();
|
||||
}
|
||||
|
||||
async fetchLatestRevision() {
|
||||
try {
|
||||
const res = await fetch(`/api/buildings/revision`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
this.increaseRevision(data.latestRevisionId);
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
getCategory(category: string) {
|
||||
if (category === 'categories') return undefined;
|
||||
|
||||
@ -76,7 +97,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
selectBuilding(building) {
|
||||
selectBuilding(building: Building) {
|
||||
const mode = this.props.match.params.mode || 'view';
|
||||
const category = this.props.match.params.category || 'age';
|
||||
|
||||
@ -142,11 +163,17 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
colourBuilding(building) {
|
||||
const cat = this.props.match.params.category;
|
||||
const q = parse(window.location.search);
|
||||
const data = (cat === 'like') ? { like: true } : JSON.parse(q.data as string); // TODO: verify what happens if data is string[]
|
||||
|
||||
if (cat === 'like') {
|
||||
this.likeBuilding(building.building_id)
|
||||
} else {
|
||||
this.updateBuilding(building.building_id, data)
|
||||
try {
|
||||
// TODO: verify what happens if data is string[]
|
||||
const data = JSON.parse(q.data as string);
|
||||
this.updateBuilding(building.building_id, data)
|
||||
} catch (error) {
|
||||
console.error(error, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,7 +220,8 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const mode = this.props.match.params.mode || 'basic';
|
||||
const mode = this.props.match.params.mode;
|
||||
const viewEditMode = mode === 'multi-edit' ? undefined : mode;
|
||||
|
||||
let category = this.state.category || 'age';
|
||||
|
||||
@ -219,7 +247,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
<Route exact path="/:mode/:cat/:building?">
|
||||
<Sidebar>
|
||||
<BuildingView
|
||||
mode={mode}
|
||||
mode={viewEditMode}
|
||||
cat={category}
|
||||
building={this.state.building}
|
||||
building_like={this.state.building_like}
|
||||
@ -228,13 +256,18 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
/>
|
||||
</Sidebar>
|
||||
</Route>
|
||||
<Route exact path="/(view|edit|multi-edit)">
|
||||
<Redirect to="/view/categories" />
|
||||
<Route exact path="/:mode/:cat/:building/history">
|
||||
<Sidebar>
|
||||
<EditHistory building={this.state.building} />
|
||||
</Sidebar>
|
||||
</Route>
|
||||
<Route exact path="/:mode(view|edit|multi-edit)"
|
||||
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
|
||||
/>
|
||||
</Switch>
|
||||
<ColouringMap
|
||||
building={this.state.building}
|
||||
mode={mode}
|
||||
mode={mode || 'basic'}
|
||||
category={category}
|
||||
revision_id={this.state.revision_id}
|
||||
selectBuilding={this.selectBuilding}
|
||||
@ -245,4 +278,4 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
export default MapApp;
|
||||
export default MapApp;
|
||||
|
@ -3,7 +3,7 @@
|
||||
*/
|
||||
.map-legend {
|
||||
position: absolute;
|
||||
bottom: 2.5rem;
|
||||
bottom: 3.5rem;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
|
||||
@ -34,7 +34,12 @@
|
||||
}
|
||||
|
||||
/* Prevent legend from overlapping with attribution */
|
||||
@media (min-width: 706px){
|
||||
@media(min-width: 392px) {
|
||||
.map-legend {
|
||||
bottom: 2.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 760px){
|
||||
.map-legend {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
@ -44,7 +49,7 @@
|
||||
bottom: 2.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1072px){
|
||||
@media (min-width: 1129px){
|
||||
.map-legend {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
|
@ -102,12 +102,13 @@ const LEGEND_CONFIG = {
|
||||
like: {
|
||||
title: 'Like Me',
|
||||
elements: [
|
||||
{ color: '#bd0026', text: '👍👍👍 ≥10' },
|
||||
{ color: '#e31a1c', text: '👍👍 5–10' },
|
||||
{ color: '#fc4e2a', text: '👍 4' },
|
||||
{ color: '#fd8d3c', text: '👍 3' },
|
||||
{ color: '#feb24c', text: '👍 2' },
|
||||
{ color: '#fed976', text: '👍 1' },
|
||||
{ color: '#bd0026', text: '👍👍👍👍 100+' },
|
||||
{ color: '#e31a1c', text: '👍👍👍 50–99' },
|
||||
{ color: '#fc4e2a', text: '👍👍 20–49' },
|
||||
{ color: '#fd8d3c', text: '👍👍 10–19' },
|
||||
{ color: '#feb24c', text: '👍 3–9' },
|
||||
{ color: '#fed976', text: '👍 2' },
|
||||
{ color: '#ffe8a9', text: '👍 1'}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { LatLngExpression } from 'leaflet';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl } from 'react-leaflet-universal';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl, GeoJSON } from 'react-leaflet-universal';
|
||||
import { GeoJsonObject } from 'geojson';
|
||||
|
||||
import '../../../node_modules/leaflet/dist/leaflet.css'
|
||||
import './map.css'
|
||||
|
||||
import { HelpIcon } from '../components/icons';
|
||||
import Legend from './legend';
|
||||
import { parseCategoryURL } from '../../parse';
|
||||
import SearchBox from './search-box';
|
||||
import ThemeSwitcher from './theme-switcher';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
|
||||
|
||||
interface ColouringMapProps {
|
||||
building: any;
|
||||
building: Building;
|
||||
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
|
||||
category: string;
|
||||
revision_id: number;
|
||||
@ -28,11 +28,12 @@ interface ColouringMapState {
|
||||
lat: number;
|
||||
lng: number;
|
||||
zoom: number;
|
||||
boundary: GeoJsonObject;
|
||||
}
|
||||
/**
|
||||
* Map area
|
||||
*/
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
building: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
@ -48,7 +49,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
theme: 'night',
|
||||
lat: 51.5245255,
|
||||
lng: -0.1338422,
|
||||
zoom: 16
|
||||
zoom: 16,
|
||||
boundary: undefined,
|
||||
};
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleLocate = this.handleLocate.bind(this);
|
||||
@ -83,8 +85,11 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
this.props.selectBuilding(undefined);
|
||||
}
|
||||
} else {
|
||||
// deselect but keep/return to expected colour theme
|
||||
this.props.selectBuilding(undefined);
|
||||
if (mode !== 'multi-edit') {
|
||||
// deselect but keep/return to expected colour theme
|
||||
// except if in multi-edit (never select building, only colour on click)
|
||||
this.props.selectBuilding(undefined);
|
||||
}
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => console.error(err)
|
||||
@ -97,16 +102,28 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
this.setState({theme: newTheme});
|
||||
}
|
||||
|
||||
async getBoundary() {
|
||||
const res = await fetch('/geometries/boundary-detailed.geojson');
|
||||
const data = await res.json() as GeoJsonObject;
|
||||
this.setState({
|
||||
boundary: data
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getBoundary();
|
||||
}
|
||||
|
||||
render() {
|
||||
const position: LatLngExpression = [this.state.lat, this.state.lng];
|
||||
const position: [number, number] = [this.state.lat, this.state.lng];
|
||||
|
||||
// baselayer
|
||||
const key = OS_API_KEY;
|
||||
const tilematrixSet = 'EPSG:3857';
|
||||
const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857';
|
||||
const baseUrl = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`;
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.';
|
||||
const baseLayer = <TileLayer
|
||||
const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines. <a href=/ordnance-survey-licence.html>OS licence</a>';
|
||||
const baseLayer = <TileLayer
|
||||
url={baseUrl}
|
||||
attribution={attribution}
|
||||
/>;
|
||||
@ -114,6 +131,11 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
|
||||
|
||||
|
||||
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
|
||||
const boundaryLayer = this.state.boundary &&
|
||||
<GeoJSON data={this.state.boundary} style={boundaryStyleFn}/>;
|
||||
|
||||
// colour-data tiles
|
||||
const cat = this.props.category;
|
||||
const tilesetByCat = {
|
||||
@ -163,6 +185,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { //
|
||||
>
|
||||
{ baseLayer }
|
||||
{ buildingBaseLayer }
|
||||
{ boundaryLayer }
|
||||
{ dataLayer }
|
||||
{ highlightLayer }
|
||||
<ZoomControl position="topright" />
|
||||
|
12
app/src/frontend/models/building.ts
Normal file
12
app/src/frontend/models/building.ts
Normal file
@ -0,0 +1,12 @@
|
||||
interface Building {
|
||||
building_id: number;
|
||||
geometry_id: number;
|
||||
revision_id: number;
|
||||
|
||||
uprns: string[];
|
||||
// TODO: add other fields as needed
|
||||
}
|
||||
|
||||
export {
|
||||
Building
|
||||
};
|
8
app/src/frontend/models/user.ts
Normal file
8
app/src/frontend/models/user.ts
Normal file
@ -0,0 +1,8 @@
|
||||
interface User {
|
||||
username: string;
|
||||
// TODO: add other fields as needed
|
||||
}
|
||||
|
||||
export {
|
||||
User
|
||||
};
|
@ -45,6 +45,9 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
|
||||
<section className="main-col">
|
||||
<h1 className="h2">Open data extracts</h1>
|
||||
<p>
|
||||
Choose one of the links below to download an archive containing the open data collected on the Colouring London platform
|
||||
</p>
|
||||
<p>
|
||||
Colouring London contributions are open data, licensed under the <a href="http://opendatacommons.org/licenses/odbl/">Open Data Commons Open Database License</a> (ODbL) by Colouring London contributors.
|
||||
</p>
|
||||
<p>
|
||||
@ -54,8 +57,9 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
|
||||
Choose one of the links below to download an archive containing the open data collected on the Colouring London platform.
|
||||
</p>
|
||||
<p>
|
||||
By downloading data extracts from this site, you agree to the <Link to="/data-accuracy.html">data accuracy agreement </Link>.
|
||||
By downloading data extracts from this site, you agree to the <Link to="/data-accuracy.html">data accuracy agreement and the <Link to="/ordnance-survey-uprn.html">Ordnance Survey terms of UPRN usage</Link>.
|
||||
</p>
|
||||
|
||||
{
|
||||
this.state.extracts == undefined ?
|
||||
<p>Loading extracts...</p> :
|
||||
|
22
app/src/frontend/pages/ordnance-survey-licence.tsx
Normal file
22
app/src/frontend/pages/ordnance-survey-licence.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
const OrdnanceSurveyLicencePage = () => (
|
||||
<article className="main-col">
|
||||
<section>
|
||||
<h1 className="h2">Ordnance Survey Data - Terms and Conditions</h1>
|
||||
<ul>
|
||||
<li>
|
||||
You are granted a non-exclusive, royalty free revocable licence solely to <strong>view</strong> the licensed mapping (and addressing) data for non-commercial purposes for the period during which we make it available;
|
||||
</li>
|
||||
<li>
|
||||
You are not permitted to copy, sub-license, distribute, sell or otherwise make available such data to third parties in any form; and
|
||||
</li>
|
||||
<li>
|
||||
Third party rights to enforce the terms of this licence shall be reserved to OS.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default OrdnanceSurveyLicencePage;
|
14
app/src/frontend/pages/ordnance-survey-uprn.tsx
Normal file
14
app/src/frontend/pages/ordnance-survey-uprn.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const OrdnanceSurveyUprnPage = () => (
|
||||
<article className="main-col">
|
||||
<section>
|
||||
<h1 className="h2">Ordnance Survey - UPRN Usage Terms</h1>
|
||||
<p>
|
||||
You may use the UPRN for personal, non-commercial use or for the internal administration and operation of your business, which does not permit using the UPRN for the purpose of creating or developing any product or service intended for sub-licensing, distribution or sale to third parties (notwithstanding that the UPRN themselves or any associated attribution or geometry that has been created with benefit of UPRN may not actually be supplied as part of such product or service). Therefore if you create any data which has benefited from, relied on or made use of the UPRN you are not permitted to sub-license, distribute or sell such data to third parties under the above terms.
|
||||
</p>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default OrdnanceSurveyUprnPage;
|
@ -9,7 +9,7 @@
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
padding: 1.5em 2.5em 2.5em;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.welcome-float.jumbotron {
|
||||
background: #fff;
|
||||
|
@ -57,38 +57,38 @@
|
||||
* Category colours
|
||||
*/
|
||||
.background-location {
|
||||
background-color: #edc40b;
|
||||
background-color: #f7c625;
|
||||
}
|
||||
.background-use {
|
||||
background-color: #f0ee0c;
|
||||
background-color: #f7ec25;
|
||||
}
|
||||
.background-type {
|
||||
background-color: #ff9100;
|
||||
background-color: #f77d11;
|
||||
}
|
||||
.background-age {
|
||||
background-color: #ee5f63;
|
||||
background-color: #ff6161;
|
||||
}
|
||||
.background-size {
|
||||
background-color: #ee91bf;
|
||||
background-color: #f2a2b9;
|
||||
}
|
||||
.background-construction {
|
||||
background-color: #aa7fa7;
|
||||
background-color: #ab8fb0;
|
||||
}
|
||||
.background-streetscape {
|
||||
background-color: #6f879c;
|
||||
background-color: #718899;
|
||||
}
|
||||
.background-team {
|
||||
background-color: #5ec232;
|
||||
background-color: #7cbf39;
|
||||
}
|
||||
.background-sustainability {
|
||||
background-color: #6dbb8b;
|
||||
background-color: #57c28e;
|
||||
}
|
||||
.background-community {
|
||||
background-color: #65b7ff;
|
||||
background-color: #6bb1e3;
|
||||
}
|
||||
.background-planning {
|
||||
background-color: #a1a3a9;
|
||||
background-color: #aaaaaa;
|
||||
}
|
||||
.background-like {
|
||||
background-color: #9c896d;
|
||||
background-color: #a3916f;
|
||||
}
|
||||
|
@ -12,7 +12,8 @@ import { getUserById } from './api/services/user';
|
||||
import {
|
||||
getBuildingById,
|
||||
getBuildingLikeById,
|
||||
getBuildingUPRNsById
|
||||
getBuildingUPRNsById,
|
||||
getLatestRevisionId
|
||||
} from './api/services/building';
|
||||
|
||||
|
||||
@ -36,8 +37,9 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
userId ? getUserById(userId) : undefined,
|
||||
isBuilding ? getBuildingById(buildingId) : undefined,
|
||||
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
|
||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false
|
||||
]).then(function ([user, building, uprns, buildingLike]) {
|
||||
(isBuilding && userId) ? getBuildingLikeById(buildingId, userId) : false,
|
||||
getLatestRevisionId()
|
||||
]).then(function ([user, building, uprns, buildingLike, latestRevisionId]) {
|
||||
if (isBuilding && typeof (building) === 'undefined') {
|
||||
context.status = 404;
|
||||
}
|
||||
@ -47,12 +49,14 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
if (data.building != null) {
|
||||
data.building.uprns = uprns;
|
||||
}
|
||||
data.latestRevisionId = latestRevisionId;
|
||||
renderHTML(context, data, req, res);
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
data.user = undefined;
|
||||
data.building = undefined;
|
||||
data.building_like = undefined;
|
||||
data.latestRevisionId = 0;
|
||||
context.status = 500;
|
||||
renderHTML(context, data, req, res);
|
||||
});
|
||||
@ -61,7 +65,12 @@ function frontendRoute(req: express.Request, res: express.Response) {
|
||||
function renderHTML(context, data, req, res) {
|
||||
const markup = renderToString(
|
||||
<StaticRouter context={context} location={req.url}>
|
||||
<App user={data.user} building={data.building} building_like={data.building_like} />
|
||||
<App
|
||||
user={data.user}
|
||||
building={data.building}
|
||||
building_like={data.building_like}
|
||||
revisionId={data.latestRevisionId}
|
||||
/>
|
||||
</StaticRouter>
|
||||
);
|
||||
|
||||
@ -81,9 +90,9 @@ function renderHTML(context, data, req, res) {
|
||||
<meta property="og:url" content="https://colouring.london" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Colouring London" />
|
||||
<meta property="og:description" content="Colouring London is a citizen science platform collecting information on every building in London, to help make the city more sustainable. We’re building it at The Bartlett Centre for Advanced Spatial Analysis, University College London." />
|
||||
<meta property="og:description" content="Colouring London is a knowledge exchange platform collecting information on every building in London, to help make the city more sustainable. We’re building it at The Bartlett Centre for Advanced Spatial Analysis, University College London." />
|
||||
<meta property="og:locale" content="en_GB" />
|
||||
<meta property="og:image" content="https://colouring.london/images/logo-cl.png" />
|
||||
<meta property="og:image" content="https://colouring.london/images/logo-cl-square.png" />
|
||||
|
||||
<link rel="manifest" href="site.webmanifest">
|
||||
|
||||
|
@ -23,7 +23,7 @@ function strictParseInt(value) {
|
||||
* @returns {number|undefined}
|
||||
*/
|
||||
function parseBuildingURL(url) {
|
||||
const re = /\/(\d+)$/;
|
||||
const re = /\/(\d+)(\/history)?$/;
|
||||
const matches = re.exec(url);
|
||||
|
||||
if (matches && matches.length >= 2) {
|
||||
|
109
etl/join_building_data/load_csv_to_staging.py
Normal file
109
etl/join_building_data/load_csv_to_staging.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Join csv data to buildings
|
||||
Example usage (replace URL with test/staging/localhost as necessary, API key with real key for
|
||||
the appropriate site):
|
||||
python load_csv_to_staging.py \
|
||||
https://clstaging.casa.ucl.ac.uk \
|
||||
a0a00000-0a00-0aaa-a0a0-0000aaaa0000 \
|
||||
data.csv
|
||||
This script uses the HTTP API, and can process CSV files which identify buildings by id, TOID,
|
||||
UPRN.
|
||||
The process:
|
||||
- assume first line of the CSV is a header, where column names are either
|
||||
- building identifiers - one of:
|
||||
- building_id
|
||||
- toid
|
||||
- uprn
|
||||
- building data field names
|
||||
- read through lines of CSV:
|
||||
- use building id if provided
|
||||
- else lookup by toid
|
||||
- else lookup by uprn
|
||||
- else locate building by representative point
|
||||
- update building
|
||||
TODO extend to allow latitude,longitude or easting,northing columns and lookup by location.
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
session = requests.Session()
|
||||
session.verify = False
|
||||
|
||||
def main(base_url, api_key, source_file):
|
||||
"""Read from file, update buildings
|
||||
"""
|
||||
with open(source_file, 'r') as source:
|
||||
reader = csv.DictReader(source)
|
||||
for line in reader:
|
||||
building_id = find_building(line, base_url)
|
||||
|
||||
if building_id is None:
|
||||
continue
|
||||
|
||||
response_code, response_data = update_building(building_id, line, api_key, base_url)
|
||||
if response_code != 200:
|
||||
print('ERROR', building_id, response_code, response_data)
|
||||
|
||||
|
||||
def update_building(building_id, data, api_key, base_url):
|
||||
"""Save data to a building
|
||||
"""
|
||||
r = requests.post(
|
||||
"{}/api/buildings/{}.json".format(base_url, building_id),
|
||||
params={'api_key': api_key},
|
||||
json=data,
|
||||
verify=False
|
||||
)
|
||||
print(r)
|
||||
return r.status_code, r.json()
|
||||
|
||||
|
||||
def find_building(data, base_url):
|
||||
if 'toid' in data:
|
||||
building_id = find_by_reference(base_url, 'toid', data['toid'])
|
||||
if building_id is not None:
|
||||
print("match_by_toid", data['toid'], building_id)
|
||||
return building_id
|
||||
|
||||
if 'uprn' in data:
|
||||
building_id = find_by_reference(base_url, 'uprn', data['uprn'])
|
||||
if building_id is not None:
|
||||
print("match_by_uprn", data['uprn'], building_id)
|
||||
return building_id
|
||||
|
||||
print("no_match", data)
|
||||
return None
|
||||
|
||||
|
||||
def find_by_reference(base_url, ref_key, ref_id):
|
||||
"""Find building_id by TOID or UPRN
|
||||
"""
|
||||
r = requests.get("{}/api/buildings/reference".format(base_url), params={
|
||||
'key': ref_key,
|
||||
'id': ref_id,
|
||||
},
|
||||
verify=False
|
||||
)
|
||||
buildings = r.json()
|
||||
|
||||
if buildings and 'error' not in buildings and len(buildings) == 1:
|
||||
building_id = buildings[0]['building_id']
|
||||
else:
|
||||
building_id = None
|
||||
|
||||
return building_id
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
url, api_key, filename = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
except IndexError:
|
||||
print(
|
||||
"Usage: {} <URL> <api_key> ./path/to/data.csv".format(
|
||||
os.path.basename(__file__)
|
||||
))
|
||||
exit()
|
||||
|
||||
main(url, api_key, filename)
|
Loading…
Reference in New Issue
Block a user