diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index 9b28f6b3..7969f35a 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -4,16 +4,54 @@ const TailboneAutocomplete = { template: '#tailbone-autocomplete-template', 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, + + // 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, + + // 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, + + // 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, - 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, + + // simple placeholder text for the input box placeholder: String, + + // TODO: pretty sure this can be ignored..? + // (should deprecate / remove if so) + assignedValue: String, }, 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 if (this.value) { selected = { @@ -21,89 +59,55 @@ const TailboneAutocomplete = { label: this.initialLabel, } } - return { - data: [], - selected: selected, - isFetching: false, - } - }, - watch: { - value(to, from) { - if (from && !to) { - this.clearSelection(false) - } - }, + return { + + // this contains the search results; its contents may + // 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: { - clearSelection(focus) { - if (focus === undefined) { - focus = true - } - this.selected = null - this.value = null - 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 + // fetch new search results from the server. this is invoked + // via the `@typing` event from buefy autocomplete component. + // the doc at https://buefy.org/documentation/autocomplete + // mentions `debounce` as being optional. at one point i + // thought it would fix a performance bug; not sure `debounce` + // helped but figured might as well leave it 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) { this.data = [] return } - this.isFetching = true + + // and perform the search this.$http.get(this.serviceUrl + '?term=' + encodeURIComponent(entry)) .then(({ data }) => { this.data = data @@ -112,10 +116,81 @@ const TailboneAutocomplete = { this.data = [] 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 "" + }, }, } diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index e7aad900..7961d07c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -65,12 +65,11 @@