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