From 9b31053c7babbca4e6d1c0b668c615305e0db704 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Tue, 18 Jun 2024 12:57:13 -0700 Subject: [PATCH 01/13] Default to `no opinion` in edit form if no vote currently --- app/helpers/namings_helper.rb | 7 +++++-- app/views/controllers/observations/namings/_fields.erb | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/helpers/namings_helper.rb b/app/helpers/namings_helper.rb index 82aa48712b..dafa4ebf7d 100644 --- a/app/helpers/namings_helper.rb +++ b/app/helpers/namings_helper.rb @@ -256,9 +256,12 @@ def your_vote_html(naming, vote) def naming_vote_form(naming, vote, context: "blank") vote_id = vote&.id method = vote_id ? :patch : :post - menu = Vote.confidence_menu can_vote = check_permission(naming) - menu = [Vote.no_opinion] + menu if !can_vote || !vote || vote&.value&.zero? + menu = if !can_vote || !vote || vote&.value&.zero? + Vote.opinion_menu + else + Vote.confidence_menu + end localizations = { lose_changes: :show_namings_lose_changes.l.tr("\n", " "), saving: :show_namings_saving.l diff --git a/app/views/controllers/observations/namings/_fields.erb b/app/views/controllers/observations/namings/_fields.erb index 503f1597f1..fe7b22e531 100644 --- a/app/views/controllers/observations/namings/_fields.erb +++ b/app/views/controllers/observations/namings/_fields.erb @@ -14,7 +14,12 @@ feedback_locals = { parent_deprecated: @parent_deprecated, names: @names } -confidences = options_for_select(Vote.confidence_menu, @vote&.value) +menu = unless @vote&.nonzero? + Vote.opinion_menu + else + Vote.confidence_menu + end +confidences = options_for_select(menu, @vote&.value) select_opts = { include_blank: ["new", "create"].include?(action_name) } context ||= "blank" name_help ||= :form_naming_name_help.t From 5e91f71067365baf8acaff510befd3269ade6755 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Tue, 18 Jun 2024 13:07:49 -0700 Subject: [PATCH 02/13] Add test --- test/controllers/observations/namings_controller_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/controllers/observations/namings_controller_test.rb b/test/controllers/observations/namings_controller_test.rb index 94e03fcc8e..3e39dd3260 100644 --- a/test/controllers/observations/namings_controller_test.rb +++ b/test/controllers/observations/namings_controller_test.rb @@ -44,6 +44,7 @@ def test_edit_naming_no_votes assert_empty(nam.votes) login(nam.user.login) get(:edit, params: { observation_id: nam.observation_id, id: nam.id }) + assert_select("#naming_vote_value", text: /#{:vote_no_opinion.l}/) end def test_update_observation_new_name From cd4e144c65f2e3d6e98e0ce9f6a86664e57c7ae6 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Tue, 18 Jun 2024 14:11:59 -0700 Subject: [PATCH 03/13] Update _fields.erb --- app/views/controllers/observations/namings/_fields.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/controllers/observations/namings/_fields.erb b/app/views/controllers/observations/namings/_fields.erb index fe7b22e531..7b47857a37 100644 --- a/app/views/controllers/observations/namings/_fields.erb +++ b/app/views/controllers/observations/namings/_fields.erb @@ -14,7 +14,7 @@ feedback_locals = { parent_deprecated: @parent_deprecated, names: @names } -menu = unless @vote&.nonzero? +menu = unless @vote&.value&.nonzero? Vote.opinion_menu else Vote.confidence_menu From d441619d46ad991025e3b978bfe58074c2021c54 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Mon, 17 Jun 2024 19:15:46 -0700 Subject: [PATCH 04/13] Refactor autocompleters to use query params --- app/classes/auto_complete.rb | 32 ++++---- app/classes/auto_complete/for_location.rb | 4 +- app/controllers/autocompleters_controller.rb | 18 ++--- app/helpers/forms_helper.rb | 2 +- .../controllers/autocompleter_controller.js | 81 +++++++++++-------- config/routes.rb | 2 +- .../autocompleters_controller_test.rb | 41 +++++----- test/models/auto_complete_test.rb | 10 +-- 8 files changed, 102 insertions(+), 88 deletions(-) diff --git a/app/classes/auto_complete.rb b/app/classes/auto_complete.rb index d1bbab7d1e..03466d110d 100644 --- a/app/classes/auto_complete.rb +++ b/app/classes/auto_complete.rb @@ -9,10 +9,10 @@ # ################################################################################ -PUNCTUATION = '[ -\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]' - class AutoComplete - attr_accessor :string, :matches + attr_accessor :string, :matches, :all + + PUNCTUATION = '[ -\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]' def limit 1000 @@ -24,14 +24,18 @@ def self.subclass(type) raise("Invalid auto-complete type: #{type.inspect}") end - def initialize(string, _params = {}) - self.string = string.to_s.strip_squeeze + def initialize(params = {}) + self.string = params[:string].to_s.strip_squeeze + self.all = params[:all].present? end def matching_strings + # just use the first letter of the string to define the matches self.matches = rough_matches(string[0]) clean_matches - minimal_string = refine_matches + return matches if all + + minimal_string = refine_matches # defined in subclass truncate_matches [minimal_string] + matches # [[minimal_string, nil]] + matches @@ -39,14 +43,6 @@ def matching_strings private - def truncate_matches - return unless matches.length > limit - - matches.slice!(limit..-1) - matches.push("...") - # matches.push(["...", nil]) - end - def clean_matches matches.map! do |str| str.sub(/\s*[\r\n]\s*.*/m, "").sub(/\A\s+/, "").sub(/\s+\Z/, "") @@ -57,4 +53,12 @@ def clean_matches # end matches.uniq! end + + def truncate_matches + return unless matches.length > limit + + matches.slice!(limit..-1) + matches.push("...") + # matches.push(["...", nil]) + end end diff --git a/app/classes/auto_complete/for_location.rb b/app/classes/auto_complete/for_location.rb index ce08aa6c52..717765e0c8 100644 --- a/app/classes/auto_complete/for_location.rb +++ b/app/classes/auto_complete/for_location.rb @@ -10,8 +10,8 @@ class AutoComplete::ForLocation < AutoComplete::ByWord attr_accessor :reverse - def initialize(string, params) - super(string, params) + def initialize(params) + super self.reverse = (params[:format] == "scientific") end diff --git a/app/controllers/autocompleters_controller.rb b/app/controllers/autocompleters_controller.rb index 41b43f1483..7022c6015a 100644 --- a/app/controllers/autocompleters_controller.rb +++ b/app/controllers/autocompleters_controller.rb @@ -16,32 +16,32 @@ class AutocompletersController < ApplicationController # could add record ids. The first line of the returned results is the actual # (minimal) string used to match the records. If it had to truncate the list # of results, the last string is "...". - # type:: Type of string. - # id:: String user has entered. + # type:: Type of string. + # params[:string]:: String user has entered. def new @user = User.current = session_user - string = CGI.unescape(@id).strip_squeeze - if string.blank? + if params[:string].blank? && params[:all].blank? render(json: ActiveSupport::JSON.encode([])) else - render(json: ActiveSupport::JSON.encode(auto_complete_results(string))) + render(json: ActiveSupport::JSON.encode(auto_complete_results)) end end private - def auto_complete_results(string) + def auto_complete_results case @type - when "location" + when "location", "location_containing" params[:format] = @user&.location_format when "herbarium" params[:user_id] = @user&.id end - ::AutoComplete.subclass(@type).new(string, params).matching_strings + ::AutoComplete.subclass(@type).new(params).matching_strings end + # callback on `around_action` def catch_ajax_errors prepare_parameters yield @@ -53,8 +53,6 @@ def catch_ajax_errors def prepare_parameters @type = params[:type].to_s - @id = params[:id].to_s - @value = params[:value].to_s end def backtrace(exception) diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 6314b55ce7..bf8d738c6d 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -148,7 +148,7 @@ def text_field_with_label(**args) # our autocompleter: use the browser standard autocomplete att "one-time-code" def autocompleter_field(**args) autocompleter_args = { - placeholder: :start_typing.l, autocomplete: "one-time-code", + placeholder: :start_typing.l, autocomplete: "off", data: { controller: :autocompleter, autocompleter_target: "input", autocomplete: args[:autocomplete], separator: args[:separator] } }.deep_merge(args.except(:autocomplete, :separator, :textarea)) diff --git a/app/javascript/controllers/autocompleter_controller.js b/app/javascript/controllers/autocompleter_controller.js index bdc1dc0515..cc50722f8c 100644 --- a/app/javascript/controllers/autocompleter_controller.js +++ b/app/javascript/controllers/autocompleter_controller.js @@ -14,7 +14,7 @@ const DEFAULT_OPTS = { // N = etc. COLLAPSE: 0, // where to request primer from - AJAX_URL: null, + AJAX_URL: "/autocompleters/new/", // how long to wait before sending AJAX request (seconds) REFRESH_DELAY: 0.10, // how long to wait before hiding pulldown (seconds) @@ -28,7 +28,7 @@ const DEFAULT_OPTS = { // amount to move cursor on page up and down PAGE_SIZE: 10, // max length of string to send via AJAX - MAX_REQUEST_LINK: 50, + MAX_STRING_LENGTH: 50, // Sub-match: starts finding new matches for the string *after the separator* // allowed separators (e.g. " OR ") SEPARATOR: null, @@ -45,34 +45,26 @@ const DEFAULT_OPTS = { // Allowed types of autocompleter. Sets some DEFAULT_OPTS from type const AUTOCOMPLETER_TYPES = { clade: { - AJAX_URL: "/autocompleters/new/clade/@", }, herbarium: { // params[:user_id] handled in controller - AJAX_URL: "/autocompleters/new/herbarium/@", UNORDERED: true }, location: { // params[:format] handled in controller - AJAX_URL: "/autocompleters/new/location/@", UNORDERED: true }, name: { - AJAX_URL: "/autocompleters/new/name/@", COLLAPSE: 1 }, project: { - AJAX_URL: "/autocompleters/new/project/@", UNORDERED: true }, region: { - AJAX_URL: "/autocompleters/new/location/@", UNORDERED: true }, species_list: { - AJAX_URL: "/autocompleters/new/species_list/@", UNORDERED: true }, user: { - AJAX_URL: "/autocompleters/new/user/@", UNORDERED: true } } @@ -103,6 +95,8 @@ const INTERNAL_OPTS = { // Connects to data-controller="autocomplete" export default class extends Controller { + // The select target is not the input element, but a that can - // swap out the autocompleter type. The input element is the target. static targets = ["input", "select"] initialize() { @@ -161,14 +167,7 @@ export default class extends Controller { this.add_event_listeners(); // sanity check to show which autocompleter is currently on the element - this.inputTarget.setAttribute("data-ajax-url", this.AJAX_URL + this.TYPE); - - // If the primer is not based on input, go ahead and request from server. - if (this.ACT_LIKE_SELECT == true) { - this.inputTarget.click(); - this.inputTarget.focus(); - this.inputTarget.value = ' '; - } + this.inputTarget.setAttribute("data-ajax-url", this.AJAX_URL); } // NOTE: `this` within an event listener function refers to the element @@ -425,30 +424,30 @@ export default class extends Controller { go_end() { this.move_cursor(this.matches.length) } move_cursor(rows) { this.verbose("move_cursor()"); - const _old_row = this.current_row, - _old_scr = this.scroll_offset; - let _new_row = _old_row + rows, - _new_scr = _old_scr; + const old_row = this.current_row, + old_scr = this.scroll_offset; + let new_row = old_row + rows, + new_scr = old_scr; // Move cursor, but keep in bounds. - if (_new_row < 0) - _new_row = _old_row < 0 ? -1 : 0; - if (_new_row >= this.matches.length) - _new_row = this.matches.length - 1; - this.current_row = _new_row; - this.current_value = _new_row < 0 ? null : this.matches[_new_row]; + if (new_row < 0) + new_row = old_row < 0 ? -1 : 0; + if (new_row >= this.matches.length) + new_row = this.matches.length - 1; + this.current_row = new_row; + this.current_value = new_row < 0 ? null : this.matches[new_row]; // Scroll view so new row is visible. - if (_new_row < _new_scr) - _new_scr = _new_row; - if (_new_scr < 0) - _new_scr = 0; - if (_new_row >= _new_scr + this.PULLDOWN_SIZE) - _new_scr = _new_row - this.PULLDOWN_SIZE + 1; + if (new_row < new_scr) + new_scr = new_row; + if (new_scr < 0) + new_scr = 0; + if (new_row >= new_scr + this.PULLDOWN_SIZE) + new_scr = new_row - this.PULLDOWN_SIZE + 1; // Update if something changed. - if (_new_row != _old_row || _new_scr != _old_scr) { - this.scroll_offset = _new_scr; + if (new_row != old_row || new_scr != old_scr) { + this.scroll_offset = new_scr; this.draw_pulldown(); } } @@ -456,17 +455,17 @@ export default class extends Controller { // Mouse has moved over a menu item. highlight_row(new_hl) { this.verbose("highlight_row()"); - const _rows = this.LIST_ELEM.children, - _old_hl = this.current_highlight; + const rows = this.LIST_ELEM.children, + old_hl = this.current_highlight; this.current_highlight = new_hl; this.current_row = this.scroll_offset + new_hl; - if (_old_hl != new_hl) { - if (_old_hl >= 0) - _rows[_old_hl].classList.remove(this.HOT_CLASS); + if (old_hl != new_hl) { + if (old_hl >= 0) + rows[old_hl].classList.remove(this.HOT_CLASS); if (new_hl >= 0) - _rows[new_hl].classList.add(this.HOT_CLASS); + rows[new_hl].classList.add(this.HOT_CLASS); } this.inputTarget.focus(); this.update_width(); @@ -475,18 +474,18 @@ export default class extends Controller { // Called when users scrolls via scrollbar. our_scroll() { this.verbose("our_scroll()"); - const _old_scr = this.scroll_offset, - _new_scr = Math.round(this.PULLDOWN_ELEM.scrollTop / this.ROW_HEIGHT), - _old_row = this.current_row; - let _new_row = this.current_row; - - if (_new_row < _new_scr) - _new_row = _new_scr; - if (_new_row >= _new_scr + this.PULLDOWN_SIZE) - _new_row = _new_scr + this.PULLDOWN_SIZE - 1; - if (_new_row != _old_row || _new_scr != _old_scr) { - this.current_row = _new_row; - this.scroll_offset = _new_scr; + const old_scr = this.scroll_offset, + new_scr = Math.round(this.PULLDOWN_ELEM.scrollTop / this.ROW_HEIGHT), + old_row = this.current_row; + let new_row = this.current_row; + + if (new_row < new_scr) + new_row = new_scr; + if (new_row >= new_scr + this.PULLDOWN_SIZE) + new_row = new_scr + this.PULLDOWN_SIZE - 1; + if (new_row != old_row || new_scr != old_scr) { + this.current_row = new_row; + this.scroll_offset = new_scr; this.draw_pulldown(); } } @@ -495,20 +494,20 @@ export default class extends Controller { select_row(row) { this.verbose("select_row()"); // const old_val = this.inputTarget.value; - let _new_val = this.matches[this.scroll_offset + row]; + let new_val = this.matches[this.scroll_offset + row]; // Close pulldown unless the value the user selected uncollapses into a set // of new options. In that case schedule a refresh and leave it up. if (this.COLLAPSE > 0 && - (_new_val.match(/ /g) || []).length < this.COLLAPSE) { - _new_val += ' '; + (new_val.match(/ /g) || []).length < this.COLLAPSE) { + new_val += ' '; this.schedule_refresh(); } else { this.schedule_hide(); } this.inputTarget.focus(); this.focused = true; - this.inputTarget.value = _new_val; - this.set_search_token(_new_val); + this.inputTarget.value = new_val; + this.set_search_token(new_val); this.our_change(false); } @@ -516,25 +515,23 @@ export default class extends Controller { // Create div for pulldown. Presence of this is checked in system tests. create_pulldown() { - const _pulldown = document.createElement("div"); - _pulldown.classList.add(this.PULLDOWN_CLASS); + const div = document.createElement("div"); + div.classList.add(this.PULLDOWN_CLASS); - const _list = document.createElement('ul'); - _list.classList.add(this.LIST_CLASS); - - let i, _item; + const list = document.createElement('ul'); + let i, row; for (i = 0; i < this.PULLDOWN_SIZE; i++) { - _item = document.createElement("li"); - _item.style.display = 'none'; - this.attach_row_events(_item, i); - _list.append(_item); + row = document.createElement("li"); + row.style.display = 'none'; + this.attach_row_events(row, i); + list.append(row); } - _pulldown.appendChild(_list) + div.appendChild(list) - _pulldown.addEventListener("scroll", this.our_scroll.bind(this)); - this.inputTarget.insertAdjacentElement("afterend", _pulldown); - this.PULLDOWN_ELEM = _pulldown; - this.LIST_ELEM = _list; + div.addEventListener("scroll", this.our_scroll.bind(this)); + this.inputTarget.insertAdjacentElement("afterend", div); + this.PULLDOWN_ELEM = div; + this.LIST_ELEM = list; } // Add "click" and "mouseover" events to a row of the pulldown menu. @@ -585,48 +582,48 @@ export default class extends Controller { // Redraw the pulldown options. draw_pulldown() { this.verbose("draw_pulldown()"); - const _list = this.LIST_ELEM, - _rows = _list.children, - _size = this.PULLDOWN_SIZE, - _scroll = this.scroll_offset, - _current = this.current_row, - _matches = this.matches; + const list = this.LIST_ELEM, + rows = list.children, + size = this.PULLDOWN_SIZE, + scroll = this.scroll_offset, + cur = this.current_row, + matches = this.matches; if (this.log) { this.debug( - "Redraw: matches=" + _matches.length + ", scroll=" + _scroll + ", cursor=" + _current + "Redraw: matches=" + matches.length + ", scroll=" + scroll + ", cursor=" + cur ); } // Get row height if haven't been able to yet. this.get_row_height(); - if (_rows.length) { - this.update_rows(_rows, _matches, _size, _scroll); - this.highlight_new_row(_rows, _current, _size, _scroll) - this.make_menu_visible(_matches, _size, _scroll) + if (rows.length) { + this.update_rows(rows, matches, size, scroll); + this.highlight_new_row(rows, cur, size, scroll) + this.make_menu_visible(matches, size, scroll) } // Make sure input focus stays on text field! this.inputTarget.focus(); } - // Update menu text first - add from stored matches. + // Update menu text first. update_rows(rows, matches, size, scroll) { let i, x, y; for (i = 0; i < size; i++) { - let _row = rows.item(i); - x = _row.innerHTML; + let row = rows.item(i); + x = row.innerHTML; if (i + scroll < matches.length) { y = this.escapeHTML(matches[i + scroll]); if (x != y) { if (x == '') - _row.style.display = 'block'; - _row.innerHTML = y; + row.style.display = 'block'; + row.innerHTML = y; } } else { if (x != '') { - _row.innerHTML = ''; - _row.style.display = 'none'; + row.innerHTML = ''; + row.style.display = 'none'; } } } @@ -634,42 +631,41 @@ export default class extends Controller { // Highlight that row. highlight_new_row(rows, cur, size, scroll) { - const _old_hl = this.current_highlight; - let _new_hl = cur - scroll; - - if (_new_hl < 0 || _new_hl >= size) - _new_hl = -1; - this.current_highlight = _new_hl; - if (_new_hl != _old_hl) { - if (_old_hl >= 0) - rows[_old_hl].classList.remove(this.HOT_CLASS); - if (_new_hl >= 0) - rows[_new_hl].classList.add(this.HOT_CLASS); + const old_hl = this.current_highlight; + let new_hl = cur - scroll; + + if (new_hl < 0 || new_hl >= size) + new_hl = -1; + this.current_highlight = new_hl; + if (new_hl != old_hl) { + if (old_hl >= 0) + rows[old_hl].classList.remove(this.HOT_CLASS); + if (new_hl >= 0) + rows[new_hl].classList.add(this.HOT_CLASS); } } // Make menu visible if nonempty. make_menu_visible(matches, size, scroll) { - const _pulldown = this.PULLDOWN_ELEM, - _list = _pulldown.children[0]; + const menu = this.PULLDOWN_ELEM, + inner = menu.children[0]; if (matches.length > 0) { // console.log("Matches:" + matches) - const _top = this.inputTarget.offsetTop, - _left = this.inputTarget.offsetLeft, - _hgt = this.inputTarget.offsetHeight, - _scr = this.inputTarget.scrollTop; - _pulldown.style.top = (_top + _hgt + _scr) + "px"; - _pulldown.style.left = _left + "px"; + const top = this.inputTarget.offsetTop, + left = this.inputTarget.offsetLeft, + hgt = this.inputTarget.offsetHeight, + scr = this.inputTarget.scrollTop; + menu.style.top = (top + hgt + scr) + "px"; + menu.style.left = left + "px"; // Set height of menu. - _pulldown.style.overflowY = matches.length > size ? "scroll" : "hidden"; - _pulldown.style.height = this.ROW_HEIGHT * - (size < matches.length - scroll ? size : matches.length - scroll) + - "px"; - _list.style.marginTop = this.ROW_HEIGHT * scroll + "px"; - _list.style.height = this.ROW_HEIGHT * (matches.length - scroll) + "px"; - _pulldown.scrollTo({ top: this.ROW_HEIGHT * scroll }); + menu.style.overflowY = matches.length > size ? "scroll" : "hidden"; + menu.style.height = this.ROW_HEIGHT * (size < matches.length - scroll ? size : matches.length - scroll) + "px"; + inner.style.marginTop = this.ROW_HEIGHT * scroll + "px"; + inner.style.height = this.ROW_HEIGHT * (matches.length - scroll) + "px"; + menu.scrollTo({ top: this.ROW_HEIGHT * scroll }); + // } // Set width of menu. this.set_width(); @@ -679,17 +675,17 @@ export default class extends Controller { // the value that's already in the text field. if (matches.length > 1 || this.inputTarget.value != matches[0]) { this.clear_hide(); - _pulldown.style.display = 'block'; + menu.style.display = 'block'; this.menu_up = true; } else { - _pulldown.style.display = 'none'; + menu.style.display = 'none'; this.menu_up = false; } } // Hide the menu if it's empty now. else { - _pulldown.style.display = 'none'; + menu.style.display = 'none'; this.menu_up = false; } } @@ -728,10 +724,9 @@ export default class extends Controller { // Update content of pulldown. update_matches() { this.verbose("update_matches()"); - if (this.ACT_LIKE_SELECT) - this.current_row = 0; + // Remember which option used to be highlighted. - const _last = this.current_row < 0 ? null : this.matches[this.current_row]; + const last = this.current_row < 0 ? null : this.matches[this.current_row]; // Update list of options appropriately. if (this.ACT_LIKE_SELECT) @@ -743,107 +738,103 @@ export default class extends Controller { else this.update_normal(); - // Sort and remove duplicates, unless it's already sorted. - if (!this.ACT_LIKE_SELECT) - this.matches = this.remove_dups(this.matches.sort()); + // Sort and remove duplicates. + this.matches = this.remove_dups(this.matches.sort()); // Try to find old highlighted row in new set of options. - this.update_current_row(_last); + this.update_current_row(last); // Reset width each time we change the options. this.current_width = this.inputTarget.offsetWidth; } // When "acting like a select" make it display all options in the - // order given right from the moment they enter the field, - // and pick the first one. + // order given right from the moment they enter the field. update_select() { this.matches = this.primer; - if (this.matches.length > 0) - this.inputTarget.value = this.matches[0]; } // Grab all matches, doing exact match, ignoring number of words. update_normal() { - const _token = this.get_search_token().normalize().toLowerCase(), + const val = this.get_search_token().normalize().toLowerCase(), // normalize the Unicode of each string in primer for search - _primer = this.primer.map((str) => { return str.normalize() }), - _matches = []; - - if (_token != '') { - for (let i = 0; i < _primer.length; i++) { - let s = _primer[i + 1]; - if (s && s.length > 0 && s.toLowerCase().indexOf(_token) >= 0) { - _matches.push(s); + primer = this.primer.map((str) => { return str.normalize() }), + matches = []; + + if (val != '') { + for (let i = 0; i < primer.length; i++) { + let s = primer[i + 1]; + if (s && s.length > 0 && s.toLowerCase().indexOf(val) >= 0) { + matches.push(s); } } } - this.matches = _matches; + this.matches = matches; } // Grab matches ignoring order of words. update_unordered() { // regularize spacing in the input - const _token = this.get_search_token().normalize().toLowerCase(). + const val = this.get_search_token().normalize().toLowerCase(). replace(/^ */, '').replace(/ +/g, ' '), - // get the separate words as _tokens - _tokens = _token.split(' '), + // get the separate words as vals + vals = val.split(' '), // normalize the Unicode of each string in primer for search - _primer = this.primer.map((str) => { return str.normalize() }), - _matches = []; + primer = this.primer.map((str) => { return str.normalize() }), + matches = []; - if (_token != '' && _primer.length > 1) { - for (let i = 1; i < _primer.length; i++) { - let s = _primer[i] || '', + if (val != '' && primer.length > 1) { + for (let i = 1; i < primer.length; i++) { + let s = primer[i] || '', s2 = ' ' + s.toLowerCase() + ' ', k; // check each word in the primer entry for a matching word - for (k = 0; k < _tokens.length; k++) { - if (s2.indexOf(' ' + _tokens[k]) < 0) break; + for (k = 0; k < vals.length; k++) { + if (s2.indexOf(' ' + vals[k]) < 0) break; } - if (k >= _tokens.length) { - _matches.push(s); + if (k >= vals.length) { + matches.push(s); } } } - this.matches = _matches; + this.matches = matches; } // Grab all matches, preferring the ones with no additional words. // Note: order must have genera first, then species, then varieties. update_collapsed() { - const _token = this.get_search_token().toLowerCase(), - _primer = this.primer, + const val = this.get_search_token().toLowerCase(), + primer = this.primer, // make a lowercased duplicate of primer to regularize search - _primer_lc = this.primer.map((str) => { return str.toLowerCase() }), - _matches = []; - - if (_token != '' && _primer.length > 1) { - let _the_rest = (_token.match(/ /g) || []).length >= this.COLLAPSE; - - for (let i = 1; i < _primer_lc.length; i++) { - if (_primer_lc[i].indexOf(_token) > -1) { - let _s = _primer[i]; - if (_s.length > 0) { - if (_the_rest || _s.indexOf(' ', _token.length) < _token.length) { - _matches.push(_s); - } else if (_matches.length > 1) { + primer_lc = this.primer.map((str) => { return str.toLowerCase() }), + matches = []; + + if (val != '' && primer.length > 1) { + let the_rest = (val.match(/ /g) || []).length >= this.COLLAPSE; + + for (let i = 1; i < primer_lc.length; i++) { + if (primer_lc[i].indexOf(val) > -1) { + let s = primer[i]; + if (s.length > 0) { + if (the_rest || s.indexOf(' ', val.length) < val.length) { + matches.push(s); + } else if (matches.length > 1) { break; } else { - if (_matches[0] == _token) - _matches.pop(); - _matches.push(_s); - _the_rest = true; + if (matches[0] == val) + matches.pop(); + matches.push(s); + the_rest = true; } } } } - if (_matches.length == 1 && - (_token == matches[0].toLowerCase() || - _token == matches[0].toLowerCase() + ' ')) - _matches.pop(); + if (matches.length == 1 && + (val == matches[0].toLowerCase() || + val == matches[0].toLowerCase() + ' ')) + matches.pop(); } - this.matches = _matches; + this.matches = matches; } /** @@ -879,35 +870,35 @@ export default class extends Controller { // otherwise highlight first match. update_current_row(val) { this.verbose("update_current_row()"); - const _matches = this.matches, - _size = this.PULLDOWN_SIZE; - let _exact = -1, - _part = -1; + const matches = this.matches, + size = this.PULLDOWN_SIZE; + let exact = -1, + part = -1; if (val && val.length > 0) { - for (let i = 0; i < _matches.length; i++) { - if (_matches[i] == val) { - _exact = i; + for (let i = 0; i < matches.length; i++) { + if (matches[i] == val) { + exact = i; break; } - if (_matches[i] == val.substr(0, _matches[i].length) && - (_part < 0 || _matches[i].length > _matches[_part].length)) - _part = i; + if (matches[i] == val.substr(0, matches[i].length) && + (part < 0 || matches[i].length > matches[part].length)) + part = i; } } - let _new_row = _exact >= 0 ? _exact : _part >= 0 ? _part : -1; - let _new_scroll = this.scroll_offset; - if (_new_scroll > _new_row) - _new_scroll = _new_row; - if (_new_scroll > _matches.length - _size) - _new_scroll = _matches.length - _size; - if (_new_scroll < _new_row - _size + 1) - _new_scroll = _new_row - _size + 1; - if (_new_scroll < 0) - _new_scroll = 0; - - this.current_row = _new_row; - this.scroll_offset = _new_scroll; + let new_row = exact >= 0 ? exact : part >= 0 ? part : -1; + let new_scroll = this.scroll_offset; + if (new_scroll > new_row) + new_scroll = new_row; + if (new_scroll > matches.length - size) + new_scroll = matches.length - size; + if (new_scroll < new_row - size + 1) + new_scroll = new_row - size + 1; + if (new_scroll < 0) + new_scroll = 0; + + this.current_row = new_row; + this.scroll_offset = new_scroll; } /** @@ -924,53 +915,53 @@ export default class extends Controller { // Get search token under or immediately in front of cursor. get_search_token() { - const _val = this.inputTarget.value; - let _token = _val; + const val = this.inputTarget.value; + let token = val; if (this.SEPARATOR) { - const _extents = this.search_token_extents(); - _token = _val.substring(_extents.start, _extents.end); + const s_ext = this.search_token_extents(); + token = val.substring(s_ext.start, s_ext.end); } - return _token; + return token; } // Change the token under or immediately in front of the cursor. set_search_token(new_val) { - const _old_str = this.inputTarget.value; + const old_str = this.inputTarget.value; if (this.SEPARATOR) { - let _new_str = ""; - const _extents = this.search_token_extents(); - - if (_extents.start > 0) - _new_str += _old_str.substring(0, _extents.start); - _new_str += new_val; - - if (_extents.end < _old_str.length) - _new_str += _old_str.substring(_extents.end); - if (_old_str != _new_str) { - let _old_scroll = this.inputTarget.offsetTop; - this.inputTarget.value = _new_str; + let new_str = ""; + const s_ext = this.search_token_extents(); + + if (s_ext.start > 0) + new_str += old_str.substring(0, s_ext.start); + new_str += new_val; + + if (s_ext.end < old_str.length) + new_str += old_str.substring(s_ext.end); + if (old_str != new_str) { + var old_scroll = this.inputTarget.offsetTop; + this.inputTarget.value = new_str; this.setCursorPosition(this.inputTarget[0], - _extents.start + new_val.length); - this.inputTarget.offsetTop = _old_scroll; + s_ext.start + new_val.length); + this.inputTarget.offsetTop = old_scroll; } } else { - if (_old_str != new_val) + if (old_str != new_val) this.inputTarget.value = new_val; } } // Get index of first character and character after last of current token. search_token_extents() { - const _val = this.inputTarget.value; - let start = _val.lastIndexOf(this.SEPARATOR), - end = _val.length; + const val = this.inputTarget.value; + let start = val.lastIndexOf(this.SEPARATOR), + end = val.length; if (start < 0) start = 0; else start += this.SEPARATOR.length; - return { start, end }; + return { start: start, end: end }; } // ------------------------------ Fetch matches ------------------------------ @@ -978,69 +969,65 @@ export default class extends Controller { // Send request for updated primer. refresh_primer() { this.verbose("refresh_primer()"); - - const _token = this.get_search_token().toLowerCase(), - _last_request = this.last_fetch_request; + // let val = this.inputTarget.value.toLowerCase(); + const val = this.get_search_token().toLowerCase(), + last_request = this.last_fetch_request; // Don't make request on empty string! - if (!this.ACT_LIKE_SELECT && (!_token || _token.length < 1)) + if (!val || val.length < 1) return; // Don't repeat last request accidentally! - if (_last_request == _token) + if (last_request == val) return; - // Memoize this condition, used twice: - // "is the new search token an extension of the previous search string?" - const _new_val_refines_last_request = - (_last_request?.length < _token.length) && - (_last_request == _token.substr(0, _last_request?.length)); + // Memoize this condition, used twice. + // is the new search token an extension of the previous search string? + const new_val_refines_last_request = + (last_request.length < val.length) && + (last_request == val.substr(0, last_request.length)); // No need to make more constrained request if we got all results last time. if (!this.last_fetch_incomplete && - _last_request && (_last_request.length > 0) && - _new_val_refines_last_request) + last_request && (last_request.length > 0) && + new_val_refines_last_request) return; // If a less constrained request is pending, wait for it to return before // refining the request, just in case it returns complete results // (rendering the more refined request unnecessary). - if (this.fetch_request && _new_val_refines_last_request) + if (this.fetch_request && new_val_refines_last_request) return; - if (_token.length > this.MAX_STRING_LENGTH) - _token = _token.substr(0, this.MAX_STRING_LENGTH); - - const _query_params = { string: _token, ...this.request_params } - - // If it's a param search, ignore the search token and return all results. - if (this.ACT_LIKE_SELECT) { _query_params["all"] = true; } - // Make request. - this.send_fetch_request(_query_params); + this.send_fetch_request(val); } // Send AJAX request for more matching strings. - async send_fetch_request(query_params) { + async send_fetch_request(val) { this.verbose("send_fetch_request()"); + if (val.length > this.MAX_REQUEST_LINK) + val = val.substr(0, this.MAX_REQUEST_LINK); if (this.log) { - this.debug("Sending fetch request: " + query_params.string + "..."); + this.debug("Sending fetch request: " + val); } - const _url = this.AJAX_URL + this.TYPE, - _controller = new AbortController(); + // Need to doubly-encode this to prevent router from interpreting slashes, + // dots, etc. + const url = this.AJAX_URL.replace( + '@', encodeURIComponent(encodeURIComponent(val.replace(/\./g, '%2e'))) + ); - this.last_fetch_request = query_params.string; - if (this.fetch_request) - _controller.abort(); + this.last_fetch_request = val; + + const controller = new AbortController(), + signal = controller.signal; - const response = await get(_url, { - signal: _controller.signal, - query: query_params, - responseKind: "json" - }); + if (this.fetch_request) + controller.abort(); + const response = await get(url, { signal }); if (response.ok) { const json = await response.json if (json) { @@ -1049,7 +1036,7 @@ export default class extends Controller { } } else { this.fetch_request = null; - console.log(`got a ${response.status}: ${response.text}`); + console.log(`got a ${response.status}`); } } @@ -1110,7 +1097,7 @@ export default class extends Controller { // ------------------------------- DEBUGGING ------------------------------ debug(str) { - // document.getElementById("log").insertAdjacentText("beforeend", str + "
"); + document.getElementById("log").insertAdjacentText("beforeend", str + "
"); } verbose(str) { From a094a7454c5bd67d602fd4e082feec63b2fc2301 Mon Sep 17 00:00:00 2001 From: andrew nimmo Date: Wed, 19 Jun 2024 02:03:51 -0700 Subject: [PATCH 12/13] Revert "Update autocompleter_controller.js" This reverts commit a20878e670c8a66f00b591c04d816e2d00583a11. --- .../controllers/autocompleter_controller.js | 501 +++++++++--------- 1 file changed, 257 insertions(+), 244 deletions(-) diff --git a/app/javascript/controllers/autocompleter_controller.js b/app/javascript/controllers/autocompleter_controller.js index bdc1dc0515..6dba618209 100644 --- a/app/javascript/controllers/autocompleter_controller.js +++ b/app/javascript/controllers/autocompleter_controller.js @@ -14,7 +14,7 @@ const DEFAULT_OPTS = { // N = etc. COLLAPSE: 0, // where to request primer from - AJAX_URL: null, + AJAX_URL: "/autocompleters/new/", // how long to wait before sending AJAX request (seconds) REFRESH_DELAY: 0.10, // how long to wait before hiding pulldown (seconds) @@ -28,7 +28,7 @@ const DEFAULT_OPTS = { // amount to move cursor on page up and down PAGE_SIZE: 10, // max length of string to send via AJAX - MAX_REQUEST_LINK: 50, + MAX_STRING_LENGTH: 50, // Sub-match: starts finding new matches for the string *after the separator* // allowed separators (e.g. " OR ") SEPARATOR: null, @@ -45,34 +45,26 @@ const DEFAULT_OPTS = { // Allowed types of autocompleter. Sets some DEFAULT_OPTS from type const AUTOCOMPLETER_TYPES = { clade: { - AJAX_URL: "/autocompleters/new/clade/@", }, herbarium: { // params[:user_id] handled in controller - AJAX_URL: "/autocompleters/new/herbarium/@", UNORDERED: true }, location: { // params[:format] handled in controller - AJAX_URL: "/autocompleters/new/location/@", UNORDERED: true }, name: { - AJAX_URL: "/autocompleters/new/name/@", COLLAPSE: 1 }, project: { - AJAX_URL: "/autocompleters/new/project/@", UNORDERED: true }, region: { - AJAX_URL: "/autocompleters/new/location/@", UNORDERED: true }, species_list: { - AJAX_URL: "/autocompleters/new/species_list/@", UNORDERED: true }, user: { - AJAX_URL: "/autocompleters/new/user/@", UNORDERED: true } } @@ -103,6 +95,8 @@ const INTERNAL_OPTS = { // Connects to data-controller="autocomplete" export default class extends Controller { + // The select target is not the input element, but a