diff --git a/Gemfile b/Gemfile index 15cfc77..4f7ac39 100644 --- a/Gemfile +++ b/Gemfile @@ -21,7 +21,11 @@ group :development do end group :test do - gem "rspec" + gem "capybara" + gem "cuprite" + gem "nokogiri" + gem "rspec-given" + gem "rspec-html-matchers" end group :development, :test do diff --git a/assets/css/index.css b/assets/css/index.css index 3721848..6c90be6 100644 --- a/assets/css/index.css +++ b/assets/css/index.css @@ -544,6 +544,10 @@ video { --tw-backdrop-sepia: ; } +.m-2 { + margin: 0.5rem; +} + .mx-3 { margin-left: 0.75rem; margin-right: 0.75rem; @@ -562,23 +566,89 @@ video { display: flex; } +.hidden { + display: none; +} + +.items-center { + align-items: center; +} + +.gap-1 { + gap: 0.25rem; +} + .gap-2 { gap: 0.5rem; } +.rounded { + border-radius: 0.25rem; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + .border-b { border-bottom-width: 1px; } +.border-slate-300 { + --tw-border-opacity: 1; + border-color: rgb(203 213 225 / var(--tw-border-opacity)); +} + +.border-slate-800 { + --tw-border-opacity: 1; + border-color: rgb(30 41 59 / var(--tw-border-opacity)); +} + +.bg-slate-50 { + --tw-bg-opacity: 1; + background-color: rgb(248 250 252 / var(--tw-bg-opacity)); +} + +.bg-slate-800 { + --tw-bg-opacity: 1; + background-color: rgb(30 41 59 / var(--tw-bg-opacity)); +} + .bg-slate-900 { --tw-bg-opacity: 1; background-color: rgb(15 23 42 / var(--tw-bg-opacity)); } +.p-1 { + padding: 0.25rem; +} + .p-2 { padding: 0.5rem; } +.p-4 { + padding: 1rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + .text-xl { font-size: 1.25rem; line-height: 1.75rem; @@ -588,6 +658,11 @@ video { font-weight: 600; } +.text-gray-800 { + --tw-text-opacity: 1; + color: rgb(31 41 55 / var(--tw-text-opacity)); +} + .text-slate-50 { --tw-text-opacity: 1; color: rgb(248 250 252 / var(--tw-text-opacity)); @@ -609,6 +684,21 @@ video { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.placeholder\:text-gray-400::-moz-placeholder { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.placeholder\:text-gray-400::placeholder { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.hover\:bg-slate-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + .hover\:shadow-sm:hover { --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); diff --git a/docker-compose.yml b/docker-compose.yml index 73b5cbe..6002a99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,21 +18,14 @@ services: web: <<: [*build-common, *app-common] command: "rerun --pattern **/*.{rb,ru,yml} -- bundle exec rackup --host=0.0.0.0" - tty: true - stdin_open: true entrypoint: script/docker-entrypoint.sh depends_on: - tailwindcss environment: ENVIRONMENT_NAME: development HISTFILE: /app/.zsh_history - expose: - - 9292 ports: - "9292:9292" - volumes: - - .:/app - - bundler:/bundler tailwindcss: <<: [*build-common, *app-common] diff --git a/spec/framework/web/form_errors_spec.rb b/spec/framework/web/form_errors_spec.rb new file mode 100644 index 0000000..9c8e7bc --- /dev/null +++ b/spec/framework/web/form_errors_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe Forms::Errors do + Given(:html) { form.call } + Given(:result) { HTML(html) } + + Given(:form_class) { + Class.new { + include Phlex::Renderable + include Forms::Form + + def template + errors_for(:my_field) + end + } + } + + When(:form) { form_class.new(errors: { my_field: field_errors }) } + When(:form_tag) { result.find_tag(:form) } + + context " when there are NO errors for field" do + context "when errors for the field is empty" do + Given(:field_errors) { [] } + + Then { form_tag.empty_element? } + end + + context "when errors for the field is nil" do + Given(:field_errors) { nil } + + Then { form_tag.empty_element? } + end + + context "when there are no errors" do + Given(:form) { form_class.new(errors: {}) } + + Then { form_tag.empty_element? } + end + + context "when there are no errors passed in" do + Given(:form) { form_class.new } + + Then { form_tag.empty_element? } + end + end + + context "when the field has errors" do + context "when there is one error for the field" do + Given(:field_errors) { "an error" } + + Then { form_tag.has_tag?(:span, text: "an error") } + end + + context "when there are multiple errors for the field" do |_v| + Given(:field_errors) { %w[error-one error-two] } + + Then { form_tag.has_tag?(:span, text: "error-one") } + And { form_tag.has_tag?(:span, text: "error-two") } + end + end + + describe "css classes" do + Given(:form_class) { + Class.new { + include Phlex::Renderable + include Forms::Form + + def template + errors_for(:my_field) + end + + private + + def errors_css_classes + %w[a-class] + end + + def error_css_classes + %w[another-class] + end + } + } + + When(:field_errors) { "an-error" } + + Then { form_tag.has_tag?(:div, with: {class: "a-class"}) } + Then { form_tag.has_tag?(:span, with: {class: "another-class"}) } + end +end diff --git a/spec/framework/web/form_fields_spec.rb b/spec/framework/web/form_fields_spec.rb new file mode 100644 index 0000000..9a432b6 --- /dev/null +++ b/spec/framework/web/form_fields_spec.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +RSpec.describe Forms::Fields do + Given(:html) { field.call } + Given(:result) { HTML(html) } + + describe Forms::Fields::Field do + describe "field with name" do + When(:field) { described_class.new(:field_name) } + + Then { result.has_tag?("input", with: { name: "field_name" }) } + end + + describe "value attribute" do + When(:field) { described_class.new(:field_name, value: "field value") } + + Then { + result.has_tag?("input", + with: { name: "field_name", value: "field value" }) + } + end + + describe "css class attribute" do + Given(:default_css_classes) { + %w[p-1 text-gray-800 placeholder:text-gray-400 border rounded] + } + + When(:field) { described_class.new(:field_name, class: css_class) } + + context "when giving it a simple html class" do + Given(:css_class) { "a_class" } + + Then { + result.has_tag?( + "input", + with: { + class: (default_css_classes + [css_class]).join(" "), + } + ) + } + end + + context "when giving it multiple html classes as a string" do + Given(:css_class) { "a_class another_class" } + + Then { + result.has_tag?( + "input", + with: { + class: (default_css_classes + [css_class]).join(" "), + } + ) + } + end + + context "when giving it multiple html classes as an array of strings" do + Given(:css_class) { %w[a_class another_class] } + + Then { + result.has_tag?( + "input", + with: { + class: (default_css_classes + css_class).join(" "), + } + ) + } + end + + describe "default css class" do + When(:field) { described_class.new(:field_name) } + + Then { + result.has_tag?( + "input", + with: { + class: default_css_classes.join(" "), + } + ) + } + end + end + + describe "html options" do + context "adding one html option" do + When(:field) { described_class.new(:field_name, placeholder: "a placeholder") } + + Then { result.has_tag?("input", with: { placeholder: "a placeholder" }) } + end + + context "adding multiple html options" do + When(:field) { described_class.new(:field_name, placeholder: "a placeholder", size: "10") } + + Then { result.has_tag?("input", with: { placeholder: "a placeholder", size: "10" }) } + end + + context "numeric html option" do + When(:field) { described_class.new(:field_name, size: 20) } + + Then { result.has_tag?("input", with: { size: "20" }) } + end + end + end + + describe "field types" do + shared_context "field type" do |type, type_text| + describe type do + When(:field) { described_class.new(:my_field_name) } + + Then { result.has_tag?("input", with: { type: type_text }) } + end + end + + include_context "field type", Forms::Fields::TextField, "text" + include_context "field type", Forms::Fields::HiddenField, "hidden" + include_context "field type", Forms::Fields::HiddenField, "hidden" + include_context "field type", Forms::Fields::PasswordField, "password" + include_context "field type", Forms::Fields::NumberField, "number" + include_context "field type", Forms::Fields::EmailField, "email" + include_context "field type", Forms::Fields::SearchField, "search" + include_context "field type", Forms::Fields::DateField, "date" + include_context "field type", Forms::Fields::DatetimeLocalField, "datetime-local" + include_context "field type", Forms::Fields::ColorField, "color" + end + + pending_describe "select" + pending_describe "button" + pending_describe "checkbox" + pending_describe "file" + pending_describe "image" + pending_describe "month" + pending_describe "radio" + pending_describe "range" + pending_describe "reset" + pending_describe "submit" + pending_describe "tel" + pending_describe "time" + pending_describe "url" + pending_describe "week" + + describe Forms::Fields::Label do + describe "empty label" do + When(:field) { described_class.new(:my_field_is_for) } + + Then { + result.has_tag?( + "label", + with: { + for: "my_field_is_for", + }, + text: "my_field_is_for", + ) + } + end + + describe "value" do + When(:field) { described_class.new(:my_field_is_for, text: "Label text") } + + Then { + result.has_tag?( + "label", + with: { + for: "my_field_is_for", + }, + text: "Label text", + ) + } + end + + describe "css class attribute" do + Given(:default_css_classes) { [] } + + When(:field) { described_class.new(:field_name, class: css_class) } + + context "when giving it a simple html class" do + When(:css_class) { "a_class" } + + Then { + result.has_tag?( + "label", + with: { + class: (default_css_classes + [css_class]).join(" "), + } + ) + } + end + + context "when giving it multiple html classes as a string" do + When(:css_class) { "a_class another_class" } + + Then { + result.has_tag?( + "label", + with: { + class: (default_css_classes + [css_class]).join(" "), + } + ) + } + end + + context "when giving it multiple html classes as an array of strings" do + When(:css_class) { %w[a_class another_class] } + + Then { + result.has_tag?( + "label", + with: { + class: (default_css_classes + css_class).join(" "), + } + ) + } + end + + describe "default css class" do + When(:field) { described_class.new(:field_name) } + + Then { + result.has_tag?( + "label", + with: { + class: default_css_classes.join(" "), + } + ) + } + end + end + + describe "html options" do + context "adding one html option" do + When(:field) { described_class.new(:field_name, placeholder: "a placeholder") } + + Then { result.has_tag?("label", with: { placeholder: "a placeholder" }) } + end + + context "adding multiple html options" do + When(:field) { described_class.new(:field_name, placeholder: "a placeholder", size: "10") } + + Then { result.has_tag?("label", with: { placeholder: "a placeholder", size: "10" }) } + end + + context "numeric html option" do + When(:field) { described_class.new(:field_name, size: 20) } + + Then { result.has_tag?("label", with: { size: "20" }) } + end + end + end +end diff --git a/spec/framework/web/forms_spec.rb b/spec/framework/web/forms_spec.rb new file mode 100644 index 0000000..a2ad6e3 --- /dev/null +++ b/spec/framework/web/forms_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +RSpec.describe Forms::Form do + Given(:form_class) { + Class.new { + include Phlex::Renderable + include Forms::Form + } + } + + Given(:result) { + HTML( + form.call + ) + } + + describe "empty form" do + When(:form) { form_class.new } + + Then { result.has_tag?("form") } + end + + describe "form with content" do + When(:form_class) { + Class.new { + include Phlex::Renderable + include Forms::Form + + def template + h1 { "form content" } + end + } + } + When(:form) { form_class.new } + + Then { result.has_tag?("form > h1", text: "form content") } + end + + describe "basic attributes" do + shared_context "method" do |tested_method| + When(:form) { form_class.new(method: method) } + + context "when the method is #{tested_method.upcase}" do + Given(:method) { tested_method } + + describe "rendering the form" do + Then { result.has_tag?("form", with: { method: tested_method }) } + end + + describe "asserting http methods" do + context "when the method is lowercase <#{tested_method}>" do + Then { form.http_method?(tested_method) == true } + And { form.http_method?(tested_method.upcase) == true } + + ( + Forms::Form.http_methods - + [tested_method] + ).each do |potential_method| + And { form.http_method?(potential_method) == false } + end + end + + context "when the method is uppercase <#{tested_method}>" do + Given(:method) { tested_method.upcase } + + Then { form.http_method?(tested_method) == true } + And { form.http_method?(tested_method.upcase) == true } + + ( + Forms::Form.http_methods - + [tested_method] + ).each do |potential_method| + And { form.http_method?(potential_method) == false } + end + end + end + end + end + + describe "http methods" do + context "when the http method is not specified" do + When(:form) { form_class.new } + + Then { form.http_method?("post") == true } + end + + include_context "method", "get" + include_context "method", "post" + include_context "method", "put" + include_context "method", "head" + include_context "method", "delete" + include_context "method", "patch" + include_context "method", "options" + include_context "method", "connect" + include_context "method", "trace" + + context "non-existent method" do + When(:form) { form_class.new(method: "other") } + + Then { form == Failure(Forms::Form::InvalidHttpMethod, Forms::Form::INVALID_HTTP_METHOD) } + end + end + + describe "html options" do + context "when passing one html option" do + Given(:action) { "/my_action" } + + When(:form) { form_class.new(action: action) } + + Then { result.has_tag?("form", with: { action: action }) } + end + + context "when passing multiple html options" do + Given(:target) { "_blank" } + Given(:autocomplete) { :on } + + When(:form) { + form_class.new( + target: target, + autocomplete: autocomplete, + ) + } + + Then { + result.has_tag?("form", with: { + target: target, + autocomplete: autocomplete.to_s, + }) + } + end + end + + describe "form class" do + When(:form) { form_class.new(class: css_class) } + + context "when giving it a simple html class" do + Given(:css_class) { "a_class" } + + Then { result.has_tag?("form", with: { class: css_class }) } + end + + context "when giving it multiple html classes as a string" do + Given(:css_class) { "a_class other_class" } + + Then { result.has_tag?("form", with: { class: css_class }) } + end + + context "when giving it multiple html classes as an array of strings" do + Given(:css_class) { %w[a_class other_class] } + + Then { result.has_tag?("form", with: { class: css_class.join(" ") }) } + end + + describe "default css class" do + Given(:form_class) { + Class.new { + include Phlex::Renderable + include Forms::Form + + def default_classes + "my-default-class" + end + } + } + + context "when NOT specifying a class in the initializer" do + When(:form) { form_class.new } + + Then { result.has_tag?("form", with: { class: "my-default-class" }) } + end + + context "when specifying a class in the initializer" do + When(:form) { form_class.new(class: "my-class") } + + Then { result.has_tag?("form", with: { class: "my-default-class my-class" }) } + end + end + end + end + + describe "form name" do + Given(:form) { form_class.new(name: "my-form") } + + Then { form.named?("my-form") == true } + And { form.named?("not-my-form") == false } + Then { result.has_tag?("form", with: { name: "my-form" }) == false } + end + + pending_context "different field types" + + pending_context "form id for model-backed forms" + + pending_context "default action for form should be \"?\"" +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bf50042..c65fe9d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ ENV["ENVIRONMENT_NAME"] = "test" require "./boot" +require "rspec-given" RSpec.configure do |config| config.expect_with :rspec do |expectations| @@ -16,6 +17,6 @@ require "rake" -FileList["./spec/support/**/*.rb"].each do |file| +Dir.glob("./spec/support/**/*.rb").each do |file| require file end diff --git a/spec/support/expect_example_to_have_assertions.rb b/spec/support/expect_example_to_have_assertions.rb deleted file mode 100644 index ba59b94..0000000 --- a/spec/support/expect_example_to_have_assertions.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -RSpec.configure do |config| - config.include( - Module.new do - attr_accessor :assertions_have_been_run - - def expect(*) - self.assertions_have_been_run = true - super - end - end - ) - - config.after do |example| - unless assertions_have_been_run || example.exception - no_assertions_error = RuntimeError.new("No assertion run in example '#{example.description}'") - no_assertions_error.set_backtrace([example.location]) - raise no_assertions_error - end - end -end diff --git a/spec/support/html_specs.rb b/spec/support/html_specs.rb new file mode 100644 index 0000000..a606bec --- /dev/null +++ b/spec/support/html_specs.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "nokogiri" +require "rspec-html-matchers" + +def HTML(html_string) + Nokogiri::HTML(html_string) +end + +module HtmlMatchers + def has_tag?(tagname, text: nil, with: {}) + nodes = css(tagname) + + return false unless node_exists?(nodes) + + nodes.any? { |node| + node_has_text?(node, text) && + node_has_attributes?(node, with) + } + end + + def empty_element? + children.empty? + end + + def find_tag(tagname) + css(tagname).first + end + + def find_tags(tagname) + css(tagname) + end + + private + + def node_has_text?(node, text) + return true unless text + + node.text == text + end + + def node_has_attributes?(node, attributes) + attributes.all? { |attr, value| + node[attr.to_s] == value + } + end + + def node_exists?(nodes) + !nodes.empty? + end +end + +[ + Nokogiri::XML::Element, + Nokogiri::XML::Document, +].each do |node_type| + node_type.include(HtmlMatchers) +end diff --git a/spec/support/pending_specs.rb b/spec/support/pending_specs.rb new file mode 100644 index 0000000..2a76e1e --- /dev/null +++ b/spec/support/pending_specs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +def pending_spec(description) + called_from = caller.first.gsub(/#{ROOT}/, ".") + skip(description + "\n # #{called_from}") +end + +alias pending pending_spec +alias pending_context pending_spec +alias pcontext pending_spec +alias pending_describe pending_spec +alias pdescribe pending_spec diff --git a/spec/support/request_specs.rb b/spec/support/request_specs.rb new file mode 100644 index 0000000..11b075e --- /dev/null +++ b/spec/support/request_specs.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.around do |example| + if %i[request web].include?(RSpec.current_example.metadata[:type]) + require "capybara/rspec" + + config.include Capybara::DSL + + Capybara.app = WebApp + Capybara.default_driver = :rack_test + Capybara.server = :puma, { Silent: true } + + if RSpec.current_example.metadata.has_key?(:js) + require "capybara/cuprite" + Capybara.default_driver = :cuprite + Capybara.javascript_driver = :cuprite + Capybara.register_driver(:cuprite) do |app| + Capybara::Cuprite::Driver.new( + app, + # inspector: true, + window_size: [1200, 800], + browser_options: { "no-sandbox": nil }, + timeout: 30, + ) + end + end + end + + example.run + end +end diff --git a/web/forms/errors.rb b/web/forms/errors.rb new file mode 100644 index 0000000..5a459b6 --- /dev/null +++ b/web/forms/errors.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Forms + class Errors + include Phlex::HtmlRenderable + + def self.for(field_name, + errors: [], + errors_css_classes: [], + error_css_classes: []) + subclasses.find { |subclass| + subclass.for?(errors) + }.new( + field_name: field_name, errors: errors, + errors_css_classes: errors_css_classes, + error_css_classes: error_css_classes, + ) + end + + private + + attr_reader :errors + attr_reader :errors_css_classes + attr_reader :error_css_classes + + def initialize( + field_name:, errors:, + errors_css_classes:, + error_css_classes: + ) + @errors = Array(errors) + @errors_css_classes = Array(errors_css_classes) + @error_css_classes = Array(error_css_classes) + end + end +end diff --git a/web/forms/errors/empty.rb b/web/forms/errors/empty.rb new file mode 100644 index 0000000..81f75fc --- /dev/null +++ b/web/forms/errors/empty.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Forms + class Errors + class Empty < Forms::Errors + def self.for?(errors) + Array(errors).empty? + end + + def template; end + end + end +end diff --git a/web/forms/errors/populated.rb b/web/forms/errors/populated.rb new file mode 100644 index 0000000..1372df9 --- /dev/null +++ b/web/forms/errors/populated.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Forms + class Errors + class Populated < Forms::Errors + def self.for?(errors) + Array(errors).any? + end + + def template + div(class: errors_css_classes) { + errors.each do |error| + span(class: error_css_classes) { error } + end + } + end + end + end +end diff --git a/web/forms/example_form.rb b/web/forms/example_form.rb new file mode 100644 index 0000000..5b27ab1 --- /dev/null +++ b/web/forms/example_form.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Forms + class ExampleForm + include Phlex::HtmlRenderable + include Forms::Form + + def template + h1(class: "text-slate-800 font-semibold text-xl") { "HTML Form Example" } + + field_wrapper do + label(:username) + text_field(:username, value: "snth") + end + + field_wrapper do + label(:color_field) + color_field(:color_field) + end + + field_wrapper do + label(:date_field) + date_field(:date_field) + end + + field_wrapper do + label(:datetime_local_field) + datetime_local_field(:datetime_local_field) + end + + field_wrapper do + label(:email_field) + email_field(:email_field) + end + + field_wrapper do + label(:hidden_field) + hidden_field(:hidden_field) + end + + field_wrapper do + label(:label) + label(:label) + end + + field_wrapper do + label(:number_field) + number_field(:number_field) + end + + field_wrapper do + label(:password_field) + password_field(:password_field) + end + + field_wrapper do + label(:search_field) + search_field(:search_field) + end + + field_wrapper do + label(:select) + select(:select, options: [ + %w[text value] + ]) + end + + field_wrapper do + label(:text_field) + text_field(:text_field) + end + + input(type: "submit", class: "py-1 px-2 text-slate-50 bg-slate-800 border border-1 border-slate-800 hover:bg-slate-700 rounded") { "send" } + end + + private + + def field_wrapper(&) + div(class: "flex items-center gap-1", &) + end + + def default_classes + %w[p-4 m-2 bg-slate-50 border border-2 border-slate-300 rounded-lg] + end + end +end diff --git a/web/forms/fields/color_field.rb b/web/forms/fields/color_field.rb new file mode 100644 index 0000000..5624e39 --- /dev/null +++ b/web/forms/fields/color_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class ColorField < Field + include Phlex::HtmlRenderable + + private + + def type + "color" + end + end + end +end diff --git a/web/forms/fields/date_field.rb b/web/forms/fields/date_field.rb new file mode 100644 index 0000000..ca66e99 --- /dev/null +++ b/web/forms/fields/date_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class DateField < Field + include Phlex::HtmlRenderable + + private + + def type + "date" + end + end + end +end diff --git a/web/forms/fields/datetime_local_field.rb b/web/forms/fields/datetime_local_field.rb new file mode 100644 index 0000000..f038a3f --- /dev/null +++ b/web/forms/fields/datetime_local_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class DatetimeLocalField < Field + include Phlex::HtmlRenderable + + private + + def type + "datetime-local" + end + end + end +end diff --git a/web/forms/fields/email_field.rb b/web/forms/fields/email_field.rb new file mode 100644 index 0000000..54dbb07 --- /dev/null +++ b/web/forms/fields/email_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class EmailField < Field + include Phlex::HtmlRenderable + + private + + def type + "email" + end + end + end +end diff --git a/web/forms/fields/field.rb b/web/forms/fields/field.rb new file mode 100644 index 0000000..1faee71 --- /dev/null +++ b/web/forms/fields/field.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Forms + module Fields + class Field + include Phlex::HtmlRenderable + + def initialize(name, value: nil, type: nil, **html_options) + @name = name + @value = value + @type = type + @css_classes = build_css_classes(html_options.delete(:class)) + @html_options = html_options + end + + def template + input( + name: name, + value: value, + type: type, + class: css_classes, + **html_options + ) + end + + private + + attr_reader :name + attr_reader :value + attr_reader :html_options + attr_reader :type + + def css_classes + default_css_classes + @css_classes + end + + def build_css_classes(classes) + Array(classes) + end + + def default_css_classes + %w[p-1 text-gray-800 placeholder:text-gray-400 border rounded] + end + end + end +end diff --git a/web/forms/fields/hidden_field.rb b/web/forms/fields/hidden_field.rb new file mode 100644 index 0000000..66251f8 --- /dev/null +++ b/web/forms/fields/hidden_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class HiddenField < Field + include Phlex::HtmlRenderable + + private + + def type + "hidden" + end + end + end +end diff --git a/web/forms/fields/label.rb b/web/forms/fields/label.rb new file mode 100644 index 0000000..552c5b1 --- /dev/null +++ b/web/forms/fields/label.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Forms + module Fields + class Label + include Phlex::HtmlRenderable + + def initialize(for_field, text: for_field, **html_options) + @for_field = for_field + @text = text + @css_classes = build_css_classes(html_options.delete(:class)) + @html_options = html_options + end + + def template + label( + for: for_field, + class: css_classes, + **html_options + ) { text } + end + + private + + attr_reader :for_field + attr_reader :text + attr_reader :html_options + + def css_classes + default_css_classes + @css_classes + end + + def build_css_classes(classes) + Array(classes) + end + + def default_css_classes + %w[] + end + end + end +end diff --git a/web/forms/fields/number_field.rb b/web/forms/fields/number_field.rb new file mode 100644 index 0000000..f9a9272 --- /dev/null +++ b/web/forms/fields/number_field.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Forms + module Fields + class NumberField < Field + include Phlex::HtmlRenderable + + private + + def type + "number" + end + + def default_options + { inputmode: "numeric" } + end + end + end +end diff --git a/web/forms/fields/password_field.rb b/web/forms/fields/password_field.rb new file mode 100644 index 0000000..83a82c3 --- /dev/null +++ b/web/forms/fields/password_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class PasswordField < Field + include Phlex::HtmlRenderable + + private + + def type + "password" + end + end + end +end diff --git a/web/forms/fields/search_field.rb b/web/forms/fields/search_field.rb new file mode 100644 index 0000000..bfe6d94 --- /dev/null +++ b/web/forms/fields/search_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class SearchField < Field + include Phlex::HtmlRenderable + + private + + def type + "search" + end + end + end +end diff --git a/web/forms/fields/select.rb b/web/forms/fields/select.rb new file mode 100644 index 0000000..5cd5013 --- /dev/null +++ b/web/forms/fields/select.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Forms + module Fields + class Select < Field + # TODO: I want to use this like: + # select(:field_name) do |select| + # select.option(text: "do", value: "yes", default: true) + # select.option(text: "don't", value: "no") + # end + # For this I need to create Forms::Fields::Option and the Forms::Fields::Select#option method + + include Phlex::HtmlRenderable + + def initialize(name, options: [], **html_options) + @options = Array(options) + super(name, **html_options) + end + + def template + select(value: value, name: name, class: css_classes, **html_options) do + options.each do |option_text, option_value| + option(value: option_value) { option_text } + end + end + end + + private + + attr_reader :options + + def type + "number" + end + + def default_options + { inputmode: "numeric" } + end + end + end +end diff --git a/web/forms/fields/text_field.rb b/web/forms/fields/text_field.rb new file mode 100644 index 0000000..cf10ecc --- /dev/null +++ b/web/forms/fields/text_field.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Forms + module Fields + class TextField < Field + include Phlex::HtmlRenderable + + private + + def type + "text" + end + end + end +end diff --git a/web/forms/form.rb b/web/forms/form.rb new file mode 100644 index 0000000..2a0cd61 --- /dev/null +++ b/web/forms/form.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module Forms + module Form + include Phlex::HtmlRenderable + + def initialize(name: "", method: "post", errors: {}, **html_options) + @http_method = build_http_method(method) + @css_class = build_css_class(html_options.delete(:class)) + @name = name + @errors = errors + @html_options = html_options.transform_values(&:to_s) + end + + def around_template(&) + form( + method: http_method, + class: css_class, + **html_options, & + ) + end + + def template(*); end + + def http_method?(potential_http_method) + http_method == potential_http_method.downcase + end + + def self.included(including_class) + including_class.extend(ClassMethods) + end + + def named?(potential_name) + name == potential_name + end + + module ClassMethods + def http_methods + %w[get post delete patch put head options connect trace] + end + end + extend ClassMethods + + private + + attr_reader :http_method + attr_reader :html_options + attr_reader :css_class + attr_reader :name + attr_reader :errors + + def build_http_method(an_http_method) + downcased_http_method = an_http_method.downcase + raise InvalidHttpMethod, INVALID_HTTP_METHOD unless http_methods.include?(downcased_http_method) + + downcased_http_method + end + + def build_css_class(some_css_classes) + [].concat(arrayify_css_classes(some_css_classes)) + end + + def arrayify_css_classes(some_css_classes) + ( + Array(default_classes) + + Array(some_css_classes) + ).join(" ").split + end + + def http_methods = self.class.http_methods + def default_classes = [] + + def color_field(...) + render Forms::Fields::ColorField.new(...) + end + + def date_field(...) + render Forms::Fields::DateField.new(...) + end + + def datetime_local_field(...) + render Forms::Fields::DatetimeLocalField.new(...) + end + + def email_field(...) + render Forms::Fields::EmailField.new(...) + end + + def field(...) + render Forms::Fields.field.new(...) + end + + def hidden_field(...) + render Forms::Fields::HiddenField.new(...) + end + + def label(...) + render Forms::Fields::Label.new(...) + end + + def number_field(...) + render Forms::Fields::NumberField.new(...) + end + + def password_field(...) + render Forms::Fields::PasswordField.new(...) + end + + def search_field(...) + render Forms::Fields::SearchField.new(...) + end + + def select(...) + render Forms::Fields::Select.new(...) + end + + def text_field(...) + render Forms::Fields::TextField.new(...) + end + + def errors_for(field_name) + render Forms::Errors.for( + field_name, + errors: errors[field_name], + errors_css_classes: errors_css_classes, + error_css_classes: error_css_classes, + ) + end + + def errors_css_classes + [] + end + + def error_css_classes + [] + end + + def field_wrapper(&) + div(&) + end + + InvalidHttpMethod = Class.new(RuntimeError) + INVALID_HTTP_METHOD = "Invalid HTTP Method" + end +end diff --git a/web/forms/form.txt b/web/forms/form.txt new file mode 100644 index 0000000..57e741e --- /dev/null +++ b/web/forms/form.txt @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +# require "dry/inflector" + +# # TODO: implement methods and actions + +module Forms + module Form + def initialize(method: "post") + @http_method = method + end + + def around_template(&) + form( + method: http_method, + class: css_class, + action: action, + **html_options, + ) {} + end + + def http_method?(potential_method) + self.class.http_method?(potential_method) + end + + def self.included(including_class) + including_class.extend(ClassMethods) + end + + module ClassMethods + def action(an_action) + @action = an_action + end + + def css_class(a_css_class) + @css_class = a_css_class + end + + def http_method(an_html_method) + # # TODO: extract HttpMethod hierarchy + @http_method = http_methods.find( + proc { + raise InvalidHttpMethod, INVALID_HTTP_METHOD + } + ) { |http_method| + an_html_method.downcase == http_method + } + end + + def html_options(**some_html_options) + @html_options = some_html_options + end + + def http_methods + %w[get post delete patch put head options connect trace] + end + + def http_method?(potential_method) + @http_method == potential_method.downcase + end + + def http_method_option + @http_method || "post" + end + + def action_option + @action + end + + def css_class_option + @css_class + end + + def html_options_option + @html_options || {} + end + end + + private + + def http_method + @http_method + end + + def action + self.class.action_option + end + + def css_class + self.class.css_class_option + end + + def html_options + self.class.html_options_option + end + + InvalidHttpMethod = Class.new(RuntimeError) + INVALID_HTTP_METHOD = "Invalid HTTP Method" + end +end + +# module Forms +# module Form +# include Phlex::HtmlRenderable + +# def self.included(including_class) +# super +# including_class.extend(ClassMethods) +# end + +# def around_template(&) +# form( +# id: form_id, +# class: css_class, +# **html_options, & +# ) +# end + +# module ClassMethods +# end + +# def initialize(**html_options) +# super + +# @css_class = build_class(html_options.delete(:class)) +# @html_options = default_html_options.merge(html_options) +# end + +# private + +# attr_reader :html_options +# attr_reader :css_class + +# def build_class(classes) +# default_css_class.concat( +# arrayfy_classes(classes) +# ) +# end + +# def default_css_class +# %w[] +# end + +# def arrayfy_classes(classes) +# Array(classes).join(" ").split(" ") +# end + +# def default_html_options +# {} +# end + +# def default_html_options +# { +# autocomplete: true, +# action: "/", +# method: "POST", +# } +# end + +# def form_id +# inflector = Dry::Inflector.new + +# full_class_path = inflector.underscore(self.class.to_s) +# form_subpath = full_class_path.gsub(%r{\Aforms/}, "") + +# inflector.dasherize(form_subpath + "-#{model_id}") +# end + +# def model_id +# id +# end + +# def text_field(...) +# render Forms::Fields::TextField.new(...) +# end + +# def hidden_field(...) +# render Forms::Fields::HiddenField.new(...) +# end + +# def password_field(...) +# render Forms::Fields::PasswordField.new(...) +# end + +# def number_field(...) +# render Forms::Fields::NumberField.new(...) +# end + +# def email_field(...) +# render Forms::Fields::EmailField.new(...) +# end + +# def search_field(...) +# render Forms::Fields::SearchField.new(...) +# end + +# def date_field(...) +# render Forms::Fields::DateField.new(...) +# end + +# def datetime_local_field(...) +# render Forms::Fields::DatetimeLocalField.new(...) +# end + +# def color_field(...) +# render Forms::Fields::ColorField.new(...) +# end + +# def select(...) +# render Forms::Fields::Select.new(...) +# end + +# def errors_for(field_name) +# raise NotImplementedError, "Implement 'errors_for'" +# end + +# # TODO: implement types +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# # +# end +# end diff --git a/web/views/home_page.rb b/web/views/home_page.rb index 7b344eb..fe1d9ef 100644 --- a/web/views/home_page.rb +++ b/web/views/home_page.rb @@ -11,6 +11,8 @@ def template p(class: "mx-3 text-slate-600") { "On file #{__FILE__}" } p(class: "mx-3 text-slate-600") { "Current time #{Time.now}" } + + render Forms::ExampleForm.new end end end