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/admin/defs/extra_options_add_reference_if_pattern_1_always_defs.yaml b/app/models/admin/defs/extra_options_add_reference_if_pattern_1_always_defs.yaml new file mode 100644 index 0000000000..d6ad9f98e4 --- /dev/null +++ b/app/models/admin/defs/extra_options_add_reference_if_pattern_1_always_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 1: Always allow adding the reference + add_reference_if: + always: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_add_reference_if_pattern_2_never_defs.yaml b/app/models/admin/defs/extra_options_add_reference_if_pattern_2_never_defs.yaml new file mode 100644 index 0000000000..860be6e6df --- /dev/null +++ b/app/models/admin/defs/extra_options_add_reference_if_pattern_2_never_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 2: Never allow adding the reference + add_reference_if: + never: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_add_reference_if_pattern_3_conditional_defs.yaml b/app/models/admin/defs/extra_options_add_reference_if_pattern_3_conditional_defs.yaml new file mode 100644 index 0000000000..be9915f3c9 --- /dev/null +++ b/app/models/admin/defs/extra_options_add_reference_if_pattern_3_conditional_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: Conditional reference addition + add_reference_if: + all: + this: + status: active + not_any: + activity_log__example_reviews: + extra_log_type: closed \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_caption_before_defs.yaml b/app/models/admin/defs/extra_options_caption_before_defs.yaml index 0f2cc87c27..5ada4bd2cc 100644 --- a/app/models/admin/defs/extra_options_caption_before_defs.yaml +++ b/app/models/admin/defs/extra_options_caption_before_defs.yaml @@ -1,18 +1,40 @@ # caption_before options +# Canonical schema +# caption_before: +# +# Key type +# caption_target_key: > +# +# Value type +# CaptionBeforeValue: +# All keys are optional. +# +# Pattern 1: Simple caption string +#!defs(extra_options_caption_before_pattern_1_simple_defs.yaml) +# +# Pattern 2: Caption hash with keep_label +#!defs(extra_options_caption_before_pattern_2_keep_label_defs.yaml) +# +# Pattern 3: View-specific captions +#!defs(extra_options_caption_before_pattern_3_view_specific_defs.yaml) +# +# Special keys caption_before: - field_name: | - String caption to appear before field. Use markdown formatting if needed. - - [Substitutions](../general/substitutions.md) of data can be performed with `\{\{data_attribute\}\}` notation. all_fields: caption to appear before all fields submit: caption to appear before submit button - field_to_retain_label: - keep_label: true - caption: caption to appear before label - field_with_different_views: - show_caption: caption in show mode - edit_caption: caption in edit mode - new_caption: caption in new mode - reference_with_reference_name: | + reference_: | add caption above a reference action / list where the reference is named reference_ + +# Notes: +# - String values are expanded to all caption modes with text_to_html conversion. +# - new_caption defaults to edit_caption when not specified. +# - all_fields and submit are pseudo-keys excluded from field_configs merging. +# - reference_ adds a caption above the named reference action/list. +# - and must refer to valid configured items in the current definition. diff --git a/app/models/admin/defs/extra_options_caption_before_pattern_1_simple_defs.yaml b/app/models/admin/defs/extra_options_caption_before_pattern_1_simple_defs.yaml new file mode 100644 index 0000000000..5b345811aa --- /dev/null +++ b/app/models/admin/defs/extra_options_caption_before_pattern_1_simple_defs.yaml @@ -0,0 +1,7 @@ +# Pattern 1: Simple caption string +# Specify a caption to appear before a field, hiding the field's label. + caption_before: + field_name: | + String caption to appear before field. Use markdown formatting if needed. + + [Substitutions](../general/substitutions.md) of data can be performed with `\{\{data_attribute\}\}` notation. diff --git a/app/models/admin/defs/extra_options_caption_before_pattern_2_keep_label_defs.yaml b/app/models/admin/defs/extra_options_caption_before_pattern_2_keep_label_defs.yaml new file mode 100644 index 0000000000..3dfea92106 --- /dev/null +++ b/app/models/admin/defs/extra_options_caption_before_pattern_2_keep_label_defs.yaml @@ -0,0 +1,6 @@ +# Pattern 2: Caption hash with keep_label +# Specify a field with a caption hash allowing an option to retain the label. + caption_before: + field_name: + caption: caption to appear before label + keep_label: true diff --git a/app/models/admin/defs/extra_options_caption_before_pattern_3_view_specific_defs.yaml b/app/models/admin/defs/extra_options_caption_before_pattern_3_view_specific_defs.yaml new file mode 100644 index 0000000000..11bd6eca9d --- /dev/null +++ b/app/models/admin/defs/extra_options_caption_before_pattern_3_view_specific_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: View-specific captions +# Specify a field with captions that appear in different views. + caption_before: + field_name: + show_caption: caption in show mode + edit_caption: caption in edit mode + new_caption: caption in new mode + keep_label: true diff --git a/app/models/admin/defs/extra_options_creatable_if_defs.yaml b/app/models/admin/defs/extra_options_creatable_if_defs.yaml index 8f7f344855..a5b54389e3 100644 --- a/app/models/admin/defs/extra_options_creatable_if_defs.yaml +++ b/app/models/admin/defs/extra_options_creatable_if_defs.yaml @@ -1,2 +1,22 @@ # creatable_if options - creatable_if: 'ref: ** conditions reference **' +# +# Pattern 1: Always creatable + creatable_if: + always: true + +# Pattern 2: Never creatable + creatable_if: + never: true + +# Pattern 3: Creatable only when conditions are met + creatable_if: + all: + this: + status: active + not_any: + activity_log__example_reviews: + extra_log_type: closed + +# Notes: +# - creatable_if must be a Hash using the standard conditional-actions syntax. +# - This commonly interacts with view_options.hide_unless_creatable and with references.creatable_if. diff --git a/app/models/admin/defs/extra_options_creatable_if_pattern_1_always_defs.yaml b/app/models/admin/defs/extra_options_creatable_if_pattern_1_always_defs.yaml new file mode 100644 index 0000000000..880b8aacd1 --- /dev/null +++ b/app/models/admin/defs/extra_options_creatable_if_pattern_1_always_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 1: Always creatable + creatable_if: + always: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_creatable_if_pattern_2_never_defs.yaml b/app/models/admin/defs/extra_options_creatable_if_pattern_2_never_defs.yaml new file mode 100644 index 0000000000..a0fc3be9f4 --- /dev/null +++ b/app/models/admin/defs/extra_options_creatable_if_pattern_2_never_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 2: Never creatable + creatable_if: + never: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_creatable_if_pattern_3_conditional_defs.yaml b/app/models/admin/defs/extra_options_creatable_if_pattern_3_conditional_defs.yaml new file mode 100644 index 0000000000..08b4b5713f --- /dev/null +++ b/app/models/admin/defs/extra_options_creatable_if_pattern_3_conditional_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: Creatable only when conditions are met + creatable_if: + all: + this: + status: active + not_any: + activity_log__example_reviews: + extra_log_type: closed \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_dialog_before_defs.yaml b/app/models/admin/defs/extra_options_dialog_before_defs.yaml index 645e648bef..a4d2fadc33 100644 --- a/app/models/admin/defs/extra_options_dialog_before_defs.yaml +++ b/app/models/admin/defs/extra_options_dialog_before_defs.yaml @@ -1,11 +1,34 @@ # dialog_before options +# Canonical schema +# dialog_before: +# +# Key type +# dialog_target_key: +# +# Value type +# DialogBeforeValue: +# Keys are optional unless marked (required). +# +# Pattern 1: Simple string (template name only) +#!defs(extra_options_dialog_before_pattern_1_simple_defs.yaml) +# +# Pattern 2: Hash with label +#!defs(extra_options_dialog_before_pattern_2_hash_defs.yaml) +# +# Special keys dialog_before: - field_name: - name: message template name - label: show dialog button label all_fields: name: message template name label: show dialog button label submit: name: message template name label: show dialog button label + +# Notes: +# - String values are expanded to { name: string }. +# - The name must reference an existing Admin::MessageTemplate. +# - all_fields and submit are pseudo-keys with the same behavior as caption_before. diff --git a/app/models/admin/defs/extra_options_dialog_before_pattern_1_simple_defs.yaml b/app/models/admin/defs/extra_options_dialog_before_pattern_1_simple_defs.yaml new file mode 100644 index 0000000000..f9274c03ae --- /dev/null +++ b/app/models/admin/defs/extra_options_dialog_before_pattern_1_simple_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 1: Simple string (template name only) + dialog_before: + field_name: message template name diff --git a/app/models/admin/defs/extra_options_dialog_before_pattern_2_hash_defs.yaml b/app/models/admin/defs/extra_options_dialog_before_pattern_2_hash_defs.yaml new file mode 100644 index 0000000000..c4be99978f --- /dev/null +++ b/app/models/admin/defs/extra_options_dialog_before_pattern_2_hash_defs.yaml @@ -0,0 +1,5 @@ +# Pattern 2: Hash with label + dialog_before: + field_name: + name: message template name + label: show dialog button label diff --git a/app/models/admin/defs/extra_options_editable_if_defs.yaml b/app/models/admin/defs/extra_options_editable_if_defs.yaml index 9a72ed6aea..3fca4cbf39 100644 --- a/app/models/admin/defs/extra_options_editable_if_defs.yaml +++ b/app/models/admin/defs/extra_options_editable_if_defs.yaml @@ -1,6 +1,30 @@ # editable_if options - editable_if: | - 'ref: ** conditions reference **' +# +# Pattern 1: Explicitly always editable + editable_if: + always: true - NOTE: if not defined, the default is to only allow an item to be editable if it is the most recently created or - item in the list. If you want items to be always editable, use *always: true* +# Pattern 2: Explicitly never editable + editable_if: + never: true + +# Pattern 3: Conditional editable rules using the standard conditions reference + editable_if: + all: + this: + status: draft + not_any: + activity_log__example_reviews: + extra_log_type: finalized + +# Pattern 4: Merge reusable conditions with local rules + editable_if: + <<: *some_condition_anchor + all: + this: + status: draft + +# Notes: +# - editable_if must be a Hash using the standard conditional-actions syntax. +# - If not defined, the default behavior is to allow editing only for the most recent item in the list. +# - Use always: true when you want to override that default and keep all matching items editable. diff --git a/app/models/admin/defs/extra_options_editable_if_pattern_1_always_defs.yaml b/app/models/admin/defs/extra_options_editable_if_pattern_1_always_defs.yaml new file mode 100644 index 0000000000..6b5888a381 --- /dev/null +++ b/app/models/admin/defs/extra_options_editable_if_pattern_1_always_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 1: Explicitly always editable + editable_if: + always: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_editable_if_pattern_2_never_defs.yaml b/app/models/admin/defs/extra_options_editable_if_pattern_2_never_defs.yaml new file mode 100644 index 0000000000..360c98295d --- /dev/null +++ b/app/models/admin/defs/extra_options_editable_if_pattern_2_never_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 2: Explicitly never editable + editable_if: + never: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_editable_if_pattern_3_conditional_defs.yaml b/app/models/admin/defs/extra_options_editable_if_pattern_3_conditional_defs.yaml new file mode 100644 index 0000000000..93acefae99 --- /dev/null +++ b/app/models/admin/defs/extra_options_editable_if_pattern_3_conditional_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: Conditional editable rules + editable_if: + all: + this: + status: draft + not_any: + activity_log__example_reviews: + extra_log_type: finalized \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_editable_if_pattern_4_merge_anchor_defs.yaml b/app/models/admin/defs/extra_options_editable_if_pattern_4_merge_anchor_defs.yaml new file mode 100644 index 0000000000..cbfbf64ad4 --- /dev/null +++ b/app/models/admin/defs/extra_options_editable_if_pattern_4_merge_anchor_defs.yaml @@ -0,0 +1,6 @@ +# Pattern 4: Merge reusable conditions with local rules + editable_if: + <<: *some_condition_anchor + all: + this: + status: draft \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_embed_defs.yaml b/app/models/admin/defs/extra_options_embed_defs.yaml index c47e90535a..f81fd69b02 100644 --- a/app/models/admin/defs/extra_options_embed_defs.yaml +++ b/app/models/admin/defs/extra_options_embed_defs.yaml @@ -1,28 +1,25 @@ # embed options - # embed can be defined in one of three styles: - embed: default_embed_resource - # Simply embeds the dynamic model matching the default_embed_resource_name for this activity - # Must include a field acting as a foreign key onto the parent `_id` +# +# Pattern 1: Use the activity's default embedded resource + embed: default_embed_resource - embed: resource name for the item to be embedded - # Must include a field acting as a foreign key onto the parent `_id` +# Pattern 2: Embed a specific resource by resource name string + embed: dynamic_model__resource_name - embed(form 2): - resource_name: resource name for the item to embed - resource_id: | - (optional) literal integer or a named attribute on the resource to use as an id of the embedded item +# Pattern 3: Explicit Hash form for direct embed configuration + embed: + resource_name: dynamic_model__resource_name + resource_id: 123 | attribute_name + limit: 1 - When not specified, the embedded item is looked up based on the existence of a foreign key - field in the target direct embed table pointing back to the parent record. The foreign key - field is named "_id" - limit: (optional) default 1 - although other values may be set, the aim is not really to use direct embedded items for this - - # NOTE: direct embed may be configured either using this *embed:* configuration, or directly - # defining fields on the parent and optionally target tables. - # An advantage of using fields on the parent table is that the target embed resource may be specified on a - # record by record basis. - # To define an embedded item using fields alone, use one of the following field configurations: - # : embed_resource_name, embed_resource_id - directly reference the embedded item - # : embed_resource_name, : _id - foreign key reference. - # Whichever way an embedded item is specified, - # `\{\{substitutions\}\}` may reference attributes in it item using `\{\{embedded_item.\}\}` +# Notes: +# - embed supports only the three patterns above. +# - In Hash form, the supported admin keys are resource_name, resource_id and limit. +# - resource_id may be a literal integer or an attribute name on the resource. +# - limit is normally 1 for direct embeds. +# - The embedded resource must still support linking back to the parent record, usually through +# _id. +# - Direct embed may also be configured using fields alone: +# : embed_resource_name, embed_resource_id +# : embed_resource_name plus : _id +# - Whichever way an embedded item is specified, {{embedded_item.}} substitutions are available. diff --git a/app/models/admin/defs/extra_options_embed_pattern_1_default_defs.yaml b/app/models/admin/defs/extra_options_embed_pattern_1_default_defs.yaml new file mode 100644 index 0000000000..d07a573103 --- /dev/null +++ b/app/models/admin/defs/extra_options_embed_pattern_1_default_defs.yaml @@ -0,0 +1,2 @@ +# Pattern 1: Default embedded resource + embed: default_embed_resource \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_embed_pattern_2_resource_name_defs.yaml b/app/models/admin/defs/extra_options_embed_pattern_2_resource_name_defs.yaml new file mode 100644 index 0000000000..42c1f9cce1 --- /dev/null +++ b/app/models/admin/defs/extra_options_embed_pattern_2_resource_name_defs.yaml @@ -0,0 +1,2 @@ +# Pattern 2: Specific resource name + embed: dynamic_model__resource_name \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_embed_pattern_3_hash_defs.yaml b/app/models/admin/defs/extra_options_embed_pattern_3_hash_defs.yaml new file mode 100644 index 0000000000..361fece987 --- /dev/null +++ b/app/models/admin/defs/extra_options_embed_pattern_3_hash_defs.yaml @@ -0,0 +1,5 @@ +# Pattern 3: Explicit hash configuration + embed: + resource_name: dynamic_model__resource_name + resource_id: 123 | attribute_name + limit: 1 \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_label_defs.yaml b/app/models/admin/defs/extra_options_label_defs.yaml index 363fc590ca..81a04191ec 100644 --- a/app/models/admin/defs/extra_options_label_defs.yaml +++ b/app/models/admin/defs/extra_options_label_defs.yaml @@ -1,3 +1,11 @@ # label options +# Canonical schema +# label: +# label: main label (also used as + button label if button_label not defined) button_label: add record button label + +# Notes: +# - label must be a String; non-string values produce a warning and default to empty string. +# - If not specified, defaults to the humanized option type name. +# - button_label is a separate top-level key, not nested under label. diff --git a/app/models/admin/defs/extra_options_labels_defs.yaml b/app/models/admin/defs/extra_options_labels_defs.yaml index 185a1fcea9..bf8adc3add 100644 --- a/app/models/admin/defs/extra_options_labels_defs.yaml +++ b/app/models/admin/defs/extra_options_labels_defs.yaml @@ -1,3 +1,10 @@ # labels options +# Canonical schema +# labels: +# labels: field_name: label to show + +# Notes: +# - Each key must be a valid field name on the current model. +# - Values are plain strings used as display labels for the corresponding field. diff --git a/app/models/admin/defs/extra_options_references_defs.yaml b/app/models/admin/defs/extra_options_references_defs.yaml index ea864fcb70..d8fcc70881 100644 --- a/app/models/admin/defs/extra_options_references_defs.yaml +++ b/app/models/admin/defs/extra_options_references_defs.yaml @@ -1,58 +1,70 @@ # references options +# +# Pattern 1: Simple reference definition references: model_name: label: button label - result_label: alternative label rather than the to-record label in result caption - from: this | master | any | user_is_creator (item in any master that was created by current user) - without_reference: | - true | outside_master - - true will check only within the current master, if the instance has a master association - outside_master always ignores the master, even if defined, relying only on the filter_by definition + from: this | master | any | user_is_creator add: many | one_to_master | one_to_this + +# Pattern 2: Reference with add_with and filter_by + references: + activity_log__example_step: + label: Add Example Step + from: this + add: one_to_this add_with: - extra_log_type: type name + extra_log_type: review item_name: embedded_item: field_name: value filter_by: - field_name: literal value to filter the referenced items by - may include [substitutions](../general/substitutions.md) + field_name: literal value or {{substitution}} field_name2: - this: + this: field_name: return_value - # Note that this is evaluated in the context of the current item, so - # the attributes returned are relative to the `from:` item: - # `from: this`, then the attributes are from the current item - # `from: master` or `from: any`, then they are from the master item - order_by: # Order results within model references matching this model and filter - # Note that this can be overridden by view_options.sort_references, sorting the - # results across all references - field_name: asc | desc - field_name2: asc | desc + +# Pattern 3: Reference with ordering and activity selector + references: + activity_log__example_step: + from: this + add: many + order_by: + created_at: desc type_config: activity_selector: - # use an activity selector to show the creatable extra log types extra_log_type_key1: Label for button - extra_log_type_key2: Label for button - extra_log_type_key3: Label for button + extra_log_type_key2: Another label + +# Pattern 4: Reference display behavior + references: + model_name: + result_label: alternative label shown in the result caption view_as: - edit: hide|readonly|not_embedded|select_or_add - show: hide|readonly|see_presence|filestore|as_edit - new: outside_this|not_embedded|select_or_add - _notes_: - - "*outside_this* presents a button that triggers a form outside of this container - in the regular panel" + edit: hide | readonly | not_embedded | select_or_add + show: hide | readonly | see_presence | filestore | as_edit + new: outside_this | not_embedded | select_or_add view_options: - always_open: true # force the reference to always be expanded - showable_if: # exclude to always show - creatable_if: # exclude to always allow creation - prevent_disable: true | condition Hash - # always prevent the reference from being disabled, even if the containing record is editable - also_disable_record: false (default) | true - # if not prevented from disabling the model reference, also disable the referenced record if the - # reference is disabled - allow_disable_if_not_editable: true | condition Hash - # always allow the references to be disabled, even if the containing record is not editable - prevent_reload_on_save: true | false (default) - # prevent the parent from reloading on save - use in combination with {save_action: expand_reference} - action_position: top|bottom - # position this action at the top or bottom of the reference results (default: bottom) + always_open: true + showable_if: + always: true + creatable_if: + all: + this: + status: active + +# Pattern 5: Reference disable behavior + references: + model_name: + prevent_disable: true | + also_disable_record: false | true + allow_disable_if_not_editable: true | + prevent_reload_on_save: true | false + action_position: top | bottom + +# Notes: +# - Each reference entry must be a Hash. +# - Top-level keys are reference model names; they may be declared in hash form or array form. +# - without_reference may be true or outside_master. +# - order_by applies within one reference definition; view_options.sort_references can reorder across all references. +# - Only the documented keys in each entry are supported for admin configuration. diff --git a/app/models/admin/defs/extra_options_references_pattern_1_simple_defs.yaml b/app/models/admin/defs/extra_options_references_pattern_1_simple_defs.yaml new file mode 100644 index 0000000000..bd15dea990 --- /dev/null +++ b/app/models/admin/defs/extra_options_references_pattern_1_simple_defs.yaml @@ -0,0 +1,6 @@ +# Pattern 1: Simple reference definition + references: + model_name: + label: button label + from: this | master | any | user_is_creator + add: many | one_to_master | one_to_this \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_references_pattern_2_add_with_filter_defs.yaml b/app/models/admin/defs/extra_options_references_pattern_2_add_with_filter_defs.yaml new file mode 100644 index 0000000000..9b16903668 --- /dev/null +++ b/app/models/admin/defs/extra_options_references_pattern_2_add_with_filter_defs.yaml @@ -0,0 +1,16 @@ +# Pattern 2: add_with and filter_by + references: + activity_log__example_step: + label: Add Example Step + from: this + add: one_to_this + add_with: + extra_log_type: review + item_name: + embedded_item: + field_name: value + filter_by: + field_name: literal value or {{substitution}} + field_name2: + this: + field_name: return_value \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_references_pattern_3_order_type_config_defs.yaml b/app/models/admin/defs/extra_options_references_pattern_3_order_type_config_defs.yaml new file mode 100644 index 0000000000..fb636eaaea --- /dev/null +++ b/app/models/admin/defs/extra_options_references_pattern_3_order_type_config_defs.yaml @@ -0,0 +1,11 @@ +# Pattern 3: order_by and type_config + references: + activity_log__example_step: + from: this + add: many + order_by: + created_at: desc + type_config: + activity_selector: + extra_log_type_key1: Label for button + extra_log_type_key2: Another label \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_references_pattern_4_display_defs.yaml b/app/models/admin/defs/extra_options_references_pattern_4_display_defs.yaml new file mode 100644 index 0000000000..0221145b66 --- /dev/null +++ b/app/models/admin/defs/extra_options_references_pattern_4_display_defs.yaml @@ -0,0 +1,16 @@ +# Pattern 4: Display behavior + references: + model_name: + result_label: alternative label shown in the result caption + view_as: + edit: hide | readonly | not_embedded | select_or_add + show: hide | readonly | see_presence | filestore | as_edit + new: outside_this | not_embedded | select_or_add + view_options: + always_open: true + showable_if: + always: true + creatable_if: + all: + this: + status: active \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_references_pattern_5_disable_behavior_defs.yaml b/app/models/admin/defs/extra_options_references_pattern_5_disable_behavior_defs.yaml new file mode 100644 index 0000000000..294d3ba4c1 --- /dev/null +++ b/app/models/admin/defs/extra_options_references_pattern_5_disable_behavior_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 5: Disable and reload behavior + references: + model_name: + prevent_disable: true | + also_disable_record: false | true + allow_disable_if_not_editable: true | + prevent_reload_on_save: true | false + action_position: top | bottom \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_defs.yaml b/app/models/admin/defs/extra_options_show_if_defs.yaml index 79e7665630..3954f03264 100644 --- a/app/models/admin/defs/extra_options_show_if_defs.yaml +++ b/app/models/admin/defs/extra_options_show_if_defs.yaml @@ -1,37 +1,60 @@ # show_if options +# +# Pattern 1: Simple field-to-field condition show_if: field_name: depends_on_field_name: conditional value current_mode: show | edit + +# Pattern 2: Combined conditions for one field +# Use all, any, not_all or not_any when the field depends on more than one rule. + show_if: + field_name: + all: + depends_on_field_name: conditional value + current_mode: show | edit + any: + alternative_field: another conditional value + not_all: + hidden_when_field: hidden value + +# Pattern 3: Conditions that refer to an embedded item +# embedded_item is available when the current option type defines an embed: +# configuration or otherwise exposes embedded-item substitutions. + show_if: field_depends_on_embedded_item: - # Condition referencing data from an embedded item defined via embed: configuration - # The embedded_item key allows access to fields in a separate model that is - # embedded in the current model through the embed: option depends_on_field_name: conditional value embedded_item: field_name: conditional value field_name_2: conditional value - field_name_2: - all: - depends_on_field_name: conditional value - current_mode: show | edit - all_with_any_prefix_description: - depends_on_field_name: conditional value - current_mode: show | edit + +# Pattern 4: Nested conditional groups with embedded_item + show_if: + field_name: any: - depends_on_field_name: conditional value - current_mode: show | edit - not_all: - depends_on_field_name: conditional value - current_mode: show | edit - not_any: - depends_on_field_name: conditional value - current_mode: show | edit - # Use regex to match multiple fields to control using the same condition - # For example: `/^.+_complete/` or `field_[0-9]` or `/^status_.*$/` + this_form_field: current value + embedded_item: + all: + embedded_status: active + embedded_score: + condition: '>=' + value: 10 + +# Pattern 5: Apply the same condition to multiple fields using a regex key +# Example regex keys: /^.+_complete/, field_[0-9], /^status_.*$/ + show_if: /field_name_regex/: depends_on_field_name: conditional value current_mode: show | edit - submit_buttons_: | - Controls the display of a display button, identified by being - the underscored id representing the button label + +# Pattern 6: Control submit buttons instead of data fields + show_if: + submit_buttons_: + current_mode: edit + depends_on_field_name: conditional value + +# Notes: +# - Each top-level show_if entry must be a Hash of conditions. +# - show_if_condition_strings generates additional Hash entries in this same shape. +# - Field keys may be literal field names, regex keys or submit_buttons_. +# - current_mode is commonly combined with field conditions, but still belongs in the same Hash. diff --git a/app/models/admin/defs/extra_options_show_if_pattern_1_simple_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_1_simple_defs.yaml new file mode 100644 index 0000000000..4a204b6978 --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_1_simple_defs.yaml @@ -0,0 +1,5 @@ +# Pattern 1: Simple field-to-field condition + show_if: + field_name: + depends_on_field_name: conditional value + current_mode: show | edit \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_pattern_2_combined_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_2_combined_defs.yaml new file mode 100644 index 0000000000..d8dff63b92 --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_2_combined_defs.yaml @@ -0,0 +1,10 @@ +# Pattern 2: Combined conditions + show_if: + field_name: + all: + depends_on_field_name: conditional value + current_mode: show | edit + any: + alternative_field: another conditional value + not_all: + hidden_when_field: hidden value \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_pattern_3_embedded_item_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_3_embedded_item_defs.yaml new file mode 100644 index 0000000000..b16be074e0 --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_3_embedded_item_defs.yaml @@ -0,0 +1,7 @@ +# Pattern 3: Embedded item condition + show_if: + field_depends_on_embedded_item: + depends_on_field_name: conditional value + embedded_item: + field_name: conditional value + field_name_2: conditional value \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_pattern_4_nested_embedded_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_4_nested_embedded_defs.yaml new file mode 100644 index 0000000000..13eb3fe3c1 --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_4_nested_embedded_defs.yaml @@ -0,0 +1,11 @@ +# Pattern 4: Nested embedded conditions + show_if: + field_name: + any: + this_form_field: current value + embedded_item: + all: + embedded_status: active + embedded_score: + condition: '>=' + value: 10 \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_pattern_5_regex_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_5_regex_defs.yaml new file mode 100644 index 0000000000..fb3ad1150a --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_5_regex_defs.yaml @@ -0,0 +1,5 @@ +# Pattern 5: Regex key + show_if: + /field_name_regex/: + depends_on_field_name: conditional value + current_mode: show | edit \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_show_if_pattern_6_submit_button_defs.yaml b/app/models/admin/defs/extra_options_show_if_pattern_6_submit_button_defs.yaml new file mode 100644 index 0000000000..c5cbb342c1 --- /dev/null +++ b/app/models/admin/defs/extra_options_show_if_pattern_6_submit_button_defs.yaml @@ -0,0 +1,5 @@ +# Pattern 6: Submit button visibility + show_if: + submit_buttons_: + current_mode: edit + depends_on_field_name: conditional value \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_showable_if_defs.yaml b/app/models/admin/defs/extra_options_showable_if_defs.yaml index 573e7acedb..b3f833e859 100644 --- a/app/models/admin/defs/extra_options_showable_if_defs.yaml +++ b/app/models/admin/defs/extra_options_showable_if_defs.yaml @@ -1,2 +1,22 @@ # showable_if options - showable_if: 'ref: ** conditions reference **' +# +# Pattern 1: Always show the option type + showable_if: + always: true + +# Pattern 2: Never show the option type + showable_if: + never: true + +# Pattern 3: Show only when conditions are met + showable_if: + all: + this: + status: active + not_any: + activity_log__example_reviews: + extra_log_type: hidden + +# Notes: +# - showable_if must be a Hash using the standard conditional-actions syntax. +# - showable_if controls the visibility of the whole option type, whereas show_if controls individual fields or buttons. diff --git a/app/models/admin/defs/extra_options_showable_if_pattern_1_always_defs.yaml b/app/models/admin/defs/extra_options_showable_if_pattern_1_always_defs.yaml new file mode 100644 index 0000000000..fdab7d4064 --- /dev/null +++ b/app/models/admin/defs/extra_options_showable_if_pattern_1_always_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 1: Always show the option type + showable_if: + always: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_showable_if_pattern_2_never_defs.yaml b/app/models/admin/defs/extra_options_showable_if_pattern_2_never_defs.yaml new file mode 100644 index 0000000000..6fa2133846 --- /dev/null +++ b/app/models/admin/defs/extra_options_showable_if_pattern_2_never_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 2: Never show the option type + showable_if: + never: true \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_showable_if_pattern_3_conditional_defs.yaml b/app/models/admin/defs/extra_options_showable_if_pattern_3_conditional_defs.yaml new file mode 100644 index 0000000000..566442356e --- /dev/null +++ b/app/models/admin/defs/extra_options_showable_if_pattern_3_conditional_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: Show only when conditions are met + showable_if: + all: + this: + status: active + not_any: + activity_log__example_reviews: + extra_log_type: hidden \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_view_options_defs.yaml b/app/models/admin/defs/extra_options_view_options_defs.yaml index b2a6a2f6ba..5321bcd043 100644 --- a/app/models/admin/defs/extra_options_view_options_defs.yaml +++ b/app/models/admin/defs/extra_options_view_options_defs.yaml @@ -1,54 +1,46 @@ # view_options options +# +# Pattern 1: Basic display text and styling view_options: - show_embedded_at_top: | - true | false to position a single auto loaded embedded item - hide_unless_creatable: | - true | false to hide add-item buttons in activity logs if - they are not creatable data_attribute: string or list of fields to use as the data attribute - always_embed_reference: reference name to always show embedded (or 'never' to never embed one) - always_embed_creatable_reference: | - reference name to always show embedded during - new/create (or 'never' to never embed one) - alt_order: string or list of date / time or integer fields to use for ordering - show_cancel: show cancel button alongside save button - only_create_as_reference: | - prevent creation as a standalone item, only embedded - / referenced within another - sort_references: - # Sort model references in an alternative order, using the specified model_reference attribute - # Typically this will be "id" to sort by when the reference was created, as opposed to the - # typical sorting which groups references in the order of their definition. - # NOTE: this setting may override *order_by:* specified for a specific *references:* configuration - attribute: | - one of: id, from_record_type, from_record_id, from_record_master_id, to_record_type, to_record_id, - to_record_master_id, user_id, created_at, updated_at, disabled - direction: | - Default is ascending sort. To reverse the sort order use - "desc" or "reverse" for descending results - keep_top: | - If the sorting moves the top item that you want to keep in place, set this to *true* - null_value: | - If sorting items that may have a null value in the specified attribute, this specifies the value - to use for the comparison of that item + header_caption: header caption to use - may include {{substitutions}} + alt_width_classes: html classes replacing standard col-* classes + extra_class: additional html classes for the block + view_handlers: + - address + - contact - view_handlers: | - name of handler for UI, and models if they exist - (prebuilt UI and model options include: address, contact, subject, secondary_info) - header_caption: header caption to use - can include [substitutions](../general/substitutions.md) - alt_width_classes: html classes (space separated) to replace standard col-* classes - # For example: - # resized-width col-md-18 col-lg-18 col-md-offset-3 prevent_nfs_resize - # This forces the width of the block, and prevents the NFS filestore block changing the container width - extra_class: html classes (space separated) to add to block - # Useful classes: - # - allow-refresh-item-on-modal-close - # - to ensure an embedded_report_* field refreshes the block when the modal is closed - # - prevent-reload-on-reference-save - # - by default the container will reload when a reference is created or updated. This prevents it - # - no-scroll-on-click-edit - # - prevent scrolling to this item if edit is clicked (manually or automatically) - # - make-labels-placeholders - # - show fields with in field placeholders rather than labels - # - hide-form-metadata - # - hides the form metadata in edit mode +# Pattern 2: Embedded-reference presentation + view_options: + show_embedded_at_top: true | false + always_embed_reference: reference name | never + always_embed_creatable_reference: reference name | never + hide_unless_creatable: true | false + only_create_as_reference: true | false + +# Pattern 3: Alternative ordering of item lists + view_options: + alt_order: string or list of date / time / integer fields + +# Pattern 4: Cross-reference sorting +# sort_references interacts with references.order_by by applying an overall sort across +# the combined reference results. + view_options: + sort_references: + attribute: id | from_record_type | from_record_id | from_record_master_id | to_record_type | to_record_id | to_record_master_id | user_id | created_at | updated_at | disabled + direction: asc | desc | reverse + keep_top: true | false + null_value: value used when the sorted attribute is null + +# Pattern 5: Form button behavior + view_options: + show_cancel: true | false + +# Notes: +# - view_options must be a Hash. +# - Only the documented top-level keys are supported. +# - data_attribute, alt_order and view_handlers may be a single string or an array of strings. +# - sort_references must be a Hash when present. +# - Boolean flags such as show_embedded_at_top, hide_unless_creatable, show_cancel and only_create_as_reference should use true or false. +# - Common extra_class values include prevent-reload-on-reference-save, no-scroll-on-click-edit, +# make-labels-placeholders and hide-form-metadata. diff --git a/app/models/admin/defs/extra_options_view_options_pattern_1_basic_defs.yaml b/app/models/admin/defs/extra_options_view_options_pattern_1_basic_defs.yaml new file mode 100644 index 0000000000..6e7fa599b0 --- /dev/null +++ b/app/models/admin/defs/extra_options_view_options_pattern_1_basic_defs.yaml @@ -0,0 +1,9 @@ +# Pattern 1: Basic display and styling + view_options: + data_attribute: string or list of fields to use as the data attribute + header_caption: header caption to use - may include {{substitutions}} + alt_width_classes: html classes replacing standard col-* classes + extra_class: additional html classes for the block + view_handlers: + - address + - contact \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_view_options_pattern_2_embedded_reference_defs.yaml b/app/models/admin/defs/extra_options_view_options_pattern_2_embedded_reference_defs.yaml new file mode 100644 index 0000000000..a30c04c626 --- /dev/null +++ b/app/models/admin/defs/extra_options_view_options_pattern_2_embedded_reference_defs.yaml @@ -0,0 +1,7 @@ +# Pattern 2: Embedded-reference presentation + view_options: + show_embedded_at_top: true | false + always_embed_reference: reference name | never + always_embed_creatable_reference: reference name | never + hide_unless_creatable: true | false + only_create_as_reference: true | false \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_view_options_pattern_3_alt_order_defs.yaml b/app/models/admin/defs/extra_options_view_options_pattern_3_alt_order_defs.yaml new file mode 100644 index 0000000000..72c9f3ed79 --- /dev/null +++ b/app/models/admin/defs/extra_options_view_options_pattern_3_alt_order_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 3: Alternative ordering + view_options: + alt_order: string or list of date / time / integer fields \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_view_options_pattern_4_sort_references_defs.yaml b/app/models/admin/defs/extra_options_view_options_pattern_4_sort_references_defs.yaml new file mode 100644 index 0000000000..22e9e95c73 --- /dev/null +++ b/app/models/admin/defs/extra_options_view_options_pattern_4_sort_references_defs.yaml @@ -0,0 +1,7 @@ +# Pattern 4: Cross-reference sorting + view_options: + sort_references: + attribute: id | from_record_type | from_record_id | from_record_master_id | to_record_type | to_record_id | to_record_master_id | user_id | created_at | updated_at | disabled + direction: asc | desc | reverse + keep_top: true | false + null_value: value used when the sorted attribute is null \ No newline at end of file diff --git a/app/models/admin/defs/extra_options_view_options_pattern_5_form_button_defs.yaml b/app/models/admin/defs/extra_options_view_options_pattern_5_form_button_defs.yaml new file mode 100644 index 0000000000..b41c62de9e --- /dev/null +++ b/app/models/admin/defs/extra_options_view_options_pattern_5_form_button_defs.yaml @@ -0,0 +1,3 @@ +# Pattern 5: Form button behavior + view_options: + show_cancel: true | false \ No newline at end of file diff --git a/app/models/admin/defs/valid_if_options_defs.yaml b/app/models/admin/defs/valid_if_options_defs.yaml index 0a3612d2a6..266d5edf0a 100644 --- a/app/models/admin/defs/valid_if_options_defs.yaml +++ b/app/models/admin/defs/valid_if_options_defs.yaml @@ -1,73 +1,82 @@ -# Valid If Options -# The valid_if option allows you to define custom validation rules that are evaluated when saving records. -# These validations can check conditions on the current item, related items, or even cross-table lookups. -# If validation fails, the record will not be saved and error messages will be displayed. - +# valid_if options +# +# Pattern 1: Trigger map +# valid_if is a Hash keyed only by the save lifecycle trigger names below. +# Each trigger value must itself be a Hash containing conditional-actions syntax. +# on_save is merged into on_create and on_update as the default base definition. valid_if: on_save: - # Validation rules evaluated when saving (both create and update) - # Uses the standard conditional actions syntax - - all: # All conditions must be true for validation to pass - # For simple validations on the current item, put invalid_error_message directly within the condition: + all: this: - field_name: 'expected_value' - invalid_error_message: 'Custom error message shown when validation fails' - - # For cross-table queries (with no_masters), use a named condition wrapper: - condition_name: # Named condition (required when querying tables) - # The condition name appears in error messages as a prefix - invalid_error_message: 'Custom error message shown when validation fails' - no_masters: {} - table_name: - field_in_table: - this: field_name # Match field in table to field in current item - -# Examples: + field_name: expected_value + invalid_error_message: Custom error message shown when validation fails + on_create: + all: + this: + create_only_field: expected_value + invalid_error_message: Only checked on create + on_update: + all: + this: + update_only_field: expected_value + invalid_error_message: Only checked on update -# Example 1: Basic Field Validation -# Ensure status field is set to 'active' when saving +# Pattern 2: Simple validation against the current item +# Put invalid_error_message inside the this: block when validating attributes +# on the current item only. valid_if: on_save: all: this: - status: 'active' - invalid_error_message: 'Status must be active' + status: active + invalid_error_message: Status must be active -# Example 2: Multiple Field Conditions -# Ensure both status is 'active' AND item_type is 'primary' (note: avoid 'type' - reserved for Rails STI) +# Pattern 3: Multiple current-item requirements in one validation block +# All field/value pairs inside the same this: block are evaluated together. valid_if: on_save: all: this: - status: 'active' - item_type: 'primary' - invalid_error_message: 'Record must have status=active and item_type=primary' + status: active + item_type: primary + invalid_error_message: Record must have status=active and item_type=primary -# Example 3: Cross-Table Email Validation -# Verify that an email address exists in the users table -# Uses no_masters: {} to query without master_id constraint -# Requires named condition wrapper for cross-table queries +# Pattern 4: Named validation wrapper for cross-table lookup +# Use a named wrapper when the validation refers to another table, especially +# with no_masters: {}. valid_if: on_save: all: user_exists: - invalid_error_message: 'This email address does not exist as a user of the system' + invalid_error_message: This email address does not exist as a user of the system + no_masters: {} + users: + email: + this: email + +# Pattern 5: Mixed validation blocks in one trigger +# A trigger may combine multiple named or unnamed validations using the normal +# all / any / not_* conditional-actions structure. +valid_if: + on_save: + all: + this: + status: active + invalid_error_message: Status must be active + user_exists: + invalid_error_message: This email address does not exist as a user of the system no_masters: {} users: email: - this: email # Match users.email to this_record.email + this: email # Notes: -# - For simple `this:` conditions, put invalid_error_message inside the `this:` block -# - For cross-table queries with `no_masters: {}`, use a named condition wrapper with invalid_error_message inside -# - Error messages appear with format: " invalid_error_message: " -# or " invalid_error_message: " for named conditions -# - Validation happens before save_trigger actions -# - Use no_masters: {} for queries that should not filter by master_id -# - All standard conditional actions syntax is supported (all, any, not, etc.) -# - Substitutions with {{field_name}} are supported in error messages and values -# - Multiple validations under 'all' must all pass for the record to save -# - Tested examples match those in spec/models/option_configs/valid_if_spec.rb -# - More complex validation patterns (master record attributes, current_user conditions, value_in) -# may require different syntax than documented here - consult real working examples in app configs +# - Top-level keys are limited to on_save, on_create and on_update. +# - Each trigger payload must be a Hash, not a String, Array or scalar value. +# - For simple this: conditions, place invalid_error_message inside the this: block. +# - For cross-table lookups, place invalid_error_message inside the named wrapper block. +# - Error messages appear as " invalid_error_message: " or +# " invalid_error_message: ". +# - Validation happens before save_trigger actions. +# - All standard conditional-actions syntax is supported inside each trigger block. +# - Tested examples match spec/models/option_configs/valid_if_spec.rb. diff --git a/app/models/admin/defs/valid_if_pattern_1_trigger_map_defs.yaml b/app/models/admin/defs/valid_if_pattern_1_trigger_map_defs.yaml new file mode 100644 index 0000000000..8998bf71f8 --- /dev/null +++ b/app/models/admin/defs/valid_if_pattern_1_trigger_map_defs.yaml @@ -0,0 +1,17 @@ +# Pattern 1: Trigger map + valid_if: + on_save: + all: + this: + field_name: expected_value + invalid_error_message: Custom error message shown when validation fails + on_create: + all: + this: + create_only_field: expected_value + invalid_error_message: Only checked on create + on_update: + all: + this: + update_only_field: expected_value + invalid_error_message: Only checked on update \ No newline at end of file diff --git a/app/models/admin/defs/valid_if_pattern_2_simple_this_defs.yaml b/app/models/admin/defs/valid_if_pattern_2_simple_this_defs.yaml new file mode 100644 index 0000000000..39a594ceff --- /dev/null +++ b/app/models/admin/defs/valid_if_pattern_2_simple_this_defs.yaml @@ -0,0 +1,7 @@ +# Pattern 2: Simple current-item validation +valid_if: + on_save: + all: + this: + status: active + invalid_error_message: Status must be active \ No newline at end of file diff --git a/app/models/admin/defs/valid_if_pattern_3_multiple_fields_defs.yaml b/app/models/admin/defs/valid_if_pattern_3_multiple_fields_defs.yaml new file mode 100644 index 0000000000..daabd0b42a --- /dev/null +++ b/app/models/admin/defs/valid_if_pattern_3_multiple_fields_defs.yaml @@ -0,0 +1,8 @@ +# Pattern 3: Multiple current-item requirements +valid_if: + on_save: + all: + this: + status: active + item_type: primary + invalid_error_message: Record must have status=active and item_type=primary \ No newline at end of file diff --git a/app/models/admin/defs/valid_if_pattern_4_cross_table_defs.yaml b/app/models/admin/defs/valid_if_pattern_4_cross_table_defs.yaml new file mode 100644 index 0000000000..263e395f93 --- /dev/null +++ b/app/models/admin/defs/valid_if_pattern_4_cross_table_defs.yaml @@ -0,0 +1,10 @@ +# Pattern 4: Cross-table validation +valid_if: + on_save: + all: + user_exists: + invalid_error_message: This email address does not exist as a user of the system + no_masters: {} + users: + email: + this: email \ No newline at end of file diff --git a/app/models/admin/defs/valid_if_pattern_5_mixed_blocks_defs.yaml b/app/models/admin/defs/valid_if_pattern_5_mixed_blocks_defs.yaml new file mode 100644 index 0000000000..ed26816738 --- /dev/null +++ b/app/models/admin/defs/valid_if_pattern_5_mixed_blocks_defs.yaml @@ -0,0 +1,13 @@ +# Pattern 5: Mixed validation blocks +valid_if: + on_save: + all: + this: + status: active + invalid_error_message: Status must be active + user_exists: + invalid_error_message: This email address does not exist as a user of the system + no_masters: {} + users: + email: + this: email \ No newline at end of file diff --git a/app/models/concerns/options_handler.rb b/app/models/concerns/options_handler.rb index f0ed1c0175..f86b62156b 100644 --- a/app/models/concerns/options_handler.rb +++ b/app/models/concerns/options_handler.rb @@ -172,6 +172,83 @@ 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 shared validation and future coercion. Currently supported types: + # - :string — stores a String value + # - :array — stores an Array value + # - :hash — stores an arbitrary Hash value + # - :array_or_hash — stores either an Array or a 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, :array_or_hash, :if_condition) + # @param [Symbol] level - validation severity for automatic type enforcement + def configure_direct(config_item_name, type:, level: :error) + attr_accessor(config_item_name) unless method_defined?(config_item_name) + + add_option_type(:direct, config_item_name) + + # Store type metadata for shared validation/coercion + @direct_types ||= {} + @direct_types[config_item_name] = type + + @direct_validation_levels ||= {} + @direct_validation_levels[config_item_name] = level + 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 + + # Returns the registered validation levels for direct types. + # @return [Hash{Symbol => Symbol}] mapping of attribute name to validation level + def direct_validation_levels + @direct_validation_levels || {} + 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 +257,9 @@ def option_types @option_types ||= { multi: [], simple: [], - hash: [] + hash: [], + direct: [], + typed: [] } end @@ -303,6 +382,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 +402,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/dynamic/implementation_handler.rb b/app/models/dynamic/implementation_handler.rb index 24cfd75c18..b50ce88137 100644 --- a/app/models/dynamic/implementation_handler.rb +++ b/app/models/dynamic/implementation_handler.rb @@ -168,7 +168,8 @@ def can_add_reference? dopt = option_type_config return unless dopt - return unless dopt.add_reference_if.is_a?(Hash) && dopt.add_reference_if.first + add_reference_if = dopt.add_reference_if + return unless condition_config_present?(add_reference_if) res = dopt.calc_if(:add_reference_if, self) @can_add_reference = !!res @@ -192,7 +193,7 @@ def calc_can(type) return end - return unless doptif.is_a?(Hash) && doptif.first && respond_to?(:master) + return unless condition_config_present?(doptif) && respond_to?(:master) # Generate an old version of the object prior to changes old_obj = dup @@ -217,6 +218,10 @@ def calc_can(type) res end + def condition_config_present?(config) + config.present? && config.first.present? + end + # If access has changed since an initial check, reset the cached results def reset_access_evaluations! @can_access = nil diff --git a/app/models/formatter/substitution.rb b/app/models/formatter/substitution.rb index f58b2cd86f..cd53d68e42 100644 --- a/app/models/formatter/substitution.rb +++ b/app/models/formatter/substitution.rb @@ -714,8 +714,8 @@ def self.associated_reference(name, data, item, item_reference) elsif name == 'embedded_item' && item.respond_to?(:embedded_item) item.embedded_item elsif name == 'constants' - # Options constants - item.versioned_definition.options_constants&.dup if item.respond_to?(:versioned_definition) + # Options constants — convert to plain Hash for tag substitution compatibility + item.versioned_definition.options_constants&.to_h if item.respond_to?(:versioned_definition) elsif name == 'variables' # Set variables from option type config data[:variables] if data.is_a?(Hash) diff --git a/app/models/option_configs/activity_log_options.rb b/app/models/option_configs/activity_log_options.rb index 9d973495a7..694adf54d7 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_config: ExtraOptionConfigs::ESignConfig, + nfs_store: ExtraOptionConfigs::NfsStoreConfig + ) + end + def self.add_key_attributes - %i[e_sign nfs_store] + %i[e_sign] 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..ca90e4a0ad 100644 --- a/app/models/option_configs/base_named_configuration.rb +++ b/app/models/option_configs/base_named_configuration.rb @@ -4,6 +4,107 @@ 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 + + # Check if a key is a recognized attribute with a non-nil value. + # Matches Hash#key? semantics for configs parsed from YAML: + # a key is "present" only when it was actually defined (non-nil). + # @param [Symbol | String] key - the attribute name + # @return [Boolean] + def key?(key) + sym_key = key.to_sym + self.class.option_types[:simple]&.include?(sym_key) && !send(sym_key).nil? + end + + alias has_key? key? + + # Set a configuration attribute by key. + # Enables backward compatibility with template code that mutates + # field option configs, e.g. `options[:include_blank] = true`. + # Only allows setting attributes declared via configure_attributes. + # @param [Symbol | String] key - the attribute name + # @param [Object] value - value to set + def []=(key, value) + sym_key = key.to_sym + return unless respond_to?(:"#{sym_key}=") + + send(:"#{sym_key}=", value) + 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 filtered_hash == 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 + + # Duplicate as a plain Hash for backward compatibility with callers that + # historically treated named configurations as mutable hashes. + # @return [Hash{Symbol => Object}] + def dup + filtered_hash.deep_dup + end + + # Deep duplicate as a plain Hash for compatibility with callers that merge + # nested field option configs after duplicating them. + # @return [Hash{Symbol => Object}] + def deep_dup + filtered_hash.deep_dup + end + + # Merge with another hash-like object and return a plain Hash. + # @param other [Hash, #to_h, #filtered_hash] + # @return [Hash{Symbol => Object}] + def merge(other) + filtered_hash.merge(coerce_hash(other)) + end + def config_text return super unless owner @@ -24,5 +125,30 @@ 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 + + private + + def coerce_hash(other) + return other.filtered_hash if other.respond_to?(:filtered_hash) + return other.to_h if other.respond_to?(:to_h) + + other || {} + 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..e1a8d024dd --- /dev/null +++ b/app/models/option_configs/extra_option_configs/base_configuration.rb @@ -0,0 +1,337 @@ +# 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 + include Concerns::PatternValidation + + validate :validate_configure_direct_types + + attr_reader :raw_configuration + + # 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 + + # Optional: override to read raw input from a different ExtraOptions attribute + # than the registry key. For example, References uses `source_attribute :references` + # so the registry key `references_config` reads its input from `extra_options.references`, + # and the enriched hash is stored back there while the instance is kept at the registry key. + # @return [Symbol, nil] the source attribute name, or nil to use the registry key + def self.source_attribute + nil + 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 = {} + @raw_configuration = hash_config + 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. + # Skips metadata keys injected by PatternValidation (e.g. _valid_fields). + # Subclasses override for custom preprocessing. + # @return [void] + def setup_named_configurations + hash_configuration.each do |k, v| + next if k == Concerns::PatternValidation::VALID_FIELDS_KEY + + 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. + # Tracks @current_field_name so failed_config can include field context. + # @param [Symbol] sym_key - field name + # @param [Object] value - field configuration value + def add_named_configuration(sym_key, value) + @current_field_name = sym_key + 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 + ensure + @current_field_name = nil + 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 + + # Return a plain Hash with specified keys removed. + # Used by ConditionalActions#calc_save_option_if to strip keys like :label. + # @param keys [Array] keys to exclude + # @return [Hash] + def except(*keys) + symbolize_keys.except(*keys) + end + + # 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 + + alias to_h symbolize_keys + alias to_hash symbolize_keys + + # 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. + # Includes the current field name and raw config when available for context. + # @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) + entry = { type:, message:, extra_details: } + if @current_field_name + entry[:field_name] = @current_field_name + raw = hash_configuration[@current_field_name] if hash_configuration.is_a?(Hash) + entry[:field_config] = raw + elsif hash_configuration.present? + entry[:field_config] = hash_configuration + end + target << entry + end + + def add_validation_notice(attribute, message, level: :error) + options = {} + options[:type] = :warning if level == :warn + errors.add(attribute, message, **options) + end + + def validate_hash_attribute(attribute, value, allow_blank: true, level: :error) + return true if allow_blank && value.blank? + return true if value.is_a?(Hash) + + add_validation_notice(attribute, 'must be a Hash', level:) + false + end + + def validate_array_or_hash_attribute(attribute, value, allow_blank: true, level: :error) + return true if allow_blank && value.blank? + return true if value.is_a?(Hash) || value.is_a?(Array) + + add_validation_notice(attribute, 'must be a Hash or Array', level:) + false + end + + def validate_allowed_hash_keys(attribute, value, allowed_keys, level: :warn) + return unless value.is_a?(Hash) + + invalid = value.keys.map(&:to_sym) - allowed_keys + return if invalid.empty? + + add_validation_notice(attribute, "contains unrecognized keys #{invalid}", level:) + end + + private + + def validate_configure_direct_types + self.class.direct_types.each do |attribute, expected_type| + validate_direct_type(attribute, expected_type) + end + end + + def validate_direct_type(attribute, expected_type) + return if raw_configuration.blank? + return if direct_type_valid?(raw_configuration, expected_type) + + level = self.class.direct_validation_levels[attribute] || :error + add_validation_notice(attribute, direct_type_error_message(expected_type), level:) + end + + def direct_type_valid?(value, expected_type) + case expected_type + when :string + value.is_a?(String) + when :array + value.is_a?(Array) + when :hash, :if_condition + value.is_a?(Hash) + when :array_or_hash + value.is_a?(Array) || value.is_a?(Hash) + else + true + end + end + + def direct_type_error_message(expected_type) + actual_type = raw_configuration.class.name.downcase + + case expected_type + when :string + "must be a string, got #{actual_type}" + when :array + "must be an array, got #{actual_type}" + when :array_or_hash + "must be a Hash or Array, got #{actual_type}" + when :if_condition + "must be a Hash, got #{actual_type}" + else + "must be a #{expected_type}, got #{actual_type}" + end + end + + # 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..c70eb4e69d --- /dev/null +++ b/app/models/option_configs/extra_option_configs/batch_trigger.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for batch trigger setup. + # Schema docs: docs/admin_reference/general/batch_trigger.md + # 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 + + validate :validate_batch_trigger_shape + + # 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 + config_hash = hash_configuration.is_a?(Hash) ? hash_configuration : {} + setup_all_options_typed(config_hash) + # 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 + + private + + def validate_batch_trigger_shape + return unless validate_hash_attribute(:batch_trigger, raw_configuration) + + return unless hash_configuration.key?(:on_record) + return if hash_configuration[:on_record].is_a?(Hash) || hash_configuration[:on_record].is_a?(Array) + + add_validation_notice(:batch_trigger, 'on_record must be a Hash or Array of Hash task definitions') + 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..89c6ec2a83 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/caption_before.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for caption formatting (text-to-HTML conversion). + # Schema docs: docs/admin_reference/general/caption_before.md + # 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 + extra_keys :all_fields, :submit, /\Areference_/ + + # 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 keep_label] + end + + value_pattern :simple_caption, + description: 'Simple caption string', + match: String + + value_pattern :caption_hash, + description: 'Caption hash with optional view-specific modes', + match: Hash, + allowed_keys: NamedConfiguration.option_types[:simple] + + validate :validate_field_key_names + validate :validate_value_patterns + + # 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/comments.rb b/app/models/option_configs/extra_option_configs/comments.rb new file mode 100644 index 0000000000..57071f666c --- /dev/null +++ b/app/models/option_configs/extra_option_configs/comments.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for top-level _comments options. + # Schema docs: docs/admin_reference/general/comments.md + # Extracted from ExtraOptions.parse_config + # + # Stores table and field comments for the underlying database. + # Keys: :table (string), :fields (hash of field_name => comment), + # :original_fields (computed backup by handle_table_comments). + class Comments < BaseConfiguration + RECOGNIZED_KEYS = %i[table fields original_fields].to_set.freeze + + # Override to warn about unrecognized keys. + def setup_named_configurations + super + return unless hash_configuration.is_a?(Hash) + + hash_configuration.each_key do |key| + next if RECOGNIZED_KEYS.include?(key) + + failed_config(key, "unrecognized comment key '#{key}'", level: :warn) + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/concerns/pattern_validation.rb b/app/models/option_configs/extra_option_configs/concerns/pattern_validation.rb new file mode 100644 index 0000000000..3cf4ac1eac --- /dev/null +++ b/app/models/option_configs/extra_option_configs/concerns/pattern_validation.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + module Concerns + # Provides class-level DSLs for declaring extra key patterns and value patterns + # on field-keyed BaseConfiguration subclasses. + # + # == Extra Keys + # + # Declares non-field key names that are valid for this config class. + # Accepts symbols (exact match) and regexes (pattern match): + # + # extra_keys :all_fields, :submit, /\Areference_/ + # + # == Value Patterns + # + # Declares named value shapes that entries can take. Each pattern carries: + # - +match+: Class, Array of Classes, or Proc for type discrimination + # - +allowed_keys+: (Hash patterns) valid keys within the hash value + # - +required_keys+: (Hash patterns) keys that must be present + # - +description+: human-readable description for future UI guidance + # + # value_pattern :simple_caption, + # description: 'Simple caption string', + # match: String + # + # value_pattern :caption_hash, + # description: 'Caption hash with optional view-specific modes', + # match: Hash, + # allowed_keys: %i[caption show_caption edit_caption new_caption keep_label] + # + # == Automatic Validation + # + # When +_valid_fields+ metadata is present (injected by +prepare_config+), + # +validate_field_key_names+ checks each entry's key against extra_keys + # and the valid field list. + # + # +validate_value_patterns+ checks each entry's value against declared patterns, + # reporting errors for values that match no pattern, warnings for unrecognized + # hash keys, and errors for missing required keys. + module PatternValidation + extend ActiveSupport::Concern + + VALID_FIELDS_KEY = :_valid_fields + + included do + class_attribute :_extra_keys, default: [] + class_attribute :_value_patterns, default: {} + end + + class_methods do + # Declare extra key names or patterns that are valid beyond field names. + # @param keys [Array] exact symbols or regex patterns + def extra_keys(*keys) + self._extra_keys = keys.freeze + end + + # Declare a named value pattern for entries in this config. + # @param name [Symbol] unique pattern identifier + # @param description [String] human-readable pattern description + # @param match [Class, Array, Proc] type discriminator + # @param allowed_keys [Array, nil] valid hash keys (Hash patterns only) + # @param required_keys [Array, nil] mandatory hash keys (Hash patterns only) + def value_pattern(name, description:, match:, allowed_keys: nil, required_keys: nil) + self._value_patterns = _value_patterns.merge( + name => { + description:, + match:, + allowed_keys: allowed_keys&.freeze, + required_keys: required_keys&.freeze + } + ).freeze + end + + # Default prepare_config that injects _valid_fields from parent context. + # Only injects when the class has registered validate_field_key_names, + # avoiding pollution of non-field-keyed classes (ViewOptions, Filestore, etc.). + # Subclasses with custom prepare_config should call super or inject _valid_fields. + # @param raw [Hash, nil] raw config hash + # @param parent [ExtraOptions] parent options instance + # @return [Object] raw config with _valid_fields metadata + def prepare_config(raw, parent) + return raw unless raw.is_a?(Hash) + return raw unless uses_field_key_validation? + + raw[VALID_FIELDS_KEY] = parent.fields || [] + raw + end + + # Whether this class has registered validate_field_key_names in its callbacks. + # @return [Boolean] + def uses_field_key_validation? + _validate_callbacks.any? { |cb| cb.filter == :validate_field_key_names } + end + end + + # Check whether a field key matches any declared extra_keys entry. + # @param field_name [Symbol] the key to check + # @return [Boolean] + def extra_key?(field_name) + self.class._extra_keys.any? do |key| + key.is_a?(Regexp) ? key.match?(field_name.to_s) : key == field_name + end + end + + # Validate all entry keys against valid field names and extra_keys. + # Skips validation when _valid_fields metadata is not present. + # Reports unrecognized keys as warnings. + def validate_field_key_names + return unless hash_configuration.is_a?(Hash) + + valid_fields = hash_configuration[VALID_FIELDS_KEY] + return unless valid_fields + + each_config_entry do |field_name, _value| + next if extra_key?(field_name) + next if valid_fields.include?(field_name.to_s) + + extra_keys_desc = self.class._extra_keys.map { |k| k.is_a?(Regexp) ? k.inspect : k }.join(', ') + add_validation_notice(field_name, + "#{field_name} is not a valid field name" \ + "#{extra_keys_desc.present? ? " or extra key (#{extra_keys_desc})" : ''}", + level: :warn) + end + end + + # Validate all entry values against declared value patterns. + # When no patterns are declared, no value type validation occurs. + # Reports errors for values matching no pattern, warns on unrecognized hash keys, + # errors on missing required keys. + def validate_value_patterns + return unless hash_configuration.is_a?(Hash) + return if self.class._value_patterns.empty? + + each_config_entry do |field_name, value| + matched = match_value_pattern(value) + unless matched + types = self.class._value_patterns.values.map { |p| describe_match(p[:match]) }.join(' or ') + add_validation_notice(field_name, + "#{field_name} must be #{types}, got #{value.class}") + next + end + + validate_pattern_constraints(field_name, value, matched) + end + end + + # Find the first matching value pattern for a given value. + # @param value [Object] the entry value + # @return [Hash, nil] the matched pattern definition, or nil + def match_value_pattern(value) + self.class._value_patterns.each_value do |pattern| + return pattern if value_matches?(value, pattern[:match]) + end + nil + end + + private + + # Iterate hash_configuration entries, skipping metadata keys. + def each_config_entry + hash_configuration.each do |field_name, value| + next if field_name == VALID_FIELDS_KEY + + yield field_name, value + end + end + + # Test whether a value matches a pattern's match spec. + def value_matches?(value, match_spec) + case match_spec + when Class + value.is_a?(match_spec) + when Array + match_spec.any? { |klass| value.is_a?(klass) } + when Proc + match_spec.call(value) + else + false + end + end + + # Apply constraints from a matched pattern (allowed_keys, required_keys). + def validate_pattern_constraints(field_name, value, pattern) + return unless value.is_a?(Hash) + + if pattern[:allowed_keys] + invalid = value.keys.map(&:to_sym) - pattern[:allowed_keys] + if invalid.present? + add_validation_notice(field_name, + "#{field_name} contains unrecognized keys #{invalid}", + level: :warn) + end + end + + return unless pattern[:required_keys] + + missing = pattern[:required_keys] - value.keys.map(&:to_sym) + return if missing.empty? + + add_validation_notice(field_name, + "#{field_name} is missing required keys #{missing}") + end + + # Human-readable description of a match spec. + def describe_match(match_spec) + case match_spec + when Class + match_spec.name + when Array + match_spec.map(&:name).join(' or ') + when Proc + 'a valid value' + else + match_spec.to_s + end + 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..a17b35df34 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/config_trigger.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for config trigger setup. + # Schema docs: docs/admin_reference/general/config_trigger.md + # 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/configurations.rb b/app/models/option_configs/extra_option_configs/configurations.rb new file mode 100644 index 0000000000..d3624ca57f --- /dev/null +++ b/app/models/option_configs/extra_option_configs/configurations.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for top-level _configurations options. + # Schema docs: docs/admin_reference/general/configurations.md + # Extracted from ExtraOptions.parse_config + # + # Stores definition-level settings such as secondary_key, view_sql, + # batch_trigger, prevent_migrations, etc. Values are stored in + # the configurations hash keyed by setting name, supporting + # Hash-like access via [], dig, reject, etc. + class Configurations < BaseConfiguration + # Known top-level configuration keys. + # Values that are Hashes (e.g. batch_trigger) are stored as-is. + RECOGNIZED_KEYS = %i[ + use_current_version secondary_key view_sql prevent_migrations + batch_trigger tab_caption uniqueness_fields can_change_master + foreign_key_through_external_id no_user_id default_option_type_name + ].to_set.freeze + + # Override to warn about unrecognized keys without requiring NamedConfiguration. + def setup_named_configurations + super + return unless hash_configuration.is_a?(Hash) + + hash_configuration.each_key do |key| + next if RECOGNIZED_KEYS.include?(key) + + failed_config(key, "unrecognized configuration key '#{key}'", level: :warn) + end + end + + # Hash-compatible reject returning a Hash (not Array). + # Used by model_generator to filter existing configurations. + def reject(&) + configurations.reject(&) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/constants.rb b/app/models/option_configs/extra_option_configs/constants.rb new file mode 100644 index 0000000000..dca6e69af9 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/constants.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for top-level _constants options. + # Schema docs: docs/admin_reference/general/constants.md + # Extracted from ExtraOptions.parse_config + # + # Stores user-defined constant key-value pairs available for + # runtime {{constants.name}} template substitutions. + # All keys are valid — no recognized-key restriction since + # constants are user-defined arbitrary names. + class Constants < BaseConfiguration + 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..77dfdea0e8 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/creatable_if.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for creatable_if access control condition. + # Schema docs: docs/admin_reference/general/creatable_if.md + # Implemented as an IfCondition-backed config object rather than a raw Hash + # so validation and hash-like behavior come from the shared IfCondition class. + class CreatableIf < IfCondition + def creatable_if + conditions + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/data_dictionary_config.rb b/app/models/option_configs/extra_option_configs/data_dictionary_config.rb new file mode 100644 index 0000000000..592dcf4ecb --- /dev/null +++ b/app/models/option_configs/extra_option_configs/data_dictionary_config.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for top-level _data_dictionary options. + # Schema docs: docs/admin_reference/general/data_dictionary.md + # Extracted from ExtraOptions.parse_config + # + # Stores data dictionary settings for automatic variable registration. + # Consumed by Dynamic::DataDictionary to populate Datadic::Variable records. + class DataDictionaryConfig < BaseConfiguration + RECOGNIZED_KEYS = %i[ + study domain prevent_update source_name source_type form_name + storage_type db_or_fs schema_or_path table_or_file is_derived_var + owner_email fields derived_var_options + ].to_set.freeze + + # Override to warn about unrecognized keys. + def setup_named_configurations + super + return unless hash_configuration.is_a?(Hash) + + hash_configuration.each_key do |key| + next if RECOGNIZED_KEYS.include?(key) + + failed_config(key, "unrecognized data dictionary key '#{key}'", level: :warn) + end + 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..a44d408b4c --- /dev/null +++ b/app/models/option_configs/extra_option_configs/db_configs.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for database column configs. + # Schema docs: docs/admin_reference/general/db_columns.md + # Extracted from ExtraOptions#clean_db_configs_def + # + # Values are column configuration hashes keyed by column name. + # Each entry defines database column overrides (type, array, index, encrypted). + # The mutation of config_obj.db_columns is handled by ExtraOptions + # after this config class runs. + class DbConfigs < BaseConfiguration + # Named configuration for a single column's database settings. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[type array index encrypted] + end + + value_pattern :db_config_hash, + description: 'Column configuration hash', + match: Hash, + allowed_keys: NamedConfiguration.option_types[:simple] + + validate :validate_field_key_names + validate :validate_value_patterns + 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..188ff866aa --- /dev/null +++ b/app/models/option_configs/extra_option_configs/dialog_before.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for dialog overlay definitions. + # Schema docs: docs/admin_reference/general/dialog_before.md + # 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 + extra_keys :all_fields, :submit + + # Named configuration for a single field's dialog settings. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[name label keep_label] + end + + value_pattern :simple_template, + description: 'Template name string', + match: String + + value_pattern :dialog_hash, + description: 'Dialog hash with template name and label', + match: Hash, + allowed_keys: NamedConfiguration.option_types[:simple], + required_keys: %i[name] + + validate :validate_field_key_names + validate :validate_value_patterns + validate :validate_dialog_template_existence + + # 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 that referenced Admin::MessageTemplate records exist. + # Value type and key validation is handled by PatternValidation. + def validate_dialog_template_existence + return if hash_configuration.blank? + + each_config_entry do |key, value| + next unless value.is_a?(String) || value.is_a?(Hash) + + processed = value.is_a?(String) ? { name: value } : value.symbolize_keys + name = processed[:name] + next unless name + + mt = Admin::MessageTemplate.active.find_by(name:) + next if mt + + errors.add(:dialog_before, + "specifies a named message template that doesn't exist: #{name}", + type: :warning) + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/e_sign_config.rb b/app/models/option_configs/extra_option_configs/e_sign_config.rb new file mode 100644 index 0000000000..0658b1caa0 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/e_sign_config.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for e-signature definitions. + # Schema docs: docs/admin_reference/general/e_sign.md + # + # Uses the source_attribute pattern: the registry key is :e_sign_config, + # but the raw input is read from :e_sign (an add_key_attribute on ActivityLogOptions). + # After processing: + # - extra_options.e_sign = enriched hash (runtime code consumes this) + # - extra_options.e_sign_config = this ESignConfig instance (input-only attributes) + # + # Handles: + # - Wrapping document_reference in {item: ...} + # - Singularizing keys within document_reference entries + # - Resolving model references and enriching with class metadata + class ESignConfig < BaseConfiguration + configure_direct :e_sign, type: :hash + configure_attributes %i[create_document auto_create_document document_reference title intro] + + # Keys added by prepare_config within document_reference model entries + # that are not part of admin input. + COMPUTED_KEYS = %i[to_record_label no_master_association to_model_name_us].freeze + + def self.source_attribute + :e_sign + end + + def self.store_processed_value? + true + end + + # Pre-process the e_sign config: wrap document_reference, singularize keys, + # and resolve model references. + # @param raw [Hash, nil] the raw e_sign config + # @param _parent [ExtraOptions] the parent ExtraOptions instance (unused) + # @return [Hash, nil] the processed e_sign hash + def self.prepare_config(raw, _parent) + return nil unless raw + + raw = raw.symbolize_keys if raw.is_a?(Hash) + + # Wrap document_reference in {item: ...} if not already wrapped + raw[:document_reference] = { item: raw[:document_reference] } unless raw[:document_reference][:item] + + raw[:document_reference].each_value do |refitem| + # Singularize all keys + refitem.transform_keys! do |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 + + raw + end + + # Store enriched hash on the direct attribute and assign input-only + # configured attributes for round-trip serialization. + # @return [void] + def setup_named_configurations + self.e_sign = hash_configuration.presence + return unless e_sign + + # Assign top-level input attributes + e_sign.each do |k, v| + next if k == :document_reference + + send("#{k}=", v) if respond_to?("#{k}=") + end + + # Store input-only document_reference (strip computed keys from nested model entries) + return unless e_sign[:document_reference] + + self.document_reference = strip_doc_ref_computed_keys(e_sign[:document_reference]) + end + + private + + # Strip computed keys from each model entry nested within document_reference. + # Structure: { item: { model_name: { from: ..., to_record_label: ..., ... } } } + # @param doc_ref [Hash] the document_reference hash + # @return [Hash] document_reference with computed keys removed from model entries + def strip_doc_ref_computed_keys(doc_ref) + doc_ref.transform_values do |group| + next group unless group.is_a?(Hash) + + group.transform_values do |entry| + next entry unless entry.is_a?(Hash) + + entry.except(*COMPUTED_KEYS) + end + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/editable_if.rb b/app/models/option_configs/extra_option_configs/editable_if.rb new file mode 100644 index 0000000000..799228477b --- /dev/null +++ b/app/models/option_configs/extra_option_configs/editable_if.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for editable_if access control condition. + # Schema docs: docs/admin_reference/general/editable_if.md + # Implemented as an IfCondition-backed config object rather than a raw Hash + # so validation and hash-like behavior come from the shared IfCondition class. + class EditableIf < IfCondition + def editable_if + conditions + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/embed.rb b/app/models/option_configs/extra_option_configs/embed.rb new file mode 100644 index 0000000000..0caeff12b0 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/embed.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for embedded resource definitions. + # Schema docs: docs/admin_reference/general/embed.md + # + # Uses the source_attribute pattern: the registry key is :embed_config, + # but the raw input is read from :embed (a base_key_attribute). + # After processing: + # - extra_options.embed = enriched hash (runtime code consumes this) + # - extra_options.embed_config = this Embed instance (input-only attributes) + # + # Handles: + # - Converting 'default_embed_resource' string to resource_name hash + # - Converting plain string to { resource_name: string } hash + # - Looking up the resource model definition + # - Warning when embedded resource does not exist + class Embed < BaseConfiguration + configure_direct :embed, type: :hash + configure_attributes %i[resource_name resource_id limit] + VALID_KEYS = %i[resource_name resource_id limit].freeze + + # Key added by prepare_config that is not part of admin input. + COMPUTED_KEYS = %i[resource_model_def].freeze + + def self.source_attribute + :embed + end + + def self.store_processed_value? + true + end + + validate :validate_embed_structure + validate :validate_embed_resource + + # Pre-process the embed value using parent context. + # Converts string values and 'default_embed_resource' to hash format. + # Looks up the resource model and reports warnings for missing resources. + # @param raw [String, Hash, nil] the raw embed value from YAML config + # @param parent [ExtraOptions] the parent ExtraOptions instance + # @return [Hash, nil] the processed embed hash + def self.prepare_config(raw, parent) + return nil unless raw + + config_obj = parent.config_obj + + if raw == 'default_embed_resource' + rn = config_obj.default_embed_resource_name(parent.name) + emb = { resource_name: rn } + elsif raw.is_a?(String) + rn = raw + emb = { resource_name: rn } + else + emb = raw.is_a?(Hash) ? raw.symbolize_keys : { _validation_errors: ['embed must be a String or Hash'] } + rn = emb[:resource_name] + end + + resource = rn.present? ? Resources::Models.find_by(resource_name: rn) : nil + emb[:resource_model_def] = resource + + if rn.present? && !(resource && resource[:model]) + Rails.logger.warn "embed for #{rn} does not exist as a class in #{parent.name} / #{config_obj.name}" + end + + emb + end + + # Store enriched hash on the direct attribute and assign input-only + # configured attributes for round-trip serialization. + # @return [void] + def setup_named_configurations + raw = hash_configuration + self.embed = if raw.is_a?(String) + { resource_name: raw } + elsif raw.is_a?(Hash) && raw.present? + raw + end + return unless embed + + embed.except(*COMPUTED_KEYS, :_validation_errors).each do |k, v| + send("#{k}=", v) if respond_to?("#{k}=") + end + end + + private + + # Validate that the embedded resource exists. + def validate_embed_structure + raw = hash_configuration + return if raw.blank? + return if raw.is_a?(String) + return unless validate_hash_attribute(:embed, raw) + + Array(raw[:_validation_errors]).each { |msg| add_validation_notice(:embed, msg) } + validate_allowed_hash_keys(:embed, raw.except(*COMPUTED_KEYS, :_validation_errors), VALID_KEYS) + + if raw.key?(:resource_name) && !string_like?(raw[:resource_name]) + add_validation_notice(:embed, 'resource_name must be a String') + end + + if raw.key?(:resource_id) && !scalar_reference?(raw[:resource_id]) + add_validation_notice(:embed, 'resource_id must be a String or Integer') + end + + return unless raw.key?(:limit) && !raw[:limit].is_a?(Integer) + + add_validation_notice(:embed, 'limit must be an Integer') + end + + def validate_embed_resource + emb = hash_configuration + return if emb.blank? || !emb.is_a?(Hash) + + rn = emb[:resource_name] + return unless rn + + resource = emb[:resource_model_def] + return if resource && resource[:model] + + errors.add(:embed, "embed for #{rn} does not exist as a resource", type: :warning) + end + + def string_like?(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def scalar_reference?(value) + value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/field_configs.rb b/app/models/option_configs/extra_option_configs/field_configs.rb new file mode 100644 index 0000000000..0256f570b8 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/field_configs.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for per-field configuration merging. + # Converted from ConfigBase to BaseConfiguration pattern. + # + # Handles: + # - Parsing field_configs and distributing values to standalone config attributes + # - Validating field_configs fields are in the field list + # - Validating field_configs keys are in ValidFieldConfigs + # - Merging standalone definitions back into field_configs + # + # Note: This config class reads from and writes to multiple parent attributes + # (field_configs, plus the ValidFieldConfigs attributes like caption_before, labels, etc.) + # because the clean_field_configs method has cross-cutting behavior. + # The processed hash is stored back on the parent ExtraOptions (not the object). + class FieldConfigs < BaseConfiguration + configure_direct :field_configs, type: :hash + + def self.store_processed_value? + true + end + + # Pre-process field_configs using parent context. + # Distributes field config values to standalone parent attributes and validates. + # @param raw [Hash, nil] the raw field_configs value from YAML config + # @param parent [ExtraOptions] the parent ExtraOptions instance + # @return [Hash] the processed field_configs hash + def self.prepare_config(raw, parent) + # Fields is already processed at this point (it runs before FieldConfigs in the registry) + fla = parent.fields || [] + + return {} if raw.nil? + + fc = raw.symbolize_keys + failed = false + validation_errors = [] + fc.each do |fname, fconfig| + unless fconfig&.is_a? Hash + validation_errors << "field '#{fname}' is not a Hash" + failed = true + fc[fname] = {} + next + end + + OptionConfigs::ExtraOptions::ValidFieldConfigs.each do |vc| + c = fconfig[vc] + next unless c + + ivar = parent.instance_variable_get("@#{vc}") + unless ivar + parent.instance_variable_set("@#{vc}", {}) + ivar = parent.instance_variable_get("@#{vc}") + end + + ivar.merge!(fname => c) + end + end + + if failed + fc[:_validation_errors] = validation_errors if validation_errors.present? + return fc + end + + # Build the list of errors from the explicitly defined field_configs + efs = fc.keys.map(&:to_s) - fla + if efs.present? + validation_errors << "field_configs includes fields that are not in the field list: #{efs.join(', ')}" + end + + fc.each do |fname, fconfig| + extra_keys = fconfig.keys - OptionConfigs::ExtraOptions::ValidFieldConfigs + next if extra_keys.empty? + + validation_errors << "field_configs for #{fname} includes invalid keys: #{extra_keys}" + end + + # Merge raw standalone definitions into field_configs BEFORE standalone classes clean them. + # This matches the old clean_field_configs behavior where add_field_configs_from_standalone_defs + # was called internally, producing raw_field_configs with uncleaned values. + OptionConfigs::ExtraOptions::ValidFieldConfigs.each do |vc| + c = parent.instance_variable_get("@#{vc}") + next unless c + + c_hash = c.is_a?(Hash) ? c.symbolize_keys : next + c_hash.each do |k, v| + next unless fla.include?(k.to_s) + + fc[k] ||= {} + fc[k].merge!({ vc => v }) + end + end + + # Save pre-clean snapshot for raw_field_configs + # Using Marshal for deep cloning is safe here since we're only operating on data already in memory + parent.raw_field_configs = Marshal.load(Marshal.dump(fc)) + + fc[:_validation_errors] = validation_errors if validation_errors.present? + + fc + end + + validate :validate_field_configs + + # Store the field_configs hash value, defaulting to empty hash. + # @return [void] + def setup_named_configurations + self.field_configs = hash_configuration.except(:_validation_errors).presence || {} + end + + private + + # Validate field_configs entries via ActiveModel validate callback. + def validate_field_configs + stored_errors = hash_configuration[:_validation_errors] + return unless stored_errors&.present? + + stored_errors.each do |msg| + errors.add(:field_configs, msg) + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/field_options.rb b/app/models/option_configs/extra_option_configs/field_options.rb new file mode 100644 index 0000000000..6b72f43192 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/field_options.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for field-level edit options. + # Schema docs: docs/admin_reference/general/field_options.md + # Extracted from ExtraOptions#clean_field_options_def + # + # Values are per-field option hashes keyed by field name. + # Handles converting edit_as.alt_options from Array to Hash. + class FieldOptions < BaseConfiguration + # Named configuration for a single field's options. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[ + include_blank pattern value blank_value preset_value blank_preset_value + active_value no_downcase view_original_case view_with_formats format + config edit_as calculate_with prompt use_app_type selected show_expanded + keep_label + ] + end + + value_pattern :field_option_hash, + description: 'Per-field option hash with edit behavior settings', + match: Hash, + allowed_keys: NamedConfiguration.option_types[:simple] + + validate :validate_field_key_names + validate :validate_value_patterns + + # Override to preprocess alt_options arrays. + def add_named_configuration(sym_key, value) + super(sym_key, preprocess_field(value)) + end + + private + + # Convert edit_as.alt_options from Array to Hash if needed. + # @param [Object] value - raw field option value + # @return [Object] processed value + def preprocess_field(value) + return value unless value.is_a?(Hash) + + ao = value.dig(:edit_as, :alt_options) + return value unless ao.is_a?(Array) + + new_ao = {} + ao.each { |aov| new_ao[aov.to_s.to_sym] = aov.to_s.downcase } + value[:edit_as][:alt_options] = new_ao + value + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/fields.rb b/app/models/option_configs/extra_option_configs/fields.rb new file mode 100644 index 0000000000..263ceca56c --- /dev/null +++ b/app/models/option_configs/extra_option_configs/fields.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for field list setup. + # Schema docs: docs/admin_reference/general/fields.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the field list as a direct array value. + # The processed array is stored back on the parent ExtraOptions (not the object). + class Fields < BaseConfiguration + configure_direct :fields, type: :array + + def self.store_processed_value? + true + end + + # Store the array value, defaulting to empty array. + # @return [void] + def setup_named_configurations + self.fields = hash_configuration.is_a?(Array) ? hash_configuration : [] + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/filestore.rb b/app/models/option_configs/extra_option_configs/filestore.rb new file mode 100644 index 0000000000..bd612687b9 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/filestore.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for filestore configuration. + # Schema docs: docs/admin_reference/general/filestore_container.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the entire hash as a single direct attribute. + # + # @example + # fs = Filestore.new(container: { path: '/data' }) + # fs.filestore #=> { container: { path: '/data' } } + class Filestore < BaseConfiguration + configure_direct :filestore, type: :hash + + # Store the entire input hash as the filestore attribute + # and populate configurations for hash-like [] access. + # @return [void] + def setup_named_configurations + self.filestore = hash_configuration.presence || {} + filestore.each { |k, v| configurations[k] = v } + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/if_condition.rb b/app/models/option_configs/extra_option_configs/if_condition.rb new file mode 100644 index 0000000000..d220868178 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/if_condition.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Reusable configuration class for if_condition hashes + # (e.g. creatable_if, editable_if, showable_if conditions). + # + # Stores the entire conditions hash as a single attribute rather than + # splitting it into individual field-keyed entries. + # + # @example + # cond = IfCondition.new(always: true, user_is_creator: true) + # cond.conditions #=> { always: true, user_is_creator: true } + # cond.blank? #=> false + class IfCondition < BaseConfiguration + configure_direct :conditions, type: :hash + + validate :validate_condition_shape + + # Override default field-keyed setup to store the hash as a single value + # on the conditions attribute. + # @return [void] + def setup_named_configurations + self.conditions = hash_configuration.is_a?(Hash) ? hash_configuration.symbolize_keys : {} + conditions.each { |key, value| configurations[key] = value } + end + + # Returns true when no conditions are defined. + def blank? + conditions.blank? + end + + # Returns the conditions hash for backward compatibility. + # @return [Hash{Symbol => Object}] + def symbolize_keys + conditions || {} + end + + private + + def validate_condition_shape + validate_hash_attribute(:conditions, hash_configuration) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/label.rb b/app/models/option_configs/extra_option_configs/label.rb new file mode 100644 index 0000000000..bdc4fcbdbf --- /dev/null +++ b/app/models/option_configs/extra_option_configs/label.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for the definition label. + # Schema docs: docs/admin_reference/general/label.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the label string as a direct value. + # Uses prepare_config to default to humanized name when not specified. + # The processed string is stored back on the parent ExtraOptions (not the object). + class Label < BaseConfiguration + configure_direct :label, type: :string, level: :warn + + def self.store_processed_value? + true + end + + # Default the label to the humanized option name when not specified. + # @param raw [String, nil] the raw label value from YAML config + # @param parent [ExtraOptions] the parent ExtraOptions instance + # @return [String] the label string + def self.prepare_config(raw, parent) + raw || parent.name.to_s.humanize + end + + # Store the string value, defaulting to empty string. + # @return [void] + def setup_named_configurations + self.label = raw_configuration.is_a?(String) ? raw_configuration : '' + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/labels.rb b/app/models/option_configs/extra_option_configs/labels.rb new file mode 100644 index 0000000000..69f50e6048 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/labels.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for field labels. + # Schema docs: docs/admin_reference/general/labels.md + # Extracted from ExtraOptions#clean_labels_def + # + # Values are plain strings keyed by field name. + # No NamedConfiguration needed — values are simple strings. + class Labels < BaseConfiguration + value_pattern :label_string, + description: 'Display label string', + match: String + + validate :validate_field_key_names + validate :validate_value_patterns + end + end +end diff --git a/app/models/option_configs/extra_option_configs/nfs_store_config.rb b/app/models/option_configs/extra_option_configs/nfs_store_config.rb new file mode 100644 index 0000000000..d58c1b3625 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/nfs_store_config.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for NFS store definitions. + # Schema docs: docs/admin_reference/general/filestore_nfs_store.md + # Migrated from ActivityLogOptions#clean_nfs_store_def (issue #986). + # + # Validates nfs_store top-level and can sub-keys, and delegates + # pipeline cleaning to NfsStore::Config::ExtraOptions.clean_def. + # + # The processed hash is stored back on the parent ExtraOptions (not the object). + class NfsStoreConfig < BaseConfiguration + configure_direct :nfs_store, type: :hash + + def self.store_processed_value? + true + end + + validate :validate_nfs_store_keys + + # Delegate pipeline cleaning to NfsStore::Config::ExtraOptions. + # @param raw [Hash, nil] the raw nfs_store config + # @param _parent [ExtraOptions] the parent ExtraOptions instance (unused) + # @return [Hash, nil] the raw config after pipeline cleaning + def self.prepare_config(raw, _parent) + return nil unless raw + + NfsStore::Config::ExtraOptions.clean_def(raw) + raw + end + + def setup_named_configurations + self.nfs_store = hash_configuration + end + + private + + def validate_nfs_store_keys + raw = hash_configuration + return if raw.blank? + + unless valid_nfs_store_top_keys?(raw) + errors.add(:nfs_store, + "nfs_store contains invalid keys #{raw.keys} - " \ + "expected only #{ActivityLogOptions::ValidNfsStoreKeys}") + end + + can_perform = raw[:can] + return if can_perform.nil? + + return if valid_nfs_store_can_keys?(can_perform) + + errors.add(:nfs_store, + "nfs_store.can contains invalid keys #{can_perform.keys} - " \ + "expected only #{ActivityLogOptions::ValidNfsStoreCanPerformKeys}") + end + + def valid_nfs_store_top_keys?(config) + config.keys.empty? || (config.keys - ActivityLogOptions::ValidNfsStoreKeys).empty? + end + + def valid_nfs_store_can_keys?(config) + config.keys.empty? || (config.keys - ActivityLogOptions::ValidNfsStoreCanPerformKeys).empty? + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/preset_fields.rb b/app/models/option_configs/extra_option_configs/preset_fields.rb new file mode 100644 index 0000000000..d5af42531f --- /dev/null +++ b/app/models/option_configs/extra_option_configs/preset_fields.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for preset field values. + # Schema docs: docs/admin_reference/general/preset_fields.md + # Extracted from ExtraOptions#clean_preset_fields + # + # Values are preset values (strings, hashes, or arrays) keyed by field name. + class PresetFields < BaseConfiguration + validate :validate_field_key_names + end + end +end diff --git a/app/models/option_configs/extra_option_configs/references.rb b/app/models/option_configs/extra_option_configs/references.rb new file mode 100644 index 0000000000..22dba4ba1b --- /dev/null +++ b/app/models/option_configs/extra_option_configs/references.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for model reference configurations. + # Schema docs: docs/admin_reference/general/references.md + # + # Uses the source_attribute pattern: the registry key is :references_config, + # but the raw input is read from :references (a base_key_attribute). + # After processing: + # - extra_options.references = enriched hash (views/models consume this) + # - extra_options.references_config = this References instance (configurations hold ReferenceEntry objects) + # + # Handles: + # - Converting array-style and hash-style references + # - Singularizing reference keys + # - Looking up target classes and enriching reference metadata + # - Warning when referenced models do not exist + class References < BaseConfiguration + configure_direct :references, type: :hash + SPECIAL_KEYS = %i[_bad_references _validation_errors].freeze + + # Keys added by enrich_ref_metadata that are not part of admin input. + COMPUTED_KEYS = %i[ + to_record_label no_master_association to_model_name_us + to_model_class_name to_table_name to_schema_name to_class_type + ].freeze + + # Named configuration for a single reference entry's admin-configured attributes. + # Validates that only recognized keys are present in each reference config. + class NamedConfiguration < OptionConfigs::BaseNamedConfiguration + configure_attributes %i[ + label result_label from without_reference add add_with + filter_by order_by limit type_config + view_as view_options showable_if creatable_if + prevent_disable also_disable_record allow_disable_if_not_editable + prevent_reload_on_save action_position + ] + end + + ReferenceEntry = NamedConfiguration + + def self.source_attribute + :references + end + + def self.store_processed_value? + true + end + + validate :validate_references + + # Pre-process references using parent context. + # Normalizes array/hash formats, singularizes keys, resolves target classes, + # and enriches reference entries with class metadata. + # @param raw [Array, Hash, nil] the raw references value from YAML config + # @param parent [ExtraOptions] the parent ExtraOptions instance + # @return [Hash, nil] the processed references hash + def self.prepare_config(raw, parent) + return nil unless raw + + new_ref, validation_errors = normalize_references(raw) + bad_items = resolve_reference_classes(new_ref, parent) + new_ref[:_bad_references] = bad_items if bad_items.present? + new_ref[:_validation_errors] = validation_errors if validation_errors.present? + new_ref + end + + # Normalize raw references from Array or Hash format into a unified hash. + # Each entry maps a composite key to { model_name => config }. + # Plural keys are singularized (e.g. :player_contacts => :player_contact). + # @param raw [Array, Hash] raw references from YAML config + # @return [Hash] normalized references hash + private_class_method def self.normalize_references(raw) + unless raw.is_a?(Hash) || raw.is_a?(Array) + return [{}, ['references must be a Hash or an Array of Hash entries']] + end + + validation_errors = [] + ref_items = if raw.is_a?(Array) + raw.each_with_index.filter_map do |item, index| + if item.is_a?(Hash) + item.dup + else + validation_errors << "references entry #{index + 1} must be a Hash" + nil + end + end + else + [raw.dup] + end + + result = {} + ref_items.each do |refitem| + singularize_keys!(refitem) + refitem.each do |k, v| + unless v.is_a?(Hash) + validation_errors << "reference #{k} must be a Hash" + v = {} + end + + result[composite_ref_key(k, v)] = { k => v } + end + end + [result, validation_errors] + end + + # Replace all plural keys with their singular form, mutating the hash. + # @param hash [Hash] hash whose keys to singularize + # @return [void] + private_class_method def self.singularize_keys!(hash) + hash.keys.each do |k| + singular = k.to_s.singularize.to_sym + next if singular == k + + hash[singular] = hash.delete(k) + end + end + + # Build a composite reference key from the model name and optional extra_log_type. + # For example, :player_contact or :player_contact_initial_review. + # @param model_name [Symbol] the singularized model name + # @param config [Hash] the reference configuration + # @return [Symbol] composite key + private_class_method def self.composite_ref_key(model_name, config) + elt = config.dig(:add_with, :extra_log_type) + key = model_name.to_s + key += "_#{elt}" if elt + key.to_sym + end + + # Resolve target classes for each reference and enrich with metadata. + # Removes entries whose classes cannot be resolved. + # @param new_ref [Hash] normalized references hash (mutated in place) + # @param parent [ExtraOptions] the parent ExtraOptions instance (used for log context) + # @return [Array] model names that could not be resolved + private_class_method def self.resolve_reference_classes(new_ref, parent) + all_bad_items = [] + + new_ref.each_value do |refitem| + bad_items = [] + + refitem.each do |mn, conf| + to_class = ModelReference.to_record_class_for_type(mn) + + # Skip references whose target model or definition isn't set up yet, + # to avoid breaking app type imports. + if to_class.nil? || (to_class.respond_to?(:definition) && !to_class.definition) + Rails.logger.warn "Definition for class #{to_class} is not set - skipping reference setup for #{mn}" + all_bad_items << mn + bad_items << mn + break + end + + enrich_ref_metadata(refitem, mn, conf, to_class) + end + + bad_items.each { |br| refitem.delete(br) } + end + + all_bad_items + end + + # Enrich a single reference entry with resolved class metadata. + # @param refitem [Hash] the reference item hash (mutated) + # @param model_name [Symbol] the model name key + # @param conf [Hash] the reference configuration + # @param to_class [Class] the resolved target class + # @return [void] + private_class_method def self.enrich_ref_metadata(refitem, model_name, conf, to_class) + elt = conf.dig(:add_with, :extra_log_type) + add_with_label = to_class.human_name_for(elt) if elt && to_class.respond_to?(:human_name_for) + + entry = refitem[model_name] + entry[:to_record_label] = conf[:result_label] || conf[:label] || add_with_label || to_class.human_name + entry[:no_master_association] = to_class.no_master_association if to_class.respond_to?(:no_master_association) + entry[:to_model_name_us] = to_class.to_s.ns_underscore + entry[:to_model_class_name] = to_class.to_s + entry[:to_table_name] = to_class.table_name + + return unless to_class.respond_to?(:definition) + + defn = to_class.definition + entry[:to_schema_name] = defn.schema_name + entry[:to_class_type] = defn.class.to_s + end + + # Re-process references on an already-initialized ExtraOptions instance. + # Use this after mutating `instance.references` post-initialization. + # @param instance [ExtraOptions] the ExtraOptions instance to reprocess + # @return [void] + def self.reprocess(instance) + instance.references = prepare_config(instance.references, instance) + end + + # Store the enriched hash on the direct attribute and create ReferenceEntry + # named configurations for each entry's input-only keys. + # @return [void] + def setup_named_configurations + self.references = hash_configuration.except(*SPECIAL_KEYS).presence + return unless references + + references.each do |composite_key, refitem| + refitem.each_value do |config| + input_only = config.except(*COMPUTED_KEYS) + add_named_configuration(composite_key, input_only) + end + end + end + + private + + # Validate that all referenced models exist. + def validate_references + Array(hash_configuration[:_validation_errors]).each do |msg| + errors.add(:references, msg) + end + + bad_refs = hash_configuration[:_bad_references] + return unless bad_refs&.present? + + bad_refs.each do |mn| + errors.add(:references, "reference for #{mn} does not exist as a class", type: :warning) + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/save_action.rb b/app/models/option_configs/extra_option_configs/save_action.rb new file mode 100644 index 0000000000..2ecb7ecc43 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/save_action.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for save action cascading (on_save -> on_create/on_update). + # Schema docs: docs/admin_reference/general/save_action.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the entire hash as a single direct attribute after cascading. + # + # Handles: + # - on_save cascade to on_create and on_update (hash merge) + # + # @example + # sa = SaveAction.new(on_save: { label: 'Saved' }) + # sa.save_action[:on_create] #=> { label: 'Saved' } + class SaveAction < BaseConfiguration + configure_direct :save_action, type: :hash + + # Store the input hash with on_save cascade applied + # and populate configurations for hash-like [] access. + # @return [void] + def setup_named_configurations + sa = hash_configuration.presence || {} + cascade_on_save(sa) + self.save_action = sa + sa.each { |k, v| configurations[k] = v } + end + + private + + # Cascade on_save into on_create and on_update as defaults. + # on_save values are merged as base, with specific key values taking priority. + # @param [Hash] sa - the save_action hash (modified in place) + # @return [void] + def cascade_on_save(sa) + os = sa[:on_save] + return unless os + + ou = sa[:on_update] || {} + oc = sa[:on_create] || {} + sa[:on_update] = os.merge(ou) + sa[:on_create] = os.merge(oc) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/save_trigger.rb b/app/models/option_configs/extra_option_configs/save_trigger.rb new file mode 100644 index 0000000000..87d87628a3 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/save_trigger.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for save trigger cascading. + # Schema docs: docs/admin_reference/general/save_trigger.md + # Uses the BaseConfiguration pattern with typed attributes for each + # trigger key, all typed as TriggerTasks. + # + # Handles: + # - Key validation against ValidSaveTriggerTriggers + # - on_save cascade to on_create/on_update (wrapping hashes into arrays) + # - Default blank TriggerTasks for on_upload and on_disable + # + # @example + # st = SaveTrigger.new(on_save: { notify: { type: 'email' } }) + # st[:on_create].tasks #=> [{ notify: { type: 'email' } }] + class SaveTrigger < BaseConfiguration + configure_typed_attribute :on_create, type: TriggerTasks + configure_typed_attribute :on_update, type: TriggerTasks + configure_typed_attribute :on_save, type: TriggerTasks + configure_typed_attribute :on_upload, type: TriggerTasks + configure_typed_attribute :on_disable, type: TriggerTasks + configure_typed_attribute :before_save, type: TriggerTasks + + validate :validate_keys + validate :validate_trigger_shapes + + # Set up typed attributes with cascade logic. + # @return [void] + def setup_named_configurations + config_hash = hash_configuration.is_a?(Hash) ? hash_configuration.deep_dup : {} + self.hash_configuration = config_hash + cascade_on_save + + setup_all_options_typed(config_hash) + + # Store the 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 if no trigger tasks are configured on any key. + def blank? + self.class.option_types[:typed].all? { |key| send(key).blank? } + end + + private + + # Validate keys against the allowed set. + # @return [void] + def validate_keys + return unless validate_hash_attribute(:save_trigger, raw_configuration) + return if hash_configuration.keys.empty? + + invalid = hash_configuration.keys - OptionConfigs::ExtraOptions::ValidSaveTriggerTriggers + return if invalid.empty? + + errors.add(:save_trigger, + "contains invalid keys #{hash_configuration.keys} - expected only: #{OptionConfigs::ExtraOptions::ValidSaveTriggerTriggers}") + end + + def validate_trigger_shapes + return unless raw_configuration.is_a?(Hash) + + hash_configuration.each do |trigger_name, config| + next if config.nil? || config.is_a?(Hash) || config.is_a?(Array) + + add_validation_notice(:save_trigger, + "#{trigger_name} must be a Hash or Array of Hash task definitions") + end + end + + # Cascade on_save into on_create and on_update (wrapping hashes into arrays). + # Modifies hash_configuration in place before typed attributes are initialized. + # @return [void] + def cascade_on_save + return unless hash_configuration.is_a?(Hash) + + os = hash_configuration[:on_save] + return unless os + + os = [os] if os.is_a?(Hash) + + oc = hash_configuration[:on_create] + ou = hash_configuration[:on_update] + oc = [oc] if oc.is_a?(Hash) + ou = [ou] if ou.is_a?(Hash) + oc ||= [] + ou ||= [] + + hash_configuration[:on_create] = os + oc + hash_configuration[:on_update] = os + ou + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/set_variable.rb b/app/models/option_configs/extra_option_configs/set_variable.rb new file mode 100644 index 0000000000..85e5b2e010 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/set_variable.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for variable definitions. + # Schema docs: docs/admin_reference/general/set_variables.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the validated array as a direct attribute. + # + # Handles: + # - Validating set_variables is an array + # - Validating each entry has :name and :value keys + # - Filtering out invalid entries + # + # @example + # sv = SetVariable.new([{ name: 'var1', value: 'val1' }]) + # sv.set_variables #=> [{ name: 'var1', value: 'val1' }] + class SetVariable < BaseConfiguration + configure_direct :set_variables, type: :array + + validate :validate_structure + + def self.store_processed_value? + true + end + + # Parse and store the input array. + # @return [void] + def setup_named_configurations + raw = hash_configuration + if raw.blank? + self.set_variables = nil + return + end + + unless raw.is_a?(Array) + self.set_variables = [] + return + end + + self.set_variables = raw.filter_map do |entry| + entry = entry.symbolize_keys if entry.is_a?(Hash) + next nil unless entry.is_a?(Hash) && entry[:name].present? && entry.key?(:value) + + entry + end + end + + # Returns true when no variables are defined. + def blank? + set_variables.blank? + end + + private + + # Validate set_variables structure. + # @return [void] + def validate_structure + raw = hash_configuration + return if raw.blank? + + unless raw.is_a?(Array) + errors.add(:set_variables, 'must be an array of variable definitions') + return + end + + raw.each do |entry| + entry = entry.symbolize_keys if entry.is_a?(Hash) + next if entry.is_a?(Hash) && entry[:name].present? && entry.key?(:value) + + errors.add(:set_variables, "each entry must have 'name' and 'value' keys") + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/show_if.rb b/app/models/option_configs/extra_option_configs/show_if.rb new file mode 100644 index 0000000000..dc917039f8 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/show_if.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for conditional visibility (show_if). + # Schema docs: docs/admin_reference/general/show_if.md + # Extracted from ExtraOptions#clean_show_if_def + # + # Values are arbitrary condition hashes keyed by field name. + # show_if_condition_strings (REDCap branching logic) are preprocessed + # via prepare_config before initialization. + class ShowIf < BaseConfiguration + # No NamedConfiguration — values are arbitrary condition hashes + + value_pattern :condition_hash, + description: 'Hash of field conditions', + match: Hash + + validate :validate_field_key_names + validate :validate_value_patterns + validate :validate_show_if_shape + + # Pre-process config using parent context to merge + # show_if_condition_strings (REDCap branching logic). + # Also injects _valid_fields for field key validation. + # Called by ExtraOptions before initialization. + # @param [Hash] raw - raw show_if hash from YAML + # @param [ExtraOptions] parent - parent ExtraOptions instance + # @return [Hash] merged show_if hash + def self.prepare_config(raw, parent) + raw ||= {} + + parent.show_if_condition_strings&.each do |fn, val| + next if val.nil? || val.empty? || raw[fn] + + begin + bl = Redcap::DataDictionaries::BranchingLogic.new(val) + sis = bl&.generate_show_if + raw[fn] = sis if sis.present? + rescue StandardError => e + Rails.logger.warn "Failed to generate real show_if (in #{parent.config_obj&.resource_name}) " \ + "for #{fn}: #{val}\n#{e}" + raw[fn] = { generate_show_if: "failed - #{e}" } + end + end + + raw[Concerns::PatternValidation::VALID_FIELDS_KEY] = parent.fields || [] if raw.is_a?(Hash) + raw + end + + def setup_named_configurations + return unless hash_configuration.is_a?(Hash) + + super + end + + private + + def validate_show_if_shape + return unless validate_hash_attribute(:show_if, hash_configuration) + + each_config_entry do |field_name, config| + next if config.nil? || config.is_a?(Hash) + + add_validation_notice(:show_if, "#{field_name} must define a Hash of conditions") + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/showable_if.rb b/app/models/option_configs/extra_option_configs/showable_if.rb new file mode 100644 index 0000000000..183e88aa50 --- /dev/null +++ b/app/models/option_configs/extra_option_configs/showable_if.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for showable_if access control condition. + # Schema docs: docs/admin_reference/general/showable_if.md + # Implemented as an IfCondition-backed config object rather than a raw Hash + # so validation and hash-like behavior come from the shared IfCondition class. + class ShowableIf < IfCondition + def showable_if + conditions + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/trigger_tasks.rb b/app/models/option_configs/extra_option_configs/trigger_tasks.rb new file mode 100644 index 0000000000..9fa934772b --- /dev/null +++ b/app/models/option_configs/extra_option_configs/trigger_tasks.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Reusable configuration class for trigger task hashes or arrays. + # Used by BatchTrigger (on_record), SaveTrigger (on_create, on_update, etc.) + # to store trigger task configurations. + # + # Accepts either a Hash or an Array of trigger task hashes. + # Hash values get symbolized keys; arrays are stored as-is. + # + # @example Hash input + # tasks = TriggerTasks.new(notify: { type: 'email' }, update_this: { field: 'val' }) + # tasks.tasks #=> { notify: { type: 'email' }, update_this: { field: 'val' } } + # + # @example Array input + # tasks = TriggerTasks.new([{ notify: { type: 'email' } }]) + # tasks.tasks #=> [{ notify: { type: 'email' } }] + class TriggerTasks < BaseConfiguration + configure_direct :tasks, type: :array_or_hash + + validate :validate_tasks_structure + + # Override default field-keyed setup to store the value as a single + # attribute. Handles both Hash and Array inputs. + # @return [void] + def setup_named_configurations + raw = hash_configuration + self.tasks = normalize_tasks(raw) + end + + # Returns true when no tasks are defined. + def blank? + tasks.blank? + end + + # Returns the tasks value for backward compatibility. + # @return [Hash{Symbol => Object}, Array] + def symbolize_keys + tasks || {} + end + + private + + def validate_tasks_structure + raw = hash_configuration + return if raw.blank? + return unless validate_array_or_hash_attribute(:tasks, raw) + return unless raw.is_a?(Array) + + raw.each_with_index do |item, index| + next if item.is_a?(Hash) + + add_validation_notice(:tasks, "entry #{index + 1} must be a Hash task definition") + end + end + + def normalize_tasks(value) + return nil if value.nil? + + case value + when Array + value.map { |item| normalize_tasks(item) } + when Hash + value.each_with_object({}) do |(key, nested_value), normalized| + normalized[key.to_sym] = normalize_tasks(nested_value) + end + else + if value.respond_to?(:filtered_hash) + normalize_tasks(value.filtered_hash) + elsif value.respond_to?(:to_h) && !value.is_a?(String) + normalize_tasks(value.to_h) + else + value + end + end + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/valid_if.rb b/app/models/option_configs/extra_option_configs/valid_if.rb new file mode 100644 index 0000000000..cd0985d86f --- /dev/null +++ b/app/models/option_configs/extra_option_configs/valid_if.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for validation trigger conditions. + # Schema docs: docs/admin_reference/general/valid_if.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the entire hash as a single direct attribute after validation and cascade. + # + # Handles: + # - Key validation against ValidValidIfTriggers + # - on_save cascade to on_create/on_update + # + # @example + # vi = ValidIf.new(on_save: { all: { this: { field: 'is not null' } } }) + # vi.valid_if[:on_create] #=> { all: { this: { field: 'is not null' } } } + class ValidIf < BaseConfiguration + configure_direct :valid_if, type: :hash + + validate :validate_keys + validate :validate_trigger_payloads + + # Apply on_save cascade and store as valid_if attribute. + # Populates configurations for hash-like [] access. + # @return [void] + def setup_named_configurations + vi = hash_configuration.is_a?(Hash) ? hash_configuration.deep_dup : {} + cascade_on_save(vi) + self.valid_if = vi + vi.each { |k, v| configurations[k] = v } + end + + private + + # Validate keys against the allowed set. + # @return [void] + def validate_keys + return unless validate_hash_attribute(:valid_if, raw_configuration) + + vi = hash_configuration + return if vi.blank? || vi.keys.empty? + + invalid = vi.keys - OptionConfigs::ExtraOptions::ValidValidIfTriggers + return if invalid.empty? + + errors.add(:valid_if, + "contains invalid keys #{vi.keys} - expected only: #{OptionConfigs::ExtraOptions::ValidValidIfTriggers}") + end + + def validate_trigger_payloads + return unless raw_configuration.is_a?(Hash) + + hash_configuration.each do |trigger_name, config| + next if config.nil? || config.is_a?(Hash) + + add_validation_notice(:valid_if, "#{trigger_name} must be a Hash of conditions") + end + end + + # Cascade on_save into on_create and on_update as defaults. + # on_save values are merged as base, with specific key values taking priority. + # @param [Hash] vi - the valid_if hash (modified in place) + # @return [void] + def cascade_on_save(vi) + os = vi[:on_save] + return unless os.is_a?(Hash) + + ou = vi[:on_update].is_a?(Hash) ? vi[:on_update] : {} + oc = vi[:on_create].is_a?(Hash) ? vi[:on_create] : {} + vi[:on_update] = os.merge(ou) + vi[:on_create] = os.merge(oc) + end + end + end +end diff --git a/app/models/option_configs/extra_option_configs/view_options.rb b/app/models/option_configs/extra_option_configs/view_options.rb new file mode 100644 index 0000000000..8d5a1a100b --- /dev/null +++ b/app/models/option_configs/extra_option_configs/view_options.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module OptionConfigs + module ExtraOptionConfigs + # Configuration class for view options. + # Schema docs: docs/admin_reference/general/view_options.md + # Converted from ConfigBase to BaseConfiguration pattern. + # Stores the entire hash as a single direct attribute. + # + # @example + # vo = ViewOptions.new(data_attribute: 'field_1', show_embedded: true) + # vo.view_options #=> { data_attribute: 'field_1', show_embedded: true } + class ViewOptions < BaseConfiguration + configure_direct :view_options, type: :hash + + ALLOWED_KEYS = %i[ + show_embedded_at_top hide_unless_creatable data_attribute + always_embed_reference always_embed_creatable_reference alt_order + show_cancel only_create_as_reference sort_references view_handlers + header_caption alt_width_classes extra_class + ].freeze + BOOLEAN_KEYS = %i[ + show_embedded_at_top hide_unless_creatable show_cancel only_create_as_reference + ].freeze + STRING_OR_ARRAY_KEYS = %i[data_attribute alt_order view_handlers].freeze + STRING_KEYS = %i[ + always_embed_reference always_embed_creatable_reference + header_caption alt_width_classes extra_class + ].freeze + SORT_REFERENCE_KEYS = %i[attribute direction keep_top null_value].freeze + SORT_REFERENCE_DIRECTIONS = %w[asc desc reverse].freeze + + validate :validate_view_options_shape + + # Store the entire input hash as the view_options attribute + # and populate configurations for hash-like [] access. + # @return [void] + def setup_named_configurations + self.view_options = hash_configuration.is_a?(Hash) ? (hash_configuration.presence || {}) : {} + view_options.each { |k, v| configurations[k] = v } + end + + private + + def validate_view_options_shape + return unless validate_hash_attribute(:view_options, hash_configuration) + + validate_allowed_hash_keys(:view_options, hash_configuration, ALLOWED_KEYS) + + BOOLEAN_KEYS.each do |key| + next unless hash_configuration.key?(key) + next if [true, false].include?(hash_configuration[key]) + + add_validation_notice(:view_options, "#{key} must be true or false") + end + + STRING_OR_ARRAY_KEYS.each do |key| + next unless hash_configuration.key?(key) + next if string_or_array_of_strings?(hash_configuration[key]) + + add_validation_notice(:view_options, "#{key} must be a String or an Array of Strings") + end + + STRING_KEYS.each do |key| + next unless hash_configuration.key?(key) + next if string_like?(hash_configuration[key]) + + add_validation_notice(:view_options, "#{key} must be a String") + end + + validate_sort_references(hash_configuration[:sort_references]) if hash_configuration.key?(:sort_references) + end + + def validate_sort_references(config) + unless validate_hash_attribute(:view_options, config, allow_blank: false) + add_validation_notice(:view_options, 'sort_references must be a Hash') + return + end + + validate_allowed_hash_keys(:view_options, config, SORT_REFERENCE_KEYS) + + if config.key?(:attribute) && !string_like?(config[:attribute]) + add_validation_notice(:view_options, 'sort_references.attribute must be a String') + end + + if config.key?(:direction) && !SORT_REFERENCE_DIRECTIONS.include?(config[:direction].to_s) + add_validation_notice(:view_options, + "sort_references.direction must be one of #{SORT_REFERENCE_DIRECTIONS}") + end + + return unless config.key?(:keep_top) && ![true, false].include?(config[:keep_top]) + + add_validation_notice(:view_options, 'sort_references.keep_top must be true or false') + end + + def string_like?(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def string_or_array_of_strings?(value) + return true if string_like?(value) + return false unless value.is_a?(Array) + + value.all? { |item| string_like?(item) } + end + end + end +end diff --git a/app/models/option_configs/extra_options.rb b/app/models/option_configs/extra_options.rb index 5a7575e20d..ebb5d3cb49 100644 --- a/app/models/option_configs/extra_options.rb +++ b/app/models/option_configs/extra_options.rb @@ -13,29 +13,62 @@ class ExtraOptions < BaseOptions LibraryMatchRegex = /# @library\s+([^\s]+)\s+([^\s]+)\s*$/ ValidFieldConfigs = %i[db_configs field_options labels caption_before dialog_before show_if].freeze + # Registry of configuration classes, in the order they must be initialized. + # Each entry maps a key (used in config_instances) to the config class. + # The order mirrors the original clean_... method call sequence. + def self.config_class_registry + { + fields: ExtraOptionConfigs::Fields, + field_configs: ExtraOptionConfigs::FieldConfigs, + label: ExtraOptionConfigs::Label, + caption_before: ExtraOptionConfigs::CaptionBefore, + dialog_before: ExtraOptionConfigs::DialogBefore, + labels: ExtraOptionConfigs::Labels, + show_if: ExtraOptionConfigs::ShowIf, + save_action: ExtraOptionConfigs::SaveAction, + view_options: ExtraOptionConfigs::ViewOptions, + db_configs: ExtraOptionConfigs::DbConfigs, + creatable_if: ExtraOptionConfigs::CreatableIf, + editable_if: ExtraOptionConfigs::EditableIf, + showable_if: ExtraOptionConfigs::ShowableIf, + valid_if: ExtraOptionConfigs::ValidIf, + filestore: ExtraOptionConfigs::Filestore, + field_options: ExtraOptionConfigs::FieldOptions, + embed_config: ExtraOptionConfigs::Embed, + references_config: ExtraOptionConfigs::References, + save_trigger: ExtraOptionConfigs::SaveTrigger, + batch_trigger: ExtraOptionConfigs::BatchTrigger, + config_trigger: ExtraOptionConfigs::ConfigTrigger, + preset_fields: ExtraOptionConfigs::PresetFields, + set_variables: ExtraOptionConfigs::SetVariable + } + end + def self.base_key_attributes %i[ - name label config_obj caption_before show_if resource_name resource_item_name save_action view_options - field_options dialog_before creatable_if editable_if showable_if add_reference_if valid_if - filestore labels fields button_label orig_config db_configs save_trigger embed references - show_if_condition_strings batch_trigger config_trigger preset_fields field_configs raw_field_configs - set_variables + name config_obj resource_name resource_item_name add_reference_if + button_label orig_config show_if_condition_strings raw_field_configs + references embed ] end + def self.config_class_attributes + config_class_registry.keys + end + def self.add_key_attributes [] end def self.key_attributes - base_key_attributes + add_key_attributes + base_key_attributes + config_class_attributes + add_key_attributes end def self.editable_attributes key_attributes - %i[name config_obj resource_name resource_item_name] + [:label] end - attr_accessor(*key_attributes, :def_item, :bad_ref_items) + attr_accessor(*key_attributes, :def_item, :config_instances) # # Initialize a named option configuration, which may form one of many in a dynamic definition @@ -46,6 +79,7 @@ def initialize(name, config, config_obj) super() @name = name @orig_config = config + @config_instances = {} self.def_item = @config_obj = config_obj @@ -62,332 +96,52 @@ def initialize(name, config, config_obj) begin self.resource_name = "#{config_obj.full_implementation_class_name.ns_underscore}__#{self.name}" self.resource_item_name = resource_name - clean_fields_def - clean_field_configs - - clean_label_def - clean_caption_before_def - clean_dialog_before_def - clean_labels_def - clean_show_if_def - clean_save_action_def - clean_view_options_def - clean_db_configs_def - clean_access_if_def - clean_valid_if_def - clean_filestore_def - clean_field_options_def - clean_embed_def - clean_references_def - clean_save_triggers - clean_batch_triggers - clean_config_triggers - clean_preset_fields - clean_set_variables_def - - # Add the cleaned values back into field_configs - save a raw version for use elsewhere - # This needs to be "deep cloned", to avoid a simple clone just copying references - # Using Marshal for deep cloning is safe here since we're only operating on data already in memory - self.raw_field_configs = Marshal.load(Marshal.dump(field_configs)) - add_field_configs_from_standalone_defs - rescue StandardError => e - Rails.logger.warn "Failed to initialize ExtraOptions for #{@name}: #{e}" - Rails.logger.warn e.short_string_backtrace - raise FphsOptionsGeneralError, "Failed to initialize ExtraOptions for #{@name}: #{e}", e.backtrace - end - end - - # Defintion label - def clean_label_def - self.label ||= @name.to_s.humanize - end - - def clean_caption_before_def - self.caption_before ||= {} - self.caption_before = self.caption_before.symbolize_keys - - self.caption_before = self.caption_before.each do |k, v| - if v.is_a? String - - v = Formatter::Substitution.text_to_html(v).strip - - self.caption_before[k] = { - caption: v, - edit_caption: v, - show_caption: v, - new_caption: v - } - elsif v.is_a? Hash - v.each do |mode, modeval| - v[mode] = Formatter::Substitution.text_to_html(modeval).to_s.strip - end - - v[:new_caption] = v[:edit_caption] unless v.key?(:new_caption) - end - end - end - - def clean_dialog_before_def - self.dialog_before ||= {} - self.dialog_before = self.dialog_before.symbolize_keys - - dialog_before.transform_values! { |v| v.is_a?(String) ? { name: v } : v } - dialog_before.each do |k, v| - unless v.is_a? Hash - failed_config :dialog_before, - "dialog_before must be a Hash { name: '