From aafb81a17b45067d7a00b4b078ecfe70483a82d2 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 6 Jan 2020 16:15:36 +0000 Subject: [PATCH 01/46] Add autofill backend --- app/src/api/api.ts | 2 ++ app/src/api/controllers/autofillController.ts | 14 +++++++++++ app/src/api/services/autofill.ts | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 app/src/api/controllers/autofillController.ts create mode 100644 app/src/api/services/autofill.ts diff --git a/app/src/api/api.ts b/app/src/api/api.ts index 59471b6b..62e683b1 100644 --- a/app/src/api/api.ts +++ b/app/src/api/api.ts @@ -1,6 +1,7 @@ import bodyParser from 'body-parser'; import express from 'express'; +import autofillController from './controllers/autofillController'; import * as editHistoryController from './controllers/editHistoryController'; import buildingsRouter from './routes/buildingsRouter'; import extractsRouter from './routes/extractsRouter'; @@ -19,6 +20,7 @@ server.use('/users', usersRouter); server.use('/extracts', extractsRouter); server.get('/history', editHistoryController.getGlobalEditHistory); +server.get('/autofill', autofillController.getAutofillOptions); // POST user auth server.post('/login', function (req, res) { diff --git a/app/src/api/controllers/autofillController.ts b/app/src/api/controllers/autofillController.ts new file mode 100644 index 00000000..527979d1 --- /dev/null +++ b/app/src/api/controllers/autofillController.ts @@ -0,0 +1,14 @@ +import asyncController from '../routes/asyncController'; +import * as autofillService from '../services/autofill'; + +const getAutofillOptions = asyncController(async (req, res) => { + const { field_name, field_value } = req.query; + + const options = await autofillService.getAutofillOptions(field_name, field_value); + + res.send(options); +}); + +export default { + getAutofillOptions +}; diff --git a/app/src/api/services/autofill.ts b/app/src/api/services/autofill.ts new file mode 100644 index 00000000..8c296a27 --- /dev/null +++ b/app/src/api/services/autofill.ts @@ -0,0 +1,25 @@ +import db from '../../db'; + +const autofillFunctionMap = { + current_landuse_class: getLanduseClassOptions +}; + +function getLanduseClassOptions(value: string) { + return db.manyOrNone(` + SELECT landuse_id AS id, description as value, similarity(description, $1) AS similarity + FROM reference_tables.building_landuse_class + WHERE description % $1 + ORDER BY similarity DESC, description + `, [value] + ); +} + +export function getAutofillOptions(fieldName: string, fieldValue: any) { + const optionsFn = autofillFunctionMap[fieldName]; + + if (optionsFn == undefined) { + throw new Error(`Autofill options not available for field '${fieldName}'`); + } + + return optionsFn(fieldValue); +} From f67323fc164eb8fe05e9964477d341c6b503c29e Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 6 Jan 2020 16:21:44 +0000 Subject: [PATCH 02/46] Reuse single data entry in multi --- .../data-components/data-entry-input.tsx | 30 ++++++ .../building/data-components/data-entry.tsx | 27 ++---- .../data-components/multi-data-entry.tsx | 95 ++++++++++++------- 3 files changed, 99 insertions(+), 53 deletions(-) create mode 100644 app/src/frontend/building/data-components/data-entry-input.tsx diff --git a/app/src/frontend/building/data-components/data-entry-input.tsx b/app/src/frontend/building/data-components/data-entry-input.tsx new file mode 100644 index 00000000..09c02b90 --- /dev/null +++ b/app/src/frontend/building/data-components/data-entry-input.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +export interface TextDataEntryInputProps { + slug: string; + maxLength?: number; + disabled?: boolean; + placeholder?: string; + valueTransform?: (val: string) => string; + onChange?: (key: string, val: any) => void; +} + +export const TextDataEntryInput: React.FC = props => { + return ( + { + const transform = props.valueTransform || (x => x); + const val = e.target.value === '' ? + null : + transform(e.target.value); + props.onChange(props.slug, val); + }} + /> + ); +}; diff --git a/app/src/frontend/building/data-components/data-entry.tsx b/app/src/frontend/building/data-components/data-entry.tsx index 1e1bfca0..071d17c6 100644 --- a/app/src/frontend/building/data-components/data-entry.tsx +++ b/app/src/frontend/building/data-components/data-entry.tsx @@ -2,6 +2,7 @@ import React, { Fragment } from 'react'; import { CopyProps } from '../data-containers/category-view-props'; +import { TextDataEntryInput, TextDataEntryInputProps } from './data-entry-input'; import { DataTitleCopyable } from './data-title'; interface BaseDataEntryProps { @@ -14,14 +15,11 @@ interface BaseDataEntryProps { onChange?: (key: string, value: any) => void; } -interface DataEntryProps extends BaseDataEntryProps { +interface DataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps { value?: string; - maxLength?: number; - placeholder?: string; - valueTransform?: (string) => string; } -const DataEntry: React.FunctionComponent = (props) => { +const DataEntry: React.FC = (props) => { return ( = (props) => { disabled={props.disabled || props.value == undefined || props.value == ''} copy={props.copy} /> - { - const transform = props.valueTransform || (x => x); - const val = e.target.value === '' ? - null : - transform(e.target.value); - props.onChange(props.slug, val); - }} + valueTransform={props.valueTransform} /> ); diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry.tsx index e51e7d8c..ce4e0644 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry.tsx @@ -3,49 +3,57 @@ import React, { Component, Fragment } from 'react'; import { sanitiseURL } from '../../helpers'; import { BaseDataEntryProps } from './data-entry'; +import { TextDataEntryInput, TextDataEntryInputProps } from './data-entry-input'; import { DataTitleCopyable } from './data-title'; -interface MultiDataEntryProps extends BaseDataEntryProps { +interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps { value: string[]; - placeholder: string; } -class MultiDataEntry extends Component { +interface MultiDataEntryState { + newValue: string; +} + +class MultiDataEntry extends Component { constructor(props) { super(props); + this.state = { + newValue: '' + }; + + this.setNewValue = this.setNewValue.bind(this); this.edit = this.edit.bind(this); - this.add = this.add.bind(this); + this.addNew = this.addNew.bind(this); this.remove = this.remove.bind(this); this.getValues = this.getValues.bind(this); } getValues() { - return (this.props.value && this.props.value.length)? this.props.value : [null]; + return this.props.value == undefined ? [] : this.props.value; } - edit(event) { - const editIndex = +event.target.dataset.index; - const editItem = event.target.value; - const oldValues = this.getValues(); - const values = oldValues.map((item, i) => { - return i === editIndex ? editItem : item; - }); + setNewValue(value: string) { + this.setState({newValue: value}); + } + + edit(index: number, value: string) { + let values = this.getValues(); + values.splice(index, 1, value); this.props.onChange(this.props.slug, values); } - - add(event) { + addNew(event) { event.preventDefault(); - const values = this.getValues().concat(''); + const values = this.getValues().concat(this.state.newValue); + this.setState({newValue: ''}); this.props.onChange(this.props.slug, values); } remove(event){ const removeIndex = +event.target.dataset.index; - const values = this.getValues().filter((_, i) => { - return i !== removeIndex; - }); + const values = this.getValues(); + values.splice(removeIndex, 1); this.props.onChange(this.props.slug, values); } @@ -74,30 +82,45 @@ class MultiDataEntry extends Component { } :'\u00A0' - : values.map((item, i) => ( -
- + {values.map((val, i) => ( +
+ this.edit(i, val)} + + maxLength={props.maxLength} + placeholder={props.placeholder} + valueTransform={props.valueTransform} + /> +
+ +
+
+ ))} +
+ this.setNewValue(val)} + + maxLength={props.maxLength} + placeholder={props.placeholder} + valueTransform={props.valueTransform} />
- +
- )) + } - ; } } From cf5906dc892a25db6b70b1ec3d4cc052b32fea9a Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 6 Jan 2020 16:36:19 +0000 Subject: [PATCH 03/46] Reorganise multi data entry --- .../data-components/multi-data-entry.tsx | 92 +++++++++---------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry.tsx index ce4e0644..3e3b25b1 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry.tsx @@ -50,16 +50,16 @@ class MultiDataEntry extends Component this.props.onChange(this.props.slug, values); } - remove(event){ - const removeIndex = +event.target.dataset.index; + remove(index: number){ const values = this.getValues(); - values.splice(removeIndex, 1); + values.splice(index, 1); this.props.onChange(this.props.slug, values); } render() { const values = this.getValues(); const props = this.props; + const isDisabled = props.mode === 'view' || props.disabled; return tooltip={props.tooltip} disabled={props.disabled || props.value == undefined || props.value.length === 0} /> +
    { - (props.mode === 'view')? - (props.value && props.value.length)? -
      - { - props.value.map((item, index) => { - return
    • - {item} -
    • ; - }) - } -
    - :'\u00A0' - : <> - {values.map((val, i) => ( -
    - this.edit(i, val)} - - maxLength={props.maxLength} - placeholder={props.placeholder} - valueTransform={props.valueTransform} - /> -
    - -
    -
    - ))} -
    + values.length === 0 && +
    No elements
    + } + { + values.map((val, i) => ( +
  • this.setNewValue(val)} + slug={`${props.slug}-${i}`} + value={val} + disabled={isDisabled} + onChange={(key, val) => this.edit(i, val)} maxLength={props.maxLength} placeholder={props.placeholder} valueTransform={props.valueTransform} /> -
    - -
    + { + !isDisabled && +
    + +
    + } +
  • + )) + } +
