From 305f2f167147d0b1f77445c027d35884734e20a8 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 22 Feb 2021 06:59:24 +0000 Subject: [PATCH] Refactor map app with hooks and separated config --- app/src/frontend/app.test.tsx | 2 +- app/src/frontend/app.tsx | 32 +- app/src/frontend/building/building-view.tsx | 141 ++----- app/src/frontend/building/categories.tsx | 115 +----- .../data-components/like-data-entry.tsx | 3 +- .../data-components/year-data-entry.tsx | 2 +- app/src/frontend/building/data-container.tsx | 19 +- .../frontend/building/data-containers/age.tsx | 2 +- .../data-containers/category-view-props.ts | 4 +- .../building/data-containers/construction.tsx | 2 +- .../building/data-containers/location.tsx | 2 +- .../building/data-containers/planning.tsx | 2 +- .../building/data-containers/size.tsx | 2 +- .../data-containers/sustainability.tsx | 2 +- .../building/data-containers/type.tsx | 2 +- .../frontend/building/data-containers/use.tsx | 2 +- .../edit-history/building-edit-summary.tsx | 16 +- .../edit-history/category-edit-summary.tsx | 12 +- app/src/frontend/building/multi-edit.tsx | 93 ++--- app/src/frontend/components/error-box.tsx | 24 +- app/src/frontend/config/categories-config.ts | 124 ++++++ .../frontend/config/category-maps-config.ts | 195 ++++++++++ app/src/frontend/config/category-ui-config.ts | 32 ++ .../data-fields-config.ts} | 185 ++++----- app/src/frontend/hooks/use-building-data.ts | 47 +++ .../frontend/hooks/use-building-like-data.ts | 43 ++ app/src/frontend/hooks/use-last-not-empty.ts | 7 + app/src/frontend/hooks/use-multi-edit-data.ts | 27 ++ app/src/frontend/hooks/use-previous.ts | 10 + app/src/frontend/hooks/use-query.ts | 6 + app/src/frontend/hooks/use-revision.tsx | 33 ++ .../frontend/hooks/use-user-verified-data.ts | 40 ++ app/src/frontend/map-app.tsx | 367 +++++++----------- app/src/frontend/map/legend.tsx | 198 ++-------- app/src/frontend/map/map.tsx | 107 ++--- app/src/frontend/models/building.ts | 25 +- app/src/frontend/nav/url-param-transform.ts | 16 + .../frontend/nav/use-url-building-param.ts | 6 + .../frontend/nav/use-url-category-param.ts | 14 + app/src/frontend/nav/use-url-mode-param.ts | 10 + app/src/frontend/nav/use-url-param.ts | 33 ++ app/src/frontendRoute.tsx | 1 + 42 files changed, 1093 insertions(+), 912 deletions(-) create mode 100644 app/src/frontend/config/categories-config.ts create mode 100644 app/src/frontend/config/category-maps-config.ts create mode 100644 app/src/frontend/config/category-ui-config.ts rename app/src/frontend/{data_fields.ts => config/data-fields-config.ts} (81%) create mode 100644 app/src/frontend/hooks/use-building-data.ts create mode 100644 app/src/frontend/hooks/use-building-like-data.ts create mode 100644 app/src/frontend/hooks/use-last-not-empty.ts create mode 100644 app/src/frontend/hooks/use-multi-edit-data.ts create mode 100644 app/src/frontend/hooks/use-previous.ts create mode 100644 app/src/frontend/hooks/use-query.ts create mode 100644 app/src/frontend/hooks/use-revision.tsx create mode 100644 app/src/frontend/hooks/use-user-verified-data.ts create mode 100644 app/src/frontend/nav/url-param-transform.ts create mode 100644 app/src/frontend/nav/use-url-building-param.ts create mode 100644 app/src/frontend/nav/use-url-category-param.ts create mode 100644 app/src/frontend/nav/use-url-mode-param.ts create mode 100644 app/src/frontend/nav/use-url-param.ts diff --git a/app/src/frontend/app.test.tsx b/app/src/frontend/app.test.tsx index 08fcee5c..87566f20 100644 --- a/app/src/frontend/app.test.tsx +++ b/app/src/frontend/app.test.tsx @@ -9,7 +9,7 @@ describe('', () => { const div = document.createElement('div'); ReactDOM.render( - + , div ); diff --git a/app/src/frontend/app.tsx b/app/src/frontend/app.tsx index d23ce6d8..b9fcfa4f 100644 --- a/app/src/frontend/app.tsx +++ b/app/src/frontend/app.tsx @@ -5,10 +5,10 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import './app.css'; import { AuthRoute, PrivateRoute } from './route'; -import { AuthContext, AuthProvider } from './auth-context'; +import { AuthProvider } from './auth-context'; import { Header } from './header'; -import MapApp from './map-app'; -import { Building } from './models/building'; +import { MapApp } from './map-app'; +import { Building, UserVerified } from './models/building'; import { User } from './models/user'; import AboutPage from './pages/about'; import ChangesPage from './pages/changes'; @@ -33,8 +33,8 @@ interface AppProps { user?: User; building?: Building; building_like?: boolean; - user_verified?: object; - revisionId: number; + user_verified?: UserVerified; + revisionId: string; } /** @@ -80,20 +80,14 @@ export const App: React.FC = props => { - ( - - {({user}) => - - } - - )} /> + + + diff --git a/app/src/frontend/building/building-view.tsx b/app/src/frontend/building/building-view.tsx index 0dcffb39..0bbcc888 100644 --- a/app/src/frontend/building/building-view.tsx +++ b/app/src/frontend/building/building-view.tsx @@ -1,30 +1,21 @@ import React from 'react'; +import { useAuth } from '../auth-context'; -import { Building } from '../models/building'; +import { categoriesConfig, Category } from '../config/categories-config'; +import { categoryUiConfig } from '../config/category-ui-config'; +import { Building, UserVerified } 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 DynamicsContainer from './data-containers/dynamics'; -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; + cat: Category; mode: 'view' | 'edit'; building?: Building; building_like?: boolean; - user?: any; - selectBuilding: (building: Building) => void; user_verified?: any; + onBuildingUpdate: (buildingId: number, updatedData: Building) => void; + onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void; + onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void; } /** @@ -33,100 +24,30 @@ interface BuildingViewProps { * @param props */ const BuildingView: React.FunctionComponent = (props) => { - switch (props.cat) { - case 'location': - return ; - case 'use': - return ; - case 'type': - return ; - case 'age': - return ; - case 'size': - return ; - case 'construction': - return ; - case 'team': - return ; - case 'sustainability': - return ; - case 'streetscape': - return ; - case 'community': - return ; - case 'planning': - return ; - case 'dynamics': - return ; - default: - return ; + const { user } = useAuth(); + const DataContainer = categoryUiConfig[props.cat]; + + const categoryConfig = categoriesConfig[props.cat]; + + if(categoryConfig == undefined) { + return ; } + + const { + name, + aboutUrl, + intro, + inactive = false + } = categoryConfig; + + return ; }; export default BuildingView; diff --git a/app/src/frontend/building/categories.tsx b/app/src/frontend/building/categories.tsx index a46ceedf..13900215 100644 --- a/app/src/frontend/building/categories.tsx +++ b/app/src/frontend/building/categories.tsx @@ -4,6 +4,7 @@ import { CategoryLink } from './category-link'; import { ListWrapper } from '../components/list-wrapper'; import './categories.css'; +import { categoriesOrder, categoriesConfig } from '../config/categories-config'; interface CategoriesProps { mode: 'view' | 'edit' | 'multi-edit'; @@ -12,102 +13,24 @@ interface CategoriesProps { const Categories: React.FC = (props) => ( - - - - - - - - - - - - + {categoriesOrder.map(category => { + const { + name, + slug, + aboutUrl, + inactive = false + } = categoriesConfig[category]; + + return + })} ); diff --git a/app/src/frontend/building/data-components/like-data-entry.tsx b/app/src/frontend/building/data-components/like-data-entry.tsx index e034f2b3..cdb24d24 100644 --- a/app/src/frontend/building/data-components/like-data-entry.tsx +++ b/app/src/frontend/building/data-components/like-data-entry.tsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import { NavLink } from 'react-router-dom'; import Tooltip from '../../components/tooltip'; +import { Category } from '../../config/categories-config'; interface LikeDataEntryProps { @@ -19,7 +20,7 @@ const LikeDataEntry: React.FunctionComponent = (props) => {
Like more diff --git a/app/src/frontend/building/data-components/year-data-entry.tsx b/app/src/frontend/building/data-components/year-data-entry.tsx index 54ad3f48..376b2bca 100644 --- a/app/src/frontend/building/data-components/year-data-entry.tsx +++ b/app/src/frontend/building/data-components/year-data-entry.tsx @@ -1,7 +1,7 @@ import React, { Component, Fragment } from 'react'; import Verification from './verification'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import { CopyProps } from '../data-containers/category-view-props'; import NumericDataEntry from './numeric-data-entry'; diff --git a/app/src/frontend/building/data-container.tsx b/app/src/frontend/building/data-container.tsx index 1462c615..052b1f56 100644 --- a/app/src/frontend/building/data-container.tsx +++ b/app/src/frontend/building/data-container.tsx @@ -6,7 +6,7 @@ import { apiPost } from '../apiHelpers'; import ErrorBox from '../components/error-box'; import InfoBox from '../components/info-box'; import { compareObjects } from '../helpers'; -import { Building } from '../models/building'; +import { Building, UserVerified } from '../models/building'; import { User } from '../models/user'; import ContainerHeader from './container-header'; @@ -26,7 +26,9 @@ interface DataContainerProps { building?: Building; building_like?: boolean; user_verified?: any; - selectBuilding: (building: Building) => void; + onBuildingUpdate: (buildingId: number, updatedData: Building) => void; + onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void; + onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void; } interface DataContainerState { @@ -38,6 +40,8 @@ interface DataContainerState { buildingEdits: Partial; } +export type DataContainerType = React.ComponentType; + /** * Shared functionality for view/edit forms * @@ -46,7 +50,7 @@ interface DataContainerState { * * @param WrappedComponent */ -const withCopyEdit = (WrappedComponent: React.ComponentType) => { +const withCopyEdit: (wc: React.ComponentType) => DataContainerType = (WrappedComponent: React.ComponentType) => { return class DataContainer extends React.Component { constructor(props) { super(props); @@ -174,8 +178,9 @@ const withCopyEdit = (WrappedComponent: React.ComponentType) if (data.error) { this.setState({error: data.error}); } else { - this.props.selectBuilding(data); - this.updateBuildingState('likes_total', data.likes_total); + // like endpoint returns whole building data so we can update both + this.props.onBuildingUpdate(this.props.building.building_id, data); + this.props.onBuildingLikeUpdate(this.props.building.building_id, like); } } catch(err) { this.setState({error: err}); @@ -195,7 +200,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType) if (data.error) { this.setState({error: data.error}); } else { - this.props.selectBuilding(data); + this.props.onBuildingUpdate(this.props.building.building_id, data); } } catch(err) { this.setState({error: err}); @@ -227,7 +232,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType) zIndex: 2000 }); } - this.props.selectBuilding(this.props.building); + this.props.onUserVerifiedUpdate(this.props.building.building_id, data); } } catch(err) { this.setState({error: err}); diff --git a/app/src/frontend/building/data-containers/age.tsx b/app/src/frontend/building/data-containers/age.tsx index 1a2f521b..2ac2f4a0 100644 --- a/app/src/frontend/building/data-containers/age.tsx +++ b/app/src/frontend/building/data-containers/age.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry'; import SelectDataEntry from '../data-components/select-data-entry'; diff --git a/app/src/frontend/building/data-containers/category-view-props.ts b/app/src/frontend/building/data-containers/category-view-props.ts index 38a4e88c..e277df4d 100644 --- a/app/src/frontend/building/data-containers/category-view-props.ts +++ b/app/src/frontend/building/data-containers/category-view-props.ts @@ -1,3 +1,5 @@ +import { Building } from '../../models/building'; + interface CopyProps { copying: boolean; toggleCopying: () => void; @@ -7,7 +9,7 @@ interface CopyProps { interface CategoryViewProps { intro: string; - building: any; // TODO: add Building type with all fields + building: Building; building_like: boolean; mode: 'view' | 'edit' | 'multi-edit'; edited: boolean; diff --git a/app/src/frontend/building/data-containers/construction.tsx b/app/src/frontend/building/data-containers/construction.tsx index 7eaa563d..09979a20 100644 --- a/app/src/frontend/building/data-containers/construction.tsx +++ b/app/src/frontend/building/data-containers/construction.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import SelectDataEntry from '../data-components/select-data-entry'; import withCopyEdit from '../data-container'; diff --git a/app/src/frontend/building/data-containers/location.tsx b/app/src/frontend/building/data-containers/location.tsx index b559a4cf..553cb6ce 100644 --- a/app/src/frontend/building/data-containers/location.tsx +++ b/app/src/frontend/building/data-containers/location.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import InfoBox from '../../components/info-box'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import DataEntry from '../data-components/data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry'; import UPRNsDataEntry from '../data-components/uprns-data-entry'; diff --git a/app/src/frontend/building/data-containers/planning.tsx b/app/src/frontend/building/data-containers/planning.tsx index 58e37356..a8f78494 100644 --- a/app/src/frontend/building/data-containers/planning.tsx +++ b/app/src/frontend/building/data-containers/planning.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import InfoBox from '../../components/info-box'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import CheckboxDataEntry from '../data-components/checkbox-data-entry'; import DataEntry from '../data-components/data-entry'; import { DataEntryGroup } from '../data-components/data-entry-group'; diff --git a/app/src/frontend/building/data-containers/size.tsx b/app/src/frontend/building/data-containers/size.tsx index 6eed1292..72e8e306 100644 --- a/app/src/frontend/building/data-containers/size.tsx +++ b/app/src/frontend/building/data-containers/size.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import { DataEntryGroup } from '../data-components/data-entry-group'; import NumericDataEntry from '../data-components/numeric-data-entry'; import SelectDataEntry from '../data-components/select-data-entry'; diff --git a/app/src/frontend/building/data-containers/sustainability.tsx b/app/src/frontend/building/data-containers/sustainability.tsx index b3a2d5ee..a23cc1f0 100644 --- a/app/src/frontend/building/data-containers/sustainability.tsx +++ b/app/src/frontend/building/data-containers/sustainability.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import NumericDataEntry from '../data-components/numeric-data-entry'; import SelectDataEntry from '../data-components/select-data-entry'; import Verification from '../data-components/verification'; diff --git a/app/src/frontend/building/data-containers/type.tsx b/app/src/frontend/building/data-containers/type.tsx index b9a83b6f..19a1ba62 100644 --- a/app/src/frontend/building/data-containers/type.tsx +++ b/app/src/frontend/building/data-containers/type.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import DataEntry from '../data-components/data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry'; import SelectDataEntry from '../data-components/select-data-entry'; diff --git a/app/src/frontend/building/data-containers/use.tsx b/app/src/frontend/building/data-containers/use.tsx index c69d41e8..545b569a 100644 --- a/app/src/frontend/building/data-containers/use.tsx +++ b/app/src/frontend/building/data-containers/use.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import InfoBox from '../../components/info-box'; -import { dataFields } from '../../data_fields'; +import { dataFields } from '../../config/data-fields-config'; import DataEntry from '../data-components/data-entry'; import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry'; import withCopyEdit from '../data-container'; diff --git a/app/src/frontend/building/edit-history/building-edit-summary.tsx b/app/src/frontend/building/edit-history/building-edit-summary.tsx index e55399f9..1c8f336e 100644 --- a/app/src/frontend/building/edit-history/building-edit-summary.tsx +++ b/app/src/frontend/building/edit-history/building-edit-summary.tsx @@ -3,7 +3,8 @@ import { Link } from 'react-router-dom'; import './building-edit-summary.css'; -import { Category, DataFieldDefinition, dataFields } from '../../data_fields'; +import { Category } from '../../config/categories-config'; +import { DataFieldDefinition, dataFields } from '../../config/data-fields-config'; import { arrayToDictionary, parseDate } from '../../helpers'; import { EditHistoryEntry } from '../../models/edit-history-entry'; @@ -30,11 +31,15 @@ function enrichHistoryEntries(forwardPatch: object, reversePatch: object) { return Object .entries(forwardPatch) .map(([key, value]) => { - const info = dataFields[key] || {} as DataFieldDefinition; + const { + title = `Unknown field (${key})`, + category = undefined + } = dataFields[key] as DataFieldDefinition ?? {}; + return { - title: info.title || `Unknown field (${key})`, - category: info.category || Category.Unknown, - value: value, + title, + category, + value, oldValue: reversePatch && reversePatch[key] }; }); @@ -65,6 +70,7 @@ const BuildingEditSummary: React.FunctionComponent = ( { Object.entries(entriesByCategory).map(([category, fields]) => = 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'; +const CategoryEditSummary: React.FunctionComponent = props => { + const { + name: categoryName = 'Unknown category', + slug: categorySlug = 'categories' + } = categoriesConfig[props.category] ?? {}; return (
diff --git a/app/src/frontend/building/multi-edit.tsx b/app/src/frontend/building/multi-edit.tsx index 9cc2dc94..dd130233 100644 --- a/app/src/frontend/building/multi-edit.tsx +++ b/app/src/frontend/building/multi-edit.tsx @@ -1,89 +1,62 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { parseJsonOrDefault } from '../../helpers'; +import { useMultiEditData } from '../hooks/use-multi-edit-data'; import ErrorBox from '../components/error-box'; import InfoBox from '../components/info-box'; -import { dataFields } from '../data_fields'; -import { User } from '../models/user'; +import { dataFields } from '../config/data-fields-config'; import DataEntry from './data-components/data-entry'; -import Sidebar from './sidebar'; -import Categories from './categories'; +import { Category } from '../config/categories-config'; interface MultiEditProps { - user?: User; category: string; - dataString: string; } const MultiEdit: React.FC = (props) => { - if (props.category === 'like') { - // special case for likes - return ( - - -
-
-

Like me!

-
-
- + const [data, error] = useMultiEditData(); - Back to view - Back to edit - -
-
- ); - } - - let data = parseJsonOrDefault(props.dataString); - - let error: string; - if(data == null) { - error = 'Invalid parameters supplied'; - data = {}; - } else if(Object.values(data).some(x => x == undefined)) { - error = 'Cannot copy empty values'; - data = {}; - } + const isLike = props.category === Category.Community; return ( - - -
-
-

Copy {props.category} data

-
-
-
+
+
+

{ + isLike ? + <>Like Me! : + <>Copy {props.category} data + }

+
+
+ { error ? : - + } { - Object.keys(data).map((key => { - const info = dataFields[key] || {}; - return ( + !isLike && data && + Object.keys(data).map((key => ( - ); - })) + /> + ))) } - -
- Back to view - Back to edit -
-
-
- + +
+ Back to view + Back to edit +
+
+
); }; diff --git a/app/src/frontend/components/error-box.tsx b/app/src/frontend/components/error-box.tsx index 09e10934..f70fb7b1 100644 --- a/app/src/frontend/components/error-box.tsx +++ b/app/src/frontend/components/error-box.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React from 'react'; interface ErrorBoxProps { msg: string; @@ -8,22 +8,16 @@ const ErrorBox: React.FC = (props) => { if (props.msg) { console.error(props.msg); } - return ( - + + return props.msg ? +
{ - (props.msg)? - ( -
- { - typeof props.msg === 'string' ? - props.msg - : 'Unexpected error' - } -
- ) : null + typeof props.msg === 'string' ? + props.msg + : 'Unexpected error' } - - ); +
: + null; }; export default ErrorBox; diff --git a/app/src/frontend/config/categories-config.ts b/app/src/frontend/config/categories-config.ts new file mode 100644 index 00000000..e176761c --- /dev/null +++ b/app/src/frontend/config/categories-config.ts @@ -0,0 +1,124 @@ +/** + * An enumeration of all categories in the system. + * The string value is also the category URL slug. + */ +export enum Category { + Location = 'location', + LandUse = 'use', + Type = 'type', + Age = 'age', + SizeShape = 'size', + Construction = 'construction', + Streetscape = 'streetscape', + Team = 'team', + Planning = 'planning', + Sustainability = 'sustainability', + Dynamics = 'dynamics', + Community = 'community', +} + +/** + * This is the sole configuration variable that defines the order of the categories + * in the category grid. The order in the enum defition or the other configs does + * not affect the order of the grid. + */ +export const categoriesOrder: Category[] = [ + Category.Location, + Category.LandUse, + Category.Type, + Category.Age, + Category.SizeShape, + Category.Construction, + Category.Streetscape, + Category.Team, + Category.Planning, + Category.Sustainability, + Category.Dynamics, + Category.Community, +]; + +interface CategoryDefinition { + inactive?: boolean; + slug: string; + name: string; + aboutUrl: string; + intro: string; +} + +export const categoriesConfig: {[key in Category]: CategoryDefinition} = { + [Category.Age]: { + slug: 'age', + name: 'Age', + aboutUrl: 'https://pages.colouring.london/age', + intro: 'Building age data can support energy analysis and help predict long-term change.', + }, + [Category.SizeShape]: { + slug: 'size', + name: 'Size & Shape', + aboutUrl: 'https://pages.colouring.london/shapeandsize', + intro: 'How big are buildings?', + }, + [Category.Team]: { + inactive: true, + slug: 'team', + name: 'Team', + aboutUrl: 'https://pages.colouring.london/team', + intro: 'Who built the buildings? Coming soon…', + }, + [Category.Construction]: { + slug: 'construction', + name: 'Construction', + aboutUrl: 'https://pages.colouring.london/construction', + intro: 'How are buildings built?', + }, + [Category.Location]: { + slug: 'location', + name: 'Location', + aboutUrl: 'https://pages.colouring.london/location', + intro: 'Where are the buildings? Address, location and cross-references.', + }, + [Category.Community]: { + slug: 'community', + name: 'Community', + aboutUrl: 'https://pages.colouring.london/community', + intro: 'How does this building work for the local community?', + }, + [Category.Planning]: { + slug: 'planning', + name: 'Planning', + aboutUrl: 'https://pages.colouring.london/planning', + intro: 'Planning controls relating to protection and reuse.', + }, + [Category.Sustainability]: { + slug: 'sustainability', + name: 'Sustainability', + aboutUrl: 'https://pages.colouring.london/sustainability', + intro: 'Are buildings energy efficient?', + }, + [Category.Type]: { + slug: 'type', + name: 'Type', + aboutUrl: 'https://pages.colouring.london/buildingtypology', + intro: 'How were buildings previously used?', + }, + [Category.LandUse]: { + slug: 'use', + name: 'Current Use', + aboutUrl: 'https://pages.colouring.london/use', + intro: 'How are buildings used, and how does use change over time?', + }, + [Category.Streetscape]: { + inactive: true, + slug: 'streetscape', + name: 'Streetscape', + aboutUrl: 'https://pages.colouring.london/greenery', + intro: "What's the building's context? Coming soon…", + }, + [Category.Dynamics]: { + inactive: true, + slug: 'dynamics', + name: 'Dynamics', + aboutUrl: 'https://pages.colouring.london/dynamics', + intro: 'How has the site of this building changed over time?' + }, +}; diff --git a/app/src/frontend/config/category-maps-config.ts b/app/src/frontend/config/category-maps-config.ts new file mode 100644 index 00000000..637346b3 --- /dev/null +++ b/app/src/frontend/config/category-maps-config.ts @@ -0,0 +1,195 @@ +import { Category } from './categories-config'; + +export type LegendElement = { + color: string; + border?: string; + text: string; +} | { + subtitle: string; +}; + +export interface LegendConfig { + title: string; + description?: string; + disclaimer?: string; + elements: LegendElement[]; +} + +export interface CategoryMapDefinition { + mapStyle: string; + legend: LegendConfig; +} + +export const defaultMapCategory = Category.Age; + +export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = { + [Category.Age]: { + mapStyle: 'date_year', + legend: { + title: 'Age', + elements: [ + { color: '#fff9b8', text: '>2020' }, + { color: '#fae269', text: '2000-2019' }, + { color: '#fbaf27', text: '1980-1999' }, + { color: '#e6711d', text: '1960-1979' }, + { color: '#cc1212', text: '1940-1959' }, + { color: '#8f0303', text: '1920-1939' }, + { color: '#8f5385', text: '1900-1919' }, + { color: '#c3e1eb', text: '1880-1899' }, + { color: '#6a9dba', text: '1860-1879' }, + { color: '#3b74a3', text: '1840-1859' }, + { color: '#95ded8', text: '1820-1839' }, + { color: '#68aba5', text: '1800-1819' }, + { color: '#acc98f', text: '1750-1799' }, + { color: '#6d8a51', text: '1700-1749' }, + { color: '#d0c291', text: '<1700' }, + ] + }, + }, + [Category.SizeShape]: { + mapStyle: 'size_height', + legend: { + title: 'Height to apex', + elements: [ + { color: '#f7f4f9', text: '0-5.55'}, + { color: '#e7e1ef', text: '5.55-7.73'}, + { color: '#d4b9da', text: '7.73-11.38'}, + { color: '#c994c7', text: '11.38-18.45'}, + { color: '#df65b0', text: '18.45-35.05'}, + { color: '#e7298a', text: '35.05-89.30'}, + { color: '#ce1256', text: '89.30-152'}, + { color: '#980043', text: '≥152'} + ] + }, + }, + [Category.Team]: { + mapStyle: undefined, + legend: { + title: 'Team', + elements: [] + }, + }, + [Category.Construction]: { + mapStyle: 'construction_core_material', + legend: { + title: 'Construction', + elements: [ + { color: "#96613b", text: "Wood" }, + { color: "#ffffe3", text: "Stone" }, + { color: "#f5d96b", text: "Brick" }, + { color: "#beffe8", text: "Steel" }, + { color: "#fca89d", text: "Reinforced Concrete" }, + { color: "#5c8970", text: "Other Metal" }, + { color: "#b5a859", text: "Other Natural Material" }, + { color: "#c48a85", text: "Other Man-Made Material" } + ] + }, + }, + [Category.Location]: { + mapStyle: 'location', + legend: { + title: 'Location', + description: '% data collected', + elements: [ + { color: '#084081', text: '≥80%' }, + { color: '#0868ac', text: '60–80%' }, + { color: '#43a2ca', text: '40–60%' }, + { color: '#7bccc4', text: '20–40%' }, + { color: '#bae4bc', text: '<20%' } + ] + }, + }, + [Category.Community]: { + mapStyle: 'likes', + legend: { + title: 'Like Me', + elements: [ + { color: '#bd0026', text: '👍👍👍👍 100+' }, + { color: '#e31a1c', text: '👍👍👍 50–99' }, + { color: '#fc4e2a', text: '👍👍 20–49' }, + { color: '#fd8d3c', text: '👍👍 10–19' }, + { color: '#feb24c', text: '👍 3–9' }, + { color: '#fed976', text: '👍 2' }, + { color: '#ffe8a9', text: '👍 1'} + ] + } + }, + [Category.Planning]: { + mapStyle: 'planning_combined', + legend: { + title: 'Statutory protections', + disclaimer: 'All data relating to designated buildings should be checked on the National Heritage List for England or local authority websites where used for planning or development purposes', + elements: [ + { color: '#95beba', text: 'In conservation area'}, + { color: '#c72e08', text: 'Grade I listed'}, + { color: '#e75b42', text: 'Grade II* listed'}, + { color: '#ffbea1', text: 'Grade II listed'}, + { color: '#858ed4', text: 'Locally listed'}, + ] + }, + }, + [Category.Sustainability]: { + mapStyle: 'sust_Dec', + legend: { + title: 'Sustainability', + description: 'DEC Rating', + elements: [ + { color: "#007f3d", text: 'A' }, + { color: "#2c9f29", text: 'B' }, + { color: "#9dcb3c", text: 'C' }, + { color: "#fff200", text: 'D' }, + { color: "#f7af1d", text: 'E' }, + { color: "#ed6823", text: 'F' }, + { color: "#e31d23", text: 'G' }, + ] + }, + }, + [Category.Type]: { + mapStyle: 'building_attachment_form', + legend: { + title: 'Type', + elements: [ + { color: "#f2a2b9", text: "Detached" }, + { color: "#ab8fb0", text: "Semi-Detached" }, + { color: "#3891d1", text: "End-Terrace" }, + { color: "#226291", text: "Mid-Terrace" } + ] + }, + }, + [Category.LandUse]: { + mapStyle: 'landuse', + legend: { + title: 'Land Use', + elements: [ + { color: '#e5050d', text: 'Mixed Use' }, + { subtitle: 'Single use:'}, + { color: '#4a54a6', text: 'Residential' }, + { color: '#ff8c00', text: 'Retail' }, + { color: '#f5f58f', text: 'Industry & Business' }, + { color: '#73ccd1', text: 'Community Services' }, + { color: '#ffbfbf', text: 'Recreation & Leisure' }, + { color: '#b3de69', text: 'Transport' }, + { color: '#cccccc', text: 'Utilities & Infrastructure' }, + { color: '#898944', text: 'Defence' }, + { color: '#fa667d', text: 'Agriculture' }, + { color: '#53f5dd', text: 'Minerals' }, + { color: '#ffffff', text: 'Vacant & Derelict' } + ] + }, + }, + [Category.Streetscape]: { + mapStyle: undefined, + legend: { + title: 'Streetscape', + elements: [] + }, + }, + [Category.Dynamics]: { + mapStyle: undefined, + legend: { + title: 'Dynamics', + elements: [] + }, + } + +}; diff --git a/app/src/frontend/config/category-ui-config.ts b/app/src/frontend/config/category-ui-config.ts new file mode 100644 index 00000000..33f8e9e3 --- /dev/null +++ b/app/src/frontend/config/category-ui-config.ts @@ -0,0 +1,32 @@ +import { Category } from './categories-config'; + +import AgeContainer from '../building/data-containers/age'; +import CommunityContainer from '../building/data-containers/community'; +import ConstructionContainer from '../building/data-containers/construction'; +import DynamicsContainer from '../building/data-containers/dynamics'; +import LocationContainer from '../building/data-containers/location'; +import PlanningContainer from '../building/data-containers/planning'; +import SizeContainer from '../building/data-containers/size'; +import StreetscapeContainer from '../building/data-containers/streetscape'; +import SustainabilityContainer from '../building/data-containers/sustainability'; +import TeamContainer from '../building/data-containers/team'; +import TypeContainer from '../building/data-containers/type'; +import UseContainer from '../building/data-containers/use'; + +import { DataContainerType } from '../building/data-container'; + +export const categoryUiConfig: {[key in Category]: DataContainerType} = { + [Category.Location]: LocationContainer, + [Category.LandUse]: UseContainer, + [Category.Type]: TypeContainer, + [Category.Age]: AgeContainer, + [Category.SizeShape]: SizeContainer, + [Category.Construction]: ConstructionContainer, + [Category.Streetscape]: StreetscapeContainer, + [Category.Team]: TeamContainer, + [Category.Planning]: PlanningContainer, + [Category.Sustainability]: SustainabilityContainer, + [Category.Dynamics]: DynamicsContainer, + [Category.Community]: CommunityContainer, +}; + diff --git a/app/src/frontend/data_fields.ts b/app/src/frontend/config/data-fields-config.ts similarity index 81% rename from app/src/frontend/data_fields.ts rename to app/src/frontend/config/data-fields-config.ts index 446fda51..2d216022 100644 --- a/app/src/frontend/data_fields.ts +++ b/app/src/frontend/config/data-fields-config.ts @@ -1,85 +1,4 @@ -export enum Category { - Location = 'Location', - LandUse = 'LandUse', - Type = 'Type', - Age = 'Age', - SizeShape = 'SizeShape', - Construction = 'Construction', - Streetscape = 'Streetscape', - Team = 'Team', - Sustainability = 'Sustainability', - Community = 'Community', - Planning = 'Planning', - 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, - Category.Type, - Category.Age, - Category.SizeShape, - Category.Construction, - Category.Streetscape, - Category.Team, - Category.Sustainability, - Category.Community, - Category.Planning, - Category.Like, -]; +import { Category } from './categories-config'; /** * This interface is used only in code which uses dataFields, not in the dataFields definition itself @@ -91,42 +10,51 @@ export interface DataFieldDefinition { category: Category; title: string; tooltip?: string; + properties?: { [key: string]: DataFieldDefinition}; + example: any; // the example field is used to automatically determine the type of the properties in the Building interface } -export const dataFields = { +export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ location_name: { category: Category.Location, title: "Building Name", tooltip: "May not be needed for many buildings.", + example: "The Cruciform", }, location_number: { category: Category.Location, title: "Building number", + example: 12, }, location_street: { category: Category.Location, title: "Street", + example: "Gower Street", //tooltip: , }, location_line_two: { category: Category.Location, title: "Address line 2", + example: "Flat 21", //tooltip: , }, location_town: { category: Category.Location, title: "Town", + example: "London", //tooltip: , }, location_postcode: { category: Category.Location, title: "Postcode", + example: "W1W 6TR", //tooltip: , }, ref_toid: { category: Category.Location, title: "TOID", tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)", + example: "", }, /** @@ -136,43 +64,51 @@ export const dataFields = { uprns: { category: Category.Location, title: "UPRNs", - tooltip: "Unique Property Reference Numbers (to be filled automatically)" + tooltip: "Unique Property Reference Numbers (to be filled automatically)", + example: [{uprn: "", parent_uprn: "" }, {uprn: "", parent_uprn: "" }], }, ref_osm_id: { category: Category.Location, title: "OSM ID", tooltip: "OpenStreetMap feature ID", + example: "", }, location_latitude: { category: Category.Location, title: "Latitude", + example: 12.4564, }, location_longitude: { category: Category.Location, title: "Longitude", + example: 0.12124, }, current_landuse_group: { category: Category.LandUse, title: "Current Land Use (Group)", - tooltip: "Land use Groups as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)" + tooltip: "Land use Groups as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)", + example: ["", ""], }, current_landuse_order: { category: Category.LandUse, title: "Current Land Use (Order)", - tooltip: "Land use Order as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)" + tooltip: "Land use Order as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)", + example: "", }, building_attachment_form: { category: Category.Type, title: "Building configuration (attachment)?", tooltip: "We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)", + example: "", }, date_change_building_use: { category: Category.Type, title:"When did use change?", tooltip: "This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened", + example: 1920, }, /** * original_building_use does not exist in database yet. @@ -182,101 +118,121 @@ export const dataFields = { category: Category.Type, title: "Original building use", tooltip: "What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse", + example: "", }, date_year: { category: Category.Age, - title: "Year built (best estimate)" + title: "Year built (best estimate)", + example: 1924, }, date_lower : { category: Category.Age, title: "Earliest possible start date", - tooltip: "This should be the earliest year in which building could have started." + tooltip: "This should be the earliest year in which building could have started.", + example: 1900, }, date_upper: { category: Category.Age, title: "Latest possible start year", - tooltip: "This should be the latest year in which building could have started." + tooltip: "This should be the latest year in which building could have started.", + example: 2000, }, facade_year: { category: Category.Age, title: "Facade year", - tooltip: "Best estimate" + tooltip: "Best estimate", + example: 1900, }, date_source: { category: Category.Age, title: "Source of information", - tooltip: "Source for the main start date" + tooltip: "Source for the main start date", + example: "", }, date_source_detail: { category: Category.Age, title: "Source details", - tooltip: "References for date source (max 500 characters)" + tooltip: "References for date source (max 500 characters)", + example: "", }, date_link: { category: Category.Age, title: "Text and Image Links", tooltip: "URL for age and date reference", + example: ["", "", ""], }, size_storeys_core: { category: Category.SizeShape, title: "Core storeys", tooltip: "How many storeys between the pavement and start of roof?", + example: 10, }, size_storeys_attic: { category: Category.SizeShape, title: "Attic storeys", tooltip: "How many storeys above start of roof?", + example: 1, }, size_storeys_basement: { category: Category.SizeShape, title: "Basement storeys", tooltip: "How many storeys below pavement level?", + example: 1, }, size_height_apex: { category: Category.SizeShape, title: "Height to apex (m)", + example: 100.5, //tooltip: , }, size_height_eaves: { category: Category.SizeShape, title: "Height to eaves (m)", + example: 20.33, //tooltip: , }, size_floor_area_ground: { category: Category.SizeShape, title: "Ground floor area (m²)", + example: 1245.6, //tooltip: , }, size_floor_area_total: { category: Category.SizeShape, title: "Total floor area (m²)", + example: 2001.7, //tooltip: , }, size_width_frontage: { category: Category.SizeShape, title: "Frontage Width (m)", + example: 12.2, //tooltip: , }, size_plot_area_total: { category: Category.SizeShape, title: "Total area of plot (m²)", + example: 123.02, //tooltip: , }, size_far_ratio: { category: Category.SizeShape, title: "FAR ratio (percentage of plot covered by building)", + example: 0.1, //tooltip: , }, size_configuration: { category: Category.SizeShape, title: "Configuration (semi/detached, end/terrace)", + example: "", //tooltip: , }, size_roof_shape: { category: Category.SizeShape, title: "Roof shape", + example: "", //tooltip: , }, @@ -284,155 +240,172 @@ export const dataFields = { category: Category.Construction, title: "Core Material", tooltip:"The main structural material", + example: "", }, construction_secondary_materials: { category: Category.Construction, title: "Secondary Construction Material/s", tooltip:"Other construction materials", + example: "", }, construction_roof_covering: { category: Category.Construction, title: "Main Roof Covering", tooltip:'Main roof covering material', + example: "", }, sust_breeam_rating: { category: Category.Sustainability, title: "BREEAM Rating", tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings", + example: "", }, sust_dec: { category: Category.Sustainability, title: "DEC Rating", tooltip: "(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use", + example: "G", }, sust_aggregate_estimate_epc: { category: Category.Sustainability, title: "EPC Rating", tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented", + example: "", }, sust_retrofit_date: { category: Category.Sustainability, title: "Last significant retrofit", tooltip: "Date of last major building refurbishment", + example: 1920, }, sust_life_expectancy: { category: Category.Sustainability, title: "Expected lifespan for typology", + example: 123, //tooltip: , }, planning_portal_link: { category: Category.Planning, title: "Planning portal link", + example: "", //tooltip: , }, planning_in_conservation_area: { category: Category.Planning, title: "In a conservation area?", + example: true, //tooltip: , }, planning_conservation_area_name: { category: Category.Planning, title: "Conservation area name", + example: "", //tooltip: , }, planning_in_list: { category: Category.Planning, title: "Is listed on the National Heritage List for England?", + example: true, //tooltip: , }, planning_list_id: { category: Category.Planning, title: "National Heritage List for England list id", + example: "121436", //tooltip: , }, planning_list_cat: { category: Category.Planning, title: "National Heritage List for England list type", + example: "", //tooltip: , }, planning_list_grade: { category: Category.Planning, title: "Listing grade", + example: "II", //tooltip: , }, planning_heritage_at_risk_id: { category: Category.Planning, title: "Heritage at risk list id", + example: "", //tooltip: , }, planning_world_list_id: { category: Category.Planning, title: "World heritage list id", + example: "", //tooltip: , }, planning_in_glher: { category: Category.Planning, title: "In the Greater London Historic Environment Record?", + example: true, //tooltip: , }, planning_glher_url: { category: Category.Planning, title: "Greater London Historic Environment Record link", + example: "", //tooltip: , }, planning_in_apa: { category: Category.Planning, title: "In an Architectural Priority Area?", + example: true, //tooltip: , }, planning_apa_name: { category: Category.Planning, title: "Architectural Priority Area name", + example: "", //tooltip: , }, planning_apa_tier: { category: Category.Planning, title: "Architectural Priority Area tier", + example: "2", //tooltip: , }, planning_in_local_list: { category: Category.Planning, title: "Is locally listed?", + example: true, //tooltip: , }, planning_local_list_url: { category: Category.Planning, title: "Local list link", + example: "", //tooltip: , }, planning_in_historic_area_assessment: { category: Category.Planning, title: "Within a historic area assessment?", + example: true, //tooltip: , }, planning_historic_area_assessment_url: { category: Category.Planning, title: "Historic area assessment link", + example: "", //tooltip: , }, planning_demolition_proposed: { category: Category.Planning, title: "Is the building proposed for demolition?", - //tooltip: , - }, - planning_demolition_complete: { - category: Category.Planning, - title: "Has the building been demolished?", - //tooltip: , - }, - planning_demolition_history: { - category: Category.Planning, - title: "Dates of construction and demolition of previous buildings on site", + example: true, //tooltip: , }, likes_total: { - category: Category.Like, - title: "Total number of likes" - } - + category: Category.Community, + title: "Total number of likes", + example: 100, + }, }; diff --git a/app/src/frontend/hooks/use-building-data.ts b/app/src/frontend/hooks/use-building-data.ts new file mode 100644 index 00000000..3c8ff7a9 --- /dev/null +++ b/app/src/frontend/hooks/use-building-data.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Building } from '../models/building'; +import { apiGet } from '../apiHelpers'; + +export function useBuildingData(buildingId: number, preloadedData: Building): [Building, (updatedBuilding: Building) => void, () => void] { + const [buildingData, setBuildingData] = useState(preloadedData); + const [isOld, setIsOld] = useState(preloadedData == undefined); + + const fetchData = useCallback(async () => { + if(buildingId == undefined) { + setBuildingData(undefined); + setIsOld(false); + return; + } + try { + const [building, buildingUprns] = await Promise.all([ + apiGet(`/api/buildings/${buildingId}.json`), + apiGet(`/api/buildings/${buildingId}/uprns.json`) + ]); + + building.uprns = buildingUprns.uprns; + + setBuildingData(building); + } catch(error) { + console.error(error); + // TODO: add UI for API errors + } + setIsOld(false); + }, [buildingId]); + + useEffect(() => { + return () => { + setIsOld(true); + }; + }, [buildingId]); + + useEffect(() => { + if(isOld) { + fetchData(); + } + }, [isOld]); + + const reloadData = useCallback(() => setIsOld(true), []); + + return [buildingData, setBuildingData, reloadData]; +} diff --git a/app/src/frontend/hooks/use-building-like-data.ts b/app/src/frontend/hooks/use-building-like-data.ts new file mode 100644 index 00000000..133b00a7 --- /dev/null +++ b/app/src/frontend/hooks/use-building-like-data.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { apiGet } from '../apiHelpers'; + +export function useBuildingLikeData(buildingId: number, preloadedData: boolean): [boolean, (updatedBuildingLike: boolean) => void, () => void] { + const [buildingLikeData, setBuildingLikeData] = useState(preloadedData); + // const [fetchedId, setFetchedId] = useState(preloadedData == undefined ? undefined : buildingId); + const [isOld, setIsOld] = useState(preloadedData == undefined); + + const fetchData = useCallback(async () => { + if(buildingId == undefined) { + setBuildingLikeData(undefined); + setIsOld(false); + return; + } + try { + const { like } = await apiGet(`/api/buildings/${buildingId}/like.json`); + + setBuildingLikeData(like); + } catch(error) { + console.error(error); + // TODO: add UI for API errors + } + setIsOld(false); + }, [buildingId]); + + useEffect(() => { + return () => { + setIsOld(true); + setBuildingLikeData(undefined); + }; + }, [buildingId]); + + useEffect(() => { + if(isOld) { + fetchData(); + } + }, [isOld]); + + const reloadData = useCallback(() => setIsOld(true), []); + + return [buildingLikeData, setBuildingLikeData, reloadData]; +} diff --git a/app/src/frontend/hooks/use-last-not-empty.ts b/app/src/frontend/hooks/use-last-not-empty.ts new file mode 100644 index 00000000..de577f88 --- /dev/null +++ b/app/src/frontend/hooks/use-last-not-empty.ts @@ -0,0 +1,7 @@ +import { usePrevious } from './use-previous'; + +export function useLastNotEmpty(value: T): T { + const previousValue = usePrevious(value); + + return value ?? previousValue; +} diff --git a/app/src/frontend/hooks/use-multi-edit-data.ts b/app/src/frontend/hooks/use-multi-edit-data.ts new file mode 100644 index 00000000..fa866032 --- /dev/null +++ b/app/src/frontend/hooks/use-multi-edit-data.ts @@ -0,0 +1,27 @@ +import { useQuery } from './use-query'; +import { parseJsonOrDefault } from '../../helpers'; + +export function useMultiEditData(): [object, string] { + const query = useQuery(); + + let data: object, error: string; + + const dataString = query.data; + if(dataString == undefined) { + return [undefined, undefined]; + } + if(Array.isArray(dataString)) { + return [undefined, 'Invalid parameters supplied']; + } + + data = parseJsonOrDefault(dataString); + + if(data == undefined) { + error = 'Invalid parameters supplied'; + } else if(Object.values(data).some(x => x == undefined)) { + error = 'Cannot copy empty values'; + data = undefined; + } + + return [data, error]; +} diff --git a/app/src/frontend/hooks/use-previous.ts b/app/src/frontend/hooks/use-previous.ts new file mode 100644 index 00000000..4f32cd66 --- /dev/null +++ b/app/src/frontend/hooks/use-previous.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef } from 'react'; + +export function usePrevious(value: T) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} \ No newline at end of file diff --git a/app/src/frontend/hooks/use-query.ts b/app/src/frontend/hooks/use-query.ts new file mode 100644 index 00000000..a5a1db8a --- /dev/null +++ b/app/src/frontend/hooks/use-query.ts @@ -0,0 +1,6 @@ +import { useLocation } from 'react-router'; +import { parse as parseQuery, ParsedQuery } from 'query-string'; + +export function useQuery(): ParsedQuery { + return parseQuery(useLocation().search); +} diff --git a/app/src/frontend/hooks/use-revision.tsx b/app/src/frontend/hooks/use-revision.tsx new file mode 100644 index 00000000..2a1df5c0 --- /dev/null +++ b/app/src/frontend/hooks/use-revision.tsx @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { apiGet } from '../apiHelpers'; + +export function useRevisionId(initialRevisionId: string): [string, (newId: string) => void, () => void] { + const [revisionId, setRevisionId] = useState(initialRevisionId ?? '0'); + const [isOld, setIsOld] = useState(initialRevisionId == undefined); + + const updateRevisionId = useCallback( + (newId: string) => newId != undefined && +newId > +revisionId && setRevisionId(newId), + [revisionId] + ); + + useEffect(() => { + async function fetchLatestRevision() { + try { + const { revision_id: latestRevisionId } = await apiGet(`/api/buildings/revision`); + updateRevisionId(latestRevisionId); + } catch(err) { + console.error(err); + } + setIsOld(false); + } + + if(isOld) { + fetchLatestRevision(); + } + }, [isOld]); + + const reloadRevisionId = useCallback(() => setIsOld(true), []); + + return [revisionId, updateRevisionId, reloadRevisionId]; +} diff --git a/app/src/frontend/hooks/use-user-verified-data.ts b/app/src/frontend/hooks/use-user-verified-data.ts new file mode 100644 index 00000000..51295b02 --- /dev/null +++ b/app/src/frontend/hooks/use-user-verified-data.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { UserVerified } from '../models/building'; +import { apiGet } from '../apiHelpers'; + +export function useUserVerifiedData(buildingId: number, preloadedData: UserVerified): [UserVerified, (updatedBuilding: UserVerified) => void, () => void] { + const [userVerifyData, setUserVerifyData] = useState(preloadedData); + const [isOld, setIsOld] = useState(preloadedData == undefined); + + const fetchData = useCallback(async () => { + if(buildingId == undefined) { + setUserVerifyData(undefined); + setIsOld(false); + return; + } + try { + const userVerify = await apiGet(`/api/buildings/${buildingId}/verify.json`); + + setUserVerifyData(userVerify); + } catch(error) { + console.error(error); + // TODO: add UI for API errors + } + setIsOld(false); + }, [buildingId]); + + useEffect(() => { + return () => { + setIsOld(true); + } + }, [buildingId]); + + useEffect(() => { + if(isOld) { + fetchData(); + } + }, [isOld]) + + return [userVerifyData, setUserVerifyData, () => setIsOld(true)]; +} diff --git a/app/src/frontend/map-app.tsx b/app/src/frontend/map-app.tsx index 7c7df896..24d704f4 100644 --- a/app/src/frontend/map-app.tsx +++ b/app/src/frontend/map-app.tsx @@ -1,267 +1,170 @@ -import { parse as parseQuery } from 'query-string'; -import React, { Fragment } from 'react'; -import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; -import { parseJsonOrDefault } from '../helpers'; -import { strictParseInt } from '../parse'; - -import { apiGet, apiPost } from './apiHelpers'; +import { useRevisionId } from './hooks/use-revision'; +import { useBuildingData } from './hooks/use-building-data'; +import { useBuildingLikeData } from './hooks/use-building-like-data'; +import { useUserVerifiedData } from './hooks/use-user-verified-data'; +import { useUrlBuildingParam } from './nav/use-url-building-param'; +import { useUrlCategoryParam } from './nav/use-url-category-param'; +import { useUrlModeParam } from './nav/use-url-mode-param'; +import { apiPost } from './apiHelpers'; 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 { Building, UserVerified } from './models/building'; import Welcome from './pages/welcome'; import { PrivateRoute } from './route'; +import { useLastNotEmpty } from './hooks/use-last-not-empty'; +import { Category } from './config/categories-config'; +import { defaultMapCategory } from './config/category-maps-config'; +import { useMultiEditData } from './hooks/use-multi-edit-data'; -interface MapAppRouteParams { - mode: 'view' | 'edit' | 'multi-edit'; - category: string; - building?: string; -} - -interface MapAppProps extends RouteComponentProps { +interface MapAppProps { building?: Building; building_like?: boolean; - user?: any; - revisionId?: number; + revisionId?: string; user_verified?: object; } -interface MapAppState { - category: string; - revision_id: number; - building: Building; - building_like: boolean; - user_verified: object; +/** Returns first argument, unless it's equal to the second argument - then returns undefined */ +function unless(value: V, unlessValue: U): Exclude { + return value === unlessValue ? undefined : value as Exclude; } -class MapApp extends React.Component { - constructor(props: Readonly) { - super(props); - - this.state = { - category: this.getCategory(props.match.params.category), - revision_id: props.revisionId || 0, - building: props.building, - building_like: props.building_like, - user_verified: props.user_verified || {} - }; - - this.selectBuilding = this.selectBuilding.bind(this); - this.colourBuilding = this.colourBuilding.bind(this); - this.increaseRevision = this.increaseRevision.bind(this); +/** Returns the new value, unless it is equal to the current value - then returns undefined */ +function setOrToggle(currentValue: T, newValue: T): T { + if(newValue == undefined || newValue === currentValue){ + return undefined; + } else { + return newValue; } +} - componentWillReceiveProps(props: Readonly) { - const newCategory = this.getCategory(props.match.params.category); - if (newCategory != undefined) { - this.setState({ category: newCategory }); +export const MapApp: React.FC = props => { + const [categoryUrlParam] = useUrlCategoryParam(); + const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam(); + const [mode] = useUrlModeParam(); + + const [currentCategory, setCategory] = useState(); + useEffect(() => setCategory(unless(categoryUrlParam, 'categories')), [categoryUrlParam]); + + const displayCategory = useLastNotEmpty(currentCategory) ?? defaultMapCategory; + + const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building); + const [buildingLike, updateBuildingLike] = useBuildingLikeData(selectedBuildingId, props.building_like); + const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified); + + const [revisionId, updateRevisionId] = useRevisionId(props.revisionId); + useEffect(() => { + updateRevisionId(building?.revision_id) + }, [building]); + + const viewEditMode = unless(mode, 'multi-edit'); + + const [multiEditData, multiEditError] = useMultiEditData(); + + const selectBuilding = useCallback((selectedBuilding: Building) => { + updateBuilding(Object.assign({}, building, selectedBuilding)); + setSelectedBuildingId(setOrToggle(selectedBuildingId, selectedBuilding?.building_id)); + }, [selectedBuildingId, setSelectedBuildingId, updateBuilding, building]); + + const colourBuilding = useCallback(async (building: Building) => { + const buildingId = building?.building_id; + + if(buildingId != undefined && multiEditError == undefined) { + const isLike = currentCategory === Category.Community; + const endpoint = isLike ? + `/api/buildings/${buildingId}/like.json`: + `/api/buildings/${buildingId}.json`; + + const payload = isLike ? {like: true} : multiEditData; + + try { + const res = await apiPost(endpoint, payload); + if(res.error) { + console.error({ error: res.error }); + } else { + updateRevisionId(res.revision_id); + } + } catch(error) { + console.error({ error }); + } } - } + }, [multiEditError, multiEditData, currentCategory]); - componentDidMount() { - this.fetchLatestRevision(); - - if(this.props.match.params.building != undefined && this.props.building == undefined) { - this.fetchBuildingData(strictParseInt(this.props.match.params.building)); - } - } - - async fetchLatestRevision() { - try { - const {latestRevisionId} = await apiGet(`/api/buildings/revision`); - - this.increaseRevision(latestRevisionId); - } catch(error) { - console.error(error); - } - } - - /** - * Fetches building data if a building is selected but no data provided through - * props (from server-side rendering) - */ - async fetchBuildingData(buildingId: number) { - try { - // TODO: simplify API calls, create helpers for fetching data - let [building, building_uprns, building_like, user_verified] = await Promise.all([ - apiGet(`/api/buildings/${buildingId}.json`), - apiGet(`/api/buildings/${buildingId}/uprns.json`), - apiGet(`/api/buildings/${buildingId}/like.json`), - apiGet(`/api/buildings/${buildingId}/verify.json`) - ]); - - building.uprns = building_uprns.uprns; - - this.setState({ - building: building, - building_like: building_like.like, - user_verified: user_verified - }); - - this.increaseRevision(building.revision_id); - - } catch(error) { - console.error(error); - // TODO: add UI for API errors - } - } - - getCategory(category: string) { - if (category === 'categories') return undefined; - - return category; - } - - getMultiEditDataString(): string { - const q = parseQuery(this.props.location.search); - if(Array.isArray(q.data)) { - throw new Error('Invalid format'); - } else return q.data; - } - - increaseRevision(revisionId) { - revisionId = +revisionId; - // bump revision id, only ever increasing - if (revisionId > this.state.revision_id) { - this.setState({ revision_id: revisionId }); - } - } - - selectBuilding(building: Building) { - const mode = this.props.match.params.mode || 'view'; - const category = this.props.match.params.category || 'age'; - - if (building == undefined) { - this.setState({ building: undefined }); - this.props.history.push(`/${mode}/${category}`); - return; - } - - this.fetchBuildingData(building.building_id); - - this.props.history.push(`/${mode}/${category}/${building.building_id}`); - } - - /** - * Colour building - * - * Used in multi-edit mode to colour buildings on map click - * - * Pulls data from URL to form update - * - * @param {Building} building - */ - colourBuilding(building: Building) { - const cat = this.props.match.params.category; - - if (cat === 'like') { - this.likeBuilding(building.building_id); + const handleBuildingUpdate = useCallback((buildingId: number, updatedData: Building) => { + // only update current building data if the IDs match + if(buildingId === selectedBuildingId) { + updateBuilding(Object.assign({}, building, updatedData)); } else { - const data = parseJsonOrDefault(this.getMultiEditDataString()); - - - if (data != undefined && !Object.values(data).some(x => x == undefined)) { - this.updateBuilding(building.building_id, data); - } + // otherwise, still update the latest revision ID + updateRevisionId(updatedData.revision_id); } - } + }, [selectedBuildingId, building, updateBuilding, updateRevisionId]); - likeBuilding(buildingId) { - apiPost(`/api/buildings/${buildingId}/like.json`, { like: true }) - .then(res => { - if (res.error) { - console.error({ error: res.error }); - } else { - this.increaseRevision(res.revision_id); - } - }).catch( - (err) => console.error({ error: err }) - ); - } + const handleBuildingLikeUpdate = useCallback((buildingId: number, updatedData: boolean) => { + // only update current building data if the IDs match + if(buildingId === selectedBuildingId) { + updateBuildingLike(updatedData); + } + }, [selectedBuildingId, updateBuildingLike]); - updateBuilding(buildingId, data) { - apiPost(`/api/buildings/${buildingId}.json`, data) - .then(res => { - if (res.error) { - console.error({ error: res.error }); - } else { - this.increaseRevision(res.revision_id); - } - }).catch( - (err) => console.error({ error: err }) - ); - } + const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => { + // only update current building data if the IDs match + if(buildingId === selectedBuildingId) { + updateUserVerified(Object.assign({}, userVerified, updatedData)); // quickly show added verifications + reloadBuilding(); + reloadUserVerified(); // but still reload from server to reflect removed verifications + } + }, [selectedBuildingId, updateUserVerified, reloadBuilding, userVerified]); - render() { - const mode = this.props.match.params.mode; - const viewEditMode = mode === 'multi-edit' ? undefined : mode; - - let category = this.state.category || 'age'; - - const building_id = this.state.building && this.state.building.building_id; - - return ( - - - {/* empty private route to ensure auth for editing */} - + return ( + <> + {/* empty private route to ensure auth for editing */} + - - - + - - - - + + - ( - - )} /> - - - - - - - - - - - + + + + + + + + + + ()} /> - - - ); - } -} - -export default MapApp; +
+ + + ); +}; diff --git a/app/src/frontend/map/legend.tsx b/app/src/frontend/map/legend.tsx index 6a64e932..f57d5de9 100644 --- a/app/src/frontend/map/legend.tsx +++ b/app/src/frontend/map/legend.tsx @@ -4,145 +4,10 @@ import './legend.css'; import { DownIcon, UpIcon } from '../components/icons'; import { Logo } from '../components/logo'; - -const LEGEND_CONFIG = { - location: { - title: 'Location', - description: '% data collected', - elements: [ - { color: '#084081', text: '≥80%' }, - { color: '#0868ac', text: '60–80%' }, - { color: '#43a2ca', text: '40–60%' }, - { color: '#7bccc4', text: '20–40%' }, - { color: '#bae4bc', text: '<20%' } - ] - }, - use: { - title: 'Land Use', - elements: [ - { color: '#e5050d', text: 'Mixed Use' }, - { subtitle: 'Single use:'}, - { color: '#4a54a6', text: 'Residential' }, - { color: '#ff8c00', text: 'Retail' }, - { color: '#f5f58f', text: 'Industry & Business' }, - { color: '#73ccd1', text: 'Community Services' }, - { color: '#ffbfbf', text: 'Recreation & Leisure' }, - { color: '#b3de69', text: 'Transport' }, - { color: '#cccccc', text: 'Utilities & Infrastructure' }, - { color: '#898944', text: 'Defence' }, - { color: '#fa667d', text: 'Agriculture' }, - { color: '#53f5dd', text: 'Minerals' }, - { color: '#ffffff', text: 'Vacant & Derelict' } - ] - }, - type: { - title: 'Type', - elements: [ - { color: "#f2a2b9", text: "Detached" }, - { color: "#ab8fb0", text: "Semi-Detached" }, - { color: "#3891d1", text: "End-Terrace" }, - { color: "#226291", text: "Mid-Terrace" } - ] - }, - age: { - title: 'Age', - elements: [ - { color: '#fff9b8', text: '>2020' }, - { color: '#fae269', text: '2000-2019' }, - { color: '#fbaf27', text: '1980-1999' }, - { color: '#e6711d', text: '1960-1979' }, - { color: '#cc1212', text: '1940-1959' }, - { color: '#8f0303', text: '1920-1939' }, - { color: '#8f5385', text: '1900-1919' }, - { color: '#c3e1eb', text: '1880-1899' }, - { color: '#6a9dba', text: '1860-1879' }, - { color: '#3b74a3', text: '1840-1859' }, - { color: '#95ded8', text: '1820-1839' }, - { color: '#68aba5', text: '1800-1819' }, - { color: '#acc98f', text: '1750-1799' }, - { color: '#6d8a51', text: '1700-1749' }, - { color: '#d0c291', text: '<1700' }, - ] - }, - size: { - title: 'Height to apex', - elements: [ - { color: '#f7f4f9', text: '0-5.55'}, - { color: '#e7e1ef', text: '5.55-7.73'}, - { color: '#d4b9da', text: '7.73-11.38'}, - { color: '#c994c7', text: '11.38-18.45'}, - { color: '#df65b0', text: '18.45-35.05'}, - { color: '#e7298a', text: '35.05-89.30'}, - { color: '#ce1256', text: '89.30-152'}, - { color: '#980043', text: '≥152'} - ] - }, - construction: { - title: 'Construction', - elements: [ - { color: "#96613b", text: "Wood" }, - { color: "#ffffe3", text: "Stone" }, - { color: "#f5d96b", text: "Brick" }, - { color: "#beffe8", text: "Steel" }, - { color: "#fca89d", text: "Reinforced Concrete" }, - { color: "#5c8970", text: "Other Metal" }, - { color: "#b5a859", text: "Other Natural Material" }, - { color: "#c48a85", text: "Other Man-Made Material" } - ] - }, - team: { - title: 'Team', - elements: [] - }, - sustainability: { - title: 'Sustainability', - description: 'DEC Rating', - elements: [ - { color: "#007f3d", text: 'A' }, - { color: "#2c9f29", text: 'B' }, - { color: "#9dcb3c", text: 'C' }, - { color: "#fff200", text: 'D' }, - { color: "#f7af1d", text: 'E' }, - { color: "#ed6823", text: 'F' }, - { color: "#e31d23", text: 'G' }, - ] - }, - streetscape: { - title: 'Streetscape', - elements: [] - }, - planning: { - title: 'Statutory protections', - disclaimer: 'All data relating to designated buildings should be checked on the National Heritage List for England or local authority websites where used for planning or development purposes', - elements: [ - { color: '#95beba', text: 'In conservation area'}, - { color: '#c72e08', text: 'Grade I listed'}, - { color: '#e75b42', text: 'Grade II* listed'}, - { color: '#ffbea1', text: 'Grade II listed'}, - { color: '#858ed4', text: 'Locally listed'}, - ] - }, - dynamics: { - title: 'Dynamics', - elements: [] - }, - community: { - title: 'Like Me', - elements: [ - { color: '#bd0026', text: '👍👍👍👍 100+' }, - { color: '#e31a1c', text: '👍👍👍 50–99' }, - { color: '#fc4e2a', text: '👍👍 20–49' }, - { color: '#fd8d3c', text: '👍👍 10–19' }, - { color: '#feb24c', text: '👍 3–9' }, - { color: '#fed976', text: '👍 2' }, - { color: '#ffe8a9', text: '👍 1'} - ] - } -}; - +import { LegendConfig } from '../config/category-maps-config'; interface LegendProps { - slug: string; + legendConfig: LegendConfig; } interface LegendState { @@ -184,59 +49,62 @@ class Legend extends React.Component { } render() { - const details = LEGEND_CONFIG[this.props.slug] || {}; - const title = details.title || ""; - const elements = details.elements || []; + const { + title = undefined, + elements = [], + description = undefined, + disclaimer = undefined + } = this.props.legendConfig ?? {}; return (
-

- { title } -

{ - elements.length > 0 ? + title &&

{title}

+ } + { + elements.length > 0 && : - null + } { - details.description? -

{details.description}

- : null + description &&

{description}

} { - elements.length? + elements.length === 0 ? +

Coming soon…

:
    { - details.disclaimer && -

    {details.disclaimer}

    + disclaimer &&

    {disclaimer}

    } { elements.map((item) => { - if(item.subtitle != undefined) { - return (
  • -
    {item.subtitle}
    -
  • ); - } - - return ( - -
  • -
    + let key: string, + content: React.ReactElement; + + if('subtitle' in item) { + key = item.subtitle; + content =
    {item.subtitle}
    ; + } else { + key = `${item.text}-${item.color}`; + content = <> +
    { item.text } -
  • - + ; + } + return ( +
  • + {content} +
  • ); }) }
- :

Coming soon…

}
); diff --git a/app/src/frontend/map/map.tsx b/app/src/frontend/map/map.tsx index 3e4d4f90..42e9c4b0 100644 --- a/app/src/frontend/map/map.tsx +++ b/app/src/frontend/map/map.tsx @@ -7,21 +7,22 @@ import './map.css'; import { apiGet } from '../apiHelpers'; 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 { categoryMapsConfig } from '../config/category-maps-config'; +import { Category } from '../config/categories-config'; +import { Building } from '../models/building'; const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9'; interface ColouringMapProps { - building?: Building; + selectedBuildingId: number; mode: 'basic' | 'view' | 'edit' | 'multi-edit'; - category: string; - revision_id: number; - selectBuilding: (building: Building) => void; - colourBuilding: (building: Building) => void; + category: Category; + revisionId: string; + onBuildingAction: (building: Building) => void; } interface ColouringMapState { @@ -58,30 +59,12 @@ class ColouringMap extends Component { } handleClick(e) { - const mode = this.props.mode; const { lat, lng } = e.latlng; apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`) - .then(data => { - if (data && data.length){ - const building = data[0]; - if (mode === 'multi-edit') { - // colour building directly - this.props.colourBuilding(building); - } else if (this.props.building == undefined || building.building_id !== this.props.building.building_id){ - this.props.selectBuilding(building); - } else { - this.props.selectBuilding(undefined); - } - } else { - if (mode !== 'multi-edit') { - // deselect but keep/return to expected colour theme - // except if in multi-edit (never select building, only colour on click) - this.props.selectBuilding(undefined); - } - } - }).catch( - (err) => console.error(err) - ); + .then(data => { + const building = data?.[0]; + this.props.onBuildingAction(building); + }).catch(err => console.error(err)); } themeSwitch(e) { @@ -103,6 +86,8 @@ class ColouringMap extends Component { } render() { + const categoryMapDefinition = categoryMapsConfig[this.props.category]; + const position: [number, number] = [this.state.lat, this.state.lng]; // baselayer @@ -121,55 +106,37 @@ class ColouringMap extends Component { const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`; const buildingBaseLayer = ; - - const boundaryStyleFn = () => ({color: '#bbb', fill: false}); const boundaryLayer = this.state.boundary && - ; + ; - // colour-data tiles - const cat = this.props.category; - const tilesetByCat = { - age: 'date_year', - size: 'size_height', - construction: 'construction_core_material', - location: 'location', - community: 'likes', - planning: 'planning_combined', - sustainability: 'sust_dec', - type: 'building_attachment_form', - use: 'landuse' - }; - const tileset = tilesetByCat[cat]; - // pick revision id to bust browser cache - const rev = this.props.revision_id; - const dataLayer = tileset != undefined ? + const tileset = categoryMapDefinition.mapStyle; + const dataLayer = tileset != undefined && - : null; + />; // highlight - const highlightLayer = this.props.building != undefined ? + const highlightLayer = this.props.selectedBuildingId != undefined && - : null; + />; const numbersLayer = + const hasSelection = this.props.selectedBuildingId != undefined; const isEdit = ['edit', 'multi-edit'].includes(this.props.mode); return ( @@ -195,20 +162,18 @@ class ColouringMap extends Component { { - this.props.mode !== 'basic'? ( - - { - this.props.building == undefined ? -
- {isEdit ? 'Click a building to edit' : 'Click a building for details'} -
- : null - } - - - -
- ) : null + this.props.mode !== 'basic' && + + { + !hasSelection && +
+ {isEdit ? 'Click a building to edit' : 'Click a building for details'} +
+ } + + + +
}
); diff --git a/app/src/frontend/models/building.ts b/app/src/frontend/models/building.ts index 5b81ed05..b9be5a9e 100644 --- a/app/src/frontend/models/building.ts +++ b/app/src/frontend/models/building.ts @@ -1,12 +1,21 @@ -interface Building { +import { dataFields } from '../config/data-fields-config'; + +/** + * A type representing the types of a building's attributes. + * This is derived automatically from the "example" fields in dataFieldsConfig. + * If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here, + * that means an example field is most probably missing on one of the config definitions in dataFieldsConfig. + */ +export type BuildingAttributes = {[key in keyof typeof dataFields]: (typeof dataFields)[key]['example']}; + +export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number}; + +export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]}; + +export interface Building extends BuildingAttributes { building_id: number; geometry_id: number; - revision_id: number; + revision_id: string; - uprns: string[]; - // TODO: add other fields as needed + verified: BuildingAttributeVerificationCounts; } - -export { - Building -}; diff --git a/app/src/frontend/nav/url-param-transform.ts b/app/src/frontend/nav/url-param-transform.ts new file mode 100644 index 00000000..8bef7fe9 --- /dev/null +++ b/app/src/frontend/nav/url-param-transform.ts @@ -0,0 +1,16 @@ +export interface UrlParamTransform { + fromParam: (x: string) => T; + toParam: (x: T) => string; +} + +const identity: (x: T) => T = (x) => x; + +export const stringParamTransform: UrlParamTransform = { + fromParam: identity, + toParam: identity +}; + +export const intParamTransform: UrlParamTransform = { + fromParam: x => parseInt(x, 10), + toParam: x => x.toString() +}; diff --git a/app/src/frontend/nav/use-url-building-param.ts b/app/src/frontend/nav/use-url-building-param.ts new file mode 100644 index 00000000..9a8e221c --- /dev/null +++ b/app/src/frontend/nav/use-url-building-param.ts @@ -0,0 +1,6 @@ +import { intParamTransform } from './url-param-transform'; +import { useUrlParam } from './use-url-param'; + +export function useUrlBuildingParam() { + return useUrlParam('building', intParamTransform); +} diff --git a/app/src/frontend/nav/use-url-category-param.ts b/app/src/frontend/nav/use-url-category-param.ts new file mode 100644 index 00000000..2255016d --- /dev/null +++ b/app/src/frontend/nav/use-url-category-param.ts @@ -0,0 +1,14 @@ +import { Category } from '../config/categories-config'; +import { useUrlParam } from './use-url-param'; + +export function useUrlCategoryParam() { + return useUrlParam('category', { + fromParam: (x: string) => { + // TODO: add validation + return x as Category | 'categories'; + }, + toParam: (x: Category | 'categories') => { + return x as string; + } + }); +} diff --git a/app/src/frontend/nav/use-url-mode-param.ts b/app/src/frontend/nav/use-url-mode-param.ts new file mode 100644 index 00000000..ea37d676 --- /dev/null +++ b/app/src/frontend/nav/use-url-mode-param.ts @@ -0,0 +1,10 @@ +import { useUrlParam } from './use-url-param'; + +type Mode = 'view' | 'edit' | 'multi-edit'; + +export function useUrlModeParam() { + return useUrlParam('mode', { + fromParam: (x) => x as Mode, + toParam: (x) => x + }); +} diff --git a/app/src/frontend/nav/use-url-param.ts b/app/src/frontend/nav/use-url-param.ts new file mode 100644 index 00000000..b44dbb5d --- /dev/null +++ b/app/src/frontend/nav/use-url-param.ts @@ -0,0 +1,33 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useHistory, useRouteMatch, generatePath } from 'react-router'; + +import { UrlParamTransform } from './url-param-transform'; + +export function useUrlParam( + param: string, + paramTransform: UrlParamTransform +): [T, (newParam: T) => void] { + const match = useRouteMatch(); + const history = useHistory(); + + const [paramValue, setParamValue] = useState(); + + useEffect(() => { + const stringValue: string = match.params[param]; + + setParamValue(stringValue && paramTransform.fromParam(stringValue)); + }, [param, paramTransform, match.url]); + + const setUrlParam = useCallback((value: T) => { + const stringValue = value == undefined ? '' : paramTransform.toParam(value); + const newPath = generatePath(match.path, { + ...match.params, + ...{ + [param]: stringValue + } + }); + history.push(newPath); + }, [param, paramTransform, match.url]); + + return [paramValue, setUrlParam]; +} diff --git a/app/src/frontendRoute.tsx b/app/src/frontendRoute.tsx index 90939164..0091ef6e 100644 --- a/app/src/frontendRoute.tsx +++ b/app/src/frontendRoute.tsx @@ -75,6 +75,7 @@ function renderHTML(context, data, req, res) { building={data.building} building_like={data.building_like} revisionId={data.latestRevisionId} + user_verified={data.userVerified} /> );