diff --git a/app/assets/stylesheets/mo/_form_elements.scss b/app/assets/stylesheets/mo/_form_elements.scss index 20ff6d9a2f..7d6f02cb0c 100644 --- a/app/assets/stylesheets/mo/_form_elements.scss +++ b/app/assets/stylesheets/mo/_form_elements.scss @@ -180,3 +180,9 @@ form { max-height: 30rem; overflow-y: auto; } + +.panel-body { + .form-group:last-child { + margin-bottom: 0; + } +} diff --git a/app/assets/stylesheets/mo/_utilities.scss b/app/assets/stylesheets/mo/_utilities.scss index adada3d7fb..0a29b0a978 100644 --- a/app/assets/stylesheets/mo/_utilities.scss +++ b/app/assets/stylesheets/mo/_utilities.scss @@ -122,6 +122,10 @@ object-fit: contain; } +.flex-grow-1 { + flex-grow: 1 !important; +} + .text-larger { font-size: larger !important; } diff --git a/app/classes/pattern_search/base.rb b/app/classes/pattern_search/base.rb index 62c09db83a..1c6c98ac43 100644 --- a/app/classes/pattern_search/base.rb +++ b/app/classes/pattern_search/base.rb @@ -3,17 +3,19 @@ module PatternSearch # Base class for PatternSearch; handles everything plus build_query class Base - attr_accessor :errors, :parser, :args, :query + attr_accessor :errors, :parser, :flavor, :args, :query, :form_params def initialize(string) self.errors = [] self.parser = PatternSearch::Parser.new(string) + self.form_params = make_terms_available_to_faceted_form build_query self.query = Query.lookup(model.name.to_sym, args) rescue Error => e errors << e end + # rubocop:disable Metrics/AbcSize def build_query self.args = {} parser.terms.each do |term| @@ -31,6 +33,7 @@ def build_query end end end + # rubocop:enable Metrics/AbcSize def help_message "#{:pattern_search_terms_help.l}\n#{self.class.terms_help}" @@ -52,5 +55,83 @@ def lookup_param(var) end nil end + + # Build a hash so we can populate the form fields with from the values from + # the saved search string. Turn ranges into ranges, and dates into dates. + # NOTE: The terms may be translated! We have to look up the param names that + # the translations map to. + # rubocop:disable Metrics/AbcSize + def make_terms_available_to_faceted_form + parser.terms.each_with_object({}) do |term, hash| + # term is what the user typed in, not the parsed value. + param = lookup_param_name(term.var) + if fields_with_dates.include?(param) + start, range = check_for_date_range(term) + hash[param] = start + hash[:"#{param}_range"] = range if range + elsif fields_with_numeric_range.include?(param) + start, range = check_for_numeric_range(term) + hash[param] = start + hash[:"#{param}_range"] = range if range + else + hash[param] = term.vals.join(", ") + end + end + end + # rubocop:enable Metrics/AbcSize + + def lookup_param_name(var) + # See if this var matches an English parameter name first. + return var if var == :pattern || params[var].present? + + # Then check if any of the translated parameter names match. + params.each_key do |key| + return key if var.to_s == :"search_term_#{key}".l.tr(" ", "_") + end + nil + end + + # The string could be a date string like "2010-01-01", or a range string + # like "2010-01-01-2010-01-31", or "2023-2024", or "08-10". + # If it is a range, return the two dates. + # Try for fidelity to the stored string, eg only years. + def check_for_date_range(term) + bits = term.vals[0].split("-") + case bits.size + when 1 + start = bits[0] + range = nil + when 2 + start, range = bits + when 4 + start = "#{bits[0]}-#{bits[1]}" + range = "#{bits[2]}-#{bits[3]}" + else + start, range = term.parse_date_range + end + + range = nil if start == range + + [start, range] + end + + def check_for_numeric_range(term) + bits = term.vals[0].split("-") + + if bits.size == 2 + bits.map(&:to_i) + else + [term.vals[0], nil] + end + end + + # These are set in the subclasses, but seem stable enough to be here. + def fields_with_dates + [:when, :created, :modified].freeze + end + + def fields_with_numeric_range + [:confidence].freeze + end end end diff --git a/app/classes/pattern_search/name.rb b/app/classes/pattern_search/name.rb index 15e04ff07a..cb5476208e 100644 --- a/app/classes/pattern_search/name.rb +++ b/app/classes/pattern_search/name.rb @@ -34,6 +34,31 @@ def self.params PARAMS end + # List of fields that are displayed in the search form. + # Autocompleters have id fields, and range fields are concatenated. + def self.fields + params.keys + [ + :created_range, :modified_range, :rank_range, :pattern + ] + end + + def self.fields_with_dates + [:created, :modified] + end + + def self.fields_with_range + [:created, :modified, :rank] + end + + def self.fields_with_ids + [] + end + + # hash of required: fields + def self.fields_with_requirements + {} + end + def params self.class.params end diff --git a/app/classes/pattern_search/observation.rb b/app/classes/pattern_search/observation.rb index 274e049ebc..a8bf070fea 100644 --- a/app/classes/pattern_search/observation.rb +++ b/app/classes/pattern_search/observation.rb @@ -9,7 +9,7 @@ class Observation < Base created: [:created_at, :parse_date_range], modified: [:updated_at, :parse_date_range], - # names + # names. note that the last four require the first one to be present name: [:names, :parse_list_of_names], exclude_consensus: [:exclude_consensus, :parse_boolean], # of_look_alikes include_subtaxa: [:include_subtaxa, :parse_boolean], @@ -53,6 +53,34 @@ def self.params PARAMS end + # List of fields that are displayed in the search form. + # Autocompleters have id fields, and range fields are concatenated. + def self.fields + params.keys + [ + :name_id, :location_id, :user_id, :herbarium_id, :list_id, :project_id, + :project_lists_id, :when_range, :created_range, :modified_range, + :rank_range, :confidence_range, :pattern + ] + end + + def self.fields_with_dates + [:when, :created, :modified] + end + + def self.fields_with_range + [:when, :created, :modified, :rank, :confidence] + end + + def self.fields_with_ids + [:name, :location, :user, :herbarium, :list, :project, :species_list] + end + + # hash of required: fields + def self.fields_with_requirements + { name: [:exclude_consensus, :include_subtaxa, :include_synonyms, + :include_all_name_proposals] } + end + def params self.class.params end diff --git a/app/classes/pattern_search/parser.rb b/app/classes/pattern_search/parser.rb index 46ab010681..4592635acf 100644 --- a/app/classes/pattern_search/parser.rb +++ b/app/classes/pattern_search/parser.rb @@ -12,7 +12,7 @@ class Parser /x def initialize(string) - self.incoming_string = string + self.incoming_string = string || "" self.terms = parse_incoming_string end diff --git a/app/controllers/concerns/filterable.rb b/app/controllers/concerns/filterable.rb new file mode 100644 index 0000000000..cc8bc9d5a8 --- /dev/null +++ b/app/controllers/concerns/filterable.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +# +# = Filterable Concern +# +# This is a module of reusable methods included by controllers that handle +# "faceted" pattern searches per model, with separate inputs for each keyword. +# +# We're translating the params hash into the format that the user would have +# typed into the search box if they knew how to do that, because that's what +# the PatternSearch class expects to parse. The PatternSearch class then +# unpacks, validates and re-translates all these params into the actual params +# used by the Query class. This may seem roundabout: of course we do know the +# Query param names in advance, so we could theoretically just pass the values +# directly into Query and render the index. But we'd still have to be able to +# validate the input, and give messages for all the possible errors there. +# PatternSearch class handles all that. +# +################################################################################ + +module Filterable + extend ActiveSupport::Concern + + # Rubocop is incorrect here. This is a concern, not a class. + # rubocop:disable Metrics/BlockLength + included do + def search_subclass + PatternSearch.const_get(@filter.class.search_type.capitalize) + end + + def formatted_pattern_search_string + sift_and_restructure_form_params + keyword_strings = @sendable_params.map do |key, value| + "#{key}:#{value}" + end + keyword_strings.join(" ") + end + + # One oddball is `confidence` - the string "0" should not count as a value. + def sift_and_restructure_form_params + @keywords = @filter.attributes.to_h.compact_blank.symbolize_keys + + remove_invalid_field_combinations + concatenate_range_fields + + @sendable_params = remove_ids_and_format_strings(@keywords) + # @storable_params = configure_storable_params(@keywords) + end + + # Passing some fields will raise an error if the required field is missing, + # so just toss them. + def remove_invalid_field_combinations + return unless search_subclass.respond_to?(:fields_with_requirements) + + search_subclass.fields_with_requirements.each do |req, fields| + next if @keywords[req].present? + + fields.each { |field| @keywords.delete(field) } + end + end + + # Check for `fields_with_range`, and concatenate them if range val present, + # removing the `_range` field. + def concatenate_range_fields + return unless search_subclass.respond_to?(:fields_with_range) + + search_subclass.fields_with_range.each do |key| + next if @keywords[:"#{key}_range"].blank? + + @keywords[key] = [@keywords[key].to_s.strip, + @keywords[:"#{key}_range"].to_s.strip].join("-") + @keywords.delete(:"#{key}_range") + end + end + + # SENDABLE_PARAMS - params with ids can be sent to index and query. + # + # This method deletes the strings typed in the form and sends ids, saving a + # lookup at the receiver. However, we still want a legible string saved in + # the session, so we can repopulate the form with legible values - plus + # maybe in the url. Could send the id versions as separate `filter` param? + # + # Need to modify autocompleters to check for record id on load if prefilled. + def substitute_strings_with_ids(keywords) + search_subclass.fields_with_ids.each do |key| + next if keywords[:"#{key}_id"].blank? + + keywords[key] = keywords[:"#{key}_id"] + keywords.delete(:"#{key}_id") + end + keywords + end + + # STORABLE_PARAMS - params for the pattern string in session. + # These methods don't modify the original @keywords. + # + # Ideally we'd store full strings for all values, including names and + # locations, so we can repopulate the form with the same values. + def remove_ids_and_format_strings(keywords) + escape_strings_and_remove_ids(keywords) + escape_locations_and_remove_ids(keywords) + end + + # Escape-quote the strings, the way the short form requires. + # rubocop:disable Metrics/AbcSize + def escape_strings_and_remove_ids(keywords) + search_subclass.fields_with_ids.each do |key| + # location, region handled separately + next if keywords[key].blank? || strings_with_commas.include?(key.to_sym) + + list = keywords[key].split(",").map(&:strip) + list = list.map { |name| "\"#{name}\"" } + keywords[key] = list.join(",") + next if keywords[:"#{key}_id"].blank? + + keywords.delete(:"#{key}_id") + end + keywords + end + # rubocop:enable Metrics/AbcSize + + # Escape-quote the locations and their commas. We'd prefer to have legible + # strings in the url, but the comma handling is difficult. + def escape_locations_and_remove_ids(keywords) + if keywords[:location].present? + list = keywords[:location].split("\n").map(&:strip) + list = list.map { |location| escape_location_string(location) } + keywords[:location] = list.join(",") + end + keywords.delete(:location_id) if keywords[:location_id].present? + escape_region_string(keywords) + end + + def escape_region_string(keywords) + return keywords if keywords[:region].blank? + + keywords[:region] = escape_location_string(keywords[:region].strip) + keywords + end + + def escape_location_string(location) + "\"#{location.tr(",", "\\,")}\"" + end + + def strings_with_commas + [:location, :region].freeze + end + end + # rubocop:enable Metrics/BlockLength +end diff --git a/app/controllers/names/filters_controller.rb b/app/controllers/names/filters_controller.rb new file mode 100644 index 0000000000..8bdb1c99d4 --- /dev/null +++ b/app/controllers/names/filters_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# Names pattern search form. +# +# Route: `new_name_search_path` +# Only one action here. Call namespaced controller actions with a hash like +# `{ controller: "/names/filter", action: :create }` +module Names + class FiltersController < ApplicationController + include ::Filterable + + before_action :login_required + + def new + set_up_form_field_groupings + new_filter_instance_from_session + end + + def create + return if check_for_clear_form + + set_up_form_field_groupings # in case we need to re-render the form + set_filter_instance_from_form + set_pattern_string + + redirect_to(controller: "/names", action: :index, pattern: @pattern) + end + + private + + def check_for_clear_form + if params[:commit] == :CLEAR.l + session[:pattern] = "" + session[:search_type] = nil + redirect_to(names_new_search_path) and return true + end + false + end + + def new_filter_instance_from_session + if session[:pattern].present? && session[:search_type] == :name + terms = PatternSearch::Name.new(session[:pattern]).form_params + @filter = NameFilter.new(terms) + else + @filter = NameFilter.new + end + end + + def set_filter_instance_from_form + @filter = NameFilter.new(permitted_search_params[:name_filter]) + redirect_to(names_new_search_path) && return if @filter.invalid? + end + + def set_pattern_string + @pattern = formatted_pattern_search_string + # Save it so that we can keep it in the search bar in subsequent pages. + session[:pattern] = @pattern + session[:search_type] = :name + end + + # This is the list of fields that are displayed in the search form. In the + # template, each hash is interpreted as a column, and each key is a panel + # with an array of fields or field pairings. + def set_up_form_field_groupings + @field_columns = [ + { pattern: { shown: [:pattern], collapsed: [] }, + quality: { + shown: [[:has_observations, :deprecated]], + collapsed: [[:has_author, :author], + [:has_citation, :citation]] + }, + date: { shown: [:created, :modified], collapsed: [] } }, + { scope: { + shown: [[:has_synonyms, :include_synonyms], + [:include_subtaxa, :include_misspellings]], + collapsed: [:rank, :lichen] + }, + detail: { + shown: [[:has_classification, :classification]], + collapsed: [[:has_notes, :notes], + [:has_comments, :comments], + :has_description] + } } + ].freeze + end + + def permitted_search_params + params.permit(name_search_params) + end + + def name_search_params + [{ name_filter: PatternSearch::Name.fields }] + end + end +end diff --git a/app/controllers/observations/filters_controller.rb b/app/controllers/observations/filters_controller.rb new file mode 100644 index 0000000000..c0054c5556 --- /dev/null +++ b/app/controllers/observations/filters_controller.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +# Observations pattern search form. +# +# Route: `new_observation_search_path` +# Only one action here. Call namespaced controller actions with a hash like +# `{ controller: "/observations/filter", action: :create }` +module Observations + class FiltersController < ApplicationController + include ::Filterable + + before_action :login_required + + def new + set_up_form_field_groupings + new_filter_instance_from_session + end + + def create + return if check_for_clear_form + + set_up_form_field_groupings # in case we need to re-render the form + set_filter_instance_from_form + set_pattern_string + + redirect_to(controller: "/observations", action: :index, + pattern: @pattern) + end + + private + + def check_for_clear_form + if params[:commit] == :CLEAR.l + session[:pattern] = "" + session[:search_type] = nil + redirect_to(observations_new_search_path) and return true + end + false + end + + def new_filter_instance_from_session + if session[:pattern].present? && session[:search_type] == :observation + terms = PatternSearch::Observation.new(session[:pattern]).form_params + @filter = ObservationFilter.new(terms) + else + @filter = ObservationFilter.new + end + end + + def set_filter_instance_from_form + @filter = ObservationFilter.new( + permitted_search_params[:observation_filter] + ) + redirect_to(observations_new_search_path) && return if @filter.invalid? + end + + def set_pattern_string + @pattern = formatted_pattern_search_string + # Save it so that we can keep it in the search bar in subsequent pages. + session[:pattern] = @pattern + session[:search_type] = :observation + end + + # This is the list of fields that are displayed in the search form. In the + # template, each hash is interpreted as a column, and each key is a + # panel_body (either shown or hidden) with an array of fields or field + # pairings. + def set_up_form_field_groupings + @field_columns = [ + { date: { shown: [:when], collapsed: [:created, :modified] }, + name: { + shown: [:name], + conditional: [[:include_subtaxa, :include_synonyms], + [:include_all_name_proposals, :exclude_consensus]], + collapsed: [:confidence, [:has_name, :lichen]] + }, + location: { + shown: [:location], + collapsed: [[:has_public_lat_lng, :is_collection_location], + :region, [:east, :west], [:north, :south]] + } }, + { pattern: { shown: [:pattern], collapsed: [] }, + detail: { + shown: [[:has_specimen, :has_sequence]], + collapsed: [[:has_images, :has_notes], + [:has_field, :notes], [:has_comments, :comments]] + }, + connected: { + shown: [:user, :project], + collapsed: [:herbarium, :list, :project_lists, :field_slip] + } } + ].freeze + end + + def permitted_search_params + params.permit(observation_search_params) + end + + # need to add :pattern to the list of params, plus the hidden_id fields + # of the autocompleters. + def observation_search_params + [{ observation_filter: PatternSearch::Observation.fields }] + end + end +end diff --git a/app/helpers/autocompleter_helper.rb b/app/helpers/autocompleter_helper.rb index 9b4e45ce2c..74d57954c8 100644 --- a/app/helpers/autocompleter_helper.rb +++ b/app/helpers/autocompleter_helper.rb @@ -141,11 +141,12 @@ def autocompleter_edit_box_button(args) ) end - # minimum args :form, :type. Send :hidden_name to override default field name. - # Send :hidden_value to fill id, :hidden_data to merge with hidden field data + # minimum args :form, :type. Send :hidden_name to override default field name, + # :hidden_value to fill id, :hidden_data to merge with hidden field data def autocompleter_hidden_field(**args) - return unless args[:form].present? && args[:type].present? + return unless args[:form].present? && args[:field].present? + # Default field name is "#{type}_id", so obs.place_name gets obs.location_id id = args[:hidden_name] || :"#{args[:type]}_id" data = { autocompleter_target: "hidden" }.merge(args[:hidden_data] || {}) args[:form].hidden_field( diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb new file mode 100644 index 0000000000..39d74023fe --- /dev/null +++ b/app/helpers/filters_helper.rb @@ -0,0 +1,377 @@ +# frozen_string_literal: true + +# helpers for pattern search forms. These call field helpers in forms_helper. +# args should provide form, field, label at a minimum. +# rubocop:disable Metrics/ModuleLength +module FiltersHelper + # Filter panel for a search form. Sections are shown and collapsed. + # If sections[:collapsed] is present, part of the panel will be collapsed. + def filter_panel(form:, filter:, heading:, sections:, model:) + shown = filter_panel_shown(form:, filter:, sections:, model:) + collapsed = filter_panel_collapsed(form:, filter:, sections:, model:) + open = collapse = false + if sections[:collapsed].present? + collapse = heading + open = filter_panel_open?(filter:, sections:) + end + panel_block(heading: :"search_term_group_#{heading}".l, + collapse:, open:, collapse_message: :MORE.l, + panel_bodies: [shown, collapsed]) + end + + # This returns the current filter terms in the form of a hash. + def filter_params(filter:) + filter.attributes.compact_blank.transform_keys(&:to_sym) + end + + def filter_panel_open?(filter:, sections:) + current = filter_params(filter:)&.keys || [] + this_section = sections[:collapsed].flatten # could be pairs of fields + return true if current.intersect?(this_section) + + false + end + + def filter_panel_shown(form:, filter:, sections:, model:) + return unless sections.is_a?(Hash) && sections[:shown].present? + + capture do + sections[:shown].each do |field| + concat(filter_row(form:, filter:, field:, model:, sections:)) + end + end + end + + # Content of collapsed section, composed of field rows. + def filter_panel_collapsed(form:, filter:, sections:, model:) + return unless sections.is_a?(Hash) && sections[:collapsed].present? + + capture do + sections[:collapsed].each do |field| + concat(filter_row(form:, filter:, field:, model:, sections:)) + end + end + end + + # Fields might be paired, so we need to check for that. + def filter_row(form:, filter:, field:, model:, sections:) + if field.is_a?(Array) + tag.div(class: "row") do + field.each do |subfield| + concat(tag.div(class: filter_column_classes) do + filter_field(form:, filter:, field: subfield, model:, sections:) + end) + end + end + else + filter_field(form:, filter:, field:, model:, sections:) + end + end + + # Figure out what kind of field helper to call, based on definitions below. + # Some field types need args, so there is both the component and args hash. + def filter_field(form:, filter:, field:, model:, sections:) + args = { form:, filter:, field:, model: } + args[:label] ||= filter_label(field) + field_type = filter_field_type_from_parser(field:, model:) + component = FILTER_FIELD_HELPERS[field_type][:component] + return unless component + + # Prepare args for the field helper. Requires but removes args[:model]. + args = prepare_args_for_filter_field(args, field_type, component) + # Re-add sections and model for conditional fields. + if component == :filter_autocompleter_with_conditional_fields + args = args.merge(sections:, model:, filter:) + end + return filter_region_with_compass_fields(**args) if field == :region + + send(component, **args) + end + + # The field's label. + def filter_label(field) + if field == :pattern + :PATTERN.l + else + :"search_term_#{field}".l.humanize + end + end + + # The PatternSearch subclasses define how they're going to parse their + # fields, so we can use that to assign a field helper. + # example: :parse_yes -> :yes, from which we deduce :filter_yes_field + # If the field is :pattern, there's no assigned parser. + def filter_field_type_from_parser(field:, model:) + return :pattern if field == :pattern + + subclass = PatternSearch.const_get(model.capitalize) + unless subclass.params[field] + raise("No parser defined for #{field} in #{subclass}") + end + + parser = subclass.params[field][1] + parser.to_s.gsub(/^parse_/, "").to_sym + end + + # Prepares HTML args for the field helper. This is where we can make + # adjustments to the args hash before passing it to the field helper. + # NOTE: Bootstrap 3 can't do full-width inline label/field. + def prepare_args_for_filter_field(args, field_type, component) + if component == :text_field_with_label && args[:field] != :pattern + args[:inline] = true + end + args[:help] = filter_help_text(args, field_type) + args[:hidden_name] = filter_check_for_hidden_field_name(args) + args = filter_prefill_or_select_values(args, field_type) + + FILTER_FIELD_HELPERS[field_type][:args].merge(args.except(:model, :filter)) + end + + def filter_help_text(args, field_type) + component = FILTER_FIELD_HELPERS[field_type][:component] + multiple_note = if component == :autocompleter_field + :pattern_search_terms_multiple.l + end + [:"#{args[:model]}_term_#{args[:field]}".l, multiple_note].compact.join(" ") + end + + # Overrides for the assumed name of the id field for autocompleter. + def filter_check_for_hidden_field_name(args) + case args[:field] + when :list + return "list_id" + when :project_lists + return "project_lists_id" + end + nil + end + + def filter_prefill_or_select_values(args, field_type) + if FILTER_SELECT_TYPES.include?(field_type) + args[:selected] = args[:filter].send(args[:field]) || nil + end + args + end + + ############################################################### + # + # FIELD HELPERS + # + # Complex mechanism: append collapsed fields to autocompleter that only appear + # when autocompleter has a value. Only on the name field. + def filter_autocompleter_with_conditional_fields(**args) + return if args[:sections].blank? + + # rightward destructuring assignment ruby 3 feature + args => { form:, model:, filter:, sections: } + append = filter_conditional_rows(form:, model:, filter:, sections:) + autocompleter_field( + **args.except(:sections, :model, :filter).merge(append:) + ) + end + + # Rows that only uncollapse if an autocompleter field has a value. + # Note the data-autocompleter-target attribute. + def filter_conditional_rows(form:, model:, filter:, sections:) + capture do + tag.div(data: { autocompleter_target: "collapseFields" }, + class: "collapse") do + sections[:conditional].each do |field| + concat(filter_row(form:, field:, model:, filter:, sections:)) + end + end + end + end + + def filter_yes_field(**) + options = [ + ["", nil], + ["yes", "yes"] + ] + select_with_label(options:, inline: true, **) + end + + def filter_boolean_field(**) + options = [ + ["", nil], + ["yes", "yes"], + ["no", "no"] + ] + select_with_label(options:, inline: true, **) + end + + def filter_yes_no_both_field(**) + options = [ + ["", nil], + ["yes", "yes"], + ["no", "no"], + ["both", "either"] + ] + select_with_label(options:, inline: true, **) + end + + # RANGE FIELDS The first field gets the label, name and ID of the actual + # param; the end `_range` field is optional. The controller needs to check for + # the second & join them with a hyphen if it exists (in both cases here). + def filter_date_range_field(**args) + tag.div(class: "row") do + [ + tag.div(class: filter_column_classes) do + text_field_with_label(**filter_date_args(args)) + end, + tag.div(class: filter_column_classes) do + text_field_with_label(**filter_date_range_args(args)) + end + ].safe_join + end + end + + def filter_date_args(args) + args.except(:filter).merge({ between: "(YYYY-MM-DD)" }) + end + + def filter_date_range_args(args) + args.except(:filter).merge( + { field: "#{args[:field]}_range", label: :to.l, + between: :optional, help: nil } + ) + end + + def filter_rank_range_field(**args) + [ + tag.div(class: "d-inline-block mr-4") do + select_with_label(**filter_rank_args(args)) + end, + tag.div(class: "d-inline-block") do + select_with_label(**filter_rank_range_args(args)) + end + ].safe_join + end + + def filter_rank_args(args) + args.except(:filter).merge( + { options: Name.all_ranks, include_blank: true, inline: true } + ) + end + + def filter_rank_range_args(args) + args.except(:filter).merge( + { field: "#{args[:field]}_range", label: :to.l, options: Name.all_ranks, + include_blank: true, between: :optional, help: nil, inline: true } + ) + end + + def filter_confidence_range_field(**args) + confidences = Vote.opinion_menu.map { |k, v| [k, Vote.percent(v)] } + [ + tag.div(class: "d-inline-block mr-4") do + select_with_label(**filter_confidence_args(confidences, args)) + end, + tag.div(class: "d-inline-block") do + select_with_label(**filter_confidence_range_args(confidences, args)) + end + ].safe_join + end + + def filter_confidence_args(confidences, args) + args.except(:filter).merge( + { options: confidences, include_blank: true, inline: true } + ) + end + + def filter_confidence_range_args(confidences, args) + args.except(:filter).merge( + { field: "#{args[:field]}_range", label: :to.l, options: confidences, + include_blank: true, between: :optional, help: nil, inline: true } + ) + end + + def filter_region_with_compass_fields(**args) + tag.div(data: { controller: "map", map_open: true }) do + [ + form_location_input_find_on_map(form: args[:form], field: :region, + value: args[:filter].region, + label: "#{:REGION.t}:"), + filter_compass_input_and_map(form: args[:form], filter: args[:filter]) + ].safe_join + end + end + + def filter_compass_input_and_map(form:, filter:) + minimal_loc = filter_minimal_location(filter) + capture do + [ + form_compass_input_group(form:, obj: filter), + make_map(objects: [minimal_loc], editable: true, map_type: "location", + map_open: false, controller: nil) + ].safe_join + end + end + + # To be mappable, we need to instantiate a minimal location from the filter. + def filter_minimal_location(filter) + if filter.north.present? && filter.south.present? && + filter.east.present? && filter.west.present? + Mappable::MinimalLocation.new( + nil, nil, filter.north, filter.south, filter.east, filter.west + ) + else + Mappable::MinimalLocation.new(nil, nil, 0, 0, 0, 0) + end + end + + def filter_longitude_field(**args) + text_field_with_label( + **args.except(:filter).merge(between: "(-180.0 to 180.0)") + ) + end + + def filter_latitude_field(**args) + text_field_with_label( + **args.except(:filter).merge(between: "(-90.0 to 90.0)") + ) + end + + def filter_column_classes + "col-xs-12 col-sm-6 col-md-12 col-lg-6" + end + + # Separator for autocompleter fields. + FILTER_SEPARATOR = ", " + + # Convenience for subclasses to access helper methods via subclass.params + FILTER_FIELD_HELPERS = { + pattern: { component: :text_field_with_label, args: {} }, + yes: { component: :filter_yes_field, args: {} }, + boolean: { component: :filter_boolean_field, args: {} }, + yes_no_both: { component: :filter_yes_no_both_field, args: {} }, + date_range: { component: :filter_date_range_field, args: {} }, + rank_range: { component: :filter_rank_range_field, args: {} }, + string: { component: :text_field_with_label, args: {} }, + list_of_strings: { component: :text_field_with_label, args: {} }, + list_of_herbaria: { component: :autocompleter_field, + args: { type: :herbarium, + separator: FILTER_SEPARATOR } }, + list_of_locations: { component: :autocompleter_field, + args: { type: :location, separator: "\n" } }, + list_of_names: { component: :filter_autocompleter_with_conditional_fields, + args: { type: :name, separator: FILTER_SEPARATOR } }, + list_of_projects: { component: :autocompleter_field, + args: { type: :project, + separator: FILTER_SEPARATOR } }, + list_of_species_lists: { component: :autocompleter_field, + args: { type: :species_list, + separator: FILTER_SEPARATOR } }, + list_of_users: { component: :autocompleter_field, + args: { type: :user, separator: FILTER_SEPARATOR } }, + confidence: { component: :filter_confidence_range_field, args: {} }, + # handled in filter_region_with_compass_fields + longitude: { component: nil, args: {} }, + latitude: { component: nil, args: {} } + }.freeze + + FILTER_SELECT_TYPES = [ + :yes, :boolean, :yes_no_both, :rank_range, :confidence + ].freeze +end +# rubocop:enable Metrics/ModuleLength diff --git a/app/helpers/form_locations_helper.rb b/app/helpers/form_locations_helper.rb index 1e9b77721a..54db3a7e3f 100644 --- a/app/helpers/form_locations_helper.rb +++ b/app/helpers/form_locations_helper.rb @@ -11,8 +11,9 @@ def form_location_input_find_on_map(form:, field:, value: nil, label: nil) ) end - # This will generate a compass rose of inputs for given form object. - # The inputs are for compass directions. + # This will generate a compass rose of inputs for given form object. The + # inputs are for compass directions. The object can be a location or a filter, + # that's what will prefill the values on load or reload. def form_compass_input_group(form:, obj:) capture do compass_groups.each do |dir| diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 368b94a67d..acb2b99319 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -97,9 +97,7 @@ def check_box_with_label(**args) args[:checked_value] || "1", args[:unchecked_value] || "0")) concat(args[:label]) - if args[:between].present? - concat(tag.div(class: "d-inline-block ml-3") { args[:between] }) - end + concat(args[:between]) if args[:between].present? end) concat(args[:append]) if args[:append].present? end @@ -212,7 +210,12 @@ def text_area_with_label(**args) # 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 + row_class = if args[:inline] == true + "d-inline-block" + else + "d-flex justify-content-between" + end + tag.div(class: row_class) do concat(tag.div do concat(args[:form].label(args[:field], args[:label], label_opts)) concat(args[:between]) if args[:between].present? @@ -302,7 +305,9 @@ def date_select_opts(args = {}) # The field may not be an attribute of the object if obj.present? && obj.respond_to?(field) init_year = obj.try(&field.to_sym).try(&:year) - selected = obj.try(&field.to_sym) || Time.zone.today + selected = obj.try(&field.to_sym) + # Keep blank fields blank on search filters + selected ||= Time.zone.today unless obj.is_a?(SearchFilter) end if init_year && init_year < start_year && init_year > 1900 start_year = init_year @@ -503,7 +508,7 @@ def form_group_wrap_class(args, base = "form-group") end def field_label_opts(args) - label_opts = { class: "mr-3" } + label_opts = {} label_opts[:index] = args[:index] if args[:index].present? label_opts end @@ -517,7 +522,9 @@ 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.l})") if args[pos] == key + if args[pos] == key + args[pos] = help_note(:span, "(#{key.l})", class: "ml-3") + end end end args @@ -529,13 +536,20 @@ def check_for_help_block(args) return args end + need_margin = args[:inline].present? + between_class = need_margin ? "mr-3" : "" + id = [ nested_field_id(args), "help" ].compact_blank.join("_") args[:between] = capture do - concat(args[:between]) - concat(collapse_info_trigger(id)) + tag.span(class: between_class) do + if args[:between].present? + concat(tag.span(class: "ml-3") { args[:between] }) + end + concat(collapse_info_trigger(id, class: "ml-3")) + end end args[:append] = capture do concat(args[:append]) diff --git a/app/helpers/panel_helper.rb b/app/helpers/panel_helper.rb index c6ca7e4cc4..71ae2efc9a 100644 --- a/app/helpers/panel_helper.rb +++ b/app/helpers/panel_helper.rb @@ -16,21 +16,25 @@ def panel_block(**args, &block) **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(panel_body_or_bodies(args, content)) concat(footer) end end + def panel_body_or_bodies(args, content) + if args[:panel_bodies].present? + panel_bodies(args) + elsif args[:collapse].present? + panel_collapse_body(args, content) + else + panel_body(args, content) + 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 + :collapse, :collapse_message, :open, :footer].freeze end def panel_heading(args) @@ -67,21 +71,29 @@ def panel_heading_collapse_elements(args) aria: { expanded: args[:open], controls: args[:collapse] } ) do [args[:heading], - tag.span(panel_collapse_icons, class: "float-right")].safe_join + tag.span(panel_collapse_icons(args), 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 + def panel_collapse_icons(args) + if (message = args[:collapse_message]).present? + message = tag.span(message, class: "font-weight-normal mr-2") + end + [message, + link_icon(:chevron_down, title: :OPEN.l, class: "active-icon"), + link_icon(:chevron_up, title: :CLOSE.l)].compact_blank.safe_join end - # Some panels need multiple panel bodies. + # Some panels need multiple panel bodies. Potentially collapse the last one. def panel_bodies(args) - args[:panel_bodies].map do |body| - panel_body(args, body) + args[:panel_bodies].map.with_index do |body, idx| + if args[:collapse].present? && idx == args[:panel_bodies].length - 1 + panel_collapse_body(args.merge(inner_class: "pt-0"), body) + else + panel_body(args, body) + end end.safe_join end @@ -152,8 +164,9 @@ def help_tooltip(label, **args) 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") + def help_note(element = :span, string = "", **args) + args[:class] = class_names("help-note mr-3", args[:class]) + content_tag(element, string, args) end # make a help-block styled element, like a div, p diff --git a/app/models/name_filter.rb b/app/models/name_filter.rb new file mode 100644 index 0000000000..21cd9956e1 --- /dev/null +++ b/app/models/name_filter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Non-AR model for the faceted PatternSearch form. +class NameFilter < SearchFilter + # Assign attributes from the PatternSearch::Observation.params hash, + # adjusting for range fields and autocompleters with hidden id fields. + # To switch to Query params, assign attribute(values[0], :date) etc. + # and update the @field_columns hash in FiltersController accordingly. + # Then change the form to build a @query instead of a @filter, with + # `pattern` being but one of the query params, and have the hydrator + # in the FiltersController#new action check the query instead of the + # session[:pattern] + PatternSearch::Name.params.map do |keyword, values| + case values[1] + when :parse_date_range + attribute(keyword, :date) + attribute(:"#{keyword}_range", :date) + when :parse_rank_range + attribute(keyword, :string) + attribute(:"#{keyword}_range", :string) + else + attribute(keyword, :string) + end + end +end diff --git a/app/models/observation_filter.rb b/app/models/observation_filter.rb new file mode 100644 index 0000000000..280500e5fa --- /dev/null +++ b/app/models/observation_filter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Non-AR model for the faceted PatternSearch form. +class ObservationFilter < SearchFilter + # Assign attributes from the PatternSearch::Observation.params hash, + # adjusting for range fields and autocompleters with hidden id fields. + PatternSearch::Observation.params.map do |keyword, values| + case values[1] + when :parse_date_range + attribute(keyword, :string) + attribute(:"#{keyword}_range", :string) + when :parse_confidence + attribute(keyword, :integer) + attribute(:"#{keyword}_range", :integer) + when :parse_longitude, :parse_latitude + attribute(keyword, :float) + when /parse_list_of_/ + attribute(keyword, :string) + attribute(:"#{keyword}_id", :string) + else + attribute(keyword, :string) + end + end +end diff --git a/app/models/search_filter.rb b/app/models/search_filter.rb new file mode 100644 index 0000000000..1a7093fbe7 --- /dev/null +++ b/app/models/search_filter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Non-AR model for the faceted PatternSearch form. Subclass this for each model +# you want to search, named after the model it's for, eg "ObservationFilter" +class SearchFilter + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :pattern, :string + + # Returns the type of search (table_name) the subclass filter is for. + def self.search_type + to_s.underscore.gsub("_filter", "").to_sym + end +end diff --git a/app/models/translation_string.rb b/app/models/translation_string.rb index 156ef12f84..114039bab8 100644 --- a/app/models/translation_string.rb +++ b/app/models/translation_string.rb @@ -14,6 +14,18 @@ # updated_at:: DateTime it was last updated. # user:: User who last updated it. # +# == Methods +# +# translations(locale):: Get all translations for a given locale. +# update_localization:: Update the translation in the I18n backend. +# store_localization:: Store the translation in the I18n backend. +# +# == Class Methods +# +# banner_time:: Get age of official language's banner. +# store_localizations(locale, hash_of_tags_and_texts):: Batch update. +# rename_tags(tags):: Rename a hash of tags in the database. +# # == Versions # # ActsAsVersioned tracks changes in +text+, +updated_at+, and +user+. diff --git a/app/views/controllers/names/filters/new.erb b/app/views/controllers/names/filters/new.erb new file mode 100644 index 0000000000..3b0c4e3e78 --- /dev/null +++ b/app/views/controllers/names/filters/new.erb @@ -0,0 +1,8 @@ +<% +add_page_title(add_page_title(:title_for_name_search.l)) +@container = :wide +%> + +<%= render(partial: "shared/filters_form", + locals: { local: true, filter: @filter, + field_columns: @field_columns } ) %> diff --git a/app/views/controllers/observations/filters/new.erb b/app/views/controllers/observations/filters/new.erb new file mode 100644 index 0000000000..4310e327a0 --- /dev/null +++ b/app/views/controllers/observations/filters/new.erb @@ -0,0 +1,8 @@ +<% +add_page_title(:title_for_observation_search.l) +@container = :wide +%> + +<%= render(partial: "shared/filters_form", + locals: { local: true, filter: @filter, + field_columns: @field_columns } ) %> diff --git a/app/views/controllers/shared/_filters_form.erb b/app/views/controllers/shared/_filters_form.erb new file mode 100644 index 0000000000..8c6b3638a5 --- /dev/null +++ b/app/views/controllers/shared/_filters_form.erb @@ -0,0 +1,27 @@ +<%# locals: (filter: {}, field_columns: [], model: filter.class.search_type, local: true) -%> +<% +form_args = { model: filter, url: { action: :create }, + id: "#{model}_filter_form" } +# form_args[:model] = filter if filter&:id +%> + +<%= form_with(**form_args) do |form| %> + + <%= tag.div(class: "row") do %> + <% field_columns.each do |panels| %> + + <%= tag.div(class: "col-xs-12 col-md-6") do %> + <% panels.each do |heading, sections| %> + <%= filter_panel(form:, filter:, heading:, sections:, model:) %> + <% end %> + <% end %> + + <% end %> + <% end %> + + <%= tag.div(class: "text-center") do %> + <%= submit_button(form:, button: :SEARCH.l, class: "d-inline-block mx-3") %> + <%= submit_button(form:, button: :CLEAR.l, class: "d-inline-block mx-3") %> + <% end %> + +<% end %> diff --git a/config/locales/en.txt b/config/locales/en.txt index 5809b35870..4307bf0d53 100644 --- a/config/locales/en.txt +++ b/config/locales/en.txt @@ -480,6 +480,7 @@ confidence_level: confidence level CONFIDENCE_LEVELS: Confidence Levels confidence_levels: confidence levels + CONNECTED_TO: Connected to CONSENSUS: Consensus CONSTRAINT_VIOLATIONS: Constraint Violations constraint_violations: constraint violations @@ -501,6 +502,8 @@ # deposit of Sequence Bases in an Archive DEPOSIT: Deposit deposit: deposit + DETAIL: Detail + detail: detail # DQA is iNat-ese for Data Quality Assessment DQA: Quality Grade EDITOR: Editor @@ -605,6 +608,8 @@ reviewer: reviewer REVIEWERS: Reviewers reviewers: reviewers + SCOPE: Scope + scope: scope SECONDS: Seconds SIZE: Size size: size @@ -737,6 +742,8 @@ OKAY: Okay okay: okay OPEN: Open + pattern: pattern + PATTERN: Pattern # reload a form RELOAD: Reload reload: reload @@ -892,6 +899,8 @@ "NO": "No" "yes": "yes" "no": "no" + TO: To + to: to ############################################################################## @@ -1185,6 +1194,15 @@ search_term_south: south search_term_user: user search_term_west: west + search_term_group_pattern: "[:PATTERN]" + search_term_group_search: "[:SEARCH]" + search_term_group_date: "[:DATE]" + search_term_group_quality: "[:QUALITY]" + search_term_group_scope: "[:SCOPE]" + search_term_group_detail: "[:DETAIL]" + search_term_group_name: "[:NAME]" + search_term_group_location: "[:LOCATION]" + search_term_group_connected: "[:CONNECTED_TO]" # Words recognized in search bar. # e.g. "user:me" @@ -2287,7 +2305,7 @@ show_observation_site_id: Site ID show_observation_owner_id: Observer Preference show_observation_no_clear_preference: no clear preference - show_observation_details: Details + show_observation_details: "[:DETAIL]" show_observation_details_inat_import: Imported [date] from show_observation_details_inat_export: Copied to iNaturalist as show_observation_inat_lat_lng: Public lat/lng @@ -3689,6 +3707,7 @@ # Search bar help pattern_search_terms_help: "Your search string may contain terms of the form \"variable:value\", where value can be quoted, and in some cases can contain more than one value separated by commas. Recognized variables include:" + pattern_search_terms_multiple: Separate multiple values with commas. observation_term_when: Date mushroom was observed; YYYY-MM-DD, YYYY or MM; ranges okay. observation_term_created: Date observation was first posted. @@ -3702,7 +3721,7 @@ observation_term_location: "Location (\"[:WHERE]\") mushroom was observed. Must exactly match the entire [:WHERE] field. Note that commas must be protected with a back-slash: \"Albion\\, California\\, USA\"." observation_term_region: Location mushroom was observed. Partial match anchored at end, including country at least, e.g., "California\, USA". Note that commas must be protected with a back-slash as shown. observation_term_project: Observation belongs to one of these projects. - observation_term_project_lists: Observation belongs to list in one of these projects. + observation_term_project_lists: Observation belongs to a species list in one of these projects. observation_term_list: Observation belongs to one of these species lists. observation_term_user: Observation created by one of these users. observation_term_notes: Notes contains the given string. @@ -3724,6 +3743,7 @@ observation_term_has_comments: Has any comments? observation_term_is_collection_location: Mushroom was growing at the location. ("[:form_observations_is_collection_location]" is checked.) + name_term_pattern: "[:PATTERN]" name_term_created: Date name was first used. name_term_modified: Date name was last modified. name_term_rank: Rank or range of ranks, e.g., "genus" or "species-form". diff --git a/config/routes.rb b/config/routes.rb index e8eb70eadf..a9fcbb370d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -500,6 +500,11 @@ def route_actions_hash get("locations/map", to: "locations/maps#show", as: "map_locations") # ----- Names: a lot of actions ---------------------------- + namespace :names do + get("search/new", to: "filters#new", as: "new_search") + post("search", to: "filters#create", as: "search") + end + resources :names, id: /\d+/, shallow: true do # These routes are for dealing with name attributes. # They're not `resources` because they don't have their own IDs. @@ -589,7 +594,9 @@ def route_actions_hash namespace :observations do resources :downloads, only: [:new, :create] - # Not under resources :observations because the obs doesn't have an id yet + get("search/new", to: "filters#new", as: "new_search") + post("search", to: "filters#create", as: "search") + # uploads are not under resources because the obs doesn't have an id yet get("images/uploads/new", to: "images/uploads#new", as: "new_image_upload_for") post("images/uploads", to: "images/uploads#create", diff --git a/test/controllers/names/filters_controller_test.rb b/test/controllers/names/filters_controller_test.rb new file mode 100644 index 0000000000..a52e85a6fa --- /dev/null +++ b/test/controllers/names/filters_controller_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require("test_helper") + +# ------------------------------------------------------------ +# Name filters - test pattern search +# ------------------------------------------------------------ +module Names + class FiltersControllerTest < FunctionalTestCase + def test_existing_name_pattern + login("rolf") + # may need to do this in an integration test + # @request.session["pattern"] = "something" + # @request.session["search_type"] = "name" + get(:new) + # assert_select("input[type=text]#name_filter_pattern", + # text: "something", count: 1) + end + end +end diff --git a/test/controllers/observations/filters_controller_test.rb b/test/controllers/observations/filters_controller_test.rb new file mode 100644 index 0000000000..667276f97c --- /dev/null +++ b/test/controllers/observations/filters_controller_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require("test_helper") + +# ------------------------------------------------------------ +# Observation filters - test pattern search +# ------------------------------------------------------------ +module Observations + class FiltersControllerTest < FunctionalTestCase + def test_existing_obs_pattern + login("rolf") + # may need to do this in an integration test + # @request.session["pattern"] = "something" + # @request.session["search_type"] = "observation" + get(:new) + end + end +end diff --git a/test/integration/capybara/pattern_search_integration_test.rb b/test/integration/capybara/pattern_search_integration_test.rb new file mode 100644 index 0000000000..589035e53c --- /dev/null +++ b/test/integration/capybara/pattern_search_integration_test.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require("test_helper") + +# Integration tests of the Observations controller and views. +class ObservationsIntegrationTest < CapybaraIntegrationTestCase +end diff --git a/test/models/name_filter_test.rb b/test/models/name_filter_test.rb new file mode 100644 index 0000000000..294f0cea06 --- /dev/null +++ b/test/models/name_filter_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require("test_helper") + +class NameFilterTest < UnitTestCase + def test_create_name_filter_from_session + pattern = "something" + terms = PatternSearch::Name.new(pattern).form_params + filter = NameFilter.new(terms) + assert_equal(pattern, filter.pattern) + end +end diff --git a/test/models/observation_filter_test.rb b/test/models/observation_filter_test.rb new file mode 100644 index 0000000000..947eaa07c5 --- /dev/null +++ b/test/models/observation_filter_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require("test_helper") + +class ObservationFilterTest < UnitTestCase + def test_create_observation_filter_from_session + pattern = "something" + terms = PatternSearch::Observation.new(pattern).form_params + filter = ObservationFilter.new(terms) + assert_equal(pattern, filter.pattern) + end +end