Merge branch 'master' into fix/375-attribution

This commit is contained in:
Tom Russell 2019-11-12 10:45:23 +00:00 committed by GitHub
commit 59ec2f7ecb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 2481 additions and 1235 deletions

View File

@ -222,28 +222,32 @@
</Style>
<Style name="likes">
<Rule>
<Filter>[likes] &gt;= 10</Filter>
<Filter>[likes] &gt;= 100</Filter>
<PolygonSymbolizer fill="#bd0026" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 5 and [likes] &lt; 10</Filter>
<Filter>[likes] &gt;= 50 and [likes] &lt; 100</Filter>
<PolygonSymbolizer fill="#e31a1c" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 4 and [likes] &lt; 5</Filter>
<Filter>[likes] &gt;= 20 and [likes] &lt; 50</Filter>
<PolygonSymbolizer fill="#fc4e2a" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 3 and [likes] &lt; 4</Filter>
<Filter>[likes] &gt;= 10 and [likes] &lt; 20</Filter>
<PolygonSymbolizer fill="#fd8d3c" />
</Rule>
<Rule>
<Filter>[likes] &gt;= 2 and [likes] &lt; 3</Filter>
<Filter>[likes] &gt;= 3 and [likes] &lt; 10</Filter>
<PolygonSymbolizer fill="#feb24c" />
</Rule>
<Rule>
<Filter>[likes] &lt; 2</Filter>
<Filter>[likes] = 2</Filter>
<PolygonSymbolizer fill="#fed976" />
</Rule>
<Rule>
<Filter>[likes] = 1</Filter>
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
</Style>
</Map>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -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
};

View File

@ -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)) {

View File

@ -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;

View File

@ -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
};

View File

@ -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 {

View File

@ -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')
);

View File

@ -9,7 +9,7 @@ describe('<App />', () => {
const div = document.createElement('div');
ReactDOM.render(
<MemoryRouter>
<App />
<App revisionId={0} />
</MemoryRouter>,
div
);

View File

@ -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} />

View File

@ -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 &amp; 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"

View File

@ -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

View File

@ -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>
)

View File

@ -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;

View File

@ -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;
}

View File

@ -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
};

View File

@ -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
};

View File

@ -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
})
}

View File

@ -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;

View File

@ -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>
);

View File

@ -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 => (

View File

@ -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>
);

View File

@ -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>
)

View File

@ -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;

View File

@ -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>

View File

@ -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
};

View File

@ -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">

View File

@ -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>
)

View File

@ -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>

View File

@ -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);

View File

@ -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}

View File

@ -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">

View File

@ -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>
);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;
}

View File

@ -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
};

View File

@ -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;
}

View File

@ -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
};

View File

@ -0,0 +1,8 @@
.edit-history {
background-color: white;
}
.edit-history-list {
list-style: none;
padding-left: 1rem;
}

View 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
};

View File

@ -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}:&nbsp;
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
&nbsp;
<code title="Value after edit" className='edit-history-diff new'>{formatValue(props.value)}</code>
</>
);
export {
FieldEditSummary
};

View 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
};

View File

@ -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
};

View File

@ -0,0 +1,7 @@
export interface EditHistoryEntry {
date_trunc: string;
username: string;
revision_id: string;
forward_patch: object;
reverse_patch: object;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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
};

View File

@ -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>
)

View 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"
}
};

View File

@ -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"

View File

@ -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
};

View File

@ -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;

View File

@ -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;
}

View File

@ -102,12 +102,13 @@ const LEGEND_CONFIG = {
like: {
title: 'Like Me',
elements: [
{ color: '#bd0026', text: '👍👍👍 ≥10' },
{ color: '#e31a1c', text: '👍👍 510' },
{ color: '#fc4e2a', text: '👍 4' },
{ color: '#fd8d3c', text: '👍 3' },
{ color: '#feb24c', text: '👍 2' },
{ color: '#fed976', text: '👍 1' },
{ color: '#bd0026', text: '👍👍👍👍 100+' },
{ color: '#e31a1c', text: '👍👍👍 5099' },
{ color: '#fc4e2a', text: '👍👍 2049' },
{ color: '#fd8d3c', text: '👍👍 1019' },
{ color: '#feb24c', text: '👍 39' },
{ color: '#fed976', text: '👍 2' },
{ color: '#ffe8a9', text: '👍 1'}
]
}
};

View File

@ -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" />

View 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
};

View File

@ -0,0 +1,8 @@
interface User {
username: string;
// TODO: add other fields as needed
}
export {
User
};

View File

@ -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> :

View 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;

View 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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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. Were 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. Were 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">

View File

@ -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) {

View 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)