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.