diff --git a/.github/workflows/ci_rails.yml b/.github/workflows/ci_rails.yml index d4d6eec05c..6a0937458c 100644 --- a/.github/workflows/ci_rails.yml +++ b/.github/workflows/ci_rails.yml @@ -73,7 +73,7 @@ jobs: - name: Run tests env: RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} - run: bundle exec rails test:all + run: bundle exec rails test # https://github.com/marketplace/actions/coveralls-github-action - name: Coveralls GitHub Action diff --git a/.rubocop.yml b/.rubocop.yml index 6a9e8e91ef..c63055ec44 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,4 +1,7 @@ inherit_from: .rubocop_todo.yml +inherit_mode: + merge: + - Exclude require: - rubocop-performance @@ -178,7 +181,7 @@ Rails/WhereExists: Style/AccessorGrouping: # https://github.com/MushroomObserver/mushroom-observer/pull/988 Enabled: false - # The followin Exclude in case we re-enable the cop + # The following are Exclude in case we re-enable the cop # Exclude files that individually comment accessors # else Rubocop concatenates the accessors and comments on a single line. # Disabling Style/AccessorGrouping around the accessors doesn't diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5938c28a08..946aa3b9cd 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by -# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit` -# on 2024-08-01 19:59:56 UTC using RuboCop version 1.65.1. +# `rubocop --auto-gen-config --no-exclude-limit` +# on 2024-08-08 16:15:46 UTC using RuboCop version 1.65.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -277,7 +277,7 @@ Metrics/AbcSize: - 'script/refresh_sitemap' - 'script/update_ip_stats.rb' -# Offense count: 32 +# Offense count: 31 # Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. # AllowedMethods: refine Metrics/BlockLength: @@ -287,7 +287,6 @@ Metrics/BlockLength: - 'app/controllers/concerns/descriptions/moves.rb' - 'app/controllers/concerns/descriptions/publish.rb' - 'app/extensions/symbol_extensions.rb' - - 'app/helpers/forms_helper.rb' - 'app/views/controllers/api2/_image.xml.builder' - 'app/views/controllers/api2/_name.xml.builder' - 'app/views/controllers/api2/_observation.xml.builder' @@ -320,7 +319,7 @@ Metrics/ClassLength: - 'app/models/abstract_model.rb' - 'app/models/description.rb' -# Offense count: 122 +# Offense count: 121 # Configuration parameters: AllowedMethods, AllowedPatterns, Max. Metrics/CyclomaticComplexity: Exclude: @@ -403,7 +402,7 @@ Metrics/CyclomaticComplexity: - 'script/bulk_name_change' - 'script/update_ip_stats.rb' -# Offense count: 20 +# Offense count: 19 # Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Exclude: @@ -416,7 +415,6 @@ Metrics/MethodLength: - 'app/controllers/locations_controller.rb' - 'app/controllers/names/eol_data/preview_controller.rb' - 'app/extensions/symbol_extensions.rb' - - 'app/helpers/forms_helper.rb' - 'app/helpers/pagination_helper.rb' - 'app/models/image.rb' - 'app/models/language_exporter.rb' @@ -446,7 +444,7 @@ Metrics/ParameterLists: - 'test/controllers/projects/members_controller_test.rb' - 'test/models/collapsible_map_test.rb' -# Offense count: 94 +# Offense count: 93 # Configuration parameters: AllowedMethods, AllowedPatterns, Max. Metrics/PerceivedComplexity: Exclude: diff --git a/Gemfile b/Gemfile index eb9a4c326e..b13c7e96c9 100644 --- a/Gemfile +++ b/Gemfile @@ -83,7 +83,7 @@ gem("blankslate") # Simple version models and tables for classes # Use our own fork, which stores enum attrs as integers in the db gem("mo_acts_as_versioned", ">= 0.6.6", - git: "https://github.com/MushroomObserver/acts_as_versioned/") + git: "https://github.com/MushroomObserver/acts_as_versioned") # Use ActiveModel has_secure_password gem("bcrypt") diff --git a/Gemfile.lock b/Gemfile.lock index 3614376bcb..7f14eefda8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,5 +1,5 @@ GIT - remote: https://github.com/MushroomObserver/acts_as_versioned/ + remote: https://github.com/MushroomObserver/acts_as_versioned revision: 56891f7f915610e9f884e3a932bfb6934303b3b8 specs: mo_acts_as_versioned (0.6.6) @@ -78,7 +78,7 @@ GEM bootstrap-sass (3.4.1) autoprefixer-rails (>= 5.2.1) sassc (>= 2.0.0) - brakeman (6.1.2) + brakeman (6.2.1) racc browser (6.0.0) builder (3.3.0) @@ -99,7 +99,7 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) chunky_png (1.4.0) - concurrent-ruby (1.3.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) crack (1.0.0) bigdecimal @@ -145,7 +145,7 @@ GEM ffi (1.17.0-arm64-darwin) ffi (1.17.0-x86_64-darwin) ffi (1.17.0-x86_64-linux-gnu) - fugit (1.11.0) + fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) globalid (1.2.1) @@ -308,7 +308,7 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.3.4) + rexml (3.3.6) strscan rqrcode (2.2.0) chunky_png (~> 1.0) diff --git a/app/assets/stylesheets/Admin.scss b/app/assets/stylesheets/Admin.scss index bf0e0ff2f9..823dac7709 100644 --- a/app/assets/stylesheets/Admin.scss +++ b/app/assets/stylesheets/Admin.scss @@ -9,7 +9,7 @@ $LOGO_BG_COLOR: yellow; $LOGO_HOVER_FG_COLOR: purple; $LOGO_HOVER_BG_COLOR: yellow; -$LEFT_BAR_BORDER_COLOR: #545555; // gray +$LEFT_BAR_BORDER_COLOR: #535354; // 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 c7d5dbcd37..8cc478c8e5 100644 --- a/app/assets/stylesheets/Agaricus.scss +++ b/app/assets/stylesheets/Agaricus.scss @@ -1,7 +1,7 @@ @import "defaults"; -$augustus_cap: #EaCe93; // #ECCF95 -$brasiliensis_gills_1: #A06463; +$augustus_cap: #ECCF95; +$brasiliensis_gills_1: #BF6262; // #A06463 $brasiliensis_gills_2: #743931; $campestris_cap: #F6F0F2; $cupreobrunneus_gills: #3B2821; diff --git a/app/assets/stylesheets/Amanita.scss b/app/assets/stylesheets/Amanita.scss index 44725ef795..199d8a63e1 100644 --- a/app/assets/stylesheets/Amanita.scss +++ b/app/assets/stylesheets/Amanita.scss @@ -13,7 +13,7 @@ $calyptroderma_middle_cap: #c18346; $muscaria_background: #cc2616; $muscaria_foreground: #fff8c6; $velosa_background: #dd9d5f; -$velosa_light_veil: #fbead3; // faebd4 +$velosa_light_veil: #f9e8d3; // 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 e538748a26..2809a5de38 100644 --- a/app/assets/stylesheets/BlackOnWhite.scss +++ b/app/assets/stylesheets/BlackOnWhite.scss @@ -4,7 +4,7 @@ $LOGO_BORDER_COLOR: #DDDDDD; $LEFT_BAR_BORDER_COLOR: #DDDDDD; -$TOP_BAR_BORDER_COLOR: #DFDfDD; +$TOP_BAR_BORDER_COLOR: #D9D9D9; $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 b9948d2268..22901b7443 100644 --- a/app/assets/stylesheets/Cantharellaceae.scss +++ b/app/assets/stylesheets/Cantharellaceae.scss @@ -11,7 +11,7 @@ $tubaeformis_hymenium: #c2914c; $tubaeformis_bright_stipe: #ffb230; $tubaeformis_dark_stipe: #4b2e0c; $tubaeformis_light_stipe: #e5bb67; -$cornucopioides_dark_hymenium: #11120b; // image 465 #10110b +$cornucopioides_dark_hymenium: #13120d; // 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 dc7062c8db..7cbf4e55b7 100644 --- a/app/assets/stylesheets/Hygrocybe.scss +++ b/app/assets/stylesheets/Hygrocybe.scss @@ -1,6 +1,6 @@ @import "defaults"; -$conica_stain: #35362d; // #37372f +$conica_stain: #34342c; // #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 b091bf1cec..2687d767b8 100644 --- a/app/assets/stylesheets/Sudo.scss +++ b/app/assets/stylesheets/Sudo.scss @@ -1,6 +1,6 @@ @import "defaults"; -$BODY_BG_COLOR: #DE7500; // #DD7700 +$BODY_BG_COLOR: #DE7201; // #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 885c5bb58f..af1909e111 100644 --- a/app/assets/stylesheets/mo/_autocomplete.scss +++ b/app/assets/stylesheets/mo/_autocomplete.scss @@ -6,16 +6,29 @@ .has-id-indicator { display: none; } -.create-button { - display: inline-block; -} - .has-id { .has-id-indicator { display: inline-block; } +} + +.keep-btn { + display: none; +} +.create { + .keep-btn { + display: inline-block; + } +} + +// initially we may not have id, but we also don't offer create +// until they've typed something +.create-button { + display: none; +} +.offer-create { .create-button { - display: none; + display: inline-block; } } diff --git a/app/assets/stylesheets/mo/_help_tooltips.scss b/app/assets/stylesheets/mo/_help_tooltips.scss index 5e2e32e92f..215f24aa06 100644 --- a/app/assets/stylesheets/mo/_help_tooltips.scss +++ b/app/assets/stylesheets/mo/_help_tooltips.scss @@ -17,7 +17,7 @@ $help-block-max-width: 600px; .help-block { color: $WELL_FG_COLOR; - max-width: $help-block-max-width; + // max-width: $help-block-max-width; } .tooltip-inner { diff --git a/app/assets/stylesheets/mo/_icons.scss b/app/assets/stylesheets/mo/_icons.scss index 82c0b4ddc6..6e96d3a9ee 100644 --- a/app/assets/stylesheets/mo/_icons.scss +++ b/app/assets/stylesheets/mo/_icons.scss @@ -99,11 +99,13 @@ } // This is for the stateful icon_link_to helper -.icon-link { +.icon-link, +.panel-collapse-trigger { .active-icon, .active-label { display: none; } - &.active { + &.active, + &.collapsed { .link-icon:not(.active-icon), .sr-only:not(.active-label) { display: none; diff --git a/app/assets/stylesheets/mo/_maps.scss b/app/assets/stylesheets/mo/_maps.scss index 58e2f0189a..ec76efb699 100644 --- a/app/assets/stylesheets/mo/_maps.scss +++ b/app/assets/stylesheets/mo/_maps.scss @@ -2,7 +2,7 @@ // thumbnail & google map styles // -.observation-form-map { +.form-map { height: 400px; width: 100%; background-position: center center; diff --git a/app/controllers/admin/blocked_ips_controller.rb b/app/controllers/admin/blocked_ips_controller.rb index dd9e67e39f..cc9ca6313d 100644 --- a/app/controllers/admin/blocked_ips_controller.rb +++ b/app/controllers/admin/blocked_ips_controller.rb @@ -4,7 +4,7 @@ module Admin class BlockedIpsController < AdminController # This page allows editing of blocked ips via params # params[:add_okay] and params[:add_bad] - # GETting this page with params[:report] will show info about a chosen IP + # Using params[:report] will show info about a chosen IP def edit @ip = params[:report] if validate_ip!(params[:report]) @blocked_ips = sort_by_ip(IpStats.read_blocked_ips) @@ -14,6 +14,7 @@ def edit # Render the page after an update def update + strip_params! process_blocked_ips_commands @blocked_ips = sort_by_ip(IpStats.read_blocked_ips) @okay_ips = sort_by_ip(IpStats.read_okay_ips) @@ -23,6 +24,12 @@ def update private + def strip_params! + [:add_bad, :remove_bad, :add_okay, :remove_okay].each do |param| + params[param] = params[param].strip if params[param] + end + end + def sort_by_ip(ips) ips.sort_by do |ip| ip.to_s.split(".").map { |n| n.to_i + 1000 }.map(&:to_s).join(" ") diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9c12d701d6..e1f6b95400 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -788,7 +788,12 @@ def flash_object_errors(obj) def save_with_log(obj) type_sym = obj.class.to_s.underscore.to_sym if obj.save - flash_notice(:runtime_created_at.t(type: type_sym)) + notice = if obj.respond_to?(:text_name) && (name = obj.text_name) + :runtime_created_name.t(type: type_sym, value: name) + else + :runtime_created_at.t(type: type_sym) + end + flash_notice(notice) true else flash_error(:runtime_no_save.t(type: type_sym)) diff --git a/app/controllers/concerns/locationable.rb b/app/controllers/concerns/locationable.rb new file mode 100644 index 0000000000..e4a4d44413 --- /dev/null +++ b/app/controllers/concerns/locationable.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +# +# = Locationable Concern +# +# This is a module of reusable methods that can be included by controllers that +# deal with associated locations, where the location may be created on the fly +# in the create form. (Why not "locatable"? It could be otherwise locatable.) +# - ObservationsController +# - HerbariaController +# - Account::ProfileController +# - ProjectsController +# +# The controller must follow some conventions: +# - send its main object ivar (@observation, etc) to the methods +# - use a `@location` ivar +# - set the `@user` ivar +# - use the `@any_errors` ivar to halt the save process +# +# == Methods +# +# create_location_object_if_new(object):: Create a new Location associated with +# the object, if necessary +# +# place_name_exists?(object):: Check if the location name exists in the db, and +# if so, set the location_id +# +# try_to_save_location_if_new(object):: Save the location only if it is new +# +# save_location(object):: Save the location only (at this point rest of form is +# okay) +# +################################################################################ + +module Locationable + extend ActiveSupport::Concern + + included do + # By now we should have an object (pass the ivar!), 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 @object. + def create_location_object_if_new(object) + # Resets the location_id to MO's existing Location if it already exists. + return false if place_name_exists?(object) + + # Ensure we have the minimum necessary to create a new location + unless object.location_id == -1 && + (place_name = params.dig(object.type_tag, :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?(object) + name = Location.user_format(@user, object.place_name) + location = Location.find_by(name: name) + if !object.location_id&.positive? && location || + (location && (object.location_id != location&.id)) + if object.location_id == -1 + flash_warning(:runtime_location_already_exists.t(name: name)) + end + object.location_id = location.id + return true + end + + false + end + + def try_to_save_location_if_new(object) + return if @any_errors || !@location&.new_record? || save_location(object) + + @any_errors = true + end + + # Save location only (at this point rest of form is okay). + def save_location(object) + if save_with_log(@location) + # Associate the location with the observation + object.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 + end +end diff --git a/app/controllers/herbaria_controller.rb b/app/controllers/herbaria_controller.rb index 912470f780..b45fee660b 100644 --- a/app/controllers/herbaria_controller.rb +++ b/app/controllers/herbaria_controller.rb @@ -45,7 +45,10 @@ # See https://tinyurl.com/ynapvpt7 # View and modify Herbaria (displayed as "Fungaria") +# rubocop:disable Metrics/ClassLength class HerbariaController < ApplicationController + include ::Locationable + before_action :login_required # only: [:create, :destroy, :edit, :new, :update] before_action :store_location, only: [:create, :edit, :new, :show, :update] @@ -99,24 +102,69 @@ def show def new @herbarium = Herbarium.new + respond_to do |format| + format.turbo_stream { render_modal_herbarium_form } + format.html + end end def edit @herbarium = find_or_goto_index(Herbarium, params[:id]) - return unless @herbarium - return unless make_sure_can_edit! + return unless @herbarium && make_sure_can_edit! + + set_up_herbarium_for_edit + respond_to do |format| + format.turbo_stream { render_modal_herbarium_form } + format.html + end + end + def set_up_herbarium_for_edit @herbarium.place_name = @herbarium.location.try(&:name) @herbarium.personal = @herbarium.personal_user_id.present? @herbarium.personal_user_name = @herbarium.personal_user.try(&:login) end + def render_modal_herbarium_form + render(partial: "shared/modal_form", + locals: { title: modal_title, action: modal_form_action, + identifier: modal_identifier, local: false, + form: "herbaria/form" }) and return + end + + def modal_identifier + case action_name + when "new", "create" + "herbarium" + when "edit", "update" + "herbarium_#{@herbarium.id}" + end + end + + def modal_title + case action_name + when "new", "create" + :create_herbarium_title.l + when "edit", "update" + :edit_herbarium_title.l + end + end + + def modal_form_action + case action_name + when "new", "create" then :create + when "edit", "update" then :update + end + end + # ---------- Actions to Modify data: (create, update, destroy, etc.) --------- def create @herbarium = Herbarium.new(herbarium_params) normalize_parameters - return render(:new) unless validate_herbarium! + create_location_object_if_new(@herbarium) + try_to_save_location_if_new(@herbarium) + return render(:new) unless validate_herbarium! && !@any_errors @herbarium.save @herbarium.add_curator(@user) if @herbarium.personal_user @@ -130,7 +178,9 @@ def update @herbarium.attributes = herbarium_params normalize_parameters - return unless validate_herbarium! + create_location_object_if_new(@herbarium) + try_to_save_location_if_new(@herbarium) + return unless validate_herbarium! && !@any_errors @herbarium.save redirect_to_create_location_or_referrer_or_show_location @@ -265,6 +315,10 @@ def validate_admin_personal_user! flash_notice( :edit_herbarium_successfully_made_personal.t(user: user.login) ) + update_personal_herbarium(user) + end + + def update_personal_herbarium(user) @herbarium.curators.clear @herbarium.add_curator(user) @herbarium.personal_user_id = user.id @@ -326,7 +380,7 @@ def user_can_destroy_herbarium? def redirect_to_create_location_or_referrer_or_show_location redirect_to_create_location || redirect_to_referrer || - redirect_with_query(herbarium_path(@herbarium)) + show_modal_flash_or_show_herbarium end def redirect_to_create_location @@ -339,11 +393,39 @@ def redirect_to_create_location true end + # this updates both the form and the flash + def reload_herbarium_modal_form_and_flash + render( + partial: "shared/modal_form_reload", + locals: { identifier: modal_identifier, form: "herbaria/form" } + ) and return true + end + + # What to do if the save succeeds + def show_modal_flash_or_show_herbarium + respond_to do |format| + format.html do + redirect_with_query(herbarium_path(@herbarium)) and return + end + format.turbo_stream do + # Context here is the obs form. + flash_notice( + :runtime_created_name.t(type: :herbarium, value: @herbarium.name) + ) + flash_notice( + :runtime_added_to.t(type: :herbarium, name: :observation) + ) + render(partial: "herbaria/update_observation") and return + end + end + end + def herbarium_params return {} unless params[:herbarium] params.require(:herbarium). - permit(:name, :code, :email, :mailing_address, :description, + permit(:name, :code, :email, :mailing_address, :description, :location_id, :place_name, :personal, :personal_user_name) end end +# rubocop:enable Metrics/ClassLength diff --git a/app/controllers/herbarium_records_controller.rb b/app/controllers/herbarium_records_controller.rb index 071778a4e8..b837a705bf 100644 --- a/app/controllers/herbarium_records_controller.rb +++ b/app/controllers/herbarium_records_controller.rb @@ -453,8 +453,7 @@ def render_herbarium_records_section_update def reload_herbarium_record_modal_form_and_flash render( partial: "shared/modal_form_reload", - locals: { identifier: "herbarium_record", - form: "herbarium_records/form" } + locals: { identifier: modal_identifier, form: "herbarium_records/form" } ) and return true end end diff --git a/app/controllers/observations/images_controller.rb b/app/controllers/observations/images_controller.rb index 25fd19debc..9afbb3ca6b 100644 --- a/app/controllers/observations/images_controller.rb +++ b/app/controllers/observations/images_controller.rb @@ -10,38 +10,48 @@ class ImagesController < ApplicationController before_action :login_required before_action :pass_query_params - # NEW: Upload Images to an Observation. - # was ImageController#add_image - def new - return unless (@observation = find_observation!) + ########################################################################### - return unless check_observation_permission! + # Form for editing date/license/notes on an observation image. + # Linked from: show_image/original + # Inputs: params[:id] (image) + # params[:comment][:summary] + # params[:comment][:comment] + # Outputs: @image, @licenses + def edit + return unless (@image = find_image!) - @image = rough_cut_image @licenses = current_license_names_and_ids - init_project_vars_for_add_or_edit(@observation) + check_image_permission! + init_project_vars_for_add_or_edit(@image) end - # new (upload) image commits here - def create - return unless (@observation = find_observation!) + def update + return unless (@image = find_image!) - return unless check_observation_permission! + @licenses = current_license_names_and_ids + check_image_permission! - create_image + @image.attributes = permitted_image_params + + if image_or_projects_updated + # redirect_with_query(image_path(@image.id)) + render("images/show", + location: image_path(@image.id, q: get_query_param)) + else + init_project_vars_for_reload(@image) + render(:edit, location: edit_image_path(@image.id)) + end end private - def find_observation! - find_or_goto_index(Observation, params[:id].to_s) + def find_image! + find_or_goto_index(Image, params[:id].to_s) end - def check_observation_permission! - return true if check_permission!(@observation) - - redirect_with_query(permanent_observation_path(id: @observation.id)) - false + def current_license_names_and_ids + License.available_names_and_ids(@image.license) end def init_project_vars_for_add_or_edit(obs_or_img) @@ -54,73 +64,45 @@ def init_project_vars_for_add_or_edit(obs_or_img) end end - def current_license_names_and_ids - License.available_names_and_ids(@image.license) + def check_image_permission! + return if check_permission!(@image) + + redirect_with_query(image_path(@image)) end - def rough_cut_image - @image = Image.new - @image.license = @user.license - @image.copyright_holder = @user.legal_name - @image.user = @user - # Set the default date to the date of the observation - # Don't know how to correctly test this. - @image.when = @observation.when - @image + def permitted_image_params + params.require(:image).permit(permitted_image_args) end - def create_image - if params[:upload].blank? - flash_warning(:runtime_no_changes.t) + def image_or_projects_updated + if !image_data_changed? + update_projects_and_flash_notice! + true + elsif !@image.save + flash_object_errors(@image) + false else - # There are five upload spots on the form. - # For as many as have an image, process the image. - args = params[:image] - i = 1 - while i < 5 || params[:upload]["image#{i}"].present? - process_image(args, params[:upload]["image#{i}"]) - i += 1 - end + @image.log_update + flash_notice(:runtime_image_edit_success.t(id: @image.id)) + update_related_projects(@image, params[:project]) + true end - redirect_with_query(permanent_observation_path(id: @observation.id)) end - def process_image(args, upload) - return if upload.blank? - - @image = Image.new(args.permit(permitted_image_args)) - @image.created_at = Time.current - @image.updated_at = @image.created_at - @image.user = @user - @image.image = upload - @image.save - - # The 1st save (or !save) puts the image's original filename in the db, - # whether or not the user wants it. So if we don't want it, - # we must empty it and save a 2nd time. - @image.original_name = "" if @user.keep_filenames == "toss" - return flash_object_errors(@image) unless @image.save - - return revert_image_name_and_flash_errors unless - @image.process_image(strip: @observation.gps_hidden) - - add_image_to_observation! - end - - def add_image_to_observation! - @observation.add_image(@image) - @image.log_create_for(@observation) - name = @image.original_name - name = "##{@image.id}" if name.empty? - flash_notice(:runtime_image_uploaded_image.t(name: name)) - update_related_projects(@image, params[:project]) + def image_data_changed? + @image.when_changed? || + @image.notes_changed? || + @image.copyright_holder_changed? || + @image.original_name_changed? || + @image.license_id_changed? end - def revert_image_name_and_flash_errors - name = @image.original_name - name = "???" if name.empty? - flash_error(:runtime_image_invalid_image.t(name: name)) - flash_object_errors(@image) + def update_projects_and_flash_notice! + if update_related_projects(@image, params[:project]) + flash_notice(:runtime_image_edit_success.t(id: @image.id)) + else + flash_notice(:runtime_no_changes.t) + end end def update_related_projects(img, checks) @@ -173,89 +155,6 @@ def attach_images_to_projects_and_flash_notices(img, projects, checks) any_changes end - public - - ########################################################################### - - # Form for editing date/license/notes on an observation image. - # Linked from: show_image/original - # Inputs: params[:id] (image) - # params[:comment][:summary] - # params[:comment][:comment] - # Outputs: @image, @licenses - def edit - return unless (@image = find_image!) - - @licenses = current_license_names_and_ids - check_image_permission! - init_project_vars_for_add_or_edit(@image) - end - - def update - return unless (@image = find_image!) - - @licenses = current_license_names_and_ids - check_image_permission! - - @image.attributes = permitted_image_params - - if image_or_projects_updated - # redirect_with_query(image_path(@image.id)) - render("images/show", - location: image_path(@image.id, q: get_query_param)) - else - init_project_vars_for_reload(@image) - render(:edit, location: edit_image_path(@image.id)) - end - end - - private - - def find_image! - find_or_goto_index(Image, params[:id].to_s) - end - - def check_image_permission! - return if check_permission!(@image) - - redirect_with_query(image_path(@image)) - end - - def permitted_image_params - params.require(:image).permit(permitted_image_args) - end - - def image_or_projects_updated - if !image_data_changed? - update_projects_and_flash_notice! - true - elsif !@image.save - flash_object_errors(@image) - false - else - @image.log_update - flash_notice(:runtime_image_edit_success.t(id: @image.id)) - update_related_projects(@image, params[:project]) - true - end - end - - def image_data_changed? - @image.when_changed? || - @image.notes_changed? || - @image.copyright_holder_changed? || - @image.original_name_changed? || - @image.license_id_changed? - end - - def update_projects_and_flash_notice! - if update_related_projects(@image, params[:project]) - flash_notice(:runtime_image_edit_success.t(id: @image.id)) - else - flash_notice(:runtime_no_changes.t) - end - end - def init_project_vars_for_reload(obs_or_img) # (Note: In practice, this is never called for add_image, # so obs_or_img is always an image.) @@ -302,6 +201,17 @@ def attach private + def find_observation! + find_or_goto_index(Observation, params[:id].to_s) + end + + def check_observation_permission! + return true if check_permission!(@observation) + + redirect_with_query(permanent_observation_path(id: @observation.id)) + false + end + # Attach an image to observation. def attach_image_to_observation(image) @observation.add_image(image) @@ -315,59 +225,5 @@ def attach_image_to_observation(image) # location: permanent_observation_path(id: @observation.id, # q: get_query_param)) end - - public - - ############################################################################ - - # REMOVE IMAGES: Uses shared partial "shared/images_to_remove" - # Form used to remove one or more images from an observation (not destroy!) - # Linked from: observations/show - # Inputs: - # params[:obj_id] (observation) - # params[:selected][image_id] (value of "yes" means remove) - # Outputs: @observation - # Redirects to observations/show. - # remove_images - def remove - @object = find_or_goto_index(Observation, params[:id].to_s) - return unless @object - - return if check_permission!(@object) - - redirect_with_query(permanent_observation_path(@object.id)) - end - - # Callback to DETACH images from an observation, form :put commits here - def detach - @object = find_or_goto_index(Observation, params[:id].to_s) - return unless @object - - unless check_permission!(@object) - return redirect_with_query(permanent_observation_path(@object.id)) - end - - images = params[:selected] || [{ params[:image_id] => "yes" }] - return unless images - - remove_images_from_object(images) - end - - ############################################################################ - - private - - def remove_images_from_object(images) - images.each do |image_id, do_it| - next unless do_it == "yes" - - next unless (image = Image.safe_find(image_id)) - - @object.remove_image(image) - image.log_remove_from(@object) - flash_notice(:runtime_image_remove_success.t(id: image_id)) - end - redirect_with_query(permanent_observation_path(@object.id)) - end end end diff --git a/app/controllers/observations_controller/create.rb b/app/controllers/observations_controller/create.rb index a29f5a2601..c9fe1bd5bc 100644 --- a/app/controllers/observations_controller/create.rb +++ b/app/controllers/observations_controller/create.rb @@ -3,6 +3,7 @@ module ObservationsController::Create include ObservationsController::SharedFormMethods include ObservationsController::Validators + include ::Locationable # Form to create a new observation, naming, vote, and images. # Linked from: left panel @@ -37,7 +38,7 @@ def create init_new_image_var(Time.zone.now) rough_cut - create_location_object_if_new # may set @location + create_location_object_if_new(@observation) # may set @location @any_errors = false validate_name @@ -47,7 +48,7 @@ def create validate_naming if @name validate_vote if @name validate_images - try_to_save_location_if_new + try_to_save_location_if_new(@observation) try_to_save_new_observation return reload_new_form(params.dig(:naming, :reasons)) if @any_errors diff --git a/app/controllers/observations_controller/edit_and_update.rb b/app/controllers/observations_controller/edit_and_update.rb index f4a3d14cc1..1d7e753f79 100644 --- a/app/controllers/observations_controller/edit_and_update.rb +++ b/app/controllers/observations_controller/edit_and_update.rb @@ -4,6 +4,7 @@ module ObservationsController::EditAndUpdate include ObservationsController::SharedFormMethods include ObservationsController::Validators + include ::Locationable # Form to edit an existing observation. # Linked from: left panel @@ -83,7 +84,7 @@ def update @any_errors = false update_permitted_observation_attributes # may set a new location_id - create_location_object_if_new + create_location_object_if_new(@observation) @observation.notes = notes_to_sym_and_compact warn_if_unchecking_specimen_with_records_present! strip_images! if @observation.gps_hidden @@ -92,7 +93,7 @@ def update validate_projects detach_removed_images try_to_upload_images - try_to_save_location_if_new + try_to_save_location_if_new(@observation) try_to_update_observation_if_there_are_changes reload_edit_form and return if @any_errors diff --git a/app/controllers/observations_controller/shared_form_methods.rb b/app/controllers/observations_controller/shared_form_methods.rb index d8b5355100..62896bf8d4 100644 --- a/app/controllers/observations_controller/shared_form_methods.rb +++ b/app/controllers/observations_controller/shared_form_methods.rb @@ -123,87 +123,6 @@ def init_list_vars_for_reload end 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 return true if @observation.save diff --git a/app/extensions/string_extensions.rb b/app/extensions/string_extensions.rb index c16983359f..f78cce1a70 100644 --- a/app/extensions/string_extensions.rb +++ b/app/extensions/string_extensions.rb @@ -23,6 +23,8 @@ # gsub_html_special_chars:: auxiliary to html_to_ascii # unescape_html:: Render special encoded characters as regular characters # as_displayed:: Render everything humanly legible, for integration tests +# id_of_nested_field:: Rails generates `observation_notes` for the ID of a +# nested field like `observation[notes]` # --- # break_name:: Break a taxon name at the author # small_author:: Wrap the author in a span @@ -546,6 +548,12 @@ def as_displayed strip_html.unescape_html.strip_squeeze end + # Rails generates an id for a nested field like "foo[bar]" that's snake_case + # - no brackets. This gets you that string. (used in forms_helper) + def id_of_nested_field + gsub(/[\[\]]+/, "_").chop + end + # Insert a line break between the scientific name and the author # (for styling taxonomic names legibly) def break_name diff --git a/app/helpers/autocompleter_helper.rb b/app/helpers/autocompleter_helper.rb new file mode 100644 index 0000000000..b726e14c9a --- /dev/null +++ b/app/helpers/autocompleter_helper.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/AbcSize +module AutocompleterHelper + # MO's autocompleter_field is a text_field that fetches suggestions from the + # db for the requested model. (For a textarea, pass textarea: true.) + # + # The stimulus controller handles keyboard and mouse interactions, does the + # fetching, and draws the dropdown menu. `args` allow incoming data attributes + # to deep_merge with controller data. + # + # We attempt to disable browser autocomplete via `autocomplete="off"` — the + # W3C standard API, but it has never been honored by Chrome or Safari. Chrome + # seems to be in a race to defeat the evolving hacks by developers to disable + # inappropriate autocompletes, and Safari is not much better - you just can't + # turn their crap off. (documented on SO) + # + def autocompleter_field(**args) + ac_args = { + placeholder: :start_typing.l, autocomplete: "off", + data: { autocompleter_target: "input" } + }.deep_merge(args.except(*autocompleter_outer_args)) + ac_args[:class] = class_names("dropdown", args[:class]) + # inner form-group wrap, because dropdown is position-absolute + ac_args[:wrap_data] = { autocompleter_target: "wrap" } + ac_args[:label_after] = autocompleter_label_after(args) + ac_args[:label_end] = autocompleter_label_end(args) + ac_args[:append] = autocompleter_append(args) + + tag.div(id: args[:controller_id], + data: autocompleter_controller_data(args)) do + if args[:textarea] == true + concat(text_area_with_label(**ac_args)) + else + concat(text_field_with_label(**ac_args)) + end + concat(args[:append]) + end + end + + # Any arg not on this list gets sent to the text field/area. + def autocompleter_outer_args + [:controller_data, :controller_id, :type, :separator, :textarea, + :hidden_value, :hidden_data, :create_text, :keep_text, :edit_text, + :find_text, :create, :create_path, :map_outlet, :geocode_outlet].freeze + end + + # This data goes on the outer div (controller element), not the input field. + def autocompleter_controller_data(args) + { + controller: :autocompleter, + type: args[:type], + separator: args[:separator], + autocompleter_map_outlet: args[:map_outlet], + autocompleter_geocode_outlet: args[:geocode_outlet] + }.deep_merge(args[:outer_data] || {}) + end + + def autocompleter_label_after(args) + capture do + [ + autocompleter_has_id_indicator, + autocompleter_find_button(args), + autocompleter_keep_box_button(args), + autocompleter_edit_box_button(args) + ].safe_join + end + end + + def autocompleter_label_end(args) + capture do + concat(autocompleter_create_button(args)) + concat(autocompleter_modal_create_link(args)) + end + end + + def autocompleter_has_id_indicator + link_icon(:check, title: :autocompleter_has_id.l, + class: "ml-3 px-2 text-success has-id-indicator", + data: { autocompleter_target: "hasIdIndicator" }) + end + + def autocompleter_create_button(args) + return if !args[:create_text] || args[:create].present? + + icon_link_to( + args[:create_text], "#", + id: "create_#{args[:type]}_btn", class: "ml-3 create-button", + icon: :plus, show_text: true, icon_class: "text-primary", + name: "create_#{args[:type]}", + data: { autocompleter_target: "createBtn", + action: "autocompleter#swapCreate:prevent" } + ) + end + + def autocompleter_modal_create_link(args) + return unless args[:create_text] && args[:create].present? && + args[:create_path].present? + + modal_link_to( + args[:create], args[:create_text], args[:create_path], + icon: :plus, show_text: true, icon_class: "text-primary", + name: "create_#{args[:type]}", class: "ml-3 create-link", + data: { autocompleter_target: "createBtn" } + ) + end + + def autocompleter_find_button(args) + return unless args[:find_text] + + icon_link_to( + args[:find_text], "#", + icon: :find_on_map, show_text: false, icon_class: "text-primary", + name: "find_#{args[:type]}", class: "ml-3 find-btn d-none", + data: { map_target: "showBoxBtn", + action: "map#showBox:prevent" } + ) + end + + def autocompleter_keep_box_button(args) + return unless args[:keep_text] + + icon_link_to( + args[:keep_text], "#", + icon: :apply, show_text: false, icon_class: "text-primary", + name: "keep_#{args[:type]}", class: "ml-3 keep-btn d-none", + data: { autocompleter_target: "keepBtn", map_target: "lockBoxBtn", + action: "map#toggleBoxLock:prevent form-exif#showFields" } + ) + end + + def autocompleter_edit_box_button(args) + return unless args[:keep_text] + + icon_link_to( + args[:edit_text], "#", + icon: :edit, show_text: false, icon_class: "text-primary", + name: "edit_#{args[:type]}", class: "ml-3 edit-btn d-none", + data: { autocompleter_target: "editBtn", map_target: "editBoxBtn", + action: "map#toggleBoxLock:prevent form-exif#showFields" } + ) + end + + # minimum args :form, :type. + # Send :hidden to fill the id, :hidden_data to merge with hidden field data + def autocompleter_hidden_field(**args) + return unless args[:form].present? && args[:type].present? + + model = autocompleter_type_to_model(args[:type]) + data = { autocompleter_target: "hidden" }.merge(args[:hidden_data] || {}) + args[:form].hidden_field(:"#{model}_id", value: args[:hidden_value], data:) + end + + def autocompleter_type_to_model(type) + case type + when :region + :location + when :clade + :name + else + type + end + end + + def autocompleter_append(args) + [autocompleter_dropdown, + autocompleter_hidden_field(**args)].safe_join + end + + def autocompleter_dropdown + tag.div(class: "auto_complete dropdown-menu", + data: { autocompleter_target: "pulldown", + action: "scroll->autocompleter#scrollList:passive" }) do + tag.ul(class: "virtual_list", data: { autocompleter_target: "list" }) do + 10.times do |i| + concat(tag.li(class: "dropdown-item") do + link_to("", "#", data: { + row: i, action: "click->autocompleter#selectRow:prevent" + }) + end) + end + end + end + end +end +# rubocop:enable Metrics/AbcSize diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 40049b853d..2f89fc7254 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -76,212 +76,4 @@ def content_tag_unless(condition, name, content_or_options_with_block = nil, content_tag_if(!condition, name, content_or_options_with_block, options, escape, &block) end - - # Wrap an html object in '' tag. This has the effect of - # giving it context help (mouse-over popup) in most modern browsers. - # - # <%= help_tooltip(label, title: "Click here to do something.") %> - # - def help_tooltip(label, **args) - args[:data] ||= {} - tag.span(label, title: args[:title], - class: class_names("context-help", args[:class]), - data: { toggle: "tooltip" }.merge(args[:data])) - end - - # make a help-note styled element, like a div, p, or span - def help_note(element = :span, string = "") - content_tag(element, string, class: "help-note mr-3") - end - - # make a help-block styled element, like a div, p - def help_block(element = :div, string = "", **args, &block) - content = block ? capture(&block) : string - html_options = { - class: class_names("help-block", args[:class]) - }.deep_merge(args.except(:class)) - - content_tag(element, html_options) { content } - end - - # draw a help block with an arrow - def help_block_with_arrow(direction = nil, **args, &block) - div_class = "well well-sm help-block position-relative" - div_class += " mt-3" if direction == "up" - - tag.div(class: div_class, id: args[:id]) do - concat(capture(&block).to_s) - if direction - arrow_class = "arrow-#{direction}" - arrow_class += " hidden-xs" unless args[:mobile] - concat(tag.div("", class: arrow_class)) - end - end - end - - 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(content) - if direction - arrow_class = "arrow-#{direction}" - arrow_class += " hidden-xs" unless args[:mobile] - concat(tag.div("", class: arrow_class)) - end - end - end - end - - def collapse_info_trigger(id, **args) - link_to(link_icon(:question), "##{id}", - class: class_names("info-collapse-trigger", args[:class]), - role: "button", data: { toggle: "collapse" }, - aria: { expanded: "false", controls: id }) - end - - def panel_block(**args, &block) - heading = panel_block_heading(args) - footer = panel_block_footer(args) - content = capture(&block).to_s - - tag.div( - class: class_names("panel panel-default", args[:class]), - **args.except(:class, :inner_class, :inner_id, :heading, :heading_links) - ) do - concat(heading) - if content.present? - concat(tag.div(class: class_names("panel-body", args[:inner_class]), - id: args[:inner_id]) do - concat(content) - end) - end - concat(footer) - end - end - - def panel_block_heading(args) - if args[:heading] - tag.div(class: "panel-heading") do - tag.h4(class: "panel-title") do - els = [args[:heading]] - if args[:heading_links].present? - els << tag.span(args[:heading_links], class: "float-right") - end - els.safe_join - end - end - else - "" - end - end - - def panel_block_footer(args) - if args[:footer] - tag.div(class: "panel-footer") do - args[:footer] - end - else - "" - end - end - - def alert_block(level = :warning, string = "") - content_tag(:div, string, class: "alert alert-#{level}") - end - - # Create a div for notes. - # - # <%= notes_panel(html) %> - # - # <% notes_panel() do %> - # Render stuff in here. Note lack of "=" in line above. - # <% end %> - # - def notes_panel(msg = nil, &block) - msg = capture(&block) if block - result = tag.div(msg, class: "panel-body") - wrapper = tag.div(result, class: "panel panel-default dotted-border") - if block - concat(wrapper) - else - 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 3cbb0f35f2..4cc398a318 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -5,8 +5,8 @@ # date_select_opts # helpers for form tags -# rubocop:disable Metrics/ModuleLength -module FormsHelper + +module FormsHelper # rubocop:disable Metrics/ModuleLength # Bootstrap submit button # <%= submit_button(form: f, button: button.t, center: true) %> def submit_button(**args) @@ -92,14 +92,14 @@ def check_box_with_label(**args) wrap_class = form_group_wrap_class(args, "checkbox") tag.div(class: wrap_class) do - args[:form].label(args[:field]) do + concat(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) + concat(args[:append]) if args[:append].present? end end @@ -126,14 +126,14 @@ def radio_with_label(**args) wrap_class = form_group_wrap_class(args, "radio") tag.div(class: wrap_class) do - args[:form].label("#{args[:field]}_#{args[:value]}") do + concat(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) + concat(args[:append]) if args[:append].present? end end @@ -165,17 +165,7 @@ def text_field_with_label(**args) label_opts[:class] = class_names(label_opts[:class], args[:label_class]) tag.div(class: wrap_class, data: wrap_data) do - # 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) + concat(text_label_row(args, label_opts)) if args[:addon].present? # text addon, not interactive concat(tag.div(class: "input-group") do concat(args[:form].text_field(args[:field], opts)) @@ -196,123 +186,6 @@ def text_field_with_label(**args) end end - # MO's autocompleter_field is a text_field that fetches suggestions from the - # db for the requested model. (For a textarea, pass textarea: true.) The - # stimulus controller handles keyboard and mouse interactions, does the - # fetching, and draws the dropdown menu. `args` allow incoming data attributes - # to deep_merge with controller data. We attempt to disable browser - # autocomplete via `autocomplete="off"` — the W3C standard API, but it - # has never been honored by Chrome or Safari. Chrome seems to be in a race to - # defeat the evolving hacks by developers to disable inappropriate - # autocompletes, and Safari is not much better - you just can't turn their - # crap off. (documented on SO) - # - def autocompleter_field(**args) - ac_args = { - placeholder: :start_typing.l, autocomplete: "off", - data: { autocompleter_target: "input" } - }.deep_merge(args.except(:type, :separator, :textarea, - :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]) - end - - if args[:textarea] == true - text_area_with_label(**ac_args) - else - text_field_with_label(**ac_args) - end - end - - def autocompleter_has_id_indicator - link_icon(:check, title: :autocompleter_has_id.l, - 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. - # Send :hidden to fill the id, :hidden_data to merge with hidden field data - def autocompleter_hidden_field(**args) - model = autocompleter_type_to_model(args[:type]) - data = { autocompleter_target: "hidden" }.merge(args[:hidden_data] || {}) - args[:form].hidden_field(:"#{model}_id", value: args[:hidden], data:) - end - - def autocompleter_type_to_model(type) - case type - when :region - :location - when :clade - :name - else - type - end - end - - def autocompleter_dropdown - tag.div(class: "auto_complete dropdown-menu", - data: { autocompleter_target: "pulldown", - action: "scroll->autocompleter#scrollList:passive" }) do - tag.ul(class: "virtual_list", data: { autocompleter_target: "list" }) do - 10.times do |i| - concat(tag.li(class: "dropdown-item") do - link_to("", "#", data: { - row: i, action: "click->autocompleter#selectRow:prevent" - }) - end) - end - end - end - end - # Bootstrap text_area def text_area_with_label(**args) args = auto_label_if_form_is_account_prefs(args) @@ -327,13 +200,28 @@ def text_area_with_label(**args) label_opts = field_label_opts(args) 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? + concat(text_label_row(args, label_opts)) concat(args[:form].text_area(args[:field], opts)) concat(args[:append]) if args[:append].present? end end + # The label row for autocompleters is potentially complicated, many buttons. + # Content for `between` and `label_after` come right after the label on left, + # content for `label_end` is at the end of the same line, right justified. + def text_label_row(args, label_opts) + 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? + concat(args[:label_after]) if args[:label_after].present? + end) + concat(tag.div do + concat(args[:label_end]) if args[:label_end].present? + end) + end + end + # Bootstrap select. # Works for select_year but not date_select, which generates multiple selects def select_with_label(**args) @@ -508,6 +396,7 @@ def url_field_with_label(**args) end # Bootstrap file input field with client-side size validation. + # This could be redone as an input group with a "browse" button, in BS4. def file_field_with_label(**args) args = check_for_help_block(args) opts = separate_field_options_from_args(args) @@ -538,23 +427,6 @@ def file_field_with_label(**args) end end - # To be retired in favor of the above: - # Create stylable file input field with client-side size validation. - def custom_file_field(obj, attr, opts = {}) - max_size = MO.image_upload_max_size - max_size_in_mb = (max_size.to_f / 1024 / 1024).round - file_field = file_field( - obj, - attr, - opts.merge( - max_upload_msg: :validate_image_file_too_big.l(max: max_size_in_mb), - max_upload_size: max_size - ) - ) - tag.span(:select_file.t + file_field, class: "file-field btn btn-default") + - tag.span(:no_file_selected.t) - end - # Unused # def search_field_with_submit(args) # opts = separate_field_options_from_args(args) @@ -619,7 +491,7 @@ def check_for_optional_or_required_note(args) keys = [:optional, :required].freeze positions.each do |pos| keys.each do |key| - args[pos] = help_note(:span, "(#{key.t})") if args[pos] == key + args[pos] = help_note(:span, "(#{key.l})") if args[pos] == key end end args @@ -631,10 +503,14 @@ def check_for_help_block(args) return args end - id = "#{args[:form].object_name}_#{args[:field]}_help" + id = [ + args[:form].object_name.to_s.id_of_nested_field, + args[:field].to_s, + "help" + ].join("_") args[:between] = capture do - concat(collapse_info_trigger(id)) concat(args[:between]) + concat(collapse_info_trigger(id)) end args[:append] = capture do concat(args[:append]) @@ -650,12 +526,11 @@ def check_for_help_block(args) # be excluded separately (not here) def separate_field_options_from_args(args, extras = []) exceptions = [ - :form, :field, :label, :class, :width, :inline, :between, :between_end, - :append, :help, :addon, :optional, :required, :monospace, :type, - :wrap_data, :button, :button_data + :form, :field, :label, :class, :width, :inline, :between, :label_after, + :label_end, :append, :help, :addon, :optional, :required, :monospace, + :type, :wrap_data, :wrap_id, :button, :button_data ] + extras args.clone.except(*exceptions) end end -# rubocop:enable Metrics/ModuleLength diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index 8048896771..c7a4b18c2a 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -81,16 +81,17 @@ def modal_link_to(identifier, name, path, args) # 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) + # icon_link_to(text, path, **args). Can also print a button_to. def icon_link_to(text = nil, path = nil, options = {}, &block) return unless text - link = block ? text : path # because positional + link_path = block ? text : path # because positional content = block ? capture(&block) : text opts = block ? path : options # because positional icon_type = opts[:icon] return link_to(link, opts) { content } if icon_type.blank? + # method = opts[:button] ? :button_to : :link_to active_icon = opts[:active_icon] active_content = options[:active_content] stateful = active_icon && active_content @@ -106,14 +107,19 @@ def icon_link_to(text = nil, path = nil, options = {}, &block) 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)) + :active_icon, :active_content, :button_to)) - link_to(link, **link_opts) do + html = capture 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 + if opts[:button_to] + button_to(html, link_path, **link_opts) + else + link_to(html, link_path, **link_opts) + end end # NOTE: above re: MO tabs @@ -137,11 +143,11 @@ def link_icon(type, **args) if args[:title].present? title = args[:title] - args[:data] = { toggle: "tooltip", title: }.merge(args[:data] || {}) + args[:data] = { toggle: "tooltip" }.merge(args[:data] || {}) text = tag.span(title, class: "sr-only") end - tag.span(text, **args.except(:title)) + tag.span(text, **args) end # NOTE: Specific to glyphicons @@ -156,6 +162,12 @@ def link_icon(type, **args) x: "remove", remove: "remove-circle", send: "send", + log_in: "log-in", + log_out: "log-out", + admin: "text-background", + inbox: "inbox", + interests: "bullhorn", + settings: "cog", ban: "ban-circle", plus: "plus-sign", minus: "minus-sign", @@ -181,7 +193,11 @@ def link_icon(type, **args) print: "print", globe: "globe", find_on_map: "screenshot", - apply: "check" + apply: "check", + chevron_down: "chevron-down", + chevron_up: "chevron-up", + chevron_left: "chevron-left", + chevron_right: "chevron-right" }.freeze # button to destroy object diff --git a/app/helpers/map_helper.rb b/app/helpers/map_helper.rb index f96a851590..b2814975b9 100644 --- a/app/helpers/map_helper.rb +++ b/app/helpers/map_helper.rb @@ -17,6 +17,7 @@ def make_map(objects: [], **args) controller: "map", map_target: "mapDiv", map_type: "info", + map_open: true, editable: false, controls: [:large_map, :map_type].to_json, location_format: User.current_location_format # method has a default diff --git a/app/helpers/observations_helper.rb b/app/helpers/observations_helper.rb index 72b64af5af..b0600a6c49 100644 --- a/app/helpers/observations_helper.rb +++ b/app/helpers/observations_helper.rb @@ -151,11 +151,7 @@ def observation_map_coordinates(obs:) def observation_show_image_links(obs:) return "" unless check_permission(obs) - [ - icon_link_with_query(*new_image_for_observation_tab(obs)), - icon_link_with_query(*reuse_images_for_observation_tab(obs)), - icon_link_with_query(*remove_images_from_observation_tab(obs)) - ].safe_join(" | ") + icon_link_with_query(*reuse_images_for_observation_tab(obs)) end # The following sections of the observation_details partial are also needed as diff --git a/app/helpers/panel_helper.rb b/app/helpers/panel_helper.rb new file mode 100644 index 0000000000..c6ca7e4cc4 --- /dev/null +++ b/app/helpers/panel_helper.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +# helpers for bootstrap panels +module PanelHelper + # For a collapsing panel_block, pass an HTML id for args[:collapse] and + # args[:collapse_show] to show it open by default. + # Pass args[:panel_bodies] to create multiple panel bodies, or args[:content] + # to pass preformatted content instead of building `panel-body` here. + def panel_block(**args, &block) + heading = panel_heading(args) + footer = panel_footer(args) + content = block ? capture(&block).to_s : "" + + tag.div( + class: class_names("panel panel-default", args[:class]), + **args.except(*panel_inner_args) + ) do + concat(heading) + if args[:panel_bodies].present? + concat(panel_bodies(args)) + elsif args[:collapse].present? + concat(panel_collapse_body(args, content)) + else + concat(panel_body(args, content)) + end + concat(footer) + end + end + + # Args passed to panel components that are not applied to the outer div. + def panel_inner_args + [:class, :inner_class, :inner_id, :heading, :heading_links, :panel_bodies, + :collapse, :open, :footer].freeze + end + + def panel_heading(args) + return "" unless args[:heading] + + tag.div(class: "panel-heading") do + if args[:collapse].present? + panel_heading_collapse_elements(args) + else + panel_heading_elements(args) + end + end + end + + def panel_heading_elements(args) + els = [args[:heading]] + if args[:heading_links].present? + els << tag.span(args[:heading_links], class: "float-right") + end + + tag.h4(class: "panel-title") { els.safe_join } + end + + # The whole heading is a link that toggles the panel collapse. + # NOTE: args[:open] should be a boolean + def panel_heading_collapse_elements(args) + collapsed = args[:open] ? "" : "collapsed" + + tag.h4(class: "panel-title") do + link_to( + "##{args[:collapse]}", + class: class_names("panel-collapse-trigger", collapsed), + role: "button", data: { toggle: "collapse" }, + aria: { expanded: args[:open], controls: args[:collapse] } + ) do + [args[:heading], + tag.span(panel_collapse_icons, class: "float-right")].safe_join + end + end + end + + # The caret icon that indicates toggling the panel open/collapsed. + def panel_collapse_icons + [link_icon(:chevron_down, title: :OPEN.l, class: "active-icon"), + link_icon(:chevron_up, title: :CLOSE.l)].safe_join + end + + # Some panels need multiple panel bodies. + def panel_bodies(args) + args[:panel_bodies].map do |body| + panel_body(args, body) + end.safe_join + end + + def panel_collapse_body(args, content) + open = args[:open] ? "in" : "" + + tag.div(class: class_names("panel-collapse collapse", open), + id: args[:collapse]) do + concat(panel_body(args, content)) + end + end + + def panel_body(args, content) + return "" if content.blank? + return content if args[:formatted_content] + + tag.div(class: class_names("panel-body", args[:inner_class]), + id: args[:inner_id]) do + concat(content) + end + end + + def panel_footer(args) + if args[:footer] + tag.div(class: "panel-footer") do + args[:footer] + end + else + "" + end + end + + def alert_block(level = :warning, string = "") + content_tag(:div, string, class: "alert alert-#{level}") + end + + # Create a div for notes. + # + # <%= notes_panel(html) %> + # + # <% notes_panel() do %> + # Render stuff in here. Note lack of "=" in line above. + # <% end %> + # + def notes_panel(msg = nil, &block) + msg = capture(&block) if block + result = tag.div(msg, class: "panel-body") + wrapper = tag.div(result, class: "panel panel-default dotted-border") + if block + concat(wrapper) + else + wrapper + end + end + + # Help tooltip, note, block. + # + # Help tooltip is a span with a title attribute. This has the effect of + # giving it context help (mouse-over popup) in most modern browsers. + # + # <%= help_tooltip(label, title: "Click here to do something.") %> + # + def help_tooltip(label, **args) + args[:data] ||= {} + tag.span(label, title: args[:title], + class: class_names("context-help", args[:class]), + data: { toggle: "tooltip" }.merge(args[:data])) + end + + # make a help-note styled element, like a div, p, or span + def help_note(element = :span, string = "") + content_tag(element, string, class: "help-note mr-3") + end + + # make a help-block styled element, like a div, p + def help_block(element = :div, string = "", **args, &block) + content = block ? capture(&block) : string + html_options = { + class: class_names("help-block", args[:class]) + }.deep_merge(args.except(:class)) + + content_tag(element, html_options) { content } + end + + # draw a help block with an arrow + def help_block_with_arrow(direction = nil, **args, &block) + div_class = "well well-sm mb-3 help-block position-relative" + div_class += " mt-3" if direction == "up" + + tag.div(class: div_class, id: args[:id]) do + concat(capture(&block).to_s) + if direction + arrow_class = "arrow-#{direction}" + arrow_class += " hidden-xs" unless args[:mobile] + concat(tag.div("", class: arrow_class)) + end + end + end + + def collapse_help_block(direction = nil, string = nil, **args, &block) + div_class = "well well-sm mb-3 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(content) + if direction + arrow_class = "arrow-#{direction}" + arrow_class += " hidden-xs" unless args[:mobile] + concat(tag.div("", class: arrow_class)) + end + end + end + end + + def collapse_info_trigger(id, **args) + link_to(link_icon(:question), "##{id}", + class: class_names("info-collapse-trigger", args[:class]), + role: "button", data: { toggle: "collapse" }, + aria: { expanded: "false", controls: id }) + end + + # BOOTSTRAP TABBED CONTENT + # + # 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/tabs/herbaria_helper.rb b/app/helpers/tabs/herbaria_helper.rb index 29c4a812d6..2adbb0f957 100644 --- a/app/helpers/tabs/herbaria_helper.rb +++ b/app/helpers/tabs/herbaria_helper.rb @@ -71,12 +71,12 @@ def herbaria_curator_request_tabs(herbarium:) end def new_herbarium_tab - [:create_herbarium.t, add_query_param(new_herbarium_path), + [:create_herbarium.l, add_query_param(new_herbarium_path), { class: tab_id(__method__.to_s) }] end def edit_herbarium_tab(herbarium) - [:edit_herbarium.t, + [:edit_herbarium.l, add_query_param(edit_herbarium_path(herbarium.id)), { class: tab_id(__method__.to_s) }] end diff --git a/app/helpers/tabs/observations_helper.rb b/app/helpers/tabs/observations_helper.rb index 7ee7909111..ec7410f2dc 100644 --- a/app/helpers/tabs/observations_helper.rb +++ b/app/helpers/tabs/observations_helper.rb @@ -105,24 +105,12 @@ def observation_hide_thumbnail_map_tab(obs) { class: tab_id(__method__.to_s), icon: :hide }] end - def new_image_for_observation_tab(obs) - [:show_observation_add_images.l, - new_image_for_observation_path(obs.id), - { class: tab_id(__method__.to_s), icon: :add }] - end - def reuse_images_for_observation_tab(obs) [:show_observation_reuse_image.l, reuse_images_for_observation_path(obs.id), { class: tab_id(__method__.to_s), icon: :reuse }] end - def remove_images_from_observation_tab(obs) - [:show_observation_remove_images.l, - remove_images_from_observation_path(obs.id), - { class: tab_id(__method__.to_s), icon: :remove }] - end - ############################################ # INDEX @@ -211,7 +199,8 @@ def observations_download_as_csv_tab(query) # FORMS def observation_form_new_tabs - [new_inat_import_tab, new_herbarium_tab] + # [new_inat_import_tab, new_herbarium_tab] + [] end def observation_form_edit_tabs(obs:) @@ -251,17 +240,6 @@ def observation_images_edit_tabs(image:) [object_return_tab(image)] end - def observation_images_new_tabs(obs:) - [object_return_tab(obs), - edit_observation_tab(obs)] - end - - # Note this takes `obj:` not `obs:` - def observation_images_remove_tabs(obj:) - [object_return_tab(obj), - edit_observation_tab(obj)] - end - def observation_images_reuse_tabs(obs:) [object_return_tab(obs), edit_observation_tab(obs)] @@ -285,10 +263,7 @@ def obs_change_tabs(obs) end def obs_details_links(obs) - return print_labels_button(obs) unless check_permission(obs) - - [print_labels_button(obs), - obs_change_links(obs)].safe_join(" | ") + print_labels_button(obs) end # Buttons in "Details" panel header diff --git a/app/helpers/tabs/projects_helper.rb b/app/helpers/tabs/projects_helper.rb index cb8b8b30f9..693731a987 100644 --- a/app/helpers/tabs/projects_helper.rb +++ b/app/helpers/tabs/projects_helper.rb @@ -84,9 +84,8 @@ def add_project_banner(project) def add_background_image(image) return unless image - presenter = ImagePresenter.new(image, size: :large) content_for(:background_image) do - image_tag(presenter.img_src, class: "image-title") + image_tag(image.large_url, class: "image-title") end end end diff --git a/app/javascript/application.js b/app/javascript/application.js index 98776d8d9f..e8e553e34a 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -13,11 +13,28 @@ import "@hotwired/turbo-rails" // form, or button like delete/patch: set data-turbo="true" to opt in // link_to with GET: set data-turbo-stream="true" to opt in Turbo.setFormMode("optin") -// https://stackoverflow.com/questions/77421369/turbo-response-to-render-javascript-alert/77434363#77434363 +// https://stackoverflow.com/a/77434363/3357635 // use: <%= turbo_stream.close_modal("modal_#{obs.id}_naming") %> Turbo.StreamActions.close_modal = function () { $("#" + this.templateContent.textContent).modal('hide') }; +// https://stackoverflow.com/a/76744968/3357635 +Turbo.StreamActions.update_input = function () { + this.targetElements.forEach((target) => { + target.value = this.templateContent.textContent + }); +}; +// https://stackoverflow.com/a/77836101/3357635 +Turbo.StreamActions.add_class = function () { + this.targetElements.forEach((target) => { + target.classList.add(this.templateContent.textContent) + }); +} +Turbo.StreamActions.remove_class = function () { + this.targetElements.forEach((target) => { + target.classList.remove(this.templateContent.textContent) + }); +} import "@rails/request.js" diff --git a/app/javascript/controllers/advanced-search_controller.js b/app/javascript/controllers/advanced-search_controller.js index 74354491ff..88b73f7bbd 100644 --- a/app/javascript/controllers/advanced-search_controller.js +++ b/app/javascript/controllers/advanced-search_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = ["searchModel", "filter"] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "advanced-search-connected"; this.disableUnusedFilters(); } diff --git a/app/javascript/controllers/autocompleter_controller.js b/app/javascript/controllers/autocompleter_controller.js index fdf36438d6..f81d551679 100644 --- a/app/javascript/controllers/autocompleter_controller.js +++ b/app/javascript/controllers/autocompleter_controller.js @@ -149,7 +149,7 @@ export default class extends Controller { // The select target is not the element, but a element is its target. static targets = ["input", "select", "pulldown", "list", "hidden", "wrap", - "createBtn", "hasIdIndicator", "keepBtn"] + "createBtn", "hasIdIndicator", "keepBtn", "editBtn", "mapWrap", "collapseFields"] static outlets = ["map"] initialize() { @@ -174,7 +174,7 @@ export default class extends Controller { } connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "autocompleter-connected"; // Figure out a few browser-dependent dimensions. this.getScrollBarWidth; @@ -186,28 +186,25 @@ 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(); } - // Reinitialize autocompleter type (and properties). Callable externally. - // For example, `swap` may be called from a change event dispatched by another - // controller: `data-action: "map:pointChanged->autocompleter#swap"`. Both the - // map & form-exif controllers dispatch a pointChanged event, when changing - // lat/lngs. For now we need both events - when form-exif updates the lat/lng - // inputs programmatically, it's not caught as a `change` by map. (Also, map - // only fires its event after buffering.) + // Reinitialize autocompleter type (and properties). Callable externally. For + // example, `swap` may be called from a change event dispatched by another + // controller: `data-action: "map:pointChanged->autocompleter#swap"`. The + // form-exif and geocode/map controllers use their autocompleterOutlet to call + // swap() directly, when changing lat/lngs. We need both - when form-exif + // updates the lat/lng inputs programmatically, it's not caught as a `change` + // by geocode/map. (Also, geocode/map only fires its swap after buffering.) // - // Events should pass a detail object with a type property. Example: `event: { - // detail: { type, request_params: { lat, lng } } }`. However, the calling - // event may not pass a type, or it may be the same as the current type. - // Re-initializing the current type is ok, often means we need to refresh the - // primer (as with location_containing a changed lat/lng). - // - // Callable internally if you pass a detail object with a type property. + // Callers of swap() should pass a detail object with a type property. + // Example: `event: { detail: { type, request_params: { lat, lng } } }`. + // However, the caller may not pass a type, or it may be the same as the + // current type. Re-initializing the current type is ok, often means we need + // to refresh the primer (as with location_containing a changed lat/lng). // swap({ detail }) { let type; @@ -218,6 +215,13 @@ export default class extends Controller { } if (type == undefined) { return; } + let location = false; + if (detail.hasOwnProperty("request_params") && + detail.request_params.hasOwnProperty("lat") && + detail.request_params.hasOwnProperty("lng")) { + location = detail.request_params; + } + if (!AUTOCOMPLETER_TYPES.hasOwnProperty(type)) { alert("MOAutocompleter: Invalid type: \"" + type + "\""); } else { @@ -234,86 +238,100 @@ export default class extends Controller { this.last_fetch_params = ''; this.prepareInputElement(); this.prepareHiddenInput(); - if (!this.hasKeepBtnTarget || - !this.keepBtnTarget.classList.contains('active')) { + if (!this.hasEditBtnTarget || + this.editBtnTarget?.classList?.contains('d-none')) { this.clearHiddenId(); } - this.constrainedSelectionUI(); + this.constrainedSelectionUI(location); } } - constrainedSelectionUI() { - const outlet_class = this.appropriateOutletClass(); + // Depending on the type of autocompleter, the UI may need to change. + // detail may also contain request_params for lat/lng. + constrainedSelectionUI(location = false) { if (this.TYPE === "location_google") { - this.inputTarget.closest("form").classList.add(outlet_class); + this.verbose("autocompleter: swapped to location_google"); this.element.classList.add('create'); + this.element.classList.remove('offer-create'); this.element.classList.remove('constrained'); + if (this.hasMapWrapTarget) { + this.mapWrapTarget.classList.remove('d-none'); + } else { + this.verbose("autocompleter: no map wrap"); + } + this.activateMapOutlet(location); } else if (this.ACT_LIKE_SELECT) { - this.inputTarget.closest("form").classList.remove(outlet_class); + this.verbose("autocompleter: swapped to ACT_LIKE_SELECT"); + this.deactivateMapOutlet(); // primer is not based on input, so go ahead and request from server. this.focused = true; // so it will draw the pulldown immediately this.refreshPrimer(); // directly refresh the primer w/request_params this.element.classList.add('constrained'); this.element.classList.remove('create'); } else { - this.inputTarget.closest("form").classList.remove(outlet_class); - this.verbose("autocompleter: regular swap"); + this.verbose("autocompleter: swapped regular"); + this.deactivateMapOutlet(); this.scheduleRefresh(); this.element.classList.remove('constrained', 'create'); } } - appropriateOutletClass() { - if (this.hasMap) { - return 'map-outlet'; - } else if (this.hasGeocode) { - return 'geocode-outlet'; - } - } - swapCreate() { - // this.createBtnTarget.classList.add('d-none'); this.swap({ detail: { type: "location_google" } }); } - // Connects the location_google autocompleter to call map controller methods - mapOutletConnected(outlet, element) { - this.verbose("autocompleter:mapOutletConnected()"); + leaveCreate() { + if (!(['location_google'].includes(this.TYPE) && this.hasMapOutlet)) return; + + this.verbose("autocompleter: leaveCreate()"); + const location = this.mapOutlet.validateLatLngInputs(false); + // Will swap to location, or location_containing if lat/lngs are present + if (this.mapOutlet.ignorePlaceInput !== true) { + this.mapOutlet.sendPointChanged(location); + } + } + + // Connects autocompleter to map controller to call its methods + activateMapOutlet(location = false) { + if (!this.hasMapOutlet) { + this.verbose("autocompleter: no map outlet"); + return; + } + + this.verbose("autocompleter:activateMapOutlet()"); // open the map if not already open - if (!outlet.opened) outlet.toggleMapBtnTarget.click(); + if (!this.mapOutlet.opened && this.mapOutlet.hasToggleMapBtnTarget) { + this.verbose("autocompleter: open map"); + this.mapOutlet.toggleMapBtnTarget.click(); + } // set the map type so box is editable - outlet.map_type = "hybrid"; // only if location_google - // outlet.marker.setDraggable(false); messes up map - // outlet.marker.setClickable(false); messes up map - let location - if (location = outlet.validateLatLngInputs(false)) { - outlet.geocodeLatLng(location); - } else { - outlet.lockBoxBtnTarget.classList.remove("d-none"); + this.mapOutlet.map_type = "hybrid"; // only if location_google + // set the map to stop ignoring place input + this.mapOutlet.ignorePlaceInput = false; + + // Often, this swap to location_google is for geolocating place_names and + // should pay attention to text only. But in some cases the swap (e.g., from + // form-exif) sends request_params lat/lng, so geocode when switching. + if (location) { + // this.mapOutlet.geocodeLatLng(location); + this.mapOutlet.tryToGeocode(); } - this.createBtnTarget.classList.add('d-none'); - // this.dispatchHiddenIdEvents(); } - mapOutletDisconnected(outlet, element) { - this.verbose("autocompleter: map outlet disconnected"); - outlet.map_type = "observation"; - if (outlet.rectangle) outlet.rectangle.setEditable(false); + deactivateMapOutlet() { + if (!this.hasMapOutlet) return; - // Make the map show box button back into a create button - delete this.createBtnTarget.dataset.mapTarget; - const create_action = this.createBtnTarget.dataset.action - .replace("map#showBox:prevent", "autocompleter#swapCreate:prevent"); - this.createBtnTarget.dataset.action = create_action; - this.createBtnTarget.classList.remove('d-none'); + this.verbose("autocompleter: deactivateMapOutlet()"); + if (this.mapOutlet.rectangle) this.mapOutlet.clearRectangle(); + this.mapOutlet.map_type = "observation"; + // if (this.mapOutlet.rectangle) this.mapOutlet.rectangle.setEditable(false); - // this.dispatchHiddenIdEvents(); - outlet.northInputTarget.value = ''; - outlet.southInputTarget.value = ''; - outlet.eastInputTarget.value = ''; - outlet.westInputTarget.value = ''; - outlet.highInputTarget.value = ''; - outlet.lowInputTarget.value = ''; + this.mapOutlet.northInputTarget.value = ''; + this.mapOutlet.southInputTarget.value = ''; + this.mapOutlet.eastInputTarget.value = ''; + this.mapOutlet.westInputTarget.value = ''; + this.mapOutlet.highInputTarget.value = ''; + this.mapOutlet.lowInputTarget.value = ''; } // pulldownTargetConnected() { @@ -328,13 +346,8 @@ export default class extends Controller { // Attach events this.addEventListeners(); - const hiddenId = parseInt(this.hiddenTarget.value); - - if (hiddenId !== NaN && hiddenId != 0) { - this.wrapTarget.classList.add('has-id'); - } else { - this.wrapTarget.classList.remove('has-id'); - } + const hidden_id = parseInt(this.hiddenTarget.value); + this.cssHasIdOrNo(hidden_id); } // When swapping autocompleter types, swap the hidden input identifiers. @@ -476,11 +489,18 @@ export default class extends Controller { const old_val = this.old_value; const new_val = this.inputTarget.value; // this.debug("ourChange(" + this.inputTarget.value + ")"); - if (new_val != old_val) { - this.old_value = new_val; - if (do_refresh) { - this.verbose("autocompleter:ourChange()"); - this.scheduleRefresh(); + if (new_val.length == 0) { + this.cssCollapseFields(); + this.clearHiddenId(); + this.leaveCreate(); + } else { + this.cssUncollapseFields(); + if (new_val != old_val) { + this.old_value = new_val; + if (do_refresh) { + this.verbose("autocompleter:ourChange()"); + this.scheduleRefresh(); + } } } } @@ -546,7 +566,7 @@ export default class extends Controller { this.verbose("autocompleter:scheduleRefresh()"); this.clearRefresh(); this.refresh_timer = setTimeout((() => { - this.verbose("autocompleter:doing_refresh()"); + this.verbose("autocompleter: doing refreshPrimer()"); // this.debug("refresh_timer(" + this.inputTarget.value + ")"); this.old_value = this.inputTarget.value; // async, anything after this executes immediately @@ -566,6 +586,8 @@ export default class extends Controller { // If we don't have lat/lngs, just draw the pulldown. scheduleGoogleRefresh() { if (this.hasMapOutlet && + this.mapOutlet.hasLatInputTarget && + this.mapOutlet.hasLngInputTarget && this.mapOutlet?.latInputTarget.value && this.mapOutlet?.lngInputTarget.value) { this.drawPulldown(); @@ -575,10 +597,16 @@ export default class extends Controller { this.verbose("autocompleter:scheduleGoogleRefresh()"); this.clearRefresh(); this.refresh_timer = setTimeout((() => { - this.verbose("autocompleter:doing_google_refresh()"); + this.verbose("autocompleter: doing google refresh"); + this.verbose(this.inputTarget.value); this.old_value = this.inputTarget.value; // async, anything after this executes immediately - this.mapOutlet.geolocatePlaceName(true); + // STORE AND COMPARE SEARCH STRING. Otherwise we're doing double lookups + if (this.hasGeocodeOutlet) { + this.geocodeOutlet.tryToGeolocate(this.inputTarget.value); + } else if (this.hasMapOutlet) { + this.mapOutlet.tryToGeolocate(this.inputTarget.value); + } // still necessary if primer unchanged, as likely? // this.populateMatches(); // this.drawPulldown(); @@ -970,10 +998,8 @@ export default class extends Controller { this.hiddenTarget.dataset[key] = match[key]; }); - if (match['id'] !== 0) { - this.wrapTarget.classList.add('has-id'); - } - this.dispatchHiddenIdEvents(); + this.cssHasIdOrNo(parseInt(match['id'])); + this.hiddenIdChanged(); } // Clears not only the ID, but also any data attributes of selected row. @@ -989,7 +1015,53 @@ export default class extends Controller { delete this.hiddenTarget.dataset[key]; }); - this.dispatchHiddenIdEvents(); + this.hiddenIdChanged(); + } + + // Respond to the state of the hidden input. Initially we may not have id, but + // we also don't offer create until they've typed something. + // The `keepBtn` is for freezing the current box so people can pick a point. + // Otherwise you can't click a point inside the box. + cssHasIdOrNo(hidden_id) { + this.verbose("autocompleter:cssHasIdOrNo()"); + + if (hidden_id && hidden_id !== NaN && hidden_id != 0) { + this.wrapTarget.classList.add('has-id'); + this.wrapTarget.classList.remove('offer-create'); + if (this.hasKeepBtnTarget) { + this.keepBtnTarget.classList.remove("d-none"); + } + } else { + this.wrapTarget.classList.remove('has-id'); + if (this.inputTarget.value && + !this.wrapTarget.classList.contains('create')) { + this.wrapTarget.classList.add('offer-create'); + } + if (this.hasKeepBtnTarget) { + this.keepBtnTarget.classList.add("d-none"); + } + } + // On forms where a map may not be relevant, we also show/hide the map. + // Only show if we're in "create" mode. + if (this.hasMapWrapTarget) { + if (this.wrapTarget.classList.contains('create')) { + this.mapWrapTarget.classList.remove('d-none'); + } else { + // this.mapWrapTarget.classList.add('d-none'); + } + } + } + + cssCollapseFields() { + if (!this.hasCollapseFieldsTarget) return; + + $(this.collapseFieldsTarget).collapse('hide'); + } + + cssUncollapseFields() { + if (!this.hasCollapseFieldsTarget) return; + + $(this.collapseFieldsTarget).collapse('show'); } storeCurrentHiddenData() { @@ -1001,28 +1073,29 @@ export default class extends Controller { } // called on assign and clear, also when mapOutlet is connected - dispatchHiddenIdEvents() { + hiddenIdChanged() { const hidden_id = parseInt(this.hiddenTarget.value || 0), // stored_id = parseInt(this.stored_id || 0), { north, south, east, west } = this.hiddenTarget.dataset, hidden_data = { id: hidden_id, north, south, east, west }; - this.verbose("autocompleter:hidden_data: " + JSON.stringify(hidden_data)); // comparing data, not just ids, because google locations have same -1 id if (JSON.stringify(hidden_data) == JSON.stringify(this.stored_data)) { - this.verbose("autocompleter: not dispatching hiddenIdDataChanged"); + this.verbose("autocompleter: hidden_data did not change"); } else { clearTimeout(this.data_timer); this.data_timer = setTimeout(() => { - this.verbose("autocompleter: dispatching hiddenIdDataChanged"); - this.wrapTarget.classList.remove('has-id'); + this.verbose("autocompleter: hidden_data changed"); + this.verbose("autocompleter:hidden_data: ") + this.verbose(JSON.stringify(hidden_data)); + this.cssHasIdOrNo(hidden_id); if (this.hasKeepBtnTarget) { this.keepBtnTarget.classList.remove('active'); } this.inputTarget.focus(); - this.dispatch('hiddenIdDataChanged', { - detail: { id: this.hiddenTarget.value } - }); + if (this.hasMapOutlet) { + this.mapOutlet.showBox(); + } }, 750) } } @@ -1449,8 +1522,8 @@ export default class extends Controller { } // Map controller sends back a primer formatted for the autocompleter - refreshGooglePrimer({ detail }) { - this.processFetchResponse(detail.primer) + refreshGooglePrimer({ primer }) { + this.processFetchResponse(primer) } // Process response from server: diff --git a/app/javascript/controllers/banner_controller.js b/app/javascript/controllers/banner_controller.js index 4c4dfe27ea..7ac360d090 100644 --- a/app/javascript/controllers/banner_controller.js +++ b/app/javascript/controllers/banner_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="banner" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "banner-connected"; } setCookie({ params: { time } }) { diff --git a/app/javascript/controllers/donate_controller.js b/app/javascript/controllers/donate_controller.js index a82d330456..992de05b2e 100644 --- a/app/javascript/controllers/donate_controller.js +++ b/app/javascript/controllers/donate_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = ['otherCheck', 'otherAmount'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "donate-connected"; } checkOther() { diff --git a/app/javascript/controllers/field-slip-job_controller.js b/app/javascript/controllers/field-slip-job_controller.js index f65fbac72b..f111256fd2 100644 --- a/app/javascript/controllers/field-slip-job_controller.js +++ b/app/javascript/controllers/field-slip-job_controller.js @@ -16,7 +16,7 @@ export default class extends Controller { connect() { // Just a "sanity check" convention, so you can tell "is this thing on?" - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "field-slip-job-connected"; this.status_id = this.element.dataset.status this.start_timer_sending_requests() diff --git a/app/javascript/controllers/file-input_controller.js b/app/javascript/controllers/file-input_controller.js index 4ecdfd356f..7a3c3d3b02 100644 --- a/app/javascript/controllers/file-input_controller.js +++ b/app/javascript/controllers/file-input_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = ['input', 'name'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "file-input-connected"; this.max_size = Number(this.inputTarget.dataset.maxUploadSize); this.error_msg = this.inputTarget.dataset.maxUploadMsg; this.old_callback = this.inputTarget.onchange; diff --git a/app/javascript/controllers/form-exif_controller.js b/app/javascript/controllers/form-exif_controller.js index 960edfa59a..72c239a782 100644 --- a/app/javascript/controllers/form-exif_controller.js +++ b/app/javascript/controllers/form-exif_controller.js @@ -13,10 +13,12 @@ const internalConfig = { // (formerly "observation_images" section of the form) // Connects to data-controller="form-exif" export default class extends Controller { - static targets = ["carousel", "item", "useExifBtn"] + static targets = ["carousel", "item", "useExifBtn", + "collapseFields", "collapseCheck"] + static outlets = ["autocompleter", "map"] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "form-exif-connected"; Object.assign(this, internalConfig); Object.assign(this.localized_text, @@ -67,25 +69,32 @@ export default class extends Controller { } populateExifGPS(itemElement, exif_data) { - const _exif_lat = itemElement.querySelector(".exif_lat"), + const _exif_gps = itemElement.querySelector(".exif_gps"), + _exif_no_gps = itemElement.querySelector(".exif_no_gps"), + _exif_lat = itemElement.querySelector(".exif_lat"), _exif_lng = itemElement.querySelector(".exif_lng"), _exif_alt = itemElement.querySelector(".exif_alt"), _use_exif_button = itemElement.querySelector('.use_exif_btn'); // Geocode Logic // check if there is geodata on the image - if (exif_data.GPSLatitude && exif_data.GPSLongitude) { + if (exif_data.GPSLatitude && exif_data.GPSLatitude.description && + exif_data.GPSLongitude && exif_data.GPSLongitude.description) { const latLngAlt = this.getLatLngEXIF(exif_data), { lat, lng, alt } = latLngAlt; // Set item's data-geocode attribute so we can have a record itemElement.dataset.geocode = JSON.stringify(latLngAlt); - // These are not inputs, they're spans so we can't set the value + // These are spans, not inputs — set innerText, not value _exif_lat.innerText = lat == null ? lat : lat.toFixed(4); _exif_lng.innerText = lng == null ? lng : lng.toFixed(4); _exif_alt.innerText = alt == null ? alt : alt.toFixed(0); _use_exif_button.classList.remove('d-none'); + } else { + // Show the "no GPS" message + _exif_gps.classList.add("d-none"); + _exif_no_gps.classList.remove("d-none"); } } @@ -166,13 +175,17 @@ export default class extends Controller { _obs_lng.value = lng == null ? lng : lng.toFixed(4); _obs_alt.value = alt == null ? alt : alt.toFixed(0); - // should trigger change to update the map - this.dispatch("pointChanged", { - detail: { - type: "location_containing", - request_params: { lat, lng } - } - }); + // should trigger change to update the autocompleter and the map + if (this.hasAutocompleterOutlet) { + this.autocompleterOutlet.swap({ + detail: { type: "location_containing", request_params: { lat, lng } } + }); + } + if (this.hasMapOutlet) { + this.mapOutlet.calculateMarker( + { detail: { request_params: { lat, lng } } } + ); + } } if (_exif_data.exif_date) { const _exifSimpleDate = JSON.parse(_exif_data.exif_date); @@ -180,6 +193,16 @@ export default class extends Controller { } // disables the button, even when called programmatically this.selectExifButton(element); + // show the geolocation fields + this.showFields(); + } + + // show the geolocation fields + showFields() { + if (this.hasCollapseFieldsTarget) { + $(this.collapseFieldsTarget).collapse('show'); + this.collapseCheckTarget.checked = true + } } // Disables this button but enables others, kind of like a radio. Happens on diff --git a/app/javascript/controllers/form-images_controller.js b/app/javascript/controllers/form-images_controller.js index b08c398756..83a520ece1 100644 --- a/app/javascript/controllers/form-images_controller.js +++ b/app/javascript/controllers/form-images_controller.js @@ -54,7 +54,7 @@ export default class extends Controller { } connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "form-images-connected"; Object.assign(this, internalConfig); Object.assign(this.localized_text, diff --git a/app/javascript/controllers/geocode_controller.js b/app/javascript/controllers/geocode_controller.js index a71ef60356..477d072046 100644 --- a/app/javascript/controllers/geocode_controller.js +++ b/app/javascript/controllers/geocode_controller.js @@ -3,15 +3,17 @@ import { Loader } from "@googlemaps/js-api-loader" import { convert } from "geo-coordinates-parser" // Connects to data-controller="geocode" -// The connected element should contain a location autocompleter and -// bounding box/elevation inputs. +// Sort of the "lite" version of the map controller: no map, no markers, but it +// can geocode and get elevations. The connected element should contain a +// location autocompleter and bounding box/elevation inputs. export default class extends Controller { static targets = ["southInput", "westInput", "northInput", "eastInput", "highInput", "lowInput", "placeInput", "locationId", "latInput", "lngInput", "altInput", "getElevation"] + static outlets = ["autocompleter"] connect() { - this.element.dataset.stimulus = "connected" + this.element.dataset.stimulus = "geocode-connected" // These private vars are for keeping track of user inputs to a form // that should update the form after a timeout. @@ -42,12 +44,19 @@ export default class extends Controller { }) } + tryToGeocode() { + this.verbose("geocode:tryToGeocode") + const location = this.validateLatLngInputs(false) + + if (location && + JSON.stringify(location) !== JSON.stringify(this.lastGeocodedLatLng)) { + this.geocodeLatLng(location) + } + } + // Geocode a lat/lng location. If we have multiple results, we'll dispatch // Send the location from validateLatLngInputs(false) to avoid duplicate calls geocodeLatLng(location) { - if (JSON.stringify(location) == JSON.stringify(this.lastGeocodedLatLng)) - return - this.lastGeocodedLatLng = location this.verbose("geocode:geocodeLatLng") this.verbose(location) @@ -57,7 +66,7 @@ export default class extends Controller { let { results } = result // destructure, results is part of the result results = this.siftResults(results) this.ignorePlaceInput = true - this.dispatchPrimer(results) + this.sendPrimer(results) this.respondToGeocode(results) }) .catch((e) => { @@ -66,9 +75,18 @@ export default class extends Controller { }); } - geolocatePlaceName() { - let address = this.placeInputTarget.value - if (address === this.lastGeolocatedAddress) return + tryToGeolocate() { + this.verbose("geocode:tryToGeolocate") + const address = this.placeInputTarget.value + + if (this.ignorePlaceInput === false && + address !== "" && address !== this.lastGeolocatedAddress) { + this.geolocatePlaceName(address) + } + } + + geolocatePlaceName(address) { + if (address == this.lastGeolocatedAddress) return false this.lastGeolocatedAddress = address this.verbose("geocode:geolocatePlaceName") @@ -80,7 +98,7 @@ export default class extends Controller { .geocode({ address: address }) .then((result) => { const { results } = result // destructure, results is part of the result - this.dispatchPrimer(results) // will be ignored by non-autocompleters + this.sendPrimer(results) // will be ignored by non-autocompleters this.respondToGeocode(results) }) .catch((e) => { @@ -90,7 +108,7 @@ export default class extends Controller { } // Remove certain types of results from the geocoder response: - // both too precise and too general, before dispatchPrimer + // both too precise and too general, before sendPrimer siftResults(results) { if (results.length == 0) return results @@ -109,7 +127,7 @@ export default class extends Controller { } // Build a primer for the autocompleter with bounding box data, but -1 id - dispatchPrimer(results) { + sendPrimer(results) { let north, south, east, west, name, id = -1 // Prefer geometry.bounds, but bounds do not exist for point locations. // MO locations must be boxes, so use viewport if bounds null. @@ -123,14 +141,19 @@ export default class extends Controller { name = this.formatMOPlaceName(result) return { name, north, south, east, west, id } }) - this.verbose("geocode:dispatchPrimer") + this.verbose("geocode:sendPrimer") this.verbose(primer) - this.dispatch("googlePrimer", { detail: { primer } }) + // Call autocompleter#refreshGooglePrimer directly + if (this.hasAutocompleterOutlet) { + this.autocompleterOutlet.refreshGooglePrimer({ primer }) + } } // Format the address components for MO style. formatMOPlaceName(result) { + const ignore_types = ["postal_code", "postal_code_suffix", "street_number"] + let name_components = [], usa_location = false result.address_components.forEach((component) => { if (component.types.includes("country") && component.short_name == "US") { @@ -141,7 +164,7 @@ export default class extends Controller { component.long_name.includes("County")) { // MO uses "Co." for County name_components.push(component.long_name.replace("County", "Co.")) - } else if (component.types.includes("postal_code")) { + } else if (ignore_types.some((type) => component.types.includes(type))) { // skip it for all. non-US countries it's an important differentiator? } else { name_components.push(component.long_name) @@ -166,10 +189,10 @@ export default class extends Controller { const extents = results[0].geometry.bounds?.toJSON() // may not exist const center = results[0].geometry.location.toJSON() - if (viewport) - this.map.fitBounds(viewport) - if (this.map) - this.placeClosestRectangle(viewport, extents) + if (this.map) { + if (viewport) this.map.fitBounds(viewport) + this.placeClosestRectangle(viewport, extents) // viewport is optional + } this.updateFields(viewport, extents, center) // For non-autocompleted place input in the location form this.updatePlaceInputTarget(results[0]) @@ -259,6 +282,14 @@ export default class extends Controller { ] } + // Computes the center of a Google Maps Rectangle's LatLngBoundsLiteral object + centerFromBounds(bounds) { + let lat = (bounds?.north + bounds?.south) / 2.0 + let lng = (bounds?.east + bounds?.west) / 2.0 + if (bounds?.west > bounds?.east) { lng += 180 } + return { lat: lat, lng: lng } + } + // takes a LatLngBoundsLiteral object {south:, west:, north:, east:} updateBoundsInputs(bounds) { if (!this.hasSouthInputTarget) return false @@ -291,6 +322,7 @@ export default class extends Controller { // Convert from human readable and do a rough check if they make sense validateLatLngInputs(update = false) { + this.verbose("geocode:validateLatLngInputs") if (!this.hasLatInputTarget || !this.hasLngInputTarget || !this.latInputTarget.value || !this.lngInputTarget.value) return false @@ -334,40 +366,39 @@ export default class extends Controller { this.lngInputTarget.value = this.roundOff(center.lng) // If we're here, we have a lat and a lng. if (this.ignorePlaceInput !== false) - this.dispatchPointChanged(center) + this.sendPointChanged(center) } // Call the swap event on the autocompleter and send the type we need - dispatchPointChanged({ lat, lng }) { + sendPointChanged({ lat, lng }) { this.clearAutocompleterSwapBuffer() if (lat && lng) { this.autocomplete_buffer = setTimeout(() => { - this.dispatch("pointChanged", { - detail: { - type: "location_containing", - request_params: { lat, lng }, - } - }) - // this.verbose("geocode:dispatchPointChanged") + if (this.hasAutocompleterOutlet) { + this.autocompleterOutlet.swap({ + detail: + { type: "location_containing", request_params: { lat, lng } } + }) + } + // this.verbose("geocode:sendPointChanged") }, 1000) - - // if (this.placeInputTarget.value === '') { - // this.geocoder.geocode({ location: center }, (results, status) => { - // if (status === "OK") { - // if (results[0]) { - // this.placeInputTarget.value = results[0].formatted_address - // } - // } - // }) - // } } else { this.autocomplete_buffer = setTimeout(() => { - this.dispatch("pointChanged", { detail: { type: "location" } }) + if (this.hasAutocompleterOutlet) { + this.autocompleterOutlet.swap({ detail: { type: "location" } }) + } }, 1000) } } + clearAutocompleterSwapBuffer() { + if (this.autocomplete_buffer) { + clearTimeout(this.autocomplete_buffer) + this.autocomplete_buffer = 0 + } + } + // Sorts the LocationElevationResponse.results.elevation objects and // computes the high and low of these results using bounds and center highAndLowOf(results) { @@ -424,9 +455,9 @@ export default class extends Controller { // ------------------------------- DEBUGGING ------------------------------ - helpDebug() { - debugger - } + // helpDebug() { + // debugger + // } verbose(str) { // console.log(str); diff --git a/app/javascript/controllers/lazyload_controller.js b/app/javascript/controllers/lazyload_controller.js index b1904eb2fa..bc417a64f5 100644 --- a/app/javascript/controllers/lazyload_controller.js +++ b/app/javascript/controllers/lazyload_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="lazyload" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "lazyload-connected"; if (window.lazyLoadInstance != undefined) window.lazyLoadInstance.update(); diff --git a/app/javascript/controllers/lightgallery_controller.js b/app/javascript/controllers/lightgallery_controller.js index 4e4fda18ce..ca522def36 100644 --- a/app/javascript/controllers/lightgallery_controller.js +++ b/app/javascript/controllers/lightgallery_controller.js @@ -5,7 +5,7 @@ import lgZoom from 'lightgallery/plugins/zoom' // Connects to data-controller="lightgallery", currently "#content" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "lightgallery-connected"; lightGallery(this.element, { selector: '.theater-btn', diff --git a/app/javascript/controllers/links_controller.js b/app/javascript/controllers/links_controller.js index 4a775a7eb3..c57e95a9b8 100644 --- a/app/javascript/controllers/links_controller.js +++ b/app/javascript/controllers/links_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="links" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "links-connected"; } disable(e) { diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js index 282293ea13..da6b8ca127 100644 --- a/app/javascript/controllers/map_controller.js +++ b/app/javascript/controllers/map_controller.js @@ -11,13 +11,14 @@ export default class extends GeocodeController { static targets = ["mapDiv", "southInput", "westInput", "northInput", "eastInput", "highInput", "lowInput", "placeInput", "locationId", "getElevation", "mapClearBtn", "controlWrap", "toggleMapBtn", - "latInput", "lngInput", "altInput", "showBoxBtn", "lockBoxBtn"] + "latInput", "lngInput", "altInput", "showBoxBtn", "lockBoxBtn", + "editBoxBtn", "autocompleter"] connect() { - this.element.dataset.stimulus = "connected" + this.element.dataset.stimulus = "map-connected" this.map_type = this.mapDivTarget.dataset.mapType this.editable = (this.mapDivTarget.dataset.editable === "true") - this.opened = this.map_type !== "observation" + this.opened = this.element.dataset.mapOpen === "true" this.marker = null // Only gets set if we're in edit mode this.rectangle = null // Only gets set if we're in edit mode this.location_format = this.mapDivTarget.dataset.locationFormat @@ -94,19 +95,18 @@ export default class extends GeocodeController { // Lock rectangle so it's not editable, and show this state in the icon link toggleBoxLock(event) { - if (this.rectangle && this.hasLockBoxBtnTarget) { + if (this.rectangle && this.hasLockBoxBtnTarget && + this.hasEditBoxBtnTarget) { if (this.rectangle.getEditable() === true) { this.rectangle.setEditable(false) this.rectangle.setOptions({ clickable: false }) - this.lockBoxBtnTarget.classList.add("active") - const active_title = this.lockBoxBtnTarget.dataset?.activeTitle ?? '' - this.lockBoxBtnTarget.setAttribute("title", active_title) + this.lockBoxBtnTarget.classList.add("d-none") + this.editBoxBtnTarget.classList.remove("d-none") } else { this.rectangle.setEditable(true) this.rectangle.setOptions({ clickable: true }) - this.lockBoxBtnTarget.classList.remove("active") - const title = this.lockBoxBtnTarget.dataset?.title ?? '' - this.lockBoxBtnTarget.setAttribute("title", title) + this.lockBoxBtnTarget.classList.remove("d-none") + this.editBoxBtnTarget.classList.add("d-none") } } } @@ -115,6 +115,7 @@ export default class extends GeocodeController { // If we only have one marker, don't use fitBounds - it's too zoomed in. // Call setCenter, setZoom with marker position and desired zoom level. drawMap() { + this.verbose("map:drawMap") this.map = new google.maps.Map(this.mapDivTarget, this.mapOptions) if (this.mapBounds) { if (Object.keys(this.collection.sets).length == 1) { @@ -138,6 +139,9 @@ export default class extends GeocodeController { // set.center is an array [lat, lng] // the `key` of each set is an array [x,y,w,h] buildOverlays() { + if (!this.collection) return + + this.verbose("map:buildOverlays") for (const [_xywh, set] of Object.entries(this.collection.sets)) { // this.verbose({ set }) // NOTE: according to the MapSet class, location sets are always is_box. @@ -348,7 +352,7 @@ export default class extends GeocodeController { const center = this.validateLatLngInputs(false) if (!center) return - this.dispatchPointChanged(center) + this.sendPointChanged(center) if (this.latInputTarget.value === "" || this.lngInputTarget.value === "" && this.marker) { @@ -362,13 +366,6 @@ export default class extends GeocodeController { } } - clearAutocompleterSwapBuffer() { - if (this.autocomplete_buffer) { - clearTimeout(this.autocomplete_buffer) - this.autocomplete_buffer = 0 - } - } - clearMarkerDrawBuffer() { if (this.marker_draw_buffer) { clearTimeout(this.marker_draw_buffer) @@ -378,10 +375,15 @@ export default class extends GeocodeController { // Action to map an MO location, or geocode a location from a place name. // Can be called directly from a button, so check for input values. - // Now fired from location id, including when it's zero + // Now fired when locationIdTarget changes, including when it's zero showBox() { - if (!this.opened || - !this.hasPlaceInputTarget || !this.placeInputTarget.value) + if (!(this.opened && this.hasPlaceInputTarget && + this.placeInputTarget.value)) + return false + + // Forms where location is optional: stay mum unless we're in create mode + if (this.hasAutocompleterTarget && + !this.autocompleterTarget.classList.contains("create")) return false this.verbose("map:showBox") @@ -394,28 +396,28 @@ export default class extends GeocodeController { checkForBox() { // this.showBoxBtnTarget.disabled = true this.verbose("map:checkForBox") - let id, location + let id if (this.hasLocationIdTarget && (id = this.locationIdTarget.value)) { - this.mapLocationBounds() - // Only geocode lat/lng if we have no location_id and not ignoring place + this.mapLocationIdData() } else if (["location", "hybrid"].includes(this.map_type)) { - if (location = this.validateLatLngInputs(false) && - this.ignorePlaceInput !== false) { - this.geocodeLatLng(location) // multiple possible results - // ...and only geolocate placeName if we have no lat/lng - } else if (this.ignorePlaceInput === false) { - // ...and only geolocate placeName if we have no lat/lng - this.geolocatePlaceName() // 1 result + // Only geocode lat/lng if we have no location_id and not ignoring place + // ...and only geolocate placeName if we have no lat/lng + // Note: is this the right logic ????????????? + if (this.ignorePlaceInput !== false) { + this.tryToGeocode() // multiple possible results + } else { + this.tryToGeolocate() } } if (this.rectangle) this.rectangle.setVisible(true) } // The locationIdTarget should have the bounds in its dataset - mapLocationBounds() { + mapLocationIdData() { if (!this.hasLocationIdTarget || !this.locationIdTarget.dataset.north) return false + this.verbose("map:mapLocationIdData") const bounds = { north: parseFloat(this.locationIdTarget.dataset.north), south: parseFloat(this.locationIdTarget.dataset.south), @@ -437,22 +439,22 @@ export default class extends GeocodeController { const east = parseFloat(this.eastInputTarget.value) const west = parseFloat(this.westInputTarget.value) - if (!(isNaN(north) || isNaN(south) || isNaN(east) || isNaN(west))) { - this.verbose("map:calculateRectangle") - const bounds = { north: north, south: south, east: east, west: west } - if (this.rectangle) { - this.rectangle.setBounds(bounds) - } - this.map.fitBounds(bounds) + if (isNaN(north) || isNaN(south) || isNaN(east) || isNaN(west)) return false + + this.verbose("map:calculateRectangle") + const bounds = { north: north, south: south, east: east, west: west } + if (this.rectangle) { + this.rectangle.setBounds(bounds) } + this.map.fitBounds(bounds) } // Infers a rectangle from the google place, if found. (could be point/bounds) placeClosestRectangle(viewport, extents) { + this.verbose("map:placeClosestRectangle") // Prefer extents for rectangle, fallback to viewport let bounds = extents || viewport if (bounds != undefined && bounds?.north) { - this.verbose("map:placeClosestRectangle") this.placeRectangle(bounds) } // else if (center) { @@ -467,6 +469,7 @@ export default class extends GeocodeController { // called by toggleMap checkForMarker() { + this.verbose("map:checkForMarker") let center if (center = this.validateLatLngInputs(false)) { this.calculateMarker({ detail: { request_params: center } }) @@ -479,11 +482,11 @@ export default class extends GeocodeController { // so, drops a pin on that location and center. Otherwise, checks if place // input has been prepopulated and uses that to focus map and drop a marker. calculateMarker(event) { - this.verbose("map:calculateMarker") - if (this.map == undefined || - this.latInputTarget.value === '' || this.lngInputTarget.value === '' - ) return false + if (this.map == undefined || !this.hasLatInputTarget || + this.latInputTarget.value === '' || this.lngInputTarget.value === '') + return false + this.verbose("map:calculateMarker") let location if (event?.detail?.request_params) { location = event.detail.request_params @@ -500,30 +503,40 @@ export default class extends GeocodeController { // Action called by the "Open Map" button only. // open/close handled by BS collapse toggleMap() { - // this.verbose("map:toggleMap") - + this.verbose("map:toggleMap") if (this.opened) { - this.opened = false - this.controlWrapTarget.classList.remove("map-open") + this.closeMap() } else { - this.opened = true - this.controlWrapTarget.classList.add("map-open") + this.openMap() + } + } - if (this.map == undefined) { - this.drawMap() - this.makeMapClickable() - } else { - this.map.fitBounds(this.mapBounds) - } + closeMap() { + this.verbose("map:closeMap") + this.opened = false + this.controlWrapTarget.classList.remove("map-open") + } + + openMap() { + this.verbose("map:openMap") + this.opened = true + this.controlWrapTarget.classList.add("map-open") - setTimeout(() => { - this.checkForMarker() - this.checkForBox() // regardless if point - }, 500) // wait for map to open + if (this.map == undefined) { + this.drawMap() + this.makeMapClickable() + } else if (this.mapBounds) { + this.map.fitBounds(this.mapBounds) } + + setTimeout(() => { + this.checkForMarker() + this.checkForBox() // regardless if point + }, 500) // wait for map to open } makeMapClickable() { + this.verbose("map:makeMapClickable") google.maps.event.addListener(this.map, 'click', (e) => { // this.map.addListener('click', (e) => { const location = e.latLng.toJSON() @@ -537,26 +550,41 @@ export default class extends GeocodeController { // Action called from the "Clear Map" button clearMap() { + this.verbose("map:clearMap") const inputTargets = [ - this.latInputTarget, this.lngInputTarget, this.altInputTarget, this.placeInputTarget, this.northInputTarget, this.southInputTarget, this.eastInputTarget, this.westInputTarget, this.highInputTarget, this.lowInputTarget ] + if (this.hasLatInputTarget) { inputTargets.push(this.latInputTarget) } + if (this.hasLngInputTarget) { inputTargets.push(this.lngInputTarget) } + if (this.hasAltInputTarget) { inputTargets.push(this.altInputTarget) } + inputTargets.forEach((element) => { element.value = '' }) this.ignorePlaceInput = false // turn string geolocation back on - if (this.marker) { - this.marker.setMap(null) - this.marker = null - } - if (this.rectangle) { - this.rectangle.setMap(null) - this.rectangle = null - // this.showBoxBtnTarget.disabled = false - } + this.clearMarker() + this.clearRectangle() this.dispatch("reenableBtns") - this.dispatchPointChanged({ lat: null, lng: null }) + this.sendPointChanged({ lat: null, lng: null }) + } + + clearMarker() { + if (!this.marker) return false + + this.verbose("map:clearMarker") + this.marker.setMap(null) + this.marker = null + } + + clearRectangle() { + if (!this.rectangle) return false + + this.verbose("map:clearRectangle") + this.rectangle.setVisible(false) + this.rectangle.setMap(null) + this.rectangle = null + return true } // @@ -608,14 +636,6 @@ export default class extends GeocodeController { return corners } - // Computes the center of a Google Maps Rectangle's LatLngBoundsLiteral object - centerFromBounds(bounds) { - let lat = (bounds?.north + bounds?.south) / 2.0 - let lng = (bounds?.east + bounds?.west) / 2.0 - if (bounds?.west > bounds?.east) { lng += 180 } - return { lat: lat, lng: lng } - } - // // COORDINATES - ELEVATION // @@ -634,9 +654,9 @@ export default class extends GeocodeController { // ------------------------------- DEBUGGING ------------------------------ - helpDebug() { - debugger - } + // helpDebug() { + // debugger + // } verbose(str) { // console.log(str); diff --git a/app/javascript/controllers/matrix-table_controller.js b/app/javascript/controllers/matrix-table_controller.js index 44e3090305..3ab0fac1ee 100644 --- a/app/javascript/controllers/matrix-table_controller.js +++ b/app/javascript/controllers/matrix-table_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="matrix-table" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "matrix-table-connected"; this.matrixBoxes = document.querySelectorAll('.matrix-box') this.boxes = document.querySelectorAll('.matrix-box .panel-sizing') diff --git a/app/javascript/controllers/modal-toggle_controller.js b/app/javascript/controllers/modal-toggle_controller.js index 9290da4a0c..a3ed06b854 100644 --- a/app/javascript/controllers/modal-toggle_controller.js +++ b/app/javascript/controllers/modal-toggle_controller.js @@ -7,10 +7,11 @@ import { get } from "@rails/request.js" // For example, you can start entering a collection number, close the modal, // open a herbarium record form, close it and go back to the collection number // and find the form form as you left it, or vice versa, until you submit. +// Connects to data-controller="modal-toggle" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "modal-toggle-connected"; this.modalSelector = this.element.dataset.modal this.destination = this.element.getAttribute("href") } diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 0740c84292..ea0738fa44 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { connect() { // console.log("Hello Modal " + this.element.id); - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "modal-connected"; } // Modal is only removed in the event that the page section updates. diff --git a/app/javascript/controllers/name-list_controller.js b/app/javascript/controllers/name-list_controller.js index 96738b2c18..609d9a747b 100644 --- a/app/javascript/controllers/name-list_controller.js +++ b/app/javascript/controllers/name-list_controller.js @@ -43,7 +43,7 @@ export default class extends Controller { } connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "name-list-connected"; // These are the div elements for each column. this.DIVS = { diff --git a/app/javascript/controllers/name-panels_controller.js b/app/javascript/controllers/name-panels_controller.js index e6863dfe3b..dfe89a1dbd 100644 --- a/app/javascript/controllers/name-panels_controller.js +++ b/app/javascript/controllers/name-panels_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { static targets = ['classification', 'lifeform'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "name-panels-connected"; this.equalizePanelHeights() } diff --git a/app/javascript/controllers/naming-reason_controller.js b/app/javascript/controllers/naming-reason_controller.js index ae878ec301..7431894fba 100644 --- a/app/javascript/controllers/naming-reason_controller.js +++ b/app/javascript/controllers/naming-reason_controller.js @@ -1,8 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { delegate, abnegate } from 'jquery-events-to-dom-events' -// Connects to data-controller="naming-reason" - // https://github.com/leastbad/jquery-events-to-dom-events // We use a Stimulus action that listens to `$shown.bs.collapse` on the div // (note the `$`). This depends on using `delegate` from the imported library @@ -11,11 +9,12 @@ import { delegate, abnegate } from 'jquery-events-to-dom-events' // // If moving to BS 5, can remove. +// Connects to data-controller="naming-reason" export default class extends Controller { static targets = ['collapse', 'input'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "naming-reason-connected"; this.delegate = delegate('shown.bs.collapse') } diff --git a/app/javascript/controllers/naming-vote_controller.js b/app/javascript/controllers/naming-vote_controller.js index aeb3123c50..ce4ae2e74f 100644 --- a/app/javascript/controllers/naming-vote_controller.js +++ b/app/javascript/controllers/naming-vote_controller.js @@ -2,6 +2,8 @@ import { Controller } from "@hotwired/stimulus" // Controller deals with naming vote select bindings ** per select. ** // the controller is on the
+ +// Connects to data-controller="naming-vote" export default class extends Controller { static targets = ["select", "submit"] @@ -11,7 +13,7 @@ export default class extends Controller { connect() { // console.log("Hello Modal"); - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "naming-vote-connected"; // The localized text is for the modal progress caption. Object.assign(this.localized_text, JSON.parse(this.element.dataset.localization)); diff --git a/app/javascript/controllers/nav-active_controller.js b/app/javascript/controllers/nav-active_controller.js index 4ff0ebda5f..26780431e2 100644 --- a/app/javascript/controllers/nav-active_controller.js +++ b/app/javascript/controllers/nav-active_controller.js @@ -6,7 +6,7 @@ export default class extends Controller { static targets = ['link'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "nav-active-connected"; this.pickActive(); } diff --git a/app/javascript/controllers/nav_controller.js b/app/javascript/controllers/nav_controller.js index 61766e685a..18dd41718d 100644 --- a/app/javascript/controllers/nav_controller.js +++ b/app/javascript/controllers/nav_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = ['hamburger', 'search', 'container', 'offcanvas', 'topNav'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "nav-connected"; } // HAMBURGER HELPER action to toggle offcanvas left nav diff --git a/app/javascript/controllers/reviewed-toggle_controller.js b/app/javascript/controllers/reviewed-toggle_controller.js index 2805c4cdf3..b0e3eb4afc 100644 --- a/app/javascript/controllers/reviewed-toggle_controller.js +++ b/app/javascript/controllers/reviewed-toggle_controller.js @@ -6,7 +6,7 @@ export default class extends Controller { static targets = ['toggle'] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "reviewed-toggle-connected"; } // https://stackoverflow.com/questions/68624668/how-can-i-submit-a-form-on-input-change-with-turbo-streams diff --git a/app/javascript/controllers/section-update_controller.js b/app/javascript/controllers/section-update_controller.js index 38519e5cf7..f8a5f4b80b 100644 --- a/app/javascript/controllers/section-update_controller.js +++ b/app/javascript/controllers/section-update_controller.js @@ -6,7 +6,7 @@ export default class extends Controller { // this is a handler for page elements that get updated // on successful form submit, so it "cleans up" connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "section-update-connected"; // Note: this is simpler than adding an action on every frame. hides modal this.element.addEventListener("turbo:frame-render", this.updated()); diff --git a/app/javascript/controllers/specimen_controller.js b/app/javascript/controllers/specimen_controller.js deleted file mode 100644 index 51dde8d910..0000000000 --- a/app/javascript/controllers/specimen_controller.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Connects to data-controller="specimen" -export default class extends Controller { - static targets = ["checkbox", "fields"] - - connect() { - this.element.dataset.stimulus = "connected"; - - this.showPref = this.element.dataset.userPref - if (this.hasFieldsTarget && this.showPref) { - this.hideShowFields() - } - } - - // Only show if user prefers - hideShowFields() { - if (this.checkboxTarget.checked) { - this.fieldsTarget.classList.remove("hidden") - $(this.fieldsTarget).show() - } else { - $(this.fieldsTarget).hide() - } - } - - // This checks "specimen" anyway if people add a CN or HR. - checkCheckbox() { - this.checkboxTarget.setAttribute("checked", true) - } -} diff --git a/app/javascript/controllers/suggestions_controller.js b/app/javascript/controllers/suggestions_controller.js index f68a978ca1..6838e3dba2 100644 --- a/app/javascript/controllers/suggestions_controller.js +++ b/app/javascript/controllers/suggestions_controller.js @@ -10,7 +10,7 @@ export default class extends Controller { } connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "suggestions-connected"; this.progressModal = document.getElementById("mo_ajax_progress") this.progressCaption = document.getElementById("mo_ajax_progress_caption") diff --git a/app/javascript/controllers/thumbnail-map_controller.js b/app/javascript/controllers/thumbnail-map_controller.js index 508f5d6dc7..b9d7c0dc57 100644 --- a/app/javascript/controllers/thumbnail-map_controller.js +++ b/app/javascript/controllers/thumbnail-map_controller.js @@ -5,7 +5,7 @@ export default class extends Controller { static targets = ["mapContainer", "map", "globe"] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "thumbnail-map-connected"; this.map_url = this.element.dataset.mapUrl this.coords = JSON.parse(this.element.dataset.coordinates) diff --git a/app/javascript/controllers/tooltip_controller.js b/app/javascript/controllers/tooltip_controller.js new file mode 100644 index 0000000000..f6cf8a2092 --- /dev/null +++ b/app/javascript/controllers/tooltip_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="tooltip" +// BS3 tooltips are "opt-in", so they require on-page activation +export default class extends Controller { + + connect() { + this.element.dataset.stimulus = "tooltip-connected"; + this.activateTooltips(); + } + + activateTooltips() { + $('[data-toggle="tooltip"]').tooltip() + } +} diff --git a/app/javascript/controllers/translation_controller.js b/app/javascript/controllers/translation_controller.js index 4c646fcee4..9615c72d60 100644 --- a/app/javascript/controllers/translation_controller.js +++ b/app/javascript/controllers/translation_controller.js @@ -8,7 +8,7 @@ export default class extends Controller { ] connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "translate-connected"; this.LOCALE = this.element.dataset.locale; this.CONFIRM_STRING = this.element.dataset.confirmString; diff --git a/app/javascript/controllers/year-input_controller.js b/app/javascript/controllers/year-input_controller.js index 4ed344bce5..b320ea2258 100644 --- a/app/javascript/controllers/year-input_controller.js +++ b/app/javascript/controllers/year-input_controller.js @@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="year-input" export default class extends Controller { connect() { - this.element.dataset.stimulus = "connected"; + this.element.dataset.stimulus = "year-input-connected"; this.id = this.element.getAttribute("id"); // console.log(this.id) diff --git a/app/models/sequence.rb b/app/models/sequence.rb index 19a6767d20..045c7c7272 100644 --- a/app/models/sequence.rb +++ b/app/models/sequence.rb @@ -124,7 +124,7 @@ def blast_url def self.blast_url_prefix "https://blast.ncbi.nlm.nih.gov/Blast.cgi?" \ - "CMD=Put&DATABASE=nt&PROGRAM=blastn&QUERY=" + "CMD=Post&DATABASE=nt&PROGRAM=blastn&QUERY=" end # convenience wrapper around class method of same name diff --git a/app/views/controllers/application/content/_title_and_tab_sets.html.erb b/app/views/controllers/application/content/_title_and_tab_sets.html.erb index 5aeefcc087..a670648ba1 100644 --- a/app/views/controllers/application/content/_title_and_tab_sets.html.erb +++ b/app/views/controllers/application/content/_title_and_tab_sets.html.erb @@ -57,6 +57,7 @@ left_cols = add_right_tabs ? "col-xs-8 col-lg-7" : "col-xs-12" <%= content_tag_if(add_right_tabs, :div, id: "right_tabs", class: "hidden-print text-right col-sm-4 col-lg-5 mb-3") do concat(yield(:interest_icons)) + concat(yield(:edit_icons)) concat(yield(:tab_set)) end %> diff --git a/app/views/controllers/application/top_nav/_user_nav.html.erb b/app/views/controllers/application/top_nav/_user_nav.html.erb index 0893acc1a4..9e09a717ad 100644 --- a/app/views/controllers/application/top_nav/_user_nav.html.erb +++ b/app/views/controllers/application/top_nav/_user_nav.html.erb @@ -1,45 +1,37 @@ +<% +admin_title = in_admin_mode? ? :app_turn_admin_off.t : :app_turn_admin_on.t +admin_mode_args = in_admin_mode? ? { turn_off: true } : { turn_on: true } +%> + diff --git a/app/views/controllers/field_slips/_obs_thumbnail.erb b/app/views/controllers/field_slips/_obs_thumbnail.erb index 92683315e8..1f0ff94f4a 100644 --- a/app/views/controllers/field_slips/_obs_thumbnail.erb +++ b/app/views/controllers/field_slips/_obs_thumbnail.erb @@ -1,5 +1,5 @@ <%= tag.div(class: "panel panel-default") do - concat(panel_block_heading( + concat(panel_heading( heading: submit_button(form: form, button: button) )) concat(tag.div(class: "thumbnail-container") do diff --git a/app/views/controllers/herbaria/_form.erb b/app/views/controllers/herbaria/_form.erb new file mode 100644 index 0000000000..65527c4098 --- /dev/null +++ b/app/views/controllers/herbaria/_form.erb @@ -0,0 +1,122 @@ +<%# locals: (action:, local: true) -%> +<% +create = (action == :create) +button_name = create ? :CREATE.l : :SAVE.l +help = ac_help = nil +if @herbarium.personal_user_id == @user.id + help = :edit_herbarium_this_is_personal_herbarium.tp +end +if in_admin_mode? && !create + top_users = herbarium_top_users(@herbarium.id) + if top_users.empty? + admin_help = :edit_herbarium_no_herbarium_records.l + else + admin_help = capture do + top_users.each do |name, login, count| + concat(tag.div(:edit_herbarium_user_records.t( + name: "#{name} (#{login})", num: count + ))) + end + end + end +end + +form_args = { + model: @herbarium, + id: "herbarium_form", + data: { + controller: "map", map_open: false, + map_autocompleter_outlet: "#herbarium_location_autocompleter" + } +} +if local == true + form_args = form_args.merge({ local: true }) +else + form_args = form_args.deep_merge({ data: { turbo: true } }) +end + +%> + +<%= form_with(**form_args) do |f| %> + + <%= f.hidden_field(:back, value: @back) %> + <%= f.hidden_field(:q, value: get_query_param) %> + + <%= text_field_with_label(form: f, field: :name, label: :NAME.t + ":", + between: :required, help:) %> + + <% if in_admin_mode? %> + + <%= autocompleter_field(form: f, field: :personal_user_name, type: :user, + label: :edit_herbarium_admin_make_personal.t, + help: admin_help, inline: true) %> + + <% elsif action == :create || @herbarium.can_make_personal?(@user) %> + + <%= check_box_with_label( + form: f, field: :personal, label: :create_herbarium_personal.l, + help: :create_herbarium_personal_help.t( + name: @user.personal_herbarium_name + ) + ) %> + + <% end %> + + <%= submit_button(form: f, button: button_name, center: true) %> + + <% if !@herbarium.personal_user_id %> + <%= text_field_with_label(form: f, field: :code, size: 8, inline: true, + label: :create_herbarium_code.l + ":", + help: :create_herbarium_code_help.t, + between: :optional) %> + <% end %> + + + <% append = capture do + tag.div(class: "mb-5 d-none", data: { autocompleter_target: "mapWrap" }) do + render(partial: "shared/form_location_map", + locals: { id: "herbarium_form_map", map_type: "observation" }) + end + end %> + + + <%= autocompleter_field( + form: f, field: :place_name, type: :location, + label: [tag.span("#{:LOCATION.l}:", class: "unconstrained-label"), + tag.span("#{:form_observations_create_locality.l}:", + class: "create-label")].safe_join(" "), + controller_data: { map_target: "autocompleter" }, + controller_id: "herbarium_location_autocompleter", + between: :optional, + append:, + hidden_data: { map_target: "locationId" }, + create_text: :form_observations_create_locality.l, + map_outlet: "#herbarium_form", + data: { + map_target: "placeInput", + # action: [ + # "map:pointChanged@window->autocompleter#swap", + # "map:googlePrimer@window->autocompleter#refreshGooglePrimer" + # ] + } + ) %> + + <%= render(partial: "locations/form/bounds_hidden_fields", + locals: { location: @location, target_controller: :map }) %> + + + <%= text_field_with_label(form: f, field: :email, + label: :create_herbarium_email.l + ":", + between: :optional) %> + + <%= text_area_with_label(form: f, field: :mailing_address, rows: 5, + label: :create_herbarium_mailing_address.l + ":", + between: :optional) %> + + <%= text_area_with_label(form: f, field: :description, rows: 10, + label: :NOTES.l + ":", + between: :optional) %> + + <%= submit_button(form: f, button: button_name, center: true) %> + +<% end %> diff --git a/app/views/controllers/herbaria/_form.html.erb b/app/views/controllers/herbaria/_form.html.erb deleted file mode 100644 index da589eb12e..0000000000 --- a/app/views/controllers/herbaria/_form.html.erb +++ /dev/null @@ -1,70 +0,0 @@ -<%= form_with(model: @herbarium, id: "herbarium_form") do |f| %> - - <%= submit_button(form: f, button: button_name.t, center: true) %> - - <%= f.hidden_field :back, value: @back %> - <%= f.hidden_field :q, value: get_query_param %> - - <%= text_field_with_label(form: f, field: :name, label: :NAME.t + ":", - between: :required) %> - - <% if in_admin_mode? %> - <%= autocompleter_field(form: f, field: :personal_user_name, type: :user, - label: :edit_herbarium_admin_make_personal.t, - inline: true) %> - - <% if button_name != :CREATE %> - <%= help_block_with_arrow("up") do %> - <% top_users = herbarium_top_users(@herbarium.id) - top_users.each do |name, login, count| %> - <%= :edit_herbarium_user_records.t( - name: "#{name} (#{login})", num: count - ) %>
- <% end %> - <%= :edit_herbarium_no_herbarium_records.t if top_users.empty? %> - <% end %> - <% end %> - - <% else %> - <% if @herbarium.personal_user_id == @user.id %> - <%= content_tag(:div, class: "form-group") do - help_block(:div, :edit_herbarium_this_is_personal_herbarium.tp) - end %> - <% end %> - - <% if button_name == :CREATE || @herbarium.can_make_personal?(@user) %> - <%= check_box_with_label(form: f, field: :personal, - label: :create_herbarium_personal.t) %> - <%= help_block_with_arrow("up") do %> - <%= :create_herbarium_personal_help.t( - name: @user.personal_herbarium_name - ) %> - <% end %> - <% end %> - <% end %> - - <% if !@herbarium.personal_user_id %> - <%= text_field_with_label(form: f, field: :code, size: 8, inline: true, - label: :create_herbarium_code.t + ":", - between: :optional) %> - <%= help_block_with_arrow("up") do :create_herbarium_code_help.t end %> - <% end %> - - <%= autocompleter_field(form: f, field: :place_name, type: :location, - label: :LOCATION.t + ":", between: :optional) %> - - <%= text_field_with_label(form: f, field: :email, - label: :create_herbarium_email.t + ":", - between: :optional) %> - - <%= text_area_with_label(form: f, field: :mailing_address, rows: 5, - label: :create_herbarium_mailing_address.t + ":", - between: :optional) %> - - <%= text_area_with_label(form: f, field: :description, rows: 10, - label: :NOTES.t + ":", - between: :optional) %> - - <%= submit_button(form: f, button: button_name.t, center: true) %> - -<% end %> diff --git a/app/views/controllers/herbaria/_update_observation.erb b/app/views/controllers/herbaria/_update_observation.erb new file mode 100644 index 0000000000..3dbd03a484 --- /dev/null +++ b/app/views/controllers/herbaria/_update_observation.erb @@ -0,0 +1,13 @@ +<%# Close the modal, update the obs with the new herbarium, and flash %> +<%= turbo_stream.close_modal("modal_herbarium") %> +<%= turbo_stream.remove("modal_herbarium") %> + +<%= turbo_stream.update("page_flash") { flash_notices_html } %> + +<%= turbo_stream.update_input("herbarium_record_herbarium_name", + @herbarium.name) %> + +<%= turbo_stream.update_input("herbarium_record_herbarium_id", + @herbarium.id) %> + +<%= turbo_stream.remove("create_herbarium_btn") %> diff --git a/app/views/controllers/herbaria/edit.html.erb b/app/views/controllers/herbaria/edit.html.erb index 48c39fa337..b498c0cd14 100644 --- a/app/views/controllers/herbaria/edit.html.erb +++ b/app/views/controllers/herbaria/edit.html.erb @@ -4,4 +4,5 @@ add_page_title(:edit_herbarium_title.l) add_tab_set(herbarium_form_edit_tabs(herbarium: @herbarium)) %> -<%= render(partial: "herbaria/form", locals: { button_name: :SAVE }) %> +<%= render(partial: "herbaria/form", + locals: { action: :update, local: true }) %> diff --git a/app/views/controllers/herbaria/new.html.erb b/app/views/controllers/herbaria/new.html.erb index 81714fe2b5..8076abbbfb 100644 --- a/app/views/controllers/herbaria/new.html.erb +++ b/app/views/controllers/herbaria/new.html.erb @@ -5,4 +5,5 @@ add_page_title(:create_herbarium_title.l) add_tab_set(herbarium_form_new_tabs) %> -<%= render(partial: "herbaria/form", locals: { button_name: :CREATE }) %> +<%= render(partial: "herbaria/form", + locals: { action: :create, local: true }) %> diff --git a/app/views/controllers/herbaria/show.html.erb b/app/views/controllers/herbaria/show.html.erb index c0e87289e8..3c22f59c9b 100644 --- a/app/views/controllers/herbaria/show.html.erb +++ b/app/views/controllers/herbaria/show.html.erb @@ -4,7 +4,7 @@ add_page_title(@herbarium.format_name.t) add_pager_for(@herbarium) add_tab_set(herbarium_show_tabs(herbarium: @herbarium, user: @user)) -map = @herbarium.location ? true : false +map = @herbarium.location @container = :wide %> @@ -20,8 +20,10 @@ map = @herbarium.location ? true : false
- <%= render(partial: "herbaria/curator_table", - locals: { herbarium: @herbarium }) %> + <% if @herbarium.curators.present? %> + <%= render(partial: "herbaria/curator_table", + locals: { herbarium: @herbarium }) %> + <% end %> <% if @herbarium.curator?(@user) || in_admin_mode? %> @@ -49,14 +51,14 @@ map = @herbarium.location ? true : false <% end %>
- <% if !@herbarium.description.blank? %> + <% if @herbarium.description.present? %>
<%= :NOTES.t %>:
<%= @herbarium.description.tpl %>
<% end %> - <% if @herbarium.mailing_address && !@herbarium.mailing_address.empty? %> + <% if @herbarium.mailing_address.present? %>
<%= :herbarium_mailing_address.t %>:
<%= @herbarium.mailing_address.tp %> @@ -66,7 +68,10 @@ map = @herbarium.location ? true : false <% if map %>
- <%= make_map(objects: [@herbarium.location]) %> + <%= tag.div(class: "mb-3") { make_map(objects: [@herbarium.location]) } %> + <%= tag.p(id: "herbarium_location") do + "#{:LOCATION.l}: #{@herbarium.location.text_name}" + end %>
<% end %>
diff --git a/app/views/controllers/images/test_add_image.html.erb b/app/views/controllers/images/test_add_image.html.erb index 4f89b297ea..4e137dc201 100644 --- a/app/views/controllers/images/test_add_image.html.erb +++ b/app/views/controllers/images/test_add_image.html.erb @@ -11,10 +11,14 @@ through this page are not saved and no database changes are made.

