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 | |