diff --git a/app/assets/stylesheets/Admin.scss b/app/assets/stylesheets/Admin.scss index 2d49dd8a43..bf0e0ff2f9 100644 --- a/app/assets/stylesheets/Admin.scss +++ b/app/assets/stylesheets/Admin.scss @@ -1,6 +1,6 @@ @import "defaults"; -$BODY_BG_COLOR: #Dc00DD; +$BODY_BG_COLOR: #DD00DD; $LOGO_BORDER_COLOR: black; $LOGO_BORDER_WIDTH: 2px; // vs 1px in default @@ -9,7 +9,7 @@ $LOGO_BG_COLOR: yellow; $LOGO_HOVER_FG_COLOR: purple; $LOGO_HOVER_BG_COLOR: yellow; -$LEFT_BAR_BORDER_COLOR: gray; +$LEFT_BAR_BORDER_COLOR: #545555; // gray $LEFT_BAR_BORDER_RADIUS: 0px; $LEFT_BAR_HEADER_FG_COLOR: black; $LEFT_BAR_HEADER_BG_COLOR: yellow; diff --git a/app/assets/stylesheets/Agaricus.scss b/app/assets/stylesheets/Agaricus.scss index 8b1ad1d68a..c7d5dbcd37 100644 --- a/app/assets/stylesheets/Agaricus.scss +++ b/app/assets/stylesheets/Agaricus.scss @@ -1,6 +1,6 @@ @import "defaults"; -$augustus_cap: #EBCF95; // #ECCF95 +$augustus_cap: #EaCe93; // #ECCF95 $brasiliensis_gills_1: #A06463; $brasiliensis_gills_2: #743931; $campestris_cap: #F6F0F2; diff --git a/app/assets/stylesheets/Amanita.scss b/app/assets/stylesheets/Amanita.scss index d9de10e87e..44725ef795 100644 --- a/app/assets/stylesheets/Amanita.scss +++ b/app/assets/stylesheets/Amanita.scss @@ -1,6 +1,6 @@ @import "defaults"; -$phalloides_foreground: #e5edd5; // #e6edd5 +$phalloides_foreground: #e6edd5; $phalloides_light_cap: #dfe4bc; $phalloides_middle_cap: #beb977; $phalloides_dark_cap: #787133; @@ -13,7 +13,7 @@ $calyptroderma_middle_cap: #c18346; $muscaria_background: #cc2616; $muscaria_foreground: #fff8c6; $velosa_background: #dd9d5f; -$velosa_light_veil: #faebd4; +$velosa_light_veil: #fbead3; // faebd4 $velosa_dark_veil: #f4d5a6; $novinupta_background: #d1afa5; $pachycolea_background: #383138; diff --git a/app/assets/stylesheets/BlackOnWhite.scss b/app/assets/stylesheets/BlackOnWhite.scss index ebb4fb9c10..e538748a26 100644 --- a/app/assets/stylesheets/BlackOnWhite.scss +++ b/app/assets/stylesheets/BlackOnWhite.scss @@ -2,9 +2,9 @@ // and does not use the "old_theme" defaults. @import "defaults"; -$LOGO_BORDER_COLOR: #DbDcDc; +$LOGO_BORDER_COLOR: #DDDDDD; $LEFT_BAR_BORDER_COLOR: #DDDDDD; -$TOP_BAR_BORDER_COLOR: #DDDDDD; +$TOP_BAR_BORDER_COLOR: #DFDfDD; $LIST_BORDER_COLOR: #DDDDDD; $BUTTON_HOVER_BORDER_COLOR: #CCCCCC; $BUTTON_BG_COLOR: #CCCCCC; diff --git a/app/assets/stylesheets/Cantharellaceae.scss b/app/assets/stylesheets/Cantharellaceae.scss index 7bfb9e736a..b9948d2268 100644 --- a/app/assets/stylesheets/Cantharellaceae.scss +++ b/app/assets/stylesheets/Cantharellaceae.scss @@ -1,6 +1,6 @@ @import "defaults"; -$californicus_cap: #f5ae4a; // image 557 #f6ae4a +$californicus_cap: #f6ae4a; // image 557 $californicus_stipe: #fae8b8; $cinnabarinus_dark_cap: #c12900; // image 551 $cinnabarinus_light_cap: #ff6524; @@ -11,7 +11,7 @@ $tubaeformis_hymenium: #c2914c; $tubaeformis_bright_stipe: #ffb230; $tubaeformis_dark_stipe: #4b2e0c; $tubaeformis_light_stipe: #e5bb67; -$cornucopioides_dark_hymenium: #10110b; // image 465 +$cornucopioides_dark_hymenium: #11120b; // image 465 #10110b $cornucopioides_light_hymenium: #9b9690; $cornucopioides_dark_cap: #4f4337; $cornucopioides_light_cap: #826c57; diff --git a/app/assets/stylesheets/Hygrocybe.scss b/app/assets/stylesheets/Hygrocybe.scss index 81a8060908..dc7062c8db 100644 --- a/app/assets/stylesheets/Hygrocybe.scss +++ b/app/assets/stylesheets/Hygrocybe.scss @@ -1,6 +1,6 @@ @import "defaults"; -$conica_stain: #36372f; // #37372f +$conica_stain: #35362d; // #37372f $conica_cap_red: #a31404; $conica_cap_orange: #dd6226; $conica_cap_yellow: #ffbf01; diff --git a/app/assets/stylesheets/Sudo.scss b/app/assets/stylesheets/Sudo.scss index a1ec449676..b091bf1cec 100644 --- a/app/assets/stylesheets/Sudo.scss +++ b/app/assets/stylesheets/Sudo.scss @@ -1,6 +1,6 @@ @import "defaults"; -$BODY_BG_COLOR: #DC7700; // #DD7700 +$BODY_BG_COLOR: #DE7500; // #DD7700 $LOGO_BORDER_COLOR: black; $LOGO_BORDER_WIDTH: 2px; // vs 1px in default diff --git a/app/assets/stylesheets/mo/_autocomplete.scss b/app/assets/stylesheets/mo/_autocomplete.scss index 61aa5ca5f6..885c5bb58f 100644 --- a/app/assets/stylesheets/mo/_autocomplete.scss +++ b/app/assets/stylesheets/mo/_autocomplete.scss @@ -2,6 +2,23 @@ // autocompletion // -------------------------------------------------- +// autocompleter state - do we have a match? +.has-id-indicator { + display: none; +} +.create-button { + display: inline-block; +} + +.has-id { + .has-id-indicator { + display: inline-block; + } + .create-button { + display: none; + } +} + .dropdown-menu.auto_complete { // position: absolute; // color: $dropdown-link-color; diff --git a/app/assets/stylesheets/mo/_content.scss b/app/assets/stylesheets/mo/_content.scss index a4f89f62d7..029b1e9723 100644 --- a/app/assets/stylesheets/mo/_content.scss +++ b/app/assets/stylesheets/mo/_content.scss @@ -87,6 +87,12 @@ ul.tight-list { .table-responsive { border-color: transparent; } + .panel-body.border-top { + border-top: 1px solid #ddd; + } + .panel-body.border-bottom { + border-bottom: 1px solid #ddd; + } } .panel-title { diff --git a/app/assets/stylesheets/mo/_form_elements.scss b/app/assets/stylesheets/mo/_form_elements.scss index cd714e8f60..20ff6d9a2f 100644 --- a/app/assets/stylesheets/mo/_form_elements.scss +++ b/app/assets/stylesheets/mo/_form_elements.scss @@ -127,17 +127,11 @@ form { } } -.has-id-indicator { +.constrained-label { display: none; } -.has-id { - .has-id-indicator { - display: inline-block; - } -} - -.constrained-label { +.create-label { display: none; } @@ -148,6 +142,21 @@ form { .constrained-label { display: inline-block; } + .create-label { + display: none; + } +} + +.create { + .create-label { + display: inline-block; + } + .unconstrained-label { + display: none; + } + .constrained-label { + display: none; + } } .map-point, @@ -166,3 +175,8 @@ form { display: inline-block; } } + +.overflow-scroll-checklist { + max-height: 30rem; + overflow-y: auto; +} diff --git a/app/assets/stylesheets/mo/_icons.scss b/app/assets/stylesheets/mo/_icons.scss index ebb824a599..82c0b4ddc6 100644 --- a/app/assets/stylesheets/mo/_icons.scss +++ b/app/assets/stylesheets/mo/_icons.scss @@ -98,6 +98,22 @@ height: 18px; } +// This is for the stateful icon_link_to helper +.icon-link { + .active-icon, .active-label { + display: none; + } + &.active { + .link-icon:not(.active-icon), + .sr-only:not(.active-label) { + display: none; + } + .active-icon, .active-label { + display: inline-block; + } + } +} + .spinner-right { @extend .glyphicon, .glyphicon-repeat; diff --git a/app/assets/stylesheets/mo/_utilities.scss b/app/assets/stylesheets/mo/_utilities.scss index 3bc7e47fcf..adada3d7fb 100644 --- a/app/assets/stylesheets/mo/_utilities.scss +++ b/app/assets/stylesheets/mo/_utilities.scss @@ -8,10 +8,26 @@ display: none !important; } +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + .d-flex { display: flex !important; } +.d-inline-flex { + display: inline-flex !important; +} + .flex-column { flex-direction: column; } @@ -20,6 +36,10 @@ align-items: center !important; } +.justify-content-between { + justify-content: space-between !important; +} + .w-auto { width: auto !important; } @@ -68,22 +88,6 @@ position: fixed !important; } -.d-inline { - display: inline !important; -} - -.d-inline-block { - display: inline-block !important; -} - -.d-block { - display: block !important; -} - -.d-flex { - display: flex !important; -} - .float-right { float: right !important; } @@ -150,6 +154,16 @@ border: none !important; } +.border-top { + border-top-width: 1px !important; + border-top-style: solid !important; +} + +.border-bottom { + border-bottom-width: 1px !important; + border-bottom-style: solid !important; +} + .rounded-0 { border-radius: 0 !important; } @@ -452,6 +466,10 @@ padding-right: 1rem !important; } +.pl-2 { + padding-left: 0.5rem !important; +} + .pl-3 { padding-left: 1rem !important; } @@ -677,6 +695,10 @@ display: block !important; } + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { display: inline-block !important; } diff --git a/app/classes/ip_stats.rb b/app/classes/ip_stats.rb index 42606242a5..c88aec3fa2 100644 --- a/app/classes/ip_stats.rb +++ b/app/classes/ip_stats.rb @@ -77,17 +77,17 @@ def clean_stats def okay?(ip) populate_blocked_ips unless blocked_ips_current? - @@okay_ips.include?(ip) + @okay_ips.include?(ip) end def blocked?(ip) populate_blocked_ips unless blocked_ips_current? - @@blocked_ips.include?(ip) && !@@okay_ips.include?(ip) # DO NOT FIX! + @blocked_ips.include?(ip) && !@okay_ips.include?(ip) # DO NOT FIX! end def blocked_ips populate_blocked_ips unless blocked_ips_current? - @@blocked_ips - @@okay_ips + @blocked_ips - @okay_ips end def add_blocked_ips(ips) @@ -138,7 +138,7 @@ def read_okay_ips def reset! # Force reload next time used. - @@blocked_ips_time = nil + @blocked_ips_time = nil end # ------------------------------------- @@ -156,18 +156,18 @@ def calc_weight(now, time) end def blocked_ips_current? - defined?(@@blocked_ips_time) && - @@blocked_ips_time.to_s != "" && - @@blocked_ips_time >= File.mtime(MO.blocked_ips_file) && - @@blocked_ips_time >= File.mtime(MO.okay_ips_file) + defined?(@blocked_ips_time) && + @blocked_ips_time.to_s != "" && + @blocked_ips_time >= File.mtime(MO.blocked_ips_file) && + @blocked_ips_time >= File.mtime(MO.okay_ips_file) end def populate_blocked_ips file1 = MO.blocked_ips_file file2 = MO.okay_ips_file - @@blocked_ips = parse_ip_list(file1) - @@okay_ips = parse_ip_list(file2) - @@blocked_ips_time = [File.mtime(file1), File.mtime(file2)].max + @blocked_ips = parse_ip_list(file1) + @okay_ips = parse_ip_list(file2) + @blocked_ips_time = [File.mtime(file1), File.mtime(file2)].max end def parse_ip_list(file) diff --git a/app/controllers/locations_controller.rb b/app/controllers/locations_controller.rb index 14fdf16912..4cd2f03ede 100644 --- a/app/controllers/locations_controller.rb +++ b/app/controllers/locations_controller.rb @@ -290,6 +290,11 @@ def new @dubious_where_reasons = Location.dubious_name?(user_format, true) end @location = Location.new + + respond_to do |format| + format.turbo_stream { render_modal_location_form } + format.html + end end def create @@ -311,7 +316,7 @@ def create # Need to create location. else - done = create_location_ivar(done, db_name) + done = create_location_ivar_and_save(done, db_name) end # If done, update any observations at @display_name, @@ -331,6 +336,11 @@ def edit params[:location] ||= {} @display_name = @location.display_name + + respond_to do |format| + format.turbo_stream { render_modal_location_form } + format.html + end end def update @@ -418,7 +428,7 @@ def init_caller_ivars_for_new @set_herbarium = params[:set_herbarium] end - def create_location_ivar(done, db_name) + def create_location_ivar_and_save(done, db_name) @location = Location.new(permitted_location_params) @location.display_name = @display_name # (strip_squozen) @@ -548,6 +558,30 @@ def email_location_change_content ) end + def render_modal_location_form + render(partial: "shared/modal_form", + locals: { title: modal_title, identifier: modal_identifier, + form: "locations/form" }) and return + end + + def modal_identifier + case action_name + when "new", "create" + "location" + when "edit", "update" + "location_#{@location.id}" + end + end + + def modal_title + case action_name + when "new", "create" + :create_location_title.t + when "edit", "update" + :edit_location_title.t(name: @location.display_name) + end + end + ############################################################################## def permitted_location_params diff --git a/app/controllers/observations_controller/create.rb b/app/controllers/observations_controller/create.rb index a3de51e647..a29f5a2601 100644 --- a/app/controllers/observations_controller/create.rb +++ b/app/controllers/observations_controller/create.rb @@ -30,24 +30,35 @@ module ObservationsController::Create # def create + # Create a bare observation @observation = create_observation_object(params[:observation]) - # set these again, in case they are not defined + # Set license/image defaults again, in case they are not defined init_license_var init_new_image_var(Time.zone.now) rough_cut - success = true - success = false unless validate_params - success = false unless validate_object(@observation) - success = false unless validate_projects - success = false if @name && !validate_object(@naming) - success = false if @name && !@vote.value.nil? && !validate_object(@vote) - success = false if @bad_images != [] - success = false if success && !save_observation - return reload_new_form(params.dig(:naming, :reasons)) unless success + create_location_object_if_new # may set @location + + @any_errors = false + validate_name + validate_place_name + validate_observation + validate_projects + validate_naming if @name + validate_vote if @name + validate_images + try_to_save_location_if_new + try_to_save_new_observation + return reload_new_form(params.dig(:naming, :reasons)) if @any_errors @observation.log(:log_observation_created) - save_everything_else(params.dig(:naming, :reasons)) + + update_naming(params.dig(:naming, :reasons)) + attach_good_images + update_projects + update_species_lists + save_collection_number + save_herbarium_record strip_images! if @observation.gps_hidden update_field_slip flash_notice(:runtime_observation_success.t(id: @observation.id)) @@ -62,36 +73,16 @@ def create # once we're sure everything is correct. # INPUT: params[:observation] (and @user) (and various notes params) # OUTPUT: new observation - def create_observation_object(args) - now = Time.zone.now - observation = new_observation(args) - observation.created_at = now - observation.updated_at = now - observation.user = @user - observation.name = Name.unknown - observation.source = "mo_website" - determine_observation_location(observation) - end - # NOTE: Call `to_h` on the permitted params if problems with nested params. - # As of rails 5, params are an ActionController::Parameters object, - # not a hash. - def new_observation(args) - if args - Observation.new(args.permit(permitted_observation_args).to_h) - else - Observation.new - end - end - - # We don't have an @observation yet. - def determine_observation_location(observation) - if Location.is_unknown?(observation.place_name) || - (observation.lat && observation.lng && observation.place_name.blank?) - observation.location = Location.unknown - observation.where = nil - end - observation + # As of rails 5, params are ActionController::Parameters object, not hash. + def create_observation_object(args = {}) + args = args&.permit(permitted_observation_args).to_h + now = Time.zone.now + Observation.new(args&.merge({ created_at: now, + updated_at: now, + user: @user, + name: Name.unknown, + source: "mo_website" })) end def rough_cut @@ -103,13 +94,13 @@ def rough_cut create_image_objects_and_update_bad_images end - def save_everything_else(reason) - update_naming(reason) - attach_good_images - update_projects - update_species_lists - save_collection_number - save_herbarium_record + def try_to_save_new_observation + return false if @any_errors + + return true if save_observation + + @any_errors = true + false end def update_naming(reason) @@ -246,7 +237,8 @@ def reload_new_form(reasons) @reasons = @naming.init_reasons(reasons) @images = @bad_images @new_image.when = @observation.when - @field_code = params[:field_code] + @field_code = params[:field_code] + init_location_var_for_reload init_specimen_vars_for_reload init_project_vars init_project_vars_for_reload @@ -262,4 +254,14 @@ def update_field_slip field_slip.observation = @observation field_slip.save end + + def init_location_var_for_reload + # keep location_id if it's -1 (new) + if @location || @observation.location_id.nil? || + @observation.location_id.zero? + return + end + + @location = @observation.location + end end diff --git a/app/controllers/observations_controller/edit_and_update.rb b/app/controllers/observations_controller/edit_and_update.rb index 5713b3f76a..f4a3d14cc1 100644 --- a/app/controllers/observations_controller/edit_and_update.rb +++ b/app/controllers/observations_controller/edit_and_update.rb @@ -82,18 +82,23 @@ def update init_new_image_var(@observation.when) @any_errors = false - update_permitted_observation_attributes + update_permitted_observation_attributes # may set a new location_id + create_location_object_if_new @observation.notes = notes_to_sym_and_compact warn_if_unchecking_specimen_with_records_present! - strip_images_if_observation_gps_hidden - validate_edit_place_name + strip_images! if @observation.gps_hidden + + validate_place_name + validate_projects detach_removed_images try_to_upload_images - try_to_save_observation_if_there_are_changes + try_to_save_location_if_new + try_to_update_observation_if_there_are_changes reload_edit_form and return if @any_errors - update_project_and_species_list_attachments + update_projects + update_species_lists redirect_to_observation_or_create_location end @@ -112,16 +117,6 @@ def warn_if_unchecking_specimen_with_records_present! flash_warning(:edit_observation_turn_off_specimen_with_records_present.t) end - def strip_images_if_observation_gps_hidden - strip_images! if @observation.gps_hidden - end - - def validate_edit_place_name - return if validate_place_name && validate_projects - - @any_errors = true - end - # As of 2024-06-01, users can remove images right on the edit obs form. def detach_removed_images new_ids = params[:good_image_ids]&.split || [] @@ -143,8 +138,8 @@ def try_to_upload_images @any_errors = true if @bad_images.any? end - def try_to_save_observation_if_there_are_changes - return unless @dubious_where_reasons == [] && @observation.changed? + def try_to_update_observation_if_there_are_changes + return unless @dubious_where_reasons.blank? && @observation.changed? @observation.updated_at = Time.zone.now if save_observation @@ -166,10 +161,7 @@ def reload_edit_form render(action: :edit) end - def update_project_and_species_list_attachments - update_projects - update_species_lists - end + ############################################################################## def redirect_to_observation_or_create_location if @observation.location_id.nil? diff --git a/app/controllers/observations_controller/new.rb b/app/controllers/observations_controller/new.rb index 8503f93ef8..499c613575 100644 --- a/app/controllers/observations_controller/new.rb +++ b/app/controllers/observations_controller/new.rb @@ -80,10 +80,7 @@ def init_project_vars_for_new def defaults_from_last_observation_created # Grab defaults from last observation the user created. # Only grab "when" if was created at most an hour ago. - last_observation = Observation. - includes(:location, :projects, :species_lists). - where(user_id: @user.id). - order(:created_at).last + last_observation = Observation.recent_by_user(@user).last return unless last_observation %w[where location_id is_collection_location gps_hidden].each do |attr| diff --git a/app/controllers/observations_controller/shared_form_methods.rb b/app/controllers/observations_controller/shared_form_methods.rb index 641cfcabc8..d8b5355100 100644 --- a/app/controllers/observations_controller/shared_form_methods.rb +++ b/app/controllers/observations_controller/shared_form_methods.rb @@ -32,9 +32,9 @@ module ObservationsController::SharedFormMethods # NOTE: potential gotcha... Any nested attributes must come last. def permitted_observation_args - [:place_name, :where, :lat, :lng, :alt, # :location_id, - :when, "when(1i)", "when(2i)", "when(3i)", :notes, :specimen, - :thumb_image_id, :is_collection_location, :gps_hidden] + [:lat, :lng, :alt, :gps_hidden, :place_name, :where, :location_id, + :is_collection_location, :when, "when(1i)", "when(2i)", "when(3i)", + :notes, :specimen, :thumb_image_id] end def update_permitted_observation_attributes @@ -124,6 +124,85 @@ def init_list_vars_for_reload end ############################################################################## + # Locations — may be created in the obs form + # + # By now we have an @observation, and maybe a "-1" location_id, indicating a + # new Location if accompanied by bounding box lat/lng. If the location name + # does not exist already, and the bounding box is present, create a new + # @location, and associate it with the @observation + def create_location_object_if_new + # Resets the location_id to MO's existing Location if it already exists. + return false if place_name_exists? + + # Ensure we have the minimum necessary to create a new location + unless @observation.location_id == -1 && + (place_name = params.dig(:observation, :place_name)).present? && + (north = params.dig(:location, :north)).present? && + (south = params.dig(:location, :south)).present? && + (east = params.dig(:location, :east)).present? && + (west = params.dig(:location, :west)).present? + return false + end + + # Ignore hidden attribute even if the obs is hidden, because saving a + # Location with `hidden: true` fuzzes the lat/lng bounds unpredictably. + attributes = { hidden: false, user_id: @user.id, + north:, south:, east:, west: } + # Add optional attributes. :notes not implemented yet. + [:high, :low, :notes].each do |key| + if (val = params.dig(:location, key)).present? + attributes[key] = val + end + end + + @location = Location.new(attributes) + # With a Location instance, we can use the `display_name=` setter method, + # which figures out scientific/postal format of user input and sets + # location `name` and `scientific_name` accordingly. + @location.display_name = place_name + end + + # Check if we somehow got a location name that exists in the db, but didn't + # get a location_id, or the location name is out of sync with the location_id. + # (This should not usually happen with the autocompleter). If it happens, + # match the obs to the existing Location by name. If the user was trying to + # create a new Location with the existing name, use the existing location and + # flash that we did that, returning `true` so we can bail on creating a "new" + # location, but go ahead with the observation save. + def place_name_exists? + name = Location.user_format(@user, @observation.place_name) + location = Location.find_by(name: name) + if !@observation.location_id&.positive? && location || + (location && (@observation.location_id != location&.id)) + if @observation.location_id == -1 + flash_warning(:runtime_location_already_exists.t(name: name)) + end + @observation.location_id = location.id + return true + end + + false + end + + def try_to_save_location_if_new + return if @any_errors || !@location&.new_record? || save_location + + @any_errors = true + end + + # Save location only (at this point rest of form is okay). + def save_location + if save_with_log(@location) + # Associate the location with the observation + @observation.location_id = @location.id + # flash_notice(:runtime_location_success.t(id: @location.id)) + true + else + # Failed to create location + flash_object_errors(@location) + false + end + end # Save observation now that everything is created successfully. def save_observation @@ -134,6 +213,8 @@ def save_observation false end + ############################################################################## + # Attempt to upload any images. We will attach them to the observation # later, assuming we can create it. Problem is if anything goes wrong, we # cannot repopulate the image forms (security issue associated with giving diff --git a/app/controllers/observations_controller/validators.rb b/app/controllers/observations_controller/validators.rb index 8419a2d047..0b1bc64360 100644 --- a/app/controllers/observations_controller/validators.rb +++ b/app/controllers/observations_controller/validators.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true # :section: Validators -# -# validate_params +# These validators return Boolean values, and also set the @any_errors ivar. # # validate_name # name_params @@ -17,11 +16,6 @@ module ObservationsController::Validators private - def validate_params - validate_name && - validate_place_name - end - def validate_name success = resolve_name if @name @@ -31,7 +25,10 @@ def validate_name :form_observations_there_is_a_problem_with_name.t) flash_object_errors(@naming) end - success + return true if success + + @any_errors = true + false end # Set the ivars for the form: @given_name, @name - and potentially ivars for @@ -63,16 +60,36 @@ def name_params } end + # The form may be in a state where it has an existing MO Location name in the + # `place_name` field, but not the corresponding MO location_id. It could be + # because of user trying to create a duplicate, or because the user had a + # prefilled location, but clicked on the "Create Location" button - this keeps + # the place_name, but clears the location_id field. Either way, we need to + # check if we already have a location by this name. If so, find the existing + # location and use that for the obs. def validate_place_name - success = true - @place_name = @observation.place_name - @dubious_where_reasons = [] - if @place_name != params[:approved_where] && @observation.location_id.nil? - db_name = Location.user_format(@user, @place_name) - @dubious_where_reasons = Location.dubious_name?(db_name, true) - success = false if @dubious_where_reasons != [] + place_name = @observation.place_name + lat = @observation.lat + lng = @observation.lng + if !lat && !lng && place_name.blank? + @any_errors = true + return false end - success + + # Set location to unknown if place_name blank && lat/lng are present + if Location.is_unknown?(place_name) || (lat && lng && place_name.blank?) + @observation.location = Location.unknown + @observation.where = nil + # If it's unknown, we're good. don't need to check for duplicates. + return true + end + + name = Location.user_format(@user, @observation.place_name) + @dubious_where_reasons = Location.dubious_name?(name, true) + return true if @dubious_where_reasons.empty? + + @any_errors = true + false end def validate_projects @@ -84,6 +101,7 @@ def validate_projects end if @error_checked_projects.any? flash_error(:form_observations_there_is_a_problem_with_projects.t) + @any_errors = true return false end @@ -93,7 +111,10 @@ def validate_projects if @suspect_checked_projects.any? flash_warning(:form_observations_there_is_a_problem_with_projects.t) end - @suspect_checked_projects.empty? + return true if @suspect_checked_projects.empty? + + @any_errors = true + false end def checked_project_conflicts @@ -108,4 +129,32 @@ def checked_project_conflicts proj.violates_constraints?(@observation) end end + + def validate_observation + return true if validate_object(@observation) + + @any_errors = true + false + end + + def validate_naming + return true if !@name || validate_object(@naming) + + @any_errors = true + false + end + + def validate_vote + return true if !@name || @vote.value.nil? || validate_object(@vote) + + @any_errors = true + false + end + + def validate_images + return true if @bad_images.empty? + + @any_errors = true + false + end end diff --git a/app/helpers/carousel_helper.rb b/app/helpers/carousel_helper.rb index d94ccab5e5..8bb2c8b6df 100644 --- a/app/helpers/carousel_helper.rb +++ b/app/helpers/carousel_helper.rb @@ -102,7 +102,7 @@ def carousel_remove_image_button(image_id: nil) data: data ) do [tag.span(:image_remove_remove.l), - link_icon(:remove, classes: "text-danger ml-3")].safe_join + link_icon(:remove, class: "text-danger ml-3")].safe_join end end diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index c86e6ba090..40049b853d 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -119,13 +119,14 @@ def help_block_with_arrow(direction = nil, **args, &block) end end - def collapse_help_block(direction = nil, **args, &block) + def collapse_help_block(direction = nil, string = nil, **args, &block) div_class = "well well-sm help-block position-relative" div_class += " mt-3" if direction == "up" + content = block ? capture(&block) : string tag.div(class: "collapse", id: args[:id]) do tag.div(class: div_class) do - concat(capture(&block).to_s) + concat(content) if direction arrow_class = "arrow-#{direction}" arrow_class += " hidden-xs" unless args[:mobile] @@ -210,4 +211,77 @@ def notes_panel(msg = nil, &block) wrapper end end + + # Bootstrap tablist + def tab_nav(**args, &block) + if args[:tabs] + content = capture do + args[:tabs].each do |tab| + concat(tab_item(tab[:name], id: tab[:id], active: tab[:active])) + end + end + elsif block + content = capture(&block).to_s + else + content = "" + end + style = args[:style] || "pills" + + tag.ul( + role: "tablist", + class: class_names("nav nav-#{style}", args[:class]), + **args.except(:class, :style) + ) do + content + end + end + + # Bootstrap "tab" item in ul/li tablist + def tab_item(name, **args) + active = args[:active] ? "active" : nil + disabled = args[:disabled] ? "disabled" : nil + + tag.li( + role: "presentation", + class: class_names(active, disabled, args[:class]) + ) do + tab_link(name, **args.except(:active, :disabled, :class)) + end + end + + # Bootstrap tab - just the link. Use for independent tab (e.g. button). + def tab_link(name, **args) + classes = args[:button] ? "btn btn-default" : "nav-link" + + link_to( + name, "##{args[:id]}-tab-pane", + role: "tab", id: "#{args[:id]}-tab", class: classes, + data: { toggle: "tab" }, aria: { controls: "#{args[:id]}-tab-pane" } + ) + end + + # Bootstrap tabpanel wrapper + def tab_content(**args, &block) + content = capture(&block).to_s + + tag.div(class: class_names("tab-content", args[:class]), + **args.except(:class)) do + content + end + end + + # Bootstrap tabpanel + def tab_panel(**args, &block) + content = capture(&block).to_s + active = args[:active] ? "in active" : nil + + tag.div( + role: "tabpanel", id: "#{args[:id]}-tab-pane", + class: class_names("tab-pane fade", active, args[:class]), + aria: { labelledby: "#{args[:id]}-tab" }, + **args.except(:class, :id) + ) do + content + end + end end diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 6e1c43c907..3cbb0f35f2 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -86,6 +86,7 @@ def js_button(**args, &block) # def check_box_with_label(**args) args = auto_label_if_form_is_account_prefs(args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) wrap_class = form_group_wrap_class(args, "checkbox") @@ -94,6 +95,9 @@ def check_box_with_label(**args) args[:form].label(args[:field]) do concat(args[:form].check_box(args[:field], opts)) concat(args[:label]) + if args[:between].present? + concat(tag.div(class: "d-inline-block ml-3") { args[:between] }) + end concat(args[:append]) if args[:append].present? end end @@ -116,6 +120,7 @@ def check_button_with_label(**args) # Bootstrap radio: form, field, value, label, class, checked def radio_with_label(**args) args = auto_label_if_form_is_account_prefs(args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args, [:value]) wrap_class = form_group_wrap_class(args, "radio") @@ -124,6 +129,9 @@ def radio_with_label(**args) args[:form].label("#{args[:field]}_#{args[:value]}") do concat(args[:form].radio_button(args[:field], args[:value], opts)) concat(args[:label]) + if args[:between].present? + concat(tag.div(class: "d-inline-block ml-3") { args[:between] }) + end concat(args[:append]) if args[:append].present? end end @@ -147,21 +155,40 @@ def radio_button_with_label(**args) def text_field_with_label(**args) args = auto_label_if_form_is_account_prefs(args) args = check_for_optional_or_required_note(args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) opts[:class] = "form-control" wrap_class = form_group_wrap_class(args) wrap_data = args[:wrap_data] || {} label_opts = field_label_opts(args) + label_opts[:class] = class_names(label_opts[:class], args[:label_class]) tag.div(class: wrap_class, data: wrap_data) do - concat(args[:form].label(args[:field], args[:label], label_opts)) - concat(args[:between]) if args[:between].present? - if args[:addon].present? + # The label row is complicated, many potential buttons here. `between` + # comes right after the label on left, `between_end` is right justified + concat(tag.div(class: "d-flex justify-content-between") do + concat(tag.div do + concat(args[:form].label(args[:field], args[:label], label_opts)) + concat(args[:between]) if args[:between].present? + end) + concat(tag.div do + concat(args[:between_end]) if args[:between_end].present? + end) + end) + if args[:addon].present? # text addon, not interactive concat(tag.div(class: "input-group") do concat(args[:form].text_field(args[:field], opts)) concat(tag.span(args[:addon], class: "input-group-addon")) end) + elsif args[:button].present? # button addon, interactive + concat(tag.div(class: "input-group") do + concat(args[:form].text_field(args[:field], opts)) + concat(tag.span(class: "input-group-btn") do + js_button(button: args[:button], class: "btn btn-default", + data: args[:button_data] || {}) + end) + end) else concat(args[:form].text_field(args[:field], opts)) end @@ -185,16 +212,23 @@ def autocompleter_field(**args) placeholder: :start_typing.l, autocomplete: "off", data: { autocompleter_target: "input" } }.deep_merge(args.except(:type, :separator, :textarea, - :hidden, :hidden_data)) + :hidden, :hidden_data, :create_text, + :keep_text, :edit_text)) ac_args[:class] = class_names("dropdown", args[:class]) ac_args[:wrap_data] = { controller: :autocompleter, type: args[:type], separator: args[:separator], + autocompleter_map_outlet: args[:map_outlet], autocompleter_target: "wrap" } ac_args[:between] = capture do concat(args[:between]) concat(autocompleter_has_id_indicator) + concat(autocompleter_find_button(args)) if args[:find_text] + concat(autocompleter_keep_button(args)) if args[:keep_text] concat(autocompleter_hidden_field(**args)) if args[:form] end + ac_args[:between_end] = capture do + autocompleter_create_button(args) if args[:create_text] + end ac_args[:append] = capture do concat(autocompleter_dropdown) concat(args[:append]) @@ -209,7 +243,39 @@ def autocompleter_field(**args) def autocompleter_has_id_indicator link_icon(:check, title: :autocompleter_has_id.l, - classes: "ml-3 px-2 text-success has-id-indicator") + class: "ml-3 px-2 text-success has-id-indicator", + data: { autocompleter_target: "hasIdIndicator" }) + end + + def autocompleter_create_button(args) + icon_link_to( + args[:create_text], "#", + icon: :plus, show_text: true, icon_class: "text-primary", + name: "create_#{args[:type]}", class: "ml-3 create-button", + data: { autocompleter_target: "createBtn", + action: "autocompleter#swapCreate:prevent" } + ) + end + + def autocompleter_find_button(args) + icon_link_to( + args[:find_text], "#", + icon: :find_on_map, show_text: false, icon_class: "text-primary", + name: "find_#{args[:type]}", class: "ml-3 d-none", + data: { map_target: "showBoxBtn", + action: "map#showBox:prevent" } + ) + end + + def autocompleter_keep_button(args) + icon_link_to( + args[:keep_text], "#", + icon: :apply, show_text: false, icon_class: "text-primary", + active_icon: :edit, active_content: args[:edit_text], + name: "keep_#{args[:type]}", class: "ml-3 d-none", + data: { autocompleter_target: "keepBtn", map_target: "lockBoxBtn", + action: "map#toggleBoxLock:prevent" } + ) end # minimum args :form, :type. @@ -251,6 +317,7 @@ def autocompleter_dropdown def text_area_with_label(**args) args = auto_label_if_form_is_account_prefs(args) args = check_for_optional_or_required_note(args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) opts[:class] = "form-control" opts[:class] += " text-monospace" if args[:monospace].present? @@ -273,6 +340,7 @@ def select_with_label(**args) args = auto_label_if_form_is_account_prefs(args) args = select_generate_default_options(args) args = check_for_optional_or_required_note(args) + args = check_for_help_block(args) opts = separate_field_options_from_args( args, [:options, :select_opts, :start_year, :end_year] @@ -310,6 +378,7 @@ def select_generate_default_options(args) # it identifies the wrapping div. (That's also valid HTML.) # https://stackoverflow.com/a/16426122/3357635 def date_select_with_label(**args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args, [:object, :data]) opts[:class] = "form-control" opts[:data] = { controller: "year-input" }.merge(args[:data] || {}) @@ -351,6 +420,7 @@ def date_select_opts(args = {}) # Bootstrap number_field def number_field_with_label(**args) args = auto_label_if_form_is_account_prefs(args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) opts[:class] = "form-control" opts[:min] ||= 1 @@ -365,6 +435,7 @@ def number_field_with_label(**args) # Bootstrap password_field def password_field_with_label(**args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) opts[:class] = "form-control" opts[:value] ||= "" @@ -423,6 +494,7 @@ def static_text_with_label(**args) # Bootstrap url_field def url_field_with_label(**args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) opts[:class] = "form-control" opts[:value] ||= "" @@ -437,6 +509,7 @@ def url_field_with_label(**args) # Bootstrap file input field with client-side size validation. def file_field_with_label(**args) + args = check_for_help_block(args) opts = separate_field_options_from_args(args) input_span_class = "file-field btn btn-default" max_size = MO.image_upload_max_size @@ -552,13 +625,34 @@ def check_for_optional_or_required_note(args) args end + # Adds a help block to the field, with a collapse trigger beside the label. + def check_for_help_block(args) + unless args[:help].present? && args[:field].present? && args[:form].present? + return args + end + + id = "#{args[:form].object_name}_#{args[:field]}_help" + args[:between] = capture do + concat(collapse_info_trigger(id)) + concat(args[:between]) + end + args[:append] = capture do + concat(args[:append]) + concat(collapse_help_block(nil, id:) do + concat(args[:help]) + end) + end + args + end + # These are args that should not be passed to the field # Note that :value is sometimes explicitly passed, so it must # be excluded separately (not here) def separate_field_options_from_args(args, extras = []) exceptions = [ - :form, :field, :label, :class, :width, :inline, :between, :append, - :addon, :optional, :required, :monospace, :type, :wrap_data + :form, :field, :label, :class, :width, :inline, :between, :between_end, + :append, :help, :addon, :optional, :required, :monospace, :type, + :wrap_data, :button, :button_data ] + extras args.clone.except(*exceptions) diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index aec14aefb2..8048896771 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -55,6 +55,31 @@ def link_to_coerced_query(query, model) link_to(*tab) end + # Link should be to a controller action that renders the form in the modal. + # Stimulus modal-toggle controller fetches the form from the link as a . + # turbo-stream response. It also checks if it needs to generate a modal, or + # just show the one in progress. + # NOTE: Needs a modal `identifier`, in case of multiple form modals + # NOTE: Args from an MO "tab" will be a hash. + # Links with data-turbo-frame do a direct page update, and if turbo doesn't + # find the frame already on the page it's appended after body! That may be + # why it's appended to the page and not returned to the stimulus caller + def modal_link_to(identifier, name, path, args) + args = args.deep_merge({ data: { + modal: "modal_#{identifier}", + controller: "modal-toggle", + action: "modal-toggle#showModal:prevent" + } }) + + if args[:icon].present? + icon_link_to(name, path, **args) + else + link_to(name, path, **args) + end + end + + # Icon link with optional active state. (Tooltip title must be swapped in JS.) + # Now also accepts active state options: active_icon, active_content # NOTE: Takes same args as link_to, e.g. *edit_description_tab(desc, type) # icon_link_to(text, path, **args) def icon_link_to(text = nil, path = nil, options = {}, &block) @@ -62,19 +87,32 @@ def icon_link_to(text = nil, path = nil, options = {}, &block) link = block ? text : path # because positional content = block ? capture(&block) : text - opts = block ? path : options + opts = block ? path : options # because positional icon_type = opts[:icon] - label_class = opts[:show_text] ? "pl-3" : "sr-only" return link_to(link, opts) { content } if icon_type.blank? - opts = { - title: content, - data: { toggle: "tooltip" } - }.deep_merge(opts.except(:icon, :show_text)) - - link_to(link, **opts) do - concat(link_icon(icon_type)) + active_icon = opts[:active_icon] + active_content = options[:active_content] + stateful = active_icon && active_content + icon_class = class_names(opts[:icon_class], "px-2") + icon_active_class = class_names(icon_class, "active-icon") + label_show_classes = "pl-2 d-none d-sm-inline font-weight-bold" + label_class = opts[:show_text] ? label_show_classes : "sr-only" + label_active_class = class_names(label_class, "active-label") + + link_opts = { + role: "button", title: content, # title is what shows up in tooltip + class: class_names("icon-link", opts[:class]), + data: { toggle: "tooltip", title: content, # needed for swapping only + active_title: opts[:active_content] } + }.deep_merge(opts.except(:class, :icon, :icon_class, :show_text, + :active_icon, :active_content)) + + link_to(link, **link_opts) do + concat(link_icon(icon_type, class: icon_class)) + concat(link_icon(active_icon, class: icon_active_class)) if stateful concat(tag.span(content, class: label_class)) + concat(tag.span(active_content, class: label_active_class)) if stateful end end @@ -89,43 +127,21 @@ def icon_link_with_query(text = nil, path = nil, options = {}, &block) icon_link_to(add_query_param(link), opts) { content } end - # Link should be to a controller action that renders the form in the modal. - # Stimulus modal-toggle controller fetches the form from the link as a . - # turbo-stream response. It also checks if it needs to generate a modal, or - # just show the one in progress. - # NOTE: Needs a modal `identifier`, in case of multiple form modals - # NOTE: Args from an MO "tab" will be a hash. - # Links with data-turbo-frame do a direct page update, and if turbo doesn't - # find the frame already on the page it's appended after body! That may be - # why it's appended to the page and not returned to the stimulus caller - def modal_link_to(identifier, name, path, args) - args = args.deep_merge({ data: { - modal: "modal_#{identifier}", - controller: "modal-toggle", - action: "modal-toggle#showModal:prevent" - } }) - - if args[:icon].present? - icon_link_to(name, path, **args) - else - link_to(name, path, **args) - end - end - # pass title if it's a plain button (say for collapse) but you want a tooltip - def link_icon(type, title: "", classes: "px-2") + def link_icon(type, **args) return "" unless (glyph = LINK_ICON_INDEX[type]) text = "" - opts = { class: "glyphicon glyphicon-#{glyph} link-icon #{classes}" } + args[:class] = class_names("glyphicon glyphicon-#{glyph} link-icon", + args[:class]) - if title.present? - tooltip_opts = { data: { toggle: "tooltip", title: title } } - opts = opts.merge(tooltip_opts) + if args[:title].present? + title = args[:title] + args[:data] = { toggle: "tooltip", title: }.merge(args[:data] || {}) text = tag.span(title, class: "sr-only") end - tag.span(text, **opts) + tag.span(text, **args.except(:title)) end # NOTE: Specific to glyphicons @@ -141,6 +157,7 @@ def link_icon(type, title: "", classes: "px-2") remove: "remove-circle", send: "send", ban: "ban-circle", + plus: "plus-sign", minus: "minus-sign", trash: "trash", cancel: "remove", @@ -162,7 +179,9 @@ def link_icon(type, title: "", classes: "px-2") manage_lists: "indent-left", observations: "tags", print: "print", - globe: "globe" + globe: "globe", + find_on_map: "screenshot", + apply: "check" }.freeze # button to destroy object diff --git a/app/helpers/tabs/herbaria_helper.rb b/app/helpers/tabs/herbaria_helper.rb index da325a3bf1..29c4a812d6 100644 --- a/app/helpers/tabs/herbaria_helper.rb +++ b/app/helpers/tabs/herbaria_helper.rb @@ -53,7 +53,7 @@ def herbarium_show_tabs(herbarium:, user:) end def herbarium_form_new_tabs - nonpersonal_herbaria_index_tab + [nonpersonal_herbaria_index_tab] end def herbarium_form_edit_tabs(herbarium:) diff --git a/app/helpers/title_and_tabset_helper.rb b/app/helpers/title_and_tabset_helper.rb index 6d212d5a92..48e5f39f81 100644 --- a/app/helpers/title_and_tabset_helper.rb +++ b/app/helpers/title_and_tabset_helper.rb @@ -103,7 +103,7 @@ def link_next(object) else send(:"#{object.type_tag}_path", object.id, flow: "next") end - link_with_query("#{:FORWARD.t} »", path) + link_with_query("#{:NEXT.t} »", path) end # link to previous object in query results @@ -113,7 +113,7 @@ def link_prev(object) else send(:"#{object.type_tag}_path", object.id, flow: "prev") end - link_with_query("« #{:BACK.t}", path) + link_with_query("« #{:PREV.t}", path) end # Short-hand to render shared tab_set partial for a given set of tabs. diff --git a/app/javascript/controllers/autocompleter_controller.js b/app/javascript/controllers/autocompleter_controller.js index a75fc08be3..fdf36438d6 100644 --- a/app/javascript/controllers/autocompleter_controller.js +++ b/app/javascript/controllers/autocompleter_controller.js @@ -1,5 +1,5 @@ import { Controller } from "@hotwired/stimulus" -import { escapeHTML, getScrollBarWidth, EVENT_KEYS } from "src/mo_utilities" +import { mo_form_utilities, EVENT_KEYS } from "src/mo_form_utilities" import { get } from "@rails/request.js" // @pellaea's autocompleter is different from other open source autocompleter @@ -36,9 +36,9 @@ const DEFAULT_OPTS = { // where to request primer from AJAX_URL: "/autocompleters/new/", // how long to wait before sending AJAX request (seconds) - REFRESH_DELAY: 0.10, + REFRESH_DELAY: 0.33, // how long to wait before hiding pulldown (seconds) - HIDE_DELAY: 0.25, + HIDE_DELAY: 0.50, // initial key repeat delay (seconds) KEY_DELAY_1: 0.50, // subsequent key repeat delay (seconds) @@ -64,6 +64,7 @@ const DEFAULT_OPTS = { } // Allowed types of autocompleter. Sets some DEFAULT_OPTS from type +// Model is used for the field identifier in the hidden input. const AUTOCOMPLETER_TYPES = { clade: { model: 'name' @@ -74,12 +75,21 @@ const AUTOCOMPLETER_TYPES = { }, location: { // params[:format] handled in controller ACT_LIKE_SELECT: false, + AUTOFILL_SINGLE_MATCH: false, UNORDERED: true, - model: 'location' + model: 'location', + // create_link: '/locations/new?where=' }, location_containing: { // params encoded from dataset ACT_LIKE_SELECT: true, - model: 'location' + AUTOFILL_SINGLE_MATCH: true, + model: 'location', + // create_link: '/locations/new?where=' + }, + location_google: { // params encoded from dataset + ACT_LIKE_SELECT: true, + AUTOFILL_SINGLE_MATCH: false, + model: 'location', // because it's creating a location }, name: { COLLAPSE: 1, @@ -112,6 +122,7 @@ const INTERNAL_OPTS = { menu_up: false, // is pulldown visible? old_value: null, // previous value of input field stored_id: 0, // id of selected option + stored_data: { id: 0 }, // data of selected option primer: [], // a server-supplied list of many options matches: [], // list of options currently showing current_row: -1, // index of option currently highlighted (0 = none) @@ -120,13 +131,16 @@ const INTERNAL_OPTS = { current_width: 0, // current width of menu scroll_offset: 0, // scroll offset last_fetch_request: '', // last fetch request we got results for - last_fetch_params: '', // last fetch request we sent, minus the string + last_fetch_params: '', // last fetch request we sent, minus the string last_fetch_incomplete: true, // did we get all the results we requested? fetch_request: null, // ajax request while underway refresh_timer: null, // timer used to delay update after typing hide_timer: null, // timer used to delay hiding of pulldown key_timer: null, // timer used to emulate key repeat - log: false // log debug messages to console? + data_timer: null, // timer used to delay hidden data updated event (map) + create_timer: null, // timer used to delay create link + log: false, // log debug messages to console? + has_create_link: false // pulldown currently has link to create new record } // Connects to data-controller="autocompleter" @@ -134,7 +148,9 @@ export default class extends Controller { // The root element should usually be the .form-group wrapping the . // The select target is not the element, but a element is its target. - static targets = ["input", "select", "pulldown", "list", "hidden", "wrap"] + static targets = ["input", "select", "pulldown", "list", "hidden", "wrap", + "createBtn", "hasIdIndicator", "keepBtn"] + static outlets = ["map"] initialize() { Object.assign(this, DEFAULT_OPTS); @@ -148,10 +164,13 @@ export default class extends Controller { Object.assign(this, AUTOCOMPLETER_TYPES[this.TYPE]); Object.assign(this, INTERNAL_OPTS); + // Does this autocompleter affect a map? + this.hasMap = this.inputTarget.dataset.hasOwnProperty("mapTarget"); + this.hasGeocode = this.inputTarget.dataset.hasOwnProperty("geocodeTarget"); + // Shared MO utilities, imported at the top: this.EVENT_KEYS = EVENT_KEYS; - this.escapeHTML = escapeHTML; - this.getScrollBarWidth = getScrollBarWidth; + Object.assign(this, mo_form_utilities); } connect() { @@ -167,20 +186,30 @@ export default class extends Controller { this.WRAP_CLASS + "\""); } + // this.create_text = this.inputTarget.dataset?.createText ?? null; + this.default_action = + this.listTarget?.children[0]?.children[0]?.dataset.action; // Attach events, etc. to input element. this.prepareInputElement(); } - // Swap out autocompleter type (and properties) - // Callable externally. Action may be called from a