From 4bfb64159e89b52fec7f0c11285ebdf3148c3459 Mon Sep 17 00:00:00 2001 From: Jose Farias <31393016+josefarias@users.noreply.github.com> Date: Mon, 29 Jul 2024 21:04:31 -0600 Subject: [PATCH] Create callback queue to run callbacks in order (#191) * Create callback queue to run callbacks in order * _runCallbackAfterEndOfOptionsStream -> _runCallback --- .../controllers/hw_combobox_controller.js | 40 ++++++-- .../hw_combobox/models/combobox.js | 1 + .../hw_combobox/models/combobox/callbacks.js | 45 +++++++++ .../hw_combobox/models/combobox/filtering.js | 3 +- .../hotwire_combobox/_pagination.html.erb | 2 +- test/system/hotwire_combobox_test.rb | 91 +++++++++++++++++++ 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/hw_combobox/models/combobox/callbacks.js diff --git a/app/assets/javascripts/controllers/hw_combobox_controller.js b/app/assets/javascripts/controllers/hw_combobox_controller.js index cc297dc..37e6027 100644 --- a/app/assets/javascripts/controllers/hw_combobox_controller.js +++ b/app/assets/javascripts/controllers/hw_combobox_controller.js @@ -1,5 +1,6 @@ import Combobox from "hw_combobox/models/combobox" import { Concerns, sleep } from "hw_combobox/helpers" +import { nextRepaint } from "hw_combobox/helpers" import { Controller } from "@hotwired/stimulus" window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0 // ms, for testing purposes @@ -10,6 +11,7 @@ const concerns = [ Combobox.Announcements, Combobox.AsyncLoading, Combobox.Autocomplete, + Combobox.Callbacks, Combobox.Dialog, Combobox.Events, Combobox.Filtering, @@ -61,6 +63,7 @@ export default class HwComboboxController extends Concerns(...concerns) { initialize() { this._initializeActors() this._initializeFiltering() + this._initializeCallbacks() } connect() { @@ -87,18 +90,39 @@ export default class HwComboboxController extends Concerns(...concerns) { } async endOfOptionsStreamTargetConnected(element) { - const inputType = element.dataset.inputType - const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY + if (element.dataset.callbackId) { + this._runCallback(element) + } else { + this._preselectSingle() + } + } + + async _runCallback(element) { + const callbackId = element.dataset.callbackId - this._resetMultiselectionMarks() + if (this._callbackAttemptsExceeded(callbackId)) { + this._dequeueCallback(callbackId) + return + } else { + this._recordCallbackAttempt(callbackId) + } + + if (this._isNextCallback(callbackId)) { + const inputType = element.dataset.inputType + const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY - if (inputType === "hw:multiselectSync") { - this.openByFocusing() - } else if (inputType && inputType !== "hw:lockInSelection") { if (delay) await sleep(delay) - this._selectOnQuery(inputType) + this._dequeueCallback(callbackId) + this._resetMultiselectionMarks() + + if (inputType === "hw:multiselectSync") { + this.openByFocusing() + } else if (inputType !== "hw:lockInSelection") { + this._selectOnQuery(inputType) + } } else { - this._preselectSingle() + await nextRepaint() + this._runCallback(element) } } diff --git a/app/assets/javascripts/hw_combobox/models/combobox.js b/app/assets/javascripts/hw_combobox/models/combobox.js index dedfcad..5d91dee 100644 --- a/app/assets/javascripts/hw_combobox/models/combobox.js +++ b/app/assets/javascripts/hw_combobox/models/combobox.js @@ -4,6 +4,7 @@ import "hw_combobox/models/combobox/actors" import "hw_combobox/models/combobox/announcements" import "hw_combobox/models/combobox/async_loading" import "hw_combobox/models/combobox/autocomplete" +import "hw_combobox/models/combobox/callbacks" import "hw_combobox/models/combobox/dialog" import "hw_combobox/models/combobox/events" import "hw_combobox/models/combobox/filtering" diff --git a/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js b/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js new file mode 100644 index 0000000..cb78843 --- /dev/null +++ b/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js @@ -0,0 +1,45 @@ +import Combobox from "hw_combobox/models/combobox/base" + +const MAX_CALLBACK_ATTEMPTS = 3 + +Combobox.Callbacks = Base => class extends Base { + _initializeCallbacks() { + this.callbackQueue = [] + this.callbackExecutionAttempts = {} + } + + _enqueueCallback() { + const callbackId = crypto.randomUUID() + this.callbackQueue.push(callbackId) + return callbackId + } + + _isNextCallback(callbackId) { + return this._nextCallback === callbackId + } + + _callbackAttemptsExceeded(callbackId) { + return this._callbackAttempts(callbackId) > MAX_CALLBACK_ATTEMPTS + } + + _callbackAttempts(callbackId) { + return this.callbackExecutionAttempts[callbackId] || 0 + } + + _recordCallbackAttempt(callbackId) { + this.callbackExecutionAttempts[callbackId] = this._callbackAttempts(callbackId) + 1 + } + + _dequeueCallback(callbackId) { + this.callbackQueue = this.callbackQueue.filter(id => id !== callbackId) + this._forgetCallbackExecutionAttempts(callbackId) + } + + _forgetCallbackExecutionAttempts(callbackId) { + delete this.callbackExecutionAttempts[callbackId] + } + + get _nextCallback() { + return this.callbackQueue[0] + } +} diff --git a/app/assets/javascripts/hw_combobox/models/combobox/filtering.js b/app/assets/javascripts/hw_combobox/models/combobox/filtering.js index a4f309c..7355cb5 100644 --- a/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +++ b/app/assets/javascripts/hw_combobox/models/combobox/filtering.js @@ -36,7 +36,8 @@ Combobox.Filtering = Base => class extends Base { const query = { q: this._fullQuery, input_type: inputType, - for_id: this.element.dataset.asyncId + for_id: this.element.dataset.asyncId, + callback_id: this._enqueueCallback() } await get(this.asyncSrcValue, { responseKind: "turbo-stream", query }) diff --git a/app/views/hotwire_combobox/_pagination.html.erb b/app/views/hotwire_combobox/_pagination.html.erb index 88c92ff..fb84b5f 100644 --- a/app/views/hotwire_combobox/_pagination.html.erb +++ b/app/views/hotwire_combobox/_pagination.html.erb @@ -1,7 +1,7 @@ <%# locals: (for_id:, src:) -%> <%= tag.li id: hw_pagination_frame_wrapper_id(for_id), class: "hw_combobox__pagination__wrapper", - data: { hw_combobox_target: "endOfOptionsStream", input_type: params[:input_type] }, + data: { hw_combobox_target: "endOfOptionsStream", input_type: params[:input_type], callback_id: params[:callback_id] }, aria: { hidden: true } do %> <%= turbo_frame_tag hw_pagination_frame_id(for_id), src: src, loading: :lazy %> <% end %> diff --git a/test/system/hotwire_combobox_test.rb b/test/system/hotwire_combobox_test.rb index 140eb0b..dbf6b37 100644 --- a/test/system/hotwire_combobox_test.rb +++ b/test/system/hotwire_combobox_test.rb @@ -175,11 +175,102 @@ class HotwireComboboxTest < ApplicationSystemTestCase open_combobox "#state-field" type_in_combobox "#state-field", "is" + assert_selected_option_with text: "Illinois" click_away assert_closed_combobox assert_combobox_display_and_value "#state-field", "Illinois", "IL" open_combobox "#state-field" + "Illinois".chars.each { type_in_combobox "#state-field", :arrow_right } + type_in_combobox "#state-field", :backspace + assert_combobox_display_and_value "#state-field", "Illinoi", nil + assert_no_visible_selected_option + click_away + assert_closed_combobox + assert_combobox_display_and_value "#state-field", "Illinois", "IL" + + on_small_screen do + visit html_options_path + + open_combobox "#state-field" + + within "dialog" do + type_in_combobox "#state-field-hw-dialog-combobox", "il" + assert_selected_option_with text: "Illinois" + assert_combobox_display "#state-field-hw-dialog-combobox", "Illinois" + end + + click_away + assert_closed_combobox + assert_combobox_display_and_value "#state-field", "Illinois", "IL" + + open_combobox "#state-field" + + within "dialog" do + "Illinois".chars.each { type_in_combobox "#state-field-hw-dialog-combobox", :arrow_right } + type_in_combobox "#state-field-hw-dialog-combobox", :backspace + assert_combobox_display "#state-field-hw-dialog-combobox", "Illinoi" + assert_no_visible_selected_option + end + + click_away + assert_closed_combobox + assert_combobox_display_and_value "#state-field", "Illinois", "IL" + end + + open_combobox "#state-field" + assert_options_with count: 1 + end + + test "clicking away locks in the current selection for async comboboxes" do + visit async_path + + open_combobox "#movie-field" + type_in_combobox "#movie-field", "wh" + assert_selected_option_with text: "Whiplash" + click_away + assert_closed_combobox + assert_combobox_display_and_value "#movie-field", "Whiplash", movies(:whiplash).id + + open_combobox "#movie-field" + "Whiplash".chars.each { type_in_combobox "#movie-field", :arrow_right } + type_in_combobox "#movie-field", :backspace + assert_combobox_display_and_value "#movie-field", "Whiplas", nil + assert_no_visible_selected_option + click_away + assert_closed_combobox + assert_combobox_display_and_value "#movie-field", "Whiplash", movies(:whiplash).id + + on_small_screen do + visit async_path + + open_combobox "#movie-field" + + within "dialog" do + type_in_combobox "#movie-field-hw-dialog-combobox", "wh" + assert_selected_option_with text: "Whiplash" + assert_combobox_display "#movie-field-hw-dialog-combobox", "Whiplash" + end + + click_away + assert_closed_combobox + assert_combobox_display_and_value "#movie-field", "Whiplash", movies(:whiplash).id + + open_combobox "#movie-field" + + within "dialog" do + "Whiplash".chars.each { type_in_combobox "#movie-field-hw-dialog-combobox", :arrow_right } + type_in_combobox "#movie-field-hw-dialog-combobox", :backspace + assert_combobox_display "#movie-field-hw-dialog-combobox", "Whiplas" + assert_no_visible_selected_option + end + + click_away + assert_closed_combobox + assert_combobox_display_and_value "#movie-field", "Whiplash", movies(:whiplash).id + end + + open_combobox "#movie-field" assert_options_with count: 1 end