Overhaul the autocomplete component, for sake of new custorder

turns out we had some issues with our understanding of how that all
was supposed to work.  this seems to be much cleaner and even
semi-documented :)
This commit is contained in:
Lance Edgar 2021-10-16 15:37:23 -04:00
parent 232a02b944
commit 52fbe73893
3 changed files with 166 additions and 86 deletions

View file

@ -4,16 +4,54 @@ const TailboneAutocomplete = {
template: '#tailbone-autocomplete-template', template: '#tailbone-autocomplete-template',
props: { props: {
// this is the "input" field name essentially. primarily is
// useful for "traditional" tailbone forms; it normally is not
// used otherwise. it is passed as-is to the buefy
// autocomplete component `name` prop
name: String, name: String,
// the url from which search results are to be obtained. the
// url should expect a GET request with a query string with a
// single `term` parameter, and return results as a JSON array
// containing objects with `value` and `label` properties.
serviceUrl: String, serviceUrl: String,
// callers do not specify this directly but rather by way of
// the `v-model` directive. this component will emit `input`
// events when the value changes
value: String, value: String,
// callers may set an initial label if needed. this is useful
// in cases where the autocomplete needs to "already have a
// value" on page load. for instance when a user fills out
// the autocomplete field, but leaves other required fields
// blank and submits the form; page will re-load showing
// errors but the autocomplete field should remain "set" -
// normally it is only given a "value" (e.g. uuid) but this
// allows for the "label" to display correctly as well
initialLabel: String, initialLabel: String,
assignedValue: String,
// TODO: i am not sure this is needed? but current logic does
// handle it specially, so am leaving for now. if this prop
// is set by the caller, then the `assignedLabel` will *always*
// be shown for the button (when "selection" has been made)
assignedLabel: String, assignedLabel: String,
// simple placeholder text for the input box
placeholder: String, placeholder: String,
// TODO: pretty sure this can be ignored..?
// (should deprecate / remove if so)
assignedValue: String,
}, },
data() { data() {
// we want to track the "currently selected option" - which
// should normally be `null` to begin with, unless we were
// given a value, in which case we use `initialLabel` to
// complete the option
let selected = null let selected = null
if (this.value) { if (this.value) {
selected = { selected = {
@ -21,89 +59,55 @@ const TailboneAutocomplete = {
label: this.initialLabel, label: this.initialLabel,
} }
} }
return {
data: [],
selected: selected,
isFetching: false,
}
},
watch: { return {
value(to, from) {
if (from && !to) { // this contains the search results; its contents may
this.clearSelection(false) // change over time as new searches happen. the
} // "currently selected option" should be one of these,
}, // unless it is null
data: [],
// this tracks our "currently selected option" - per above
selected: selected,
// since we are wrapping a component which also makes use
// of the "value" paradigm, we must separate the concerns.
// so we use our own `value` prop to interact with the
// caller, but then we use this `buefyValue` data point to
// communicate with the buefy autocomplete component.
// note that `this.value` will always be either a uuid or
// null, whereas `this.buefyValue` may be raw text as
// entered by the user.
buefyValue: this.value,
// // TODO: we are "setting" this at the appropriate time,
// // but not clear if that actually affects anything.
// // should we just remove it?
// isFetching: false,
}
}, },
methods: { methods: {
clearSelection(focus) { // fetch new search results from the server. this is invoked
if (focus === undefined) { // via the `@typing` event from buefy autocomplete component.
focus = true // the doc at https://buefy.org/documentation/autocomplete
} // mentions `debounce` as being optional. at one point i
this.selected = null // thought it would fix a performance bug; not sure `debounce`
this.value = null // helped but figured might as well leave it
if (focus) {
this.$nextTick(function() {
this.focus()
})
}
// TODO: should emit event for caller logic (can they cancel?)
// $('#' + oid + '-textbox').trigger('autocompletevaluecleared');
},
focus() {
this.$refs.autocomplete.focus()
},
getDisplayText() {
if (this.assignedLabel) {
return this.assignedLabel
}
if (this.selected) {
return this.selected.display || this.selected.label
}
return ""
},
// TODO: should we allow custom callback? or is event enough?
// function (oid) {
// $('#' + oid + '-textbox').on('autocompletevaluecleared', function() {
// ${cleared_callback}();
// });
// }
selectionMade(option) {
this.selected = option
// TODO: should emit event for caller logic (can they cancel?)
// $('#' + oid + '-textbox').trigger('autocompletevalueselected',
// [ui.item.value, ui.item.label]);
},
// TODO: should we allow custom callback? or is event enough?
// function (oid) {
// $('#' + oid + '-textbox').on('autocompletevalueselected', function(event, uuid, label) {
// ${selected_callback}(uuid, label);
// });
// }
itemSelected(value) {
if (this.selected || !value) {
this.$emit('input', value)
}
},
// TODO: buefy example uses `debounce()` here and perhaps we should too?
// https://buefy.org/documentation/autocomplete
getAsyncData: debounce(function (entry) { getAsyncData: debounce(function (entry) {
// since the `@typing` event from buefy component does not
// "self-regulate" in any way, we a) use `debounce` above,
// but also b) skip the search unless we have at least 3
// characters of input from user
if (entry.length < 3) { if (entry.length < 3) {
this.data = [] this.data = []
return return
} }
this.isFetching = true
// and perform the search
this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry))
.then(({ data }) => { .then(({ data }) => {
this.data = data this.data = data
@ -112,10 +116,81 @@ const TailboneAutocomplete = {
this.data = [] this.data = []
throw error throw error
}) })
.finally(() => {
this.isFetching = false
})
}), }),
// this method is invoked via the `@select` event of the buefy
// autocomplete component. the `option` received will either
// be `null` or else a simple object with (at least) `value`
// and `label` properties
selectionMade(option) {
// we want to keep track of the "currently selected
// option" so we can display its label etc. also this
// helps control the visibility of the autocomplete input
// field vs. the button which indicates the field has a
// value
this.selected = option
// reset the internal value for buefy autocomplete
// component. note that this value will normally hold
// either the raw text entered by the user, or a uuid. we
// will not be needing either of those b/c they are not
// visible to user once selection is made, and if the
// selection is cleared we want user to start over anyway
this.buefyValue = null
// here is where we alert callers to the new value
this.$emit('input', option ? option.value : null)
},
// clear the field of any value, i.e. set the "currently
// selected option" to null. this is invoked when you click
// the button, which is visible while the field has a value.
// but callers can invoke it directly as well.
clearSelection(focus) {
// clear selection for the buefy autocomplete component
this.$refs.autocomplete.setSelected(null)
// maybe set focus to our (autocomplete) component
if (focus) {
this.$nextTick(function() {
this.focus()
})
}
},
// set focus to this component, which will just set focus to
// the buefy autocomplete component
focus() {
this.$refs.autocomplete.focus()
},
// this determines the "display text" for the button, which is
// shown when a selection has been made (or rather, when the
// field actually has a value)
getDisplayText() {
// always use the "assigned" label if we have one
// TODO: where is this used? what is the use case?
if (this.assignedLabel) {
return this.assignedLabel
}
// if we have a "currently selected option" then use its
// label. all search results / options have a `label`
// property as that is shown directly in the autocomplete
// dropdown. but if the option also has a `display`
// property then that is what we will show in the button.
// this way search results can show one thing in the
// search dropdown, and another in the button.
if (this.selected) {
return this.selected.display || this.selected.label
}
// we have nothing to go on here..
return ""
},
}, },
} }

