From 05db93d9c51a54a58593dd316ade3a0a83bea4f2 Mon Sep 17 00:00:00 2001 From: stephann <3025661+stephannv@users.noreply.github.com> Date: Sat, 21 Sep 2024 08:17:25 -0300 Subject: [PATCH] feat: Add form_builder/inputs helper --- README.md | 2 +- shard.yml | 2 +- spec/blueprint/html/enveloping_spec.cr | 2 + spec/blueprint/html/form_builder_spec.cr | 328 ++++++++++++++++++++++ src/blueprint/form/builder.cr | 23 ++ src/blueprint/form/inputs.cr | 13 + src/blueprint/form/tags.cr | 70 +++++ src/blueprint/html.cr | 15 +- src/blueprint/html/component_registrar.cr | 12 +- src/blueprint/html/forms.cr | 20 ++ src/blueprint/safe_value.cr | 6 +- 11 files changed, 467 insertions(+), 26 deletions(-) create mode 100644 spec/blueprint/html/form_builder_spec.cr create mode 100644 src/blueprint/form/builder.cr create mode 100644 src/blueprint/form/inputs.cr create mode 100644 src/blueprint/form/tags.cr create mode 100644 src/blueprint/html/forms.cr diff --git a/README.md b/README.md index 45d8a2b..1c96775 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

- A framework for writing reusable and testable HTML templates in plain Crystal. + A lib for writing reusable and testable views templates (HTML, SVG, Forms) in plain Crystal.

diff --git a/shard.yml b/shard.yml index ff704dc..ad7c8dd 100644 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: blueprint description: | - Blueprint is a lib for writing reusable and testable HTML templates in plain Crystal, allowing an OOP (Oriented Object Programming) approach when building your views. + A lib for writing reusable and testable views templates (HTML, SVG, Forms) in plain Crystal. Inspired by Phlex. version: 0.8.0 diff --git a/spec/blueprint/html/enveloping_spec.cr b/spec/blueprint/html/enveloping_spec.cr index 8caf05b..ad7af25 100644 --- a/spec/blueprint/html/enveloping_spec.cr +++ b/spec/blueprint/html/enveloping_spec.cr @@ -1,3 +1,5 @@ +require "../../spec_helper" + private class MainLayout include Blueprint::HTML diff --git a/spec/blueprint/html/form_builder_spec.cr b/spec/blueprint/html/form_builder_spec.cr new file mode 100644 index 0000000..9b29c17 --- /dev/null +++ b/spec/blueprint/html/form_builder_spec.cr @@ -0,0 +1,328 @@ +require "../../spec_helper" + +private class CustomFormBuilder(T) < Blueprint::Form::Builder(T) + def label(attribute : Symbol, value = nil, **html_options, &) + div class: "label" do + super(attribute, value, **html_options.merge(class: "label-content")) do + span { yield } + end + end + end + + def text_input(attribute : Symbol, **html_options) + super(attribute, **html_options.merge(class: "form-input")) + end + + def money_input(attribute : Symbol, **html_options) + text_input(attribute, **html_options.merge(mask: "$#.##")) + end +end + +describe "form builder" do + describe "#form_builder" do + it "accepts html options" do + actual_html = Blueprint::HTML.build do + form_builder(action: "/search", method: :post) do |form| + end + end + + expected_html = normalize_html <<-HTML +

