Lesson 2: Averting Monkey Patches with a Custom CMS.

Post 2 in the 6 Things We Learned Revamping Ruby Central's Conference Websites series

This is the second post in a series on things we explored while “Revamping the Ruby Central Conference Websites”.

In the introduction to this series we explored the reasons why we chose to include a new website generator within the existing Rails CFP app which manages Call For Proposals for conference events. Essentially we decided it would be more efficient to both build and maintain this new feature inside the existing related repository.

On the other hand, when deciding how to support managing the static content for conference website pages we chose a different approach. While there are many excellent CMS plugins and services out there to choose from, we decided to build something from scratch. Mostly this was an intuitive decision sensing that we were going to need something that would not fit easily into the boxes provided by a typical CMS.

Often one gets a big boost in development by using a third party package in an application since a focused team will have already tackled the hard problems and will have tested their solutions. However, once the initial burst in speed has worn off, you can easily find yourself spending more time wrestling with the interface to software written by others writing monkey patches and other unpleasant hacks. Since we were already piggy backing our new website feature into the existing codebase and its admin interface, we wanted to keep things as light, nimble and unobtrusive as possible. Moreover, there can be no substitute to learning new things the hard way when building from scratch.

Of course, you are rarely really writing things truly from scratch in a modern web application. Starting out we recognized that we would need some sort of full text editor to help users compose the html for the static pages. The natural first choice was Trix since it is built right into Rails.

However, we also realized that we wanted to give conference organizers back some of the creative freedom to brand and design each website. It would be great for the primary content editor to be a syntax highlighted code editor for the HTML so that a designer could still position and style the body content for each static page.

The solution we went with was to use CodeMirror and initialize a text area for the body content using a Stimulus controller. We also wanted to have a wysiwyg editor as a backup in case someone without familiarity with html needed to adjust some page content. Unfortunately, Trix is opinionated about how it marks up the HTML by adding and removing css classes, so we settled with using TinyMCE. It is much more cumbersome to configure and still messes some with the html but at least it did not change the styles that were added using the CodeMirror editor.

The haml view for the page form ended up looking like this:

= simple_form_for([current_event, :staff, page],
  { data: { controller: :editor,
  action: "beforeunload@window->editor#leavingPage",
  'editor-changed-value' => "false" } }) do |f|
  .preview-flex
    .resize
      .inner
        # other input fields...
        %div{ data: { "editor-target": :wysiwyg }, class: 'hidden', disabled: true }
          = f.input :unpublished_body,
            as: :text,
            input_html: { data: { "editor-target": :wysiwygContent } }
          = link_to("Edit HTML", '#', { data: { action: "click->editor#editHtml" } })
        %div{ data: { "editor-target": :html } }
          = f.input :unpublished_body,
            as: :text,
            label: "Unpublished Body #{link_to_docs("codemirror")}".html_safe,
            input_html: { data: { "editor-target": :htmlContent } }
          = link_to("WYSIWYG", '#', { data: { action: "click->editor#wysiwyg" } })
    .resize
      #page-preview-wrapper
        %h4 Preview Page
        %iframe{ src: "#{event_staff_page_path(current_event, @page, params.to_unsafe_hash)}",
                 id: "page-preview", name: "page-preview" }
  .row
    .col-sm-12
      = submit_tag("Save",
        class: "btn btn-success",
        type: "submit",
        data: { action: "editor#allowFormSubmission" })
      = link_to "Cancel", event_staff_pages_path(current_event), {:class=>"cancel-form btn btn-danger"}
= form_with url: event_staff_page_path(current_event, @page),
  html: { target: "page-preview", id: "preview-form" } do |f|
  = f.hidden_field :preview, id: "hidden-preview"

While the stimulus controller was as follows:

import { Controller } from 'stimulus'
import CodeMirror from 'codemirror/lib/codemirror.js'
import 'codemirror/mode/htmlmixed/htmlmixed.js'

export default class extends Controller {
  static targets = ['htmlContent', 'wysiwygContent', 'wysiwyg', 'html']
  static values = { changed: { type: Boolean, default: false } }

  initialize () {
    this.tinyMCEDefaults = {
      height: 500,
      // other defaults...
      init_instance_callback: (editor) => {
        editor.on('input', (e) => {
          this.preview(e.target.innerHTML);
          this.changedValue = true;
        });
        editor.on('change', (e) => {
          this.preview(e.target.getContent());
          this.changedValue = true;
        });
      }
    }
  }

  editHtml(e) {
    e.preventDefault();
    this.wysiwygTarget.classList.add("hidden");
    this.htmlTarget.classList.remove("hidden");
    this.htmlContentTarget.disabled = false;
    this.initializeCodeMirror().setValue(this.wysiwygEditor.getContent());
  }

