diff --git a/.rubocop.yml b/.rubocop.yml index 796faa6..42af7ef 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,8 +5,6 @@ require: AllCops: TargetRubyVersion: 3.0 NewCops: enable - Exclude: - - lib/lifeform/phlex_renderable.rb Lint/MissingSuper: Enabled: false diff --git a/Gemfile.lock b/Gemfile.lock index 8961726..88e5be4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,8 +2,9 @@ PATH remote: . specs: lifeform (0.11.0) - activesupport (>= 7.0) - phlex (>= 1.7) + hash_with_dot_access (>= 1.2) + sequel (>= 5.72) + serbea (>= 2.0) zeitwerk (~> 2.5) GEM @@ -18,13 +19,14 @@ GEM ast (2.4.2) backport (1.2.0) benchmark (0.2.1) + bigdecimal (3.1.4) builder (3.2.4) - cgi (0.3.6) concurrent-ruby (1.2.2) diff-lcs (1.5.0) e2mmap (0.1.0) - erb (4.0.2) - cgi (>= 0.3.3) + erubi (1.12.0) + hash_with_dot_access (1.2.0) + activesupport (>= 5.0.0, < 8.0) i18n (1.14.1) concurrent-ruby (~> 1.0) jaro_winkler (1.5.6) @@ -47,10 +49,6 @@ GEM parser (3.2.2.3) ast (~> 2.4.1) racc - phlex (1.8.1) - concurrent-ruby (~> 1.2) - erb (>= 4) - zeitwerk (~> 2.6) racc (1.7.1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) @@ -78,6 +76,12 @@ GEM rubocop-rake (0.6.0) rubocop (~> 1.0) ruby-progressbar (1.13.0) + sequel (5.74.0) + bigdecimal + serbea (2.0.0) + activesupport (>= 6.0) + erubi (>= 1.10) + tilt (~> 2.0) solargraph (0.45.0) backport (~> 1.2) benchmark diff --git a/lib/lifeform.rb b/lib/lifeform.rb index 2b51567..94eb586 100644 --- a/lib/lifeform.rb +++ b/lib/lifeform.rb @@ -1,22 +1,12 @@ # frozen_string_literal: true -require "phlex" -require "active_support/core_ext/string/output_safety" - +require "serbea/pipeline" require "zeitwerk" loader = Zeitwerk::Loader.for_gem loader.setup module Lifeform class Error < StandardError; end - - module RefineProcToString - refine Proc do - def to_s - call.to_s - end - end - end end if defined?(Bridgetown) @@ -24,6 +14,5 @@ def to_s raise "The Lifeform support for Bridgetown requires v1.2 or newer" if Bridgetown::VERSION.to_f < 1.2 Bridgetown.initializer :lifeform do # |config| - require "lifeform/phlex_renderable" unless Phlex::HTML.instance_methods.include?(:render_in) end end diff --git a/lib/lifeform/capturing_renderable.rb b/lib/lifeform/capturing_renderable.rb deleted file mode 100644 index f7275a3..0000000 --- a/lib/lifeform/capturing_renderable.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Lifeform - module CapturingRenderable - # NOTE: the previous `with_output_buffer` stuff is for some reason incompatible with Serbea. - # So we'll use a simpler capture. - def render_in(view_context, &block) - if block - call(view_context: view_context) do |*args, **kwargs| - unsafe_raw(view_context.capture(*args, **kwargs, &block)) - end.html_safe - else - call(view_context: view_context).html_safe - end - end - end -end diff --git a/lib/lifeform/form.rb b/lib/lifeform/form.rb index 4fac701..24d844d 100644 --- a/lib/lifeform/form.rb +++ b/lib/lifeform/form.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true -require "active_support/core_ext/string/inflections" -require "active_support/ordered_options" +require "hash_with_dot_access" module Lifeform FieldDefinition = Struct.new(:type, :library, :parameters) # A form object which stores field definitions and can be rendered as a component - class Form < Phlex::HTML # rubocop:todo Metrics/ClassLength - include CapturingRenderable + class Form # rubocop:todo Metrics/ClassLength + include Lifeform::Renderable + extend Sequel::Inflections + MODEL_PATH_HELPER = :polymorphic_path class << self @@ -20,7 +21,7 @@ def inherited(subclass) # Helper to point to `I18n.t` method def t(...) = I18n.t(...) - def configuration = @configuration ||= ActiveSupport::OrderedOptions.new + def configuration = @configuration ||= HashWithDotAccess::Hash.new # @param block [Proc, nil] # @return [Hash] @@ -41,7 +42,7 @@ def subforms = @subforms ||= {} def field(name, type: :text, library: self.library, **parameters) parameters[:name] = name.to_sym - fields[name] = FieldDefinition.new(type, Libraries.const_get(library.to_s.classify), parameters) + fields[name] = FieldDefinition.new(type, Libraries.const_get(camelize(library)), parameters) end def subform(name, klass, parent_name: nil) @@ -92,7 +93,7 @@ def name_of_model(model) model.to_model.model_name.param_key else # Or just use basic underscore - model.class.name.underscore.tr("/", "_") + underscore(model.class.name).tr("/", "_") end end @@ -126,7 +127,7 @@ def initialize( # rubocop:disable Metrics/ParameterLists ) @model, @url, @library_name, @parameters, @emit_form_tag, @parent_name = model, url, library, parameters, emit_form_tag, parent_name - @library = Libraries.const_get(@library_name.to_s.classify) + @library = Libraries.const_get(self.class.send(:camelize, @library_name)) @subform_instances = {} self.class.initialize_field_definitions! @@ -139,15 +140,13 @@ def initialize( # rubocop:disable Metrics/ParameterLists def verify_method return if %w[get post].include?(parameters[:method].to_s.downcase) - @method_tag = Class.new(Phlex::HTML) do # TODO: break this out into a real component - def initialize(method:) - @method = method - end + method_value = @parameters[:method].to_s.downcase - def template - input type: "hidden", name: "_method", value: @method, autocomplete: "off" - end - end.new(method: @parameters[:method].to_s.downcase) + @method_tag = -> { + <<~HTML + + HTML + } parameters[:method] = :post end @@ -191,13 +190,21 @@ def template(&block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComple form_tag = library::FORM_TAG parameters[:action] ||= url || (model ? helpers.send(self.class.const_get(:MODEL_PATH_HELPER), model) : nil) - send(form_tag, **attributes) do - unsafe_raw(add_authenticity_token) unless parameters[:method].to_s.downcase == "get" - unsafe_raw @method_tag&.() || "" - block ? yield_content(&block) : auto_render_fields - end + html -> { + <<~HTML + <#{form_tag}#{attrs -> { attributes }}> + #{add_authenticity_token unless parameters[:method].to_s.downcase == "get"} + #{@method_tag&.() || ""} + #{block ? capture(self, &block) : auto_render_fields} + + HTML + } end - def auto_render_fields = self.class.fields.map { |k, _v| render(field(k)) } + def auto_render_fields = html_map(self.class.fields) { |k, _v| render(field(k)) } + + def render(field_object) + field_object.render_in(helpers || self) + end end end diff --git a/lib/lifeform/helpers.rb b/lib/lifeform/helpers.rb new file mode 100644 index 0000000..20453db --- /dev/null +++ b/lib/lifeform/helpers.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "sequel/model/default_inflections" +require "sequel/model/inflections" + +module Lifeform + module Helpers + def attributes_from_options(options) + segments = [] + options.each do |attr, option| + attr = dashed(attr) + if option.is_a?(Hash) + option = option.transform_keys { |key| "#{attr}-#{dashed(key)}" } + segments << attributes_from_options(option) + else + segments << attribute_segment(attr, option) + end + end + segments.join(" ") + end + + # Covert an underscored value into a dashed string. + # + # @example "foo_bar_baz" => "foo-bar-baz" + # + # @param value [String|Symbol] + # @return [String] + def dashed(value) + value.to_s.tr("_", "-") + end + + # Create an attribute segment for a tag. + # + # @param attr [String] the HTML attribute name + # @param value [String] the attribute value + # @return [String] + def attribute_segment(attr, value) + "#{attr}=#{value.to_s.encode(xml: :attr)}" + end + + def attrs(callback) + attrs_string = attributes_from_options(callback.() || {}) + + attrs_string = " #{attrs_string}" unless attrs_string.blank? + + attrs_string + end + + # Below is verbatim copied over from Bridgetown + # TODO: extract both out to a shared gem + + module PipeableProc + include Serbea::Pipeline::Helper + + attr_accessor :pipe_block, :touched + + def pipe(&block) + return super(self.(), &pipe_block) if pipe_block && !block + + self.touched = true + return self unless block + + tap { _1.pipe_block = block } + end + + def to_s + return self.().to_s if touched + + super + end + + def encode(...) + to_s.encode(...) + end + end + + Proc.prepend(PipeableProc) unless defined?(Bridgetown::HTMLinRuby::PipeableProc) + + def text(callback) + (callback.is_a?(Proc) ? html(callback) : callback).to_s.then do |str| + next str if str.respond_to?(:html_safe) && str.html_safe? + + str.encode(xml: :attr).gsub(/\A"|"\Z/, "") + end + end + + def html(callback) + callback.pipe + end + + def html_map(input, &callback) + input.map(&callback).join + end + end +end diff --git a/lib/lifeform/libraries/default.rb b/lib/lifeform/libraries/default.rb index 3d239a9..e951513 100644 --- a/lib/lifeform/libraries/default.rb +++ b/lib/lifeform/libraries/default.rb @@ -10,7 +10,7 @@ class Default # @param attributes [Hash] # @return [Input] def self.object_for_field_definition(form, field_definition, attributes) - type_classname = field_definition[:type].to_s.classify + type_classname = Lifeform::Form.send(:camelize, field_definition[:type]) if const_defined?(type_classname) const_get(type_classname) else diff --git a/lib/lifeform/libraries/default/button.rb b/lib/lifeform/libraries/default/button.rb index f3326cf..c7cc8bb 100644 --- a/lib/lifeform/libraries/default/button.rb +++ b/lib/lifeform/libraries/default/button.rb @@ -3,17 +3,14 @@ module Lifeform module Libraries class Default - class Button < Phlex::HTML - using RefineProcToString - include CapturingRenderable + class Button + include Lifeform::Renderable attr_reader :form, :field_definition, :attributes WRAPPER_TAG = :form_button BUTTON_TAG = :button - register_element WRAPPER_TAG - def initialize(form, field_definition, **attributes) @form = form @field_definition = field_definition @@ -23,21 +20,27 @@ def initialize(form, field_definition, **attributes) @attributes[:type] ||= "button" end - def template(&block) - return if !@if.nil? && !@if + def template(&block) # rubocop:disable Metrics/AbcSize + return "" if !@if.nil? && !@if + + wrapper_tag = dashed self.class.const_get(:WRAPPER_TAG) + button_tag = dashed self.class.const_get(:BUTTON_TAG) - wrapper_tag = self.class.const_get(:WRAPPER_TAG) - button_tag = self.class.const_get(:BUTTON_TAG) + label_text = block ? capture(self, &block) : @label.is_a?(Proc) ? @label.pipe : @label # rubocop:disable Style/NestedTernaryOperator - field_body = proc { - send(button_tag, **@attributes) do - unsafe_raw(@label.to_s) unless block - yield_content(&block) - end + field_body = html -> { + <<-HTML + <#{button_tag}#{attrs -> { @attributes }}>#{text -> { label_text }} + HTML } - return field_body.() unless wrapper_tag - send wrapper_tag, name: @attributes[:name], &field_body + return field_body unless wrapper_tag + + html -> { + <<-HTML + <#{wrapper_tag}#{attrs -> { { name: @attributes[:name] } }}>#{field_body} + HTML + } end end end diff --git a/lib/lifeform/libraries/default/input.rb b/lib/lifeform/libraries/default/input.rb index 8ca9650..6497078 100644 --- a/lib/lifeform/libraries/default/input.rb +++ b/lib/lifeform/libraries/default/input.rb @@ -3,17 +3,14 @@ module Lifeform module Libraries class Default - class Input < Phlex::HTML - using RefineProcToString - include CapturingRenderable + class Input + include Lifeform::Renderable attr_reader :form, :field_definition, :attributes WRAPPER_TAG = :form_field INPUT_TAG = :input - register_element WRAPPER_TAG - def initialize(form, field_definition, **attributes) @form = form @field_definition = field_definition @@ -30,7 +27,8 @@ def verify_attributes # rubocop:disable Metrics @if = attributes.delete(:if) attributes[:value] ||= value_for_model if form.model attributes[:name] = "#{model_name}[#{attributes[:name]}]" if @model - attributes[:id] ||= attributes[:name].parameterize(separator: "_") + # TODO: validate if this is enough + attributes[:id] ||= attributes[:name].tr("[]", "_").gsub("__", "_").chomp("_") if attributes[:name] @label = handle_labels if attributes[:label] end @@ -42,31 +40,41 @@ def model_name def value_for_model = @model.send(attributes[:name]) - def handle_labels - label_text = attributes[:label].to_s + def handle_labels # rubocop:disable Metrics/AbcSize + label_text = attributes[:label].is_a?(Proc) ? attributes[:label].pipe : attributes[:label] label_name = (attributes[:id] || attributes[:name]).to_s @attributes = attributes.filter_map { |k, v| [k, v] unless k == :label }.to_h - proc { - label(for: label_name) { unsafe_raw label_text } + -> { + <<~HTML + + HTML } end - def template(&block) - return if !@if.nil? && !@if + def template(&block) # rubocop:disable Metrics/AbcSize + return "" if !@if.nil? && !@if - wrapper_tag = self.class.const_get(:WRAPPER_TAG) - input_tag = self.class.const_get(:INPUT_TAG) + wrapper_tag = dashed self.class.const_get(:WRAPPER_TAG) + input_tag = dashed self.class.const_get(:INPUT_TAG) + closing_tag = input_tag != "input" - field_body = proc { - @label&.() - send input_tag, type: @field_type.to_s, **@attributes - yield_content(&block) + field_body = html -> { + <<~HTML + #{html(@label || -> {}).to_s.strip} + <#{input_tag}#{attrs -> { { type: @field_type.to_s, **@attributes } }}>#{"" if closing_tag} + #{html -> { capture(self, &block) } if block} + HTML } - return field_body.() unless wrapper_tag - send wrapper_tag, name: @attributes[:name], &field_body + return field_body unless wrapper_tag + + html -> { + <<~HTML + <#{wrapper_tag}#{attrs -> { { name: @attributes[:name] } }}>#{field_body.to_s.strip} + HTML + } end end end diff --git a/lib/lifeform/libraries/shoelace/input.rb b/lib/lifeform/libraries/shoelace/input.rb index 867d05f..7be31c4 100644 --- a/lib/lifeform/libraries/shoelace/input.rb +++ b/lib/lifeform/libraries/shoelace/input.rb @@ -6,8 +6,6 @@ class Shoelace class Input < Default::Input INPUT_TAG = :sl_input - register_element :sl_input - # no-op def handle_labels; end end diff --git a/lib/lifeform/phlex_renderable.rb b/lib/lifeform/phlex_renderable.rb deleted file mode 100644 index c533cd6..0000000 --- a/lib/lifeform/phlex_renderable.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -# Bridgetown-specific workaround that got copied over from the phlex-rails gem (for now) -module Lifeform - module PhlexRenderable - def helpers - @_view_context - end - - def render(renderable, *args, **kwargs, &block) - return super if renderable.is_a?(Phlex::HTML) - return super if renderable.is_a?(Class) && renderable < Phlex::HTML - - @_target << @_view_context.render(renderable, *args, **kwargs, &block) - - nil - end - - def render_in(view_context, &block) - if block_given? - call(view_context: view_context) do |*args| - view_context.with_output_buffer(self) do - original_length = @_target.length - output = yield(*args) - unchanged = (original_length == @_target.length) - - if unchanged - if output.is_a?(ActiveSupport::SafeBuffer) - unsafe_raw(output) - else - text(output) - end - end - end - end.html_safe - else - call(view_context: view_context).html_safe - end - end - - def safe_append=(value) - return unless value - - @_target << case value - when String then value - when Symbol then value.name - else value.to_s - end - end - - def append=(value) - return unless value - - if value.html_safe? - self.safe_append = value - else - @_target << case value - when String then ERB::Util.html_escape(value) - when Symbol then ERB::Util.html_escape(value.name) - else ERB::Util.html_escape(value.to_s) - end - end - end - - def capture = super.html_safe - - # Trick ViewComponent into thinking we're a ViewComponent to fix rendering output - def set_original_view_context(view_context) - end - end -end - -Phlex::HTML.prepend(Lifeform::PhlexRenderable) diff --git a/lib/lifeform/renderable.rb b/lib/lifeform/renderable.rb new file mode 100644 index 0000000..2650a6f --- /dev/null +++ b/lib/lifeform/renderable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lifeform + module Renderable + include Lifeform::Helpers + + def render_in(view_context, &block) + @_view_context = view_context + template(&block).to_s.strip + end + + def helpers + @_view_context + end + + def capture(*args, &block) + helpers ? helpers.capture(*args, &block) : yield(*args) + end + end +end diff --git a/lifeform.gemspec b/lifeform.gemspec index eb9401c..c34c393 100644 --- a/lifeform.gemspec +++ b/lifeform.gemspec @@ -23,8 +23,8 @@ Gem::Specification.new do |spec| end spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - spec.add_dependency "activesupport", ">= 7.0" - spec.add_dependency "phlex", ">= 1.7" + spec.add_dependency "hash_with_dot_access", ">= 1.2" + spec.add_dependency "sequel", ">= 5.72" + spec.add_dependency "serbea", ">= 2.0" spec.add_dependency "zeitwerk", "~> 2.5" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 542ccfb..3911fe8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,7 +2,6 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "lifeform" -require "lifeform/phlex_renderable" require "minitest/autorun" require "minitest/reporters" diff --git a/test/test_lifeform.rb b/test/test_lifeform.rb index efbb25d..4bd560a 100644 --- a/test/test_lifeform.rb +++ b/test/test_lifeform.rb @@ -20,11 +20,11 @@ class TestForm < Lifeform::Form TestForm.configuration.occupation_key = :occupation class TestAutolayout < Lifeform::Form - field :first_name, label: "First Name", required: true - field :last_name, label: "Last Name", goof: "Wow" + field :first_name, label: "First Name".html_safe, required: true + field :last_name, label: -> { "Last Name" }, goof: "Wow" field :age, library: :shoelace, label: "Your Age" - field :submit, type: :submit_button, label: "Save", class: "font-bold" + field :submit, type: :submit_button, label: "Save".html_safe, class: "font-bold" end class TestLifeform < Minitest::Test @@ -147,6 +147,9 @@ def test_autolayout assert_equal "text", input[:type] assert_equal "struct_person[first_name]", input[:name] + label = form.at("label[for='struct_person_last_name']") + assert_equal "Last Name", label.inner_html + button_wrapper = form.css("form-button")[0] assert_equal "commit", button_wrapper[:name] @@ -157,28 +160,6 @@ def test_autolayout assert_equal "Save", button.inner_html end - def test_inside_phlex - phlex_view = Class.new(Phlex::HTML) do - def initialize - @form = TestForm.new(url: "/path") - end - - def template - h1 { "Howdy" } - render @form do |f| - render f.field(:occupation) - render f.field(:age, value: 47) - end - end - end - - result = phlex_view.new.render_in(self) - - assert_equal <<~HTML.strip, result -

Howdy

- HTML - end - def render(obj, &block) obj.render_in(self, &block) end