Merge pull request #668 from mz8i/feature/667-list-entry-ui
Feature 667: improved list entry UI
This commit is contained in:
commit
2169efdf98
@ -39,7 +39,8 @@
|
|||||||
"camelcase": ["warn", {"ignoreDestructuring": true ,"properties": "never"}],
|
"camelcase": ["warn", {"ignoreDestructuring": true ,"properties": "never"}],
|
||||||
"eol-last": ["warn", "always"],
|
"eol-last": ["warn", "always"],
|
||||||
"no-multiple-empty-lines": ["warn", {"max": 1}],
|
"no-multiple-empty-lines": ["warn", {"max": 1}],
|
||||||
"prefer-const": "warn"
|
"prefer-const": "warn",
|
||||||
|
"react/prop-types": "off"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
"react": {
|
||||||
|
@ -64,8 +64,6 @@ export const DataEntryInput: React.FC<TextDataEntryInputProps & {value?: string}
|
|||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if(e.keyCode === 13) {
|
if(e.keyCode === 13) {
|
||||||
// prevent form submit on enter
|
|
||||||
e.preventDefault();
|
|
||||||
if(props.confirmOnEnter) {
|
if(props.confirmOnEnter) {
|
||||||
handleConfirm();
|
handleConfirm();
|
||||||
}
|
}
|
||||||
|
@ -1,105 +1,81 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import './multi-data-entry.css';
|
import './multi-data-entry.css';
|
||||||
|
|
||||||
import { BaseDataEntryProps } from '../data-entry';
|
import { BaseDataEntryProps } from '../data-entry';
|
||||||
import { DataEntryInput, TextDataEntryInputProps } from '../data-entry-input';
|
import { DataEntryInput, TextDataEntryInputProps } from '../data-entry-input';
|
||||||
import { DataTitleCopyable } from '../data-title';
|
import { DataTitleCopyable } from '../data-title';
|
||||||
|
import { CloseIcon, SaveIcon } from '../../../components/icons';
|
||||||
|
|
||||||
interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps {
|
interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps {
|
||||||
value: string[];
|
value: string[];
|
||||||
copyable: boolean;
|
copyable?: boolean;
|
||||||
editableEntries: boolean;
|
editableEntries?: boolean;
|
||||||
confirmOnEnter: boolean;
|
confirmOnEnter?: boolean;
|
||||||
|
|
||||||
addOnAutofillSelect: boolean;
|
|
||||||
acceptAutofillValuesOnly: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MultiDataEntryState {
|
export const MultiDataEntry: React.FC<MultiDataEntryProps> = ({
|
||||||
newValue: string;
|
editableEntries = false,
|
||||||
}
|
copyable = false,
|
||||||
|
confirmOnEnter = true,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [newValue, setNewValue] = useState<string>();
|
||||||
|
|
||||||
class MultiDataEntry extends Component<MultiDataEntryProps, MultiDataEntryState> {
|
const values = useMemo(() => props.value ?? [], [props.value]);
|
||||||
|
|
||||||
static defaultProps = {
|
const edit = useCallback((index: number, value: string) => {
|
||||||
editableEntries: false,
|
const editedValues = values.slice();
|
||||||
copyable: false,
|
editedValues.splice(index, 1, value);
|
||||||
confirmOnEnter: true,
|
props.onChange(props.slug, editedValues);
|
||||||
addOnAutofillSelect: false,
|
}, [values, props.onChange, props.slug]);
|
||||||
acceptAutofillValuesOnly: false
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
/* accept a newValue parameter to handle cases where the value is set and submitted at the same time
|
||||||
super(props);
|
* (like with autofill select enabled) - but otherwise use the current newValue saved in state
|
||||||
this.state = {
|
*/
|
||||||
newValue: null
|
const addNew = useCallback((newValueArg?: string) => {
|
||||||
};
|
const val = newValueArg ?? newValue;
|
||||||
|
|
||||||
this.setNewValue = this.setNewValue.bind(this);
|
|
||||||
this.edit = this.edit.bind(this);
|
|
||||||
this.addNew = this.addNew.bind(this);
|
|
||||||
this.remove = this.remove.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
getValues() {
|
|
||||||
return this.props.value == undefined ? [] : this.props.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
cloneValues() {
|
|
||||||
return this.getValues().slice();
|
|
||||||
}
|
|
||||||
|
|
||||||
setNewValue(value: string) {
|
|
||||||
this.setState({newValue: value});
|
|
||||||
}
|
|
||||||
|
|
||||||
edit(index: number, value: string) {
|
|
||||||
let values = this.cloneValues();
|
|
||||||
values.splice(index, 1, value);
|
|
||||||
this.props.onChange(this.props.slug, values);
|
|
||||||
}
|
|
||||||
addNew(newValue?: string) {
|
|
||||||
// accept a newValue parameter to handle cases where the value is set and submitted at the same time
|
|
||||||
// (like with autofill select enabled) - but otherwise use the current newValue saved in state
|
|
||||||
const val = newValue ?? this.state.newValue;
|
|
||||||
if(val == undefined) return;
|
if(val == undefined) return;
|
||||||
const values = this.cloneValues().concat(val);
|
|
||||||
this.setState({newValue: null});
|
|
||||||
this.props.onChange(this.props.slug, values);
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(index: number){
|
const editedValues = values.slice().concat(val);
|
||||||
const values = this.cloneValues();
|
|
||||||
values.splice(index, 1);
|
setNewValue(null);
|
||||||
this.props.onChange(this.props.slug, values);
|
props.onChange(props.slug, editedValues);
|
||||||
}
|
}, [newValue, values, props.onChange, props.slug]);
|
||||||
|
|
||||||
|
const clearNew = useCallback(() => {
|
||||||
|
setNewValue(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = useCallback((index: number) => {
|
||||||
|
const editedValues = values.slice();
|
||||||
|
editedValues.splice(index, 1);
|
||||||
|
|
||||||
|
props.onChange(props.slug, editedValues);
|
||||||
|
}, [values, props.onChange, props.slug]);
|
||||||
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const values = this.getValues();
|
|
||||||
const props = this.props;
|
|
||||||
const isEditing = props.mode === 'edit';
|
const isEditing = props.mode === 'edit';
|
||||||
const isDisabled = !isEditing || props.disabled;
|
const isDisabled = !isEditing || props.disabled;
|
||||||
const slugWithModifier = props.slug + (props.slugModifier ?? '');
|
const slugWithModifier = props.slug + (props.slugModifier ?? '');
|
||||||
|
|
||||||
return <Fragment>
|
return (<>
|
||||||
<DataTitleCopyable
|
<DataTitleCopyable
|
||||||
slug={props.slug}
|
slug={props.slug}
|
||||||
slugModifier={props.slugModifier}
|
slugModifier={props.slugModifier}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
tooltip={props.tooltip}
|
tooltip={props.tooltip}
|
||||||
disabled={props.disabled || props.value == undefined || props.value.length === 0}
|
disabled={props.disabled || props.value == undefined || props.value.length === 0}
|
||||||
copy={props.copyable ? props.copy : undefined}
|
copy={copyable ? props.copy : undefined}
|
||||||
/>
|
/>
|
||||||
<div id={`${props.slug}-wrapper`}>
|
<div id={`${props.slug}-wrapper`}>
|
||||||
<ul className="data-link-list">
|
|
||||||
{
|
{
|
||||||
values.length === 0 && !isEditing &&
|
values.length === 0 && !isEditing &&
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input className="form-control no-entries" type="text" value="No entries" disabled={true} />
|
<input className="form-control no-entries" type="text" value="No entries" disabled={true} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<ul className="data-entry-list">
|
||||||
{
|
{
|
||||||
values.map((val, i) => (
|
values.map((val, i) => (
|
||||||
<li className="input-group" key={i /* i as key prevents input component recreation on edit */}>
|
<li className="input-group" key={i /* i as key prevents input component recreation on edit */}>
|
||||||
@ -108,8 +84,8 @@ class MultiDataEntry extends Component<MultiDataEntryProps, MultiDataEntryState>
|
|||||||
name={`${slugWithModifier}-${i}`}
|
name={`${slugWithModifier}-${i}`}
|
||||||
id={`${slugWithModifier}-${i}`}
|
id={`${slugWithModifier}-${i}`}
|
||||||
value={val}
|
value={val}
|
||||||
disabled={!props.editableEntries || isDisabled}
|
disabled={!editableEntries || isDisabled}
|
||||||
onChange={(key, val) => this.edit(i, val)}
|
onChange={(_key, val) => edit(i, val)}
|
||||||
|
|
||||||
maxLength={props.maxLength}
|
maxLength={props.maxLength}
|
||||||
isUrl={props.isUrl}
|
isUrl={props.isUrl}
|
||||||
@ -121,51 +97,57 @@ class MultiDataEntry extends Component<MultiDataEntryProps, MultiDataEntryState>
|
|||||||
{
|
{
|
||||||
!isDisabled &&
|
!isDisabled &&
|
||||||
<div className="input-group-append">
|
<div className="input-group-append">
|
||||||
<button type="button" onClick={e => this.remove(i)}
|
<button type="button" onClick={() => remove(i)}
|
||||||
title="Remove"
|
title="Remove"
|
||||||
data-index={i} className="btn btn-outline-dark">✕</button>
|
data-index={i} className="btn btn-outline-dark data-entry-list-button"><CloseIcon /></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
|
||||||
{
|
{
|
||||||
!isDisabled &&
|
!isDisabled &&
|
||||||
<div className="input-group">
|
<li className="input-group">
|
||||||
<DataEntryInput
|
<DataEntryInput
|
||||||
slug={props.slug}
|
slug={props.slug}
|
||||||
name={slugWithModifier}
|
name={slugWithModifier}
|
||||||
id={slugWithModifier}
|
id={slugWithModifier}
|
||||||
value={this.state.newValue}
|
value={newValue}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
required={props.required && values.length < 1}
|
required={props.required && values.length < 1}
|
||||||
onChange={(key, val) => this.setNewValue(val)}
|
onChange={(_key, val) => setNewValue(val)}
|
||||||
onConfirm={(key, val) => this.addNew(val)}
|
onConfirm={(_key, val) => addNew(val)}
|
||||||
|
|
||||||
maxLength={props.maxLength}
|
maxLength={props.maxLength}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
isUrl={props.isUrl}
|
isUrl={props.isUrl}
|
||||||
valueTransform={props.valueTransform}
|
valueTransform={props.valueTransform}
|
||||||
confirmOnEnter={props.confirmOnEnter}
|
confirmOnEnter={confirmOnEnter}
|
||||||
|
|
||||||
autofill={props.autofill}
|
autofill={props.autofill}
|
||||||
showAllOptionsOnEmpty={props.showAllOptionsOnEmpty}
|
showAllOptionsOnEmpty={props.showAllOptionsOnEmpty}
|
||||||
confirmOnAutofillSelect={true}
|
confirmOnAutofillSelect={true}
|
||||||
/>
|
/>
|
||||||
|
{
|
||||||
|
newValue != undefined &&
|
||||||
|
<>
|
||||||
<div className="input-group-append">
|
<div className="input-group-append">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
className="btn btn-outline-dark"
|
className="btn btn-primary data-entry-list-button"
|
||||||
title="Add to list"
|
title="Confirm new value"
|
||||||
onClick={() => this.addNew()}
|
onClick={() => addNew()}
|
||||||
disabled={this.state.newValue == undefined}
|
><SaveIcon /></button>
|
||||||
>+</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="input-group-append">
|
||||||
|
<button type="button" onClick={() => clearNew()}
|
||||||
|
title="Clear new value"
|
||||||
|
className="btn btn-warning data-entry-list-button"><CloseIcon /></button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>;
|
</>)
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default MultiDataEntry;
|
|
||||||
|
@ -364,6 +364,8 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
|
|||||||
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
|
action={`/edit/${this.props.cat}/${this.props.building.building_id}`}
|
||||||
method="POST"
|
method="POST"
|
||||||
onSubmit={this.handleSubmit}>
|
onSubmit={this.handleSubmit}>
|
||||||
|
{/* this disabled button prevents form submission on enter - see https://stackoverflow.com/a/51507806/1478817 */}
|
||||||
|
<button type="submit" disabled style={{display: 'none'}}></button>
|
||||||
{
|
{
|
||||||
(this.props.mode === 'edit' && !this.props.inactive) ?
|
(this.props.mode === 'edit' && !this.props.inactive) ?
|
||||||
<div className='edit-bar'>
|
<div className='edit-bar'>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
|
|
||||||
import { dataFields } from '../../config/data-fields-config';
|
import { dataFields } from '../../config/data-fields-config';
|
||||||
import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
|
import { MultiDataEntry } from '../data-components/multi-data-entry/multi-data-entry';
|
||||||
import NumericDataEntry from '../data-components/numeric-data-entry';
|
import NumericDataEntry from '../data-components/numeric-data-entry';
|
||||||
import SelectDataEntry from '../data-components/select-data-entry';
|
import SelectDataEntry from '../data-components/select-data-entry';
|
||||||
import TextboxDataEntry from '../data-components/textbox-data-entry';
|
import TextboxDataEntry from '../data-components/textbox-data-entry';
|
||||||
|
@ -6,10 +6,11 @@ import { FieldRow } from '../../data-components/field-row';
|
|||||||
import DataEntry, { BaseDataEntryProps } from '../../data-components/data-entry';
|
import DataEntry, { BaseDataEntryProps } from '../../data-components/data-entry';
|
||||||
import { dataFields } from '../../../config/data-fields-config';
|
import { dataFields } from '../../../config/data-fields-config';
|
||||||
import SelectDataEntry from '../../data-components/select-data-entry';
|
import SelectDataEntry from '../../data-components/select-data-entry';
|
||||||
import MultiDataEntry from '../../data-components/multi-data-entry/multi-data-entry';
|
import { MultiDataEntry } from '../../data-components/multi-data-entry/multi-data-entry';
|
||||||
import { NumberRangeDataEntry } from './number-range-data-entry';
|
import { NumberRangeDataEntry } from './number-range-data-entry';
|
||||||
|
|
||||||
import './dynamics-data-entry.css';
|
import './dynamics-data-entry.css';
|
||||||
|
import { CloseIcon } from '../../../components/icons';
|
||||||
|
|
||||||
type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]);
|
type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]);
|
||||||
|
|
||||||
@ -211,7 +212,7 @@ export const DynamicsDataEntry: React.FC<DynamicsDataEntryProps> = (props) => {
|
|||||||
<label>Please supply sources for any edits of existing records</label>
|
<label>Please supply sources for any edits of existing records</label>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
<ul className="data-link-list">
|
<ul className="data-entry-list">
|
||||||
{
|
{
|
||||||
values.length === 0 &&
|
values.length === 0 &&
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@ -229,7 +230,7 @@ export const DynamicsDataEntry: React.FC<DynamicsDataEntryProps> = (props) => {
|
|||||||
title="Delete Record"
|
title="Delete Record"
|
||||||
onClick={() => remove(id)}
|
onClick={() => remove(id)}
|
||||||
data-index={id}
|
data-index={id}
|
||||||
>x</button>
|
><CloseIcon /></button>
|
||||||
}
|
}
|
||||||
<DynamicsDataRow
|
<DynamicsDataRow
|
||||||
value={pastBuilding}
|
value={pastBuilding}
|
||||||
|
@ -3,7 +3,7 @@ import React, { Fragment } from 'react';
|
|||||||
import InfoBox from '../../components/info-box';
|
import InfoBox from '../../components/info-box';
|
||||||
import { dataFields } from '../../config/data-fields-config';
|
import { dataFields } from '../../config/data-fields-config';
|
||||||
import DataEntry from '../data-components/data-entry';
|
import DataEntry from '../data-components/data-entry';
|
||||||
import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
|
import { MultiDataEntry } from '../data-components/multi-data-entry/multi-data-entry';
|
||||||
import withCopyEdit from '../data-container';
|
import withCopyEdit from '../data-container';
|
||||||
|
|
||||||
import { CategoryViewProps } from './category-view-props';
|
import { CategoryViewProps } from './category-view-props';
|
||||||
@ -28,7 +28,6 @@ const UseView: React.FunctionComponent<CategoryViewProps> = (props) => (
|
|||||||
copyable={true}
|
copyable={true}
|
||||||
autofill={true}
|
autofill={true}
|
||||||
showAllOptionsOnEmpty={true}
|
showAllOptionsOnEmpty={true}
|
||||||
addOnAutofillSelect={true}
|
|
||||||
/>
|
/>
|
||||||
<Verification
|
<Verification
|
||||||
slug="current_landuse_group"
|
slug="current_landuse_group"
|
||||||
|
@ -162,13 +162,17 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
.data-section .data-link-list {
|
.data-section .data-entry-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
.data-link-list li {
|
.data-entry-list li {
|
||||||
border-color: #6c757d;
|
border-color: #6c757d;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-entry-list-button {
|
||||||
|
width: 2.5em;
|
||||||
|
}
|
@ -43,9 +43,7 @@ label {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
form .btn {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.buttons-container.btn-center {
|
.buttons-container.btn-center {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
Loading…
Reference in New Issue
Block a user