  initializeCodeMirror() {
    var editor = CodeMirror.fromTextArea(this.htmlContentTarget, {
      mode: "htmlmixed",
      lineWrapping: true,
    });
    for (var i=0;i<editor.lineCount();i++) { editor.indentLine(i); }
    editor.on('change', (e) => {
      this.changedValue = true;
      this.preview(e.getValue());
    })
    editor.on('drop', (e, event) => {
      event.preventDefault();
      this.uploadFile(event.dataTransfer.files[0], event, e)
    })
    return editor;
  }

  preview(content) {
    this.debounce(function() {
      document.getElementById('hidden-preview').value = content;
      document.getElementById('preview-form').submit();
    }, 1000)
  }

  debounce(func, delay) {
    if(this.timeout) { clearTimeout(this.timeout) }
    this.timeout = setTimeout(func, delay);
  }

  wysiwyg(e) {
    e.preventDefault();
    this.wysiwygTarget.classList.remove("hidden");
    this.htmlTarget.classList.add("hidden");
    this.htmlContentTarget.disabled = true;
    this.wysiwygEditor.setContent(this.htmlEditor.getValue());
    this.htmlEditor.toTextArea();
  }

  uploadFile(file, event, editor) {
    let url = '/image_uploads'
    let formData = new FormData()

    formData.append('file', file)

    fetch(url, {
      method: 'POST',
      body: formData
    }).then(response => response.json())
      .then(data => {
        let newline = `<img src="${data.location}"/>`
        let doc= editor.getDoc()
        editor.focus()
        let x = event.pageX
        let y = event.pageY
        editor.setCursor(editor.coordsChar({left:x,top:y}))
        let newpos = editor.getCursor()
        doc.replaceRange(newline, newpos)
      })
  }

  get wysiwygEditor() {
    return tinyMCE.activeEditor;
  }

  get htmlEditor() {
    return document.querySelector('.CodeMirror').CodeMirror;
  }

  leavingPage(event) {
    if (this.changedValue) {
      event.returnValue = "Are you sure you want to leave with unsaved changes?";
      return event.returnValue;
    }
  }

  allowFormSubmission(event) {
    this.changedValue = false;
  }

  connect () {
    let config = Object.assign({ target: this.wysiwygContentTarget }, this.tinyMCEDefaults)
    tinyMCE.init(config)
    this.initializeCodeMirror();
  }

  disconnect () {
    tinyMCE.remove()
  }
}

There are links to toggle between CodeMirror and TinyMCE and a certain amount of extra fuss to transfer and format the html when switching back and forth. CodeMirror makes for a more familiar code editing interface and even has some handy commands which can be customized further. You will also note that we are able to add the ability to drag and drop a file for uploading images by making an Ajax request to store the image in s3 using ActiveStorage.

Another cool requested feature that we were able to support was the ability to preview the page in realtime while editing (note the page-preview iframe). At first we were simply copying the html over to the content area of a rendered static page in the iframe but that stopped working once we added the ability to inject some custom tags into the html to populate the page with some dynamic content.

For example, many pages need to display realtime conference sponsor information in a banner. When rendering the page we pass the content through an embed helper method to help support this requirement:

module PageHelper
  TAGS = {
    "<sponsors-banner-ads></sponsors-banner-ads>" => "sponsors/banner_ads",
    "<sponsors-footer></sponsors-footer>" => "sponsors/sponsors_footer",
    /(<logo-image.*><\/logo-image>)/ => :logo_image,
    "background-image-style-url" => :background_style,
  }

  def embed(body)
    body.tap do |body|
      TAGS.each do |tag, template|
        body.gsub!(tag) do
          args = tag.is_a?(Regexp) ? extract($1) : {}
          case template
          when String
            render(**args.merge(template: template, layout: false))
          when Symbol
            send(template, **args)
          end
        end
      end
    end.html_safe
  end

  def extract(tag)
    fragment = Nokogiri::HTML.fragment(tag)
    tag_name = fragment.children.first.name
    fragment.at(tag_name).to_h.symbolize_keys
  end

  def background_style
    current_website.background_style_html
  end

  def logo_image(args)
    resize_image_tag(current_website.logo, **args)
  end
end

Copying the html of <sponsors-banner-ads></sponsors-banner-ads> into an iframe of course would not be recognized by any old browser so instead we switched to submitting a hidden form (see the preview-form) using the page-preview iframe as the target. The backend would now fully render the page content embedding any of the custom tags. Because of the extra delay in rendering we added a debounce so that the update would not happen on every keystroke.

All together it ends up looking something like this:

Page Preview

Wait, what! Was that tailwind being used? Sure thing. But that will make for a good separate blog post and maybe we can talk about that template trick as well. Until next time…

If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.

Published on November 15, 2022