September 5, 2022; in Ruby, JavaScript

Creating a framework for Framework7 on Rails

Creating a framework for Framework7

Framework7 is a UI framework for creating web, mobile, and desktop applications with native look and feel. And it’s honestly pretty great. But there’s one problem: it makes extensive use of JavaScript, and I hate JavaScript.

The “Getting Started” page

A basic page of Framework7 looks like this:

<!DOCTYPE html>
<html>
  <head>
    <!-- Required meta tags-->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <!-- Color theme for statusbar (Android only) -->
    <meta name="theme-color" content="#2196f3">
    <!-- Your app title -->
    <title>My App</title>
    <!-- Path to Framework7 Library Bundle CSS -->
    <link rel="stylesheet" href="path/to/framework7-bundle.min.css">
    <!-- Path to your custom app styles-->
    <link rel="stylesheet" href="path/to/my-app.css">
  </head>
  <body>
    <!-- App root element -->
    <div id="app">

      <!-- Your main view, should have "view-main" class -->
      <div class="view view-main">
        <!-- Initial Page, "data-name" contains page name -->
        <div data-name="home" class="page">

          <!-- Top Navbar -->
          <div class="navbar">
            <div class="navbar-bg"></div>
            <div class="navbar-inner">
              <div class="title">Awesome App</div>
            </div>
          </div>

          <!-- Bottom Toolbar -->
          <div class="toolbar toolbar-bottom">
            <div class="toolbar-inner">
              <!-- Toolbar links -->
              <a href="#" class="link">Link 1</a>
              <a href="#" class="link">Link 2</a>
            </div>
          </div>

          <!-- Scrollable page content -->
          <div class="page-content">
            <p>Page content goes here</p>
            <!-- Link to another page -->
            <a href="/about/">About app</a>
          </div>
        </div>
      </div>
    </div>
    <!-- Path to Framework7 Library Bundle JS-->
    <script type="text/javascript" src="path/to/framework7-bundle.min.js"></script>
    <!-- Path to your app js-->
    <script type="text/javascript" src="path/to/my-app.js"></script>
  </body>
</html>

With some JavaScript:

var app = new Framework7({
  // App root element
  el: '#app',
  // App Name
  name: 'My App',
  // App id
  id: 'com.myapp.test',
  // Enable swipe panel
  panel: {
    swipe: true,
  },
  // Add default routes
  routes: [
    {
      path: '/about/',
      url: 'about.html',
    },
  ],
  // ... other parameters
});

Before I tell you why I hate this, let me redfine hate: hate, in this context, means “boilerplate I don’t want to have to write because it takes away from solving business problems.”

So, things I hate about this:

  • Wow, I have to define routes in JavaScript
  • Each page has to be wrapped in a template so it can have the attributes it expects for the router

Adding buttons

I wanted to add a link to the header that would show an Action Sheet on click. This was originally done with in-line:

<%= page do %>
  <%= render Staff::HeaderComponent.new(title: "Payment #{@payment.id}", back_link: "/staff/appointments/#{@appointment.id}") do %>
    <a class="ac1">Actions</a>
  <% end %>
  <div class="page-content">
    <div class="block-title">Payment details</div>
    <div class="list">
      <ul>
        <%= render Staff::ListItemComponent.new(title: "Gateway", text: @payment.gateway) %>
        <%= render Staff::ListItemComponent.new(title: "Amount", text: number_in_cents_to_currency(@payment.amount_in_cents)) %>
        <%= render Staff::ListItemComponent.new(title: "Date", text: @payment.created_at.strftime('%B %d, %Y at %H:%M')) %>
        <%= render Staff::ListItemComponent.new(title: "Deposit?", text: @payment.deposit?) %>
      </ul>
    </div>
  </div>
  <%= button_to "Destroy", staff_appointments_payment_path(@payment, appointment_id: @appointment.id), method: :delete, form: { id: :destroy_payment } %>

  <script>

      var ac1 = window.app7.actions.create({
          buttons: [
              {
                  text: 'Open in CRM',
                  onClick: function () {
                      window.open("/admin/booking/payments/<%= @payment.id %>");
                  }
              },
              {
                  text: 'Delete',
                  onClick: function () {
                      if(confirm("Are you sure?")) {
                          document.querySelector('form#destroy_payment').submit()

                      }
                  },
                  color: 'red'
              },
          ]
      })

      $('.ac1').on('click', () => {
          ac1.open();
      });
  </script>
<% end %>

But saner heads prevailed and I moved it to a Stimulus controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
    static values = { payment: Object }
    connect() {
       if(!window.app7) { return }
       
       const self = this;
        var ac1 = window.app7.actions.create({
            buttons: [
                {
                    text: 'Open in CRM',
                    onClick: function () {
                        window.open(`/admin/booking/payments/${self.paymentValue.id}`);
                    }
                },
                {
                    text: 'Delete',
                    onClick: function () {
                        if(confirm("Are you sure?")) {
                            document.querySelector('form#destroy_payment').submit()
                        }
                    },
                    color: 'red'
                },
            ]
        })
        $('.ac1').on('click', () => {
            ac1.open();
        });
    }
}

