From aafb81a17b45067d7a00b4b078ecfe70483a82d2 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Mon, 6 Jan 2020 16:15:36 +0000 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 bb30aa7098196d7b39d0592810d7e5e35ec27ee1 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Sun, 8 Mar 2020 18:46:28 +0000 Subject: [PATCH 17/32] Include test files in tsconfig for intellisense --- app/tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/tsconfig.json b/app/tsconfig.json index 9b4ebd04..10ab0397 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -13,5 +13,9 @@ "files": [ "./src/index.ts", "./src/client.tsx" + ], + "include": [ + "./src/**/*.test.*", + "./src/**/*.spec.*" ] } \ No newline at end of file From 4bda0f0aa688cc5ae1d9910b4b437a58d441f776 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Tue, 17 Mar 2020 16:29:59 +0000 Subject: [PATCH 18/32] Add unfinished land use class,group,order impl --- app/package-lock.json | 4 +- app/src/api/dataAccess/landUse.ts | 2 +- app/src/api/errors/general.ts | 7 ++ .../__tests__/domainLogic/landUse.test.ts | 103 ++++++++++++++++++ ...ntLandUseClassifications.ts => landUse.ts} | 16 ++- .../domainLogic/processBuildingUpdate.ts | 29 ++++- 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 app/src/api/services/__tests__/domainLogic/landUse.test.ts rename app/src/api/services/domainLogic/{currentLandUseClassifications.ts => landUse.ts} (87%) diff --git a/app/package-lock.json b/app/package-lock.json index 905d5dbc..75528e3e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -2410,7 +2410,7 @@ }, "babel-plugin-syntax-object-rest-spread": { "version": "6.13.0", - "resolved": "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=", "dev": true }, @@ -9729,7 +9729,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" diff --git a/app/src/api/dataAccess/landUse.ts b/app/src/api/dataAccess/landUse.ts index 83368c29..02cdfcb8 100644 --- a/app/src/api/dataAccess/landUse.ts +++ b/app/src/api/dataAccess/landUse.ts @@ -1,6 +1,6 @@ import db from '../../db'; -export async function getLanduseGroupFromClass(classes: string[]): Promise { +export async function getLandUseGroupFromClass(classes: string[]): Promise { if (classes.length === 0) return []; return (await db.many( diff --git a/app/src/api/errors/general.ts b/app/src/api/errors/general.ts index faacdb41..484e8c40 100644 --- a/app/src/api/errors/general.ts +++ b/app/src/api/errors/general.ts @@ -15,3 +15,10 @@ export class DatabaseError extends Error { this.detail = detail; } } + +export class DomainLogicError extends Error { + constructor(message?: string) { + super(message); + this.name = 'DomainLogicError'; + } +} diff --git a/app/src/api/services/__tests__/domainLogic/landUse.test.ts b/app/src/api/services/__tests__/domainLogic/landUse.test.ts new file mode 100644 index 00000000..6a01ca3d --- /dev/null +++ b/app/src/api/services/__tests__/domainLogic/landUse.test.ts @@ -0,0 +1,103 @@ +import * as _ from 'lodash'; + +import { DomainLogicError } from '../../../errors/general'; +import { LandUseState, updateLandUse } from '../../domainLogic/landUse'; + +const testClassToGroup = { + 'Animal breeding places': 'Agriculture', + 'Egg grading place': 'Agriculture', + 'Fish farm': 'Fisheries', + 'Brewery': 'Manufacturing', + 'Business meeting places': 'Offices' +}; +const testGroupToOrder = { + 'Agriculture': 'Agriculture And Fisheries', + 'Fisheries': 'Agriculture And Fisheries', + 'Manufacturing': 'Industry And Business', + 'Offices': 'Industry And Business' +}; + +jest.mock('../../../dataAccess/landUse', () => ({ + getLandUseGroupFromClass: jest.fn((classes: string[]) => { + const groups = _.chain(classes).map(c => testClassToGroup[c]).uniq().value(); + + return Promise.resolve(groups); + }), + getLandUseOrderGromGroup: jest.fn((groups: string[]) => { + const orders = _.chain(groups).map(g => testGroupToOrder[g]).uniq().value(); + + let result: string; + if(orders.length == 0) result = null; + else if(orders.length == 1) result = orders[0]; + else result = 'Mixed Use'; + + return Promise.resolve(result); + }) +})); + +describe('updateLandUse()', () => { + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it.each([ + [{ + landUseClass: [], + landUseGroup: [], + landUseOrder: null + }, { + landUseClass: ['Animal breeding places'] + }, { + landUseClass: ['Animal breeding places'], + landUseGroup: ['Agriculture'], + landUseOrder: 'Agriculture And Fisheries' + }], + + [{ + landUseClass: ['Animal breeding places'], + landUseGroup: ['Agriculture'], + landUseOrder: 'Agriculture And Fisheries' + }, { + landUseClass: ['Fish farm'] + }, { + landUseClass: ['Fish farm'], + landUseGroup: ['Fisheries'], + landUseOrder: 'Agriculture And Fisheries' + }], + + [{ + landUseClass: ['Animal breeding places'], + landUseGroup: ['Agriculture'], + landUseOrder: 'Agriculture And Fisheries' + }, { + landUseClass: ['Animal breeding places', 'Business meeting places'] + }, { + landUseClass: ['Animal breeding places', 'Business meeting places'], + landUseGroup: ['Agriculture', 'Offices'], + landUseOrder: 'Mixed Use' + }] + ])('Should derive higher level land use classifications from lower level ones', + async (landUse: LandUseState, landUseUpdate: Partial, expectedUpdate: LandUseState) => { + const result = await updateLandUse(landUse, landUseUpdate); + + expect(result).toBe(expectedUpdate); + } + ); + + it.each([ + [{ + landUseClass: ['Fish farm'], + landUseGroup: ['Fisheries'], + landUseOrder: 'Agriculture And Fisheries' + }, { + landUseGroup: [] + }] + ])('Should error when update breaks an automatic chain of classifications', + async (landUse: LandUseState, landUseUpdate: Partial) => { + const resultPromise = updateLandUse(landUse, landUseUpdate); + + expect(resultPromise).rejects.toBeInstanceOf(DomainLogicError); + } + ); +}); diff --git a/app/src/api/services/domainLogic/currentLandUseClassifications.ts b/app/src/api/services/domainLogic/landUse.ts similarity index 87% rename from app/src/api/services/domainLogic/currentLandUseClassifications.ts rename to app/src/api/services/domainLogic/landUse.ts index 26c2f6b0..56bffbeb 100644 --- a/app/src/api/services/domainLogic/currentLandUseClassifications.ts +++ b/app/src/api/services/domainLogic/landUse.ts @@ -1,9 +1,19 @@ -import * as _ from 'lodash'; +import _ from 'lodash'; import { isNullishOrEmpty } from '../../../helpers'; -import { getLanduseGroupFromClass, getLandUseOrderFromGroup } from '../../dataAccess/landUse'; +import { getLandUseGroupFromClass, getLandUseOrderFromGroup } from '../../dataAccess/landUse'; import { getCurrentBuildingDataById } from '../building'; +export interface LandUseState { + landUseClass: string[]; + landUseGroup: string[]; + landUseOrder: string; +} + +export async function updateLandUse(landUse: LandUseState, landUseUpdate: Partial): Promise { + throw new Error('Not implemented'); +} + export async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise { let updateData = _.pick(await getCurrentBuildingDataById(buildingId), [ 'current_landuse_class', @@ -16,7 +26,7 @@ export async function processCurrentLandUseClassifications(buildingId: number, b const updateFrom = getUpdateStartingStage(buildingUpdate); if(updateFrom === 'class') { updateData.current_landuse_class = buildingUpdate.current_landuse_class; - updateData.current_landuse_group = await getLanduseGroupFromClass(updateData.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)) { diff --git a/app/src/api/services/domainLogic/processBuildingUpdate.ts b/app/src/api/services/domainLogic/processBuildingUpdate.ts index e176dcd7..640b2a16 100644 --- a/app/src/api/services/domainLogic/processBuildingUpdate.ts +++ b/app/src/api/services/domainLogic/processBuildingUpdate.ts @@ -1,6 +1,9 @@ -import { hasAnyOwnProperty } from '../../../helpers'; +import * as _ from 'lodash'; -import { processCurrentLandUseClassifications } from './currentLandUseClassifications'; +import { hasAnyOwnProperty } from '../../../helpers'; +import { getCurrentBuildingDataById } from '../building'; + +import { updateLandUse } from './landUse'; export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise { if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) { @@ -9,3 +12,25 @@ export async function processBuildingUpdate(buildingId: number, buildingUpdate: return buildingUpdate; } + +async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise { + const currentBuildingData = await getCurrentBuildingDataById(buildingId); + + const currentLandUseUpdate = await updateLandUse( + { + landUseClass: currentBuildingData.current_landuse_class, + landUseGroup: currentBuildingData.current_landuse_group, + landUseOrder: currentBuildingData.current_landuse_order + }, { + landUseClass: buildingUpdate.current_landuse_class, + landUseGroup: buildingUpdate.current_landuse_group, + landUseOrder: buildingUpdate.current_landuse_order + } + ); + + return Object.assign({}, buildingUpdate, { + current_landuse_class: currentLandUseUpdate.landUseClass, + current_landuse_group: currentLandUseUpdate.landUseGroup, + current_landuse_order: currentLandUseUpdate.landUseOrder, + }); +} From 914d7e56d4cdbc576784f59c2ce9106b203bad6b Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 19 Mar 2020 13:45:35 +0000 Subject: [PATCH 19/32] Rewrite landuse domain logic without landuse class --- .../__tests__/domainLogic/landUse.test.ts | 60 +++++++--------- app/src/api/services/domainLogic/landUse.ts | 71 ++----------------- .../domainLogic/processBuildingUpdate.ts | 8 +-- 3 files changed, 36 insertions(+), 103 deletions(-) diff --git a/app/src/api/services/__tests__/domainLogic/landUse.test.ts b/app/src/api/services/__tests__/domainLogic/landUse.test.ts index 6a01ca3d..00793647 100644 --- a/app/src/api/services/__tests__/domainLogic/landUse.test.ts +++ b/app/src/api/services/__tests__/domainLogic/landUse.test.ts @@ -1,15 +1,7 @@ import * as _ from 'lodash'; -import { DomainLogicError } from '../../../errors/general'; import { LandUseState, updateLandUse } from '../../domainLogic/landUse'; -const testClassToGroup = { - 'Animal breeding places': 'Agriculture', - 'Egg grading place': 'Agriculture', - 'Fish farm': 'Fisheries', - 'Brewery': 'Manufacturing', - 'Business meeting places': 'Offices' -}; const testGroupToOrder = { 'Agriculture': 'Agriculture And Fisheries', 'Fisheries': 'Agriculture And Fisheries', @@ -18,12 +10,7 @@ const testGroupToOrder = { }; jest.mock('../../../dataAccess/landUse', () => ({ - getLandUseGroupFromClass: jest.fn((classes: string[]) => { - const groups = _.chain(classes).map(c => testClassToGroup[c]).uniq().value(); - - return Promise.resolve(groups); - }), - getLandUseOrderGromGroup: jest.fn((groups: string[]) => { + getLandUseOrderFromGroup: jest.fn((groups: string[]) => { const orders = _.chain(groups).map(g => testGroupToOrder[g]).uniq().value(); let result: string; @@ -38,66 +25,73 @@ jest.mock('../../../dataAccess/landUse', () => ({ describe('updateLandUse()', () => { beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it.each([ [{ - landUseClass: [], landUseGroup: [], landUseOrder: null }, { - landUseClass: ['Animal breeding places'] + landUseGroup: ['Agriculture'] }, { - landUseClass: ['Animal breeding places'], landUseGroup: ['Agriculture'], landUseOrder: 'Agriculture And Fisheries' }], [{ - landUseClass: ['Animal breeding places'], landUseGroup: ['Agriculture'], landUseOrder: 'Agriculture And Fisheries' }, { - landUseClass: ['Fish farm'] + landUseGroup: ['Fisheries'] }, { - landUseClass: ['Fish farm'], landUseGroup: ['Fisheries'], landUseOrder: 'Agriculture And Fisheries' }], [{ - landUseClass: ['Animal breeding places'], landUseGroup: ['Agriculture'], landUseOrder: 'Agriculture And Fisheries' }, { - landUseClass: ['Animal breeding places', 'Business meeting places'] + landUseGroup: ['Agriculture', 'Offices'], }, { - landUseClass: ['Animal breeding places', 'Business meeting places'], landUseGroup: ['Agriculture', 'Offices'], landUseOrder: 'Mixed Use' }] - ])('Should derive higher level land use classifications from lower level ones', + ])('Should derive land use order from group', async (landUse: LandUseState, landUseUpdate: Partial, expectedUpdate: LandUseState) => { const result = await updateLandUse(landUse, landUseUpdate); - expect(result).toBe(expectedUpdate); + expect(result).toEqual(expectedUpdate); } ); it.each([ [{ - landUseClass: ['Fish farm'], - landUseGroup: ['Fisheries'], + landUseGroup: ['Agriculture'], landUseOrder: 'Agriculture And Fisheries' }, { landUseGroup: [] - }] - ])('Should error when update breaks an automatic chain of classifications', - async (landUse: LandUseState, landUseUpdate: Partial) => { - const resultPromise = updateLandUse(landUse, landUseUpdate); + }, { + landUseGroup: [], + landUseOrder: null + }], - expect(resultPromise).rejects.toBeInstanceOf(DomainLogicError); + [{ + landUseGroup: ['Agriculture', 'Offices'], + landUseOrder: 'Mixed Use', + }, { + landUseGroup: ['Agriculture'], + }, { + landUseGroup: ['Agriculture'], + landUseOrder: 'Agriculture And Fisheries' + }] + ])('Should remove derived land use order when land use group is removed', + async (landUse: LandUseState, landUseUpdate: Partial, expectedUpdate: LandUseState) => { + const result = await updateLandUse(landUse, landUseUpdate); + + expect(result).toEqual(expectedUpdate); } ); + }); diff --git a/app/src/api/services/domainLogic/landUse.ts b/app/src/api/services/domainLogic/landUse.ts index 56bffbeb..b4b0f4b9 100644 --- a/app/src/api/services/domainLogic/landUse.ts +++ b/app/src/api/services/domainLogic/landUse.ts @@ -1,75 +1,18 @@ import _ from 'lodash'; -import { isNullishOrEmpty } from '../../../helpers'; -import { getLandUseGroupFromClass, getLandUseOrderFromGroup } from '../../dataAccess/landUse'; -import { getCurrentBuildingDataById } from '../building'; +import { getLandUseOrderFromGroup } from '../../dataAccess/landUse'; export interface LandUseState { - landUseClass: string[]; landUseGroup: string[]; landUseOrder: string; } export async function updateLandUse(landUse: LandUseState, landUseUpdate: Partial): Promise { - throw new Error('Not implemented'); -} + const landUseGroupUpdate = landUseUpdate.landUseGroup; + const landUseOrderUpdate = await getLandUseOrderFromGroup(landUseGroupUpdate); -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(buildingUpdate)); - - const updateFrom = getUpdateStartingStage(buildingUpdate); - if(updateFrom === 'class') { - 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 = 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 = 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({}, buildingUpdate, updateData); -} - -function getClearValues(building) { - const clearValues: any = {}; - if(building.hasOwnProperty('current_landuse_class') && isNullishOrEmpty(building.current_landuse_class)) { - clearValues.current_landuse_class = []; - } - if(building.hasOwnProperty('current_landuse_group') && isNullishOrEmpty(building.current_landuse_group)) { - clearValues.current_landuse_group = []; - } - if(building.hasOwnProperty('current_landuse_order') && isNullishOrEmpty(building.current_landuse_order)) { - clearValues.current_landuse_order = null; - } - - return clearValues; -} -/** - * Choose which level of the land use classification hierarchy the update should start from. - * @param building - */ -function getUpdateStartingStage(building) { - if(building.hasOwnProperty('current_landuse_class') && !isNullishOrEmpty(building.current_landuse_class)) { - return 'class'; - } else if(building.hasOwnProperty('current_landuse_group') && !isNullishOrEmpty(building.current_landuse_group)) { - return 'group'; - } else if(building.hasOwnProperty('current_landuse_order') && !isNullishOrEmpty(building.current_landuse_order)) { - return 'order'; - } else return 'none'; + return { + landUseGroup: landUseGroupUpdate, + landUseOrder: landUseOrderUpdate + }; } diff --git a/app/src/api/services/domainLogic/processBuildingUpdate.ts b/app/src/api/services/domainLogic/processBuildingUpdate.ts index 640b2a16..46f5f7c7 100644 --- a/app/src/api/services/domainLogic/processBuildingUpdate.ts +++ b/app/src/api/services/domainLogic/processBuildingUpdate.ts @@ -6,7 +6,7 @@ import { getCurrentBuildingDataById } from '../building'; import { updateLandUse } from './landUse'; export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise { - if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_class', 'current_landuse_group', 'current_landuse_order'])) { + if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) { buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); } @@ -18,18 +18,14 @@ async function processCurrentLandUseClassifications(buildingId: number, building const currentLandUseUpdate = await updateLandUse( { - landUseClass: currentBuildingData.current_landuse_class, landUseGroup: currentBuildingData.current_landuse_group, landUseOrder: currentBuildingData.current_landuse_order }, { - landUseClass: buildingUpdate.current_landuse_class, - landUseGroup: buildingUpdate.current_landuse_group, - landUseOrder: buildingUpdate.current_landuse_order + landUseGroup: buildingUpdate.current_landuse_group } ); return Object.assign({}, buildingUpdate, { - current_landuse_class: currentLandUseUpdate.landUseClass, current_landuse_group: currentLandUseUpdate.landUseGroup, current_landuse_order: currentLandUseUpdate.landUseOrder, }); From ff027cb64b00064b8f8515accb8fad02ce19d878 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 19 Mar 2020 14:41:44 +0000 Subject: [PATCH 20/32] Remove current land use class from frontend --- app/src/api/services/building.ts | 2 +- .../frontend/building/data-containers/use.tsx | 16 ++++------------ app/src/frontend/data_fields.ts | 8 ++------ 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/app/src/api/services/building.ts b/app/src/api/services/building.ts index 79511324..197b57d1 100644 --- a/app/src/api/services/building.ts +++ b/app/src/api/services/building.ts @@ -405,7 +405,7 @@ const BUILDING_FIELD_WHITELIST = new Set([ 'building_attachment_form', 'date_change_building_use', - 'current_landuse_class', + // 'current_landuse_class', 'current_landuse_group', 'current_landuse_order' ]); diff --git a/app/src/frontend/building/data-containers/use.tsx b/app/src/frontend/building/data-containers/use.tsx index 7956f9ab..26aa3b8f 100644 --- a/app/src/frontend/building/data-containers/use.tsx +++ b/app/src/frontend/building/data-containers/use.tsx @@ -13,23 +13,11 @@ import { CategoryViewProps } from './category-view-props'; */ const UseView: React.FunctionComponent = (props) => ( - = (props) => ( copy={props.copy} onChange={props.onChange} /> + { + props.mode != 'view' && + + } ); const UseContainer = withCopyEdit(UseView); diff --git a/app/src/frontend/data_fields.ts b/app/src/frontend/data_fields.ts index 7e46cdad..fb88db43 100644 --- a/app/src/frontend/data_fields.ts +++ b/app/src/frontend/data_fields.ts @@ -153,17 +153,13 @@ export const dataFields = { title: "Longitude", }, - current_landuse_class: { - category: Category.LandUse, - title: "Current Land Use Class" - }, current_landuse_group: { category: Category.LandUse, - title: "Current Land Use Group" + title: "Current Land Use (Group)" }, current_landuse_order: { category: Category.LandUse, - title: "Current Land Use Order" + title: "Current Land Use (Order)" }, building_attachment_form: { From c35f545540ab84a83ad0cb94af8198cc91743cbe Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 19 Mar 2020 14:42:01 +0000 Subject: [PATCH 21/32] Remove current landuse class from data extract --- maintenance/extract_data/README.txt | 1 - maintenance/extract_data/export_attributes.sql | 1 - 2 files changed, 2 deletions(-) diff --git a/maintenance/extract_data/README.txt b/maintenance/extract_data/README.txt index 54db7145..909f18d2 100644 --- a/maintenance/extract_data/README.txt +++ b/maintenance/extract_data/README.txt @@ -41,7 +41,6 @@ This is the main table, containing almost all data collected by Colouring London - `location_postcode`: postcode - `location_latitude`: latitude - `location_longitude`: longitude -- `current_landuse_class`: current land use class - `current_landuse_group`: current land use group - `current_landuse_order`: current land use order - `building_attachment_form`: building attachment form diff --git a/maintenance/extract_data/export_attributes.sql b/maintenance/extract_data/export_attributes.sql index 87159603..4e797a8d 100644 --- a/maintenance/extract_data/export_attributes.sql +++ b/maintenance/extract_data/export_attributes.sql @@ -11,7 +11,6 @@ COPY (SELECT location_postcode, location_latitude, location_longitude, - current_landuse_class, current_landuse_group, current_landuse_order, building_attachment_form, From f6b71c7fc72c6a3a0cda3a58965fd3d0c82b85ed Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 19 Mar 2020 18:17:56 +0000 Subject: [PATCH 22/32] Improve land use group autofill --- app/src/api/controllers/autofillController.ts | 7 ++- app/src/api/parameters.ts | 9 ++++ app/src/api/services/autofill.ts | 48 +++++++++++++++---- .../autofill-dropdown/autofill-dropdown.tsx | 17 ++++++- .../data-components/data-entry-input.tsx | 2 + .../multi-data-entry/multi-data-entry.tsx | 12 ++++- .../frontend/building/data-containers/age.tsx | 1 + .../frontend/building/data-containers/use.tsx | 2 + 8 files changed, 82 insertions(+), 16 deletions(-) diff --git a/app/src/api/controllers/autofillController.ts b/app/src/api/controllers/autofillController.ts index 2514a17d..ca6efb7a 100644 --- a/app/src/api/controllers/autofillController.ts +++ b/app/src/api/controllers/autofillController.ts @@ -1,10 +1,13 @@ +import { parseBooleanParam, processParam } from '../parameters'; 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 fieldName = processParam(req.query, 'field_name', x => x, true); + const { field_value: fieldValue } = req.query; + const allValues = processParam(req.query, 'all_values', parseBooleanParam); - const options = await autofillService.getAutofillOptions(field_name, field_value); + const options = await autofillService.getAutofillOptions(fieldName, fieldValue, allValues); res.send({ options: options }); }); diff --git a/app/src/api/parameters.ts b/app/src/api/parameters.ts index 865c0947..9656b854 100644 --- a/app/src/api/parameters.ts +++ b/app/src/api/parameters.ts @@ -33,6 +33,15 @@ export function parsePositiveIntParam(param: string) { return result; } +export function parseBooleanParam(param: string) { + if(param == undefined) return undefined; + + if(param === 'true') return true; + if(param === 'false') return false; + + throw new ApiParamInvalidFormatError('Invalid format: not a true/false value'); +} + export function checkRegexParam(param: string, regex: RegExp): string { if(param == undefined) return undefined; diff --git a/app/src/api/services/autofill.ts b/app/src/api/services/autofill.ts index 8ecd6709..27ab8cf3 100644 --- a/app/src/api/services/autofill.ts +++ b/app/src/api/services/autofill.ts @@ -1,28 +1,56 @@ import db from '../../db'; -const autofillFunctionMap = { - current_landuse_class: getLanduseClassOptions +interface AutofillOption { + id: string; + value: string; + similarity?: number; +} + +type GetAutofillOptionsFn = (value: string, all?: boolean) => Promise; + +const autofillFunctionMap : { [fieldName: string] : GetAutofillOptionsFn } = { + current_landuse_group: getLanduseGroupOptions, }; -function getLanduseClassOptions(value: string) { + +function getLanduseGroupOptions(value: string, all: boolean = false) { + if(all) { + return db.manyOrNone(` + SELECT + landuse_id AS id, + description AS value + FROM reference_tables.buildings_landuse_group + ` + ); + } + + let query = buildPartialMatchQuery(value); + return db.manyOrNone(` 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 to_tsvector(description) @@ plainto_tsquery($1) + description AS value, + ts_rank(to_tsvector(description), to_tsquery('simple', $1)) AS similarity + FROM reference_tables.buildings_landuse_group + WHERE to_tsvector(description) @@ to_tsquery('simple', $1) ORDER BY similarity DESC, description - `, [value] + `, [query] ); } -export function getAutofillOptions(fieldName: string, fieldValue: any) { +function buildPartialMatchQuery(value: string) { + return tokenizeValue(value).map(x => `${x}:*`).join(' & '); +} +function tokenizeValue(value: string) { + return value.split(/[^\w]+/).filter(x => x !== ''); +} + +export function getAutofillOptions(fieldName: string, fieldValue: any, allValues: boolean) { const optionsFn = autofillFunctionMap[fieldName]; if (optionsFn == undefined) { throw new Error(`Autofill options not available for field '${fieldName}'`); } - return optionsFn(fieldValue); + return optionsFn(fieldValue, allValues); } diff --git a/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx b/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx index 8a03a0eb..380892f7 100644 --- a/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx +++ b/app/src/frontend/building/data-components/autofill-dropdown/autofill-dropdown.tsx @@ -8,6 +8,7 @@ import { apiGet } from '../../../apiHelpers'; interface AutofillDropdownProps { fieldName: string; fieldValue: string; + showAllOptionsOnEmpty?: boolean; editing: boolean; onSelect: (val: string) => void; onClose: () => void; @@ -27,9 +28,21 @@ export const AutofillDropdown: React.FC = props => { useEffect(() => { const doAsync = async () => { - if (!props.editing || (props.fieldValue === '' && options == null)) return setOptions(null); + if (!props.editing) return setOptions(null); - const url = `/api/autofill?field_name=${props.fieldName}&field_value=${props.fieldValue}`; + let valueParam: string; + + if(props.fieldValue == null) { + if(!props.showAllOptionsOnEmpty) { + if(options == null) return setOptions(null); + } else { + valueParam = 'all_values=true'; + } + } else { + valueParam = `field_value=${props.fieldValue}`; + } + + const url = `/api/autofill?field_name=${props.fieldName}&${valueParam}`; const { options: newOptions } = await apiGet(url); if (!props.editing) return; 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 d48b2042..3ec133a6 100644 --- a/app/src/frontend/building/data-components/data-entry-input.tsx +++ b/app/src/frontend/building/data-components/data-entry-input.tsx @@ -13,6 +13,7 @@ export interface TextDataEntryInputProps { placeholder?: string; valueTransform?: (val: string) => string; autofill?: boolean; + showAllOptionsOnEmpty?: boolean; } export const DataEntryInput: React.FC = props => { @@ -51,6 +52,7 @@ export const DataEntryInput: React.FC setEditing(false)} fieldName={props.slug} fieldValue={props.value} + showAllOptionsOnEmpty={props.showAllOptionsOnEmpty} /> } diff --git a/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx index c2b30583..42d853f5 100644 --- a/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx +++ b/app/src/frontend/building/data-components/multi-data-entry/multi-data-entry.tsx @@ -9,6 +9,7 @@ import { DataTitleCopyable } from '../data-title'; interface MultiDataEntryProps extends BaseDataEntryProps, TextDataEntryInputProps { value: string[]; + editableEntries: boolean; } interface MultiDataEntryState { @@ -17,6 +18,10 @@ interface MultiDataEntryState { class MultiDataEntry extends Component { + static defaultProps = { + editableEntries: false + }; + constructor(props) { super(props); this.state = { @@ -51,7 +56,7 @@ class MultiDataEntry extends Component event.preventDefault(); if (this.state.newValue == undefined) return; const values = this.cloneValues().concat(this.state.newValue); - this.setState({newValue: ''}); + this.setState({newValue: null}); this.props.onChange(this.props.slug, values); } @@ -88,12 +93,14 @@ class MultiDataEntry extends Component name={`${props.slug}-${i}`} id={`${props.slug}-${i}`} value={val} - disabled={isDisabled} + disabled={!props.editableEntries || isDisabled} onChange={(key, val) => this.edit(i, val)} maxLength={props.maxLength} valueTransform={props.valueTransform} + autofill={props.autofill} + showAllOptionsOnEmpty={props.showAllOptionsOnEmpty} /> { !isDisabled && @@ -122,6 +129,7 @@ class MultiDataEntry extends Component placeholder={props.placeholder} valueTransform={props.valueTransform} autofill={props.autofill} + showAllOptionsOnEmpty={props.showAllOptionsOnEmpty} />