From e1ce7ceb6fa34f41d804aa6db155bb2faf79b5f3 Mon Sep 17 00:00:00 2001 From: Phil Date: Thu, 2 Apr 2026 09:24:44 +0100 Subject: [PATCH 01/16] Refactored ExtraOptionConfigs to BaseConfiguration pattern with ActiveModel::Validations - fixes #986 --- app/helpers/application_helper.rb | 4 +- app/models/concerns/options_handler.rb | 79 +- .../option_configs/activity_log_options.rb | 61 +- .../base_named_configuration.rb | 71 + .../base_configuration.rb | 218 ++ .../extra_option_configs/batch_trigger.rb | 33 + .../extra_option_configs/caption_before.rb | 47 + .../extra_option_configs/config_base.rb | 106 + .../extra_option_configs/config_trigger.rb | 43 + .../extra_option_configs/creatable_if.rb | 25 + .../extra_option_configs/db_configs.rb | 16 + .../extra_option_configs/dialog_before.rb | 70 + .../extra_option_configs/e_sign_config.rb | 57 + .../extra_option_configs/editable_if.rb | 25 + .../extra_option_configs/embed.rb | 87 + .../extra_option_configs/field_configs.rb | 125 + .../extra_option_configs/field_options.rb | 36 + .../extra_option_configs/fields.rb | 23 + .../extra_option_configs/filestore.rb | 24 + .../extra_option_configs/if_condition.rb | 37 + .../extra_option_configs/label.rb | 32 + .../extra_option_configs/labels.rb | 13 + .../extra_option_configs/nfs_store_config.rb | 67 + .../extra_option_configs/preset_fields.rb | 13 + .../extra_option_configs/references.rb | 164 ++ .../extra_option_configs/save_action.rb | 45 + .../extra_option_configs/save_trigger.rb | 85 + .../extra_option_configs/set_variable.rb | 75 + .../extra_option_configs/show_if.rb | 41 + .../extra_option_configs/showable_if.rb | 25 + .../extra_option_configs/trigger_tasks.rb | 46 + .../extra_option_configs/valid_if.rb | 61 + .../extra_option_configs/view_options.rb | 24 + app/models/option_configs/extra_options.rb | 557 +---- .../validates/typed_attribute_validator.rb | 14 + spec/models/calc_actions/calculate_spec.rb | 6 +- .../dynamic_model_options_spec.rb | 18 +- .../extra_option_configs_spec.rb | 2099 +++++++++++++++++ .../extra_options_clean_methods_spec.rb | 1196 ++++++++++ spec/models/save_triggers/notify_spec.rb | 2 +- spec/support/activity_log_support.rb | 2 +- 41 files changed, 5249 insertions(+), 523 deletions(-) create mode 100644 app/models/option_configs/extra_option_configs/base_configuration.rb create mode 100644 app/models/option_configs/extra_option_configs/batch_trigger.rb create mode 100644 app/models/option_configs/extra_option_configs/caption_before.rb create mode 100644 app/models/option_configs/extra_option_configs/config_base.rb create mode 100644 app/models/option_configs/extra_option_configs/config_trigger.rb create mode 100644 app/models/option_configs/extra_option_configs/creatable_if.rb create mode 100644 app/models/option_configs/extra_option_configs/db_configs.rb create mode 100644 app/models/option_configs/extra_option_configs/dialog_before.rb create mode 100644 app/models/option_configs/extra_option_configs/e_sign_config.rb create mode 100644 app/models/option_configs/extra_option_configs/editable_if.rb create mode 100644 app/models/option_configs/extra_option_configs/embed.rb create mode 100644 app/models/option_configs/extra_option_configs/field_configs.rb create mode 100644 app/models/option_configs/extra_option_configs/field_options.rb create mode 100644 app/models/option_configs/extra_option_configs/fields.rb create mode 100644 app/models/option_configs/extra_option_configs/filestore.rb create mode 100644 app/models/option_configs/extra_option_configs/if_condition.rb create mode 100644 app/models/option_configs/extra_option_configs/label.rb create mode 100644 app/models/option_configs/extra_option_configs/labels.rb create mode 100644 app/models/option_configs/extra_option_configs/nfs_store_config.rb create mode 100644 app/models/option_configs/extra_option_configs/preset_fields.rb create mode 100644 app/models/option_configs/extra_option_configs/references.rb create mode 100644 app/models/option_configs/extra_option_configs/save_action.rb create mode 100644 app/models/option_configs/extra_option_configs/save_trigger.rb create mode 100644 app/models/option_configs/extra_option_configs/set_variable.rb create mode 100644 app/models/option_configs/extra_option_configs/show_if.rb create mode 100644 app/models/option_configs/extra_option_configs/showable_if.rb create mode 100644 app/models/option_configs/extra_option_configs/trigger_tasks.rb create mode 100644 app/models/option_configs/extra_option_configs/valid_if.rb create mode 100644 app/models/option_configs/extra_option_configs/view_options.rb create mode 100644 app/models/validates/typed_attribute_validator.rb create mode 100644 spec/models/option_configs/extra_option_configs_spec.rb create mode 100644 spec/models/option_configs/extra_options_clean_methods_spec.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c41dfcb3c1..8ad8174922 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -214,7 +214,9 @@ def show_caption_before(key, captions, mode: nil, no_sub: nil, ignore_missing: t mode ||= action_name == 'new' ? :new : :edit caption = captions[key] - caption = caption[:"#{mode}_caption"] || caption[:caption] || '' if caption.is_a?(Hash) + if caption.is_a?(Hash) || caption.is_a?(OptionConfigs::BaseNamedConfiguration) + caption = caption[:"#{mode}_caption"] || caption[:caption] || '' + end if @form_object_instance && !no_sub caption = Formatter::Substitution.substitute(caption, data: @form_object_instance, tag_subs: nil, ignore_missing:) diff --git a/app/models/concerns/options_handler.rb b/app/models/concerns/options_handler.rb index f0ed1c0175..4130b9ceaf 100644 --- a/app/models/concerns/options_handler.rb +++ b/app/models/concerns/options_handler.rb @@ -172,6 +172,72 @@ def configure_hash(config_item_name, with:) ch.const_set(config_item_name.ns_camelize, c) end + # + # Class method for declaring that a class stores a single direct value of a given type. + # This provides a consistent mechanism for defining configuration classes that hold + # one typed value (e.g., a string, array, hash, or if_condition) rather than + # multiple named attributes. + # + # The type metadata allows future validation and coercion. Currently supported types: + # - :string — stores a String value + # - :array — stores an Array value + # - :hash — stores an arbitrary Hash value + # - :if_condition — stores a conditional Hash (for access/validation conditions) + # + # Example: + # class Label < SomeBase + # configure_direct :label, type: :string + # end + # + # @param [Symbol] config_item_name - the name of the configuration item + # @param [Symbol] type - the value type (:string, :array, :hash, :if_condition) + def configure_direct(config_item_name, type:) + attr_accessor(config_item_name) unless method_defined?(config_item_name) + + add_option_type(:direct, config_item_name) + + # Store type metadata for future validation/coercion + @direct_types ||= {} + @direct_types[config_item_name] = type + end + + # + # Returns the registered direct types for this class. + # @return [Hash{Symbol => Symbol}] mapping of attribute name to type + def direct_types + @direct_types || {} + end + + # + # Class method for declaring a typed attribute whose value is an instance of + # a class inheriting from BaseConfiguration. + # + # When the including class is initialized with a hash configuration, the + # attribute value is automatically set by passing the corresponding hash + # entry to the type class constructor. + # + # @param [Symbol] config_item_name - the attribute name + # @param [Class] type - a class inheriting from BaseConfiguration that + # accepts a hash in its constructor + # + # @example + # configure_typed_attribute :creatable_if, type: ExtraOptionConfigs::IfCondition + def configure_typed_attribute(config_item_name, type:) + attr_accessor(config_item_name) unless method_defined?(config_item_name) + + add_option_type(:typed, config_item_name) + + @typed_attribute_types ||= {} + @typed_attribute_types[config_item_name] = type + end + + # + # Returns the registered typed attribute types for this class. + # @return [Hash{Symbol => Class}] mapping of attribute name to type class + def typed_attribute_types + @typed_attribute_types || {} + end + # # List of configuration items having child options. # Each represents the name of an accessor attribute in this model @@ -180,7 +246,9 @@ def option_types @option_types ||= { multi: [], simple: [], - hash: [] + hash: [], + direct: [], + typed: [] } end @@ -303,6 +371,7 @@ def setup_from_hash_config setup_all_options_multi hash_configuration setup_all_options_simple hash_configuration setup_all_options_hash hash_configuration + setup_all_options_typed hash_configuration hash_configuration end @@ -322,6 +391,14 @@ def setup_all_options_simple(hash_configuration) end end + def setup_all_options_typed(hash_configuration) + self.class.option_types[:typed]&.each do |option_type| + type_class = self.class.typed_attribute_types[option_type] + config_val = hash_configuration[option_type] + send("#{option_type}=", type_class.new(config_val)) + end + end + def setup_all_options_hash(hash_configuration) self.class.option_types[:hash].each do |option_type| setup_options_hash(hash_configuration, option_type) diff --git a/app/models/option_configs/activity_log_options.rb b/app/models/option_configs/activity_log_options.rb index 9d973495a7..3a8093f9a2 100644 --- a/app/models/option_configs/activity_log_options.rb +++ b/app/models/option_configs/activity_log_options.rb @@ -10,14 +10,21 @@ class ActivityLogOptions < ExtraOptions ValidNfsStoreCanPerformKeys = %i[download_if view_files_as_image_if view_files_as_html_if send_files_to_trash_if move_files_if user_file_actions_if].freeze + def self.config_class_registry + super.merge( + e_sign: ExtraOptionConfigs::ESignConfig, + nfs_store: ExtraOptionConfigs::NfsStoreConfig + ) + end + def self.add_key_attributes - %i[e_sign nfs_store] + [] end attr_accessor(*key_attributes) def initialize(name, config, parent_activity_log) - super(name, config, parent_activity_log) + super if @config_obj.disabled Rails.logger.info "configuration for this activity log has not been enabled: #{@config_obj.table_name}" @@ -26,57 +33,11 @@ def initialize(name, config, parent_activity_log) raise FphsException, 'extra log options name: property can not be blank' if self.name.blank? # Activity logs have some predefined captions. Set these up. - if caption_before && !caption_before.is_a?(Hash) + if caption_before && !caption_before.is_a?(Hash) && !caption_before.is_a?(ExtraOptionConfigs::BaseConfiguration) raise FphsException, 'extra log options caption_before: must be a hash of {field_name: caption, ...}' end init_caption_before - - clean_e_sign_def - clean_nfs_store_def - end - - def clean_nfs_store_def - return unless nfs_store - - can_perform = nfs_store[:can] - - unless valid_config_keys?(nfs_store, ValidNfsStoreKeys) - failed_config :nfs_store, - "nfs_store contains invalid keys #{nfs_store.keys} - " \ - "expected only #{ValidNfsStoreKeys}" - end - - unless can_perform.nil? || valid_config_keys?(can_perform, ValidNfsStoreCanPerformKeys) - failed_config :nfs_store__can, - "nfs_store.can contains invalid keys #{can_perform.keys} - " \ - "expected only #{ValidNfsStoreCanPerformKeys}" - end - - NfsStore::Config::ExtraOptions.clean_def nfs_store - end - - def clean_e_sign_def - return unless e_sign - - # Set up the structure so that we can use the standard reference methods to parse the configuration - e_sign[:document_reference] = { item: e_sign[:document_reference] } unless e_sign[:document_reference][:item] - e_sign[:document_reference].each_value do |refitem| - # Make all keys singular, to simplify configurations - refitem.transform_keys! do |k| - new_k = k.to_s.singularize.to_sym - end - - refitem.each do |mn, conf| - to_class = ModelReference.to_record_class_for_type(mn) - - refitem[mn][:to_record_label] = conf[:label] || to_class&.human_name - if to_class&.respond_to?(:no_master_association) - refitem[mn][:no_master_association] = to_class.no_master_association - end - refitem[mn][:to_model_name_us] = to_class&.to_s&.ns_underscore - end - end end # A list of all fields defined within all the individual activity definitions. This does not include @@ -129,7 +90,7 @@ def init_caption_before protocol_id: { caption: "Select the protocol this #{curr_name} is related to. A tracker event will be recorded under this protocol." }, - "set_related_#{item_type}_rank".to_sym => { + "set_related_#{item_type}_rank": { caption: "To change the rank of the related #{item_type.to_s.humanize}, select it:" } } diff --git a/app/models/option_configs/base_named_configuration.rb b/app/models/option_configs/base_named_configuration.rb index 30fa1f53ea..5cbba29c92 100644 --- a/app/models/option_configs/base_named_configuration.rb +++ b/app/models/option_configs/base_named_configuration.rb @@ -4,6 +4,61 @@ class BaseNamedConfiguration < OptionConfigs::BaseOptions attr_accessor :owner, :use_hash_config + # + # Hash-like access to configuration attributes by key name. + # Enables backward compatibility with code that expects Hash-like access + # on individual named configuration items, e.g. `named_config[:caption]` + # @param [Symbol | String] key - the attribute name + # @return [Object] the attribute value, or nil if not recognized + def [](key) + sym_key = key.to_sym + return nil unless respond_to?(sym_key) + + send(sym_key) + end + + # + # Convert all configured attributes to a plain Hash. + # Mirrors OptionsHandler::Configuration#to_h for named configurations. + # @return [Hash{Symbol => Object}] + def to_h + res = {} + self.class.option_types[:simple].each { |k| res[k] = send(k) } + res + end + + alias to_hash to_h + + # Hash-compatible dig for nested access on named configurations. + # @param keys [Array] nested key path + # @return [Object, nil] + def dig(*keys) + first = keys.shift + val = self[first] + return val if keys.empty? || val.nil? + + val.respond_to?(:dig) ? val.dig(*keys) : nil + end + + # + # Equality comparison: compare as plain Hash for backward compatibility + # with code that previously stored raw Hashes instead of NamedConfiguration objects. + # @param other [Object] value to compare against + # @return [Boolean] + def ==(other) + return to_h == other if other.is_a?(Hash) + + super + end + + # + # Return a Hash containing only non-nil attribute values. + # Useful for serialization where nil values should be omitted. + # @return [Hash{Symbol => Object}] + def filtered_hash + to_h.reject { |_k, v| v.nil? } + end + def config_text return super unless owner @@ -24,5 +79,21 @@ def persisted? owner.persisted? end + + # Check for keys in hash_configuration that don't match any declared + # configure_attributes. Reports each unrecognized key as a warning + # on the owner (BaseConfiguration) via failed_config. + # Called after setup_from_hash_config so that declared attributes are known. + def validate_recognized_keys + return unless hash_configuration.is_a?(Hash) + return unless owner&.respond_to?(:failed_config, true) + + recognized = self.class.option_types[:simple].to_set + hash_configuration.each_key do |key| + next if recognized.include?(key) + + owner.send(:failed_config, key, "unrecognized attribute '#{key}'", level: :warn) + end + end end end diff --git a/app/models/option_configs/extra_option_configs/base_configuration.rb b/app/models/option_configs/extra_option_configs/base_configuration.rb new file mode 100644 index 0000000000..00ebf47223 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/base_configuration.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Base class for field-keyed configuration classes that follow the + # BaseConfiguration/NamedConfiguration pattern. + # + # Inherits from OptionConfigs::BaseConfiguration and provides: + # - Hash initialization: `ConfigClass.new({ field1: value1, field2: value2 })` + # - Hash-like interface: `[]`, `[]=`, `merge!`, `keys`, `each`, `blank?`, `key?` + # - Backward compatibility: `symbolize_keys`, `as_json`, `to_json` + # - Error reporting: `failed_config` stores errors locally for ExtraOptions to collect + # + # Subclasses may: + # - Define a NamedConfiguration inner class for structured field values + # - Override `add_named_configuration` for preprocessing (call super with processed value) + # - Define `self.prepare_config(raw, parent)` for pre-initialization context needs + # - Override `self.store_processed_value?` to return true for classes that should + # store their processed attribute value on the parent ExtraOptions instead of the object + class BaseConfiguration < OptionConfigs::BaseConfiguration + include Enumerable + + # Provide a model_name that handles anonymous subclasses. + # ActiveModel::Name requires a class name; anonymous classes have nil. + # Falls back to 'Configuration' for anonymous classes. + def self.model_name + @_model_name ||= ActiveModel::Name.new(self, nil, name || 'Configuration') + end + + # Whether the registry should store the processed attribute value (not the object) + # on the parent ExtraOptions. Override to return true in subclasses that act as + # value preprocessors (e.g. Label, Fields, *_if classes). + # @return [Boolean] + def self.store_processed_value? + false + end + + # Initialize with just a hash config (no owner required). + # Bypasses the parent's owner-based initialization since these + # config classes don't persist independently — managed by ExtraOptions. + # @param [Hash, Array, nil] hash_config - raw configuration (usually a Hash keyed by field name, + # but may be an Array for classes like TriggerTasks that accept list values) + def initialize(hash_config = {}) + self.errors = ActiveModel::Errors.new(self) + self.config_errors = [] + self.config_warnings = [] + self.configurations = {} + self.hash_configuration = hash_config.is_a?(Hash) ? hash_config.symbolize_keys : (hash_config || {}) + setup_named_configurations + run_validations + end + + # Default setup: iterate hash entries and create configurations. + # Subclasses override for custom preprocessing. + # @return [void] + def setup_named_configurations + hash_configuration.each do |k, v| + add_named_configuration(k.to_sym, v) + end + end + + # Override parent's add_named_configuration to pass value directly + # (not wrapped in {key => value}). + # For classes with NamedConfiguration and Hash values, creates a NamedConfiguration. + # For classes without (or with non-Hash values), stores the value directly. + # @param [Symbol] sym_key - field name + # @param [Object] value - field configuration value + def add_named_configuration(sym_key, value) + configurations[sym_key] = if self.class.const_defined?(:NamedConfiguration) && value.is_a?(Hash) + nc = self.class::NamedConfiguration.new(self, use_hash_config: value) + nc.validate_recognized_keys + nc + else + value + end + end + + # Assign a field configuration by key. + # @param [Symbol | String] key - field name + # @param [Object] value - field configuration + def []=(key, value) + add_named_configuration(key.to_sym, value) + end + + # Merge a plain hash of field configurations. + # @param [Hash] other_hash - hash of { field_name: config } entries + # @return [self] + def merge!(other_hash) + other_hash.each { |k, v| add_named_configuration(k.to_sym, v) } + self + end + + # Return symbol keys of all configured fields. + # @return [Array] + def keys + configurations.keys + end + + # Check if a field key exists. + # @param [Symbol | String] key + # @return [Boolean] + def key?(key) + configurations.key?(key.to_sym) + end + + alias has_key? key? + + # Returns true when no fields are configured. + def blank? + configurations.blank? + end + + # Returns true when no fields are configured (Hash-compatible). + def empty? + configurations.empty? + end + + # Hash-compatible dig for nested access. + # Delegates to configurations hash then chains dig on the result. + # @param keys [Array] nested key path + # @return [Object, nil] + def dig(*keys) + first = keys.shift + val = configurations[first.to_sym] + return val if keys.empty? || val.nil? + + val.respond_to?(:dig) ? val.dig(*keys) : nil + end + + # Hash-compatible iteration methods delegated to configurations. + delegate :each_value, :each_key, :each_pair, :values, :size, :length, + :any?, :all?, :none?, :count, :to_a, :map, to: :configurations + + # Delegates to configurations hash for iteration. + # Yields [key, value] pairs for iteration. + # @yield [Symbol, Object] field name and its configuration value + def each(&) + configurations.each(&) + end + + # Override Enumerable#select to preserve Hash return type, + # matching the behavior of Hash#select/filter. + # @return [Hash] + def select(&) + configurations.select(&) + end + + alias filter select + + # Equality comparison: compare as plain Hash for backward compatibility + # with code that previously compared against Hash literals. + # @param other [Object] value to compare against + # @return [Boolean] + def ==(other) + return symbolize_keys == other if other.is_a?(Hash) + + super + end + + # Returns a plain Hash representation for backward compatibility. + # NamedConfiguration values are converted to filtered hashes; + # plain values are returned as-is. + # @return [Hash{Symbol => Object}] + def symbolize_keys + configurations.transform_values do |v| + v.respond_to?(:filtered_hash) ? v.filtered_hash : v + end + end + + # JSON serialization producing the same format as the original plain hash. + # @return [Hash] + def as_json(options = nil) + symbolize_keys.as_json(options) + end + + def to_json(*) + as_json.to_json(*) + end + + # OptionsHandler persistence stubs — field-keyed configs don't store YAML independently + def config_text = nil + + def config_text=(value); end + + def save_options; end + + def persisted? = false + + protected + + # Error reporting — stores errors locally. + # ExtraOptions collects these after initialization. + # @param [Symbol] type - error category + # @param [String] message - error description + # @param [Object] extra_details - additional context + # @param [Symbol] level - :error or :warn + def failed_config(type, message, extra_details: nil, level: :error) + target = (level == :warn ? config_warnings : config_errors) + target << { type:, message:, extra_details: } + end + + private + + # Bridge ActiveModel::Validations errors into config_errors. + # Called at the end of initialize so that subclass validates + # declarations are checked after setup_named_configurations. + # Errors with options[:type] == :warning are directed to config_warnings. + def run_validations + return if valid? + + errors.each do |error| + level = error.options[:type] == :warning ? :warn : :error + failed_config error.attribute, "#{error.attribute} #{error.message}", level: level + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/batch_trigger.rb b/app/models/option_configs/extra_option_configs/batch_trigger.rb new file mode 100644 index 0000000000..5cf7633c4a --- /dev/null +++ b/app/models/option_configs/extra_option_configs/batch_trigger.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for batch trigger setup. + # Uses the BaseConfiguration pattern with a single typed attribute + # `:on_record` that is a TriggerTasks instance. + # + # @example + # bt = BatchTrigger.new(on_record: { notify: { type: 'email' } }) + # bt[:on_record] #=> TriggerTasks instance + # bt[:on_record].tasks #=> { notify: { type: 'email' } } + class BatchTrigger < BaseConfiguration + configure_typed_attribute :on_record, type: TriggerTasks + + # Set up typed attributes from the hash configuration. + # Delegates to OptionsHandler's setup_all_options_typed which + # instantiates TriggerTasks for the :on_record key. + # @return [void] + def setup_named_configurations + setup_all_options_typed(hash_configuration) + # Store the raw tasks value in configurations for hash-like access. + # Consumers expect raw Array/Hash, not TriggerTasks instance. + configurations[:on_record] = on_record.respond_to?(:tasks) ? on_record.tasks : on_record + end + + # Returns true if there are no trigger tasks configured. + def blank? + on_record.blank? + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/caption_before.rb b/app/models/option_configs/extra_option_configs/caption_before.rb new file mode 100644 index 0000000000..108fe34b25 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/caption_before.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for caption formatting (text-to-HTML conversion). + # Extracted from ExtraOptions#clean_caption_before_def + # + # Each field name maps to a NamedConfiguration with caption mode attributes. + # Preprocessing (string → hash expansion, text_to_html conversion) happens + # via add_named_configuration override. + class CaptionBefore < BaseConfiguration + # Named configuration for a single field's caption settings. + # Each field (e.g. :test1) has caption values for different display modes. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[caption edit_caption show_caption new_caption] + end + + # Override to preprocess caption values before creating NamedConfiguration. + # Handles string → hash expansion and text_to_html conversion. + def add_named_configuration(sym_key, value) + super(sym_key, preprocess_field(value)) + end + + private + + # Pre-process a single field's raw value into a hash suitable for NamedConfiguration. + # - String values are expanded to all 4 caption mode keys with HTML conversion + # - Hash values have text_to_html applied to each mode value + # - new_caption defaults to edit_caption when not specified + # @param [String | Hash] value - raw caption value + # @return [Hash{Symbol => String}] processed hash with caption mode keys + def preprocess_field(value) + if value.is_a?(String) + html = Formatter::Substitution.text_to_html(value).strip + { caption: html, edit_caption: html, show_caption: html, new_caption: html } + elsif value.is_a?(Hash) + processed = {} + value.each { |mode, modeval| processed[mode.to_sym] = Formatter::Substitution.text_to_html(modeval).to_s.strip } + processed[:new_caption] = processed[:edit_caption] unless processed.key?(:new_caption) + processed + else + {} + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/config_base.rb b/app/models/option_configs/extra_option_configs/config_base.rb new file mode 100644 index 0000000000..c83cba7bd7 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/config_base.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Base class for all ExtraOptions configuration classes. + # Each subclass encapsulates the normalization/cleaning logic + # for one top-level configuration area in ExtraOptions. + # + # Subclasses should: + # - Declare managed attributes with `attribute :name` + # - Implement `#clean` to normalize values + # - Use `#failed_config` to report configuration errors + # + # @example + # class LabelDef < ConfigBase + # attribute :label + # + # def clean + # self.label = parent_options.label || parent_options.name.to_s.humanize + # end + # end + class ConfigBase + include ActiveModel::Validations + + attr_reader :parent_options + + # Track which attributes this config class manages + # Each subclass maintains its own list via Ruby class instance variables + # @return [Array] + def self.managed_attributes + @managed_attributes ||= [] + end + + # Declare one or more managed attributes on this config class. + # Creates attr_accessor and registers the attribute names. + # @param names [Array] attribute names + def self.attribute(*names) + attr_accessor(*names) + + managed_attributes.push(*names) + end + + # Declare that this class stores a single direct value of a given type. + # Records type metadata in option_types[:direct] for reflection. + # @param config_item_name [Symbol] the attribute name + # @param type [Symbol] the value type (:string, :array, :hash, :if_condition) + def self.configure_direct(config_item_name, type:) + attribute(config_item_name) unless managed_attributes.include?(config_item_name) + + option_types[:direct] << config_item_name + + @direct_types ||= {} + @direct_types[config_item_name] = type + end + + # Returns the option types declared on this class. + # @return [Hash{Symbol => Array}] + def self.option_types + @option_types ||= { direct: [] } + end + + # Returns the registered direct types for this class. + # @return [Hash{Symbol => Symbol}] + def self.direct_types + @direct_types || {} + end + + # @param parent_options [OptionConfigs::ExtraOptions] the parent ExtraOptions instance + def initialize(parent_options) + @parent_options = parent_options + clean + end + + # Write cleaned attribute values back to the parent ExtraOptions instance. + # Called after #clean to synchronize values. + def apply_to_parent! + self.class.managed_attributes.each do |attr| + parent_options.send("#{attr}=", send(attr)) + end + end + + # Override in subclasses to perform cleaning/normalization + # @raise [NotImplementedError] if not overridden + def clean + raise NotImplementedError, "#{self.class.name} must implement #clean" + end + + private + + # Access the dynamic definition record (DynamicModel, ActivityLog, etc.) + # @return [ActiveRecord::Base] + def config_obj + parent_options.config_obj + end + + # Delegate error reporting to the parent ExtraOptions instance + # @param type [Symbol] error category + # @param message [String] error message + # @param extra_details [Object] additional detail + # @param level [Symbol] :error or :warn + def failed_config(type, message, extra_details: nil, level: :error) + parent_options.send(:failed_config, type, message, extra_details:, level:) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/config_trigger.rb b/app/models/option_configs/extra_option_configs/config_trigger.rb new file mode 100644 index 0000000000..a38cb66115 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/config_trigger.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for config trigger setup. + # Converted from ConfigBase to BaseConfiguration pattern. + # Uses a typed TriggerTasks attribute for on_define. + # + # Handles: + # - Wrapping a single on_define hash in an array + # - Defaulting on_define to an empty TriggerTasks when not provided + # + # @example + # ct = ConfigTrigger.new(on_define: [{ action: 'do_something' }]) + # ct.on_define.tasks #=> [{ action: 'do_something' }] + class ConfigTrigger < BaseConfiguration + configure_typed_attribute :on_define, type: TriggerTasks + + # Preprocess on_define (wrap non-array in array, default to empty) + # then delegate to typed attribute initialization. + # @return [void] + def setup_named_configurations + od = hash_configuration[:on_define] + od = [od] if od.is_a?(Hash) + hash_configuration[:on_define] = od || [] + + setup_all_options_typed(hash_configuration) + + # Store raw tasks values in configurations for hash-like bracket access. + # Consumers expect raw Array/Hash, not TriggerTasks instances. + self.class.option_types[:typed].each do |key| + typed = send(key) + configurations[key] = typed.respond_to?(:tasks) ? typed.tasks : typed + end + end + + # Returns true when on_define has no tasks. + def blank? + on_define.blank? + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/creatable_if.rb b/app/models/option_configs/extra_option_configs/creatable_if.rb new file mode 100644 index 0000000000..83d8ee9b77 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/creatable_if.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for creatable_if access control condition. + # Converted from ConfigBase to BaseConfiguration pattern. + # Split from AccessIf to manage a single if_condition directly. + # The processed hash is stored back on the parent ExtraOptions (not the object). + class CreatableIf < BaseConfiguration + configure_direct :creatable_if, type: :hash + + def self.store_processed_value? + true + end + + # Store the symbolized hash value, defaulting to empty hash. + # Populate configurations for hash-like bracket access. + # @return [void] + def setup_named_configurations + self.creatable_if = hash_configuration.presence || {} + creatable_if.each { |k, v| configurations[k] = v } + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/db_configs.rb b/app/models/option_configs/extra_option_configs/db_configs.rb new file mode 100644 index 0000000000..1ef9a50873 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/db_configs.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for database column configs. + # Extracted from ExtraOptions#clean_db_configs_def + # + # Values are column configuration hashes keyed by column name. + # The mutation of config_obj.db_columns is handled by ExtraOptions + # after this config class runs. + class DbConfigs < BaseConfiguration + # No special processing needed — keys are already symbolized + # by parse_options_text's deep_symbolize_keys! + end + end +end diff --git a/app/models/option_configs/extra_option_configs/dialog_before.rb b/app/models/option_configs/extra_option_configs/dialog_before.rb new file mode 100644 index 0000000000..58fba8b839 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/dialog_before.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for dialog overlay definitions. + # Extracted from ExtraOptions#clean_dialog_before_def + # + # Each field name maps to a NamedConfiguration with dialog attributes. + # String values are expanded to { name: string } hashes. + # Validates that referenced Admin::MessageTemplate records exist. + class DialogBefore < BaseConfiguration + # Named configuration for a single field's dialog settings. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[name label keep_label] + end + + validate :validate_dialog_entries + + # Override to preprocess and validate dialog values. + # Converts strings to { name: string } hashes, validates template existence. + def add_named_configuration(sym_key, value) + processed = preprocess_field(sym_key, value) + return unless processed + + super(sym_key, processed) + end + + private + + # Pre-process a single dialog_before value. + # @param [Symbol] key - field name (used in error messages) + # @param [String | Hash] value - raw dialog value + # @return [Hash | nil] processed hash, or nil if invalid + def preprocess_field(key, value) + if value.is_a?(String) + { name: value } + elsif value.is_a?(Hash) + value.symbolize_keys + else + nil + end + end + + # Validate dialog_before entries via ActiveModel validate callback. + # Checks for invalid types and missing message templates. + def validate_dialog_entries + return if hash_configuration.blank? + + hash_configuration.each do |key, value| + unless value.is_a?(String) || value.is_a?(Hash) + errors.add(:dialog_before, + "must be a Hash { name: '