Fold edit-handling functionality into data-container

The intention is to wrap components using withCopyEdit and to use
a 'mode' prop to control view/edit/copy/paste mode.

Next steps to unpack form field data components and update in eaech
category container.
This commit is contained in:
Tom Russell 2019-08-21 22:20:31 +01:00
parent 5060628937
commit 51d8ac3ed7
3 changed files with 203 additions and 381 deletions

View File

@ -1,367 +1,8 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { Link, NavLink, Redirect } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Sidebar from './sidebar';
import BuildingNotFound from './building-not-found';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import Tooltip from '../components/tooltip'; import Tooltip from '../components/tooltip';
import { BackIcon, SaveIcon } from '../components/icons';
import CONFIG from './fields-config.json';
const BuildingEdit = (props) => {
if (!props.user){
return <Redirect to="/sign-up.html" />
}
const cat = props.match.params.cat;
const sections = CONFIG.filter((d) => d.slug === cat)
if (!props.building_id || sections.length !== 1){
return (<BuildingNotFound mode="edit" />);
}
const section = sections[0];
return (
<Sidebar>
<EditForm
key={props.building_id} // provide key so that React re-creates if props change
{...section}
{...props}
cat={cat}
/>
</Sidebar>
);
}
BuildingEdit.propTypes = {
user: PropTypes.object,
match: PropTypes.object,
building_id: PropTypes.number
}
class EditForm extends Component<any, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS
title: PropTypes.string,
slug: PropTypes.string,
cat: PropTypes.string,
help: PropTypes.string,
error: PropTypes.object,
like: PropTypes.bool,
building_like: PropTypes.bool,
selectBuilding: PropTypes.func,
building_id: PropTypes.number,
inactive: PropTypes.bool,
fields: PropTypes.array
};
constructor(props) {
super(props);
// create object and spread into state to avoid TS complaining about modifying readonly state
let fieldsObj = {};
for (const field of props.fields) {
fieldsObj[field.slug] = props[field.slug];
}
this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
copying: false,
keys_to_copy: {},
...fieldsObj
}
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.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);
}
/**
* Enter or exit "copying" state - allow user to select attributes to copy
*/
toggleCopying() {
this.setState({
copying: !this.state.copying
})
}
/**
* Keep track of data to copy (accumulate while in "copying" state)
*
* Note that we track keys only - values are already held in state
*
* @param {string} key
*/
toggleCopyAttribute(key) {
const keys = this.state.keys_to_copy;
if(this.state.keys_to_copy[key]){
delete keys[key];
} else {
keys[key] = true;
}
this.setState({
keys_to_copy: keys
})
}
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {DocumentEvent} 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();
}
this.setState({
[name]: value
});
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {DocumentEvent} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
this.setState({
[name]: value
});
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} key
* @param {*} value
*/
handleUpdate(key, value) {
this.setState({
[key]: value
});
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {DocumentEvent} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/api/buildings/${this.props.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})
} else {
this.props.selectBuilding(res);
this.setState({
likes_total: res.likes_total
})
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
fetch(`/api/buildings/${this.props.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() {
const match = this.props.cat === this.props.slug;
const cat = this.props.cat;
const buildingLike = this.props.building_like;
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state[key]
}
const data_string = JSON.stringify(values_to_copy);
return (
<section className={(this.props.inactive)? 'data-section inactive': 'data-section'}>
<header className={`section-header edit ${this.props.slug} background-${this.props.slug}`}>
<Link className="icon-button back" to="/edit/categories.html">
<BackIcon />
</Link>
<h2 className="h2">{this.props.title}</h2>
<nav className="icon-buttons">
{
(match && !this.props.inactive && this.props.slug !== 'like')?
this.state.copying?
<Fragment>
<NavLink
to={`/multi-edit/${this.props.cat}.html?data=${data_string}`}
className="icon-button copy">
Copy selected
</NavLink>
<a className="icon-button copy" onClick={this.toggleCopying}>Cancel</a>
</Fragment>
:
<a className="icon-button copy" onClick={this.toggleCopying}>Copy</a>
: null
}
{
(match && this.props.slug === 'like')?
<NavLink
to={`/multi-edit/${this.props.cat}.html`}
className="icon-button copy">
Copy
</NavLink>
: null
}
{
this.props.help && !this.state.copying?
<a className="icon-button help" title="Find out more" href={this.props.help}>
Info
</a>
: null
}
{
(match && !this.state.copying && !this.props.inactive && this.props.slug !== 'like')? // special-case for likes
<NavLink className="icon-button save" title="Save Changes"
onClick={this.handleSubmit}
to={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}>
Save
<SaveIcon />
</NavLink>
: null
}
</nav>
</header>
{
match? (
!this.props.inactive?
<form action={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
method="GET" onSubmit={this.handleSubmit}>
{
this.props.slug === 'location'?
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
: null
}
<ErrorBox msg={this.state.error} />
{
this.props.fields.map((props) => {
switch (props.type) {
case 'text':
return <TextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_list':
return <TextListInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_long':
return <LongTextInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'number':
return <NumberInput {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'year_estimator':
return <YearEstimator {...props} handleChange={this.handleChange}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'text_multi':
return <MultiTextInput {...props} handleChange={this.handleUpdate}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'checkbox':
return <CheckboxInput {...props} handleChange={this.handleCheck}
copying={this.state.copying}
toggleCopyAttribute={this.toggleCopyAttribute}
copy={this.state.keys_to_copy[props.slug]}
value={this.state[props.slug]} key={props.slug} cat={cat} />
case 'like':
return <LikeButton {...props} handleLike={this.handleLike}
building_like={buildingLike}
value={this.state[props.slug]} key={props.slug} cat={cat} />
default:
return null
}
})
}
<InfoBox msg="Colouring may take a few seconds - try zooming the map or hitting refresh after saving (we're working on making this smoother)." />
{
(this.props.slug === 'like')? // special-case for likes
null :
<div className="buttons-container">
<button type="submit" className="btn btn-primary">Save</button>
</div>
}
</form>
: <form>
<InfoBox msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`} />
</form>
) : null
}
</section>
)
}
}
const TextInput = (props) => ( const TextInput = (props) => (
<Fragment> <Fragment>
@ -598,9 +239,12 @@ class YearEstimator extends Component<any, any> { // TODO: add proper types
// TODO handle changes internally, reporting out date_year, date_upper, date_lower // TODO handle changes internally, reporting out date_year, date_upper, date_lower
render() { render() {
return ( return (
<NumberInput {...this.props} handleChange={this.props.handleChange} <NumberInput {...this.props}
handleChange={this.props.handleChange}
value={this.props.value} key={this.props.slug} /> value={this.props.value}
key={this.props.slug}
/>
) )
} }
} }

View File

@ -1,9 +1,12 @@
import React from 'react'; import React, { Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Redirect } from 'react-router-dom';
import BuildingNotFound from './building-not-found'; import BuildingNotFound from './building-not-found';
import ContainerHeader from './container-header'; import ContainerHeader from './container-header';
import Sidebar from './sidebar'; import Sidebar from './sidebar';
import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
/** /**
* Shared functionality for view/edit forms * Shared functionality for view/edit forms
@ -27,10 +30,27 @@ const withCopyEdit = (WrappedComponent) => {
constructor(props) { constructor(props) {
super(props); super(props);
// create object and spread into state to avoid TS complaining about modifying readonly state
let fieldsObj = {};
for (const field of props.fields) {
fieldsObj[field.slug] = props[field.slug];
}
this.state = { this.state = {
error: this.props.error || undefined,
like: this.props.like || undefined,
copying: false, copying: false,
values_to_copy: {} keys_to_copy: {},
...fieldsObj
}; };
this.handleChange = this.handleChange.bind(this);
this.handleCheck = this.handleCheck.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.toggleCopying = this.toggleCopying.bind(this);
this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this); this.toggleCopyAttribute = this.toggleCopyAttribute.bind(this);
} }
@ -49,21 +69,134 @@ const withCopyEdit = (WrappedComponent) => {
* *
* @param {string} key * @param {string} key
*/ */
toggleCopyAttribute(key) { toggleCopyAttribute(key: string) {
const value = this.props.building[key]; const keys = this.state.keys_to_copy;
const values = this.state.values_to_copy; if(this.state.keys_to_copy[key]){
if(Object.keys(this.state.values_to_copy).includes(key)){ delete keys[key];
delete values[key];
} else { } else {
values[key] = value; keys[key] = true;
} }
this.setState({ this.setState({
values_to_copy: values keys_to_copy: keys
}) })
} }
/**
* Handle changes on typical inputs
* - e.g. input[type=text], radio, select, textare
*
* @param {DocumentEvent} 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();
}
this.setState({
[name]: value
});
}
/**
* Handle changes on checkboxes
* - e.g. input[type=checkbox]
*
* @param {DocumentEvent} event
*/
handleCheck(event) {
const target = event.target;
const value = target.checked;
const name = target.name;
this.setState({
[name]: value
});
}
/**
* Handle update directly
* - e.g. as callback from MultiTextInput where we set a list of strings
*
* @param {String} key
* @param {*} value
*/
handleUpdate(key, value) {
this.setState({
[key]: value
});
}
/**
* Handle likes separately
* - like/love reaction is limited to set/unset per user
*
* @param {DocumentEvent} event
*/
handleLike(event) {
event.preventDefault();
const like = event.target.checked;
fetch(`/building/${this.props.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})
} else {
this.props.selectBuilding(res);
this.setState({
likes_total: res.likes_total
})
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
handleSubmit(event) {
event.preventDefault();
this.setState({error: undefined})
fetch(`/building/${this.props.building_id}.json`, {
method: 'POST',
body: JSON.stringify(this.state),
headers:{
'Content-Type': 'application/json'
},
credentials: 'same-origin'
}).then(
res => res.json()
).then(function(res){
if (res.error) {
this.setState({error: res.error})
} else {
this.props.selectBuilding(res);
}
}.bind(this)).catch(
(err) => this.setState({error: err})
);
}
render() { render() {
const data_string = JSON.stringify(this.state.values_to_copy); if (this.state.mode === 'edit' && !this.props.user){
return <Redirect to="/sign-up.html" />
}
const values_to_copy = {}
for (const key of Object.keys(this.state.keys_to_copy)) {
values_to_copy[key] = this.state[key]
}
const data_string = JSON.stringify(values_to_copy);
const copy = { const copy = {
copying: this.state.copying, copying: this.state.copying,
toggleCopying: this.toggleCopying, toggleCopying: this.toggleCopying,
@ -72,13 +205,54 @@ const withCopyEdit = (WrappedComponent) => {
} }
return this.props.building? return this.props.building?
<Sidebar> <Sidebar>
<section id={this.props.slug} className="data-section"> <section
id={this.props.slug}
className="data-section">
<form
action={`/edit/${this.props.slug}/building/${this.props.building_id}.html`}
method="POST"
onSubmit={this.handleSubmit}>
<ContainerHeader <ContainerHeader
{...this.props} {...this.props}
data_string={data_string} data_string={data_string}
copy={copy} copy={copy}
/> />
<WrappedComponent {...this.props} copy={copy} /> <ErrorBox msg={this.state.error} />
{
(this.props.mode === 'edit' && this.props.inactive)?
<InfoBox
msg={`We're not collecting data on ${this.props.title.toLowerCase()} yet - check back soon.`}
/>
: null
}
<WrappedComponent
{...this.props}
copy={copy}
handleChange={this.handleChange}
handleCheck={this.handleCheck}
handleLike={this.handleLike}
handleUpdate={this.handleUpdate}
/>
{
(this.props.mode === 'edit' && !this.props.inactive)?
<Fragment>
<InfoBox
msg="Colouring may take a few seconds - try zooming the map or hitting refresh after saving (we're working on making this smoother)." />
{
this.props.slug === 'like'? // special-case for likes
null :
<div className="buttons-container">
<button
type="submit"
className="btn btn-primary">
Save
</button>
</div>
}
</Fragment>
: null
}
</form>
</section> </section>
</Sidebar> </Sidebar>
: <BuildingNotFound mode="view" /> : <BuildingNotFound mode="view" />

View File

@ -1,10 +1,13 @@
import React from 'react'; import React, { Fragment } from 'react';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry'; import UPRNsDataEntry from '../data-components/uprns-data-entry';
import InfoBox from '../../components/info-box';
const LocationView = (props) => ( const LocationView = (props) => (
<Fragment>
<InfoBox msg="Text-based address fields are disabled at the moment. We're looking into how best to collect this data." />
<dl className="data-list"> <dl className="data-list">
<DataEntry <DataEntry
title="Building Name" title="Building Name"
@ -120,6 +123,7 @@ const LocationView = (props) => (
// "placeholder": 0 // "placeholder": 0
} }
</dl> </dl>
</Fragment>
) )
const LocationContainer = withCopyEdit(LocationView); const LocationContainer = withCopyEdit(LocationView);