But I really hated this idea of writing JavaScript for a bunch of screens.

The framework for Framework7

I wanted to get as much Ruby as I could into the application because I’m good at Ruby and bad at everything else.

The setup

First, my layout:

<!DOCTYPE html>
<html lang="en">
<head>
  <%= csp_meta_tag %>
  <%= csrf_meta_tags %>
  <%= javascript_importmap_tags "staff" %>
  <link rel="stylesheet" href="https://unpkg.com/framework7@7.0.8/framework7-bundle.min.css" />
</head>
<body>

<div id="app" data-controller="staff--app" data-staff--app-routes-value="<%= staff_routes.to_json %>">
  <div class="view view-main view-init safe-areas" data-browser-history="true" data-browser-history-separator="" data-master-detail-breakpoint="768" data-url="<%= request.path %>">
    <%= yield %>
  </div>
</div>

</body>
</html>

With an accompanying Stimulus controller:

import { Controller } from "@hotwired/stimulus"
import Framework7 from "framework7/bundle"
import Dom7 from "dom7"

export default class extends Controller {
    static values = { routes: Array }

    connect() {
        window.$$ = Dom7;

        if(!window.app7) {
            window.$ = Dom7;
            window.app7 = new Framework7({
                id: 'io.framework7.testapp',
                el: '#app',
                theme: 'ios',
                routes: this.routesValue,
                calendar: {
                    dateFormat: 'yyyy-mm-dd',
                },
                view: { animate: false, componentCache: false }
            });
        }
    }
}

(if it’s not clear by now, I have no idea what I’m doing on the front-end)

Something interesting here is #staff_routes helper. This could probably be optimized, but this is a back-end application for a side project that has some real-world use every day so it doesn’t need to be perfect:

def staff_routes
  Rails.application.routes.routes.filter_map do |r|
    if r.defaults[:controller].to_s.start_with?("staff/")
      if r.verb == "GET"
        path = r.path.spec.to_s.delete_suffix("(.:format)")
        component = path.gsub(%r{\:([0-9a-z_-]*)?}, '{{\1}}')
        thing = {
          name: r.name,
          path: path,
          url: component,
          componentUrl: component
        }
        thing
      end
    end
  end
end

So that I don’t need to add all the routes myself.

Defining a page

First, a helper to… help:

def page(name = nil, options = {})
  content_tag(:div, class: "page", data: options.merge({ url: request.path, name: name, controller_name: controller_name, action_name: action_name})) do
    yield
  end
end

Then the content of the page:

<%= page :appointments do %>
  <%= render Staff::HeaderComponent.new(title: "Appointments", back_link: "/staff") do %>
    <a href="#" id="actions">Actions</a>
  <% end %>
  <div class="page-content">
    <div class="block-title"><%= time %></div>
    <div class="list components-list">
      <ul>
        <% @appointments.each do |appt| %>
          <%= render Staff::Appointments::ListItemComponent.new(appt) %>
        <% end %>
      </ul>
    </div>
  </div>
<% end %>

I use ViewComponent for some light, re-usable thingies. It’s nice and fits the workflow here very well.

Post-JavaScript clarity

Now, I want my buttons. And I want them in Ruby as much as possible. I want this:

f7.buttons("Actions", "appointment-actions") do |buttons|
  buttons.add("Record payment", new_staff_appointments_payment_path(@appointment))
  buttons.add("Delete", "#", form: "form#destroy_appointment", color: :red, confirm: "Are you sure?")
end

And I want some generic JavaScript adapter to do all that work for me. Because I don’t want to do it.

First, I’ll start by dropping a helper into the base controller of my “Staff” controller:

helper_method :f7_page
def f7_page
  @f7_page ||= Framework7::Page.new
end

And then an accompanying helper:

def framework7_page(name = nil, options = {})
  content_tag(:div, class: "page", data: options.merge({ url: request.path, name: name, controller_name: controller_name, action_name: action_name})) do
    yield f7_page
    concat tag(:div, class: 'd-none', data: { controller: 'framework7', **f7_page.to_json }).html_safe
  end  
end

I’ll access this in my actions like so:

def show
  @appointment = ::Booking::Appointment.find_by!(uuid: params[:uuid])
  f7_page.buttons("Actions", "appointment-actions") do |buttons|
    buttons.add("Record payment", new_staff_appointments_payment_path(@appointment))
    buttons.add("Delete", "#", form: "form#destroy_appointment", color: :red, confirm: "Are you sure?")
  end
end

How nice!

And then I’ll adjust my view to use framework7_page

