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.
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.
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:
https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
Update
To initialize the editor on page load you have to do it separately:
You could make the configuration global if you're using importmaps:
and then use it from the inline script: