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

162 views Asked by At

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.

1

There are 1 answers

4
Alex On BEST ANSWER
# app/models/page.rb
class Page < ApplicationRecord
  has_many :page_sections
  accepts_nested_attributes_for :page_sections
end

# app/models/page_section.rb
class PageSection < ApplicationRecord
  belongs_to :page
end
# app/helpers/application_helper.rb

module ApplicationHelper
  def link_to_add_fields(name, f, association)
    association_class = f.object.class.reflect_on_association(association).klass

    template = f.fields_for association, association_class.new, child_index: "__CHILD_INDEX__" do |ff|
      # UPDATE: you're right, fields partial should be based on
      #         the association name, not the class name.
      # render "#{association_class.model_name.singular}_fields", f: ff
      render "#{association.to_s.singularize}_fields", f: ff
    end

    link_to name, "#", class: "add_fields", data: {template:}
  end
end
# _form.html.erb

<%= tinymce_assets %> # this gem isn't really required 

<%= form_with model: Page.new do |f| %>
  <%= f.fields_for :page_sections do |ff| %>
    <%= render "page_section_fields", f: ff %>
  <% end %>
  <%= link_to_add_fields "Add Section", f, :page_sections %>

  <%= f.submit %>
<% end %>
# _page_section_fields.html.erb

<%= f.text_area :content, class: "tinymce" %>

You're not really delegating, you're initializing event listeners on elements so that your clicks would add new fields. When delegating, you set up one event listener and then figure out if you need to do something when event is dispatched.

// app/javascript/application.js

document.addEventListener("click", function(event) {
  // this is delegation
  if (event.target.matches(".add_fields")) {
    addFields(event);
  }
});

// forget yaml config, just do it here
const tinyConfig = {
  event_root: null,
  selector: ".tinymce",
  menubar: "file edit view insert format tools table help",
  toolbar: ["undo redo | blocks fontfamily fontsize | bold italic underline strikethrough | align numlist bullist","link image | accordion accordionremove | 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 codesample,table charmap pagebreak nonbreaking insertdatetime advlist lists,wordcount help charmap quickbars emoticons accordion",
  promotion: false,
  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"}]
}

function addFields(event) {
  event.preventDefault();
  const { target } = event;
  const template = target.dataset.template.replace(/__CHILD_INDEX__/g, new Date().getTime().toString())
  target.insertAdjacentHTML("beforebegin", template)

  // initialize tinymce
  tinymce.init(tinyConfig);
}

Another way is to use an observer, when things get complicated there are only so many places you can stick your various init functions to keep things "alive". It's probably an overkill for this situation:

// app/javascript/application.js

const observer = new MutationObserver((mutationList) => {
  mutationList.forEach((mutation) => {
    if (mutation.type == "childList") {
      mutation.addedNodes.forEach((node) => {
        if (node instanceof Element) {

          if (node.matches(".tinymce")) {
            tinymce.init(tinyConfig);
          }

        }
      });
    }
  });
});

observer.observe(document.body, { childList: true, subtree: true });

https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver


Update

To initialize the editor on page load you have to do it separately:

// app/javascript/application.js

document.addEventListener("DOMContentLoaded", function() {
  tinymce.init(tinyConfig);
});

You could make the configuration global if you're using importmaps:

// app/javascript/application.js

window.tinyConfig = {
  event_root: null,
  selector: ".tinymce",
  // ...
}

and then use it from the inline script:

<script type="module">
  tinymce.init(tinyConfig);
</script>