From c7cbd0b67757baf218ee2ddde178e3877b2a440f Mon Sep 17 00:00:00 2001 From: Phil Ayres Date: Thu, 16 Jul 2026 20:46:35 +0100 Subject: [PATCH 1/2] Added name_starts_with_yaml_object edit field and result field - fixes #1269 --- app/assets/javascripts/app/_fpa_form_utils.js | 49 ++++ .../javascripts/app/handlebars-helpers.js | 14 ++ app/assets/javascripts/application.js | 9 + app/assets/stylesheets/app/forms.css.scss | 6 + app/assets/stylesheets/application.css | 4 + .../edit_fields/edit_form_field_helper.rb | 4 + app/models/dynamic/field_edit_as/handler.rb | 49 +++- .../dynamic/field_edit_as/yaml_object.rb | 37 +++ .../_common_template_result_fields.html.erb | 11 + .../_name_starts_with_yaml_object.html.erb | 32 +++ .../edit_form_field_helper_spec.rb | 123 ++++++++++ .../_fpa_form_utils_codemirror_spec.js | 169 ++++++++++++++ .../_handlebars_helpers_yaml_parse_spec.js | 56 +++++ .../dynamic/field_edit_as/handler_spec.rb | 158 +++++++++++++ .../dynamic/field_edit_as/yaml_object_spec.rb | 53 +++++ spec/support/codemirror_editor_support.rb | 18 +- .../dynamic_model/dynamic_model_spec.rb | 18 +- .../dynamic_model_yaml_object_spec.rb | 218 ++++++++++++++++++ ...template_result_fields_yaml_object_spec.rb | 77 +++++++ .../_name_starts_with_yaml_object_spec.rb | 114 +++++++++ 20 files changed, 1201 insertions(+), 18 deletions(-) create mode 100644 app/models/dynamic/field_edit_as/yaml_object.rb create mode 100644 app/views/common_templates/edit_fields/_name_starts_with_yaml_object.html.erb create mode 100644 spec/javascripts/_fpa_form_utils_codemirror_spec.js create mode 100644 spec/javascripts/_handlebars_helpers_yaml_parse_spec.js create mode 100644 spec/models/dynamic/field_edit_as/handler_spec.rb create mode 100644 spec/models/dynamic/field_edit_as/yaml_object_spec.rb create mode 100644 spec/system/dynamic_model/dynamic_model_yaml_object_spec.rb create mode 100644 spec/views/common_templates/_common_template_result_fields_yaml_object_spec.rb create mode 100644 spec/views/common_templates/edit_fields/_name_starts_with_yaml_object_spec.rb diff --git a/app/assets/javascripts/app/_fpa_form_utils.js b/app/assets/javascripts/app/_fpa_form_utils.js index 97178ea1e3..87dde50629 100644 --- a/app/assets/javascripts/app/_fpa_form_utils.js +++ b/app/assets/javascripts/app/_fpa_form_utils.js @@ -138,6 +138,8 @@ _fpa.form_utils = { // form using the "form" attribute, indicating they belong to it. Check for this // and adjust the block to be formatted appropriately. on_form_submit: function (block) { + var form_block = block; + if (block && block.is('form')) { var form_id = block.prop('id'); if (form_id) { @@ -148,10 +150,26 @@ _fpa.form_utils = { } } + // Synchronize CodeMirror editors to their backing textareas before submission + _fpa.form_utils.save_codemirror_editors(form_block); + if (block !== form_block) { + _fpa.form_utils.save_codemirror_editors(block); + } + _fpa.form_utils.date_inputs_to_iso(block); _fpa.form_utils.unmask_inputs(block); }, + // Save all CodeMirror editor instances within a block back to their backing textareas. + // Safely skips elements where CodeMirror was never initialized. + save_codemirror_editors: function (block) { + block.find('.code-editor').each(function () { + if (this.CodeMirror) { + this.CodeMirror.save(); + } + }); + }, + unmask_inputs: function (block) { var inputs = block.find("input[data-unmask='number'].is-masked"); inputs @@ -2452,6 +2470,36 @@ _fpa.form_utils = { _fpa.custom_editor.setup(block); }, + // Initialize CodeMirror editors for textareas marked with class 'code-editor' + // (e.g. YAML/JSON edit fields - see common_templates/edit_fields/_column_type_jsonb.html.erb + // and _name_starts_with_yaml_object.html.erb). Without this, those fields render as plain + // textareas, since the main app does not otherwise load or initialize CodeMirror (unlike + // the admin panel - see admin/all/admin_edit_form.js#setup_codemirror_editors, which this + // mirrors). + setup_codemirror_editors: function (block) { + block.find('.code-editor').not('.code-editor-formatted').each(function () { + var code_el = $(this).get(0); + var mode = $(this).attr('data-code-editor-type'); + if (!mode) mode = 'yaml'; + + var cm = CodeMirror.fromTextArea(code_el, { + lineNumbers: true, + mode: mode, + foldGutter: true, + gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], + extraKeys: { + Tab: function (cm) { cm.execCommand("indentMore") }, + "Shift-Tab": function (cm) { cm.execCommand("indentLess") } + } + }); + var cme = cm.getWrapperElement(); + cme.style.width = '100%'; + cme.style.height = '100%'; + code_el.CodeMirror = cm; + cm.refresh(); + }).addClass('code-editor-formatted'); + }, + setup_filestore: function (block) { block .find('.browse-container') @@ -2704,6 +2752,7 @@ _fpa.form_utils = { _fpa.form_utils.mask_inputs(block); _fpa.form_utils.setup_textarea_autogrow(block); _fpa.form_utils.setup_textarea_editor(block); + _fpa.form_utils.setup_codemirror_editors(block); _fpa.form_utils.setup_contact_field_mask(block); _fpa.form_utils.setup_filestore(block); _fpa.form_utils.setup_e_signature(block); diff --git a/app/assets/javascripts/app/handlebars-helpers.js b/app/assets/javascripts/app/handlebars-helpers.js index 87dcdcd03a..1c59cfa640 100644 --- a/app/assets/javascripts/app/handlebars-helpers.js +++ b/app/assets/javascripts/app/handlebars-helpers.js @@ -285,6 +285,20 @@ return typeof obj; }); + // Parse a YAML text string into its represented Hash/Array, for fields stored as + // plain YAML text (see name_starts_with_yaml_object edit fields). If obj is not a + // string (e.g. already parsed, null, or undefined), it is returned unchanged. If the + // string fails to parse as YAML, it is returned unchanged so rendering degrades + // gracefully rather than raising. + Handlebars.registerHelper('yaml_parse', function (obj) { + if (typeof obj !== 'string') return obj; + try { + return jsyaml.load(obj); + } catch (e) { + return obj; + } + }); + Handlebars.registerHelper('nl2br', function (text) { var nl2br = _fpa.utils.nl2br(text); return new Handlebars.SafeString(nl2br) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 1c6e2ef9ae..209cee206d 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -55,4 +55,13 @@ //= require ./big_select/big_select.js //= require js-yaml/dist/js-yaml +// CodeMirror - used to edit YAML content for fields such as +// common_templates/edit_fields/_column_type_jsonb.html.erb and +// _name_starts_with_yaml_object.html.erb (see _fpa_form_utils.js#setup_codemirror_editors) +//= require codemirror/lib/codemirror +//= require codemirror/addon/fold/foldcode +//= require codemirror/addon/fold/foldgutter +//= require codemirror/addon/fold/indent-fold +//= require codemirror/mode/yaml/yaml + //= require_tree ./app diff --git a/app/assets/stylesheets/app/forms.css.scss b/app/assets/stylesheets/app/forms.css.scss index f67ff4ce14..8894d4c8b5 100644 --- a/app/assets/stylesheets/app/forms.css.scss +++ b/app/assets/stylesheets/app/forms.css.scss @@ -28,6 +28,12 @@ form .full-width input.form-control { width: 100% !important; } +// Basic visual boundary for CodeMirror editors used by YAML/JSON edit fields +// (see _fpa_form_utils.js#setup_codemirror_editors) +.CodeMirror.cm-s-default { + border: 1px solid rgb(204, 204, 204); +} + .tt-menu { border: 1px solid silver; width: 100%; diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 8ec7808c88..728ac6d366 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -33,6 +33,10 @@ * *= require ./big_select/big_select * + * CodeMirror - used to edit YAML content (see application.js and _fpa_form_utils.js) + *= require codemirror/lib/codemirror + *= require codemirror/addon/fold/foldgutter + * *= require_tree ./app *= require_self */ diff --git a/app/helpers/edit_fields/edit_form_field_helper.rb b/app/helpers/edit_fields/edit_form_field_helper.rb index 0e44250233..2d12c495a7 100644 --- a/app/helpers/edit_fields/edit_form_field_helper.rb +++ b/app/helpers/edit_fields/edit_form_field_helper.rb @@ -73,6 +73,10 @@ def edit_form_field( match_name = "name_starts_with_#{sw}" next unless curr_field_name.start_with?("#{sw}_") && f_names.include?(match_name) + # yaml_object editor is only valid for text/string columns; other column types + # (e.g. jsonb) fall through to normal column-type rendering. + next if (sw == 'yaml_object') && !%i[text string].include?(column_type.to_sym) + partial_fn = "common_templates/edit_fields/#{match_name}" got = render partial: partial_fn, locals: local_vars[:locals] diff --git a/app/models/dynamic/field_edit_as/handler.rb b/app/models/dynamic/field_edit_as/handler.rb index abbde01e9c..b5a5dc4f55 100644 --- a/app/models/dynamic/field_edit_as/handler.rb +++ b/app/models/dynamic/field_edit_as/handler.rb @@ -17,7 +17,11 @@ class Handler # the `col_type_jsonb` field type derived from jsonb columns - both are # handled by Dynamic::FieldEditAs::ColTypeJson, which parses the submitted # YAML text back into a Hash/Array for storage. - TransformFieldTypes = %w[multi_editable_list multi_editable_choices col_type_json].freeze + # `yaml_object` is resolved for name_starts_with_yaml_object fields backed by a + # text/varchar column (see #edit_as_field_types below) and is handled by + # Dynamic::FieldEditAs::YamlObject, which validates the YAML but stores the + # original text (since the column is text, not json/jsonb). + TransformFieldTypes = %w[multi_editable_list multi_editable_choices col_type_json yaml_object].freeze # # Initialize with the object instance to be stored to, and the params from @@ -54,7 +58,14 @@ def translate_to_persistable # # Return a hash of field_options: : edit_as: field_type: as # { : } - # The result uses column type for those that don't have a field_type specified + # The result uses column type for those that don't have a field_type specified. + # Fields named `yaml_object_*` are resolved to the `yaml_object` field type, + # ONLY when the underlying column is text/varchar (the only supported backing + # types for yaml_object). JSON/JSONB columns continue to use their existing + # col_type_json/col_type_jsonb handling, and other incompatible column types + # (integer, boolean, etc.) fall through to their normal col_type_* behavior. + # Explicit yaml_object edit_as values are subject to the same column-type + # restriction. # @return [Hash] def edit_as_field_types fo = object_instance.option_type_config&.field_options || {} @@ -63,10 +74,42 @@ def edit_as_field_types field_list.to_h do |fn| [ fn.to_sym, - fo.dig(fn.to_sym, :edit_as, :field_type) || "col_type_#{cols[fn].type}" + field_type(fn, cols, fo) ] end end + + # + # The configured field type when supported by the backing column, otherwise + # the column-derived default. yaml_object fields may only target text/varchar + # columns regardless of whether their type is inferred from the field name or + # explicitly configured. + # @param [String] field_name + # @param [Hash] cols - object_instance.class.columns_hash + # @param [Hash] field_options + # @return [String] + def field_type(field_name, cols, field_options) + configured_type = field_options.dig(field_name.to_sym, :edit_as, :field_type) + return default_field_type(field_name, cols) unless configured_type + return configured_type unless configured_type.include?('yaml_object') + return configured_type if %i[text string].include?(cols[field_name].type) + + default_field_type(field_name, cols) + end + + # + # The default edit_as field type for a field, derived from its name and column type. + # @param [String] field_name + # @param [Hash] cols - object_instance.class.columns_hash + # @return [String] + def default_field_type(field_name, cols) + col_type = cols[field_name].type + if field_name.start_with?('yaml_object_') && %i[text string].include?(col_type) + 'yaml_object' + else + "col_type_#{col_type}" + end + end end end end diff --git a/app/models/dynamic/field_edit_as/yaml_object.rb b/app/models/dynamic/field_edit_as/yaml_object.rb new file mode 100644 index 0000000000..2efc3f644c --- /dev/null +++ b/app/models/dynamic/field_edit_as/yaml_object.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Dynamic + module FieldEditAs + # + # Handle validation of the YAML text edited by the user for `name_starts_with_yaml_object` + # fields (see views/common_templates/edit_fields/_name_starts_with_yaml_object.html.erb), + # for storage in a plain text/varchar database column. + # + # Unlike Dynamic::FieldEditAs::ColTypeJson (which parses the submitted YAML text into a + # Hash/Array for storage in a json/jsonb column), this class stores the YAML TEXT ITSELF, + # since the backing column here is text/varchar, not json/jsonb. Parsing here is used only + # to validate that the submitted text is well-formed YAML representing a Hash or Array, + # matching the same validation contract as ColTypeJson, before allowing it to be saved. + class YamlObject + # + # Get the persistable value for the provided saved_value. + # The incoming parameter is a YAML string submitted from the form's YAML code editor. + # It is validated by parsing it, but the ORIGINAL YAML TEXT is returned for storage, + # since the backing column is text/varchar (not json/jsonb). + # @param [String] saved_value - YAML text value from the param + # @return [String, nil] the original YAML text, once validated + def self.persistable_value(saved_value) + return unless saved_value.present? + + curr_val = YAML.safe_load(saved_value) + unless curr_val.is_a?(Hash) || curr_val.is_a?(Array) + raise FphsException, "yaml_object: cannot parse saved value: (#{saved_value.class.name}) #{saved_value}" + end + + saved_value + rescue Psych::Exception + raise FphsException, "yaml_object: cannot parse saved value: (#{saved_value.class.name}) #{saved_value}" + end + end + end +end diff --git a/app/views/common_templates/_common_template_result_fields.html.erb b/app/views/common_templates/_common_template_result_fields.html.erb index 0900ed6046..5b1e0ae651 100644 --- a/app/views/common_templates/_common_template_result_fields.html.erb +++ b/app/views/common_templates/_common_template_result_fields.html.erb @@ -208,6 +208,17 @@ {{else if (get result_data._general_selections key this_value 'name')}}
  • {{> field_label label_resource_name=(or name_with_option_type name) replace='^select_' }} {{#if (get result_data._general_selections key this_value 'name')}}{{get result_data._general_selections key this_value 'name'}}{{else}}{{pretty_string this_value return_string="true" capitalize=false}}{{/if}}
  • +{{! ####### Field name starts with yaml_object_ - the stored value is plain YAML text + (from a text/varchar column). Parse it with the yaml_parse Handlebars helper, then + delegate back to this same partial with field_type cleared, reusing the existing + 'typeof object' rendering below for the parsed Hash/Array (or falling through to + plain-text/null handling if parsing fails or the value is blank). + This must appear BEFORE all suffix-based handlers (notes, description, details, + etc.) so that e.g. yaml_object_notes is parsed as a YAML object rather than rendered + as raw text by a suffix handler. }} + {{else is field_type 'includes' '^yaml_object_'}} + {{> common_template_result_field this_value=(yaml_parse this_value) field_type=""}} + {{! ####### Field ends with ... }} {{else is field_type 'includes' "_notes$"}} {{> search_results_notes_block key=key value=this_value full_name_hyphenated=(hyphenate full_name) label=name template_config=template_config }} diff --git a/app/views/common_templates/edit_fields/_name_starts_with_yaml_object.html.erb b/app/views/common_templates/edit_fields/_name_starts_with_yaml_object.html.erb new file mode 100644 index 0000000000..a329abf6bb --- /dev/null +++ b/app/views/common_templates/edit_fields/_name_starts_with_yaml_object.html.erb @@ -0,0 +1,32 @@ +<% +# Edit a text/varchar database column's contents as YAML text. +# +# DISPLAY (this template): the current column value IS the YAML text (a String, since +# the backing column is text/varchar - NOT json/jsonb) and is shown directly in a YAML +# codemirror editor (data-code-editor-type: 'yaml'). No hash/array conversion is +# performed here, since the persisted value already IS the YAML text. +# +# SAVE: on submit, the edited YAML text arrives as a plain param string. +# Dynamic::FieldEditAs::Handler resolves fields named "yaml_object_*" that are backed +# by a text/varchar column (i.e. not json/jsonb) to the "yaml_object" edit_as +# field_type, and hands the raw YAML string to +# Dynamic::FieldEditAs::YamlObject.persistable_value, which validates that the text +# parses as a YAML Hash/Array (raising FphsException if not) and returns the ORIGINAL +# YAML TEXT to be persisted as-is into the text/varchar column. +# +# VIEW (search results): common_templates/_common_template_result_fields.html.erb +# parses this stored YAML text back into a Hash/Array (client-side, via the +# `yaml_parse` Handlebars helper) to render the same structured/collapsible object +# view used for actual json/jsonb object fields. + +gs = form_object_instance[field_name_sym.to_s] +options = field_options_for(form_object_instance, field_name_sym) + +html_options = { + data: { attr_name: field_name_sym, object_name: form_object_item_type_us, code_editor_type: 'yaml' }, + class: 'code-editor code-editor-yaml', + value: gs +}.merge(options) +%> +<%= edit_field_label(form, field_name_sym, labels, /^yaml_object_/) %> +<%= form.text_area field_name_sym, html_options %> diff --git a/spec/helpers/edit_fields/edit_form_field_helper_spec.rb b/spec/helpers/edit_fields/edit_form_field_helper_spec.rb index f900cefe98..a942817965 100644 --- a/spec/helpers/edit_fields/edit_form_field_helper_spec.rb +++ b/spec/helpers/edit_fields/edit_form_field_helper_spec.rb @@ -13,6 +13,9 @@ # - Regression test for bug where Rails 7.2 capture helper HTML-escaped plain String # return values from javascript_tag blocks, producing invalid