+ { + !isDisabled && +
+ this.setNewValue(val)} + + maxLength={props.maxLength} + placeholder={props.placeholder} + valueTransform={props.valueTransform} + /> +
+
- +
}
; } From 4a098ad57c9d095e9607b148b0259363941f04ed Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 6 Jan 2020 19:48:47 +0000 Subject: [PATCH 04/46] Add autofill dropdown to data entry --- app/src/api/controllers/autofillController.ts | 2 +- app/src/api/services/autofill.ts | 2 +- .../autofill/autofillDropdown.css | 9 +++ .../autofill/autofillDropdown.tsx | 59 ++++++++++++++++++ .../data-components/data-entry-input.tsx | 60 +++++++++++++------ .../building/data-components/data-entry.tsx | 4 +- .../data-components/multi-data-entry.tsx | 14 ++--- .../frontend/building/data-containers/use.tsx | 9 +-- 8 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 app/src/frontend/building/data-components/autofill/autofillDropdown.css create mode 100644 app/src/frontend/building/data-components/autofill/autofillDropdown.tsx diff --git a/app/src/api/controllers/autofillController.ts b/app/src/api/controllers/autofillController.ts index 527979d1..2514a17d 100644 --- a/app/src/api/controllers/autofillController.ts +++ b/app/src/api/controllers/autofillController.ts @@ -6,7 +6,7 @@ const getAutofillOptions = asyncController(async (req, res) => { const options = await autofillService.getAutofillOptions(field_name, field_value); - res.send(options); + res.send({ options: options }); }); export default { diff --git a/app/src/api/services/autofill.ts b/app/src/api/services/autofill.ts index 8c296a27..41e78174 100644 --- a/app/src/api/services/autofill.ts +++ b/app/src/api/services/autofill.ts @@ -7,7 +7,7 @@ const autofillFunctionMap = { function getLanduseClassOptions(value: string) { return db.manyOrNone(` SELECT landuse_id AS id, description as value, similarity(description, $1) AS similarity - FROM reference_tables.building_landuse_class + FROM reference_tables.buildings_landuse_class WHERE description % $1 ORDER BY similarity DESC, description `, [value] diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.css b/app/src/frontend/building/data-components/autofill/autofillDropdown.css new file mode 100644 index 00000000..4257d37c --- /dev/null +++ b/app/src/frontend/building/data-components/autofill/autofillDropdown.css @@ -0,0 +1,9 @@ +.autofill-dropdown { + position: absolute; + z-index: 1000; + background-color: white; + max-height: 5em; + width: 100%; + overflow-y: scroll; + overflow-x: hidden; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx new file mode 100644 index 00000000..ac0d500c --- /dev/null +++ b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; + +import './autofillDropdown.css'; + +import { apiGet } from '../../../apiHelpers'; + +interface AutofillDropdownProps { + fieldName: string; + fieldValue: string; + editing: boolean; + onSelect: (val: string) => void; + onClose: () => void; +} + +interface AutofillOption { + id: string; + value: string; + similarity: number; +} + +export const AutofillDropdown: React.FC = props => { + const [options, setOptions] = useState(null); + + useEffect(() => { + const doAsync = async () => { + if (!props.editing || props.fieldValue === '') return setOptions(null); + + const url = `/api/autofill?field_name=${props.fieldName}&field_value=${props.fieldValue}`; + const { options } = await apiGet(url); + + if (!props.editing) return; + + setOptions(options); + }; + + doAsync(); + }, [props.editing, props.fieldName, props.fieldValue]); + + if (!props.editing || options == undefined) return null; + + return ( +
+ { + options.map(option => +
/* prevent input blur */ e.preventDefault()} + onClick={e => { + props.onSelect(option.value); + // close dropdown manually rather than through input blur + props.onClose(); + }} + > + {option.value} ({option.id}) +
) + } +
+ ); + +}; diff --git a/app/src/frontend/building/data-components/data-entry-input.tsx b/app/src/frontend/building/data-components/data-entry-input.tsx index 09c02b90..56a0a8e6 100644 --- a/app/src/frontend/building/data-components/data-entry-input.tsx +++ b/app/src/frontend/building/data-components/data-entry-input.tsx @@ -1,30 +1,54 @@ -import React from 'react'; +import React, { useState } from 'react'; + +import { AutofillDropdown } from './autofill/autofillDropdown'; export interface TextDataEntryInputProps { slug: string; + name?: string; + onChange?: (key: string, val: any) => void; + maxLength?: number; disabled?: boolean; placeholder?: string; valueTransform?: (val: string) => string; - onChange?: (key: string, val: any) => void; + autofill?: boolean; } -export const TextDataEntryInput: React.FC = props => { +export const DataEntryInput: React.FC = props => { + const [isEditing, setEditing] = useState(false); + + const handleChange = (value: string) => { + console.log(value); + const transform = props.valueTransform || (x => x); + const transformedValue = value === '' ? + null : + transform(value); + props.onChange(props.slug, transformedValue); + }; + return ( - { - const transform = props.valueTransform || (x => x); - const val = e.target.value === '' ? - null : - transform(e.target.value); - props.onChange(props.slug, val); - }} - /> +
setEditing(true)} + onBlur={e => setEditing(false)} + > + handleChange(e.target.value)} + /> + { + props.autofill && + handleChange(value)} + onClose={() => setEditing(false)} + fieldName={props.slug} + fieldValue={props.value} + /> + } +
); }; diff --git a/app/src/frontend/building/data-components/data-entry.tsx b/app/src/frontend/building/data-components/data-entry.tsx index 071d17c6..7c45bca0 100644 --- a/app/src/frontend/building/data-components/data-entry.tsx +++ b/app/src/frontend/building/data-components/data-entry.tsx @@ -2,7 +2,7 @@ import React, { Fragment } from 'react'; import { CopyProps } from '../data-containers/category-view-props'; -import { TextDataEntryInput, TextDataEntryInputProps } from './data-entry-input'; +import { DataEntryInput, TextDataEntryInputProps } from './data-entry-input'; import { DataTitleCopyable } from './data-title'; interface BaseDataEntryProps { @@ -29,7 +29,7 @@ const DataEntry: React.FC = (props) => { disabled={props.disabled || props.value == undefined || props.value == ''} copy={props.copy} /> - { values.map((val, i) => (
  • - this.edit(i, val)} @@ -84,6 +82,7 @@ class MultiDataEntry extends Component maxLength={props.maxLength} placeholder={props.placeholder} valueTransform={props.valueTransform} + autofill={props.autofill} /> { !isDisabled && @@ -100,8 +99,8 @@ class MultiDataEntry extends Component { !isDisabled &&
    - this.setNewValue(val)} @@ -109,6 +108,7 @@ class MultiDataEntry extends Component maxLength={props.maxLength} placeholder={props.placeholder} valueTransform={props.valueTransform} + autofill={props.autofill} />
    + className="btn btn-outline-dark">+
    } From d9c0cca31be80b164d4bfb0c5e9890fa2c4b32c6 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Tue, 7 Jan 2020 18:35:30 +0000 Subject: [PATCH 08/46] Fix frontend detection of edits in arrays --- .../building/data-components/multi-data-entry.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry.tsx index 6a0a91a9..9932b803 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry.tsx @@ -32,24 +32,28 @@ class MultiDataEntry extends Component 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.getValues(); + let values = this.cloneValues(); values.splice(index, 1, value); this.props.onChange(this.props.slug, values); } addNew(event) { event.preventDefault(); - const values = this.getValues().concat(this.state.newValue); + const values = this.cloneValues().concat(this.state.newValue); this.setState({newValue: ''}); this.props.onChange(this.props.slug, values); } remove(index: number){ - const values = this.getValues(); + const values = this.cloneValues(); values.splice(index, 1); this.props.onChange(this.props.slug, values); } From 6ee4432fd5538a57ff1f26bcca39ec0684762a9f Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Tue, 7 Jan 2020 19:06:42 +0000 Subject: [PATCH 09/46] Improve multi-edit, autofill usability --- .../autofill/autofillDropdown.tsx | 7 ++++--- .../data-components/data-entry-input.tsx | 16 ++++++++++------ .../data-components/multi-data-entry.tsx | 8 ++++++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx index 6fadcd33..60f258e8 100644 --- a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx +++ b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx @@ -27,14 +27,14 @@ export const AutofillDropdown: React.FC = props => { useEffect(() => { const doAsync = async () => { - if (!props.editing || props.fieldValue === '') return setOptions(null); + if (!props.editing || (props.fieldValue === '' && options == null)) return setOptions(null); const url = `/api/autofill?field_name=${props.fieldName}&field_value=${props.fieldValue}`; - const { options } = await apiGet(url); + const { options: newOptions } = await apiGet(url); if (!props.editing) return; - setOptions(options); + setOptions(newOptions); }; doAsync(); @@ -47,6 +47,7 @@ export const AutofillDropdown: React.FC = props => { { options.map(option =>
    /* prevent input blur */ e.preventDefault()} onClick={e => { props.onSelect(option.value); diff --git a/app/src/frontend/building/data-components/data-entry-input.tsx b/app/src/frontend/building/data-components/data-entry-input.tsx index 56a0a8e6..f6b47428 100644 --- a/app/src/frontend/building/data-components/data-entry-input.tsx +++ b/app/src/frontend/building/data-components/data-entry-input.tsx @@ -5,6 +5,7 @@ import { AutofillDropdown } from './autofill/autofillDropdown'; export interface TextDataEntryInputProps { slug: string; name?: string; + id?: string; onChange?: (key: string, val: any) => void; maxLength?: number; @@ -16,6 +17,8 @@ export interface TextDataEntryInputProps { export const DataEntryInput: React.FC = props => { const [isEditing, setEditing] = useState(false); + const nameAttr = props.name || props.slug; + const idAttr = props.id || props.slug; const handleChange = (value: string) => { console.log(value); @@ -27,17 +30,18 @@ export const DataEntryInput: React.FC setEditing(true)} - onBlur={e => setEditing(false)} - > + <> handleChange(e.target.value)} + onInput={e => setEditing(true)} + onFocus={e => setEditing(true)} + onBlur={e => setEditing(false)} /> { props.autofill && @@ -49,6 +53,6 @@ export const DataEntryInput: React.FC } -
    + ); }; diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry.tsx index 9932b803..62eb1a1b 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry.tsx @@ -69,16 +69,18 @@ class MultiDataEntry extends Component tooltip={props.tooltip} disabled={props.disabled || props.value == undefined || props.value.length === 0} /> -
      +
        { values.length === 0 &&
        No entries
        } { values.map((val, i) => ( -
      • +
      • this.edit(i, val)} @@ -105,6 +107,8 @@ class MultiDataEntry extends Component
        this.setNewValue(val)} From cd6fa2d68e4c8436905379c986713197dcd5d25a Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Tue, 7 Jan 2020 19:27:31 +0000 Subject: [PATCH 10/46] Disable landuse_group when landuse_class not empty --- app/src/frontend/building/data-containers/use.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/frontend/building/data-containers/use.tsx b/app/src/frontend/building/data-containers/use.tsx index b5940638..67f292d3 100644 --- a/app/src/frontend/building/data-containers/use.tsx +++ b/app/src/frontend/building/data-containers/use.tsx @@ -29,6 +29,7 @@ const UseView: React.FunctionComponent = (props) => ( slug="current_landuse_group" value={props.building.current_landuse_group} mode={props.mode} + disabled={(props.building.current_landuse_class || []).length !== 0} copy={props.copy} onChange={props.onChange} // tooltip={dataFields.current_landuse_class.tooltip} From c07937baee63cc6f2efd2ebaa51e2636ab0f9185 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 9 Jan 2020 13:07:50 +0000 Subject: [PATCH 11/46] Use npm package for throttle hook --- app/package-lock.json | 5 +++++ app/package.json | 4 +++- .../autofill/autofillDropdown.tsx | 4 ++-- app/src/frontend/hooks/useThrottledValue.ts | 22 ------------------- 4 files changed, 10 insertions(+), 25 deletions(-) delete mode 100644 app/src/frontend/hooks/useThrottledValue.ts diff --git a/app/package-lock.json b/app/package-lock.json index cb72d65a..abaefaa9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -17172,6 +17172,11 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "use-throttle": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/use-throttle/-/use-throttle-0.0.3.tgz", + "integrity": "sha512-cgA3c+pe6V7cZ7pkLnYnWxXJub2AmksY7YTp/xiZzKesQyECJ1slWknVY2CVZOBf48avbZsAvJIDjO+aFu9+pw==" + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", diff --git a/app/package.json b/app/package.json index fb83180e..9241fc40 100644 --- a/app/package.json +++ b/app/package.json @@ -5,6 +5,7 @@ "private": true, "license": "MIT", "scripts": { + "clean": "rm -rf build", "start": "razzle start", "build": "razzle build", "test": "razzle test --env=jsdom", @@ -35,7 +36,8 @@ "react-leaflet-universal": "^1.2.0", "react-router-dom": "^5.0.1", "serialize-javascript": "^2.1.1", - "sharp": "^0.22.1" + "sharp": "^0.22.1", + "use-throttle": "0.0.3" }, "devDependencies": { "@babel/plugin-syntax-jsx": "^7.7.4", diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx index 60f258e8..843079f1 100644 --- a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx +++ b/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from 'react'; +import { useThrottle } from 'use-throttle'; import './autofillDropdown.css'; import { apiGet } from '../../../apiHelpers'; -import { useThrottledValue } from '../../../hooks/useThrottledValue'; interface AutofillDropdownProps { fieldName: string; @@ -23,7 +23,7 @@ export const AutofillDropdown: React.FC = props => { const [options, setOptions] = useState(null); // use both throttled and debounced field value as trigger for update - const throttledFieldValue = useThrottledValue(props.fieldValue, 1000); + const throttledFieldValue = useThrottle(props.fieldValue, 1000); useEffect(() => { const doAsync = async () => { diff --git a/app/src/frontend/hooks/useThrottledValue.ts b/app/src/frontend/hooks/useThrottledValue.ts deleted file mode 100644 index 2d19e247..00000000 --- a/app/src/frontend/hooks/useThrottledValue.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -export function useThrottledValue(value: V, delay: number): V { - const [throttledValue, setThrottledValue] = useState(value); - let lastUpdated = useRef(Date.now()); - - useEffect(() => { - const timer = setTimeout(() => { - if(Date.now() - lastUpdated.current >= delay) { - setThrottledValue(value); - console.log('Updating to', value); - lastUpdated.current = Date.now(); - } - }, delay - (Date.now() - lastUpdated.current)); - - return () => { - clearTimeout(timer); - }; - }, [delay, value]); - - return throttledValue; -} From 11743dc98323ae470d0700490384061a89479a04 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 9 Jan 2020 15:08:58 +0000 Subject: [PATCH 12/46] Group multi-data-entry elements --- .../data-components/multi-data-entry.tsx | 96 ++++++++++--------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry.tsx index 62eb1a1b..29db6330 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry.tsx @@ -69,62 +69,64 @@ class MultiDataEntry extends Component tooltip={props.tooltip} disabled={props.disabled || props.value == undefined || props.value.length === 0} /> -
          - { - values.length === 0 && -
          No entries
          - } - { - values.map((val, i) => ( -
        • +
          +
            + { + values.length === 0 && +
            No entries
            + } + { + values.map((val, i) => ( +
          • + this.edit(i, val)} + + maxLength={props.maxLength} + placeholder={props.placeholder} + valueTransform={props.valueTransform} + autofill={props.autofill} + /> + { + !isDisabled && +
            + +
            + } +
          • + )) + } +
          + { + !isDisabled && +
          this.edit(i, val)} + name={`${props.slug}`} + id={`${props.slug}`} + value={this.state.newValue} + disabled={props.disabled} + onChange={(key, val) => this.setNewValue(val)} maxLength={props.maxLength} placeholder={props.placeholder} valueTransform={props.valueTransform} autofill={props.autofill} /> - { - !isDisabled && -
          - -
          - } -
        • - )) - } -
        - { - !isDisabled && -
        - this.setNewValue(val)} - - maxLength={props.maxLength} - placeholder={props.placeholder} - valueTransform={props.valueTransform} - autofill={props.autofill} - /> -
        - +
        + +
        -
        - } + } +
        ; } } From 494ec52afb1303b3c5907648abc809a15b32a63c Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 9 Jan 2020 15:35:38 +0000 Subject: [PATCH 13/46] Move from pg_trgm to full-text search for autofill --- app/src/api/services/autofill.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/api/services/autofill.ts b/app/src/api/services/autofill.ts index 41e78174..8ecd6709 100644 --- a/app/src/api/services/autofill.ts +++ b/app/src/api/services/autofill.ts @@ -6,9 +6,12 @@ const autofillFunctionMap = { function getLanduseClassOptions(value: string) { return db.manyOrNone(` - SELECT landuse_id AS id, description as value, similarity(description, $1) AS similarity + SELECT + landuse_id AS id, + description as value, + ts_rank(to_tsvector(description), plainto_tsquery($1)) as similarity FROM reference_tables.buildings_landuse_class - WHERE description % $1 + WHERE to_tsvector(description) @@ plainto_tsquery($1) ORDER BY similarity DESC, description `, [value] ); From 09c92616770cadd6aaedde05fccd429b0412e185 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 9 Jan 2020 19:43:59 +0000 Subject: [PATCH 14/46] Improve multi-data-entry UI The "No entries" state is now contained in a dashed-border box Adding an empty entry is not not possible --- .../multi-data-entry/multi-data-entry.css | 5 ++++ .../multi-data-entry.tsx | 23 ++++++++++++------- .../frontend/building/data-containers/age.tsx | 2 +- .../frontend/building/data-containers/use.tsx | 2 +- 4 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.css rename app/src/frontend/building/data-components/{ => multi-data-entry}/multi-data-entry.tsx (86%) diff --git a/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.css b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.css new file mode 100644 index 00000000..e18692e7 --- /dev/null +++ b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.css @@ -0,0 +1,5 @@ +.input-group .no-entries { + border-style: dashed; + border-color: #aaa; + margin-bottom: 0.4em; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-components/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx similarity index 86% rename from app/src/frontend/building/data-components/multi-data-entry.tsx rename to app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx index 29db6330..c2b30583 100644 --- a/app/src/frontend/building/data-components/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx @@ -1,8 +1,10 @@ import React, { Component, Fragment } from 'react'; -import { BaseDataEntryProps } from './data-entry'; -import { DataEntryInput, TextDataEntryInputProps } from './data-entry-input'; -import { DataTitleCopyable } from './data-title'; +import './multi-data-entry.css'; + +import { BaseDataEntryProps } from '../data-entry'; +import { DataEntryInput, TextDataEntryInputProps } from '../data-entry-input'; +import { DataTitleCopyable } from '../data-title'; interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps { @@ -18,7 +20,7 @@ class MultiDataEntry extends Component constructor(props) { super(props); this.state = { - newValue: '' + newValue: null }; this.setNewValue = this.setNewValue.bind(this); @@ -47,6 +49,7 @@ class MultiDataEntry extends Component } addNew(event) { event.preventDefault(); + if (this.state.newValue == undefined) return; const values = this.cloneValues().concat(this.state.newValue); this.setState({newValue: ''}); this.props.onChange(this.props.slug, values); @@ -73,7 +76,9 @@ class MultiDataEntry extends Component
          { values.length === 0 && -
          No entries
          +
          + +
          } { values.map((val, i) => ( @@ -87,7 +92,6 @@ class MultiDataEntry extends Component onChange={(key, val) => this.edit(i, val)} maxLength={props.maxLength} - placeholder={props.placeholder} valueTransform={props.valueTransform} autofill={props.autofill} /> @@ -120,9 +124,12 @@ class MultiDataEntry extends Component autofill={props.autofill} />
          - + onClick={this.addNew} + disabled={this.state.newValue == undefined} + >+
  • } diff --git a/app/src/frontend/building/data-containers/age.tsx b/app/src/frontend/building/data-containers/age.tsx index 1be202d7..61d03e0a 100644 --- a/app/src/frontend/building/data-containers/age.tsx +++ b/app/src/frontend/building/data-containers/age.tsx @@ -1,7 +1,7 @@ import React, { Fragment } from 'react'; import { dataFields } from '../../data_fields'; -import MultiDataEntry from '../data-components/multi-data-entry'; +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'; import TextboxDataEntry from '../data-components/textbox-data-entry'; diff --git a/app/src/frontend/building/data-containers/use.tsx b/app/src/frontend/building/data-containers/use.tsx index 67f292d3..7956f9ab 100644 --- a/app/src/frontend/building/data-containers/use.tsx +++ b/app/src/frontend/building/data-containers/use.tsx @@ -3,7 +3,7 @@ import React, { Fragment } from 'react'; import InfoBox from '../../components/info-box'; import { dataFields } from '../../data_fields'; import DataEntry from '../data-components/data-entry'; -import MultiDataEntry from '../data-components/multi-data-entry'; +import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry'; import withCopyEdit from '../data-container'; import { CategoryViewProps } from './category-view-props'; From 7efcf8ccc34ac98f7eab3d3601549992e39a33cf Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 9 Jan 2020 19:44:38 +0000 Subject: [PATCH 15/46] Rename autofill dropdown folder and files --- .../autofill-dropdown.css} | 0 .../autofill-dropdown.tsx} | 2 +- app/src/frontend/building/data-components/data-entry-input.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename app/src/frontend/building/data-components/{autofill/autofillDropdown.css => autofill-dropdown/autofill-dropdown.css} (100%) rename app/src/frontend/building/data-components/{autofill/autofillDropdown.tsx => autofill-dropdown/autofill-dropdown.tsx} (98%) diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.css b/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.css similarity index 100% rename from app/src/frontend/building/data-components/autofill/autofillDropdown.css rename to app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.css diff --git a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx b/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx similarity index 98% rename from app/src/frontend/building/data-components/autofill/autofillDropdown.tsx rename to app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx index 843079f1..8a03a0eb 100644 --- a/app/src/frontend/building/data-components/autofill/autofillDropdown.tsx +++ b/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useThrottle } from 'use-throttle'; -import './autofillDropdown.css'; +import './autofill-dropdown.css'; import { apiGet } from '../../../apiHelpers'; diff --git a/app/src/frontend/building/data-components/data-entry-input.tsx b/app/src/frontend/building/data-components/data-entry-input.tsx index f6b47428..d48b2042 100644 --- a/app/src/frontend/building/data-components/data-entry-input.tsx +++ b/app/src/frontend/building/data-components/data-entry-input.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { AutofillDropdown } from './autofill/autofillDropdown'; +import { AutofillDropdown } from './autofill-dropdown/autofill-dropdown'; export interface TextDataEntryInputProps { slug: string; From d2a1469b722004812d6adc8f1edfcd8ae6611fe8 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Tue, 14 Jan 2020 22:53:22 +0000 Subject: [PATCH 16/46] Separate out land use data access layer --- app/src/api/dataAccess/landUse.ts | 38 ++++++++++++ .../currentLandUseClassifications.ts | 59 ++++--------------- .../domainLogic/processBuildingUpdate.ts | 8 +-- 3 files changed, 53 insertions(+), 52 deletions(-) create mode 100644 app/src/api/dataAccess/landUse.ts diff --git a/app/src/api/dataAccess/landUse.ts b/app/src/api/dataAccess/landUse.ts new file mode 100644 index 00000000..83368c29 --- /dev/null +++ b/app/src/api/dataAccess/landUse.ts @@ -0,0 +1,38 @@ +import db from '../../db'; + +export async function getLanduseGroupFromClass(classes: string[]): Promise { + if (classes.length === 0) return []; + + return (await db.many( + ` + SELECT DISTINCT parent.description + FROM reference_tables.buildings_landuse_group AS parent + JOIN reference_tables.buildings_landuse_class AS child + ON child.parent_group_id = parent.landuse_id + WHERE child.description IN ($1:csv) + ORDER BY parent.description`, + [classes] + )).map(x => x.description); +} + +export async function getLandUseOrderFromGroup(groups: string[]): Promise { + if(groups.length === 0) return null; + + const orders = (await db.many( + ` + SELECT DISTINCT parent.description + FROM reference_tables.buildings_landuse_order AS parent + JOIN reference_tables.buildings_landuse_group AS child + ON child.parent_order_id = parent.landuse_id + WHERE child.description IN ($1:csv) + ORDER BY parent.description + `, + [groups] + )).map(x => x.description); + + if(orders.length === 1) { + return orders[0]; + } else if (orders.length > 1) { + return 'Mixed Use'; + } else return null; +} diff --git a/app/src/api/services/domainLogic/currentLandUseClassifications.ts b/app/src/api/services/domainLogic/currentLandUseClassifications.ts index e15a9244..26c2f6b0 100644 --- a/app/src/api/services/domainLogic/currentLandUseClassifications.ts +++ b/app/src/api/services/domainLogic/currentLandUseClassifications.ts @@ -1,39 +1,39 @@ import * as _ from 'lodash'; -import db from '../../../db'; import { isNullishOrEmpty } from '../../../helpers'; +import { getLanduseGroupFromClass, getLandUseOrderFromGroup } from '../../dataAccess/landUse'; import { getCurrentBuildingDataById } from '../building'; -export async function processCurrentLandUseClassifications(buildingId: number, building: any): Promise { +export async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise { let updateData = _.pick(await getCurrentBuildingDataById(buildingId), [ 'current_landuse_class', 'current_landuse_group', 'current_landuse_order' ]); - updateData = Object.assign({}, updateData, getClearValues(building)); + updateData = Object.assign({}, updateData, getClearValues(buildingUpdate)); - const updateFrom = getUpdateStartingStage(building); + const updateFrom = getUpdateStartingStage(buildingUpdate); if(updateFrom === 'class') { - updateData.current_landuse_class = building.current_landuse_class; - updateData.current_landuse_group = await deriveGroupFromClass(updateData.current_landuse_class); - updateData.current_landuse_order = await deriveOrderFromGroup(updateData.current_landuse_group); + updateData.current_landuse_class = buildingUpdate.current_landuse_class; + updateData.current_landuse_group = await getLanduseGroupFromClass(updateData.current_landuse_class); + updateData.current_landuse_order = await getLandUseOrderFromGroup(updateData.current_landuse_group); } else if (updateFrom === 'group') { if (isNullishOrEmpty(updateData.current_landuse_class)) { - updateData.current_landuse_group = building.current_landuse_group; - updateData.current_landuse_order = await deriveOrderFromGroup(building.current_landuse_group); + updateData.current_landuse_group = buildingUpdate.current_landuse_group; + updateData.current_landuse_order = await getLandUseOrderFromGroup(buildingUpdate.current_landuse_group); } else { throw new Error('Trying to update current_landuse_group field but a more detailed field (current_landuse_class) is already filled'); } } else if (updateFrom === 'order') { if (isNullishOrEmpty(updateData.current_landuse_class) && isNullishOrEmpty(updateData.current_landuse_group)) { - updateData.current_landuse_order = building.current_landuse_order; + updateData.current_landuse_order = buildingUpdate.current_landuse_order; } else { throw new Error('Trying to update current_landuse_order field but a more detailed field (current_landuse_class or current_landuse_group) is already filled'); } } - return Object.assign({}, building, updateData); + return Object.assign({}, buildingUpdate, updateData); } function getClearValues(building) { @@ -63,40 +63,3 @@ function getUpdateStartingStage(building) { return 'order'; } else return 'none'; } - -async function deriveGroupFromClass(classes: string[]): Promise { - if (classes.length === 0) return []; - - return (await db.many( - ` - SELECT DISTINCT parent.description - FROM reference_tables.buildings_landuse_group AS parent - JOIN reference_tables.buildings_landuse_class AS child - ON child.parent_group_id = parent.landuse_id - WHERE child.description IN ($1:csv) - ORDER BY parent.description`, - [classes] - )).map(x => x.description); -} - -async function deriveOrderFromGroup(groups: string[]): Promise { - if(groups.length === 0) return null; - - const orders = (await db.many( - ` - SELECT DISTINCT parent.description - FROM reference_tables.buildings_landuse_order AS parent - JOIN reference_tables.buildings_landuse_group AS child - ON child.parent_order_id = parent.landuse_id - WHERE child.description IN ($1:csv) - ORDER BY parent.description - `, - [groups] - )).map(x => x.description); - - if(orders.length === 1) { - return orders[0]; - } else if (orders.length > 1) { - return 'Mixed Use'; - } else return null; -} diff --git a/app/src/api/services/domainLogic/processBuildingUpdate.ts b/app/src/api/services/domainLogic/processBuildingUpdate.ts index 7b7d166f..e176dcd7 100644 --- a/app/src/api/services/domainLogic/processBuildingUpdate.ts +++ b/app/src/api/services/domainLogic/processBuildingUpdate.ts @@ -2,10 +2,10 @@ import { hasAnyOwnProperty } from '../../../helpers'; import { processCurrentLandUseClassifications } from './currentLandUseClassifications'; -export async function processBuildingUpdate(buildingId: number, building: any): Promise { - if(hasAnyOwnProperty(building, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) { - building = await processCurrentLandUseClassifications(buildingId, building); +export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise { + if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) { + buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); } - return building; + return buildingUpdate; } From b168a0b331da6eb3baab3adeeabb1c7c5e06f974 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Fri, 7 Feb 2020 15:57:44 +0000 Subject: [PATCH 17/46] Adjust age map colours --- app/map_styles/polygon.xml | 40 +++++---------------------------- app/src/frontend/map/legend.tsx | 17 +++++--------- 2 files changed, 11 insertions(+), 46 deletions(-) diff --git a/app/map_styles/polygon.xml b/app/map_styles/polygon.xml index 963f5fd2..a34ae1c7 100644 --- a/app/map_styles/polygon.xml +++ b/app/map_styles/polygon.xml @@ -154,55 +154,27 @@ [date_year] >= 1880 and [date_year] < 1900 - + [date_year] >= 1860 and [date_year] < 1880 - + [date_year] >= 1840 and [date_year] < 1860 - [date_year] >= 1820 and [date_year] < 1840 - - - - [date_year] >= 1800 and [date_year] < 1820 - - - - [date_year] >= 1780 and [date_year] < 1800 + [date_year] >= 1800 and [date_year] < 1840 - [date_year] >= 1760 and [date_year] < 1780 + [date_year] >= 1700 and [date_year] < 1800 - [date_year] >= 1740 and [date_year] < 1760 - - - - [date_year] >= 1720 and [date_year] < 1740 - - - - [date_year] >= 1700 and [date_year] < 1720 - - - - [date_year] >= 1680 and [date_year] < 1700 - - - - [date_year] >= 1660 and [date_year] < 1680 - - - - [date_year] < 1660 - + [date_year] < 1700 + +