+ HTML + + actual_html.to_s.should eq expected_html + end + + it "accepts custom form builders" do + actual_html = Blueprint::HTML.build do + form_builder(builder: CustomFormBuilder) do |form| + form.label :name + form.text_input :name + + form.money_input :balance + end + end + + expected_html = normalize_html <<-HTML +
+
+ +
+ + + +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + describe "#inputs" do + it "allows building inputs without form builder" do + actual_html = Blueprint::HTML.build do + inputs do |builder| + builder.label :name + builder.text_input :name + + builder.radio_input :color, :red + builder.label :color, "Red", :red + end + + inputs :settings do |builder| + builder.label :volume + builder.range_input :volume, 1..99 + end + end + + expected_html = normalize_html <<-HTML + + + + + + + + + HTML + actual_html.to_s.should eq expected_html + end + end + + describe "#label" do + it "renders label" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.label :username + + form.label :email, "E-mail" + + form.label :password do + plain "Password" + whitespace + span "(*required)" + end + + form.label :upload, id: "upload_label", class: "form-label", for: "another_upload" + + form.label :color, "Red", value: :red + end + + form_builder(scope: :search) do |form| + form.label :title + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + + + + + +
+ +
+ +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + {% for type in %i[color date datetime datetime_local email file hidden month number password search tel text time url week] %} + describe "{{type.id}}_input" do + it "renders input with type = {{type.id}}" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.{{type.id}}_input :x + + form.{{type.id}}_input :y, id: "custom_id", name: "custom_name" + end + + form_builder(scope: :custom_scope) do |form| + form.{{type.id}}_input :z + end + end + + expected_html = normalize_html <<-HTML +
+ + + +
+ +
+ +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + {% end %} + + describe "#checkbox_input" do + it "renders input with type = checkbox" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.checkbox_input :paid + + form.checkbox_input :admin, "yes", "no" + + form.checkbox_input :accepted, unchecked_value: nil + end + + form_builder(scope: :user) do |form| + form.checkbox_input :admin + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + + + +
+ +
+ + +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + describe "#radio_input" do + it "renders input with type = radio" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.radio_input :color, :yellow + + form.radio_input :color, "red" + + form.radio_input :color, safe(:blue) + end + + form_builder(scope: :theme) do |form| + form.radio_input :color, :green + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + +
+ +
+ +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + describe "#range_input" do + it "renders input with type = range" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.range_input :volume + + form.range_input :volume, min: 20, max: 40, step: 5 + + form.range_input :volume, -20..20 + end + + form_builder(scope: :settings) do |form| + form.range_input :volume, 0...10 + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + +
+ +
+ +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + describe "#submit" do + it "renders submit input" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.submit + + form.submit "Save" + + form.submit "Update", class: "btn", name: "commit" + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + +
+ HTML + + actual_html.to_s.should eq expected_html + end + end + + describe "#reset" do + it "renders reset input" do + actual_html = Blueprint::HTML.build do + form_builder do |form| + form.reset + + form.reset "Resetar" + + form.reset "Clear", class: "btn", name: "commit" + end + end + + expected_html = normalize_html <<-HTML +
+ + + + + +
+ HTML + + actual_html.to_s.should eq expected_html + end + end +end diff --git a/src/blueprint/form/builder.cr b/src/blueprint/form/builder.cr new file mode 100644 index 0000000..b960098 --- /dev/null +++ b/src/blueprint/form/builder.cr @@ -0,0 +1,23 @@ +require "./tags" +require "./inputs" + +class Blueprint::Form::Builder(T) + include Tags + + @scope : Symbol + @html_options : T + + def self.new(scope = :"", **html_options) : self + new scope, html_options + end + + def initialize(@scope, @html_options : T) + {% T.raise "Expected T be NamedTuple, but got #{T}." unless T <= NamedTuple %} + end + + def blueprint(&) + form(**@html_options) do + yield + end + end +end diff --git a/src/blueprint/form/inputs.cr b/src/blueprint/form/inputs.cr new file mode 100644 index 0000000..67f2759 --- /dev/null +++ b/src/blueprint/form/inputs.cr @@ -0,0 +1,13 @@ +require "./tags" + +class Blueprint::Form::Inputs + include Tags + + @scope : Symbol | String + + def initialize(@scope = :""); end + + def blueprint(&) + yield + end +end diff --git a/src/blueprint/form/tags.cr b/src/blueprint/form/tags.cr new file mode 100644 index 0000000..57f08a3 --- /dev/null +++ b/src/blueprint/form/tags.cr @@ -0,0 +1,70 @@ +require "../html" + +module Blueprint::Form::Tags + include Blueprint::HTML + + def label(attribute : Symbol, value = nil, **html_options, &) : Nil + super(**{for: input_id(attribute, value)}.merge(html_options)) do + yield + end + end + + def label(attribute : Symbol, text : String? = nil, value = nil, **html_options) : Nil + label(attribute, value, **html_options) { text || attribute.to_s.capitalize } + end + + {% for type in %i[color date datetime datetime_local email file hidden month number password range search tel text time url week] %} + def {{type.id}}_input(attribute : Symbol, **html_options) : Nil + input **default_input_options("{{type.tr("_", "-").id}}", attribute).merge(html_options) + end + {% end %} + + def checkbox_input(attribute : Symbol, checked_value = "1", unchecked_value = "0", **html_options) : Nil + hidden_input(attribute, id: nil, value: unchecked_value) if unchecked_value + + input(**default_input_options("checkbox", attribute, value: checked_value).merge(html_options)) + end + + def radio_input(attribute : Symbol, value, **html_options) : Nil + input **{type: safe("radio"), id: input_id(attribute, value), name: input_name(attribute), value: value} + .merge(html_options) + end + + def range_input(attribute : Symbol, range : Range, **html_options) + range_input(attribute, **html_options.merge(min: range.begin, max: range.end)) + end + + def reset(value = safe("Reset"), **html_options) : Nil + input(**{type: safe("reset"), value: value}.merge(html_options)) + end + + def submit(value = safe("Submit"), **html_options) : Nil + input(**{type: safe("submit"), value: value}.merge(html_options)) + end + + def input_id(attribute : Symbol, value = nil) + String.build do |io| + if @scope != :"" + io << @scope << "_" + end + + io << attribute + + if value + io << "_" << value + end + end + end + + def input_name(attribute : Symbol) + if @scope == :"" + attribute + else + String.build { |io| io << @scope << "[" << attribute << "]" } + end + end + + private def default_input_options(type : String, attribute : Symbol, **options) : NamedTuple + {type: safe(type), id: input_id(attribute), name: input_name(attribute)}.merge(options) + end +end diff --git a/src/blueprint/html.cr b/src/blueprint/html.cr index 5c5ceef..2246485 100644 --- a/src/blueprint/html.cr +++ b/src/blueprint/html.cr @@ -1,18 +1,6 @@ require "html" -require "./html/attributes_handler" -require "./html/block_renderer" -require "./html/buffer_appender" -require "./html/builder" -require "./html/component_registrar" -require "./html/component_renderer" -require "./html/element_registrar" -require "./html/element_renderer" -require "./html/helpers" -require "./html/standard_elements" -require "./html/style_builder" -require "./html/svg" -require "./html/utils" +require "./html/*" require "./safe_object" require "./safe_value" @@ -25,6 +13,7 @@ module Blueprint::HTML include Blueprint::HTML::ComponentRenderer include Blueprint::HTML::ElementRegistrar include Blueprint::HTML::ElementRenderer + include Blueprint::HTML::Forms include Blueprint::HTML::Helpers include Blueprint::HTML::StandardElements include Blueprint::HTML::StyleBuilder diff --git a/src/blueprint/html/component_registrar.cr b/src/blueprint/html/component_registrar.cr index dc08ad6..c0df21d 100644 --- a/src/blueprint/html/component_registrar.cr +++ b/src/blueprint/html/component_registrar.cr @@ -1,20 +1,20 @@ module Blueprint::HTML::ComponentRegistrar macro register_component(helper_method, component_class, block = true) {% if block %} - private def {{helper_method.id}}(**args, &) : Nil - render {{component_class}}.new(**args) do |component| + private def {{helper_method.id}}(*args, **kwargs, &) : Nil + render {{component_class}}.new(*args, **kwargs) do |component| yield component end end {% if block == :optional %} - private def {{helper_method.id}}(**args) : Nil - render({{component_class}}.new(**args)) {} + private def {{helper_method.id}}(*args, **kwargs) : Nil + render({{component_class}}.new(*args, **kwargs)) {} end {% end %} {% else %} - private def {{helper_method.id}}(**args) : Nil - render {{component_class}}.new(**args) + private def {{helper_method.id}}(*args, **kwargs) : Nil + render {{component_class}}.new(*args, **kwargs) end {% end %} end diff --git a/src/blueprint/html/forms.cr b/src/blueprint/html/forms.cr new file mode 100644 index 0000000..1d24171 --- /dev/null +++ b/src/blueprint/html/forms.cr @@ -0,0 +1,20 @@ +require "../form/builder" +require "../form/inputs" + +module Blueprint::HTML::Forms + def form_builder(scope : Symbol = :"", builder : Form::Builder.class = default_form_builder, **html_options, &) + render builder.new(scope, **html_options) do |form| + yield form + end + end + + def inputs(scope : Symbol | String = :"", &) + render Form::Inputs.new(scope) do |builder| + yield builder + end + end + + def default_form_builder : Form::Builder.class + Form::Builder + end +end diff --git a/src/blueprint/safe_value.cr b/src/blueprint/safe_value.cr index e711fa2..e24fcb1 100644 --- a/src/blueprint/safe_value.cr +++ b/src/blueprint/safe_value.cr @@ -1,11 +1,7 @@ class Blueprint::SafeValue(T) include SafeObject - getter value : T - def initialize(@value : T); end - def to_s(io : String::Builder) - @value.to_s(io) - end + delegate to_s, to: @value end