Merge pull request #505 from tomalrussell/develop
Release latest changes
This commit is contained in:
commit
9f2711f0ff
13
app/.babelrc
Normal file
13
app/.babelrc
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-jsx",
|
||||
"@babel/plugin-syntax-typescript",
|
||||
[
|
||||
"babel-plugin-typescript-to-proptypes",
|
||||
{
|
||||
"implicitChildren": true,
|
||||
"typeCheck": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
28
app/package-lock.json
generated
28
app/package-lock.json
generated
@ -425,6 +425,15 @@
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-syntax-typescript": {
|
||||
"version": "7.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.3.3.tgz",
|
||||
"integrity": "sha512-dGwbSMA1YhVS8+31CnPR7LB4pcbrzcV99wQzby4uAfrkZPYZlQ7ImwdpzLqi6Z6IL02b8IAL379CaMwo0x5Lag==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-plugin-utils": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz",
|
||||
@ -2324,6 +2333,17 @@
|
||||
"integrity": "sha512-f49NsaohQ1ByY20nUrpc30QFdbeT4ntV4PAL2vSZe6uCB5nqAcqXS/qzU+aI6ZfYhWASx5eIsTFvFrs1B2ffGg==",
|
||||
"dev": true
|
||||
},
|
||||
"babel-plugin-typescript-to-proptypes": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-typescript-to-proptypes/-/babel-plugin-typescript-to-proptypes-0.17.1.tgz",
|
||||
"integrity": "sha512-yREUfvDlmn6QjM0QbywXUkXBQMD/iFfLVTl+jig4X7ZLUg9lq8ZLuex8HIM2SQ4X3vcjGnWPFowodlMcXhwxdQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-module-imports": "^7.0.0",
|
||||
"@babel/helper-plugin-utils": "^7.0.0",
|
||||
"@babel/plugin-syntax-typescript": "^7.2.0"
|
||||
}
|
||||
},
|
||||
"babel-preset-jest": {
|
||||
"version": "23.2.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-23.2.0.tgz",
|
||||
@ -9509,7 +9529,7 @@
|
||||
},
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||
"resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
|
||||
},
|
||||
"minipass": {
|
||||
@ -13639,7 +13659,7 @@
|
||||
"dependencies": {
|
||||
"minimist": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
|
||||
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
|
||||
}
|
||||
}
|
||||
@ -17045,7 +17065,7 @@
|
||||
"dependencies": {
|
||||
"minimist": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
|
||||
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=",
|
||||
"dev": true
|
||||
}
|
||||
@ -17886,7 +17906,7 @@
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
|
||||
"integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
|
@ -37,6 +37,8 @@
|
||||
"sharp": "^0.22.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-syntax-jsx": "^7.2.0",
|
||||
"@babel/plugin-syntax-typescript": "^7.3.3",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/express-session": "^1.15.13",
|
||||
"@types/jest": "^24.0.17",
|
||||
@ -51,6 +53,7 @@
|
||||
"@types/sharp": "^0.22.2",
|
||||
"@types/webpack-env": "^1.14.0",
|
||||
"babel-eslint": "^10.0.2",
|
||||
"babel-plugin-typescript-to-proptypes": "^0.17.1",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-jest": "^22.15.0",
|
||||
"eslint-plugin-react": "^7.14.3",
|
||||
|
@ -9,6 +9,17 @@ module.exports = {
|
||||
})
|
||||
config.module.rules = rules;
|
||||
|
||||
// find module rule that runs ts-loader for TS(X) files
|
||||
const tsRule = config.module.rules.find(r =>
|
||||
new RegExp(r.test).test('test.tsx') && Array.isArray(r.use) && r.use.some(u => u.loader.includes('ts-loader')));
|
||||
|
||||
// run babel-loader before ts-loader to generate propTypes
|
||||
tsRule.use.push({
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
babelrc: true
|
||||
}
|
||||
})
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
@ -1,12 +1,12 @@
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import express from 'express';
|
||||
|
||||
import { authUser, getNewUserAPIKey, logout } from './services/user';
|
||||
import { queryLocation } from './services/search';
|
||||
|
||||
import * as editHistoryController from './controllers/editHistoryController';
|
||||
import buildingsRouter from './routes/buildingsRouter';
|
||||
import usersRouter from './routes/usersRouter';
|
||||
import extractsRouter from './routes/extractsRouter';
|
||||
import usersRouter from './routes/usersRouter';
|
||||
import { queryLocation } from './services/search';
|
||||
import { authUser, getNewUserAPIKey, logout } from './services/user';
|
||||
|
||||
|
||||
const server = express.Router();
|
||||
@ -18,6 +18,8 @@ server.use('/buildings', buildingsRouter);
|
||||
server.use('/users', usersRouter);
|
||||
server.use('/extracts', extractsRouter);
|
||||
|
||||
server.get('/history', editHistoryController.getGlobalEditHistory);
|
||||
|
||||
// POST user auth
|
||||
server.post('/login', function (req, res) {
|
||||
authUser(req.body.username, req.body.password).then(function (user: any) { // TODO: remove any
|
||||
@ -29,7 +31,7 @@ server.post('/login', function (req, res) {
|
||||
res.send(user);
|
||||
}).catch(function (error) {
|
||||
res.send(error);
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// POST user logout
|
||||
@ -46,7 +48,7 @@ server.post('/logout', function (req, res) {
|
||||
server.post('/api/key', function (req, res) {
|
||||
if (!req.session.user_id) {
|
||||
res.send({ error: 'Must be logged in' });
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
getNewUserAPIKey(req.session.user_id).then(function (apiKey) {
|
||||
@ -54,7 +56,7 @@ server.post('/api/key', function (req, res) {
|
||||
}).catch(function (error) {
|
||||
res.send(error);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
// GET search
|
||||
server.get('/search', function (req, res) {
|
||||
@ -62,20 +64,20 @@ server.get('/search', function (req, res) {
|
||||
if (!searchTerm) {
|
||||
res.send({
|
||||
error: 'Please provide a search term'
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
queryLocation(searchTerm).then((results) => {
|
||||
if (typeof (results) === 'undefined') {
|
||||
res.send({
|
||||
error: 'Database error'
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.send({
|
||||
results: results.map(item => {
|
||||
// map from DB results to GeoJSON Feature objects
|
||||
const geom = JSON.parse(item.st_asgeojson)
|
||||
const geom = JSON.parse(item.st_asgeojson);
|
||||
return {
|
||||
type: 'Feature',
|
||||
attributes: {
|
||||
@ -83,13 +85,13 @@ server.get('/search', function (req, res) {
|
||||
zoom: item.zoom || 9
|
||||
},
|
||||
geometry: geom
|
||||
}
|
||||
};
|
||||
})
|
||||
})
|
||||
});
|
||||
}).catch(function (error) {
|
||||
res.send(error);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
server.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
@ -104,7 +106,7 @@ server.use((err, req, res, next) => {
|
||||
|
||||
server.use((req, res) => {
|
||||
res.status(404).json({ error: 'Resource not found'});
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
export default server;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import express from 'express';
|
||||
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as buildingService from '../services/building';
|
||||
import * as userService from '../services/user';
|
||||
import asyncController from '../routes/asyncController';
|
||||
|
||||
|
||||
// GET buildings
|
||||
@ -106,7 +107,7 @@ const getBuildingLikeById = asyncController(async (req: express.Request, res: ex
|
||||
// any value returned means like
|
||||
res.send({ like: like });
|
||||
} catch(error) {
|
||||
res.send({ error: 'Database error' })
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
20
app/src/api/controllers/editHistoryController.ts
Normal file
20
app/src/api/controllers/editHistoryController.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
|
||||
import asyncController from "../routes/asyncController";
|
||||
import * as editHistoryService from '../services/editHistory';
|
||||
|
||||
const getGlobalEditHistory = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const result = await editHistoryService.getGlobalEditHistory();
|
||||
res.send({
|
||||
history: result
|
||||
});
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
res.send({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
getGlobalEditHistory
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import * as dataExtractService from '../services/dataExtract';
|
||||
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as dataExtractService from '../services/dataExtract';
|
||||
|
||||
|
||||
const getAllDataExtracts = asyncController(async function(req: express.Request, res: express.Response) {
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { URL } from 'url';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import * as userService from '../services/user';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as passwordResetService from '../services/passwordReset';
|
||||
import { TokenVerificationError } from '../services/passwordReset';
|
||||
import asyncController from '../routes/asyncController';
|
||||
import * as userService from '../services/user';
|
||||
import { ValidationError } from '../validation';
|
||||
|
||||
const createUser = asyncController(async (req: express.Request, res: express.Response) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* A wrapper for controller functions that return a Promise, enabling them to be used with Express
|
||||
@ -14,4 +14,4 @@ function asyncController(fn: (req: Request, res: Response, next: NextFunction) =
|
||||
};
|
||||
}
|
||||
|
||||
export default asyncController;
|
||||
export default asyncController;
|
||||
|
@ -12,4 +12,4 @@ router.route('/me')
|
||||
|
||||
router.put('/password', userController.resetPassword);
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
@ -2,10 +2,11 @@
|
||||
* Building data access
|
||||
*
|
||||
*/
|
||||
import { ITask } from 'pg-promise';
|
||||
|
||||
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.
|
||||
@ -276,7 +277,7 @@ async function updateBuildingData(
|
||||
|
||||
console.log(update);
|
||||
const patches = compare(oldBuilding, update);
|
||||
console.log('Patching', buildingId, patches)
|
||||
console.log('Patching', buildingId, patches);
|
||||
const [forward, reverse] = patches;
|
||||
if (Object.keys(forward).length === 0) {
|
||||
throw 'No change provided';
|
||||
@ -336,7 +337,7 @@ function privateQueryBuildingBBOX(buildingId: number){
|
||||
}
|
||||
|
||||
async function expireBuildingTileCache(buildingId: number) {
|
||||
const bbox = await privateQueryBuildingBBOX(buildingId)
|
||||
const bbox = await privateQueryBuildingBBOX(buildingId);
|
||||
const buildingBbox: BoundingBox = [bbox.xmax, bbox.ymax, bbox.xmin, bbox.ymin];
|
||||
tileCache.removeAllAtBbox(buildingBbox);
|
||||
}
|
||||
|
@ -50,12 +50,12 @@ function getDataExtractFromRow(er: DataExtractRow): DataExtract {
|
||||
extract_id: er.extract_id,
|
||||
extracted_on: er.extracted_on,
|
||||
download_path: getDownloadLinkForExtract(er)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getDownloadLinkForExtract(extract: DataExtractRow): string {
|
||||
const file_name = path.basename(extract.extract_path);
|
||||
return `/downloads/${file_name}` // TODO: potentially move base path to env var
|
||||
return `/downloads/${file_name}`; // TODO: potentially move base path to env var
|
||||
}
|
||||
|
||||
export {
|
||||
|
21
app/src/api/services/editHistory.ts
Normal file
21
app/src/api/services/editHistory.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import db from '../../db';
|
||||
|
||||
async function getGlobalEditHistory() {
|
||||
try {
|
||||
return await db.manyOrNone(
|
||||
`SELECT log_id as revision_id, forward_patch, reverse_patch, date_trunc('minute', log_timestamp), username, building_id
|
||||
FROM logs, users
|
||||
WHERE logs.user_id = users.user_id
|
||||
AND log_timestamp >= now() - interval '21 days'
|
||||
ORDER BY log_timestamp DESC`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
getGlobalEditHistory
|
||||
};
|
@ -1,12 +1,13 @@
|
||||
import url, { URL } from 'url';
|
||||
import { errors } from 'pg-promise';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { errors } from 'pg-promise';
|
||||
import url, { URL } from 'url';
|
||||
|
||||
import db from '../../db';
|
||||
import * as userService from './user';
|
||||
import { transporter } from './email';
|
||||
import { validatePassword } from '../validation';
|
||||
|
||||
import { transporter } from './email';
|
||||
import * as userService from './user';
|
||||
|
||||
|
||||
/**
|
||||
* Generate a password reset token for the specified account and send the password reset link by email
|
||||
|
@ -3,10 +3,10 @@
|
||||
*
|
||||
*/
|
||||
import { errors } from 'pg-promise';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import db from '../../db';
|
||||
import { validateUsername, ValidationError, validatePassword } from '../validation';
|
||||
import { promisify } from 'util';
|
||||
import { validatePassword, validateUsername, ValidationError } from '../validation';
|
||||
|
||||
|
||||
async function createUser(user) {
|
||||
@ -71,9 +71,9 @@ async function authUser(username: string, password: string) {
|
||||
);
|
||||
|
||||
if (user && user.auth_ok) {
|
||||
return { user_id: user.user_id }
|
||||
return { user_id: user.user_id };
|
||||
} else {
|
||||
return { error: 'Username or password not recognised' }
|
||||
return { error: 'Username or password not recognised' };
|
||||
}
|
||||
} catch(err) {
|
||||
if (err instanceof errors.QueryResultError) {
|
||||
@ -99,14 +99,14 @@ async function getUserById(id: string) {
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
console.error('Error:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail(email: string) {
|
||||
try {
|
||||
return db.one(
|
||||
return await db.one(
|
||||
`SELECT
|
||||
user_id, username, email
|
||||
FROM
|
||||
@ -123,7 +123,7 @@ async function getUserByEmail(email: string) {
|
||||
|
||||
async function getNewUserAPIKey(id: string) {
|
||||
try{
|
||||
return db.one(
|
||||
return await db.one(
|
||||
`UPDATE
|
||||
users
|
||||
SET
|
||||
@ -137,7 +137,7 @@ async function getNewUserAPIKey(id: string) {
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
console.error('Error:', error);
|
||||
return { error: 'Failed to generate new API key.' };
|
||||
}
|
||||
}
|
||||
@ -156,7 +156,7 @@ async function authAPIUser(key: string) {
|
||||
]
|
||||
);
|
||||
} catch(error) {
|
||||
console.error('Error:', error)
|
||||
console.error('Error:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
@ -22,4 +22,4 @@ export {
|
||||
ValidationError,
|
||||
validateUsername,
|
||||
validatePassword
|
||||
};
|
||||
};
|
||||
|
@ -2,9 +2,9 @@
|
||||
* Client-side entry point to shared frontend React App
|
||||
*
|
||||
*/
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { hydrate } from 'react-dom';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import App from './frontend/app';
|
||||
|
||||
|
@ -1,36 +1,40 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Route, Switch, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './app.css';
|
||||
|
||||
import Header from './header';
|
||||
import AboutPage from './pages/about';
|
||||
import ContributorAgreementPage from './pages/contributor-agreement';
|
||||
import PrivacyPolicyPage from './pages/privacy-policy';
|
||||
import DataExtracts from './pages/data-extracts';
|
||||
|
||||
import Login from './user/login';
|
||||
import MyAccountPage from './user/my-account';
|
||||
import SignUp from './user/signup';
|
||||
import ForgottenPassword from './user/forgotten-password';
|
||||
import PasswordReset from './user/password-reset';
|
||||
|
||||
import MapApp from './map-app';
|
||||
import { Building } from './models/building';
|
||||
import { User } from './models/user';
|
||||
import AboutPage from './pages/about';
|
||||
import ChangesPage from './pages/changes';
|
||||
import ContactPage from './pages/contact';
|
||||
import ContributorAgreementPage from './pages/contributor-agreement';
|
||||
import DataAccuracyPage from './pages/data-accuracy';
|
||||
import DataExtracts from './pages/data-extracts';
|
||||
import OrdnanceSurveyLicencePage from './pages/ordnance-survey-licence';
|
||||
import OrdnanceSurveyUprnPage from './pages/ordnance-survey-uprn';
|
||||
import PrivacyPolicyPage from './pages/privacy-policy';
|
||||
import ForgottenPassword from './user/forgotten-password';
|
||||
import Login from './user/login';
|
||||
import MyAccountPage from './user/my-account';
|
||||
import PasswordReset from './user/password-reset';
|
||||
import SignUp from './user/signup';
|
||||
|
||||
|
||||
interface AppProps {
|
||||
user?: any;
|
||||
building?: any;
|
||||
user?: User;
|
||||
building?: Building;
|
||||
building_like?: boolean;
|
||||
revisionId: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
/**
|
||||
* App component
|
||||
*
|
||||
@ -43,13 +47,7 @@ interface AppProps {
|
||||
* map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
|
||||
* child components to navigate without a full page reload.
|
||||
*/
|
||||
class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
user: PropTypes.object,
|
||||
building: PropTypes.object,
|
||||
building_like: PropTypes.bool
|
||||
};
|
||||
|
||||
class App extends React.Component<AppProps, AppState> {
|
||||
static mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
|
||||
|
||||
constructor(props: Readonly<AppProps>) {
|
||||
@ -115,6 +113,7 @@ class App extends React.Component<AppProps, any> { // TODO: add proper types
|
||||
<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="/history.html" component={ChangesPage} />
|
||||
<Route exact path={App.mapAppPaths} render={(props) => (
|
||||
<MapApp
|
||||
{...props}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
|
||||
interface BuildingNotFoundProps {
|
||||
mode: string
|
||||
mode: string;
|
||||
}
|
||||
|
||||
const BuildingNotFound: React.FunctionComponent<BuildingNotFoundProps> = (props) => (
|
||||
@ -18,8 +17,4 @@ const BuildingNotFound: React.FunctionComponent<BuildingNotFoundProps> = (props)
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
BuildingNotFound.propTypes = {
|
||||
mode: PropTypes.string
|
||||
}
|
||||
|
||||
export default BuildingNotFound;
|
||||
|
@ -1,29 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import BuildingNotFound from './building-not-found';
|
||||
|
||||
import LocationContainer from './data-containers/location';
|
||||
import UseContainer from './data-containers/use';
|
||||
import TypeContainer from './data-containers/type';
|
||||
import AgeContainer from './data-containers/age';
|
||||
import SizeContainer from './data-containers/size';
|
||||
import ConstructionContainer from './data-containers/construction';
|
||||
import TeamContainer from './data-containers/team';
|
||||
import SustainabilityContainer from './data-containers/sustainability';
|
||||
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';
|
||||
|
||||
import BuildingNotFound from './building-not-found';
|
||||
import AgeContainer from './data-containers/age';
|
||||
import CommunityContainer from './data-containers/community';
|
||||
import ConstructionContainer from './data-containers/construction';
|
||||
import LikeContainer from './data-containers/like';
|
||||
import LocationContainer from './data-containers/location';
|
||||
import PlanningContainer from './data-containers/planning';
|
||||
import SizeContainer from './data-containers/size';
|
||||
import StreetscapeContainer from './data-containers/streetscape';
|
||||
import SustainabilityContainer from './data-containers/sustainability';
|
||||
import TeamContainer from './data-containers/team';
|
||||
import TypeContainer from './data-containers/type';
|
||||
import UseContainer from './data-containers/use';
|
||||
|
||||
|
||||
interface BuildingViewProps {
|
||||
cat: string;
|
||||
mode: 'view' | 'edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
selectBuilding: (building: Building) => void
|
||||
building?: Building;
|
||||
building_like?: boolean;
|
||||
user?: any;
|
||||
selectBuilding: (building: Building) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -39,7 +39,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
title="Location"
|
||||
help="https://pages.colouring.london/location"
|
||||
intro="Where are the buildings? Address, location and cross-references."
|
||||
/>
|
||||
/>;
|
||||
case 'use':
|
||||
return <UseContainer
|
||||
{...props}
|
||||
@ -47,7 +47,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
title="Land Use"
|
||||
intro="How are buildings used, and how does use change over time? Coming soon…"
|
||||
help="https://pages.colouring.london/use"
|
||||
/>
|
||||
/>;
|
||||
case 'type':
|
||||
return <TypeContainer
|
||||
{...props}
|
||||
@ -55,21 +55,21 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
title="Type"
|
||||
intro="How were buildings previously used?"
|
||||
help="https://www.pages.colouring.london/buildingtypology"
|
||||
/>
|
||||
/>;
|
||||
case 'age':
|
||||
return <AgeContainer
|
||||
{...props}
|
||||
title="Age"
|
||||
help="https://pages.colouring.london/age"
|
||||
intro="Building age data can support energy analysis and help predict long-term change."
|
||||
/>
|
||||
/>;
|
||||
case 'size':
|
||||
return <SizeContainer
|
||||
{...props}
|
||||
title="Size & Shape"
|
||||
intro="How big are buildings?"
|
||||
help="https://pages.colouring.london/shapeandsize"
|
||||
/>
|
||||
/>;
|
||||
case 'construction':
|
||||
return <ConstructionContainer
|
||||
{...props}
|
||||
@ -77,7 +77,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
intro="How are buildings built? Coming soon…"
|
||||
help="https://pages.colouring.london/construction"
|
||||
inactive={true}
|
||||
/>
|
||||
/>;
|
||||
case 'team':
|
||||
return <TeamContainer
|
||||
{...props}
|
||||
@ -85,7 +85,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
intro="Who built the buildings? Coming soon…"
|
||||
help="https://pages.colouring.london/team"
|
||||
inactive={true}
|
||||
/>
|
||||
/>;
|
||||
case 'sustainability':
|
||||
return <SustainabilityContainer
|
||||
{...props}
|
||||
@ -93,7 +93,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
intro="Are buildings energy efficient?"
|
||||
help="https://pages.colouring.london/sustainability"
|
||||
inactive={false}
|
||||
/>
|
||||
/>;
|
||||
case 'streetscape':
|
||||
return <StreetscapeContainer
|
||||
{...props}
|
||||
@ -101,7 +101,7 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
intro="What's the building's context? Coming soon…"
|
||||
help="https://pages.colouring.london/streetscape"
|
||||
inactive={true}
|
||||
/>
|
||||
/>;
|
||||
case 'community':
|
||||
return <CommunityContainer
|
||||
{...props}
|
||||
@ -109,24 +109,24 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
|
||||
intro="How does this building work for the local community?"
|
||||
help="https://pages.colouring.london/community"
|
||||
inactive={true}
|
||||
/>
|
||||
/>;
|
||||
case 'planning':
|
||||
return <PlanningContainer
|
||||
{...props}
|
||||
title="Planning"
|
||||
intro="Planning controls relating to protection and reuse."
|
||||
help="https://pages.colouring.london/planning"
|
||||
/>
|
||||
/>;
|
||||
case 'like':
|
||||
return <LikeContainer
|
||||
{...props}
|
||||
title="Like Me!"
|
||||
intro="Do you like the building and think it contributes to the city?"
|
||||
help="https://pages.colouring.london/likeme"
|
||||
/>
|
||||
/>;
|
||||
default:
|
||||
return <BuildingNotFound mode="view" />
|
||||
return <BuildingNotFound mode="view" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default BuildingView;
|
||||
|
@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './categories.css'
|
||||
import './categories.css';
|
||||
|
||||
const Categories = (props) => (
|
||||
interface CategoriesProps {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
building_id?: number;
|
||||
}
|
||||
|
||||
const Categories: React.FC<CategoriesProps> = (props) => (
|
||||
<ol className="data-category-list">
|
||||
<Category
|
||||
title="Location"
|
||||
@ -115,14 +119,19 @@ const Categories = (props) => (
|
||||
building_id={props.building_id}
|
||||
/>
|
||||
</ol>
|
||||
)
|
||||
);
|
||||
|
||||
Categories.propTypes = {
|
||||
mode: PropTypes.string,
|
||||
building_id: PropTypes.number
|
||||
interface CategoryProps {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
building_id?: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
help: string;
|
||||
inactive: boolean;
|
||||
}
|
||||
|
||||
const Category = (props) => {
|
||||
const Category: React.FC<CategoryProps> = (props) => {
|
||||
let categoryLink = `/${props.mode}/${props.slug}`;
|
||||
if (props.building_id != undefined) categoryLink += `/${props.building_id}`;
|
||||
|
||||
@ -143,16 +152,6 @@ const Category = (props) => {
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
Category.propTypes = {
|
||||
title: PropTypes.string,
|
||||
desc: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
help: PropTypes.string,
|
||||
inactive: PropTypes.bool,
|
||||
mode: PropTypes.string,
|
||||
building_id: PropTypes.number
|
||||
}
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
|
||||
import { BackIcon }from '../components/icons';
|
||||
|
||||
interface ContainerHeaderProps {
|
||||
cat?: string;
|
||||
@ -19,6 +19,6 @@ const ContainerHeader: React.FunctionComponent<ContainerHeaderProps> = (props) =
|
||||
{props.children}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
);
|
||||
|
||||
export default ContainerHeader;
|
||||
|
@ -1,14 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
interface CheckboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
|
||||
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
|
||||
return (
|
||||
<Fragment>
|
||||
@ -35,20 +33,6 @@ const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (prop
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
CheckboxDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.any,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default CheckboxDataEntry;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
|
||||
import './data-entry-group.css';
|
||||
import { RightIcon, DownIcon } from "../../components/icons";
|
||||
|
||||
import { DownIcon, RightIcon } from "../../components/icons";
|
||||
|
||||
interface DataEntryGroupProps {
|
||||
/** Name of the group */
|
||||
@ -29,7 +30,7 @@ const DataEntryGroup: React.FunctionComponent<DataEntryGroupProps> = (props) =>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CollapseIcon: React.FunctionComponent<{collapsed: boolean}> = (props) => (
|
||||
<span className="collapse-icon">
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { CopyProps } from '../data-containers/category-view-props';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
@ -8,16 +9,16 @@ interface BaseDataEntryProps {
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // CopyProps clashes with propTypes
|
||||
copy?: CopyProps; // CopyProps clashes with propTypes
|
||||
mode?: 'view' | 'edit' | 'multi-edit';
|
||||
onChange?: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface DataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
value?: string;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
valueTransform?: (string) => string
|
||||
valueTransform?: (string) => string;
|
||||
}
|
||||
|
||||
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
|
||||
@ -47,23 +48,7 @@ const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
DataEntry.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,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default DataEntry;
|
||||
export {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import { CopyProps } from '../data-containers/category-view-props';
|
||||
|
||||
|
||||
interface DataTitleProps {
|
||||
@ -15,21 +15,16 @@ const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
|
||||
{ props.title }
|
||||
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
|
||||
</dt>
|
||||
)
|
||||
}
|
||||
|
||||
DataTitle.propTypes = {
|
||||
title: PropTypes.string,
|
||||
tooltip: PropTypes.string
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
interface DataTitleCopyableProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
tooltip?: string;
|
||||
slug: string;
|
||||
disabled?: boolean;
|
||||
copy?: any; // TODO: type should be CopyProps, but that clashes with propTypes in some obscure way
|
||||
copy?: CopyProps;
|
||||
}
|
||||
|
||||
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
|
||||
@ -53,20 +48,7 @@ const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (prop
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DataTitleCopyable.propTypes = {
|
||||
title: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func,
|
||||
toggleCopying: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default DataTitle;
|
||||
export { DataTitleCopyable }
|
||||
export { DataTitleCopyable };
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
|
||||
@ -47,13 +46,6 @@ const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
|
||||
</label>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
LikeDataEntry.propTypes = {
|
||||
// mode: PropTypes.string,
|
||||
userLike: PropTypes.bool,
|
||||
totalLikes: PropTypes.number,
|
||||
onLike: PropTypes.func
|
||||
};
|
||||
|
||||
export default LikeDataEntry;
|
||||
|
@ -1,23 +1,17 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { sanitiseURL } from '../../helpers';
|
||||
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
|
||||
class MultiDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
slug: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
value: PropTypes.arrayOf(PropTypes.string),
|
||||
placeholder: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.bool,
|
||||
toggleCopyAttribute: PropTypes.func,
|
||||
copying: PropTypes.bool
|
||||
};
|
||||
interface MultiDataEntryProps extends BaseDataEntryProps {
|
||||
value: string[];
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
class MultiDataEntry extends Component<MultiDataEntryProps> {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@ -75,7 +69,7 @@ class MultiDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
key={index}
|
||||
className="form-control">
|
||||
<a href={sanitiseURL(item)}>{item}</a>
|
||||
</li>
|
||||
</li>;
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
@ -104,7 +98,7 @@ class MultiDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
onClick={this.add}
|
||||
disabled={props.mode === 'view'}
|
||||
className="btn btn-outline-dark">+</button>
|
||||
</Fragment>
|
||||
</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
|
||||
interface NumericDataEntryProps extends BaseDataEntryProps {
|
||||
@ -43,24 +42,6 @@ const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props)
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
NumericDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.any,
|
||||
placeholder: PropTypes.string,
|
||||
max: PropTypes.number,
|
||||
min: PropTypes.number,
|
||||
step: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default NumericDataEntry;
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
interface SelectDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
@ -42,22 +41,6 @@ const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) =
|
||||
</select>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
SelectDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
slug: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
value: PropTypes.any,
|
||||
placeholder: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.string),
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default SelectDataEntry;
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
import { BaseDataEntryProps } from './data-entry';
|
||||
import { DataTitleCopyable } from './data-title';
|
||||
|
||||
interface TextboxDataEntryProps extends BaseDataEntryProps {
|
||||
value: string;
|
||||
@ -40,22 +39,6 @@ const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props)
|
||||
></textarea>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
TextboxDataEntry.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,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default TextboxDataEntry;
|
||||
|
@ -1,10 +1,18 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import DataTitle from './data-title';
|
||||
|
||||
const UPRNsDataEntry = (props) => {
|
||||
|
||||
interface UPRNsDataEntryProps {
|
||||
title: string;
|
||||
tooltip: string;
|
||||
value: {
|
||||
uprn: string;
|
||||
parent_uprn?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const UPRNsDataEntry: React.FC<UPRNsDataEntryProps> = (props) => {
|
||||
const uprns = props.value || [];
|
||||
const noParent = uprns.filter(uprn => uprn.parent_uprn == null);
|
||||
const withParent = uprns.filter(uprn => uprn.parent_uprn != null);
|
||||
@ -45,16 +53,7 @@ const UPRNsDataEntry = (props) => {
|
||||
}
|
||||
</dd>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
UPRNsDataEntry.propTypes = {
|
||||
title: PropTypes.string,
|
||||
tooltip: PropTypes.string,
|
||||
value: PropTypes.arrayOf(PropTypes.shape({
|
||||
uprn: PropTypes.string.isRequired,
|
||||
parent_uprn: PropTypes.string
|
||||
}))
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default UPRNsDataEntry;
|
||||
|
@ -1,23 +1,20 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { CopyProps } from '../data-containers/category-view-props';
|
||||
|
||||
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
|
||||
year: PropTypes.number,
|
||||
upper: PropTypes.number,
|
||||
lower: PropTypes.number,
|
||||
mode: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
copy: PropTypes.shape({
|
||||
copying: PropTypes.bool,
|
||||
copyingKey: PropTypes.func,
|
||||
toggleCopyAttribute: PropTypes.func
|
||||
})
|
||||
};
|
||||
interface YearDataEntryProps {
|
||||
year: number;
|
||||
upper: number;
|
||||
lower: number;
|
||||
copy?: CopyProps;
|
||||
mode?: 'view' | 'edit' | 'multi-edit';
|
||||
onChange?: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
class YearDataEntry extends Component<YearDataEntryProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -26,7 +23,7 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
lower: props.lower,
|
||||
decade: Math.floor(props.year / 10) * 10,
|
||||
century: Math.floor(props.year / 100) * 100
|
||||
}
|
||||
};
|
||||
}
|
||||
// TODO add dropdown for decade, century
|
||||
// TODO roll in first/last year estimate
|
||||
@ -65,7 +62,7 @@ class YearDataEntry extends Component<any, any> { // TODO: add proper types
|
||||
tooltip={dataFields.date_lower.tooltip}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,16 +1,16 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Redirect, NavLink } from 'react-router-dom';
|
||||
import { NavLink, Redirect } 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 { compareObjects } from '../helpers';
|
||||
import { Building } from '../models/building';
|
||||
import { User } from '../models/user';
|
||||
import { compareObjects } from '../helpers';
|
||||
|
||||
import ContainerHeader from './container-header';
|
||||
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
|
||||
import { CopyControl } from './header-buttons/copy-control';
|
||||
import { ViewEditControl } from './header-buttons/view-edit-control';
|
||||
|
||||
interface DataContainerProps {
|
||||
title: string;
|
||||
@ -19,11 +19,11 @@ interface DataContainerProps {
|
||||
help: string;
|
||||
inactive?: boolean;
|
||||
|
||||
user: User;
|
||||
user?: User;
|
||||
mode: 'view' | 'edit';
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
selectBuilding: (building: Building) => void
|
||||
building?: Building;
|
||||
building_like?: boolean;
|
||||
selectBuilding: (building: Building) => void;
|
||||
}
|
||||
|
||||
interface DataContainerState {
|
||||
@ -45,15 +45,6 @@ interface DataContainerState {
|
||||
*/
|
||||
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,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@ -98,7 +89,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
toggleCopying() {
|
||||
this.setState({
|
||||
copying: !this.state.copying
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,7 +106,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
}
|
||||
this.setState({
|
||||
keys_to_copy: keys
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
isEdited() {
|
||||
@ -182,7 +173,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
this.setState({error: data.error});
|
||||
} else {
|
||||
this.props.selectBuilding(data);
|
||||
this.updateBuildingState('likes_total', data.likes_total);
|
||||
@ -208,7 +199,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
const data = await res.json();
|
||||
|
||||
if (data.error) {
|
||||
this.setState({error: data.error})
|
||||
this.setState({error: data.error});
|
||||
} else {
|
||||
this.props.selectBuilding(data);
|
||||
}
|
||||
@ -219,14 +210,14 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
|
||||
render() {
|
||||
if (this.props.mode === 'edit' && !this.props.user){
|
||||
return <Redirect to="/sign-up.html" />
|
||||
return <Redirect to="/sign-up.html" />;
|
||||
}
|
||||
|
||||
const currentBuilding = this.getEditedBuilding();
|
||||
|
||||
const values_to_copy = {}
|
||||
const values_to_copy = {};
|
||||
for (const key of Object.keys(this.state.keys_to_copy)) {
|
||||
values_to_copy[key] = currentBuilding[key]
|
||||
values_to_copy[key] = currentBuilding[key];
|
||||
}
|
||||
const data_string = JSON.stringify(values_to_copy);
|
||||
const copy: CopyProps = {
|
||||
@ -234,7 +225,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
toggleCopying: this.toggleCopying,
|
||||
toggleCopyAttribute: this.toggleCopyAttribute,
|
||||
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();
|
||||
@ -356,7 +347,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default withCopyEdit;
|
||||
export default withCopyEdit;
|
||||
|
@ -1,12 +1,13 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import MultiDataEntry from '../data-components/multi-data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import TextboxDataEntry from '../data-components/textbox-data-entry';
|
||||
import YearDataEntry from '../data-components/year-data-entry';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -74,7 +75,7 @@ const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const AgeContainer = withCopyEdit(AgeView);
|
||||
|
||||
export default AgeContainer;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -27,7 +28,7 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
}
|
||||
</ul>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const CommunityContainer = withCopyEdit(CommunityView);
|
||||
|
||||
export default CommunityContainer;
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
|
||||
/**
|
||||
* Construction view/edit section
|
||||
@ -50,7 +49,7 @@ const ConstructionView = (props) => (
|
||||
}
|
||||
</ul>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const ConstructionContainer = withCopyEdit(ConstructionView);
|
||||
|
||||
export default ConstructionContainer;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import LikeDataEntry from '../data-components/like-data-entry';
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -16,7 +17,7 @@ const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
onLike={props.onLike}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const LikeContainer = withCopyEdit(LikeView);
|
||||
|
||||
export default LikeContainer;
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import InfoBox from '../../components/info-box';
|
||||
import { dataFields } from '../../data_fields';
|
||||
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 withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
@ -113,7 +114,7 @@ const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const LocationContainer = withCopyEdit(LocationView);
|
||||
|
||||
export default LocationContainer;
|
||||
|
@ -1,11 +1,12 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
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 CheckboxDataEntry from '../data-components/checkbox-data-entry';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -202,7 +203,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
/>
|
||||
</DataEntryGroup>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const PlanningContainer = withCopyEdit(PlanningView);
|
||||
|
||||
export default PlanningContainer
|
||||
export default PlanningContainer;
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import { dataFields } from '../../data_fields';
|
||||
import { DataEntryGroup } from '../data-components/data-entry-group';
|
||||
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 withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -146,7 +147,7 @@ const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
]}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const SizeContainer = withCopyEdit(SizeView);
|
||||
|
||||
export default SizeContainer;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -18,7 +19,7 @@ const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
<li>Building shading</li>
|
||||
</ul>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const StreetscapeContainer = withCopyEdit(StreetscapeView);
|
||||
|
||||
export default StreetscapeContainer;
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
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 NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
@ -78,7 +78,7 @@ const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) =
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
const SustainabilityContainer = withCopyEdit(SustainabilityView);
|
||||
|
||||
export default SustainabilityContainer;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
import DataEntry from '../data-components/data-entry';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -25,7 +25,7 @@ const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
}
|
||||
</ul>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const TeamContainer = withCopyEdit(TeamView);
|
||||
|
||||
export default TeamContainer;
|
||||
|
@ -1,10 +1,11 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
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 DataEntry from '../data-components/data-entry';
|
||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||
import SelectDataEntry from '../data-components/select-data-entry';
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
const AttachmentFormOptions = [
|
||||
@ -54,7 +55,7 @@ const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
const TypeContainer = withCopyEdit(TypeView);
|
||||
|
||||
export default TypeContainer;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import withCopyEdit from '../data-container';
|
||||
|
||||
import { CategoryViewProps } from './category-view-props';
|
||||
|
||||
/**
|
||||
@ -31,7 +32,7 @@ const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
||||
}
|
||||
</ul>
|
||||
</Fragment>
|
||||
)
|
||||
);
|
||||
const UseContainer = withCopyEdit(UseView);
|
||||
|
||||
export default UseContainer;
|
||||
|
@ -11,4 +11,8 @@
|
||||
|
||||
.edit-history-username {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.edit-history-building-id {
|
||||
font-size: 0.9em;
|
||||
}
|
@ -1,13 +1,18 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
|
||||
import './building-edit-summary.css';
|
||||
|
||||
import { Category, DataFieldDefinition, dataFields } from '../../data_fields';
|
||||
import { arrayToDictionary, parseDate } from '../../helpers';
|
||||
import { EditHistoryEntry } from '../../models/edit-history-entry';
|
||||
|
||||
import { CategoryEditSummary } from './category-edit-summary';
|
||||
|
||||
interface BuildingEditSummaryProps {
|
||||
historyEntry: EditHistoryEntry
|
||||
historyEntry: EditHistoryEntry;
|
||||
showBuildingId?: boolean;
|
||||
hyperlinkCategories?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dt: Date) {
|
||||
@ -21,31 +26,55 @@ function formatDate(dt: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
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]
|
||||
};
|
||||
});
|
||||
function enrichHistoryEntries(forwardPatch: object, reversePatch: object) {
|
||||
return Object
|
||||
.entries(forwardPatch)
|
||||
.map(([key, value]) => {
|
||||
const info = dataFields[key] || {} as DataFieldDefinition;
|
||||
return {
|
||||
title: info.title || `Unknown field (${key})`,
|
||||
category: info.category || Category.Unknown,
|
||||
value: value,
|
||||
oldValue: reversePatch && reversePatch[key]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = ({
|
||||
historyEntry,
|
||||
showBuildingId = false,
|
||||
hyperlinkCategories = false
|
||||
}) => {
|
||||
const entriesWithMetadata = enrichHistoryEntries(historyEntry.forward_patch, historyEntry.reverse_patch);
|
||||
const entriesByCategory = arrayToDictionary(entriesWithMetadata, x => x.category);
|
||||
|
||||
const categoryHyperlinkTemplate = hyperlinkCategories && historyEntry.building_id != undefined ?
|
||||
`/edit/$category/${historyEntry.building_id}` :
|
||||
undefined;
|
||||
|
||||
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>
|
||||
<h2 className="edit-history-timestamp">Edited on {formatDate(parseDate(historyEntry.date_trunc))}</h2>
|
||||
<h3 className="edit-history-username">By {historyEntry.username}</h3>
|
||||
{
|
||||
Object.entries(entriesByCategory).map(([category, fields]) => <CategoryEditSummary category={category} fields={fields} />)
|
||||
showBuildingId && historyEntry.building_id != undefined &&
|
||||
<h3 className="edit-history-building-id">
|
||||
Building <Link to={`/edit/categories/${historyEntry.building_id}`}>{historyEntry.building_id}</Link>
|
||||
</h3>
|
||||
}
|
||||
{
|
||||
Object.entries(entriesByCategory).map(([category, fields]) =>
|
||||
<CategoryEditSummary
|
||||
category={category as keyof typeof Category} // https://github.com/microsoft/TypeScript/issues/14106
|
||||
fields={fields}
|
||||
hyperlinkCategory={hyperlinkCategories}
|
||||
hyperlinkTemplate={categoryHyperlinkTemplate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
BuildingEditSummary
|
||||
|
@ -1,30 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import './category-edit-summary.css';
|
||||
|
||||
import { categories, Category } from '../../data_fields';
|
||||
|
||||
import { FieldEditSummary } from './field-edit-summary';
|
||||
|
||||
interface CategoryEditSummaryProps {
|
||||
category: string;
|
||||
category: keyof typeof Category; // https://github.com/microsoft/TypeScript/issues/14106
|
||||
fields: {
|
||||
title: string;
|
||||
value: string;
|
||||
oldValue: string;
|
||||
value: any;
|
||||
oldValue: any;
|
||||
}[];
|
||||
hyperlinkCategory: boolean;
|
||||
hyperlinkTemplate?: 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>
|
||||
);
|
||||
const CategoryEditSummary : React.FunctionComponent<CategoryEditSummaryProps> = props => {
|
||||
const category = Category[props.category];
|
||||
const categoryInfo = categories[category] || {name: undefined, slug: undefined};
|
||||
const categoryName = categoryInfo.name || 'Unknown category';
|
||||
const categorySlug = categoryInfo.slug || 'categories';
|
||||
|
||||
return (
|
||||
<div className='edit-history-category-summary'>
|
||||
<h3 className='edit-history-category-title'>
|
||||
{
|
||||
props.hyperlinkCategory && props.hyperlinkTemplate != undefined ?
|
||||
<Link to={props.hyperlinkTemplate.replace(/\$category/, categorySlug)}>{categoryName}</Link> :
|
||||
categoryName
|
||||
}:
|
||||
</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
|
||||
|
@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
import { BuildingEditSummary } from './building-edit-summary';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import './edit-history.css';
|
||||
|
||||
import { Building } from '../../models/building';
|
||||
import { EditHistoryEntry } from '../../models/edit-history-entry';
|
||||
import ContainerHeader from '../container-header';
|
||||
|
||||
import { BuildingEditSummary } from './building-edit-summary';
|
||||
|
||||
interface EditHistoryProps {
|
||||
building: Building;
|
||||
}
|
||||
@ -39,7 +41,7 @@ const EditHistory: React.FunctionComponent<EditHistoryProps> = (props) => {
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
EditHistory
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Building } from '../../models/building';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { ViewIcon, EditIcon } from '../../components/icons';
|
||||
|
||||
import { EditIcon, ViewIcon } from '../../components/icons';
|
||||
import { Building } from '../../models/building';
|
||||
|
||||
interface ViewEditControlProps {
|
||||
cat: string;
|
||||
|
@ -1,18 +1,26 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import { parse } from 'query-string';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Link, Redirect, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Sidebar from './sidebar';
|
||||
import { BackIcon } from '../components/icons';
|
||||
import InfoBox from '../components/info-box';
|
||||
import { BackIcon }from '../components/icons';
|
||||
import DataEntry from './data-components/data-entry';
|
||||
import { dataFields } from '../data_fields';
|
||||
import { User } from '../models/user';
|
||||
|
||||
import DataEntry from './data-components/data-entry';
|
||||
import Sidebar from './sidebar';
|
||||
|
||||
const MultiEdit = (props) => {
|
||||
interface MultiEditRouteParams {
|
||||
cat: string;
|
||||
}
|
||||
|
||||
interface MultiEditProps extends RouteComponentProps<MultiEditRouteParams> {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
const MultiEdit: React.FC<MultiEditProps> = (props) => {
|
||||
if (!props.user){
|
||||
return <Redirect to="/sign-up.html" />
|
||||
return <Redirect to="/sign-up.html" />;
|
||||
}
|
||||
const cat = props.match.params.cat;
|
||||
if (cat === 'like') {
|
||||
@ -38,14 +46,14 @@ const MultiEdit = (props) => {
|
||||
|
||||
let data: object;
|
||||
if (cat === 'like'){
|
||||
data = { like: true }
|
||||
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 = {}
|
||||
console.error(error, q);
|
||||
data = {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,7 +80,7 @@ const MultiEdit = (props) => {
|
||||
disabled={true}
|
||||
value={data[key]}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}))
|
||||
}
|
||||
</form>
|
||||
@ -83,12 +91,6 @@ const MultiEdit = (props) => {
|
||||
</section>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
MultiEdit.propTypes = {
|
||||
user: PropTypes.object,
|
||||
match: PropTypes.object,
|
||||
location: PropTypes.object
|
||||
}
|
||||
};
|
||||
|
||||
export default MultiEdit;
|
||||
|
@ -1,16 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './sidebar.css';
|
||||
|
||||
const Sidebar = (props) => (
|
||||
const Sidebar: React.FC<{}> = (props) => (
|
||||
<div id="sidebar" className="info-container">
|
||||
{ props.children }
|
||||
</div>
|
||||
);
|
||||
|
||||
Sidebar.propTypes = {
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
|
@ -3,14 +3,14 @@ import React from 'react';
|
||||
import './confirmation-modal.css';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
show: boolean,
|
||||
title: string,
|
||||
description: string,
|
||||
confirmButtonText?: string,
|
||||
confirmButtonClass?: string,
|
||||
cancelButtonClass?: string,
|
||||
onConfirm: () => void,
|
||||
onCancel: () => void
|
||||
show: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonClass?: string;
|
||||
cancelButtonClass?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmationModal: React.FunctionComponent<ConfirmationModalProps> = ({
|
||||
@ -58,4 +58,4 @@ const ConfirmationModal: React.FunctionComponent<ConfirmationModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
export default ConfirmationModal;
|
||||
|
@ -1,7 +1,10 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
function ErrorBox(props){
|
||||
interface ErrorBoxProps {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
const ErrorBox: React.FC<ErrorBoxProps> = (props) => {
|
||||
if (props.msg) {
|
||||
console.error(props.msg);
|
||||
}
|
||||
@ -12,7 +15,7 @@ function ErrorBox(props){
|
||||
(
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{
|
||||
(typeof props.msg === 'string' || props.msg instanceof String)?
|
||||
typeof props.msg === 'string' ?
|
||||
props.msg
|
||||
: 'Unexpected error'
|
||||
}
|
||||
@ -21,10 +24,6 @@ function ErrorBox(props){
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ErrorBox.propTypes = {
|
||||
msg: PropTypes.string
|
||||
}
|
||||
};
|
||||
|
||||
export default ErrorBox;
|
||||
|
@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Mini-library of icons
|
||||
*/
|
||||
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, faCaretRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faAngleLeft, faCaretDown, faCaretRight, faCaretUp, faCheck, faCheckDouble,
|
||||
faEye, faInfoCircle, faPaintBrush, faQuestionCircle, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React from 'react';
|
||||
|
||||
library.add(
|
||||
faQuestionCircle,
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const InfoBox = (props) => (
|
||||
interface InfoBoxProps {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
const InfoBox: React.FC<InfoBoxProps> = (props) => (
|
||||
<Fragment>
|
||||
{
|
||||
(props.msg || props.children)?
|
||||
(
|
||||
<div className="alert alert-info" role="alert">
|
||||
{
|
||||
(typeof props.msg === 'string' || props.msg instanceof String)?
|
||||
typeof props.msg === 'string' ?
|
||||
props.msg
|
||||
: 'Enjoy the colouring! Usual service should resume shortly.'
|
||||
}
|
||||
@ -21,9 +24,4 @@ const InfoBox = (props) => (
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
InfoBox.propTypes = {
|
||||
msg: PropTypes.string,
|
||||
children: PropTypes.node
|
||||
}
|
||||
|
||||
export default InfoBox;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import './logo.css';
|
||||
|
||||
interface LogoProps {
|
||||
@ -44,6 +45,6 @@ const LogoGrid: React.FunctionComponent = () => (
|
||||
<div className="cell background-like"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export { Logo };
|
||||
|
@ -1,14 +1,18 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './tooltip.css';
|
||||
|
||||
import { InfoIcon } from './icons';
|
||||
|
||||
class Tooltip extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
text: PropTypes.string
|
||||
};
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
class Tooltip extends Component<TooltipProps, TooltipState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
|
@ -1,18 +1,71 @@
|
||||
export enum Category {
|
||||
Location = 'Location',
|
||||
LandUse = 'Land Use',
|
||||
LandUse = 'LandUse',
|
||||
Type = 'Type',
|
||||
Age = 'Age',
|
||||
SizeShape = 'Size & Shape',
|
||||
SizeShape = 'SizeShape',
|
||||
Construction = 'Construction',
|
||||
Streetscape = 'Streetscape',
|
||||
Team = 'Team',
|
||||
Sustainability = 'Sustainability',
|
||||
Community = 'Community',
|
||||
Planning = 'Planning',
|
||||
Like = 'Like Me!'
|
||||
Like = 'Like',
|
||||
|
||||
Unknown = 'Unknown'
|
||||
}
|
||||
|
||||
export const categories = {
|
||||
[Category.Location]: {
|
||||
slug: 'location',
|
||||
name: 'Location'
|
||||
},
|
||||
[Category.LandUse]: {
|
||||
slug: 'use',
|
||||
name: 'Land Use'
|
||||
},
|
||||
[Category.Type]: {
|
||||
slug: 'type',
|
||||
name: 'Type'
|
||||
},
|
||||
[Category.Age]: {
|
||||
slug: 'age',
|
||||
name: 'Age'
|
||||
},
|
||||
[Category.SizeShape]: {
|
||||
slug: 'size',
|
||||
name: 'Size & Shape'
|
||||
},
|
||||
[Category.Construction]: {
|
||||
slug: 'construction',
|
||||
name: 'Construction'
|
||||
},
|
||||
[Category.Streetscape]: {
|
||||
slug: 'streetscape',
|
||||
name: 'Streetscape'
|
||||
},
|
||||
[Category.Team]: {
|
||||
slug: 'team',
|
||||
name: 'Team'
|
||||
},
|
||||
[Category.Sustainability]: {
|
||||
slug: 'sustainability',
|
||||
name: 'Sustainability'
|
||||
},
|
||||
[Category.Community]: {
|
||||
slug: 'community',
|
||||
name: 'Community'
|
||||
},
|
||||
[Category.Planning]: {
|
||||
slug: 'planning',
|
||||
name: 'Planning'
|
||||
},
|
||||
[Category.Like]: {
|
||||
slug: 'like',
|
||||
name: 'Like Me!'
|
||||
}
|
||||
};
|
||||
|
||||
export const categoriesOrder: Category[] = [
|
||||
Category.Location,
|
||||
Category.LandUse,
|
||||
@ -28,6 +81,18 @@ export const categoriesOrder: Category[] = [
|
||||
Category.Like,
|
||||
];
|
||||
|
||||
/**
|
||||
* This interface is used only in code which uses dataFields, not in the dataFields definition itself
|
||||
* Cannot make dataFields an indexed type ({[key: string]: DataFieldDefinition}),
|
||||
* because then we wouldn't have type-checking for whether a given key exists on dataFields,
|
||||
* e.g. dataFields.foo_bar would not be highlighted as an error.
|
||||
*/
|
||||
export interface DataFieldDefinition {
|
||||
category: Category;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export const dataFields = {
|
||||
location_name: {
|
||||
category: Category.Location,
|
||||
|
@ -1,13 +1,14 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './header.css';
|
||||
|
||||
import { Logo } from './components/logo';
|
||||
import './header.css';
|
||||
import { User } from './models/user';
|
||||
|
||||
|
||||
interface HeaderProps {
|
||||
user: any;
|
||||
user: User;
|
||||
animateLogo: boolean;
|
||||
}
|
||||
|
||||
@ -18,14 +19,7 @@ interface HeaderState {
|
||||
/**
|
||||
* Render the main header using a responsive design
|
||||
*/
|
||||
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
|
||||
};
|
||||
|
||||
class Header extends React.Component<HeaderProps, HeaderState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {collapseMenu: true};
|
||||
|
@ -1,27 +1,27 @@
|
||||
import urlapi from 'url';
|
||||
|
||||
function sanitiseURL(string){
|
||||
let url_
|
||||
let url_;
|
||||
|
||||
// http or https
|
||||
if (!(string.substring(0, 7) === 'http://' || string.substring(0, 8) === 'https://')){
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
url_ = document.createElement('a')
|
||||
url_.href = string
|
||||
url_ = document.createElement('a');
|
||||
url_.href = string;
|
||||
} catch (error) {
|
||||
try {
|
||||
url_ = urlapi.parse(string)
|
||||
url_ = urlapi.parse(string);
|
||||
} catch (error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// required (www.example.com)
|
||||
if (!url_.hostname || url_.hostname === '' || url_.hostname === 'localhost'){
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// optional (/some/path)
|
||||
@ -33,7 +33,7 @@ function sanitiseURL(string){
|
||||
// optional (#anchor)
|
||||
// url_.hash;
|
||||
|
||||
return `${url_.protocol}//${url_.hostname}${url_.pathname || ''}${url_.search || ''}${url_.hash || ''}`
|
||||
return `${url_.protocol}//${url_.hostname}${url_.pathname || ''}${url_.search || ''}${url_.hash || ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -63,8 +63,8 @@ function parseDate(isoUtcDate: string): Date {
|
||||
}
|
||||
|
||||
function compareObjects(objA: object, objB: object): [object, object] {
|
||||
const reverse = {}
|
||||
const forward = {}
|
||||
const reverse = {};
|
||||
const forward = {};
|
||||
for (const [key, value] of Object.entries(objB)) {
|
||||
if (objA[key] !== value) {
|
||||
reverse[key] = objA[key];
|
||||
|
@ -1,16 +1,17 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { Switch, Route, RouteComponentProps, Redirect } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Welcome from './pages/welcome';
|
||||
import Sidebar from './building/sidebar';
|
||||
import Categories from './building/categories';
|
||||
import MultiEdit from './building/multi-edit';
|
||||
import BuildingView from './building/building-view';
|
||||
import ColouringMap from './map/map';
|
||||
import { parse } from 'query-string';
|
||||
import React, { Fragment } from 'react';
|
||||
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
|
||||
|
||||
import { strictParseInt } from '../parse';
|
||||
|
||||
import BuildingView from './building/building-view';
|
||||
import Categories from './building/categories';
|
||||
import { EditHistory } from './building/edit-history/edit-history';
|
||||
import MultiEdit from './building/multi-edit';
|
||||
import Sidebar from './building/sidebar';
|
||||
import ColouringMap from './map/map';
|
||||
import { Building } from './models/building';
|
||||
import Welcome from './pages/welcome';
|
||||
|
||||
interface MapAppRouteParams {
|
||||
mode: 'view' | 'edit' | 'multi-edit';
|
||||
@ -19,10 +20,10 @@ interface MapAppRouteParams {
|
||||
}
|
||||
|
||||
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
|
||||
building: Building;
|
||||
building_like: boolean;
|
||||
user: any;
|
||||
revisionId: number;
|
||||
building?: Building;
|
||||
building_like?: boolean;
|
||||
user?: any;
|
||||
revisionId?: number;
|
||||
}
|
||||
|
||||
interface MapAppState {
|
||||
@ -33,13 +34,6 @@ interface MapAppState {
|
||||
}
|
||||
|
||||
class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
static propTypes = {
|
||||
category: PropTypes.string,
|
||||
revision_id: PropTypes.number,
|
||||
building: PropTypes.object,
|
||||
building_like: PropTypes.bool,
|
||||
user: PropTypes.object
|
||||
};
|
||||
constructor(props: Readonly<MapAppProps>) {
|
||||
super(props);
|
||||
|
||||
@ -64,6 +58,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchLatestRevision();
|
||||
this.fetchBuildingData();
|
||||
}
|
||||
|
||||
async fetchLatestRevision() {
|
||||
@ -83,6 +78,52 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches building data if a building is selected but no data provided through
|
||||
* props (from server-side rendering)
|
||||
*/
|
||||
async fetchBuildingData() {
|
||||
if(this.props.match.params.building != undefined && this.props.building == undefined) {
|
||||
try {
|
||||
// TODO: simplify API calls, create helpers for fetching data
|
||||
const buildingId = strictParseInt(this.props.match.params.building);
|
||||
let [building, building_uprns, building_like] = await Promise.all([
|
||||
fetch(`/api/buildings/${buildingId}.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/buildings/${buildingId}/uprns.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json()),
|
||||
fetch(`/api/buildings/${buildingId}/like.json`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(res => res.json())
|
||||
]);
|
||||
|
||||
building.uprns = building_uprns.uprns;
|
||||
|
||||
this.setState({
|
||||
building: building,
|
||||
building_like: building_like.like
|
||||
});
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
// TODO: add UI for API errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCategory(category: string) {
|
||||
if (category === 'categories') return undefined;
|
||||
|
||||
@ -93,7 +134,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
revisionId = +revisionId;
|
||||
// bump revision id, only ever increasing
|
||||
if (revisionId > this.state.revision_id) {
|
||||
this.setState({ revision_id: revisionId })
|
||||
this.setState({ revision_id: revisionId });
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +166,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
this.setState({ building: building });
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
console.error(err);
|
||||
this.setState({ building: building });
|
||||
});
|
||||
|
||||
@ -146,7 +187,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
this.props.history.push(`/${mode}/${category}/${building.building_id}`);
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
console.error(err);
|
||||
this.setState({ building_like: false });
|
||||
});
|
||||
}
|
||||
@ -165,14 +206,14 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
const q = parse(window.location.search);
|
||||
|
||||
if (cat === 'like') {
|
||||
this.likeBuilding(building.building_id)
|
||||
this.likeBuilding(building.building_id);
|
||||
} else {
|
||||
try {
|
||||
// TODO: verify what happens if data is string[]
|
||||
const data = JSON.parse(q.data as string);
|
||||
this.updateBuilding(building.building_id, data)
|
||||
this.updateBuilding(building.building_id, data);
|
||||
} catch (error) {
|
||||
console.error(error, q)
|
||||
console.error(error, q);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -189,7 +230,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
res => res.json()
|
||||
).then(function (res) {
|
||||
if (res.error) {
|
||||
console.error({ error: res.error })
|
||||
console.error({ error: res.error });
|
||||
} else {
|
||||
this.increaseRevision(res.revision_id);
|
||||
}
|
||||
@ -210,7 +251,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
res => res.json()
|
||||
).then(res => {
|
||||
if (res.error) {
|
||||
console.error({ error: res.error })
|
||||
console.error({ error: res.error });
|
||||
} else {
|
||||
this.increaseRevision(res.revision_id);
|
||||
}
|
||||
@ -235,7 +276,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
|
||||
</Route>
|
||||
<Route exact path="/:mode/categories/:building?">
|
||||
<Sidebar>
|
||||
<Categories mode={mode} building_id={building_id} />
|
||||
<Categories mode={mode || 'view'} building_id={building_id} />
|
||||
</Sidebar>
|
||||
</Route>
|
||||
<Route exact path="/multi-edit/:cat" render={(props) => (
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './legend.css';
|
||||
|
||||
import { DownIcon, UpIcon } from '../components/icons';
|
||||
import { Logo } from '../components/logo';
|
||||
import { DownIcon, UpIcon, BackIcon } from '../components/icons';
|
||||
|
||||
const LEGEND_CONFIG = {
|
||||
location: {
|
||||
@ -114,13 +114,15 @@ const LEGEND_CONFIG = {
|
||||
};
|
||||
|
||||
|
||||
class Legend extends React.Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
slug: PropTypes.string,
|
||||
color: PropTypes.string,
|
||||
text: PropTypes.string
|
||||
};
|
||||
interface LegendProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface LegendState {
|
||||
collapseList: boolean;
|
||||
}
|
||||
|
||||
class Legend extends React.Component<LegendProps, LegendState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {collapseList: false};
|
||||
|
@ -1,26 +1,26 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { Map, TileLayer, ZoomControl, AttributionControl, GeoJSON } from 'react-leaflet-universal';
|
||||
import { GeoJsonObject } from 'geojson';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { AttributionControl, GeoJSON, Map, TileLayer, ZoomControl } from 'react-leaflet-universal';
|
||||
|
||||
import '../../../node_modules/leaflet/dist/leaflet.css'
|
||||
import './map.css'
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './map.css';
|
||||
|
||||
import { HelpIcon } from '../components/icons';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
import Legend from './legend';
|
||||
import SearchBox from './search-box';
|
||||
import ThemeSwitcher from './theme-switcher';
|
||||
import { Building } from '../models/building';
|
||||
|
||||
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
|
||||
|
||||
interface ColouringMapProps {
|
||||
building: Building;
|
||||
building?: Building;
|
||||
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
|
||||
category: string;
|
||||
revision_id: number;
|
||||
selectBuilding: any;
|
||||
colourBuilding: any;
|
||||
selectBuilding: (building: Building) => void;
|
||||
colourBuilding: (building: Building) => void;
|
||||
}
|
||||
|
||||
interface ColouringMapState {
|
||||
@ -34,15 +34,6 @@ interface ColouringMapState {
|
||||
* Map area
|
||||
*/
|
||||
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
building: PropTypes.object,
|
||||
mode: PropTypes.string,
|
||||
category: PropTypes.string,
|
||||
revision_id: PropTypes.number,
|
||||
selectBuilding: PropTypes.func,
|
||||
colourBuilding: PropTypes.func
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -93,7 +84,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => console.error(err)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
themeSwitch(e) {
|
||||
@ -126,10 +117,12 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
const baseLayer = <TileLayer
|
||||
url={baseUrl}
|
||||
attribution={attribution}
|
||||
maxNativeZoom={18}
|
||||
maxZoom={19}
|
||||
/>;
|
||||
|
||||
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
|
||||
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} maxZoom={19}/>;
|
||||
|
||||
|
||||
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
|
||||
@ -155,6 +148,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
key={tileset}
|
||||
url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${rev}`}
|
||||
minZoom={9}
|
||||
maxZoom={19}
|
||||
/>
|
||||
: null;
|
||||
|
||||
@ -163,7 +157,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
<TileLayer
|
||||
key={this.props.building.building_id}
|
||||
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.geometry_id}&base=${tileset}`}
|
||||
minZoom={14}
|
||||
minZoom={13}
|
||||
maxZoom={19}
|
||||
zIndex={100}
|
||||
/>
|
||||
: null;
|
||||
@ -176,7 +171,7 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
|
||||
center={position}
|
||||
zoom={this.state.zoom}
|
||||
minZoom={9}
|
||||
maxZoom={18}
|
||||
maxZoom={19}
|
||||
doubleClickZoom={false}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
|
@ -1,16 +1,36 @@
|
||||
import { Point } from 'geojson';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './search-box.css';
|
||||
|
||||
import { SearchIcon } from '../components/icons';
|
||||
|
||||
interface SearchResult {
|
||||
type: string;
|
||||
attributes: {
|
||||
label: string;
|
||||
zoom: number;
|
||||
};
|
||||
geometry: Point;
|
||||
}
|
||||
|
||||
|
||||
interface SearchBoxProps {
|
||||
onLocate: (lat: number, lng: number, zoom: number) => void;
|
||||
}
|
||||
|
||||
interface SearchBoxState {
|
||||
q: string;
|
||||
results: SearchResult[];
|
||||
fetching: boolean;
|
||||
collapsedSearch: boolean;
|
||||
smallScreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for location
|
||||
*/
|
||||
class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
onLocate: PropTypes.func
|
||||
};
|
||||
|
||||
class SearchBox extends Component<SearchBoxProps, SearchBoxState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -21,7 +41,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
collapsedSearch: true,
|
||||
//is this a small screen device? if not we will disable collapse option
|
||||
smallScreen: false
|
||||
}
|
||||
};
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.search = this.search.bind(this);
|
||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||
@ -74,7 +94,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
e.preventDefault();
|
||||
this.setState({
|
||||
fetching: true
|
||||
})
|
||||
});
|
||||
|
||||
fetch(
|
||||
'/api/search?q='+this.state.q
|
||||
@ -85,23 +105,23 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
this.setState({
|
||||
results: data.results,
|
||||
fetching: false
|
||||
})
|
||||
});
|
||||
} else {
|
||||
console.error(data);
|
||||
|
||||
this.setState({
|
||||
results: [],
|
||||
fetching: false
|
||||
})
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
console.error(err);
|
||||
|
||||
this.setState({
|
||||
results: [],
|
||||
fetching: false
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -129,7 +149,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
<div className="collapse-btn" onClick={this.expandSearch}>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resultsList = this.state.results.length?
|
||||
@ -140,7 +160,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
const lng = result.geometry.coordinates[0];
|
||||
const lat = result.geometry.coordinates[1];
|
||||
const zoom = result.attributes.zoom;
|
||||
const href = `?lng=${lng}&lat=${lat}&zoom=${zoom}`
|
||||
const href = `?lng=${lng}&lat=${lat}&zoom=${zoom}`;
|
||||
return (
|
||||
<li key={result.attributes.label}>
|
||||
<a
|
||||
@ -152,7 +172,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
href={href}
|
||||
>{`${label.substring(0, 4)} ${label.substring(4, 7)}`}</a>
|
||||
</li>
|
||||
)
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
@ -177,7 +197,7 @@ class SearchBox extends Component<any, any> { // TODO: add proper types
|
||||
</form>
|
||||
{ resultsList }
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import './theme-switcher.css';
|
||||
|
||||
const ThemeSwitcher = (props) => (
|
||||
interface ThemeSwitcherProps {
|
||||
currentTheme: string;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = (props) => (
|
||||
<form className={`theme-switcher ${props.currentTheme}`} onSubmit={props.onSubmit}>
|
||||
<button className="btn btn-outline btn-outline-dark"
|
||||
type="submit">
|
||||
@ -12,9 +16,4 @@ const ThemeSwitcher = (props) => (
|
||||
</form>
|
||||
);
|
||||
|
||||
ThemeSwitcher.propTypes = {
|
||||
currentTheme: PropTypes.string,
|
||||
onSubmit: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
export default ThemeSwitcher;
|
||||
|
@ -4,4 +4,5 @@ export interface EditHistoryEntry {
|
||||
revision_id: string;
|
||||
forward_patch: object;
|
||||
reverse_patch: object;
|
||||
building_id?: number;
|
||||
}
|
@ -1,5 +1,9 @@
|
||||
interface User {
|
||||
username: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
registered?: Date;
|
||||
api_key?: string;
|
||||
error?: string;
|
||||
// TODO: add other fields as needed
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import './about.css';
|
||||
|
||||
import Categories from '../building/categories';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
|
||||
const AboutPage = () => (
|
||||
<article>
|
||||
@ -140,7 +141,7 @@ const AboutPage = () => (
|
||||
onSubmit={function() {window.open(
|
||||
'https://tinyletter.com/colouringlondon',
|
||||
'popupwindow',
|
||||
'scrollbars=yes,width=800,height=600'); return true}}>
|
||||
'scrollbars=yes,width=800,height=600'); return true;}}>
|
||||
<h3 className="h1">Keep in touch</h3>
|
||||
<p>
|
||||
|
||||
|
41
app/src/frontend/pages/changes.tsx
Normal file
41
app/src/frontend/pages/changes.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { BuildingEditSummary } from '../building/edit-history/building-edit-summary';
|
||||
import { EditHistoryEntry } from '../models/edit-history-entry';
|
||||
|
||||
const ChangesPage = () => {
|
||||
const [history, setHistory] = useState<EditHistoryEntry[]>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const res = await fetch(`/api/history`);
|
||||
const data = await res.json();
|
||||
|
||||
setHistory(data.history);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<article>
|
||||
<section className="main-col">
|
||||
<h1>Global edit history</h1>
|
||||
|
||||
<ul className="edit-history-list">
|
||||
{history && history.map(entry => (
|
||||
<li key={`${entry.revision_id}`} className="edit-history-list-element">
|
||||
<BuildingEditSummary
|
||||
historyEntry={entry}
|
||||
showBuildingId={true}
|
||||
hyperlinkCategories={true}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangesPage;
|
@ -9,14 +9,25 @@ const ContactPage = () => (
|
||||
</h1>
|
||||
<p> Colouring London has been designed as a sustainable, low-cost model for knowledge exchange/open data platforms able to be reproduced by other towns and cities using our open platform code.</p>
|
||||
|
||||
<p> It is being developed by a small, dedicated team at UCL. We are unable to answer individual queries but welcome constructive comments on how to improve the site, and on other types of data and new features you might like to see.</p>
|
||||
<p> It is being developed by a small, dedicated team at UCL. We are unable to answer individual queries but welcome constructive comments on how to improve the site, and on other types of data and new features you might like to see.</p>
|
||||
|
||||
<p> You can send us comments or ask questions on our discussion threads at <a href="https://discuss.colouring.london/">https://discuss.colouring.london/</a>.</p>
|
||||
|
||||
|
||||
<p> To view our technical site and platform code please visit Github at: <a href="https://github.com/tomalrussell/colouring-london">https://github.com/tomalrussell/colouring-london</a>.</p>
|
||||
|
||||
<p>For press enquiries please contact the Bartlett Press and Communications team at architecture@ucl.ac.uk.</p>
|
||||
<p>For press enquiries please contact the Bartlett Press and Communications team at <a href="mailto:architecture@ucl.ac.uk">architecture@ucl.ac.uk</a></p>
|
||||
|
||||
<p>
|
||||
If you capture images from the maps on Colouring London, please credit our
|
||||
contributors (who collected the data) and Ordnance Survey
|
||||
(who provided the basemaps and building geometries) as follows:
|
||||
</p>
|
||||
<p>
|
||||
<pre><code>
|
||||
Colouring London https://colouring.london Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.
|
||||
</code></pre>
|
||||
</p>
|
||||
<hr />
|
||||
<p>
|
||||
<img className="d-block mx-auto" src="images/logo-cl.png"></img>
|
||||
</p>
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
const ContributorAgreementPage : React.SFC<any> = () => (
|
||||
<article>
|
||||
<section className='main-col'>
|
||||
@ -40,6 +38,6 @@ const ContributorAgreementPage : React.SFC<any> = () => (
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
|
||||
export default ContributorAgreementPage;
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { dateReviver } from '../../helpers';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
|
||||
interface ExtractViewModel {
|
||||
extract_id: number;
|
||||
@ -33,7 +34,7 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
|
||||
.sort((a, b) => a.extracted_on.valueOf() - b.extracted_on.valueOf())
|
||||
.reverse();
|
||||
|
||||
|
||||
|
||||
|
||||
this.setState({ extracts: extracts, latestExtract: extracts[0], previousExtracts: extracts.slice(1) });
|
||||
}
|
||||
@ -44,8 +45,22 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
|
||||
<article>
|
||||
<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>By downloading data extracts from this site, you agree to the <NavLink to="/data-accuracy.html">data accuracy agreement </NavLink> and the <NavLink to="/ordnance-survey-uprn.html">Ordnance Survey terms of UPRN usage</NavLink>.</p>
|
||||
<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>
|
||||
You are free to copy, distribute, transmit and adapt our data, as long as you credit Colouring London and our contributors. If you alter or build upon our data, you may distribute the result only under the same licence.
|
||||
</p>
|
||||
<p>
|
||||
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> and the <Link to="/ordnance-survey-uprn.html">Ordnance Survey terms of UPRN usage</Link>.
|
||||
</p>
|
||||
|
||||
{
|
||||
this.state.extracts == undefined ?
|
||||
<p>Loading extracts...</p> :
|
||||
@ -69,14 +84,14 @@ export default class DataExtracts extends React.Component<{}, DataExtractsState>
|
||||
<h1 className="h3">Older extracts</h1>
|
||||
<ul>
|
||||
{
|
||||
this.state.previousExtracts.map(e =>
|
||||
this.state.previousExtracts.map(e =>
|
||||
<li>
|
||||
<ExtractDownloadLink {...e} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>) :
|
||||
</div>) :
|
||||
null
|
||||
}
|
||||
|
||||
|
@ -11,4 +11,4 @@ const OrdnanceSurveyUprnPage = () => (
|
||||
</article>
|
||||
);
|
||||
|
||||
export default OrdnanceSurveyUprnPage;
|
||||
export default OrdnanceSurveyUprnPage;
|
||||
|
@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
const PrivacyPolicyPage: React.SFC<any> = () => (
|
||||
<article>
|
||||
<section className='main-col'>
|
||||
@ -118,6 +116,6 @@ const PrivacyPolicyPage: React.SFC<any> = () => (
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
|
||||
export default PrivacyPolicyPage;
|
||||
|
@ -6,12 +6,14 @@
|
||||
z-index: 10000;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 95%;
|
||||
max-height: calc(100%-2em);
|
||||
border-radius: 0;
|
||||
padding: 1.5em 2.5em 2.5em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.welcome-float.jumbotron {
|
||||
padding: 1em 2.5em 1.5em;
|
||||
background: #fff;
|
||||
background-color: rgba(255,255,255,0.95);
|
||||
}
|
||||
@ -23,3 +25,12 @@
|
||||
top: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-float .lead {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.welcome-float .lead a {
|
||||
color: #333;
|
||||
border-bottom-color: #333;
|
||||
}
|
@ -9,19 +9,21 @@ const Welcome = () => (
|
||||
|
||||
<p className="lead">
|
||||
Colouring London is a knowledge exchange platform collecting information on every
|
||||
building in London, to help make the city more sustainable. We’re building it at The
|
||||
Bartlett Centre for Advanced Spatial Analysis, University College London.
|
||||
building in London, to help make the city more sustainable. We're developing it at University College London. Can you help us? We're looking for volunteers of all ages and abilities to help test the site and colour the buildings in.
|
||||
</p>
|
||||
<p className="lead">
|
||||
Can you help us? We’re still at an early stage of development, and we’re looking for
|
||||
volunteers of all ages and abilities to test and provide feedback on the site as we
|
||||
build it.
|
||||
Our building data comes from many different sources. Though we are unable to vouch for their accuracy, we are currently experimenting with a range of features including 'data source', 'edit history', and 'entry verification', to assist you in checking reliability and judging how suitable the data are for your intended use.
|
||||
</p>
|
||||
<p className="lead">
|
||||
All data we collect are made <Link to="/data-extracts.html">openly available</Link>. We just ask you to credit Colouring London and read our <a href="https://www.pages.colouring.london/data-ethics">data ethics policy</a> when using or sharing our data, maps or <a href="https://github.com/tomalrussell/colouring-london">code</a>.
|
||||
</p>
|
||||
<Link to="/view/categories"
|
||||
className="btn btn-outline-dark btn-lg btn-block">
|
||||
Start Colouring Here!
|
||||
</Link>
|
||||
<p>
|
||||
<img src="images/supporter-logos.png" alt="Colouring London collaborating organisations: The Bartlett UCL, Ordnance Survey, Historic England, Greater London Authority" />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { FormEvent, ChangeEvent } from 'react';
|
||||
import InfoBox from '../components/info-box';
|
||||
import React, { ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
|
||||
interface ForgottenPasswordState {
|
||||
success: boolean;
|
||||
@ -61,7 +62,7 @@ export default class ForgottenPassword extends React.Component<{}, ForgottenPass
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<InfoBox msg="">
|
||||
{this.state.success ?
|
||||
`A password reset link has been sent to ${this.state.emailUsed}. Please check your inbox.` :
|
||||
`If the email address is registered on Colouring London, a password reset link will be sent to ${this.state.emailUsed}. Please check your inbox.` :
|
||||
null
|
||||
}
|
||||
</InfoBox>
|
||||
@ -79,6 +80,6 @@ export default class ForgottenPassword extends React.Component<{}, ForgottenPass
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,17 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import { User } from '../models/user';
|
||||
|
||||
class Login extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
login: PropTypes.func,
|
||||
user: PropTypes.object
|
||||
};
|
||||
interface LoginProps {
|
||||
user: User;
|
||||
login: (user: User) => void;
|
||||
}
|
||||
|
||||
class Login extends Component<LoginProps, any> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -37,7 +37,7 @@ class Login extends Component<any, any> { // TODO: add proper types
|
||||
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined})
|
||||
this.setState({error: undefined});
|
||||
|
||||
fetch('/api/login', {
|
||||
method: 'POST',
|
||||
@ -50,7 +50,7 @@ class Login extends Component<any, any> { // TODO: add proper types
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
fetch('/api/users/me', {
|
||||
credentials: 'same-origin'
|
||||
@ -58,13 +58,13 @@ class Login extends Component<any, any> { // TODO: add proper types
|
||||
(res) => res.json()
|
||||
).then(user => {
|
||||
if (user.error) {
|
||||
this.setState({error: user.error})
|
||||
this.setState({error: user.error});
|
||||
} else {
|
||||
this.props.login(user)
|
||||
this.props.login(user);
|
||||
}
|
||||
}).catch(
|
||||
(err) => this.setState({error: err})
|
||||
)
|
||||
);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
@ -73,7 +73,7 @@ class Login extends Component<any, any> { // TODO: add proper types
|
||||
|
||||
render() {
|
||||
if (this.props.user && !this.props.user.error) {
|
||||
return <Redirect to="/my-account.html" />
|
||||
return <Redirect to="/my-account.html" />;
|
||||
}
|
||||
return (
|
||||
<article>
|
||||
@ -130,7 +130,7 @@ class Login extends Component<any, any> { // TODO: add proper types
|
||||
<SupporterLogos />
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,23 @@
|
||||
import React, { Component, FormEvent } from 'react';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ConfirmationModal from '../components/confirmation-modal';
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { User } from '../models/user';
|
||||
|
||||
class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
user: PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
email: PropTypes.string,
|
||||
registered: PropTypes.instanceOf(Date), // TODO: check if fix correct
|
||||
api_key: PropTypes.string,
|
||||
error: PropTypes.object
|
||||
}),
|
||||
updateUser: PropTypes.func,
|
||||
logout: PropTypes.func
|
||||
};
|
||||
interface MyAccountPageProps {
|
||||
user: User;
|
||||
updateUser: (user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
interface MyAccountPageState {
|
||||
showDeleteConfirm: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
|
||||
class MyAccountPage extends Component<MyAccountPageProps, MyAccountPageState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -39,7 +39,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
this.props.logout();
|
||||
}
|
||||
@ -59,7 +59,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
this.props.updateUser(res);
|
||||
}
|
||||
@ -107,11 +107,17 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
<h1 className="h1">Welcome, {this.props.user.username}!</h1>
|
||||
<p>
|
||||
|
||||
Colouring London is under active development, Please <a href="https://discuss.colouring.london/">discuss
|
||||
Colouring London is under active development. Please <a href="https://discuss.colouring.london/">discuss
|
||||
suggestions for improvements</a> and <a
|
||||
href="https://github.com/tomalrussell/colouring-london/issues">
|
||||
report issues or problems</a>.
|
||||
|
||||
</p>
|
||||
<p>
|
||||
For reference, here are the <Link
|
||||
to="/privacy-policy.html">privacy policy</Link>, <Link
|
||||
to="/contributor-agreement.html">contributor agreement</Link> and <Link
|
||||
to="/data-accuracy.html">data accuracy agreement</Link>.
|
||||
</p>
|
||||
<ErrorBox msg={this.state.error} />
|
||||
<form onSubmit={this.handleLogout}>
|
||||
@ -170,7 +176,7 @@ class MyAccountPage extends Component<any, any> { // TODO: add proper types
|
||||
} else {
|
||||
return (
|
||||
<Redirect to="/login.html" />
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { ChangeEvent, FormEvent } from 'react';
|
||||
import { RouteComponentProps, Redirect } from 'react-router';
|
||||
import React, { FormEvent } from 'react';
|
||||
import { Redirect, RouteComponentProps } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ErrorBox from '../components/error-box';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface PasswordResetState {
|
||||
error: string;
|
||||
@ -117,6 +117,6 @@ export default class PasswordReset extends React.Component<RouteComponentProps,
|
||||
</form>
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,27 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, Redirect } from 'react-router-dom';
|
||||
|
||||
import ErrorBox from '../components/error-box';
|
||||
import InfoBox from '../components/info-box';
|
||||
import SupporterLogos from '../components/supporter-logos';
|
||||
import { User } from '../models/user';
|
||||
|
||||
class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
static propTypes = { // TODO: generate propTypes from TS
|
||||
login: PropTypes.func.isRequired,
|
||||
user: PropTypes.object
|
||||
};
|
||||
interface SignUpProps {
|
||||
login: (user: User) => void;
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface SignUpState {
|
||||
username: string;
|
||||
email: string;
|
||||
confirm_email: string;
|
||||
show_password: boolean;
|
||||
password: string;
|
||||
confirm_conditions: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
class SignUp extends Component<SignUpProps, SignUpState> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -19,7 +29,7 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
email: '',
|
||||
confirm_email: '',
|
||||
password: '',
|
||||
show_password: '',
|
||||
show_password: false,
|
||||
confirm_conditions: false,
|
||||
error: undefined
|
||||
};
|
||||
@ -31,16 +41,16 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
handleChange(event) {
|
||||
const target = event.target;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
const name = target.name;
|
||||
const name: keyof SignUpState = target.name;
|
||||
|
||||
this.setState({
|
||||
[name]: value
|
||||
});
|
||||
} as Pick<SignUpState, keyof SignUpState>);
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({error: undefined})
|
||||
this.setState({error: undefined});
|
||||
|
||||
fetch('/api/users', {
|
||||
method: 'POST',
|
||||
@ -53,7 +63,7 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
res => res.json()
|
||||
).then(function(res){
|
||||
if (res.error) {
|
||||
this.setState({error: res.error})
|
||||
this.setState({error: res.error});
|
||||
} else {
|
||||
fetch('/api/users/me', {
|
||||
credentials: 'same-origin'
|
||||
@ -63,7 +73,7 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
(user) => this.props.login(user)
|
||||
).catch(
|
||||
(err) => this.setState({error: err})
|
||||
)
|
||||
);
|
||||
}
|
||||
}.bind(this)).catch(
|
||||
(err) => this.setState({error: err})
|
||||
@ -72,7 +82,7 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
|
||||
render() {
|
||||
if (this.props.user) {
|
||||
return <Redirect to="/my-account.html" />
|
||||
return <Redirect to="/my-account.html" />;
|
||||
}
|
||||
return (
|
||||
<article>
|
||||
@ -165,7 +175,7 @@ class SignUp extends Component<any, any> { // TODO: add proper types
|
||||
<SupporterLogos />
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,18 @@
|
||||
import express from 'express';
|
||||
import React from 'react';
|
||||
import { StaticRouter } from 'react-router-dom';
|
||||
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { StaticRouter } from 'react-router-dom';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import App from './frontend/app';
|
||||
|
||||
import { parseBuildingURL } from './parse';
|
||||
import { getUserById } from './api/services/user';
|
||||
import {
|
||||
getBuildingById,
|
||||
getBuildingLikeById,
|
||||
getBuildingUPRNsById,
|
||||
getLatestRevisionId
|
||||
} from './api/services/building';
|
||||
import { getUserById } from './api/services/user';
|
||||
import App from './frontend/app';
|
||||
import { parseBuildingURL } from './parse';
|
||||
|
||||
|
||||
// reference packed assets
|
||||
|
@ -27,7 +27,7 @@ function parseBuildingURL(url) {
|
||||
const matches = re.exec(url);
|
||||
|
||||
if (matches && matches.length >= 2) {
|
||||
return strictParseInt(matches[1])
|
||||
return strictParseInt(matches[1]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
@ -4,15 +4,14 @@
|
||||
* - entry-point to shared React App
|
||||
*
|
||||
*/
|
||||
import express from 'express';
|
||||
|
||||
import session from 'express-session';
|
||||
import pgConnect from 'connect-pg-simple';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
|
||||
import db from './db';
|
||||
import tileserver from './tiles/tileserver';
|
||||
import apiServer from './api/api';
|
||||
import db from './db';
|
||||
import frontendRoute from './frontendRoute';
|
||||
import tileserver from './tiles/tileserver';
|
||||
|
||||
// create server
|
||||
const server = express();
|
||||
@ -38,9 +37,9 @@ const sess: any = { // TODO: remove any
|
||||
};
|
||||
if (server.get('env') === 'production') {
|
||||
// trust first proxy
|
||||
server.set('trust proxy', 1)
|
||||
server.set('trust proxy', 1);
|
||||
// serve secure cookies
|
||||
sess.cookie.secure = true
|
||||
sess.cookie.secure = true;
|
||||
}
|
||||
server.use(session(sess));
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { strictParseInt } from "../parse";
|
||||
import { DataConfig } from "./renderers/datasourceRenderer";
|
||||
|
||||
import { DataConfig } from "./types";
|
||||
|
||||
const BUILDING_LAYER_DEFINITIONS = {
|
||||
base_light: `(
|
||||
@ -111,6 +112,14 @@ const BUILDING_LAYER_DEFINITIONS = {
|
||||
|
||||
const GEOMETRY_FIELD = 'geometry_geom';
|
||||
|
||||
function getBuildingLayerNames() {
|
||||
return Object.keys(BUILDING_LAYER_DEFINITIONS);
|
||||
}
|
||||
|
||||
function getAllLayerNames() {
|
||||
return ['highlight', ...getBuildingLayerNames()];
|
||||
}
|
||||
|
||||
function getBuildingsDataConfig(tileset: string, dataParams: any): DataConfig {
|
||||
const table = BUILDING_LAYER_DEFINITIONS[tileset];
|
||||
|
||||
@ -149,7 +158,8 @@ function getHighlightDataConfig(tileset: string, dataParams: any): DataConfig {
|
||||
}
|
||||
|
||||
export {
|
||||
BUILDING_LAYER_DEFINITIONS,
|
||||
getBuildingLayerNames,
|
||||
getAllLayerNames,
|
||||
getBuildingsDataConfig,
|
||||
getHighlightDataConfig
|
||||
};
|
||||
|
@ -1,21 +1,16 @@
|
||||
import { getAllLayerNames, getBuildingLayerNames, getBuildingsDataConfig, getHighlightDataConfig } from "./dataDefinition";
|
||||
import { createBlankTile } from "./renderers/createBlankTile";
|
||||
import { getTileWithCaching } from "./renderers/getTileWithCaching";
|
||||
import { renderDataSourceTile } from "./renderers/renderDataSourceTile";
|
||||
import { stitchTile } from "./renderers/stitchTile";
|
||||
import { TileCache } from "./tileCache";
|
||||
import { BoundingBox, TileParams } from "./types";
|
||||
import { StitchRenderer } from "./renderers/stitchRenderer";
|
||||
import { CachedRenderer } from "./renderers/cachedRenderer";
|
||||
import { BranchingRenderer } from "./renderers/branchingRenderer";
|
||||
import { WindowedRenderer } from "./renderers/windowedRenderer";
|
||||
import { BlankRenderer } from "./renderers/blankRenderer";
|
||||
import { DatasourceRenderer } from "./renderers/datasourceRenderer";
|
||||
import { getBuildingsDataConfig, getHighlightDataConfig, BUILDING_LAYER_DEFINITIONS } from "./dataDefinition";
|
||||
import { BoundingBox, Tile, TileParams } from "./types";
|
||||
import { isOutsideExtent } from "./util";
|
||||
|
||||
/**
|
||||
* A list of all tilesets handled by the tile server
|
||||
*/
|
||||
const allTilesets = ['highlight', ...Object.keys(BUILDING_LAYER_DEFINITIONS)];
|
||||
|
||||
const buildingDataRenderer = new DatasourceRenderer(getBuildingsDataConfig);
|
||||
|
||||
const stitchRenderer = new StitchRenderer(undefined); // depends recurrently on cache, so parameter will be set later
|
||||
const allTilesets = getAllLayerNames();
|
||||
|
||||
/**
|
||||
* Zoom level when we switch from rendering direct from database to instead composing tiles
|
||||
@ -23,56 +18,60 @@ const stitchRenderer = new StitchRenderer(undefined); // depends recurrently on
|
||||
*/
|
||||
const STITCH_THRESHOLD = 12;
|
||||
|
||||
const renderOrStitchRenderer = new BranchingRenderer(
|
||||
({ z }) => z <= STITCH_THRESHOLD,
|
||||
stitchRenderer, // refer to the prepared stitch renderer
|
||||
buildingDataRenderer
|
||||
);
|
||||
|
||||
const tileCache = new TileCache(
|
||||
process.env.TILECACHE_PATH,
|
||||
{
|
||||
tilesets: ['date_year', 'size_storeys', 'location', 'likes', 'conservation_area', 'sust_dec', 'building_attachment_form'],
|
||||
minZoom: 9,
|
||||
maxZoom: 18,
|
||||
scales: [1, 2]
|
||||
},
|
||||
({ tileset, z }: TileParams) => (tileset === 'date_year' && z <= 16) ||
|
||||
((tileset === 'base_light' || tileset === 'base_night') && z <= 17) ||
|
||||
z <= 13
|
||||
);
|
||||
|
||||
const cachedRenderer = new CachedRenderer(
|
||||
tileCache,
|
||||
renderOrStitchRenderer
|
||||
);
|
||||
|
||||
// set up stitch renderer to use the data renderer with caching
|
||||
stitchRenderer.tileRenderer = cachedRenderer;
|
||||
|
||||
const highlightRenderer = new DatasourceRenderer(getHighlightDataConfig);
|
||||
|
||||
const highlightOrBuildingRenderer = new BranchingRenderer(
|
||||
({ tileset }) => tileset === 'highlight',
|
||||
highlightRenderer,
|
||||
cachedRenderer
|
||||
);
|
||||
|
||||
const blankRenderer = new BlankRenderer();
|
||||
|
||||
/**
|
||||
* Hard-code extent so we can short-circuit rendering and return empty/transparent tiles outside the area of interest
|
||||
* bbox in CRS epsg:3857 in form: [w, s, e, n]
|
||||
*/
|
||||
const EXTENT_BBOX: BoundingBox = [-61149.622628, 6667754.851372, 28128.826409, 6744803.375884];
|
||||
const mainRenderer = new WindowedRenderer(
|
||||
EXTENT_BBOX,
|
||||
highlightOrBuildingRenderer,
|
||||
blankRenderer
|
||||
|
||||
const tileCache = new TileCache(
|
||||
process.env.TILECACHE_PATH,
|
||||
{
|
||||
tilesets: getBuildingLayerNames(),
|
||||
minZoom: 9,
|
||||
maxZoom: 19,
|
||||
scales: [1, 2]
|
||||
},
|
||||
|
||||
// cache age data and base building outlines for more zoom levels than other layers
|
||||
({ tileset, z }: TileParams) => (tileset === 'date_year' && z <= 16) ||
|
||||
((tileset === 'base_light' || tileset === 'base_night') && z <= 17) ||
|
||||
z <= 13,
|
||||
|
||||
// don't clear base_light and base_night on bounding box cache clear
|
||||
(tileset: string) => tileset !== 'base_light' && tileset !== 'base_night'
|
||||
);
|
||||
|
||||
const renderBuildingTile = (t: TileParams, d: any) => renderDataSourceTile(t, d, getBuildingsDataConfig);
|
||||
const renderHighlightTile = (t: TileParams, d: any) => renderDataSourceTile(t, d, getHighlightDataConfig);
|
||||
|
||||
function cacheOrCreateBuildingTile(tileParams: TileParams, dataParams: any): Promise<Tile> {
|
||||
return getTileWithCaching(tileParams, dataParams, tileCache, stitchOrRenderBuildingTile);
|
||||
}
|
||||
|
||||
function stitchOrRenderBuildingTile(tileParams: TileParams, dataParams: any): Promise<Tile> {
|
||||
if (tileParams.z <= STITCH_THRESHOLD) {
|
||||
// stitch tile, using cache recursively
|
||||
return stitchTile(tileParams, dataParams, cacheOrCreateBuildingTile);
|
||||
} else {
|
||||
return renderBuildingTile(tileParams, dataParams);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTile(tileParams: TileParams, dataParams: any): Promise<Tile> {
|
||||
if (isOutsideExtent(tileParams, EXTENT_BBOX)) {
|
||||
return createBlankTile();
|
||||
}
|
||||
|
||||
if (tileParams.tileset === 'highlight') {
|
||||
return renderHighlightTile(tileParams, dataParams);
|
||||
}
|
||||
|
||||
return cacheOrCreateBuildingTile(tileParams, dataParams);
|
||||
}
|
||||
|
||||
export {
|
||||
allTilesets,
|
||||
mainRenderer,
|
||||
renderTile,
|
||||
tileCache
|
||||
};
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { Image } from "mapnik";
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
|
||||
class BlankRenderer implements TileRenderer {
|
||||
getTile(tileParams: TileParams): Promise<Image> {
|
||||
return sharp({
|
||||
create: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
}).png().toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BlankRenderer
|
||||
};
|
@ -1,23 +0,0 @@
|
||||
import { Image } from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
|
||||
class BranchingRenderer {
|
||||
constructor(
|
||||
public branchTestFn: (tileParams: TileParams) => boolean,
|
||||
public trueResultTileRenderer: TileRenderer,
|
||||
public falseResultTileRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
if(this.branchTestFn(tileParams)) {
|
||||
return this.trueResultTileRenderer.getTile(tileParams, dataParams);
|
||||
} else {
|
||||
return this.falseResultTileRenderer.getTile(tileParams, dataParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
BranchingRenderer
|
||||
};
|
@ -1,32 +0,0 @@
|
||||
import { Image } from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
import { TileCache } from "../tileCache";
|
||||
import { formatParams } from "../util";
|
||||
|
||||
class CachedRenderer implements TileRenderer {
|
||||
constructor(
|
||||
/** Cache to use for tiles */
|
||||
public tileCache: TileCache,
|
||||
|
||||
/** Renderer to use when tile hasn't been cached yet */
|
||||
public tileRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
async getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
try {
|
||||
const tile = await this.tileCache.get(tileParams);
|
||||
return tile;
|
||||
} catch(err) {
|
||||
const im = await this.tileRenderer.getTile(tileParams, dataParams);
|
||||
try {
|
||||
await this.tileCache.put(im, tileParams);
|
||||
} catch (err) {}
|
||||
return im;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
CachedRenderer
|
||||
};
|
18
app/src/tiles/renderers/createBlankTile.ts
Normal file
18
app/src/tiles/renderers/createBlankTile.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Tile } from "../types";
|
||||
|
||||
function createBlankTile(): Promise<Tile> {
|
||||
return sharp({
|
||||
create: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
}).png().toBuffer();
|
||||
}
|
||||
|
||||
export {
|
||||
createBlankTile
|
||||
};
|
@ -1,105 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import mapnik from "mapnik";
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
import { getBbox, TILE_SIZE } from "../util";
|
||||
import { promisify } from "util";
|
||||
|
||||
interface DataConfig {
|
||||
table: string;
|
||||
geometry_field: string;
|
||||
}
|
||||
|
||||
const TILE_BUFFER_SIZE = 64;
|
||||
const PROJ4_STRING = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over';
|
||||
|
||||
// connection details from environment variables
|
||||
const DATASOURCE_CONFIG = {
|
||||
'host': process.env.PGHOST,
|
||||
'dbname': process.env.PGDATABASE,
|
||||
'user': process.env.PGUSER,
|
||||
'password': process.env.PGPASSWORD,
|
||||
'port': process.env.PGPORT,
|
||||
'extent': '-20005048.4188,-9039211.13765,19907487.2779,17096598.5401',
|
||||
'srid': 3857,
|
||||
'type': 'postgis'
|
||||
};
|
||||
|
||||
// register datasource adapters for mapnik database connection
|
||||
if (mapnik.register_default_input_plugins) {
|
||||
mapnik.register_default_input_plugins();
|
||||
}
|
||||
// register fonts for text rendering
|
||||
mapnik.register_default_fonts();
|
||||
|
||||
|
||||
class DatasourceRenderer implements TileRenderer {
|
||||
constructor(private getTableDefinitionFn: (tileset: string, dataParams: any) => DataConfig) {}
|
||||
|
||||
async getTile({tileset, z, x, y, scale}: TileParams, dataParams: any): Promise<mapnik.Image> {
|
||||
const bbox = getBbox(z, x, y);
|
||||
|
||||
const tileSize = TILE_SIZE * scale;
|
||||
let map = new mapnik.Map(tileSize, tileSize, PROJ4_STRING);
|
||||
map.bufferSize = TILE_BUFFER_SIZE;
|
||||
const layer = new mapnik.Layer('tile', PROJ4_STRING);
|
||||
|
||||
const dataSourceConfig = this.getTableDefinitionFn(tileset, dataParams);
|
||||
|
||||
const conf = Object.assign(dataSourceConfig, DATASOURCE_CONFIG);
|
||||
|
||||
const postgis = new mapnik.Datasource(conf);
|
||||
layer.datasource = postgis;
|
||||
layer.styles = [tileset];
|
||||
|
||||
const stylePath = path.join(__dirname, '..', 'map_styles', 'polygon.xml');
|
||||
|
||||
map = await promisify(map.load.bind(map))(stylePath, {strict: true});
|
||||
|
||||
map.add_layer(layer);
|
||||
const im = new mapnik.Image(map.width, map.height);
|
||||
map.extent = bbox;
|
||||
const rendered = await promisify(map.render.bind(map))(im, {});
|
||||
|
||||
return await promisify(rendered.encode.bind(rendered))('png');
|
||||
}
|
||||
}
|
||||
|
||||
function promiseHandler(resolve, reject) {
|
||||
return function(err, result) {
|
||||
if(err) reject(err);
|
||||
else resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function which promisifies a method of an object and binds it to the object
|
||||
* This makes it easier to use callback-based object methods in a promise-based way
|
||||
* @param obj Object containing the target method
|
||||
* @param methodName Method name to promisify and return
|
||||
*/
|
||||
function promisifyMethod<T, F>(obj: T, methodName: keyof T);
|
||||
/**
|
||||
* @param methodGetter accessor function to get the method from the object
|
||||
*/
|
||||
function promisifyMethod<T, S>(obj: T, methodGetter: (o: T) => S);
|
||||
function promisifyMethod<T, S>(obj: T, paramTwo: keyof T | ((o: T) => S)) {
|
||||
let method;
|
||||
if (typeof paramTwo === 'string') {
|
||||
method = obj[paramTwo];
|
||||
} else if (typeof paramTwo === 'function') {
|
||||
method = paramTwo(obj);
|
||||
}
|
||||
|
||||
if (typeof method === 'function') {
|
||||
return promisify(method.bind(obj));
|
||||
} else {
|
||||
throw new Error(`Cannot promisify non-function property '${paramTwo}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
DatasourceRenderer,
|
||||
DataConfig
|
||||
};
|
21
app/src/tiles/renderers/getTileWithCaching.ts
Normal file
21
app/src/tiles/renderers/getTileWithCaching.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { TileCache } from "../tileCache";
|
||||
import { RendererFunction, Tile, TileParams } from "../types";
|
||||
|
||||
|
||||
async function getTileWithCaching(tileParams: TileParams, dataParams: any, tileCache: TileCache, renderTile: RendererFunction): Promise<Tile> {
|
||||
try {
|
||||
const tile = await tileCache.get(tileParams);
|
||||
return tile;
|
||||
} catch (err) {
|
||||
const im = await renderTile(tileParams, dataParams);
|
||||
try {
|
||||
await tileCache.put(im, tileParams);
|
||||
} catch (err) {}
|
||||
return im;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
getTileWithCaching
|
||||
};
|
63
app/src/tiles/renderers/renderDataSourceTile.ts
Normal file
63
app/src/tiles/renderers/renderDataSourceTile.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import mapnik from "mapnik";
|
||||
import path from 'path';
|
||||
import { promisify } from "util";
|
||||
|
||||
import { TableDefinitionFunction, Tile, TileParams } from "../types";
|
||||
import { getBbox, TILE_SIZE } from "../util";
|
||||
|
||||
|
||||
const TILE_BUFFER_SIZE = 64;
|
||||
const PROJ4_STRING = '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs +over';
|
||||
|
||||
// connection details from environment variables
|
||||
const DATASOURCE_CONFIG = {
|
||||
'host': process.env.PGHOST,
|
||||
'dbname': process.env.PGDATABASE,
|
||||
'user': process.env.PGUSER,
|
||||
'password': process.env.PGPASSWORD,
|
||||
'port': process.env.PGPORT,
|
||||
'extent': '-20005048.4188,-9039211.13765,19907487.2779,17096598.5401',
|
||||
'srid': 3857,
|
||||
'type': 'postgis'
|
||||
};
|
||||
|
||||
// register datasource adapters for mapnik database connection
|
||||
if (mapnik.register_default_input_plugins) {
|
||||
mapnik.register_default_input_plugins();
|
||||
}
|
||||
// register fonts for text rendering
|
||||
mapnik.register_default_fonts();
|
||||
|
||||
|
||||
async function renderDataSourceTile({ tileset, z, x, y, scale }: TileParams, dataParams: any, getTableDefinitionFn: TableDefinitionFunction): Promise<Tile> {
|
||||
const bbox = getBbox(z, x, y);
|
||||
|
||||
const tileSize = TILE_SIZE * scale;
|
||||
let map = new mapnik.Map(tileSize, tileSize, PROJ4_STRING);
|
||||
map.bufferSize = TILE_BUFFER_SIZE;
|
||||
const layer = new mapnik.Layer('tile', PROJ4_STRING);
|
||||
|
||||
const dataSourceConfig = getTableDefinitionFn(tileset, dataParams);
|
||||
|
||||
const conf = Object.assign(dataSourceConfig, DATASOURCE_CONFIG);
|
||||
|
||||
const postgis = new mapnik.Datasource(conf);
|
||||
layer.datasource = postgis;
|
||||
layer.styles = [tileset];
|
||||
|
||||
const stylePath = path.join(__dirname, '..', 'map_styles', 'polygon.xml');
|
||||
|
||||
map = await promisify(map.load.bind(map))(stylePath, { strict: true });
|
||||
|
||||
map.add_layer(layer);
|
||||
const im = new mapnik.Image(map.width, map.height);
|
||||
map.extent = bbox;
|
||||
const rendered = await promisify(map.render.bind(map))(im, {});
|
||||
|
||||
return await promisify(rendered.encode.bind(rendered))('png');
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
renderDataSourceTile
|
||||
};
|
@ -1,67 +0,0 @@
|
||||
import sharp from 'sharp';
|
||||
import { Image } from 'mapnik';
|
||||
|
||||
import { TileParams, TileRenderer } from "../types";
|
||||
import { getBbox, getXYZ, TILE_SIZE, formatParams } from "../util";
|
||||
|
||||
class StitchRenderer implements TileRenderer {
|
||||
constructor(
|
||||
/** Renderer to use when retrieving tiles to be stitched together */
|
||||
public tileRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
console.log(`Stitching tile ${formatParams(tileParams)}`);
|
||||
return this.stitchTile(tileParams, dataParams, this.tileRenderer);
|
||||
}
|
||||
|
||||
private async stitchTile({ tileset, z, x, y, scale }: TileParams, dataParams: any, tileRenderer: TileRenderer) {
|
||||
const bbox = getBbox(z, x, y);
|
||||
const nextZ = z + 1;
|
||||
const nextXY = getXYZ(bbox, nextZ);
|
||||
const tileSize = TILE_SIZE * scale;
|
||||
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = await Promise.all([
|
||||
[nextXY.minX, nextXY.minY],
|
||||
[nextXY.maxX, nextXY.minY],
|
||||
[nextXY.minX, nextXY.maxY],
|
||||
[nextXY.maxX, nextXY.maxY]
|
||||
].map(([x, y]) => tileRenderer.getTile({ tileset, z: nextZ, x, y, scale }, dataParams)));
|
||||
|
||||
// not possible to chain overlays in a single pipeline, but there may still be a better
|
||||
// way to create image buffer here (four tiles resize to one at the next zoom level)
|
||||
// instead of repeatedly creating `sharp` objects, to png, to buffer...
|
||||
return sharp({
|
||||
create: {
|
||||
width: tileSize * 2,
|
||||
height: tileSize * 2,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
}).overlayWith(
|
||||
topLeft, { gravity: sharp.gravity.northwest }
|
||||
).png().toBuffer().then((buf) => {
|
||||
return sharp(buf).overlayWith(
|
||||
topRight, { gravity: sharp.gravity.northeast }
|
||||
).png().toBuffer()
|
||||
}).then((buf) => {
|
||||
return sharp(buf).overlayWith(
|
||||
bottomLeft, { gravity: sharp.gravity.southwest }
|
||||
).png().toBuffer()
|
||||
}).then((buf) => {
|
||||
return sharp(buf).overlayWith(
|
||||
bottomRight, { gravity: sharp.gravity.southeast }
|
||||
).png().toBuffer()
|
||||
}).then((buf) => {
|
||||
return sharp(buf
|
||||
).resize(tileSize, tileSize, { fit: 'inside' }
|
||||
).png().toBuffer()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
StitchRenderer
|
||||
};
|
43
app/src/tiles/renderers/stitchTile.ts
Normal file
43
app/src/tiles/renderers/stitchTile.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { RendererFunction, Tile, TileParams } from "../types";
|
||||
import { getBbox, getXYZ, TILE_SIZE } from "../util";
|
||||
|
||||
|
||||
async function stitchTile({ tileset, z, x, y, scale }: TileParams, dataParams: any, renderTile: RendererFunction): Promise<Tile> {
|
||||
const bbox = getBbox(z, x, y);
|
||||
const nextZ = z + 1;
|
||||
const nextXY = getXYZ(bbox, nextZ);
|
||||
const tileSize = TILE_SIZE * scale;
|
||||
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = await Promise.all([
|
||||
[nextXY.minX, nextXY.minY],
|
||||
[nextXY.maxX, nextXY.minY],
|
||||
[nextXY.minX, nextXY.maxY],
|
||||
[nextXY.maxX, nextXY.maxY]
|
||||
].map(([x, y]) => renderTile({ tileset, z: nextZ, x, y, scale }, dataParams)));
|
||||
|
||||
const compositedBuffer = await sharp({
|
||||
create: {
|
||||
width: tileSize * 2,
|
||||
height: tileSize * 2,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
}).composite([
|
||||
{input: topLeft, gravity: sharp.gravity.northwest},
|
||||
{input: topRight, gravity: sharp.gravity.northeast},
|
||||
{input: bottomLeft, gravity: sharp.gravity.southwest},
|
||||
{input: bottomRight, gravity: sharp.gravity.southeast}
|
||||
]).png().toBuffer();
|
||||
|
||||
return sharp(compositedBuffer)
|
||||
.resize(tileSize, tileSize, {fit: 'inside'})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
export {
|
||||
stitchTile
|
||||
};
|
@ -1,34 +0,0 @@
|
||||
import { Image } from "mapnik";
|
||||
|
||||
import { BoundingBox, TileParams, TileRenderer } from "../types";
|
||||
import { getXYZ } from "../util";
|
||||
|
||||
class WindowedRenderer implements TileRenderer {
|
||||
constructor(
|
||||
/** Bounding box defining the renderer window */
|
||||
public bbox: BoundingBox,
|
||||
|
||||
/** Renderer to use for tile requests inside window */
|
||||
public insideWindowRenderer: TileRenderer,
|
||||
|
||||
/** Renderer to use for tile requests outside window */
|
||||
public outsideWindowRenderer: TileRenderer
|
||||
) {}
|
||||
|
||||
getTile(tileParams: TileParams, dataParams: any): Promise<Image> {
|
||||
if(this.isOutsideExtent(tileParams)) {
|
||||
return this.outsideWindowRenderer.getTile(tileParams, dataParams);
|
||||
} else {
|
||||
return this.insideWindowRenderer.getTile(tileParams, dataParams);
|
||||
}
|
||||
}
|
||||
|
||||
private isOutsideExtent({x, y, z}: TileParams) {
|
||||
const xy = getXYZ(this.bbox, z);
|
||||
return xy.minY > y || xy.maxY < y || xy.minX > x || xy.maxX < x;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
WindowedRenderer
|
||||
};
|
@ -16,12 +16,12 @@
|
||||
// Using node-fs package to patch fs
|
||||
// for node >10 we could drop this in favour of fs.mkdir (which has recursive option)
|
||||
// and then use stdlib `import fs from 'fs';`
|
||||
import fs from 'node-fs';
|
||||
import { promisify } from 'util'
|
||||
import { Image } from 'mapnik';
|
||||
import fs from 'node-fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { TileParams, BoundingBox } from './types';
|
||||
import { getXYZ, formatParams } from './util';
|
||||
import { BoundingBox, TileParams } from './types';
|
||||
import { formatParams, getXYZ } from './util';
|
||||
|
||||
// TODO: switch to modern node and use built-in fs with promise-based API
|
||||
const readFile = promisify(fs.readFile),
|
||||
@ -70,7 +70,9 @@ class TileCache {
|
||||
/** Domain definition for the cache */
|
||||
private cacheDomain: CacheDomain,
|
||||
/** Function for defining custom caching rules (optional) */
|
||||
private shouldCacheFn?: (TileParams) => boolean
|
||||
private shouldCacheFn?: (TileParams) => boolean,
|
||||
/** Function for defining whether the tileset should be cleared when clearing cache for bounding box */
|
||||
private shouldBulkClearTilesetFn?: (tileset: string) => boolean
|
||||
) {}
|
||||
|
||||
async get(tileParams: TileParams): Promise<Image> {
|
||||
@ -108,8 +110,10 @@ class TileCache {
|
||||
async removeAllAtBbox(bbox: BoundingBox): Promise<void[]> {
|
||||
const removePromises: Promise<void>[] = [];
|
||||
for (const tileset of this.cacheDomain.tilesets) {
|
||||
if(!this.shouldBulkClearTileset(tileset)) continue;
|
||||
|
||||
for (let z = this.cacheDomain.minZoom; z <= this.cacheDomain.maxZoom; z++) {
|
||||
let tileBounds = getXYZ(bbox, z)
|
||||
let tileBounds = getXYZ(bbox, z);
|
||||
for (let x = tileBounds.minX; x <= tileBounds.maxX; x++) {
|
||||
for (let y = tileBounds.minY; y <= tileBounds.maxY; y++) {
|
||||
for (const scale of this.cacheDomain.scales) {
|
||||
@ -137,6 +141,10 @@ class TileCache {
|
||||
this.cacheDomain.scales.includes(tileParams.scale) &&
|
||||
(this.shouldCacheFn == undefined || this.shouldCacheFn(tileParams));
|
||||
}
|
||||
|
||||
private shouldBulkClearTileset(tileset: string): boolean {
|
||||
return this.shouldCacheFn == undefined || this.shouldBulkClearTilesetFn(tileset);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user