Merge pull request #472 from mz8i/feature/data-container-state

Feature: store data edits in data container state
This commit is contained in:
mz8i 2019-10-29 17:37:55 +00:00 committed by GitHub
commit a5f2df68f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 469 additions and 234 deletions

View File

@ -14,18 +14,28 @@ import StreetscapeContainer from './data-containers/streetscape';
import CommunityContainer from './data-containers/community';
import PlanningContainer from './data-containers/planning';
import LikeContainer from './data-containers/like';
import { Building } from '../models/building';
interface BuildingViewProps {
cat: string;
mode: 'view' | 'edit' | 'multi-edit';
building: Building;
building_like: boolean;
user: any;
selectBuilding: (building: Building) => void
}
/**
* Top-level container for building view/edit form
*
* @param props
*/
const BuildingView = (props) => {
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
switch (props.cat) {
case 'location':
return <LocationContainer
{...props}
key={props.building && props.building.building_id}
title="Location"
help="https://pages.colouring.london/location"
intro="Where are the buildings? Address, location and cross-references."
@ -33,7 +43,6 @@ const BuildingView = (props) => {
case 'use':
return <UseContainer
{...props}
key={props.building && props.building.building_id}
inactive={true}
title="Land Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
@ -42,7 +51,6 @@ const BuildingView = (props) => {
case 'type':
return <TypeContainer
{...props}
key={props.building && props.building.building_id}
inactive={false}
title="Type"
intro="How were buildings previously used?"
@ -51,7 +59,6 @@ const BuildingView = (props) => {
case 'age':
return <AgeContainer
{...props}
key={props.building && props.building.building_id}
title="Age"
help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change."
@ -59,7 +66,6 @@ const BuildingView = (props) => {
case 'size':
return <SizeContainer
{...props}
key={props.building && props.building.building_id}
title="Size &amp; Shape"
intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize"
@ -67,7 +73,6 @@ const BuildingView = (props) => {
case 'construction':
return <ConstructionContainer
{...props}
key={props.building && props.building.building_id}
title="Construction"
intro="How are buildings built? Coming soon…"
help="https://pages.colouring.london/construction"
@ -76,7 +81,6 @@ const BuildingView = (props) => {
case 'team':
return <TeamContainer
{...props}
key={props.building && props.building.building_id}
title="Team"
intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team"
@ -85,7 +89,6 @@ const BuildingView = (props) => {
case 'sustainability':
return <SustainabilityContainer
{...props}
key={props.building && props.building.building_id}
title="Sustainability"
intro="Are buildings energy efficient?"
help="https://pages.colouring.london/sustainability"
@ -94,7 +97,6 @@ const BuildingView = (props) => {
case 'streetscape':
return <StreetscapeContainer
{...props}
key={props.building && props.building.building_id}
title="Streetscape"
intro="What's the building's context? Coming soon…"
help="https://pages.colouring.london/streetscape"
@ -103,7 +105,6 @@ const BuildingView = (props) => {
case 'community':
return <CommunityContainer
{...props}
key={props.building && props.building.building_id}
title="Community"
intro="How does this building work for the local community?"
help="https://pages.colouring.london/community"
@ -112,7 +113,6 @@ const BuildingView = (props) => {
case 'planning':
return <PlanningContainer
{...props}
key={props.building && props.building.building_id}
title="Planning"
intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning"
@ -120,7 +120,6 @@ const BuildingView = (props) => {
case 'like':
return <LikeContainer
{...props}
key={props.building && props.building.building_id}
title="Like Me!"
intro="Do you like the building and think it contributes to the city?"
help="https://pages.colouring.london/likeme"

View File

@ -3,8 +3,18 @@ import { Link, NavLink } from 'react-router-dom';
import { BackIcon, EditIcon, ViewIcon }from '../components/icons';
interface ContainerHeaderProps {
cat: string;
mode: 'view' | 'edit' | 'multi-edit';
building: any;
title: string;
copy: any;
inactive?: boolean;
data_string: string;
help: string;
}
const ContainerHeader: React.FunctionComponent<any> = (props) => (
const ContainerHeader: React.FunctionComponent<ContainerHeaderProps> = (props) => (
<header className={`section-header view ${props.cat} background-${props.cat}`}>
<Link className="icon-button back" to={`/${props.mode}/categories${props.building != undefined ? `/${props.building.building_id}` : ''}`}>
<BackIcon />

View File

@ -2,8 +2,14 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface CheckboxDataEntryProps extends BaseDataEntryProps {
value: boolean;
}
const CheckboxDataEntry: React.FunctionComponent<CheckboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -19,7 +25,7 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
name={props.slug}
checked={!!props.value}
disabled={props.mode === 'view' || props.disabled}
onChange={props.onChange}
onChange={e => props.onChange(props.slug, e.target.checked)}
/>
<label
htmlFor={props.slug}
@ -31,14 +37,12 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
);
}
DataEntry.propTypes = {
CheckboxDataEntry.propTypes = {
title: PropTypes.string,
slug: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
value: PropTypes.any,
placeholder: PropTypes.string,
maxLength: PropTypes.number,
onChange: PropTypes.func,
copy: PropTypes.shape({
copying: PropTypes.bool,
@ -47,4 +51,4 @@ DataEntry.propTypes = {
})
}
export default DataEntry;
export default CheckboxDataEntry;

View File

@ -3,7 +3,24 @@ import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface BaseDataEntryProps {
slug: string;
title: string;
tooltip?: string;
disabled?: boolean;
copy?: any; // CopyProps clashes with propTypes
mode?: 'view' | 'edit' | 'multi-edit';
onChange?: (key: string, value: any) => void;
}
interface DataEntryProps extends BaseDataEntryProps {
value: string;
maxLength?: number;
placeholder?: string;
valueTransform?: (string) => string
}
const DataEntry: React.FunctionComponent<DataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -20,7 +37,13 @@ const DataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
maxLength={props.maxLength}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e => {
const transform = props.valueTransform || (x => x);
const val = e.target.value === '' ?
null :
transform(e.target.value);
props.onChange(props.slug, val);
}}
/>
</Fragment>
);
@ -43,3 +66,6 @@ DataEntry.propTypes = {
}
export default DataEntry;
export {
BaseDataEntryProps
};

View File

@ -3,7 +3,13 @@ import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const DataTitle: React.FunctionComponent<any> = (props) => {
interface DataTitleProps {
title: string;
tooltip: string;
}
const DataTitle: React.FunctionComponent<DataTitleProps> = (props) => {
return (
<dt>
{ props.title }
@ -17,7 +23,16 @@ DataTitle.propTypes = {
tooltip: PropTypes.string
}
const DataTitleCopyable: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface DataTitleCopyableProps {
title: string;
tooltip: string;
slug: string;
disabled?: boolean;
copy?: any; // TODO: type should be CopyProps, but that clashes with propTypes in some obscure way
}
const DataTitleCopyable: React.FunctionComponent<DataTitleCopyableProps> = (props) => {
return (
<div className="data-title">
{ props.tooltip? <Tooltip text={ props.tooltip } /> : null }
@ -48,7 +63,8 @@ DataTitleCopyable.propTypes = {
copy: PropTypes.shape({
copying: PropTypes.bool,
copyingKey: PropTypes.func,
toggleCopyAttribute: PropTypes.func
toggleCopyAttribute: PropTypes.func,
toggleCopying: PropTypes.func
})
}

View File

@ -4,7 +4,15 @@ import PropTypes from 'prop-types';
import Tooltip from '../../components/tooltip';
const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface LikeDataEntryProps {
mode: 'view' | 'edit' | 'multi-edit';
userLike: boolean;
totalLikes: number;
onLike: (userLike: boolean) => void;
}
const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
const data_string = JSON.stringify({like: true});
return (
<Fragment>
@ -21,20 +29,20 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
</div>
<p>
{
(props.value != null)?
(props.value === 1)?
`${props.value} person likes this building`
: `${props.value} people like this building`
(props.totalLikes != null)?
(props.totalLikes === 1)?
`${props.totalLikes} person likes this building`
: `${props.totalLikes} people like this building`
: "0 people like this building so far - you could be the first!"
}
</p>
<input className="form-check-input" type="checkbox"
id="like" name="like"
checked={!!props.building_like}
disabled={props.mode === 'view'}
onChange={props.onLike}
/>
<label htmlFor="like" className="form-check-label">
<label className="form-check-label">
<input className="form-check-input" type="checkbox"
name="like"
checked={!!props.userLike}
disabled={props.mode === 'view'}
onChange={e => props.onLike(e.target.checked)}
/>
I like this building and think it contributes to the city!
</label>
</Fragment>
@ -42,8 +50,10 @@ const LikeDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove
}
LikeDataEntry.propTypes = {
value: PropTypes.any,
user_building_like: PropTypes.bool
}
// mode: PropTypes.string,
userLike: PropTypes.bool,
totalLikes: PropTypes.number,
onLike: PropTypes.func
};
export default LikeDataEntry;

View File

@ -2,8 +2,18 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface NumericDataEntryProps extends BaseDataEntryProps {
value?: number;
placeholder?: string;
step?: number;
min?: number;
max?: number;
}
const NumericDataEntry: React.FunctionComponent<NumericDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -24,7 +34,12 @@ const NumericDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
min={props.min || 0}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ? null : parseFloat(e.target.value)
)
}
/>
</Fragment>
);

View File

@ -2,8 +2,16 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface SelectDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
options: string[];
}
const SelectDataEntry: React.FunctionComponent<SelectDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -17,7 +25,14 @@ const SelectDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remo
id={props.slug} name={props.slug}
value={props.value || ''}
disabled={props.mode === 'view' || props.disabled}
onChange={props.onChange}>
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)}
>
<option value="">{props.placeholder}</option>
{
props.options.map(option => (

View File

@ -2,8 +2,15 @@ import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { DataTitleCopyable } from './data-title';
import { BaseDataEntryProps } from './data-entry';
const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: remove any
interface TextboxDataEntryProps extends BaseDataEntryProps {
value: string;
placeholder?: string;
maxLength?: number;
}
const TextboxDataEntry: React.FunctionComponent<TextboxDataEntryProps> = (props) => {
return (
<Fragment>
<DataTitleCopyable
@ -18,11 +25,18 @@ const TextboxDataEntry: React.FunctionComponent<any> = (props) => { // TODO: rem
id={props.slug}
name={props.slug}
value={props.value || ''}
maxLength={props.max_length}
maxLength={props.maxLength}
rows={5}
disabled={props.mode === 'view' || props.disabled}
placeholder={props.placeholder}
onChange={props.onChange}
onChange={e =>
props.onChange(
props.slug,
e.target.value === '' ?
null :
e.target.value
)
}
></textarea>
</Fragment>
);

View File

@ -5,6 +5,33 @@ import { Redirect } from 'react-router-dom';
import ContainerHeader from './container-header';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { Building } from '../models/building';
import { User } from '../models/user';
import { compareObjects } from '../helpers';
import { CategoryViewProps, CopyProps } from './data-containers/category-view-props';
interface DataContainerProps {
title: string;
cat: string;
intro: string;
help: string;
inactive?: boolean;
user: User;
mode: 'view' | 'edit' | 'multi-edit';
building: Building;
building_like: boolean;
selectBuilding: (building: Building) => void
}
interface DataContainerState {
error: string;
copying: boolean;
keys_to_copy: {[key: string]: boolean};
currentBuildingId: number;
currentBuildingRevisionId: number;
buildingEdits: Partial<Building>;
}
/**
* Shared functionality for view/edit forms
@ -14,15 +41,14 @@ import InfoBox from '../components/info-box';
*
* @param WrappedComponent
*/
const withCopyEdit = (WrappedComponent) => {
return class extends React.Component<any, any> { // TODO: add proper types
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
intro: PropTypes.string,
help: PropTypes.string,
inactive: PropTypes.bool,
building_id: PropTypes.number,
children: PropTypes.node
};
@ -30,23 +56,40 @@ const withCopyEdit = (WrappedComponent) => {
super(props);
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
error: undefined,
copying: false,
keys_to_copy: {},
building: this.props.building
buildingEdits: {},
currentBuildingId: undefined,
currentBuildingRevisionId: undefined
};
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleLike = this.handleLike.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleUpdate = this.handleUpdate.bind(this);
this.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
}
static getDerivedStateFromProps(props, state) {
const newBuildingId = props.building == undefined ? undefined : props.building.building_id;
const newBuildingRevisionId = props.building == undefined ? undefined : props.building.revision_id;
if(newBuildingId !== state.currentBuildingId || newBuildingRevisionId > state.currentBuildingRevisionId) {
return {
error: undefined,
copying: false,
keys_to_copy: {},
buildingEdits: {},
currentBuildingId: newBuildingId,
currentBuildingRevisionId: newBuildingRevisionId
};
}
return null;
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
@ -62,7 +105,7 @@ const withCopyEdit = (WrappedComponent) => {
* @param {string} key
*/
toggleCopyAttribute(key: string) {
const keys = this.state.keys_to_copy;
const keys = {...this.state.keys_to_copy};
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
@ -73,45 +116,34 @@ const withCopyEdit = (WrappedComponent) => {
})
}
updateBuildingState(key, value) {
const building = {...this.state.building};
building[key] = value;
isEdited() {
const edits = this.state.buildingEdits;
// check if the edits object has any fields
return Object.entries(edits).length !== 0;
}
clearEdits() {
this.setState({
building: building
buildingEdits: {}
});
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {*} event
*/
handleChange(event) {
const target = event.target;
let value = (target.value === '')? null : target.value;
const name = target.name;
// special transform - consider something data driven before adding 'else if's
if (name === 'location_postcode' && value !== null) {
value = value.toUpperCase();
getEditedBuilding() {
if(this.isEdited()) {
return Object.assign({}, this.props.building, this.state.buildingEdits);
} else {
return {...this.props.building};
}
this.updateBuildingState(name, value);
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {*} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
updateBuildingState(key: string, value: any) {
const newBuilding = this.getEditedBuilding();
newBuilding[key] = value;
const [forwardPatch] = compareObjects(this.props.building, newBuilding);
this.updateBuildingState(name, value);
this.setState({
buildingEdits: forwardPatch
});
}
/**
@ -121,160 +153,166 @@ const withCopyEdit = (WrappedComponent) => {
* @param {String} name
* @param {*} value
*/
handleUpdate(name: string, value: any) {
handleChange(name: string, value: any) {
this.updateBuildingState(name, value);
}
handleReset() {
this.clearEdits();
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {*} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
async handleLike(like: boolean) {
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}/like.json`, {
method: 'POST',
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({like: like})
});
const data = await res.json();
if (data.error) {
this.setState({error: data.error})
} else {
this.props.selectBuilding(res);
this.updateBuildingState('likes_total', res.likes_total);
this.props.selectBuilding(data);
this.updateBuildingState('likes_total', data.likes_total);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
} catch(err) {
this.setState({error: err});
}
}
handleSubmit(event) {
async handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
this.setState({error: undefined});
fetch(`/api/buildings/${this.props.building.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state.building),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
try {
const res = await fetch(`/api/buildings/${this.props.building.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state.buildingEdits),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
});
const data = await res.json();
if (data.error) {
this.setState({error: data.error})
} else {
this.props.selectBuilding(res);
this.props.selectBuilding(data);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
} catch(err) {
this.setState({error: err});
}
}
render() {
if (this.state.mode === 'edit' && !this.props.user){
if (this.props.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />
}
const currentBuilding = this.getEditedBuilding();
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state.building[key]
values_to_copy[key] = currentBuilding[key]
}
const data_string = JSON.stringify(values_to_copy);
const copy = {
const copy: CopyProps = {
copying: this.state.copying,
toggleCopying: this.toggleCopying,
toggleCopyAttribute: this.toggleCopyAttribute,
copyingKey: (key) => this.state.keys_to_copy[key]
}
copyingKey: (key: string) => this.state.keys_to_copy[key]
};
const edited = this.isEdited();
return (
<section
id={this.props.slug}
id={this.props.cat}
className="data-section">
<ContainerHeader
{...this.props}
data_string={data_string}
copy={copy}
/>
<div className="section-body">
{
this.props.building != undefined ?
<form
action={`/edit/${this.props.slug}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
{
(this.props.inactive) ?
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
: null
}
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<ErrorBox msg={this.state.error} />
{
this.props.slug === 'like' ? // special-case for likes
null :
<div className="buttons-container with-space">
<button
type="submit"
className="btn btn-primary">
Save
</button>
</div>
}
</Fragment>
: null
}
<WrappedComponent
building={this.state.building}
building_like={this.props.building_like}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onCheck={this.handleCheck}
onLike={this.handleLike}
onUpdate={this.handleUpdate}
/>
</form>
:
<form>
{
(this.props.inactive)?
<Fragment>
this.props.inactive ?
<Fragment>
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
<WrappedComponent
intro={this.props.intro}
building={undefined}
building_like={undefined}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onCheck={this.handleCheck}
onLike={this.handleLike}
onUpdate={this.handleUpdate}
/>
</Fragment>
:
</Fragment> :
this.props.building != undefined ?
<form
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
method="POST"
onSubmit={this.handleSubmit}>
{
(this.props.mode === 'edit' && !this.props.inactive) ?
<Fragment>
<ErrorBox msg={this.state.error} />
{
this.props.cat !== 'like' ? // special-case for likes
<div className="buttons-container with-space">
<button
type="submit"
className="btn btn-primary"
disabled={!edited}
aria-disabled={!edited}>
Save
</button>
{
edited ?
<button
type="button"
className="btn btn-warning"
onClick={this.handleReset}
>
Discard changes
</button> :
null
}
</div> :
null
}
</Fragment>
: null
}
<WrappedComponent
intro={this.props.intro}
building={currentBuilding}
building_like={this.props.building_like}
mode={this.props.mode}
copy={copy}
onChange={this.handleChange}
onLike={this.handleLike}
/>
</form> :
<InfoBox msg="Select a building to view data"></InfoBox>
}
</form>
}
</div>
</section>
);
}
}
}
export default withCopyEdit;
export default withCopyEdit;

View File

@ -6,11 +6,12 @@ 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 { CategoryViewProps } from './category-view-props';
/**
* Age view/edit section
*/
const AgeView = (props) => (
const AgeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<YearDataEntry
year={props.building.date_year}
@ -67,7 +68,7 @@ const AgeView = (props) => (
value={props.building.date_link}
mode={props.mode}
copy={props.copy}
onChange={props.onUpdate}
onChange={props.onChange}
tooltip="URL for age and date reference"
placeholder="https://..."
/>

View File

@ -0,0 +1,21 @@
interface CopyProps {
copying: boolean;
toggleCopying: () => void;
toggleCopyAttribute: (key: string) => void;
copyingKey: (key: string) => boolean;
}
interface CategoryViewProps {
intro: string;
building: any; // TODO: add Building type with all fields
building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit';
copy: CopyProps;
onChange: (key: string, value: any) => void;
onLike: (like: boolean) => void;
}
export {
CategoryViewProps,
CopyProps
};

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Community view/edit section
*/
const CommunityView = (props) => (
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">

View File

@ -2,17 +2,18 @@ import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import LikeDataEntry from '../data-components/like-data-entry';
import { CategoryViewProps } from './category-view-props';
/**
* Like view/edit section
*/
const LikeView = (props) => (
const LikeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<LikeDataEntry
value={props.building.likes_total}
userLike={props.building_like}
totalLikes={props.building.likes_total}
mode={props.mode}
onLike={props.onLike}
building_like={props.building_like}
/>
</Fragment>
)

View File

@ -5,8 +5,9 @@ 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 { CategoryViewProps } from './category-view-props';
const LocationView = (props) => (
const LocationView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
<DataEntry
@ -63,6 +64,7 @@ const LocationView = (props) => (
copy={props.copy}
onChange={props.onChange}
maxLength={8}
valueTransform={x=>x.toUpperCase()}
/>
<DataEntry
title="TOID"
@ -96,7 +98,7 @@ const LocationView = (props) => (
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={51}
placeholder="51"
onChange={props.onChange}
/>
<NumericDataEntry
@ -106,7 +108,7 @@ const LocationView = (props) => (
mode={props.mode}
copy={props.copy}
step={0.0001}
placeholder={0}
placeholder="0"
onChange={props.onChange}
/>
</Fragment>

View File

@ -5,11 +5,12 @@ 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 { CategoryViewProps } from './category-view-props';
/**
* Planning view/edit section
*/
const PlanningView = (props) => (
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntry
title="Planning portal link"

View File

@ -4,11 +4,12 @@ import withCopyEdit from '../data-container';
import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import { CategoryViewProps } from './category-view-props';
/**
* Size view/edit section
*/
const SizeView = (props) => (
const SizeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<DataEntryGroup name="Storeys" collapsed={false}>

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Streetscape view/edit section
*/
const StreetscapeView = (props) => (
const StreetscapeView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul className="data-list">

View File

@ -4,6 +4,7 @@ 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 { CategoryViewProps } from './category-view-props';
const EnergyCategoryOptions = ["A", "B", "C", "D", "E", "F", "G"];
const BreeamRatingOptions = [
@ -17,12 +18,7 @@ const BreeamRatingOptions = [
/**
* Sustainability view/edit section
*/
const SustainabilityView = (props) => {
const dataEntryProps = {
mode: props.mode,
copy: props.copy,
onChange: props.onChange
};
const SustainabilityView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
@ -31,7 +27,9 @@ const SustainabilityView = (props) => {
value={props.building.sust_breeam_rating}
tooltip="(Building Research Establishment Environmental Assessment Method) May not be present for many buildings"
options={BreeamRatingOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title="DEC Rating"
@ -39,7 +37,9 @@ const SustainabilityView = (props) => {
value={props.building.sust_dec}
tooltip="(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use"
options={EnergyCategoryOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<SelectDataEntry
title="EPC Rating"
@ -48,7 +48,9 @@ const SustainabilityView = (props) => {
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"
options={EnergyCategoryOptions}
disabled={true}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title="Last significant retrofit"
@ -58,7 +60,9 @@ const SustainabilityView = (props) => {
step={1}
min={1086}
max={new Date().getFullYear()}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title="Expected lifespan for typology"
@ -67,7 +71,9 @@ const SustainabilityView = (props) => {
step={1}
min={1}
disabled={true}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
</Fragment>
);

View File

@ -2,11 +2,12 @@ import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry';
import { CategoryViewProps } from './category-view-props';
/**
* Team view/edit section
*/
const TeamView = (props) => (
const TeamView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>

View File

@ -4,6 +4,7 @@ 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 { CategoryViewProps } from './category-view-props';
const AttachmentFormOptions = [
"Detached",
@ -15,10 +16,7 @@ const AttachmentFormOptions = [
/**
* Type view/edit section
*/
const TypeView = (props) => {
const {mode, copy, onChange} = props;
const dataEntryProps = { mode, copy, onChange };
const TypeView: React.FunctionComponent<CategoryViewProps> = (props) => {
return (
<Fragment>
<SelectDataEntry
@ -27,7 +25,9 @@ const TypeView = (props) => {
value={props.building.building_attachment_form}
tooltip="We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)"
options={AttachmentFormOptions}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<NumericDataEntry
title="When did use change?"
@ -37,11 +37,18 @@ const TypeView = (props) => {
min={1086}
max={new Date().getFullYear()}
step={1}
{...dataEntryProps}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
/>
<DataEntry
title="Original building use"
slug=""
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"
value={undefined}
copy={props.copy}
mode={props.mode}
onChange={props.onChange}
disabled={true}
/>
</Fragment>

View File

@ -1,11 +1,12 @@
import React, { Fragment } from 'react';
import withCopyEdit from '../data-container';
import { CategoryViewProps } from './category-view-props';
/**
* Use view/edit section
*/
const UseView = (props) => (
const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
<Fragment>
<p className="data-intro">{props.intro}</p>
<ul>

View File

@ -5,7 +5,7 @@
order: 1;
padding: 0 0 2em;
background: #fff;
overflow-y: scroll;
overflow-y: auto;
height: 40%;
}
@ -34,6 +34,14 @@
text-decoration: none;
color: #222;
padding: 0.75rem 0.25rem 0.5rem 0;
z-index: 1000;
}
@media (min-width: 768px) {
.section-header {
position: sticky;
top: 0;
}
}
.section-header h2,
.section-header .icon-buttons {
@ -139,6 +147,11 @@
/**
* Data list sections
*/
.section-body {
margin-top: 0.75em;
padding: 0 0.75em;
}
.data-section .h3 {
margin: 0;
}
@ -162,9 +175,7 @@
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.data-section form {
padding: 0 0.75rem;
}
.data-list a {
color: #555;
}

View File

@ -36,4 +36,16 @@ function sanitiseURL(string){
return `${url_.protocol}//${url_.hostname}${url_.pathname || ''}${url_.search || ''}${url_.hash || ''}`
}
export { sanitiseURL }
function compareObjects(objA: object, objB: object): [object, object] {
const reverse = {}
const forward = {}
for (const [key, value] of Object.entries(objB)) {
if (objA[key] !== value) {
reverse[key] = objA[key];
forward[key] = value;
}
}
return [forward, reverse];
}
export { sanitiseURL, compareObjects }

View File

@ -9,6 +9,7 @@ import MultiEdit from './building/multi-edit';
import BuildingView from './building/building-view';
import ColouringMap from './map/map';
import { parse } from 'query-string';
import { Building } from './models/building';
interface MapAppRouteParams {
mode: 'view' | 'edit' | 'multi-edit';
@ -17,7 +18,7 @@ interface MapAppRouteParams {
}
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building: any;
building: Building;
building_like: boolean;
user: any;
revisionId: number;
@ -26,7 +27,7 @@ interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
interface MapAppState {
category: string;
revision_id: number;
building: any;
building: Building;
building_like: boolean;
}
@ -95,7 +96,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
}
selectBuilding(building) {
selectBuilding(building: Building) {
const mode = this.props.match.params.mode || 'view';
const category = this.props.match.params.category || 'age';
@ -218,8 +219,7 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
}
render() {
console.log(this.state.revision_id);
const mode = this.props.match.params.mode || 'basic';
const mode = this.props.match.params.mode;
let category = this.state.category || 'age';
@ -254,13 +254,13 @@ class MapApp extends React.Component<MapAppProps, MapAppState> {
/>
</Sidebar>
</Route>
<Route exact path="/(view|edit|multi-edit)">
<Redirect to="/view/categories" />
</Route>
<Route exact path="/:mode(view|edit|multi-edit)"
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
/>
</Switch>
<ColouringMap
building={this.state.building}
mode={mode}
mode={mode || 'basic'}
category={category}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}

View File

@ -1,6 +1,7 @@
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 '../../../node_modules/leaflet/dist/leaflet.css'
import './map.css'
@ -9,12 +10,12 @@ import { HelpIcon } from '../components/icons';
import Legend from './legend';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import { GeoJsonObject } from 'geojson';
import { Building } from '../models/building';
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
interface ColouringMapProps {
building: any;
building: Building;
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: string;
revision_id: number;
@ -32,7 +33,7 @@ interface ColouringMapState {
/**
* Map area
*/
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
static propTypes = { // TODO: generate propTypes from TS
building: PropTypes.object,
mode: PropTypes.string,

View File

@ -0,0 +1,12 @@
interface Building {
building_id: number;
geometry_id: number;
revision_id: number;
uprns: string[];
// TODO: add other fields as needed
}
export {
Building
};

View File

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

View File

@ -9,7 +9,7 @@
max-height: 100%;
border-radius: 0;
padding: 1.5em 2.5em 2.5em;
overflow-y: scroll;
overflow-y: auto;
}
.welcome-float.jumbotron {
background: #fff;