Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/assets/javascripts/app/_fpa_form_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions app/assets/javascripts/app/handlebars-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions app/assets/stylesheets/app/forms.css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
4 changes: 4 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
4 changes: 4 additions & 0 deletions app/helpers/edit_fields/edit_form_field_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
64 changes: 61 additions & 3 deletions app/models/dynamic/field_edit_as/handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,12 +37,16 @@ def initialize(object_instance, params)
# Translate all params in the @object_instance to a persistable value,
# based on the edit_as configuration
# Returns a hash of any params that have been updated, so they can be merged in
# Fields absent from the submitted params are skipped entirely (rather than being
# translated from a nil value), so a partial params submission cannot silently wipe
# out a previously persisted value for a field that simply wasn't included this time.
# @return [Hash]
def translate_to_persistable
res = {}
edit_as_field_types.each do |field_name, edit_as_field_type|
use = TransformFieldTypes.find { |f| edit_as_field_type.include? f }
next unless use
next unless params_key?(field_name)

value = params[field_name]
new_value = "dynamic/field_edit_as/#{use}".camelize.constantize.persistable_value(value)
Expand All @@ -51,10 +59,28 @@ def translate_to_persistable

private

#
# True if the submitted params include an entry for field_name, checked as both a
# Symbol and a String key, so absent fields can be skipped rather than translated
# from nil (which would otherwise overwrite/clear the persisted value). Works for
# ActionController::Parameters (already indifferent) and plain Hash params (e.g. specs).
# @param [Symbol] field_name
# @return [Boolean]
def params_key?(field_name)
params.key?(field_name) || params.key?(field_name.to_s)
end

#
# Return a hash of field_options: <field>: edit_as: field_type: <field type value> as
# { <field>: <field type value> }
# 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 || {}
Expand All @@ -63,10 +89,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
37 changes: 37 additions & 0 deletions app/models/dynamic/field_edit_as/yaml_object.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions app/views/common_templates/_common_template_result_fields.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@
{{else if (get result_data._general_selections key this_value 'name')}}
<li class="{{> field_result_class}} is--select-field from-general-selection" data-field-name="{{key}}" data-field-type="{{field_type}}" data-field-val="{{this_value}}">{{> field_label label_resource_name=(or name_with_option_type name) replace='^select_' }} <strong>{{#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}}</strong></li>

{{! ####### 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 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 %>
Loading