add a custom data attribute on HTML tags wrapping a section string input

Didier Lafforgue committed May 23, 2018
commit 6ba87ea7438b808f7594cfa56583d829b1f9dd65
Showing 5 changed files with 285 additions and 36 deletions
locomotive/steam.rb b/lib/locomotive/steam.rb +7 -3
@@ @@ -18,11 +18,15 @@ module Locomotive
WILDCARD = 'content_type_template'.freeze
- CONTENT_ENTRY_ENGINE_CLASS_NAME = /^Locomotive::ContentEntry(.*)$/o.freeze
+ CONTENT_ENTRY_ENGINE_CLASS_NAME = /^Locomotive::ContentEntry(.*)$/o.freeze
- IsHTTP = /\Ahttps?:\/\//o.freeze
+ SECTIONS_SETTINGS_VARIABLE_REGEXP = /^\s*([a-z]+\.)?settings\.(?<id>.*)\s*$/o.freeze
+ # SECTIONS_SETTINGS_TAG_REGEXP = /(?<tag><[^\>]+>)\s*\z/mo.freeze
+ SECTIONS_BLOCK_FORLOOP_REGEXP = /(?<name>.+)-section\.blocks$/o.freeze
- IsLAYOUT = /\Alayouts(\/|\z)/o.freeze
+ IsHTTP = /\Ahttps?:\/\//o.freeze
+
+ IsLAYOUT = /\Alayouts(\/|\z)/o.freeze
class << self
attr_writer :configuration
locomotive/steam/entities/section.rb b/lib/locomotive/steam/entities/section.rb +6 -0
@@ @@ -5,6 +5,7 @@ module Locomotive::Steam
def initialize(attributes = {})
super({
+ slug: nil,
template: nil,
source: nil,
definition: nil
@@ @@ -14,5 +15,10 @@ module Locomotive::Steam
def source
self[:template]
end
+
+ def type
+ self[:slug]
+ end
+
end
end
locomotive/steam/liquid/drops/section.rb b/lib/locomotive/steam/liquid/drops/section.rb +118 -0
@@ @@ -0,0 +1,118 @@
+ module Locomotive
+ module Steam
+ module Liquid
+ module Drops
+
+ class Section < ::Liquid::Drop
+
+ def initialize(section, content)
+ @section = section
+ @content = content
+
+ if @content.blank?
+ @content = section.definition['default'] || { 'settings' => {}, 'blocks' => [] }
+ end
+ end
+
+ def id
+ @content['id'] || @section.type
+ end
+
+ def settings
+ @content['settings']
+ end
+
+ def css_class
+ @section.definition['class']
+ end
+
+ def blocks
+ (@content['blocks'] || []).each_with_index.map do |block, index|
+ SectionBlock.new(@section, block, index)
+ end
+ end
+
+ def editor_setting_data
+ SectionEditorSettingData.new(@section)
+ end
+
+ end
+
+ # Section block drop
+ class SectionBlock < ::Liquid::Drop
+
+ def initialize(section, block, index)
+ @section = section
+ @block = block || { 'settings' => {} }
+ @index = index
+ end
+
+ def id
+ @block['id'] || @index
+ end
+
+ def type
+ @block['type']
+ end
+
+ def settings
+ @block['settings']
+ end
+
+ end
+
+ # Required to allow the sync between the Locomotive editor
+ # and the string/text inputs of a section and section block
+ class SectionEditorSettingData < ::Liquid::Drop
+
+ def initialize(section)
+ @section = section
+ end
+
+ def before_method(meth)
+ block = nil
+ prefix = "section-#{@context['section'].id}"
+ matches = (@context['forloop.name'] || '').match(SECTIONS_BLOCK_FORLOOP_REGEXP)
+
+ # are we inside a block?
+ if matches && variable_name = matches[:name]
+ block = @context[variable_name]
+ prefix += "-block.#{@context['forloop.index0']}"
+ end
+
+ # only string and text inputs can synced
+ if is_text?(meth.to_s, block)
+ %( data-locomotive-editor-setting="#{prefix}.#{meth}")
+ else
+ ''
+ end
+ end
+
+ private
+
+ def is_text?(id, block)
+ settings = block ? block_settings(block['type']) : section_settings
+ text_inputs(settings).include?(id)
+ end
+
+ def text_inputs(settings)
+ settings.map do |input|
+ %w(string text).include?(input['type']) ? input['id'] : nil
+ end.compact
+ end
+
+ def block_settings(type)
+ @section.definition['blocks'].find do |block|
+ block['type'] == type
+ end&.fetch('settings', nil)
+ end
+
+ def section_settings
+ @section.definition['settings']
+ end
+ end
+
+ end
+ end
+ end
+ end
locomotive/steam/liquid/tags/section.rb b/lib/locomotive/steam/liquid/tags/section.rb +78 -15
@@ @@ -12,26 +12,24 @@ module Locomotive
# @options doesn't include the page key if cache is on
@options[:page] = context.registers[:page]
- # 1. get the name/slug of the section
- @template_name = evaluate_section_name(context)
+ # 1. get the type/slug of the section
+ @section_type = evaluate_section_name(context)
+ @template_name = "sections-#{@section_type}"
# 2. get the section
section = find_section(context)
- # 3. because it's considered as a static section, go get the content from
- # the current site. If it doesn't exist, use the default attribute of
- # the section
- section_content = context['site']&.sections_content&.fetch(@template_name, nil)
-
- if section_content.blank?
- section_content = section.definition[:default] || { settings: {}, blocks: [] }
- end
+ # 3. since it's considered as static, get the content from the current site.
+ section_content = context['site']&.sections_content&.fetch(@section_type, nil)
# 4. enhance the context by setting the "section" variable
- context['section'] = section_content
+ context['section'] = Locomotive::Steam::Liquid::Drops::Section.new(
+ section,
+ section_content
+ )
begin
- super
+ _render(context)
rescue Locomotive::Steam::ParsingRenderingError => e
e.file = @template_name + ' [Section]'
raise e
@@ @@ -40,23 +38,88 @@ module Locomotive
private
+ def _render(context)
+ partial = load_cached_partial(context)
+
+ if context.registers[:live_editing]
+ editor_settings_lookup(partial.root)
+ end
+
+ context.stack do
+ html = partial.render(context)
+
+ tag_id = "locomotive-section-#{context['section'].id}"
+ tag_class = ['locomotive-section', context['section'].css_class].join(' ')
+
+ %(<div id="#{tag_id}" class="#{tag_class}">#{html}</div>)
+ end
+ end
+
+ # in order to enable string/text synchronization with the editor:
+ # - find variables like {{ section.settings.<id> }} or {{ block.settings.<id> }}
+ # - once found, get the closest tag
+ # - add custom data attributes to it
+ def editor_settings_lookup(root)
+ previous_node = nil
+ new_nodelist = []
+
+ root.nodelist.each_with_index do |node, index|
+ if node.is_a?(::Liquid::Variable) && previous_node.is_a?(::Liquid::Token)
+ matches = node.raw.match(SECTIONS_SETTINGS_VARIABLE_REGEXP)
+
+ # is a section setting variable?
+ if matches && matches[:id] && wrapped_around_tag?(index, root.nodelist)
+ # open the closest HTML tag
+ previous_node.gsub!(/>(\s*)\z/, '\1')
+
+ # here we go, add a liquid variable!
+ new_nodelist.push(::Liquid::Variable.new(
+ "section.editor_setting_data.#{matches[:id]}",
+ node.instance_variable_get(:@options))
+ )
+
+ # close the tag
+ new_nodelist.push(::Liquid::Token.new('>', previous_node.line_number))
+ end
+ elsif node.respond_to?(:nodelist)
+ editor_settings_lookup(node)
+ end
+
+ new_nodelist.push(node)
+
+ previous_node = node
+ end
+
+ root.instance_variable_set(:@nodelist, new_nodelist)
+ end
+
def read_template_from_file_system(context)
section = find_section(context)
- raise SectionNotFound.new("Section with slug '#{@template_name}' was not found") if section.nil?
+ raise SectionNotFound.new("Section with slug '#{@section_type}' was not found") if section.nil?
section.liquid_source
end
def find_section(context)
- context.registers[:services].section_finder.find(@template_name)
+ context.registers[:services].section_finder.find(@section_type)
end
- # Repeat snippet
def evaluate_section_name(context = nil)
context.try(:evaluate, @template_name) ||
(!@template_name.is_a?(String) && @template_name.send(:state).first) ||
@template_name
end
+ def wrapped_around_tag?(index, nodelist)
+ return false if index + 1 >= nodelist.size
+
+ previous_node = nodelist[index - 1]
+ next_node = nodelist[index + 1]
+
+ return false unless next_node.is_a?(::Liquid::Token)
+
+ (previous_node =~ /\>\s*\z/).present? && (next_node =~ /\A\s*\</).present?
+ end
+
end
::Liquid::Template.register_tag('section'.freeze, Section)
spec/unit/liquid/tags/section_spec.rb +76 -18
@@ @@ -2,42 +2,100 @@ require 'spec_helper'
describe Locomotive::Steam::Liquid::Tags::Section do
- let(:services) { Locomotive::Steam::Services.build_instance(nil) }
- let(:finder) { services.section_finder }
- let(:source) { "Locomotive {% section header %}" }
- let(:context) { ::Liquid::Context.new({}, {}, { services: services }) }
+ let(:services) { Locomotive::Steam::Services.build_instance(nil) }
+ let(:finder) { services.section_finder }
+ let(:source) { 'Locomotive {% section header %}' }
+ let(:live_editing) { true }
+ let(:context) { ::Liquid::Context.new({}, {}, { services: services, live_editing: live_editing }) }
before do
allow(finder).to receive(:find).and_return(section)
-
end
describe 'rendering' do
+ let(:definition) { {
+ type: 'header',
+ class: 'my-awesome-header',
+ settings: [
+ { id: 'brand', type: 'string', label: 'Brand' },
+ { id: 'image', type: 'image_picker' }
+ ],
+ blocks: [
+ { type: 'menu_item', settings: [
+ { id: 'title', type: 'string' },
+ { id: 'image', type: 'image_picker' }
+ ]}
+ ],
+ default: {
+ settings: { brand: 'NoCoffee', image: 'foo.png' },
+ blocks: [{ id: 1, type: 'menu_item', settings: { title: 'Home', image: 'foo.png' } }] }
+ }.deep_stringify_keys }
+
let(:section) { instance_double(
- 'Section',
- liquid_source: 'built by NoCoffee',
- definition: {
- default: 'some default JSON'
- }
+ 'Header',
+ slug: 'header',
+ type: 'header',
+ liquid_source: liquid_source,
+ definition: definition,
)}
-
+
subject { render_template(source, context) }
- it { is_expected.to eq 'Locomotive built by NoCoffee' }
+ context 'no blocks' do
+
+ let(:liquid_source) { %(built by <a>\n\t<strong>{{ section.settings.brand }}</strong></a>) }
+
+ it { is_expected.to eq %(Locomotive <div id="locomotive-section-header" class="locomotive-section my-awesome-header">built by <a>\n\t<strong data-locomotive-editor-setting="section-header.brand">NoCoffee</strong></a></div>) }
+
+ context 'with a non string type input' do
+
+ let(:liquid_source) { 'built by <strong>{{ section.settings.image }}</strong>' }
+
+ it { is_expected.to eq 'Locomotive <div id="locomotive-section-header" class="locomotive-section my-awesome-header">built by <strong>foo.png</strong></div>' }
+
+ end
+
+ context 'without the live editing feature enabled' do
+
+ let(:live_editing) { false }
+
+ it { is_expected.to eq %(Locomotive <div id="locomotive-section-header" class="locomotive-section my-awesome-header">built by <a>\n\t<strong>NoCoffee</strong></a></div>) }
+
+ end
+
+ end
+
+ context 'with blocks' do
+
+ let(:liquid_source) { '{% for foo in section.blocks %}<a href="/">{{ foo.settings.title }}</a>{% endfor %}' }
+
+ it { is_expected.to eq 'Locomotive <div id="locomotive-section-header" class="locomotive-section my-awesome-header"><a href="/" data-locomotive-editor-setting="section-header-block.0.title">Home</a></div>' }
+
+ context 'with a non string type input' do
+
+ let(:liquid_source) { '{% for foo in section.blocks %}<a>{{ foo.settings.image }}</a>{% endfor %}' }
+
+ it { is_expected.to eq 'Locomotive <div id="locomotive-section-header" class="locomotive-section my-awesome-header"><a>foo.png</a></div>' }
+
+ end
+
+ end
+
context 'rendering error (action) found in the section' do
- let(:section) { instance_double(
- 'section',
- liquid_source: '{% action "Hello world" %}a.b(+}{% endaction %}',
- definition: {
- default: 'some default JSON'
- }
+ let(:live_editing) { false }
+ let(:liquid_source) { '{% action "Hello world" %}a.b(+}{% endaction %}' }
+ let(:section) { instance_double('section',
+ liquid_source: liquid_source,
+ definition: { settings: [], blocks: [] }
)}
it 'raises ParsingRenderingError' do
expect { subject }.to raise_exception(Locomotive::Steam::ParsingRenderingError)
end
end
+
end
+
end