View file

@ -65,12 +65,11 @@
<b-autocomplete ref="autocomplete" <b-autocomplete ref="autocomplete"
:name="name" :name="name"
v-show="!assignedValue && !selected" v-show="!assignedValue && !selected"
v-model="value" v-model="buefyValue"
:placeholder="placeholder" :placeholder="placeholder"
:data="data" :data="data"
@typing="getAsyncData" @typing="getAsyncData"
@select="selectionMade" @select="selectionMade"
@input="itemSelected"
keep-first> keep-first>
<template slot-scope="props"> <template slot-scope="props">
{{ props.option.label }} {{ props.option.label }}
@ -79,7 +78,7 @@
<b-button v-if="assignedValue || selected" <b-button v-if="assignedValue || selected"
style="width: 100%; justify-content: left;" style="width: 100%; justify-content: left;"
@click="clearSelection()"> @click="clearSelection(true)">
{{ getDisplayText() }} (click to change) {{ getDisplayText() }} (click to change)
</b-button> </b-button>

View file

@ -113,7 +113,7 @@
<div :style="{'flex-grow': contactNotes.length ? 0 : 1}"> <div :style="{'flex-grow': contactNotes.length ? 0 : 1}">
<b-field label="Customer" grouped> <b-field label="Customer" grouped>
<b-field style="margin-left: 1rem;"" <b-field style="margin-left: 1rem;"
:expanded="!contactUUID"> :expanded="!contactUUID">
<tailbone-autocomplete ref="contactAutocomplete" <tailbone-autocomplete ref="contactAutocomplete"
v-model="contactUUID" v-model="contactUUID"
@ -927,12 +927,17 @@
} }
}, },
watch: { watch: {
contactIsKnown: function(val) { contactIsKnown: function(val) {
// if user has already specified a proper contact, then
// clicks the "contact is unknown" button, then we want // if user has already specified a proper contact,
// to *clear out* the existing contact // i.e. `contactUUID` is not null, *and* user has
if (!val && this.contactUUID) { // clicked the "contact is not yet in the system"
this.contactChanged(null) // button, i.e. `val` is false, then we want to *clear
// out* the existing contact selection. this is
// primarily to avoid any ambiguity.
if (this.contactUUID && !val) {
this.$refs.contactAutocomplete.clearSelection()
} }
}, },
}, },
@ -1053,6 +1058,7 @@
% else: % else:
that.contactUUID = response.data.person_uuid that.contactUUID = response.data.person_uuid
% endif % endif
that.contactDisplay = response.data.contact_display
that.orderPhoneNumber = response.data.phone_number that.orderPhoneNumber = response.data.phone_number
that.orderEmailAddress = response.data.email_address that.orderEmailAddress = response.data.email_address
that.addOtherPhoneNumber = response.data.add_phone_number that.addOtherPhoneNumber = response.data.add_phone_number