PropsTemplate
PropsTemplate is a direct-to-Oj, JBuilder-like DSL for building JSON. It has support for Russian-Doll caching, layouts, and of course, its most unique feature: your templates are queryable.
PropsTemplate is fast!
Most libraries would build a hash before feeding it to your serializer of choice, typically Oj. PropsTemplate writes directly to Oj using Oj::StringWriter
as its rendering your template and skips the need for an intermediate data structure.
PropsTemplate also improves caching. While other libraries spend time unmarshaling, merging, and then serializing to JSON; PropsTemplate simply takes the cached string and push_json.
Example:
json.flash flash.to_h
json. do
# all keys will be formatted as camelCase
json.current_user do
json.email current_user.email
json.avatar current_user.avatar
json.inbox current_user..count
end
end
json.dashboard(defer: :auto) do
sleep 5
json.complex_post_metric 500
end
json.posts do
page_num = params[:page_num]
paged_posts = @posts.page(page_num).per(20)
json.list do
json.array! paged_posts, key: :id do |post|
json.id post.id
json.description post.description
json.comments_count post.comments.count
json.edit_path edit_post_path(post)
end
end
json.pagination_path posts_path
json.current paged_posts.current_page
json.total @posts.count
end
json. partial: 'shared/footer' do
end
Installation
If you plan to use PropsTemplate alone just add it to your Gemfile.
gem 'props_template'
and run bundle
API
json.set! or json.
Defines the attribute or stucture. All keys are automatically camelized lower.
json.set! :author_details, {..options...} do
json.set! :first_name, 'David'
end
or
json.author_details, {..options...} do
json.first_name, 'David'
end
# => {"authorDetails": { "firstName": "David" }}
The inline form defines key and value
Parameter | Notes |
---|---|
key | A json object key |
value | A value |
json.set! :first_name, 'David'
or
json.first_name 'David'
# => { "firstName": "David" }
The block form defines key and structure
Parameter | Notes |
---|---|
key | A json object key |
options | Additional options |
block | Additional json.set! s or json.array! s |
json.set! :details do
...
end
or
json.details do
...
end
The difference between the block form and inline form is
- The block form is an internal node. Partials, Deferement and other options are only available on the block form.
- The inline form is considered a leaf node, and you can only search for internal nodes.
json.array!
Generates an array of json objects.
collection = [
{name: 'john'},
{name: 'jim'}
]
json.details do
json.array! collection, {....options...} do |person|
json.first_name person[:name]
end
end
# => {"details": [
{"firstName": 'john'},
{"firstName": 'jim'}
]}
Parameter | Notes |
---|---|
collection | A collection that responds to member_at and member_by |
options | Additional options |
To support traversing nodes, any list passed to array!
MUST implement member_at(index)
and member_by(attr, value)
.
For example, if you were using a delegate:
class ObjectCollection < SimpleDelegator
def member_at(index)
at(index)
end
def member_by(attr, val)
find do |ele|
ele[attr] == val
end
end
end
Then in your template:
data = ObjectCollection.new([{id: 1, name: 'foo'}, {id: 2, name: 'bar'}])
json.array! data do
...
end
Similarly for ActiveRecord:
class ApplicationRecord < ActiveRecord::Base
def self.member_at(index)
offset(index).limit(1).first
end
def self.member_by(attr, value)
find_by(Hash[attr, val])
end
end
Then in your template:
json.array! Post.all do
...
end
Array core extension
For convenience, PropsTemplate includes a core_ext that adds these methods to Array
. For example:
require 'props_template/core_ext'
data = [{id: 1, name: 'foo'}, {id: 2, name: 'bar'}]
json.posts
json.array! data do
...
end
end
PropsTemplate does not know what the elements are in your collection. The example above will be fine for traversing by index \posts?bzq=posts.0
, but will raise a NotImplementedError
if you traverse by attribute /posts?bzq=posts.id=1
. You may still need a delegate that implements member_by
.
json.deferred!
Returns all deferred nodes used by the #deferment option.
json.deferred json.deferred!
This method is normally used in application.json.props
when first generated by rails breezy:install:web
json.fragments!
Returns all fragment nodes used by the partial fragments option.
json.fragments json.fragments!
This method is normally used in application.json.props
when first generated by rails breezy:install:web
json.fragment_digest!
Returns the digest of the current partial name and the locals passed. Useful for optimistic updates.
# _some_partial.json.props
json.digest json.fragment_digest!
Options
Functionality such as Partials, Deferements, and Caching can only be set on a block. It is normal to see empty blocks.
json.post(partial: 'blog_post') do
end
Partials
Partials are supported. The following will render the file views/posts/_blog_posts.json.props
, and set a local variable foo
assigned with @post, which you can use inside the partial.
json.one_post partial: ["posts/blog_post", locals: {post: @post}] do
end
Usage with arrays:
# as an option on an array. The `as:` option is supported when using `array!`
json.posts do
json.array! @posts, partial: ["posts/blog_post", locals: {foo: 'bar'}, as: 'post'] do
end
end
Partial Fragments
A fragment uses a digest to identify a rendered partial across your page state in Redux. When BreezyJS recieves a payload with a fragment, it will update every fragment with the same digest in your Redux store.
You would need use partials and add the option fragment: true
.
# index.json.props
json.header partial: ["profile", fragment: true] do
end
# _profile.json.props
json.profile do
json.address do
json.state "New York City"
end
end
When using fragments with Arrays, the argument MUST be a lamda:
require 'props_template/core_ext' #See (lists)[#Lists]
json.array! ['foo', 'bar'], partial: ["footer", fragment: ->(x){ x == 'foo'}]
PropsTemplate creates a name for the partial using a digest of your locals, partial name, and globalId (to_json as fallback if there is no globalId) on objects that you pass. You may override this behavior and use a custom identifier:
# index.js.breezy
json.header partial: ["profile", fragment: 'me_header'] do
end
Optimisitc Updates
Breezy uses the digest generated by fragment: true
to uniquely identify a partial across the redux state. If you need to optimistically update a fragment, use json.fragment_digest!
to obtain the identifier in your partial, and updateFragments
from BreezyJS.
For example:
# _header.js.props
json.fragment_digest json.fragment_digest!
And in your reducer
import {updateFragments} from 'jho406/Breezy';
switch(action.type) {
case SOME_ACTION: {
const {
fragmentDigest,
prevNode // <- the content of the _header.js.props
} = action.payload
const nextNode = {
...prevNode,
foo: 'bar'
}
return updateFragments(state, {
[fragmentDigest]: nextNode
})
}
default:
return state
}
Caching
Caching is supported on any node.
Usage:
json.(cache: "some_cache_key") do
json.first_name "tommy"
end
#or
json.profile(cache: "cachekey", partial: ["profile", locals: {foo: 1}]) do
end
#or nest it
json.(cache: "some_cache_key") do
json.address(cache: "some_other_cache_key") do
json.zip 11214
end
end
When used with arrays, PropsTemplate will use Rails.cache.read_multi
.
require 'props_template/core_ext' #See (lists)[#Lists]
opts = {
cache: ->(i){ ['a', i] }
}
json.array! [4,5], opts do |x|
json.top "hello" + x.to_s
end
#or on arrays with partials
opts = {
cache: (->(d){ ['a', d.id] }),
partial: ["blog_post", as: :blog_post]
}
json.array! @options, opts
Deferment
You can defer rendering of expensive nodes in your content tree using the defer: :auto
option. Behind the scenes PropsTemplates will no-op the block entirely, replace the value with {}
as a placeholder.
When the client recieves the payload, BreezyJS will use the meta data to issue a remote
dispatch to fetch the missing node and immutibly graft it at the appropriate keypath in your Redux store.
You can access what was deferred with json.deferred!
. If you use the generators, this will be set up in application.json.props
.
Usage:
json.dashboard(defer: :auto) do
sleep 10
json.some_fancy_metric 42
end
A manual option is also available:
json.dashboard(defer: :manual) do
sleep 10
json.some_fancy_metric 42
end
Finally in your application.json.props
:
json.defers json.deferred!
If :manual
is used, PropsTemplate will no-op the block and will not populate json.deferred!
. Its up to you to traverse to fetch the node seperately. A common usecase would be tab content that does not load until you click the tab.
Working with arrays
The default behavior for deferements is to use the index of the collection to identify an element. PropsTemplate will generate ?_bzq=a.b.c.0.title
in its metadata.
If you wish to use an attribute to identify the element. You must:
- Implement
:key
to specify which attribute you want to use to uniquely identify the element in the collection. PropsTemplate will generate?_bzq=a.b.c.some_id=some_value.title
- Implement
member_at
, andmember_key
on the collection to allow for BreezyJS to traverse the tree based on key value attributes.
For example:
require 'props_template/core_ext' #See (lists)[#Lists]
data = [{id: 1, name: 'foo'}, {id: 2, name: 'bar'}]
json.posts
json.array! data, key: :some_id do |item|
json.contact(defer: :auto) do
json.address '123 example drive'
end
# json.some_id item.some_id will be appended automatically to the end of the block
end
end
When BreezyJS receives the response, it will automatically kick off remote(?bzq=posts.some_id=1.contact)
and remote(?bzq=posts.some_id=2.contact)
.
Traversing
PropsTemplate has the ability to walk the tree you build, skipping execution of untargeted nodes. This feature is useful for partial updating your frontend state. See traversing nodes
traversal_path = ['data', 'details', 'personal']
json.data(search: traversal_path) do
json.details do
json.employment do
...more stuff...
end
json.personal do
json.name 'james'
json.zip_code 91210
end
end
end
json.footer do
...
end
PropsTemplate will will walk breath first, finds the matching key, executes the associated block, then repeats until it the node is found. The above will output the below:
{
data: {
name: 'james',
zipCode: 91210
},
footer: {
....
}
}
Breezy's searching only works with blocks, and will NOT work with Scalars ("leaf" values). For example:
traversal_path = ['data', 'details', 'personal', 'name'] <- not found
json.data(search: traversal_path) do
json.details do
json.personal do
json.name 'james'
end
end
end
Nodes that do not exist
Nodes that are not found will not define the key where search was enabled on.
traversal_path = ['data', 'details', 'does_not_exist']
json.data(search: traversal_path) do
json.details do
json.personal do
json.name 'james'
end
end
end
json.footer do
...
end
The above will render:
{
footer: {
...
}
}