diff --git a/README.md b/README.md index 53617e4b..1f84badc 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,23 @@ to run. Since arguments are specified in the user interface via text area inputs, it’s important to check that they conform to the format your Task expects, and to sanitize any inputs if necessary. +#### Validating Task Parameters + +Task attributes can be validated using Active Model Validations. Attributes are +validated before a Task is enqueued. + +If an attribute uses an inclusion validator with a supported `in:` option, the +set of values will be used to populate a dropdown in the user interface. The +following types are supported: + +* Arrays +* Procs and lambdas that optionally accept the Task instance, and return an Array. +* Callable objects that receive one argument, the Task instance, and return an Array. +* Methods that return an Array, called on the Task instance. + +For enumerables that don't match the supported types, a text field will be +rendered instead. + ### Custom cursor columns to improve performance The [job-iteration gem][job-iteration], on which this gem depends, adds an diff --git a/app/helpers/maintenance_tasks/tasks_helper.rb b/app/helpers/maintenance_tasks/tasks_helper.rb index 8685af9f..56444a13 100644 --- a/app/helpers/maintenance_tasks/tasks_helper.rb +++ b/app/helpers/maintenance_tasks/tasks_helper.rb @@ -102,8 +102,13 @@ def csv_file_download_path(run) end # Resolves values covered by the inclusion validator for a task attribute. - # Only Arrays are supported, option types such as: - # Procs, lambdas, symbols, and Range are not supported and return nil. + # Supported option types: + # - Arrays + # - Procs and lambdas that optionally accept the Task instance, and return an Array. + # - Callable objects that receive one argument, the Task instance, and return an Array. + # - Methods that return an Array, called on the Task instance. + # + # Other types are not supported and will return nil. # # Returned values are used to populate a dropdown list of options. # @@ -111,20 +116,39 @@ def csv_file_download_path(run) # @param parameter_name [String] The parameter name. # # @return [Array] value of the resolved inclusion option. - def resolve_inclusion_value(task_class, parameter_name) + def resolve_inclusion_value(task, parameter_name) + task_class = task.class inclusion_validator = task_class.validators_on(parameter_name).find do |validator| validator.kind == :inclusion end return unless inclusion_validator in_option = inclusion_validator.options[:in] || inclusion_validator.options[:within] - in_option if in_option.is_a?(Array) + resolved_in_option = case in_option + when Proc + if in_option.arity == 0 + in_option.call + else + in_option.call(task) + end + when Symbol + method = task.method(in_option) + method.call if method.arity.zero? + else + if in_option.respond_to?(:call) + in_option.call(task) + else + in_option + end + end + + resolved_in_option if resolved_in_option.is_a?(Array) end # Return the appropriate field tag for the parameter, based on its type. # If the parameter has a `validates_inclusion_of` validator, return a dropdown list of options instead. def parameter_field(form_builder, parameter_name) - inclusion_values = resolve_inclusion_value(form_builder.object.class, parameter_name) + inclusion_values = resolve_inclusion_value(form_builder.object, parameter_name) return form_builder.select(parameter_name, inclusion_values, prompt: "Select a value") if inclusion_values case form_builder.object.class.attribute_types[parameter_name] diff --git a/test/dummy/app/tasks/maintenance/params_task.rb b/test/dummy/app/tasks/maintenance/params_task.rb index 7eb4cca8..c03f5629 100644 --- a/test/dummy/app/tasks/maintenance/params_task.rb +++ b/test/dummy/app/tasks/maintenance/params_task.rb @@ -1,6 +1,14 @@ # frozen_string_literal: true module Maintenance + class DropdownOptions + class << self + def call(_task) + [100, 200, 300] + end + end + end + class ParamsTask < MaintenanceTasks::Task attribute :post_ids, :string @@ -20,24 +28,31 @@ class ParamsTask < MaintenanceTasks::Task # Dropdown options with supported scenarios attribute :integer_dropdown_attr, :integer + attribute :integer_dropdown_attr_proc_no_arg, :integer + attribute :integer_dropdown_attr_proc_arg, :integer + attribute :integer_dropdown_attr_from_method, :integer + attribute :integer_dropdown_attr_callable, :integer attribute :boolean_dropdown_attr, :boolean validates_inclusion_of :integer_dropdown_attr, in: [100, 200, 300], allow_nil: true + validates_inclusion_of :integer_dropdown_attr_proc_no_arg, in: proc { [100, 200, 300] }, allow_nil: true + validates_inclusion_of :integer_dropdown_attr_proc_arg, in: proc { |_task| [100, 200, 300] }, allow_nil: true + validates_inclusion_of :integer_dropdown_attr_from_method, in: :dropdown_attr_options, allow_nil: true + validates_inclusion_of :integer_dropdown_attr_callable, in: DropdownOptions, allow_nil: true validates_inclusion_of :boolean_dropdown_attr, within: [true, false], allow_nil: true # Dropdown options with unsupported scenarios - attribute :text_integer_attr, :integer - attribute :text_integer_attr2, :integer - attribute :text_integer_attr3, :integer - - validates_inclusion_of :text_integer_attr, in: proc { [100, 200, 300] }, allow_nil: true - validates_inclusion_of :text_integer_attr2, in: :undefined_symbol, allow_nil: true - validates_inclusion_of :text_integer_attr3, in: (100..), allow_nil: true + attribute :text_integer_attr_unbounded_range, :integer + validates_inclusion_of :text_integer_attr_unbounded_range, in: (100..), allow_nil: true class << self attr_accessor :fast_task end + def dropdown_attr_options + [100, 200, 300] + end + def collection Post.where(id: post_ids_array) end diff --git a/test/helpers/maintenance_tasks/tasks_helper_test.rb b/test/helpers/maintenance_tasks/tasks_helper_test.rb index af0d30cf..da09437c 100644 --- a/test/helpers/maintenance_tasks/tasks_helper_test.rb +++ b/test/helpers/maintenance_tasks/tasks_helper_test.rb @@ -92,13 +92,18 @@ class TasksHelperTest < ActionView::TestCase end test "#resolve_inclusion_value resolves inclusion validator for task attributes" do - assert_match "Select a value", markup("integer_dropdown_attr").squish - - assert_match "Select a value", markup("boolean_dropdown_attr").squish - - ["text_integer_attr", "text_integer_attr2", "text_integer_attr3"].each do |text_integer_attr| - refute_match "Select a value", markup(text_integer_attr).squish + [ + "integer_dropdown_attr", + "boolean_dropdown_attr", + "integer_dropdown_attr_proc_no_arg", + "integer_dropdown_attr_proc_arg", + "integer_dropdown_attr_from_method", + "integer_dropdown_attr_callable", + ].each do |attribute| + assert_match "Select a value", markup(attribute).squish end + + refute_match "Select a value", markup("text_integer_attr_unbounded_range").squish end test "#parameter_field adds information about datetime fields when Time.zone_default is not set" do diff --git a/test/models/maintenance_tasks/task_data_show_test.rb b/test/models/maintenance_tasks/task_data_show_test.rb index ef6cc672..74141085 100644 --- a/test/models/maintenance_tasks/task_data_show_test.rb +++ b/test/models/maintenance_tasks/task_data_show_test.rb @@ -88,10 +88,12 @@ class TaskDataShowTest < ActiveSupport::TestCase "time_attr", "boolean_attr", "integer_dropdown_attr", + "integer_dropdown_attr_proc_no_arg", + "integer_dropdown_attr_proc_arg", + "integer_dropdown_attr_from_method", + "integer_dropdown_attr_callable", "boolean_dropdown_attr", - "text_integer_attr", - "text_integer_attr2", - "text_integer_attr3", + "text_integer_attr_unbounded_range", ], TaskDataShow.new("Maintenance::ParamsTask").parameter_names, ) diff --git a/test/system/maintenance_tasks/tasks_test.rb b/test/system/maintenance_tasks/tasks_test.rb index 9f3895c8..51f83a1d 100644 --- a/test/system/maintenance_tasks/tasks_test.rb +++ b/test/system/maintenance_tasks/tasks_test.rb @@ -117,24 +117,24 @@ class TasksTest < ApplicationSystemTestCase assert_equal("input", boolean_field.tag_name) assert_equal("checkbox", boolean_field[:type]) - integer_dropdown_field = page.find_field("task[integer_dropdown_attr]") - assert_equal("select", integer_dropdown_field.tag_name) - assert_equal("select-one", integer_dropdown_field[:type]) - integer_dropdown_field_options = integer_dropdown_field.find_all("option").map { |option| option[:value] } - assert_equal(["", "100", "200", "300"], integer_dropdown_field_options) - - boolean_dropdown_field = page.find_field("task[boolean_dropdown_attr]") - assert_equal("select", boolean_dropdown_field.tag_name) - assert_equal("select-one", boolean_dropdown_field[:type]) - boolean_dropdown_field_options = boolean_dropdown_field.find_all("option").map { |option| option[:value] } - assert_equal(["", "true", "false"], boolean_dropdown_field_options) - - ["text_integer_attr", "text_integer_attr2", "text_integer_attr3"].each do |text_integer_attr| - text_integer_dropdown_field = page.find_field("task[#{text_integer_attr}]") - assert_equal("input", text_integer_dropdown_field.tag_name) - assert_equal("number", text_integer_dropdown_field[:type]) - assert_empty(text_integer_dropdown_field[:step]) + [ + "integer_dropdown_attr", + "integer_dropdown_attr_proc_no_arg", + "integer_dropdown_attr_proc_arg", + "integer_dropdown_attr_from_method", + "integer_dropdown_attr_callable", + ].each do |dropdown_integer_attr| + integer_dropdown_field = page.find_field("task[#{dropdown_integer_attr}]") + assert_equal("select", integer_dropdown_field.tag_name) + assert_equal("select-one", integer_dropdown_field[:type]) + integer_dropdown_field_options = integer_dropdown_field.find_all("option").map { |option| option[:value] } + assert_equal(["", "100", "200", "300"], integer_dropdown_field_options) end + + text_integer_field = page.find_field("task[text_integer_attr_unbounded_range]") + assert_equal("input", text_integer_field.tag_name) + assert_equal("number", text_integer_field[:type]) + assert_empty(text_integer_field[:step]) end test "view a Task with multiple pages of Runs" do