Merge pull request #505 from tomalrussell/develop

Release latest changes
This commit is contained in:
Maciej Ziarkowski 2019-11-14 18:29:00 +00:00 committed by GitHub
commit 9f2711f0ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 1190 additions and 1044 deletions

13
app/.babelrc Normal file
View 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
View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -12,4 +12,4 @@ router.route('/me')
router.put('/password', userController.resetPassword);
export default router;
export default router;

View File

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

View File

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

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

View File

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

View File

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

View File

@ -22,4 +22,4 @@ export {
ValidationError,
validateUsername,
validatePassword
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,4 +11,8 @@
.edit-history-username {
font-size: 0.9em;
}
.edit-history-building-id {
font-size: 0.9em;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,4 +4,5 @@ export interface EditHistoryEntry {
revision_id: string;
forward_patch: object;
reverse_patch: object;
building_id?: number;
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -11,4 +11,4 @@ const OrdnanceSurveyUprnPage = () => (
</article>
);
export default OrdnanceSurveyUprnPage;
export default OrdnanceSurveyUprnPage;

View File

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

View File

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

View File

@ -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&rsquo;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&rsquo;re still at an early stage of development, and we&rsquo;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>
);

View File

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

View File

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

View File

@ -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" />
)
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

View File

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