Friday, 3 November 2023

Rails javascript delegation for tinymce-rails in a nested fields partial

Using the tinymce-rails gem in a Rails 7.1 engine, I have a form partial and a dynamically added nested fields form _page_section_fields partial, the nested fields form has a text area that makes use of the tinymce WYSIWYG editor, this works for existing fields for page sections but is not available for dynamically added partials. The form has an add fields link that dynamically adds a new nested fields form and it is this functionality that fails to display the editor. I need to find a way to delegate the <%=tinymce :try%> erb javascript to the nested-fields div in the container form somehow. I could perhaps replace the tinymce erb tag and the corresponding yml config with a script tag e.g.

<script type="text/javascript">

  tinymce.init({
    selector: 'tinymce' //etc...
  });
</script>

But still don't know how this could be beneficial.

The essential parts of the _form.html.erb form are

    <%= content_for :admin_head do %>
      <%= tinymce_assets %>
      <%= javascript_import_module_tag 'ccs_cms/custom_page/nested_fields/addFields' %>
      <%= javascript_import_module_tag 'ccs_cms/custom_page/nested_fields/removeFields' %>
    <% end %>

<fieldset>
  <legend>Page sections:</legend>
  <div id="nested-fields"> // I need to delegate tinymce to this div somehow
    <%=form.fields_for :page_sections do |page_section_form|%>
      <%= render 'page_section_fields', form: page_section_form %>
    <%end%>
    <%= link_to_add_fields "Add Section", form, :page_sections %>
  </div>
</fieldset>
  

The fields for partial named _page_section_fields.html.erb

<div id="nested-fields">
  <p></p>
  <section>
    <fieldset>
      <legend> Page Section </legend>
      <%= form.hidden_field :_destroy %>

      <div class="cms-admin-field">
        <%= form.label :content %>:
        <%= form.text_area :content, class: "tinymce" %>
      </div>

      <%=tinymce :try%> //How do I delegate this to nested-fields div?

      <div class="cms-admin-field">
        <%= form.label :collapsed_header_text %>:
        <%= form.text_field :collapsed_header_text, editor: { template: :classic, type: :classic } %>
      </div>
      <div class="cms-admin-field">
        <%= form.label :include_contact_form %>:
        <%= form.check_box :include_contact_form %>
      </div>
      <div class="cms-admin-field">
        <%= form.label :collapsible %>:
        <%= form.check_box :collapsible %>
      </div>
      <div class="cms-admin-field">
        <%= form.label :has_borders %>:
        <%= form.check_box :has_borders %>
      </div>
      <div class="cms-admin-field">
        <%= form.label :full_width %>:
        <%= form.check_box :full_width %>
      </div>
      <div>
        <%= link_to "Remove", '#', class: "remove_fields" %>
      </div>
    </fieldset>
  </section>
</div>

The link_to_add_fields rails helper

  def link_to_add_fields(name, f, association)
    new_object = f.object.send(association).klass.new

    # Saves the unique ID of the object into a variable.
    # This is needed to ensure the key of the associated array is unique. This is makes parsing the content in the `data-fields` attribute easier through Javascript.
    # We could use another method to achive this.
    id = new_object.object_id

    # https://api.rubyonrails.org/ fields_for(record_name, record_object = nil, fields_options = {}, &block)
    # record_name = :page_sections
    # record_object = new_object
    # fields_options = { child_index: id }
    # child_index` is used to ensure the key of the associated array is unique, and that it matched the value in the `data-id` attribute.
    # `page[page_sections_attributes][child_index_value][_destroy]`
    fields =
      f.fields_for(association, new_object, child_index: id) do |builder|
        # `association.to_s.singularize + "_fields"` ends up evaluating to `page_sections_fields`
        # The render function will then look for `views/pages/_page_sections_fields.html.erb`
        # The render function also needs to be passed the value of 'builder', because `views/pages/_page_sections_fields.html.erb` needs this to render the form tags.
        render(association.to_s.singularize + "_fields", form: builder)
      end

    # This renders a simple link, but passes information into `data` attributes.
    # This info can be named anything we want, but in this case we chose `data-id:` and `data-fields:`.
    # The `id:` is from `new_object.object_id`.
    # The `fields:` are rendered from the `fields` blocks.
    # We use `gsub("\n", "")` to remove anywhite space from the rendered partial.
    # The `id:` value needs to match the value used in `child_index: id`.
    link_to(
      name,
      "#",
      class: "add_fields",
      data: {
        id: id,
        fields: fields.gsub("\n", ""),
      },
    )
  end