<%= form_tag({action: :test_upload_image, log_id: @log_entry.id}, {multipart: true}) do %>
- <% label_tag(:upload_image1, :IMAGE.t + " 1:") %> <%= custom_file_field(:upload, :image1) %>
- <% label_tag(:upload_image2, :IMAGE.t + " 2:") %> <%= custom_file_field(:upload, :image2) %>
- <% label_tag(:upload_image3, :IMAGE.t + " 3:") %> <%= custom_file_field(:upload, :image3) %>
- <% label_tag(:upload_image4, :IMAGE.t + " 4:") %> <%= custom_file_field(:upload, :image4) %>
+ <%= fields_for(:upload) do |f_u| %> + <% [1,2,3,4].each do |i| %> + <%= file_field_with_label( + form: f_u, field: "image#{i}".to_sym, class: "mt-3", + label: :image_add_image.t + " #{i}:", + ) %> + <% end %> + <% end %>
<%= submit_tag(:UPLOAD.l, class: "btn btn-default center-block mt-3") %> diff --git a/app/views/controllers/layouts/application.html.erb b/app/views/controllers/layouts/application.html.erb index 5032598c1a..cbb6ecee01 100644 --- a/app/views/controllers/layouts/application.html.erb +++ b/app/views/controllers/layouts/application.html.erb @@ -14,7 +14,7 @@ body_class = class_names(ctrlr_action, theme, location_format, logged_in) <%= render(partial: "application/app/head") %> - + <% if Rails.env == "production" %> <%= render(partial: "application/app/gtm_iframe") %> <% end %> diff --git a/app/views/controllers/locations/_form.erb b/app/views/controllers/locations/_form.erb index 78d32f7a29..15ae513ab1 100644 --- a/app/views/controllers/locations/_form.erb +++ b/app/views/controllers/locations/_form.erb @@ -22,7 +22,8 @@ when "edit", "update" end form_args = { - model: @location, url: url_params, id: "location_form" + model: @location, url: url_params, id: "location_form", + data: { controller: "map", map_open: true } } if local_assigns[:local] == true @@ -43,7 +44,7 @@ map_args = { editable: true, map_type: "location" } <%# NOTE: All other Stimulus data is on the map div, but we need the fields inside the controller scope, so map has controller: nil %> - <%= tag.div(class: "row", data: { controller: "map" }) do %> + <%= tag.div(class: "row") do %> <%= tag.div(class: "col-md-8 col-lg-6") do %> <%= render(partial: "locations/form/fields", locals: { f:, button:, location: @location, diff --git a/app/views/controllers/locations/form/_bounds_hidden_fields.erb b/app/views/controllers/locations/form/_bounds_hidden_fields.erb index 79a26adf3e..3b59f8e953 100644 --- a/app/views/controllers/locations/form/_bounds_hidden_fields.erb +++ b/app/views/controllers/locations/form/_bounds_hidden_fields.erb @@ -1,10 +1,10 @@ -<%# locals: (location: nil, target_type: :geocode_target) -%> +<%# locals: (location: nil, target_controller: :geocode) -%> <%= fields_for(:location) do |f_l| %> <% %w[north south east west low high].each do |key| %> <%= f_l.hidden_field( :"#{key}", value: location&.send(key) || nil, - data: { "#{target_type}_target".to_sym => "#{key}Input" } + data: { "#{target_controller}_target".to_sym => "#{key}Input" } ) %> <% end %> <% end %> diff --git a/app/views/controllers/names/synonyms/deprecate/new.html.erb b/app/views/controllers/names/synonyms/deprecate/new.html.erb index 86c7ea9f9e..ff1bb067f3 100644 --- a/app/views/controllers/names/synonyms/deprecate/new.html.erb +++ b/app/views/controllers/names/synonyms/deprecate/new.html.erb @@ -9,10 +9,10 @@ action = { controller: "/names/synonyms/deprecate", action: :create, feedback_locals = { button_name: :SUBMIT.l, given_name: @given_name, + names: @names, valid_names: @valid_names, suggest_corrections: @suggest_corrections, - parent_deprecated: @parent_deprecated, - names: @names + parent_deprecated: @parent_deprecated } deprecate_comments_help = capture do @@ -26,7 +26,7 @@ end <%= submit_button(form: f, button: :SUBMIT.l, center: true) %> <%= render(partial: "shared/form_name_feedback", - locals: feedback_locals.merge({ f: f })) if @given_name.present? %> + locals: feedback_locals) if @given_name.present? %> <%= autocompleter_field( form: f, field: :proposed_name, type: :name, diff --git a/app/views/controllers/observations/_form.html.erb b/app/views/controllers/observations/_form.html.erb index 2ee258a907..8566ac3bfd 100644 --- a/app/views/controllers/observations/_form.html.erb +++ b/app/views/controllers/observations/_form.html.erb @@ -1,11 +1,22 @@ -<%# locals: (action:) -%> <% +# Declaring permitted locals in a magic comment doesn't work here (?) create = (action == :create) method = create ? :post : :patch button_name = create ? :CREATE.l : :SAVE_EDITS.l -include_naming = create ? true : false has_specimen = create ? false : @observation.herbarium_records.length > 0 logging_optional = create ? false : true +naming_locals = { + create:, + button_name:, + show_reasons: false, + unfocused: true, + name_help: :form_naming_name_help_leave_blank.t +} +notes_fields = @observation.form_notes_parts(@user) +notes_open = (create && notes_fields.length > 1) || @observation.notes.present? +show_projects = @projects.any? || @error_checked_projects.any? || + @suspect_checked_projects.any? +show_lists = @lists.any? # Data for the form-images Stimulus controller. # Controller element is the form, so image dropzone can cover the whole form. @@ -19,19 +30,19 @@ image_upload_localization = { show_on_map: :show_on_map.t, something_went_wrong: :form_observations_upload_error.t }.to_json -data = { +# Outlets are how the stimulus controllers call each others' methods. +form_element_data = { controller: "form-images form-exif map", - action: [ - "map:reenableBtns@window->form-exif#reenableButtons", - "form-exif:pointChanged@window->map#calculateMarker", - "autocompleter:hiddenIdDataChanged@window->map#showBox", - ].join(" "), + map_autocompleter_outlet: "#observation_location_autocompleter", + map_open: false, + form_exif_autocompleter_outlet: "#observation_location_autocompleter", + form_exif_map_outlet: "#observation_form", + action: "map:reenableBtns@window->form-exif#reenableButtons", upload_max_size: max_size, localization: image_upload_localization, form_images_target: "form", exif_used: create ? false : true } -projects_lists = @projects.any? || @lists.any? %> <%= form_with( @@ -42,26 +53,69 @@ projects_lists = @projects.any? || @lists.any? method:, multipart: true, id: "observation_form", - data: + data: form_element_data ) do |f| %> - <%= if @field_code - tag.p("#{:form_observations_field_code.t} #{@field_code}") + <% if @field_code %> + <%= tag.p("#{:form_observations_field_code.t} #{@field_code}") %> + <%= hidden_field_tag(:field_code, @field_code) %> + <% end %> + + <%= panel_block( + heading: "#{:IMAGES.l} + #{:show_observation_details.l}", + id: "observation_images_details", formatted_content: true, + collapse: "observation_images_details_inner", open: true + ) do + concat(render(partial: "observations/form/images", locals: { f: })) + concat(render(partial: "observations/form/details", + locals: { f:, action:, button_name:, location: @location, + logging_optional: })) + end %> + + <%= panel_block( + heading: "#{:IDENTIFICATION.l} + #{:SPECIMEN.l}", + id: "observation_naming_specimen", # @given_name.present? + collapse: "observation_naming_specimen_inner", open: create + ) do + tag.div(class: "row") do + concat(tag.div(class: "col-xs-12 col-md-6") do + render(partial: "observations/namings/fields", locals: naming_locals) + end) if create + concat(tag.div(class: "col-xs-12 col-md-6") do + render(partial: "observations/form/specimen_section", + locals: { f:, action: }) + end) + end end %> - <%= hidden_field_tag(:field_code, @field_code) %> - <%= tab_content do %> - <%= render(partial: "observations/form/record_step", - locals: { f:, button_name:, location: @location, - logging_optional: }) %> + <%= submit_button(form: f, button: button_name, center: true) %> + + <%= panel_block( + heading: :NOTES.l, id: "observation_notes", + collapse: "observation_notes_inner", open: notes_open + ) do + render(partial: "shared/notes_fields", + locals: { form: f, fields: notes_fields }) + end %> - <%= render(partial: "observations/form/identify_step", - locals: { f:, action:, button_name:, include_naming: }) %> + <% if show_projects %> + <%= panel_block( + heading: :PROJECTS.l, id: "observation_projects", + collapse: "observation_projects_inner", + open: @project_checks.any? + ) do + render(partial: "observations/form/projects", locals: { button_name: }) + end %> + <% end %> - <% if projects_lists %> - <%= render(partial: "observations/form/add_to_step", - locals: { f:, button_name: }) %> - <% end %> + <% if show_lists %> + <%= panel_block( + heading: :SPECIES_LISTS.l, id: "observation_lists", + collapse: "observation_lists_inner", + open: @list_checks.any? + ) do + render(partial: "observations/form/species_lists") + end %> <% end %> <% end %> diff --git a/app/views/controllers/observations/form/_add_to_step.erb b/app/views/controllers/observations/form/_add_to_step.erb deleted file mode 100644 index 47f2d195d8..0000000000 --- a/app/views/controllers/observations/form/_add_to_step.erb +++ /dev/null @@ -1,30 +0,0 @@ -<%# locals: (f:, button_name:) -%> -<%# Add to Project and List section of create_observation form %> - -<%= tab_panel(id: "add_to") do %> - <%= panel_block(id: "observation_projects_and_lists", - heading: "#{:PROJECTS.l} + #{:SPECIES_LISTS.l}") do %> - - <%= tag.div(class: "row mt-3") do %> - <% if @projects.any? %> - <%= tag.div(class: "col-xs-12 col-sm-6") do - render(partial: "observations/form/projects", - locals: { button_name: }) - end %> - <% end %> - <% if @lists.any? %> - <%= tag.div(class: "col-xs-12 col-sm-6") do - render(partial: "observations/form/species_lists") - end %> - <% end %> - <% end %> - - <%= tag.div(class: "row mt-5 text-center", id: "step-nav-3") do - [ - tab_link(:BACK.l, id: "identify", button: true), - submit_button(form: f, button: button_name) - ].safe_join(" ") - end %> - <% end %> -<% end %> - diff --git a/app/views/controllers/observations/form/_details.html.erb b/app/views/controllers/observations/form/_details.html.erb index 348ba95272..6587adacf8 100644 --- a/app/views/controllers/observations/form/_details.html.erb +++ b/app/views/controllers/observations/form/_details.html.erb @@ -1,18 +1,18 @@ -<%# locals: (f:, button_name:, location:, logging_optional:) -%> +<%# locals: (f:, action:, button_name:, location:, logging_optional:, include_naming: false) -%> <%# When and Where (location) section of create_observation form - including location autocomplete, map, lat/long/alt %> + including location autocomplete, map, lat/long/alt, naming %> <% t_s = { - lat: { abbr: :LAT.l, full: :LATITUDE.l }, - lng: { abbr: :LNG.l, full: :LONGITUDE.l }, - alt: { abbr: :ALT.l, full: :ALTITUDE.l } + lat: { abbr: :LAT.l, full: :LATITUDE.l, addon: "º" }, + lng: { abbr: :LNG.l, full: :LONGITUDE.l, addon: "º" }, + alt: { abbr: :ALT.l, full: :ALTITUDE.l, addon: "m" } } %> <%= tag.div(class: "panel-body border-top", id: "observation_details") do %> - <%= tag.div(class: "row mt-3") do %> + <%= tag.div(class: "row") do %> <%= tag.div(class: "col-xs-12 col-md-6") do %> <%= date_select_with_label( @@ -21,127 +21,115 @@ t_s = { <%= tag.div(id: "observation_where") do %> - <%= tag.p do - [tag.strong("#{:WHERE_GROUP.l}:"), - collapse_info_trigger("geolocation_help")].safe_join(" ") - end %> - - - <%= tag.div(class: "row no-gutters", - id: "observation_lat_lng_alt") do %> - <% [:lat, :lng, :alt].each do |key| %> - <%= tag.div(class: "col-xs-4") do - text_field_with_label( - form: f, field: key, class: "mb-0", addon: "º", - label: [ - tag.span("#{t_s[key][:full]}:", class: "d-none d-sm-inline"), - tag.span("#{t_s[key][:abbr]}:", class: "d-inline d-sm-none") - ].safe_join, - data: { map_target: "#{key}Input", action: "map#bufferInputs" } - ) - end %> - <% end %> - <% end %> - - <%= collapse_help_block(nil, id: "geolocation_help") do %> - <%= tag.p(:form_observations_click_point.l) %> - <%= tag.p(:form_observations_lat_long_help.t) %> - <% end %> - - <%= check_box_with_label(form: f, field: :gps_hidden, - label: :form_observations_gps_hidden.l) %> - - <%= render(partial: "shared/form_location_feedback", locals: { button: button_name } ) %> - <%= autocompleter_field( - form: f, field: :place_name, type: :location, + form: f, field: :place_name, type: :location, class: "mb-0", label: [tag.span("#{:WHERE.l}:", class: "unconstrained-label"), tag.span("#{:form_observations_locality_contains.l}:", class: "constrained-label"), tag.span("#{:form_observations_create_locality.l}:", class: "create-label")].safe_join(" "), help: observation_location_help, - hidden: location&.id, + hidden_value: location&.id, hidden_data: { map_target: "locationId", north: location&.north, south: location&.south, east: location&.east, west: location&.west }, - # These are button tooltips: + # These text strings are the button tooltips: create_text: :form_observations_create_locality.l, keep_text: :form_observations_use_locality.l, edit_text: :form_observations_edit_locality.l, # find_text: :form_locations_find_on_map.l, - map_outlet: ".map-outlet", + # Be precise about which map controller to connect to: + map_outlet: "#observation_form", + controller_id: "observation_location_autocompleter", + # Action ok because there's only one form-exif controller on the page, + # and it should only affect this autocompleter: data: { map_target: "placeInput", - action: [ - "map:pointChanged@window->autocompleter#swap", - "form-exif:pointChanged@window->autocompleter#swap", - "map:googlePrimer@window->autocompleter#refreshGooglePrimer" - ].join(" ") + action: "form-exif:pointChanged@window->autocompleter#swap" } ) %> <%= render(partial: "locations/form/bounds_hidden_fields", - locals: { location: @location, target_type: :map }) %> + locals: { location: @location, target_controller: :map }) %> <%= check_box_with_label( form: f, field: :is_collection_location, label: :form_observations_is_collection_location.l, - help: :form_observations_is_collection_location_help.t + help: :form_observations_is_collection_location_help.t, + class: "ml-5 mb-5" ) %> + + <%= check_box_with_label( + form: f, field: :has_geolocation, # field is ignored + label: "#{:GEOLOCATION.l}:", + help: :form_observations_lat_long_help.t, + data: { toggle: "collapse", target: "#observation_geolocation", + form_exif_target: "collapseCheck" }, + aria: { controls: "observation_geolocation", + expanded: @observation.lat } + ) %> + + <%= tag.div(id: "observation_geolocation", + class: class_names("collapse", ("in" if @observation.lat)), + data: { form_exif_target: "collapseFields" }) do %> + + <%= tag.p(:form_observations_click_point.l) %> + + <%= tag.div(class: "row no-gutters", + id: "observation_lat_lng_alt") do %> + <% [:lat, :lng, :alt].each do |key| %> + <%= tag.div(class: "col-xs-4") do + text_field_with_label( + form: f, field: key, class: "mb-0", addon: t_s[key][:addon], + label: [ + tag.span("#{t_s[key][:full]}:", + class: "d-none d-sm-inline"), + tag.span("#{t_s[key][:abbr]}:", + class: "d-inline d-sm-none") + ].safe_join, + data: { map_target: "#{key}Input", + action: "map#bufferInputs" } + ) + end %> + <% end %> + <% end %> + + <%= check_box_with_label( + form: f, field: :gps_hidden, label: :form_observations_gps_hidden.l, + class: "ml-5 mb-5" + ) %> + + <%# collapse_help_block(nil, id: "geolocation_help") do %> + <%# tag.p(:form_observations_lat_long_help.t) %> + <% end %> + + <% if logging_optional %> <%= check_box_with_label( form: f, field: :log_change, checked: "checked", label: :form_observations_log_change.t ) %> <% end %> - <% end %> <% end %> - <%= tag.div(class: "col-xs-12 col-md-6", - id: "observation_geolocation") do %> - <%= tag.div( - "", id: "observation_form_map", - class: "observation-form-map collapse", - data: { indicator_url: asset_path('indicator.gif'), - location_format: User.current_location_format, - map_target: "mapDiv", editable: true, - map_type: "observation" } - ) %> - <%= tag.div(class: "btn-group my-3", role: "group", - data: { map_target: "controlWrap" }) do %> - <%= js_button( - button: [ - link_icon(:globe), - tag.span(:form_observations_open_map.l, class: "map-show mx-2"), - tag.span(:form_observations_hide_map.l, class: "map-hide mx-2") - ].safe_join, - name: "map_toggle", class: "map-toggle", - data: { map_target: "toggleMapBtn", action: "map#toggleMap", - toggle: "collapse", target: "#observation_form_map" }, - aria: { expanded: "false", controls: "observation_form_map" } - ) %> - <%= js_button( - button: :form_observations_clear_map.l, - name: "map_clear", class: "map-clear", - data: { map_target: "mapClearBtn", - action: "map#clearMap form-exif#reenableButtons" } - ) %> - <% end %> + <%= tag.div(class: "col-xs-12 col-md-6") do %> + <%= render( + partial: "shared/form_location_map", + locals: { id: "observation_form_map", map_type: "observation" } + ) %> <% end %> - <% end %> <% end %> diff --git a/app/views/controllers/observations/form/_identify_step.erb b/app/views/controllers/observations/form/_identify_step.erb deleted file mode 100644 index 3ffd41c860..0000000000 --- a/app/views/controllers/observations/form/_identify_step.erb +++ /dev/null @@ -1,38 +0,0 @@ -<%# locals: (f:, action:, button_name:, include_naming:) -%> - -<%= tab_panel(id: "identify") do %> - <%= panel_block(id: "observation_identification", - heading: "#{:IDENTIFICATION.l} + #{:NOTES.l}") do %> - <%= tag.div(class: "row mt-3") do %> - <%= tag.div(class: "col-xs-12 col-sm-6") do %> - <% if include_naming - naming_locals = { - f: f, - action: action, - button_name: button_name, - show_reasons: false, - unfocused: true, - name_help: :form_naming_name_help_leave_blank.t - } %> - <%= render(partial: "observations/namings/fields", - locals: naming_locals) %> - <% end %> - <%= render(partial: "observations/form/specimen_section", - locals: { f:, action: }) %> - <% end %> - <%= tag.div(class: "col-xs-12 col-sm-6") do %> - <%= render(partial: "shared/notes_fields", - locals: { form: f, - fields: @observation.form_notes_parts(@user) }) %> - <% end %> - <% end %> - - <%= tag.div(class: "row mt-5 text-center", id: "step-nav-2") do - [ - tab_link(:BACK.l, id: "record", button: true), - tab_link(:NEXT.l, id: "add_to", button: true), - submit_button(form: f, button: button_name) - ].safe_join(" ") - end %> - <% end %> -<% end %> diff --git a/app/views/controllers/observations/form/_images.html.erb b/app/views/controllers/observations/form/_images.html.erb index ed2b3db2d1..e7676c021a 100644 --- a/app/views/controllers/observations/form/_images.html.erb +++ b/app/views/controllers/observations/form/_images.html.erb @@ -12,13 +12,6 @@ i.e. not observation[good_image_ids]. It's a top-level field. --%> <%= tag.div(id: "observation_images") do %> - <%= hidden_field_tag(:good_image_ids, - @good_images.map { |img| img.id }.join(" "), - data: { form_images_target: "goodImageIds" }) %> - - <%= f.hidden_field(:thumb_image_id, - data: { form_images_target: "thumbImageId" }) %> - <%= tag.div(class: "panel-body border-bottom") do [ tag.h4(:IMAGES.l, class: "panel-title d-inline-block mr-4"), @@ -32,7 +25,12 @@ i.e. not observation[good_image_ids]. It's a top-level field. --%> data: { action: "change->form-images#addSelectedFiles" } ) ].safe_join - end + end, + hidden_field_tag(:good_image_ids, + @good_images.map { |img| img.id }.join(" "), + data: { form_images_target: "goodImageIds" }), + f.hidden_field(:thumb_image_id, + data: { form_images_target: "thumbImageId" }) ].safe_join end %> diff --git a/app/views/controllers/observations/form/_projects.html.erb b/app/views/controllers/observations/form/_projects.html.erb index 63a002ba13..bbc8cbd610 100644 --- a/app/views/controllers/observations/form/_projects.html.erb +++ b/app/views/controllers/observations/form/_projects.html.erb @@ -20,7 +20,7 @@ if @suspect_checked_projects.any? end %> -<%= tag.div(id: "observation_projects") do %> +<%# tag.div(id: "observation_projects") do %> <%= fields_for(:project) do |f_p| %> <% if error_messages.present? || suspect_messages.present? %> @@ -50,15 +50,7 @@ end ) %> <% end %> - <%= tag.div do - [ - tag.strong("#{:PROJECTS.l}:", class: "mr-3"), - collapse_info_trigger("project_help"), - collapse_help_block(nil, id: "project_help") do - :form_observations_project_help.t - end - ].safe_join - end %> + <%= tag.p(:form_observations_project_help.t) %> <%= tag.div(class: "overflow-scroll-checklist") do %> <% @projects.each do |project| %> @@ -71,5 +63,5 @@ end <% end %> <% end %> -<% end %> +<%# end %> diff --git a/app/views/controllers/observations/form/_record_step.erb b/app/views/controllers/observations/form/_record_step.erb deleted file mode 100644 index a49bdf2488..0000000000 --- a/app/views/controllers/observations/form/_record_step.erb +++ /dev/null @@ -1,26 +0,0 @@ -<%# locals: (f:, button_name:, location:, logging_optional:) -%> -<%# NOTE: not using panel_block helper, because it has three panel-body divs %> - -<%= tab_panel(id: "record", active: true) do %> - <%= tag.div( - id: "observation_images_details", class: "panel panel-default" - ) do %> - - <%= tag.div(class: "panel-heading") do - tag.h4("#{:IMAGES.l} + #{:show_observation_details.l}", - class: "panel-title") - end %> - - <%= render(partial: "observations/form/images", locals: { f: f }) %> - - <%= render(partial: "observations/form/details", - locals: { f:, button_name:, location:, logging_optional: }) %> - - <%= tag.div(class: "panel-body mt-4") do - tag.div(class: "text-center", id: "step-nav-1") do - tab_link(:NEXT.l, id: "identify", button: true) - end - end %> - - <% end %> -<% end %> diff --git a/app/views/controllers/observations/form/_species_lists.html.erb b/app/views/controllers/observations/form/_species_lists.html.erb index 1172d3fc85..95651291bd 100644 --- a/app/views/controllers/observations/form/_species_lists.html.erb +++ b/app/views/controllers/observations/form/_species_lists.html.erb @@ -1,17 +1,9 @@ <%# species_list section of create_observation form %> -<%= tag.div(id: "observation_projects") do %> +<%# tag.div(id: "observation_lists") do %> <%= fields_for(:list) do |f_l| %> - <%= tag.div do - [ - tag.strong("#{:SPECIES_LISTS.l}:", class: "mr-3"), - collapse_info_trigger("species_lists_help"), - collapse_help_block(nil, id: "species_lists_help") do - :form_observations_list_help.t - end - ].safe_join - end %> + <%= tag.p(:form_observations_list_help.t) %> <%= tag.div(class: "overflow-scroll-checklist") do %> <% @lists.each do |list| %> @@ -23,4 +15,4 @@ <% end %> <% end %> -<% end %> +<%# end %> diff --git a/app/views/controllers/observations/form/_specimen_section.html.erb b/app/views/controllers/observations/form/_specimen_section.html.erb index 4fae2a6b43..3935a87b98 100644 --- a/app/views/controllers/observations/form/_specimen_section.html.erb +++ b/app/views/controllers/observations/form/_specimen_section.html.erb @@ -3,17 +3,15 @@ <%# Specimen section of create_observation form, for collection_number and herbarium_record. Fields hidden unless box checked. %> -<%= tag.div(id: "observation_specimen_section", - data: { controller: "specimen", - user_pref: (!@user.try(&:hide_specimen_stuff?)) }) do %> +<%= tag.div(id: "observation_specimen_section") do %> - <%= tag.div(class: "mt-3") do %> + <%= tag.div do %> <%= check_box_with_label( - form: f, field: :specimen, + form: f, field: :specimen, class: "mt-0", label: :form_observations_specimen_available.t, help: :form_observations_specimen_available_help.t, - data: { specimen_target: "checkbox", - action: "change->specimen#hideShowFields" } + data: { toggle: "collapse", target: "#specimen_fields" }, + aria: { controls: "specimen_fields", expanded: @observation.specimen } ) %> <% if action == :update %> @@ -26,11 +24,12 @@ herbarium_record. Fields hidden unless box checked. %> <% if action == :create %> - <%= tag.div(id: "specimen_fields", - class: ("hidden" if !@observation.specimen), - data: { specimen_target: "fields" }) do %> - <%= render(partial: "observations/form/collection_number") %> - <%= render(partial: "observations/form/herbarium_record") %> + <%= tag.div( + id: "specimen_fields", + class: class_names("collapse", ("in" if @observation.specimen)) + ) do %> + <%= render(partial: "observations/form/specimen/collection_number") %> + <%= render(partial: "observations/form/specimen/herbarium_record") %> <% end %> <% end %> diff --git a/app/views/controllers/observations/form/images/_camera_info.erb b/app/views/controllers/observations/form/images/_camera_info.erb index a2e108c974..1d5e322454 100644 --- a/app/views/controllers/observations/form/images/_camera_info.erb +++ b/app/views/controllers/observations/form/images/_camera_info.erb @@ -1,24 +1,27 @@ <%# locals: (img_id: "", lat: "", lng: "", alt: "", date: "", file_name: "", file_size: "") -%> +<% +# on new form, lat will not be present until form-exif reads it from the image +gps = tag.span(class: "exif_gps") do + [ + tag.strong("#{:LAT.l}: ") + tag.span(lat, class: "exif_lat"), + tag.strong("#{:LNG.l}: ") + tag.span(lng, class: "exif_lng"), + tag.strong("#{:ALT.l}: ") + tag.span(alt, class: "exif_alt") + " m" + ].safe_join(", ") +end +no_gps = tag.span("#{:image_no_geolocation.l}", class: "exif_no_gps d-none") +%> + <%= -tag.div(class: "well well-sm position-relative", - id: "camera_info_#{img_id}") do +tag.div(class: "well well-sm position-relative", id: "camera_info_#{img_id}") do [ label_tag("camera_info_#{img_id}", :image_camera_info.l), tag.div(class: "form-group") do [ tag.div do - [ - tag.strong("#{:DATE.l}: "), # :form_images_camera_date.t - carousel_exif_to_image_date_button(date: date) - ].safe_join - end, - tag.div do - [ - tag.strong("#{:LAT.l}: ") + tag.span(lat, class: "exif_lat"), - tag.strong("#{:LNG.l}: ") + tag.span(lng, class: "exif_lng"), - tag.strong("#{:ALT.l}: ") + tag.span(alt, class: "exif_alt") + " m" - ].safe_join(", ") + [tag.strong("#{:DATE.l}: "), # :form_images_camera_date.t + carousel_exif_to_image_date_button(date: date)].safe_join end, + tag.div { [gps, no_gps].safe_join }, carousel_transfer_exif_button(has_exif: date.present? || lat.present?) ].safe_join end, diff --git a/app/views/controllers/observations/form/_collection_number.html.erb b/app/views/controllers/observations/form/specimen/_collection_number.html.erb similarity index 100% rename from app/views/controllers/observations/form/_collection_number.html.erb rename to app/views/controllers/observations/form/specimen/_collection_number.html.erb diff --git a/app/views/controllers/observations/form/_herbarium_record.html.erb b/app/views/controllers/observations/form/specimen/_herbarium_record.html.erb similarity index 75% rename from app/views/controllers/observations/form/_herbarium_record.html.erb rename to app/views/controllers/observations/form/specimen/_herbarium_record.html.erb index 26bfc0adec..0851b92772 100644 --- a/app/views/controllers/observations/form/_herbarium_record.html.erb +++ b/app/views/controllers/observations/form/specimen/_herbarium_record.html.erb @@ -4,9 +4,11 @@ <% fields_for(:herbarium_record) do |fhr| %> <%= autocompleter_field( form: fhr, field: :herbarium_name, type: :herbarium, - value: @herbarium_name, hidden: @herbarium_id, + value: @herbarium_name, hidden_value: @herbarium_id, label: "#{:herbarium_record_herbarium_name.t}:", - help: :form_observations_herbarium_record_help.t + help: :form_observations_herbarium_record_help.t, + create_text: :create_herbarium.l, create: "herbarium", + create_path: new_herbarium_path ) %> <%= text_field_with_label( form: fhr, field: :accession_number, value: @accession_number, diff --git a/app/views/controllers/observations/images/edit.html.erb b/app/views/controllers/observations/images/edit.html.erb index 71b739f452..7f040c481f 100644 --- a/app/views/controllers/observations/images/edit.html.erb +++ b/app/views/controllers/observations/images/edit.html.erb @@ -13,11 +13,11 @@ form_action = { controller: "/observations/images",
<%= form_with(model: @image, url: form_action, method: :put) do |f| %> - <%= submit_button(form: f, button: :SAVE_EDITS.l, center: true) %> - <%= render(partial: "observations/images/form/fields_for_images", locals: { f: f, leave_out_original_file_name: false }) %> + <%= submit_button(form: f, button: :SAVE_EDITS.l, center: true) %> + <% if @projects.any? %>
<%= content_tag(:p, :PROJECTS.t + ":", class: "font-weight-bold") %> diff --git a/app/views/controllers/observations/images/form/_fields_for_upload.html.erb b/app/views/controllers/observations/images/form/_fields_for_upload.html.erb deleted file mode 100644 index f7d8e1f688..0000000000 --- a/app/views/controllers/observations/images/form/_fields_for_upload.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -
- <%= fields_for(:upload) do |f_u| %> - <%= f_u.label(:image1, :image_add_image.t + " 1:") %> - <%= custom_file_field(:upload, :image1) %> - (<%= :image_add_default.t %>)
- <%= f_u.label(:image2, :image_add_image.t + " 2:") %> - <%= custom_file_field(:upload, :image2) %> - (<%= :image_add_optional.t %>)
- <%= f_u.label(:image3, :image_add_image.t + " 3:") %> - <%= custom_file_field(:upload, :image3) %> - (<%= :image_add_optional.t %>)
- <%= f_u.label(:image4, :image_add_image.t + " 4:") %> - <%= custom_file_field(:upload, :image4) %> - (<%= :image_add_optional.t %>)
- <% end %> -
diff --git a/app/views/controllers/observations/images/new.html.erb b/app/views/controllers/observations/images/new.html.erb deleted file mode 100644 index 60c190252f..0000000000 --- a/app/views/controllers/observations/images/new.html.erb +++ /dev/null @@ -1,31 +0,0 @@ -<% -add_page_title(:image_add_title.t(name: @observation.unique_format_name)) - -add_tab_set(observation_images_new_tabs(obs: @observation)) -%> - -<%= form_with(model: @image, - url: add_query_param(action: :create, id: @observation.id), - html: { multipart: true }) do |f| %> - - <%= fields_for(:observation) do |f_o| %> - <%= f_o.hidden_field(:id) %> - <% end %> - -
<%= :image_add_warning.tp %>
- - <%= submit_button(form: f, button: :image_add_upload.l, center: true) %> - - <%= render(partial: "observations/images/form/fields_for_upload") %> - - <%= render(partial: "observations/images/form/fields_for_images", - locals: { f: f, leave_out_original_file_name: true }) %> - - <% if @projects.any? %> - <%= render(partial: "observations/images/form/project", - collection: @projects) %> - <% end %> - - <%= submit_button(form: f, button: :image_add_upload.l, center: true) %> - -<% end %> diff --git a/app/views/controllers/observations/images/remove.html.erb b/app/views/controllers/observations/images/remove.html.erb deleted file mode 100644 index a1366cf241..0000000000 --- a/app/views/controllers/observations/images/remove.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<% -# REMOVE IMAGES FROM OBSERVATION -# NOTE: Move template to observations/images/edit -# Observations::ImagesController#edit -# Move partial to shared/remove_images, send target_class as local -# Note totally general title and tabs based on @object target_class -add_page_title(:image_remove_title.t(name: @object.unique_format_name)) - -add_tab_set(observation_images_remove_tabs(obj: @object)) - -@container = :full -form_action = { controller: "/observations/images", - action: :detach, - id: @object.id, - q: get_query_param } -%> - -<%= render(partial: "shared/images_to_remove", - locals: { form_action: form_action }) %> diff --git a/app/views/controllers/observations/namings/_fields.erb b/app/views/controllers/observations/namings/_fields.erb index 3c1ac6ee3c..bc1b156e10 100644 --- a/app/views/controllers/observations/namings/_fields.erb +++ b/app/views/controllers/observations/namings/_fields.erb @@ -1,18 +1,17 @@ +<%# locals: (create:, button_name:, show_reasons: false, unfocused: false, name_help: :form_naming_name_help.t, context: "blank" ) -%> + <% # This is included by obs form, naming new/edit form + lightbox identifier - -unfocused ||= false -focus_on_name = !unfocused && (button_name != :CREATE.l || @given_name.empty?) -focus_on_vote = !unfocused && (button_name == :CREATE.l && @given_name.present?) +focus_on_name = !unfocused && (!create || @given_name.empty?) +focus_on_vote = !unfocused && (create && @given_name.present?) feedback_locals = { - f: f, button_name: button_name, given_name: @given_name, + names: @names, valid_names: @valid_names, suggest_corrections: @suggest_corrections, - parent_deprecated: @parent_deprecated, - names: @names + parent_deprecated: @parent_deprecated } menu = unless @vote&.value&.nonzero? Vote.opinion_menu @@ -20,23 +19,19 @@ menu = unless @vote&.value&.nonzero? 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 +select_opts = { include_blank: create } %> -<%= -[ - tag.div do - render(partial: "shared/form_name_feedback", - locals: feedback_locals) if @given_name.present? - end, - fields_for(:naming) do |f_n| +<% if @given_name.present? %> + <%= tag.div do + render(partial: "shared/form_name_feedback", locals: feedback_locals) + end %> +<% end %> + +<%= fields_for(:naming) do |f_n| %> + <% vote_reasons = tag.div(data: { autocompleter_target: "collapseFields" }, + class: ("collapse" if context == "blank")) do [ - autocompleter_field( - form: f_n, field: :name, type: :name, label: "#{:WHAT.t}:", - value: @given_name, autofocus: focus_on_name, help: name_help - ), f_n.fields_for(:vote) do |f_v| select_with_label(form: f_v, field: :value, options: confidences, select_opts: select_opts, @@ -47,7 +42,12 @@ name_help ||= :form_naming_name_help.t naming_form_reasons_fields(f_r, @reasons) end ].safe_join - end, - hidden_field_tag(:context, context) -].safe_join -%> + end %> + <%= autocompleter_field( + form: f_n, field: :name, type: :name, label: "#{:WHAT.t}:", + value: @given_name, autofocus: focus_on_name, help: name_help, + append: vote_reasons + ) %> +<% end %> + +<%= hidden_field_tag(:context, context) %> diff --git a/app/views/controllers/observations/namings/_form.erb b/app/views/controllers/observations/namings/_form.erb index 05a89d8eb0..2e860b9ff7 100644 --- a/app/views/controllers/observations/namings/_form.erb +++ b/app/views/controllers/observations/namings/_form.erb @@ -1,9 +1,12 @@ +<%# locals: (local: false, form_locals: {}) -%> + <% # Fields must be separate because they're included in the obs form too. # HTML ID's may seem overly precise, but there may be more than one of # these forms open on a page if modal. IDs must be unique. case action_name when "new", "create" + create = true button_name = :CREATE.l method = :post id = "obs_#{@observation.id}_naming_form" @@ -11,6 +14,7 @@ when "new", "create" approved_name: @given_name, q: get_query_param) when "edit", "update" + create = false button_name = :SAVE_EDITS.l method = :patch id = "obs_#{@observation.id}_naming_#{@naming.id}_form" @@ -20,23 +24,23 @@ when "edit", "update" q: get_query_param) end +# `local` true means do not send via Turbo. form_args = { model: @naming, url: url, method: method, id: id } -if local_assigns[:local] == true +if local form_args = form_args.merge({ local: true }) else form_args = form_args.deep_merge({ data: { turbo: true } }) end -# Note: the form needs local_assigns[:form_locals]. -# I can't find where show_reasons is ever false - AN 20230801 -form_locals = local_assigns[:form_locals] || {} +# `naming_locals`: modal forms can accept a `form_locals` local. The controller +# may send `context` (i.e. where the form appears), which defaults to "blank". +# `show_reasons` is false on the obs form, true on the naming form. +naming_locals = { create:, button_name:, show_reasons: true, context: "blank" }.merge(form_locals) %> <%= form_with(**form_args) do |f| %> + <%= render(partial: "observations/namings/fields", locals: naming_locals) %> <%= submit_button(form: f, button: button_name, center: true) %> - <%= render(partial: "observations/namings/fields", - locals: { f: f, button_name: button_name }.merge(form_locals)) %> - <% end # form %> diff --git a/app/views/controllers/observations/namings/edit.html.erb b/app/views/controllers/observations/namings/edit.html.erb index 715ab3e8c9..251b1e85bf 100644 --- a/app/views/controllers/observations/namings/edit.html.erb +++ b/app/views/controllers/observations/namings/edit.html.erb @@ -13,8 +13,8 @@ add_tab_set(naming_form_edit_tabs(obs: @observation))
<%= render(partial: "observations/namings/form", - locals: { action: :update, show_reasons: true, - local: true }) %> + locals: { local: true, + form_locals: { show_reasons: true } }) %>
diff --git a/app/views/controllers/observations/namings/new.html.erb b/app/views/controllers/observations/namings/new.html.erb index c4ff406d8d..11016e6299 100644 --- a/app/views/controllers/observations/namings/new.html.erb +++ b/app/views/controllers/observations/namings/new.html.erb @@ -13,8 +13,8 @@ add_tab_set(naming_form_new_tabs(obs: @observation))
<%= render(partial: "observations/namings/form", - locals: { action: :create, show_reasons: true, - local: true }) %> + locals: { local: true, + form_locals: { show_reasons: true } }) %>
diff --git a/app/views/controllers/observations/show.html.erb b/app/views/controllers/observations/show.html.erb index 767c1f46d6..0d56ab8dc6 100644 --- a/app/views/controllers/observations/show.html.erb +++ b/app/views/controllers/observations/show.html.erb @@ -4,6 +4,10 @@ add_page_title(show_obs_title(obs: @observation, owner_naming: @owner_naming)) add_owner_naming(@owner_naming) add_pager_for(@observation) add_interest_icons(@user, @observation) +content_for(:edit_icons) do + tag.span(obs_change_links(@observation), + class: "h4 ml-3 d-inline-block text-nowrap") +end # add_tab_set(show_observation_tabs(obs: @observation, user: @user)) @container = :double diff --git a/app/views/controllers/shared/_form_location_map.erb b/app/views/controllers/shared/_form_location_map.erb new file mode 100644 index 0000000000..1831402983 --- /dev/null +++ b/app/views/controllers/shared/_form_location_map.erb @@ -0,0 +1,36 @@ +<%# locals: (id: "", map_type: "location") -%> + +<%# +The stimulus controller for this map should be +on an ancestor element that also contains the inputs +%> + +<%= tag.div( + "", id:, + class: "form-map collapse", + data: { indicator_url: asset_path('indicator.gif'), + location_format: User.current_location_format, + map_target: "mapDiv", editable: true, + map_type: } +) %> +<%= tag.div(class: "btn-group my-3", role: "group", + data: { map_target: "controlWrap" }) do %> + <%= js_button( + button: [ + link_icon(:globe), + tag.span(:form_observations_open_map.l, class: "map-show mx-2"), + tag.span(:form_observations_hide_map.l, class: "map-hide mx-2") + ].safe_join, + name: "map_toggle", class: "map-toggle", + data: { map_target: "toggleMapBtn", + action: "map#toggleMap form-exif#showFields", + toggle: "collapse", target: "##{id}" }, + aria: { expanded: "false", controls: id } + ) %> + <%= js_button( + button: :form_observations_clear_map.l, + name: "map_clear", class: "map-clear", + data: { map_target: "mapClearBtn", + action: "map#clearMap form-exif#reenableButtons" } + ) %> +<% end %> diff --git a/app/views/controllers/shared/_form_name_feedback.erb b/app/views/controllers/shared/_form_name_feedback.erb index 5ae008500f..be83f5e852 100644 --- a/app/views/controllers/shared/_form_name_feedback.erb +++ b/app/views/controllers/shared/_form_name_feedback.erb @@ -1,6 +1,8 @@ +<%# locals: (given_name:, button_name:, names:, valid_names:, suggest_corrections:, parent_deprecated:) -%> + <%# -Handling deprecated, new & multiple Names -Used by Name and Naming create and edit pages +Feedback if the user entered deprecated, new or multiple Names +Used by Observation, Name and Naming create and edit pages Does two things: Describes the issue - Deprecated, Parent Deprecated, or Not Recognized Adds Help how to proceed - depends on issue and the button @@ -12,18 +14,11 @@ names - Name(s) corresponding to given_name valid_names - Name(s) that are valid synonyms suggest_corrections - t/f whether to suggest correction(s) parent_deprecated - t/f - -TODO: -Differentiate warning (valid_names) from error (!valid_names) -A conditional to watch is `if @params[:given_name].present?`. -Also, views/observations/namings/fields is doing too much work, -the multiple local_assigns for this partial should be set upstream. %> <%= +##### Warnings ##### if valid_names - ##### Warnings ##### - tag.div(class: "alert alert-warning", id: "name_messages") do concat(tag.div do if suggest_corrections || names.blank? diff --git a/app/views/controllers/shared/_modal_form_reload.erb b/app/views/controllers/shared/_modal_form_reload.erb index 63f22cb15c..5f873dc9ae 100644 --- a/app/views/controllers/shared/_modal_form_reload.erb +++ b/app/views/controllers/shared/_modal_form_reload.erb @@ -1,7 +1,8 @@ +<%# locals: (identifier:, form:, form_locals: {}) -%> <% # What to do if a form submit doesn't succeed -# Takes locals: { identifier:, form:, form_locals: } -form_locals ||= {} +# Takes locals: { identifier:, form:, form_locals: {} } +# form_locals ||= {} %> <%= turbo_stream.update("modal_#{identifier}_flash") do @@ -9,5 +10,5 @@ form_locals ||= {} end %> <%= turbo_stream.replace("#{identifier}_form") do - render(partial: form, locals: { format: :turbo_stream }.merge(form_locals)) + render(partial: form, locals: { local: false, form_locals: }) end %> diff --git a/app/views/controllers/shared/_notes_fields.html.erb b/app/views/controllers/shared/_notes_fields.html.erb index baeeb2d1df..78755e2197 100644 --- a/app/views/controllers/shared/_notes_fields.html.erb +++ b/app/views/controllers/shared/_notes_fields.html.erb @@ -1,6 +1,7 @@ -<%# Notes section of any form %> +<%# locals: (form:, fields:) -%> <% +# Notes section of any form # Users may have custom notes "parts". This prints a single large textarea and # textile help block if there's only one notes part. Otherwise, it prints # smaller textareas and a general textile help link. @@ -24,7 +25,7 @@ end indent = form.object_name == "observation" ? "" : "ml-5" %> -<%= tag.div(id: "#{form.object_name}_notes") do %> +<%= tag.div(id: "#{form.object_name}_notes_fields") do %> <%= general_help %> <%= tag.div(class: indent) do %> <%= form.fields_for(:notes) do |f_n| %> diff --git a/config/initializers/turbo_stream_actions.rb b/config/initializers/turbo_stream_actions.rb index fa3d929d6a..0d06e8c33e 100644 --- a/config/initializers/turbo_stream_actions.rb +++ b/config/initializers/turbo_stream_actions.rb @@ -1,11 +1,23 @@ # frozen_string_literal: true -# https://stackoverflow.com/questions/77421369/turbo-response-to-render-javascript-alert/77434363#77434363 +# https://stackoverflow.com/a/77434363/3357635 # this is optional but makes it much cleaner module CustomTurboStreamActions def close_modal(id) action(:close_modal, "#", id) end + def update_input(id, value) + action(:update_input, id, value) + end + + def add_class(id, class_name) + action(:add_class, id, class_name) + end + + def remove_class(id, class_name) + action(:remove_class, id, class_name) + end + ::Turbo::Streams::TagBuilder.include(self) end diff --git a/config/locales/en.txt b/config/locales/en.txt index cc21359d8d..328f8e2e00 100644 --- a/config/locales/en.txt +++ b/config/locales/en.txt @@ -710,6 +710,8 @@ close: close CREATE: Create create: create + DEFINE: Define + define: define DELETE: Delete delete: delete # deprecate a name @@ -783,16 +785,16 @@ destroyed: destroyed FILTERED: Filtered filtered: filtered + IGNORING: Ignoring + ignoring: ignoring MODIFIED: Modified modified: modified + NEW: New + new: new OPTIONAL: Optional optional: optional REQUIRED: Required required: required - WATCHING: Watching - watching: watching - IGNORING: Ignoring - ignoring: ignoring TRACKING: Tracking tracking: tracking UPDATED: Updated @@ -801,6 +803,8 @@ updated_at: updated at VIEWED: Viewed viewed: viewed + WATCHING: Watching + watching: watching # Common adjectives describing species names. (Note: "approved" seems to be # interchangeable with "accepted" in our site -- both indicate that the @@ -1683,10 +1687,10 @@ form_observations_open_map: "[:SHOW] [:map]" form_observations_hide_map: "[:HIDE] [:map]" form_observations_clear_map: "[:CLEAR] [:location]" - form_observations_click_point: "Tip: select a locality to center the map, then click a point on the map to set a location." + form_observations_click_point: "Tip: select a locality to center the map, then click a point on the map to set a location. If you're defining a new locality, click \"Use these bounds\" so you can select a point within." form_observations_locality_contains: "[:LOCALITIES] containing this point" - form_observations_create_locality: "[:CREATE] [:locality]" - form_observations_use_locality: "[:USE] this [:locality]" + form_observations_create_locality: "[:NEW] [:locality]" + form_observations_use_locality: "Use these bounds and select a point within" form_observations_edit_locality: "[:EDIT] this [:locality]" form_observations_notes_help: Please include any additional information you can think of about this observation that isn't clear from the photographs, e.g., habitat, substrate or nearby trees; distinctive texture, scent, taste, staining or bruising; results of chemical or microscopic analyses, etc. form_observations_remove_image_confirm: Are you sure you want to remove this image? This will only remove this image from this observation. If it is attached to other observations, it will remain attached to them. @@ -1755,6 +1759,7 @@ image_exif_copied: Info copied image_use_exif: Use this info image_camera_info: Camera info + image_no_geolocation: No geolocation found image_file_name: File name image_file_size: File size @@ -2620,8 +2625,8 @@ show_herbarium_request_sent: Request has been sent to admins. We'll get back to you as soon as possible. # herbaria/create - create_herbarium: Create New Fungarium - create_herbarium_title: Create New Fungarium + create_herbarium: New Fungarium + create_herbarium_title: New Fungarium create_herbarium_personal: Check this box if this is your personal fungarium create_herbarium_personal_help: Each user can have one personal fungarium that they have curator privileges for. By default it is called "[name]", but you can change that name to anything you like. create_herbarium_code: Standard Abbreviation @@ -3123,7 +3128,7 @@ # info/textile sandbox_enter: Enter Textile you wish to test here - sandbox_header: This page let's you play with the textile markup language. Click 'Test' to see what your markup looks like. You will need to copy the results from this page to where ever you want to use it. + sandbox_header: This page lets you play with the textile markup language. Click 'Test' to see what your markup looks like. You will need to copy the results from this page to wherever you want to use it. sandbox_link_hobix_textile_reference: hobix Textile Reference sandbox_link_hobix_textile_cheatsheet: hobix cheatsheet (Textile Quick Reference) sandbox_link_textile_language_website: Textile Language website diff --git a/config/routes.rb b/config/routes.rb index 31f312c3e3..8db6dca36b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -602,18 +602,10 @@ def route_actions_hash as: "new_question_for") post("emails", to: "observations/emails#create", as: "send_question_for") - get("images/new", to: "observations/images#new", - as: "new_image_for") - post("images", to: "observations/images#create", - as: "create_image_for") get("images/reuse", to: "observations/images#reuse", as: "reuse_images_for") post("images/attach", to: "observations/images#attach", as: "attach_image_to") - get("images/remove", to: "observations/images#remove", - as: "remove_images_from") - put("images(/:image_id)/detach", to: "observations/images#detach", - as: "detach_images_from") end collection do diff --git a/test/controllers/admin/blocked_ips_controller_test.rb b/test/controllers/admin/blocked_ips_controller_test.rb index 85bae39ccf..3e91205c9c 100644 --- a/test/controllers/admin/blocked_ips_controller_test.rb +++ b/test/controllers/admin/blocked_ips_controller_test.rb @@ -43,6 +43,15 @@ def test_blocked_ips assert(time < File.mtime(MO.blocked_ips_file)) IpStats.reset! assert_false(IpStats.blocked?(new_ip)) + + time = 1.minute.ago + File.utime(time.to_time, time.to_time, MO.blocked_ips_file) + patch(:update, params: { add_bad: " #{new_ip} " }) + assert_no_flash + assert(time < File.mtime(MO.blocked_ips_file)) + IpStats.reset! + assert_true(IpStats.blocked?(new_ip), + "It should ignore leading & trailing spaces in ip addr") end end end diff --git a/test/controllers/observations/images_controller_test.rb b/test/controllers/observations/images_controller_test.rb index ac489adc64..9231413b8f 100644 --- a/test/controllers/observations/images_controller_test.rb +++ b/test/controllers/observations/images_controller_test.rb @@ -5,130 +5,6 @@ # tests of Images controller module Observations class ImagesControllerTest < FunctionalTestCase - def test_add_image_to_obs_not_yours - obs = observations(:coprinus_comatus_obs) - requires_login(:new, id: obs.id) - # qr = QueryRecord.last.id.alphabetize - assert_form_action(action: :create, id: obs.id) - # Check that image cannot be added to an observation the user doesn't own. - obs_no_own = observations(:minimal_unknown_obs) - post(:create, params: { id: obs_no_own.id }) - assert_redirected_to(permanent_observation_path(obs_no_own.id)) - end - - def test_add_images_empty - login("rolf") - obs = observations(:coprinus_comatus_obs) - post(:create, params: { id: obs.id }) - assert_flash_text(/no changes/i) - end - - def test_upload_image - setup_image_dirs - obs = observations(:coprinus_comatus_obs) - updated_at = obs.updated_at - proj = projects(:bolete_project) - proj.observations << obs - img_count = obs.images.size - assert(img_count.positive?) - assert(obs.thumb_image) - file = Rack::Test::UploadedFile.new( - Rails.root.join("test/images/Coprinus_comatus.jpg"), "image/jpeg" - ) - params = { - id: obs.id, - image: { - "when(1i)" => "2007", - "when(2i)" => "3", - "when(3i)" => "29", - copyright_holder: "Douglas Smith", - notes: "Some notes." - }, - upload: { - image1: file, - image2: "", - image3: "", - image4: "" - }, - project: { - # This is a good test: Rolf doesn't belong to the Bolete project, - # but we still want this image to attach to that project by default, - # because the *observation* is attached to that project. - "id_#{proj.id}" => "1" - } - } - File.stub(:rename, false) do - login("rolf", "testpassword") - post(:create, params: params) - end - assert_equal(20, rolf.reload.contribution) - assert(obs.reload.images.size == (img_count + 1)) - assert(updated_at != obs.updated_at) - message = :runtime_image_uploaded_image.t( - name: "##{obs.images.last.id}" - ) - assert_flash_text(/#{message}/) - img = Image.last - assert_obj_arrays_equal([obs], img.observations) - assert_obj_arrays_equal([proj], img.projects) - assert_false(obs.gps_hidden) - assert_false(img.gps_stripped) - end - - def test_add_images_strip_gps - login("rolf") - obs = observations(:coprinus_comatus_obs) - obs.update_attribute(:gps_hidden, true) - - setup_image_dirs - fixture = "#{MO.root}/test/images/geotagged.jpg" - fixture = Rack::Test::UploadedFile.new(fixture, "image/jpeg") - - post(:create, - params: { id: obs.id, - image: { "when(1i)" => "2007", - "when(2i)" => "3", - "when(3i)" => "29", - copyright_holder: "Douglas Smith", - notes: "Some notes." }, - upload: { image1: fixture, - image2: "", - image3: "", - image4: "" } }) - - img = Image.last - assert_true(img.gps_stripped) - end - - def test_add_images_process_image_fail - login("rolf") - obs = observations(:coprinus_comatus_obs) - setup_image_dirs - fixture = "#{MO.root}/test/images/geotagged.jpg" - fixture = Rack::Test::UploadedFile.new(fixture, "image/jpeg") - params = { id: obs.id, - image: { "when(1i)" => "2007", - "when(2i)" => "3", - "when(3i)" => "29", - copyright_holder: "Douglas Smith", - notes: "Some notes." }, - upload: { image1: fixture, - image2: "", - image3: "", - image4: "" } } - image = images(:in_situ_image) - - # simulate process_image failure - image.stub(:process_image, false) do - Image.stub(:new, image) do - post(:create, params: params) - end - end - - assert_flash_error("image.process_image failure should cause flash error") - assert_redirected_to(permanent_observation_path(obs.id)) - end - def test_edit_image image = images(:connected_coprinus_comatus_image) params = { "id" => image.id.to_s } @@ -273,28 +149,18 @@ def test_project_checkboxes # so there is no way to test init_project_vars_for_reload(). login("rolf") - get(:new, params: { id: obs1.id }) - assert_response(:redirect) - get(:new, params: { id: obs2.id }) - assert_response(:redirect) get(:edit, params: { id: img1.id }) assert_response(:redirect) get(:edit, params: { id: img2.id }) assert_project_checks(proj1.id => :unchecked, proj2.id => :no_field) login("mary") - get(:new, params: { id: obs1.id }) - assert_project_checks(proj1.id => :unchecked, proj2.id => :unchecked) - get(:new, params: { id: obs2.id }) - assert_project_checks(proj1.id => :unchecked, proj2.id => :checked) get(:edit, params: { id: img1.id }) assert_project_checks(proj1.id => :unchecked, proj2.id => :checked) get(:edit, params: { id: img2.id }) assert_response(:redirect) login("dick") - get(:new, params: { id: obs2.id }) - assert_project_checks(proj1.id => :no_field, proj2.id => :checked) get(:edit, params: { id: img1.id }) assert_project_checks(proj1.id => :no_field, proj2.id => :checked) get(:edit, params: { id: img2.id }) @@ -423,91 +289,5 @@ def test_reuse_image_strip_gps_worked assert_not_equal(File.size(fixture), File.size(img.full_filepath("orig"))) end - - def test_remove_images_page_access - obs = observations(:coprinus_comatus_obs) - params = { id: obs.id } - assert_equal("rolf", obs.user.login) - requires_user( - :remove, - { controller: "/observations", action: :show, id: obs.id }, - params - ) - assert_form_action(action: :detach, id: obs.id) - end - - def test_remove_images - obs = observations(:detailed_unknown_obs) - keep = images(:turned_over_image) - remove = images(:in_situ_image) - assert(obs.images.member?(keep)) - assert(obs.images.member?(remove)) - assert_equal(remove.id, obs.thumb_image_id) - - selected = {} - selected[keep.id.to_s] = "no" - selected[remove.id.to_s] = "yes" - params = { - id: obs.id.to_s, - selected: selected - } - put_requires_login(:detach, params, "mary") - assert_redirected_to(permanent_observation_path(obs.id)) - assert_equal(10, mary.reload.contribution) - assert(obs.reload.images.member?(keep)) - assert_not(obs.images.member?(remove)) - assert_equal(keep.id, obs.thumb_image_id) - - selected = {} - selected[keep.id.to_s] = "yes" - params = { - id: obs.id.to_s, - selected: selected - } - put(:detach, params: params) - assert_redirected_to(permanent_observation_path(obs.id)) - # Observation gets downgraded to 1 point because it no longer has images. - # assert_equal(1, mary.reload.contribution) - assert_equal(10, mary.reload.contribution) - assert_not(obs.reload.images.member?(keep)) - assert_nil(obs.thumb_image_id) - end - - def test_remove_images2 - obs = observations(:detailed_unknown_obs) - images = obs.images - assert(images.size > 1, - "Use Observation fixture with multiple images for best coverage") - user = obs.user - selected = images.ids.each_with_object({}) do |item, hash| - # "img_id" => "yes" (yes means detach that image˝) - hash[item.to_s] = "yes" - end - params = { id: obs.id, selected: selected } - - login(user.login) - put(:detach, params: params) - - assert_empty(obs.reload.images) - end - - def test_remove_image_by_id - obs = observations(:detailed_unknown_obs) - images = obs.images - assert(images.size > 1, - "Use Observation fixture with multiple images for best coverage") - user = obs.user - login(user.login) - - params = { id: obs.id, image_id: "bad_id" } - # It will just skip a bad image id. - put(:detach, params: params) - assert_equal(obs.images, obs.reload.images) - - params[:image_id] = images.first.id - put(:detach, params: params) - - assert_equal(obs.images.except(obs.images.first), obs.reload.images) - end end end diff --git a/test/controllers/observations_controller/observations_controller_show_test.rb b/test/controllers/observations_controller/observations_controller_show_test.rb index 2dcf866bdb..469cfa7a2c 100644 --- a/test/controllers/observations_controller/observations_controller_show_test.rb +++ b/test/controllers/observations_controller/observations_controller_show_test.rb @@ -298,10 +298,6 @@ def test_show_observation_edit_links get(:show, params: { id: obs.id }) assert_select("a:match('href',?)", edit_observation_path(obs.id), count: 0) assert_select(".destroy_observation_link_#{obs.id}", count: 0) - assert_select("a:match('href',?)", - new_image_for_observation_path(obs.id), count: 0) - assert_select("a:match('href',?)", - remove_images_from_observation_path(obs.id), count: 0) assert_select("a:match('href',?)", reuse_images_for_observation_path(obs.id), count: 0) get(:edit, params: { id: obs.id }) @@ -314,10 +310,6 @@ def test_show_observation_edit_links assert_select("a[href=?]", edit_observation_path(obs.id), minimum: 1) # Destroy button is in a form, not a link_to assert_select(".destroy_observation_link_#{obs.id}", minimum: 1) - assert_select("a[href=?]", - new_image_for_observation_path(obs.id), minimum: 1) - assert_select("a[href=?]", - remove_images_from_observation_path(obs.id), minimum: 1) assert_select("a[href=?]", reuse_images_for_observation_path(obs.id), minimum: 1) get(:edit, params: { id: obs.id }) @@ -328,10 +320,6 @@ def test_show_observation_edit_links assert_select("a[href=?]", edit_observation_path(obs.id), minimum: 1) # Destroy button is in a form, not a link_to assert_select(".destroy_observation_link_#{obs.id}", minimum: 1) - assert_select("a[href=?]", - new_image_for_observation_path(obs.id), minimum: 1) - assert_select("a[href=?]", - remove_images_from_observation_path(obs.id), minimum: 1) assert_select("a[href=?]", reuse_images_for_observation_path(obs.id), minimum: 1) get(:edit, params: { id: obs.id }) diff --git a/test/system/herbarium_form_system_test.rb b/test/system/herbarium_form_system_test.rb new file mode 100644 index 0000000000..3f5dedd452 --- /dev/null +++ b/test/system/herbarium_form_system_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require("application_system_test_case") + +class HerbariumFormSystemTest < ApplicationSystemTestCase + def test_create_fungarium_new_location + # browser = page.driver.browser + rolf = users("rolf") + login!(rolf) + + visit("/herbaria/new") + assert_selector("body.herbaria__new") + create_herbarium_with_new_location + + # assert_no_selector("#modal_herbarium") + assert_selector("body.herbaria__show") + assert_selector("h1", text: "Herbarium des Cévennes (CEV)") + assert_selector("#herbarium_location", + text: "Génolhac, Gard, Occitanie, France") + end + + def test_observation_form_create_fungarium_new_location + rolf = users("rolf") + login!(rolf) + + visit("/observations/new") + assert_selector("body.observations__new") + + assert_selector("#observation_naming_specimen") + scroll_to(find("#observation_naming_specimen"), align: :top) + check("observation_specimen") + assert_selector("#herbarium_record_herbarium_name") + assert_selector(".create-link", text: :create_herbarium.l) + click_link(:create_herbarium.l) + + assert_selector("#modal_herbarium") + create_herbarium_with_new_location + + assert_no_selector("#modal_herbarium") + assert_field("herbarium_record_herbarium_name", + with: "Herbarium des Cévennes") + end + + def create_herbarium_with_new_location + assert_selector("#herbarium_place_name") + fill_in("herbarium_place_name", with: "genohlac gard france") + assert_link(:form_observations_create_locality.l) + click_link(:form_observations_create_locality.l) + + assert_selector("#herbarium_place_name.geocoded") + assert_field("herbarium_place_name", + with: "Génolhac, Gard, Occitanie, France") + + assert_field("herbarium_location_id", with: "-1", type: :hidden) + assert_field("location_north", with: "44.3726", type: :hidden) + assert_field("location_east", with: "3.985", type: :hidden) + assert_field("location_south", with: "44.3055", type: :hidden) + assert_field("location_west", with: "3.9113", type: :hidden) + assert_field("location_high", with: "1388.2098", type: :hidden) + assert_field("location_low", with: "287.8201", type: :hidden) + + within("#herbarium_form") do + fill_in("herbarium_name", with: "Herbarium des Cévennes") + fill_in("herbarium_code", with: "CEV") + click_commit + end + end +end diff --git a/test/system/location_form_system_test.rb b/test/system/location_form_system_test.rb index c9df241d3b..49225adfec 100644 --- a/test/system/location_form_system_test.rb +++ b/test/system/location_form_system_test.rb @@ -4,7 +4,6 @@ class LocationFormSystemTest < ApplicationSystemTestCase def test_format_new_location_name - skip("This test is inconsistent when run in CI") # browser = page.driver.browser rolf = users("rolf") login!(rolf) diff --git a/test/system/observation_comment_system_test.rb b/test/system/observation_comment_system_test.rb index 5142da3731..079189f035 100644 --- a/test/system/observation_comment_system_test.rb +++ b/test/system/observation_comment_system_test.rb @@ -8,7 +8,6 @@ class ObservationCommentSystemTest < ApplicationSystemTestCase # Katrina edits the comment, rolf should get the change immediately. # Katrina deletes the comment, rolf should get the deletion immediately. def test_add_and_edit_comment - skip("This test is inconsistent when run in CI") rolf = users("rolf") katrina = users("katrina") obs = observations(:coprinus_comatus_obs) diff --git a/test/system/observation_form_system_test.rb b/test/system/observation_form_system_test.rb index 8b71e12d34..3ca5ced922 100644 --- a/test/system/observation_form_system_test.rb +++ b/test/system/observation_form_system_test.rb @@ -27,9 +27,8 @@ def test_create_minimal_observation assert_field("observation_place_name", with: locations.first.name) # Move to the next step, Identification - step_nav_1 = find("#step-nav-1") - scroll_to(step_nav_1, align: :top) - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) fill_in("naming_name", with: "Elfin saddle") # don't wait for the autocompleter - we know it's an elfin saddle! @@ -50,9 +49,8 @@ def test_create_minimal_observation assert_field("observation_place_name", with: locations.first.name) # Move to the next step, Identification - step_nav_1 = find("#step-nav-1") - scroll_to(step_nav_1, align: :top) - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) assert_selector("#name_messages", text: "MO does not recognize the name") fill_in("naming_name", with: "Coprinus com") @@ -96,15 +94,17 @@ def test_trying_to_create_duplicate_location_just_uses_existing_location assert_selector("[data-type='location']") find(id: "observation_place_name").trigger("click") # This should make the "create_locality" button appear. + # It works fine in headless mode. + assert_selector(".create-button span", + text: /#{:form_observations_create_locality.l}/) click_on(:form_observations_create_locality.l) assert_selector("[data-type='location_google']") assert_field("observation_place_name", with: last_obs.where) assert_field("observation_location_id", with: "", type: :hidden) # Move to the next step, Identification - step_nav_1 = find("#step-nav-1") - scroll_to(step_nav_1, align: :top) - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) within("#observation_form") { click_commit } @@ -119,7 +119,6 @@ def test_trying_to_create_duplicate_location_just_uses_existing_location end def test_autofill_location_from_geotagged_image_nothing_matches - skip("This test is inconsistent when run in CI") setup_image_dirs # in general_extensions login!(katrina) @@ -148,7 +147,9 @@ def test_autofill_location_from_geotagged_image_nothing_matches assert_image_date_copied_to_obs(GEOTAGGED_EXIF) sleep(0.5) # we should have the new type of location_google autocompleter now - assert_selector("[data-type='location_google'][data-stimulus='connected']") + assert_selector( + "[data-type='location_google'][data-stimulus='autocompleter-connected']" + ) # Place name should now have been filled by Google, no MO locations match assert_field("observation_place_name", with: UNIVERSITY_PARK[:name], wait: 6) @@ -229,8 +230,6 @@ def test_autofill_location_from_geotagged_image_matching_location end def test_post_edit_and_destroy_with_details_and_location - skip("This test is inconsistent when run in CI") - # browser = page.driver.browser setup_image_dirs # in general_extensions @@ -248,9 +247,8 @@ def test_post_edit_and_destroy_with_details_and_location assert_field("observation_place_name", with: last_obs.where) # Move to the next step, Identification - step_nav_1 = find("#step-nav-1") - scroll_to(step_nav_1, align: :top) - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) assert_field("naming_name", with: "") assert(last_obs.is_collection_location) @@ -259,9 +257,8 @@ def test_post_edit_and_destroy_with_details_and_location assert_field(other_notes_id, with: "", visible: :all) # Move to the previous step, Images/Details - step_nav_2 = find("#step-nav-2") - scroll_to(step_nav_2, align: :top) - within(step_nav_2) { click_on(:BACK.l) } + images_details = find("#observation_images_details") + scroll_to(images_details, align: :top) # Add the images separately, so we can be sure of the order. Otherwise, # images appear in the order each upload finishes, which is unpredictable. @@ -367,7 +364,8 @@ def test_post_edit_and_destroy_with_details_and_location uncheck("observation_is_collection_location", visible: :all) # Move to the next step, Identification - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) sleep(1) specimen_section = find("#observation_specimen_section", visible: :all) @@ -379,12 +377,15 @@ def test_post_edit_and_destroy_with_details_and_location fill_in(other_notes_id, with: "Notes for observation", visible: :all) # Move to the next step, Projects/Lists - within(step_nav_2) { click_on(:NEXT.l) } + projects = find("#observation_projects") + scroll_to(projects, align: :top) # Inherited project constraints maybe messing with this observation - clear all('[id^="project_id_"]', visible: :all).each do |project_checkbox| project_checkbox.trigger("click") if project_checkbox.checked? end + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) # submit_observation_form_with_errors within("#observation_form") { click_commit } @@ -429,25 +430,30 @@ def test_post_edit_and_destroy_with_details_and_location assert_image_exif_available(SO_PASA_EXIF) end - fill_in("observation_place_name", with: "") + fill_in("observation_place_name", with: "south pas") click_on(:form_observations_create_locality.l) # lat/lng does not match Google's Pasadena, but does match South Pasadena assert_selector("[data-type='location_google']") - # assert_selector(".auto_complete", wait: 6) find("#observation_place_name").trigger("focus") - assert_selector(".dropdown-item a[data-id='-1']", - text: SOUTH_PASADENA[:name], visible: :all, wait: 6) + # assert_selector(".auto_complete", wait: 6) + # assert_selector(".dropdown-item a[data-id='-1']", + # text: SOUTH_PASADENA[:name], visible: :all, wait: 6) # There may be more than one of these, click the first - find(".dropdown-item a[data-id='-1']", - text: SOUTH_PASADENA[:name], visible: :all).trigger("click") - + # find(".dropdown-item a[data-id='-1']", + # text: SOUTH_PASADENA[:name], visible: :all).trigger("click") + assert_field("observation_place_name", with: SOUTH_PASADENA[:name]) + sleep(1) + # debugger # Check the hidden fields returned by Google assert_hidden_location_fields_filled(SOUTH_PASADENA) # Move to the next step, Identification - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) - assert_selector("[data-type='name'][data-stimulus='connected']") + assert_selector( + "[data-type='name'][data-stimulus='autocompleter-connected']" + ) fill_in("naming_name", with: "Agaricus campestris") assert_field("naming_name", with: "Agaricus campestris") select(Vote.confidence(Vote.next_best_vote), from: "naming_vote_value") @@ -521,7 +527,8 @@ def test_post_edit_and_destroy_with_details_and_location sleep(1) # Move to the next step, Identification - within(step_nav_1) { click_on(:NEXT.l) } + naming = find("#observation_naming_specimen") + scroll_to(naming, align: :top) sleep(1) obs_notes = find("#observation_notes") @@ -634,9 +641,9 @@ def assert_date_is_now end def assert_geolocation_is_empty - assert_field("observation_lat", with: "") - assert_field("observation_lng", with: "") - assert_field("observation_alt", with: "") + assert_field("observation_lat", with: "", visible: :all) + assert_field("observation_lng", with: "", visible: :all) + assert_field("observation_alt", with: "", visible: :all) end def assert_image_exif_available(image_data)