<%= framework7_page :appointment do %>
  <%= render Staff::HeaderComponent.new(title: "Appointment #{@appointment.uuid}", back_link: staff_appointments_path) do %>
    <a id="payment-actions">Actions</a>
  <% end %>
  <div class="page-content">
    <div class="block-title">Appointment details</div>
    <div class="list">
      <ul>
        <%= render Agents::ListItemComponent.new(title: "Total", text: number_in_cents_to_currency(@appointment.total_in_cents) %>
      </ul>
    </div>
  </div>
<% end %>

Almost there.

As you may have noticed, in my framework7_page helper:

concat tag(:div, class: 'd-none', data: { controller: 'framework7', **f7_page.to_json }).html_safe

We do have some javascript. But getting there, we wrote a ton of Ruby.

Framework7 on Rails

While not complete, a quick 30-minute abstraction got me the results I wanted in a DSL that was easy to pickup:

First, I created lib/framework7.rb:

module Framework7; end 

Then, a lib/framework7/page.rb:

module Framework7
  class Page
    include Components::Buttons
    
    def initialize
      @json = {}
    end

    def to_json
      build

      @json.transform_keys! { |key| "framework7-#{key}-value" }
    end
  end
end

Note: build will be defined on each of the components and we’ll super through each thanks to Module#prepend.

Eventually, I’ll want something that looks like this:

{
  "buttons": [
    {
      "title": "Actions",
      "id": "payment-actions",
      "items": [
        {
          "text": "Record payment",
          "url": "/staff/appointments/abcd1234/payments/new"
        },
        {
          "text": "Delete",
          "form": "form#destroy_appointment",
          "color": "red",
          "confirm": "Are you sure?"
        }
      ]
    }
  ]
}

Buttons

We’ll need some hooks that will drop into Page so we don’t end up with a disasterous God object:

module Framework7
  module Components
    module Buttons
      def self.included(klass)
        klass.include InstanceMethods
        klass.prepend PrependMethods
      end

      module InstanceMethods
        def buttons(name, id)
          @buttons_proxy[id] ||= Components::Buttons::Collection.new(name, id)
          yield @buttons_proxy[id]
        end
      end

      module PrependMethods
        def initialize
          super
          @buttons_proxy = Components::Buttons::Proxy.new
        end

        def build
          @json[:buttons] = @buttons_proxy.as_json
        end
      end
    end
  end
end

And then some quick POROs:

A proxy pattern to allow us to add buttons to an existing collection without having to look them up. This is the public interface for the page:

module Framework7
  module Components
    module Buttons
      class Proxy
        delegate_missing_to :@map
        def initialize
          @map = {}
        end

        def as_json
          @map.values.map(&:as_json)
        end
      end
    end
  end
end

A collection object which acts like an array:

module Framework7
  module Components
    module Buttons
      class Collection
        attr_reader :id, :name
        def initialize(name, id)
          @name = name
          @id = id
          @collection = []
        end

        def add(label, url, options = {})
          @collection << Button.new.tap { |button| button.name = label; button.url = url; button.options = options; button }
        end

        def to_json
          as_json.to_json
        end

        def as_json
          {
            name: @name,
            id: @id,
            items: @collection
          }
        end
      end
    end
  end
end

And then finally the button:

module Framework7
  module Components
    module Buttons
      class Button
        attr_accessor :name, :url, :options
      end
    end
  end
end

Cool, this will output what we need it to.

Framework7 Stimulus controller

This is where my cluelessness on the front-end shines full-circle.

import { Controller } from "@hotwired/stimulus"
import Buttons from "lib/framework7/buttons";

export default class extends Controller {
    static values = { buttons: Array }
    connect() {
        if(!window.app7) { return }

        Buttons.build(this)
    }
}

And then buttons.js:

export default class Buttons {
    static build(controller) {
        const el = controller.element;
        const buttonsJSON = JSON.parse(buttons.dataset.framework7ButtonsValue);

        buttonsJSON.forEach(function(buttonCollection) {
            const buttons = [];

            buttonCollection.items.forEach(function(item) {
                buttons.push({
                    text: item.name,
                    onClick: function () {
                        if(item.options.form) {
                            let confirmed = true;
                            if(item.options.confirm) {
                                confirmed = confirm(item.options.confirm)
                            }
                            if(confirmed) {
                                document.querySelector(`${item.options.form}`).submit()
                            }
                        } else {
                            if(item.url.includes("/staff/")) {
                                window.app7.views.main.router.navigate(item.url);
                            } else {
                                window.location.href = item.url;
                            }
                        }
                    },
                    color: item.options.color || 'blue'
                })
            })

            var target = window.app7.actions.create({buttons: buttons})

            $(`#${buttonCollection.id}`).on('click', () => {
                target.open();
            });
        })
    }
}

Don’t forget to adjust your importmap:

pin_all_from "app/javascript/lib", under: "lib"

Yay, it works!

There are lots of ways to improve upon this on the front-end, but it works and it’s good enough for me — I’m just happy to not have to write a bunch of boilerplate on the front-end. I can extend my Ruby code easily to also fit other types of JavaScript-based Framework7-components, too.