The javascript that adds the partial, perhaps this is where the delegation belongs, or maybe not!

class addFields {
  // This executes when the function is instantiated.
  constructor() {
    this.links = document.querySelectorAll(".add_fields");
    this.iterateLinks();
  }

  iterateLinks() {
    // If there are no links on the page, stop the function from executing.
    if (this.links.length === 0) return;
    // Loop over each link on the page. A page could have multiple nested forms.
    this.links.forEach((link) => {
      link.addEventListener("click", (e) => {
        this.handleClick(link, e);
      });
    });
  }

  handleClick(link, e) {
    // Stop the function from executing if a link or event were not passed into the function.
    if (!link || !e) return;
    // Prevent the browser from following the URL.
    e.preventDefault();
    // Save a unique timestamp to ensure the key of the associated array is unique.
    let time = new Date().getTime();
    // Save the data id attribute into a variable. This corresponds to `new_object.object_id`.
    let linkId = link.dataset.id;
    // Create a new regular expression needed to find any instance of the `new_object.object_id` used in the fields data attribute if there's a value in `linkId`.
    let regexp = linkId ? new RegExp(linkId, "g") : null;
    // Replace all instances of the `new_object.object_id` with `time`, and save markup into a variable if there's a value in `regexp`.
    let newFields = regexp ? link.dataset.fields.replace(regexp, time) : null;
    // Add the new markup to the form if there are fields to add.
    newFields ? link.insertAdjacentHTML("beforebegin", newFields) : null;

  }
}

document.addEventListener('DOMContentLoaded', function() {
  new addFields();
});

tinymce has an event_root option which should do what I need but it is only available for in line editing mode that I am not using

The tinymce,yml config looks like this

try:
  event_root: '#nested-fields'
  menubar: file edit view insert format tools table help

  toolbar:
    - undo redo | accordion accordionremove | blocks fontfamily fontsize | bold italic underline strikethrough | align numlist bullist
    - link image | table media | lineheight outdent indent| forecolor backcolor removeformat charmap emoticons code fullscreen preview save print | pagebreak codesample | ltr rtl
  toolbar_mode: sliding

  contextmenu: link image table
  quickbars_selection_toolbar: bold italic | quicklink h2 h3 blockquote quickimage quicktable

  plugins:
    - preview importcss searchreplace autolink autosave save directionality code
    - visualblocks visualchars fullscreen image link media template codesample
    - table charmap pagebreak nonbreaking insertdatetime advlist lists
    - wordcount help charmap quickbars emoticons accordion

  promotion: false

#  useDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
#  isSmallScreen = window.matchMedia('(max-width: 1023.5px)').matches
#  skin: useDarkMode ? 'oxide-dark' : 'oxide'
#  content_css: useDarkMode ? 'dark' : 'default'


  autosave_ask_before_unload: true
  autosave_interval: 30s
  autosave_prefix: 'tinymce-autosave-{path}{query}-{id}-'
  autosave_restore_when_empty: true
  autosave_retention: 30m

  image_caption: true
  image_advtab: true
  image_class_list: [
    { title: 'None', value: '' },
    { title: 'Drop shadow', value: 'shadow' }
  ]

If restructuring any of this makes the solution simpler to implement then that's OK. I'm always open to learning better ways of doing things.

I should add that this functionality is inside an engine but this should not effect the problem or solution.



from Rails javascript delegation for tinymce-rails in a nested fields partial

No comments:

Post a Comment