" }
14 | end
15 |
16 | it_behaves_like "component with custom html classes"
17 | it_behaves_like "component with custom data attributes"
18 | it_behaves_like "component with custom value"
19 | end
20 |
--------------------------------------------------------------------------------
/spec/view_component/form/range_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::RangeFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :size, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/spec/view_component/form/text_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TextFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :email, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/gemfiles/rails_head_vc_3.0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "generator_spec"
6 | gem "rails", github: "rails/rails", branch: "main"
7 | gem "rake", "~> 13.0"
8 | gem "sqlite3", "~> 2.1", group: :test
9 | gem "concurrent-ruby", "= 1.3.4"
10 | gem "view_component", ">= 3.0.0", "< 4.0"
11 |
12 | group :development, :test do
13 | gem "appraisal", "~> 2"
14 | gem "appraisal-run", "~> 1.0"
15 | gem "capybara", require: false
16 | gem "combustion", "~> 1.3.7"
17 | gem "rspec", "~> 3.0", require: false
18 | gem "rspec-html-matchers"
19 | gem "rspec-rails", require: false
20 | gem "rubocop", require: false
21 | gem "rubocop-performance", require: false
22 | gem "rubocop-rspec", require: false
23 | gem "simplecov", require: false, group: :test
24 | end
25 |
26 | gemspec path: "../"
27 |
--------------------------------------------------------------------------------
/gemfiles/rails_head_vc_4.0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "generator_spec"
6 | gem "rails", github: "rails/rails", branch: "main"
7 | gem "rake", "~> 13.0"
8 | gem "sqlite3", "~> 2.1", group: :test
9 | gem "concurrent-ruby", "= 1.3.4"
10 | gem "view_component", ">= 4.0.0", "< 5.0"
11 |
12 | group :development, :test do
13 | gem "appraisal", "~> 2"
14 | gem "appraisal-run", "~> 1.0"
15 | gem "capybara", require: false
16 | gem "combustion", "~> 1.3.7"
17 | gem "rspec", "~> 3.0", require: false
18 | gem "rspec-html-matchers"
19 | gem "rspec-rails", require: false
20 | gem "rubocop", require: false
21 | gem "rubocop-performance", require: false
22 | gem "rubocop-rspec", require: false
23 | gem "simplecov", require: false, group: :test
24 | end
25 |
26 | gemspec path: "../"
27 |
--------------------------------------------------------------------------------
/spec/view_component/form/search_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::SearchFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :query, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/spec/view_component/form/url_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::UrlFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :wiki_url, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/app/components/view_component/form/check_box_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class CheckBoxComponent < FieldComponent
6 | attr_reader :checked_value, :unchecked_value
7 |
8 | def initialize(form, object_name, method_name, checked_value, unchecked_value, options = {}) # rubocop:disable Metrics/ParameterLists
9 | @checked_value = checked_value
10 | @unchecked_value = unchecked_value
11 |
12 | super(form, object_name, method_name, options)
13 | end
14 |
15 | def call
16 | ActionView::Helpers::Tags::CheckBox.new(
17 | object_name,
18 | method_name,
19 | @view_context,
20 | checked_value,
21 | unchecked_value,
22 | options
23 | ).render
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/spec/view_component/form/number_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::NumberFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :shoe_size, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/bin/release:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | VERSION=$1
4 |
5 | if [[ -z "$VERSION" ]]; then
6 | echo "Version should be specified. Example: bin/release 1.0.0"
7 | exit 1
8 | fi
9 |
10 | printf "# frozen_string_literal: true\n\nmodule ViewComponent\n module Form\n VERSION = \"$VERSION\"\n end\nend\n" > ./lib/view_component/form/version.rb
11 | bundle
12 | $EDITOR CHANGELOG.md
13 | git add Gemfile.lock lib/view_component/form/version.rb CHANGELOG.md
14 | git commit -m "Bump version for $VERSION"
15 |
16 | read -p "Are you sure you want to publish this release? " -n 1 -r
17 | echo
18 | if [[ ! $REPLY =~ ^[Yy]$ ]]
19 | then
20 | [[ "$0" = "$BASH_SOURCE" ]] && exit 1 || return 1
21 | fi
22 |
23 | git push
24 | git tag v$VERSION
25 | git push --tags
26 | echo "The gem will be pushed to RubyGems automatically by"
27 | echo "Github Actions, using Trusted Publishing."
28 |
--------------------------------------------------------------------------------
/spec/view_component/form/password_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::PasswordFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :password, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/spec/view_component/form/telephone_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TelephoneFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :cellphone, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/spec/view_component/form/email_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::EmailFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :email_address, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | plugins:
2 | - rubocop-performance
3 | - rubocop-rspec
4 |
5 | inherit_from: .rubocop_todo.yml
6 |
7 | AllCops:
8 | SuggestExtensions: false
9 | TargetRubyVersion: 3.2
10 | NewCops: enable
11 | Exclude:
12 | - '.git/**/*'
13 | - 'bin/**/*'
14 | - 'gemfiles/**/*'
15 | - 'lib/generators/**/templates/**/*'
16 | - 'node_modules/**/*'
17 | - 'tmp/**/*'
18 | - 'vendor/**/*'
19 |
20 | Style/StringLiterals:
21 | Enabled: true
22 | EnforcedStyle: double_quotes
23 |
24 | Style/StringLiteralsInInterpolation:
25 | Enabled: true
26 | EnforcedStyle: double_quotes
27 |
28 | Layout/LineLength:
29 | Max: 120
30 |
31 | RSpec/MultipleExpectations:
32 | Max: 2
33 |
34 | RSpec/MultipleMemoizedHelpers:
35 | Max: 7
36 |
37 | Metrics/BlockLength:
38 | Exclude:
39 | - "spec/view_component/**/*"
40 | - "spec/internal/db/schema.rb"
41 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | # Specify your gem's dependencies in view_component-form.gemspec
6 | gemspec
7 |
8 | group :development, :test do
9 | gem "appraisal", "~> 2"
10 | gem "appraisal-run", "~> 1.0"
11 | gem "capybara", require: false
12 | gem "combustion", "~> 1.3.7"
13 | gem "rspec", "~> 3.0", require: false
14 | gem "rspec-html-matchers"
15 | gem "rspec-rails", require: false
16 | gem "rubocop", require: false
17 | gem "rubocop-performance", require: false
18 | gem "rubocop-rspec", require: false
19 | gem "simplecov", require: false, group: :test
20 | end
21 |
22 | gem "generator_spec"
23 | gem "rails"
24 | gem "rake", "~> 13.0"
25 | gem "sqlite3", "~> 2.1", group: :test
26 |
27 | # Temporarilly fi for "uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger" error
28 | gem "concurrent-ruby", "= 1.3.4"
29 |
--------------------------------------------------------------------------------
/spec/view_component/form/color_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::ColorFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :background_color, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 | it_behaves_like "component with custom value"
22 | end
23 |
--------------------------------------------------------------------------------
/bin/rspec:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'rspec' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "pathname"
12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13 | Pathname.new(__FILE__).realpath)
14 |
15 | bundle_binstub = File.expand_path("../bundle", __FILE__)
16 |
17 | if File.file?(bundle_binstub)
18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19 | load(bundle_binstub)
20 | else
21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23 | end
24 | end
25 |
26 | require "rubygems"
27 | require "bundler/setup"
28 |
29 | load Gem.bin_path("rspec-core", "rspec")
30 |
--------------------------------------------------------------------------------
/spec/view_component/form/weekday_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::WeekdaySelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :weekday, options, html_options)) }
10 | let(:component_html_attributes) { component.css("select").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a select for the weekdays" do
14 | expect(component.to_html).to have_tag("select",
15 | with: { id: "user_weekday", name: "user[weekday]" })
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes", :html_options
20 | it_behaves_like "component with custom data attributes", :html_options
21 | end
22 |
--------------------------------------------------------------------------------
/spec/view_component/form/check_box_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::CheckBoxComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :admin, "1", "0", options)) }
9 | let(:component_html_attributes) { component.css("input").last.attributes }
10 |
11 | context "with simple args" do
12 | it { expect(component.to_html).to have_tag("input", with: { type: "hidden", value: "0", name: "user[admin]" }) }
13 |
14 | it do
15 | expect(component.to_html)
16 | .to have_tag("input", with: { type: "checkbox", value: "1", name: "user[admin]", id: "user_admin" })
17 | end
18 | end
19 |
20 | it_behaves_like "component with custom html classes"
21 | it_behaves_like "component with custom data attributes"
22 | end
23 |
--------------------------------------------------------------------------------
/app/components/view_component/form/date_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class DateSelectComponent < FieldComponent
6 | attr_reader :html_options
7 |
8 | def initialize(form, object_name, method_name, options = {}, html_options = {})
9 | @html_options = html_options
10 |
11 | super(form, object_name, method_name, options)
12 |
13 | set_html_options!
14 | end
15 |
16 | def call
17 | ActionView::Helpers::Tags::DateSelect.new(
18 | object_name,
19 | method_name,
20 | @view_context,
21 | options,
22 | html_options
23 | ).render
24 | end
25 |
26 | protected
27 |
28 | def set_html_options!
29 | @html_options[:class] = class_names(html_options[:class], html_class)
30 | @html_options.delete(:class) if @html_options[:class].blank?
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/components/view_component/form/time_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class TimeSelectComponent < FieldComponent
6 | attr_reader :html_options
7 |
8 | def initialize(form, object_name, method_name, options = {}, html_options = {})
9 | @html_options = html_options
10 |
11 | super(form, object_name, method_name, options)
12 |
13 | set_html_options!
14 | end
15 |
16 | def call
17 | ActionView::Helpers::Tags::TimeSelect.new(
18 | object_name,
19 | method_name,
20 | @view_context,
21 | options,
22 | html_options
23 | ).render
24 | end
25 |
26 | protected
27 |
28 | def set_html_options!
29 | @html_options[:class] = class_names(html_options[:class], html_class)
30 | @html_options.delete(:class) if @html_options[:class].blank?
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/spec/internal/db/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ActiveRecord::Schema.define do
4 | # Set up any tables you need to exist for your test suite that don't belong
5 | # in migrations.
6 | create_table(:authors, force: true) do |t|
7 | t.string :name_with_initial
8 | t.timestamps
9 | end
10 |
11 | create_table(:people, force: true) do |t|
12 | t.string :name
13 | t.timestamps
14 | end
15 |
16 | create_table(:hidden_field_tests, force: true) do |t|
17 | t.boolean :pass_confirm
18 | t.string :token
19 | t.string :tag_list
20 | t.timestamps
21 | end
22 |
23 | create_table(:continents, force: true) do |t|
24 | t.string :name
25 | t.timestamps
26 | end
27 |
28 | create_table(:countries, force: true) do |t|
29 | t.string :name
30 | t.belongs_to :continent
31 | t.timestamps
32 | end
33 |
34 | create_table(:cities, force: true) do |t|
35 | t.belongs_to :country
36 | t.timestamps
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/app/components/view_component/form/datetime_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class DatetimeSelectComponent < FieldComponent
6 | attr_reader :html_options
7 |
8 | def initialize(form, object_name, method_name, options = {}, html_options = {})
9 | @html_options = html_options
10 |
11 | super(form, object_name, method_name, options)
12 |
13 | set_html_options!
14 | end
15 |
16 | def call
17 | ActionView::Helpers::Tags::DatetimeSelect.new(
18 | object_name,
19 | method_name,
20 | @view_context,
21 | options,
22 | html_options
23 | ).render
24 | end
25 |
26 | protected
27 |
28 | def set_html_options!
29 | @html_options[:class] = class_names(html_options[:class], html_class)
30 | @html_options.delete(:class) if @html_options[:class].blank?
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/app/components/view_component/form/weekday_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class WeekdaySelectComponent < FieldComponent
6 | attr_reader :html_options
7 |
8 | def initialize(form, object_name, method_name, options = {}, html_options = {})
9 | @html_options = html_options
10 |
11 | super(form, object_name, method_name, options)
12 |
13 | set_html_options!
14 | end
15 |
16 | def call
17 | ActionView::Helpers::Tags::WeekdaySelect.new(
18 | object_name,
19 | method_name,
20 | @view_context,
21 | options,
22 | html_options
23 | ).render
24 | end
25 |
26 | protected
27 |
28 | def set_html_options!
29 | @html_options[:class] = class_names(html_options[:class], html_class)
30 | @html_options.delete(:class) if @html_options[:class].blank?
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | appraise "rails_7.2_vc_3.0" do
4 | gem "rails", "~> 7.2.0"
5 | gem "view_component", ">= 3.0.0", "< 4.0"
6 | gem "sqlite3", "~> 1.4", group: :test
7 | end
8 |
9 | appraise "rails_7.2_vc_4.0" do
10 | gem "rails", "~> 7.2.0"
11 | gem "view_component", ">= 4.0.0", "< 5.0"
12 | gem "sqlite3", "~> 1.4", group: :test
13 | end
14 |
15 | appraise "rails_8.0_vc_3.0" do
16 | gem "rails", "~> 8.0.0"
17 | gem "view_component", ">= 3.0.0", "< 4.0"
18 | end
19 |
20 | appraise "rails_8.0_vc_4.0" do
21 | gem "rails", "~> 8.0.0"
22 | gem "view_component", ">= 4.0.0", "< 5.0"
23 | end
24 |
25 | appraise "rails_8.1_vc_4.0" do
26 | gem "rails", "~> 8.1.0"
27 | gem "view_component", ">= 4.0.0", "< 5.0"
28 | end
29 |
30 | appraise "rails_head_vc_3.0" do
31 | gem "rails", github: "rails/rails", branch: "main"
32 | gem "view_component", ">= 3.0.0", "< 4.0"
33 | end
34 |
35 | appraise "rails_head_vc_4.0" do
36 | gem "rails", github: "rails/rails", branch: "main"
37 | gem "view_component", ">= 4.0.0", "< 5.0"
38 | end
39 |
--------------------------------------------------------------------------------
/spec/view_component/form/time_zone_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TimeZoneSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :time_zone, nil, options, html_options)) }
10 | let(:component_html_attributes) { component.css("select").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a select" do
14 | expect(component.to_html).to have_tag("select",
15 | with: { id: "user_time_zone", name: "user[time_zone]" }) do
16 | with_tag "option", with: { value: "Warsaw" }
17 | end
18 | end
19 | end
20 |
21 | it_behaves_like "component with custom html classes", :html_options
22 | it_behaves_like "component with custom data attributes", :html_options
23 | end
24 |
--------------------------------------------------------------------------------
/lib/generators/vcf/builder/templates/builder.rb.erb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class <%= class_name %> < ViewComponent::Form::Builder
4 | # Instead of inheriting from ViewComponent::Form::Builder,
5 | # you can also inherit from ActionView::Helpers::FormBuilder
6 | # then include only the modules you need:
7 |
8 | # Provides `render_component` method and namespace management
9 | # include ViewComponent::Form::Renderer
10 |
11 | # Exposes a `validation_context` to your components
12 | # include ViewComponent::Form::ValidationContext
13 |
14 | # All standard Rails form helpers
15 | # include ViewComponent::Form::Helpers::Rails
16 |
17 | # Adds support for Rails 8
18 | # include ViewComponent::Form::Helpers::Rails8
19 |
20 | # Additional form helpers provided by ViewComponent::Form
21 | # include ViewComponent::Form::Helpers::Custom
22 |
23 | # Set the namespace you want to use for your own components
24 | # requires inheriting from ViewComponent::Form::Builder
25 | # or including ViewComponent::Form::Renderer
26 | namespace "<%= components_namespace %>"
27 | end
28 |
--------------------------------------------------------------------------------
/app/components/view_component/form/select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class SelectComponent < FieldComponent
6 | attr_reader :choices, :html_options
7 |
8 | def initialize(form, object_name, method_name, choices = nil, options = {}, html_options = {}) # rubocop:disable Metrics/ParameterLists
9 | @choices = choices
10 | @html_options = html_options
11 |
12 | super(form, object_name, method_name, options)
13 |
14 | set_html_options!
15 | end
16 |
17 | def call
18 | ActionView::Helpers::Tags::Select.new(
19 | object_name,
20 | method_name,
21 | @view_context,
22 | choices,
23 | options,
24 | html_options,
25 | &content
26 | ).render
27 | end
28 |
29 | protected
30 |
31 | def set_html_options!
32 | @html_options[:class] = class_names(html_options[:class], html_class)
33 | @html_options.delete(:class) if @html_options[:class].blank?
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/spec/view_component/form/submit_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::SubmitComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:value) { "" }
8 |
9 | let(:component) { render_inline(described_class.new(form, value, options)) }
10 | let(:component_html_attributes) { component.css("input").first.attributes }
11 |
12 | context "with simple args" do
13 | it do
14 | expect(component).to eq_html <<~HTML
15 |
16 | HTML
17 | end
18 | end
19 |
20 | context "with value" do
21 | let(:value) { "Save" }
22 |
23 | it do
24 | expect(component).to eq_html <<~HTML
25 |
26 | HTML
27 | end
28 | end
29 |
30 | it_behaves_like "component with custom html classes"
31 | it_behaves_like "component with custom data attributes"
32 | it_behaves_like "component with custom value"
33 | end
34 |
--------------------------------------------------------------------------------
/app/components/view_component/form/time_zone_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class TimeZoneSelectComponent < FieldComponent
6 | attr_reader :priority_zones, :html_options
7 |
8 | def initialize(form, object_name, method_name, priority_zones, options = {}, html_options = {}) # rubocop:disable Metrics/ParameterLists
9 | @priority_zones = priority_zones
10 | @html_options = html_options
11 |
12 | super(form, object_name, method_name, options)
13 |
14 | set_html_options!
15 | end
16 |
17 | def call
18 | ActionView::Helpers::Tags::TimeZoneSelect.new(
19 | object_name,
20 | method_name,
21 | @view_context,
22 | priority_zones,
23 | options,
24 | html_options
25 | ).render
26 | end
27 |
28 | protected
29 |
30 | def set_html_options!
31 | @html_options[:class] = class_names(html_options[:class], html_class)
32 | @html_options.delete(:class) if @html_options[:class].blank?
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Pantographe
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/spec/view_component/form/rich_text_area_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if defined?(ActionView::Helpers::Tags::ActionText)
4 | RSpec.describe ViewComponent::Form::RichTextAreaComponent, type: :component do
5 | let(:object) { OpenStruct.new }
6 | let(:form) { form_with(object) }
7 | let(:options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :bio, options)) }
10 | let(:component_html_attributes) { component.css("trix-editor").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a hidden field", :aggregate_failures do
14 | expect(component.to_html).to have_tag("input",
15 | with: { type: "hidden", id: "trix_input_1", name: "user[bio]" })
16 | expect(component.to_html).to have_tag("trix-editor",
17 | with: { id: "user_bio", input: "trix_input_1", class: "trix-content" })
18 | end
19 | end
20 |
21 | it_behaves_like "component with custom html classes"
22 | it_behaves_like "component with custom data attributes"
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/spec/view_component/form/date_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::DateFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :birth_date, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 |
22 | if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("7.1.0.alpha")
23 | it_behaves_like "component with custom value"
24 | else
25 | context "with custom value" do
26 | let(:options) { { value: DateTime.new(2013, 6, 29) } }
27 |
28 | it { expect(component_html_attributes["value"].to_s).to eq("2013-06-29") }
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/view_component/form/month_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::MonthFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :holiday, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 |
22 | if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("7.1.0.alpha")
23 | it_behaves_like "component with custom value"
24 | else
25 | context "with custom value" do
26 | let(:options) { { value: DateTime.new(2004, 6, 15, 1, 2, 3) } }
27 |
28 | it { expect(component_html_attributes["value"].to_s).to eq("2004-06") }
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/view_component/form/week_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::WeekFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :holiday, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 |
22 | if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("7.1.0.alpha")
23 | it_behaves_like "component with custom value"
24 | else
25 | context "with custom value" do
26 | let(:options) { { value: DateTime.new(2004, 6, 15, 1, 2, 3) } }
27 |
28 | it { expect(component_html_attributes["value"].to_s).to eq("2004-W25") }
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/view_component/form/button_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::ButtonComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:value) { "Send" }
8 | let(:block) { nil }
9 |
10 | let(:component) { render_inline(described_class.new(form, value, options), &block) }
11 | let(:component_html_attributes) { component.css("button").first.attributes }
12 |
13 | context "with simple args" do
14 | it do
15 | expect(component).to eq_html <<~HTML
16 |
17 | HTML
18 | end
19 | end
20 |
21 | context "with a block" do
22 | let(:block) do
23 | proc do
24 | "Send now!".html_safe
25 | end
26 | end
27 |
28 | it do
29 | expect(component).to eq_html <<~HTML
30 |
31 | HTML
32 | end
33 | end
34 |
35 | it_behaves_like "component with custom html classes"
36 | it_behaves_like "component with custom data attributes"
37 | end
38 |
--------------------------------------------------------------------------------
/spec/view_component/form/time_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TimeSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :wakes_up_at, options, html_options)) }
10 | let(:component_html_attributes) { component.css("select").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a select for the hours" do
14 | expect(component.to_html).to have_tag("select",
15 | with: { id: "user_wakes_up_at_4i", name: "user[wakes_up_at(4i)]" })
16 | end
17 |
18 | it "has a select for the minutes" do
19 | expect(component.to_html).to have_tag("select",
20 | with: { id: "user_wakes_up_at_5i", name: "user[wakes_up_at(5i)]" })
21 | end
22 | end
23 |
24 | it_behaves_like "component with custom html classes", :html_options
25 | it_behaves_like "component with custom data attributes", :html_options
26 | end
27 |
--------------------------------------------------------------------------------
/spec/view_component/form/time_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TimeFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :alarm_clock, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | it_behaves_like "component with custom html classes"
20 | it_behaves_like "component with custom data attributes"
21 |
22 | if Gem::Version.new(Rails::VERSION::STRING) < Gem::Version.new("7.1.0.alpha")
23 | it_behaves_like "component with custom value"
24 | else
25 | context "with custom value" do
26 | let(:options) { { value: DateTime.new(2004, 6, 15, 1, 2, 3) } }
27 |
28 | it { expect(component_html_attributes["value"].to_s).to eq("01:02:03.000") }
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/spec/view_component/form/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::Configuration do
4 | subject(:configuration) { described_class.new }
5 |
6 | describe "defaults" do
7 | it do
8 | expect(configuration).to have_attributes(parent_component: "ViewComponent::Base")
9 | end
10 |
11 | describe "#lookup_chain" do
12 | subject(:lookup_chain) { described_class.new.lookup_chain }
13 |
14 | it "by default implements one lookup lambda" do
15 | expect(lookup_chain.length).to be(1)
16 | end
17 |
18 | it "uses Component suffix" do
19 | expect(
20 | lookup_chain.first.call(:text_field, namespaces: [ViewComponent::Form])
21 | ).to be(ViewComponent::Form::TextFieldComponent)
22 | end
23 |
24 | it "finds the first klass that exists when given a list of namespaces" do
25 | expect(
26 | lookup_chain.first.call(
27 | :text_field,
28 | namespaces: [
29 | Form,
30 | ViewComponent::Form
31 | ]
32 | )
33 | ).to be(Form::TextFieldComponent)
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/spec/support/matchers/eq_html.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ComponentMatchers
4 | class EqHtml
5 | attr_reader :actual, :expected
6 |
7 | def initialize(expected)
8 | @expected = expected
9 | @actual = nil
10 | @invalid_response = nil
11 | end
12 |
13 | def matches?(html_fragment)
14 | html_fragment = html_fragment.to_html if html_fragment.is_a? Nokogiri::HTML::DocumentFragment
15 |
16 | @actual = html_fragment
17 |
18 | @actual == expected_formatted
19 | end
20 |
21 | def failure_message
22 | "expected: #{actual}\n\n got: #{expected}"
23 | end
24 |
25 | def failure_message_when_negated
26 | "expected: value != #{actual}\n\n got: #{expected}"
27 | end
28 |
29 | def description
30 | "have HTML containing #{expected_formatted}"
31 | end
32 |
33 | def diffable?
34 | true
35 | end
36 |
37 | private
38 |
39 | def expected_formatted
40 | expected.chomp
41 | end
42 | end
43 |
44 | def eq_html(html)
45 | EqHtml.new(html)
46 | end
47 | end
48 |
49 | RSpec.configure do |config|
50 | config.include ComponentMatchers, type: :component
51 | end
52 |
--------------------------------------------------------------------------------
/app/components/view_component/form/hint_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class HintComponent < FieldComponent
6 | class_attribute :tag, instance_reader: false, instance_writer: false, instance_accessor: false,
7 | instance_predicate: false
8 | attr_reader :attribute_content
9 |
10 | self.tag = :div
11 |
12 | def initialize(form, object_name, method_name, content_or_options = nil, options = nil)
13 | options ||= {}
14 |
15 | content_is_options = content_or_options.is_a?(Hash)
16 | if content_is_options
17 | options.merge! content_or_options
18 | @attribute_content = nil
19 | else
20 | @attribute_content = content_or_options
21 | end
22 |
23 | super(form, object_name, method_name, options)
24 | end
25 |
26 | def call
27 | content_or_options = content.presence || attribute_content.presence
28 |
29 | tag.public_send(self.class.tag, content_or_options, **options)
30 | end
31 |
32 | def render?
33 | content.present? || attribute_content.present?
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/spec/view_component/form/date_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::DateSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :birth_date, options, html_options)) }
10 | let(:component_html_attributes) { component.css("select").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a select for the year" do
14 | expect(component.to_html).to have_tag("select", with: { id: "user_birth_date_1i", name: "user[birth_date(1i)]" })
15 | end
16 |
17 | it "has a select for the month" do
18 | expect(component.to_html).to have_tag("select", with: { id: "user_birth_date_2i", name: "user[birth_date(2i)]" })
19 | end
20 |
21 | it "has a select for the day" do
22 | expect(component.to_html).to have_tag("select", with: { id: "user_birth_date_3i", name: "user[birth_date(3i)]" })
23 | end
24 | end
25 |
26 | it_behaves_like "component with custom html classes", :html_options
27 | it_behaves_like "component with custom data attributes", :html_options
28 | end
29 |
--------------------------------------------------------------------------------
/spec/view_component/form/text_area_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::TextAreaComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :bio, options)) }
9 | let(:component_html_attributes) { component.css("textarea").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | if Gem::Version.new(ViewComponent::VERSION::STRING) >= Gem::Version.new("4.0")
14 | expect(component).to eq_html <<~HTML
15 |
16 | HTML
17 | else
18 | expect(component).to eq_html <<~HTML
19 |
21 | HTML
22 | end
23 | end
24 | end
25 |
26 | context "with custom value" do
27 | let(:options) { { value: "Hello World" } }
28 |
29 | it { expect(component.css("textarea").first.content).to include("Hello World") }
30 | end
31 |
32 | it_behaves_like "component with custom html classes"
33 | it_behaves_like "component with custom data attributes"
34 | end
35 |
--------------------------------------------------------------------------------
/spec/view_component/form/collection_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::CollectionSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:collection) { [OpenStruct.new(name: "Belgium", code: "BE"), OpenStruct.new(name: "France", code: "FR")] }
7 | let(:options) { {} }
8 | let(:html_options) { {} }
9 |
10 | let(:component) do
11 | render_inline(described_class.new(
12 | form,
13 | object_name,
14 | :country,
15 | collection,
16 | :code,
17 | :name,
18 | options,
19 | html_options
20 | ))
21 | end
22 | let(:component_html_attributes) { component.css("select").last.attributes }
23 |
24 | context "with simple args" do
25 | it do
26 | expect(component).to eq_html <<~HTML
27 |
29 | HTML
30 | end
31 | end
32 |
33 | it_behaves_like "component with custom html classes", :html_options
34 | it_behaves_like "component with custom data attributes", :html_options
35 | end
36 |
--------------------------------------------------------------------------------
/view_component-form.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/view_component/form/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "view_component-form"
7 | spec.version = ViewComponent::Form::VERSION
8 | spec.authors = ["Pantographe"]
9 | spec.email = ["oss@pantographe.studio"]
10 |
11 | spec.summary = "Rails FormBuilder for ViewComponent"
12 | spec.description = "Rails FormBuilder for ViewComponent"
13 | spec.homepage = "https://github.com/pantographe/view_component-form"
14 | spec.license = "MIT"
15 |
16 | spec.metadata = {
17 | "homepage_uri" => spec.homepage,
18 | "changelog_uri" => "https://github.com/pantographe/view_component-form/blob/master/CHANGELOG.md",
19 | "source_code_uri" => spec.homepage,
20 | "bug_tracker_uri" => "https://github.com/pantographe/view_component-form/issues",
21 | "rubygems_mfa_required" => "true"
22 | }
23 |
24 | spec.files = Dir["CHANGELOG.md", "LICENSE.txt", "README.md", "app/**/*", "lib/**/*"]
25 | spec.require_paths = ["lib"]
26 |
27 | spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
28 |
29 | spec.add_dependency "actionview", [">= 7.2.0"]
30 | spec.add_dependency "activesupport", [">= 7.2.0"]
31 | spec.add_dependency "view_component", [">= 2.34.0", "< 5.0"]
32 | spec.add_dependency "zeitwerk", ["~> 2.5"]
33 | end
34 |
--------------------------------------------------------------------------------
/spec/view_component/generators/vcf/generators/builder_generator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "generators/vcf/builder/builder_generator"
4 |
5 | RSpec.describe Vcf::Generators::BuilderGenerator, type: :generator do
6 | destination Dir.mktmpdir
7 | arguments %w[CustomFormBuilder]
8 |
9 | before do
10 | prepare_destination
11 | end
12 |
13 | describe "the builder" do
14 | context "without options" do
15 | before do
16 | run_generator
17 | end
18 |
19 | it do
20 | assert_file "app/helpers/custom_form_builder.rb" do |builder|
21 | assert_match(/class CustomFormBuilder < ViewComponent::Form::Builder/, builder)
22 | assert_match(/namespace "Form"/, builder)
23 | end
24 | end
25 | end
26 |
27 | context "with namespace option" do
28 | before do
29 | run_generator %w[CustomFormBuilder --namespace=CustomForm]
30 | end
31 |
32 | it do
33 | assert_file "app/helpers/custom_form_builder.rb" do |builder|
34 | assert_match(/namespace "CustomForm"/, builder)
35 | end
36 | end
37 | end
38 |
39 | context "with path option" do
40 | before do
41 | run_generator %w[CustomFormBuilder --path=custom/lib]
42 | end
43 |
44 | it do
45 | assert_file "custom/lib/custom_form_builder.rb"
46 | end
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/app/components/view_component/form/collection_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class CollectionSelectComponent < FieldComponent
6 | attr_reader :collection, :value_method, :text_method, :html_options
7 |
8 | def initialize( # rubocop:disable Metrics/ParameterLists
9 | form,
10 | object_name,
11 | method_name,
12 | collection,
13 | value_method,
14 | text_method,
15 | options = {},
16 | html_options = {}
17 | )
18 | @collection = collection
19 | @value_method = value_method
20 | @text_method = text_method
21 | @html_options = html_options
22 |
23 | super(form, object_name, method_name, options)
24 |
25 | set_html_options!
26 | end
27 |
28 | def call
29 | ActionView::Helpers::Tags::CollectionSelect.new(
30 | object_name,
31 | method_name,
32 | @view_context,
33 | collection,
34 | value_method,
35 | text_method,
36 | options,
37 | html_options
38 | ).render
39 | end
40 |
41 | protected
42 |
43 | def set_html_options!
44 | @html_options[:class] = class_names(html_options[:class], html_class)
45 | @html_options.delete(:class) if @html_options[:class].blank?
46 | end
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/app/components/view_component/form/base_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class BaseComponent < ViewComponent::Form.configuration.parent_component.constantize
6 | class << self
7 | attr_accessor :default_options
8 | end
9 |
10 | attr_reader :form, :object_name, :options
11 |
12 | delegate :object, to: :form, allow_nil: true
13 |
14 | def initialize(form, object_name, options = {})
15 | @form = form
16 |
17 | # See: https://github.com/rails/rails/blob/83217025a171593547d1268651b446d3533e2019/actionview/lib/action_view/helpers/tags/base.rb#L13
18 | @object_name = object_name.to_s.dup
19 | @options = options
20 |
21 | super()
22 | end
23 |
24 | def object_errors?
25 | return false unless object
26 |
27 | object.errors.any?
28 | end
29 |
30 | def object_errors
31 | return nil unless object
32 |
33 | object.errors
34 | end
35 |
36 | def html_class
37 | nil
38 | end
39 |
40 | protected
41 |
42 | def before_render
43 | super
44 |
45 | combine_options!
46 | end
47 |
48 | def combine_options!
49 | @options = (self.class.default_options.deep_dup || {}).deep_merge(options).tap do |opts|
50 | opts[:class] = class_names(options[:class], html_class) if (html_class || options[:class]).present?
51 | end
52 | end
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/app/components/view_component/form/collection_check_boxes_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class CollectionCheckBoxesComponent < FieldComponent
6 | include ElementProc
7 |
8 | attr_reader :collection, :value_method, :text_method, :html_options
9 |
10 | def initialize( # rubocop:disable Metrics/ParameterLists
11 | form,
12 | object_name,
13 | method_name,
14 | collection,
15 | value_method,
16 | text_method,
17 | options = {},
18 | html_options = {}
19 | )
20 | @collection = collection
21 | @value_method = value_method
22 | @text_method = text_method
23 | @html_options = html_options
24 |
25 | super(form, object_name, method_name, options)
26 |
27 | set_html_options!
28 | end
29 |
30 | def call # rubocop:disable Metrics/MethodLength
31 | ActionView::Helpers::Tags::CollectionCheckBoxes.new(
32 | object_name,
33 | method_name,
34 | @view_context,
35 | collection,
36 | value_method,
37 | text_method,
38 | options,
39 | html_options,
40 | &content
41 | ).render(&element_proc)
42 | end
43 |
44 | protected
45 |
46 | def set_html_options!
47 | @html_options[:class] = class_names(html_options[:class], html_class)
48 | @html_options.delete(:class) if @html_options[:class].blank?
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/app/components/view_component/form/label_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class LabelComponent < FieldComponent
6 | attr_reader :attribute_content
7 |
8 | def initialize(form, object_name, method_name, content_or_options = nil, options = nil)
9 | options ||= {}
10 |
11 | content_is_options = content_or_options.is_a?(Hash)
12 | if content_is_options
13 | options.merge! content_or_options
14 | @attribute_content = nil
15 | else
16 | @attribute_content = content_or_options
17 | end
18 |
19 | super(form, object_name, method_name, options)
20 | end
21 |
22 | def call
23 | content_or_options = nil
24 |
25 | content_or_options = content || attribute_content if content.present? || attribute_content.present?
26 |
27 | ActionView::Helpers::Tags::Label.new(object_name, method_name, @view_context, content_or_options,
28 | options).render
29 | end
30 |
31 | # See: https://github.com/rails/rails/blob/83217025a171593547d1268651b446d3533e2019/actionview/lib/action_view/helpers/tags/label.rb#L48
32 | def builder
33 | @builder ||= begin
34 | tag_value = options.delete("value")
35 |
36 | ActionView::Helpers::Tags::Label::LabelBuilder.new(@view_context, object_name, method_name, object, tag_value)
37 | end
38 | end
39 | delegate :translation, to: :builder
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/app/components/view_component/form/collection_radio_buttons_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class CollectionRadioButtonsComponent < FieldComponent
6 | include ElementProc
7 |
8 | attr_reader :collection, :value_method, :text_method, :html_options
9 |
10 | def initialize( # rubocop:disable Metrics/ParameterLists
11 | form,
12 | object_name,
13 | method_name,
14 | collection,
15 | value_method,
16 | text_method,
17 | options = {},
18 | html_options = {}
19 | )
20 | @collection = collection
21 | @value_method = value_method
22 | @text_method = text_method
23 | @html_options = html_options
24 |
25 | super(form, object_name, method_name, options)
26 |
27 | set_html_options!
28 | end
29 |
30 | def call # rubocop:disable Metrics/MethodLength
31 | ActionView::Helpers::Tags::CollectionRadioButtons.new(
32 | object_name,
33 | method_name,
34 | @view_context,
35 | collection,
36 | value_method,
37 | text_method,
38 | options,
39 | html_options,
40 | &content
41 | ).render(&element_proc)
42 | end
43 |
44 | protected
45 |
46 | def set_html_options!
47 | @html_options[:class] = class_names(html_options[:class], html_class)
48 | @html_options.delete(:class) if @html_options[:class].blank?
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/spec/view_component/form/base_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::BaseComponent, type: :component do
4 | let(:object_klass) do
5 | Class.new do
6 | include ActiveModel::Model
7 |
8 | attr_accessor :first_name
9 |
10 | validates :first_name, presence: true, length: { minimum: 2 }
11 |
12 | class << self
13 | def name
14 | "User"
15 | end
16 | end
17 | end
18 | end
19 |
20 | let(:object) { object_klass.new }
21 | let(:form) { form_with(object) }
22 | let(:options) { {} }
23 |
24 | let(:component) { described_class.new(form, object_name, options) }
25 |
26 | describe "#object_errors?" do
27 | context "with valid object" do
28 | let(:object) { object_klass.new(first_name: "John") }
29 |
30 | before { object.validate }
31 |
32 | it { expect(component.object_errors?).to be(false) }
33 | end
34 |
35 | context "with invalid object" do
36 | let(:object) { object_klass.new(first_name: "") }
37 |
38 | before { object.validate }
39 |
40 | it { expect(component.object_errors?).to be(true) }
41 | end
42 |
43 | context "without object" do
44 | let(:object) { nil }
45 |
46 | it { expect(component.object_errors?).to be(false) }
47 | end
48 | end
49 |
50 | describe "parent_component" do
51 | subject { described_class }
52 |
53 | context "without configured parent_component" do
54 | it { is_expected.to be < ViewComponent::Base }
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/view_component/form/helpers/rails8.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | module Helpers
6 | # Rails 8.0 changed the spelling of a couple form builder methods while adding aliases
7 | # for backward compatibility. This module adds those new methods.
8 | #
9 | # https://github.com/rails/rails/blob/8-0-stable/actionview/CHANGELOG.md#rails-800beta1-september-26-2024
10 | module Rails8
11 | def textarea(method, options = {})
12 | render_component(:text_area, @object_name, method, objectify_options(options))
13 | end
14 |
15 | def checkbox(method, options = {}, checked_value = "1", unchecked_value = "0")
16 | render_component(:check_box, @object_name, method, checked_value, unchecked_value, objectify_options(options))
17 | end
18 |
19 | def collection_checkboxes(method, collection, value_method, text_method, options = {}, html_options = {}, # rubocop:disable Metrics/ParameterLists
20 | &)
21 | render_component(
22 | :collection_check_boxes, @object_name, method, collection, value_method, text_method,
23 | objectify_options(options), @default_html_options.merge(html_options), &
24 | )
25 | end
26 |
27 | if defined?(ActionView::Helpers::Tags::ActionText)
28 | def rich_textarea(method, options = {})
29 | render_component(:rich_text_area, @object_name, method, objectify_options(options))
30 | end
31 | end
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/spec/view_component/form/datetime_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::DatetimeSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :created_at, options, html_options)) }
10 | let(:component_html_attributes) { component.css("select").first.attributes }
11 |
12 | context "with simple args" do
13 | it "has a select for the year" do
14 | expect(component.to_html).to have_tag("select", with: { id: "user_created_at_1i", name: "user[created_at(1i)]" })
15 | end
16 |
17 | it "has a select for the month" do
18 | expect(component.to_html).to have_tag("select", with: { id: "user_created_at_2i", name: "user[created_at(2i)]" })
19 | end
20 |
21 | it "has a select for the day" do
22 | expect(component.to_html).to have_tag("select", with: { id: "user_created_at_3i", name: "user[created_at(3i)]" })
23 | end
24 |
25 | it "has a select for the hours" do
26 | expect(component.to_html).to have_tag("select", with: { id: "user_created_at_4i", name: "user[created_at(4i)]" })
27 | end
28 |
29 | it "has a select for the minutes" do
30 | expect(component.to_html).to have_tag("select", with: { id: "user_created_at_5i", name: "user[created_at(5i)]" })
31 | end
32 | end
33 |
34 | it_behaves_like "component with custom html classes", :html_options
35 | it_behaves_like "component with custom data attributes", :html_options
36 | end
37 |
--------------------------------------------------------------------------------
/app/components/view_component/form/grouped_collection_select_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class GroupedCollectionSelectComponent < FieldComponent
6 | attr_reader :collection, :group_method,
7 | :group_label_method, :option_key_method, :option_value_method,
8 | :html_options
9 |
10 | def initialize( # rubocop:disable Metrics/ParameterLists
11 | form,
12 | object_name,
13 | method_name,
14 | collection,
15 | group_method,
16 | group_label_method,
17 | option_key_method,
18 | option_value_method,
19 | options = {},
20 | html_options = {}
21 | )
22 | @collection = collection
23 | @group_method = group_method
24 | @group_label_method = group_label_method
25 | @option_key_method = option_key_method
26 | @option_value_method = option_value_method
27 | @html_options = html_options
28 |
29 | super(form, object_name, method_name, options)
30 |
31 | set_html_options!
32 | end
33 |
34 | def call # rubocop:disable Metrics/MethodLength
35 | ActionView::Helpers::Tags::GroupedCollectionSelect.new(
36 | object_name,
37 | method_name,
38 | @view_context,
39 | collection,
40 | group_method,
41 | group_label_method,
42 | option_key_method,
43 | option_value_method,
44 | options,
45 | html_options
46 | ).render
47 | end
48 |
49 | protected
50 |
51 | def set_html_options!
52 | @html_options[:class] = class_names(html_options[:class], html_class)
53 | @html_options.delete(:class) if @html_options[:class].blank?
54 | end
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/spec/view_component/form/error_message_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::ErrorMessageComponent, type: :component do
4 | subject(:rendered_component) { render_inline(component) }
5 |
6 | let(:component) { described_class.new(form, object_name, :first_name, options) }
7 |
8 | let(:object_klass) do
9 | Class.new do
10 | include ActiveModel::Model
11 |
12 | attr_accessor :first_name
13 |
14 | validates :first_name, presence: true, length: { minimum: 2 }
15 |
16 | class << self
17 | def name
18 | "User"
19 | end
20 | end
21 | end
22 | end
23 |
24 | let(:object) { object_klass.new }
25 | let(:form) { form_with(object) }
26 | let(:options) { {} }
27 | let(:component_html_attributes) { rendered_component.css("div").first.attributes }
28 |
29 | context "with valid object" do
30 | subject { component }
31 |
32 | let(:object) { object_klass.new(first_name: "John") }
33 |
34 | before { object.validate }
35 |
36 | it { is_expected.to have_attributes(method_errors: [], render?: false) }
37 | end
38 |
39 | context "with invalid object" do
40 | let(:object) { object_klass.new(first_name: "") }
41 | let(:blank_error_message) { I18n.t("errors.messages.blank").upcase_first }
42 |
43 | before { object.validate }
44 |
45 | context "with simple args" do
46 | it { expect(component.method_errors).to eq([blank_error_message, "Is too short (minimum is 2 characters)"]) }
47 | it { expect(component.render?).to be true }
48 |
49 | it { is_expected.to eq_html "
#{blank_error_message} Is too short (minimum is 2 characters)
" }
50 | end
51 |
52 | it_behaves_like "component with custom html classes"
53 | it_behaves_like "component with custom data attributes"
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/spec/view_component/form/file_field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::FileFieldComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 |
8 | let(:component) { render_inline(described_class.new(form, object_name, :avatar, options)) }
9 | let(:component_html_attributes) { component.css("input").first.attributes }
10 |
11 | context "with simple args" do
12 | it do
13 | expect(component).to eq_html <<~HTML
14 |
15 | HTML
16 | end
17 | end
18 |
19 | context "with direct upload" do
20 | let(:options) { { direct_upload: true } }
21 |
22 | it do
23 | expect(component).to eq_html <<~HTML
24 |
25 | HTML
26 | end
27 | end
28 |
29 | context "with multiple and include hidden" do
30 | let(:options) { { multiple: true, include_hidden: true } }
31 |
32 | it do
33 | if Gem::Version.new(ViewComponent::VERSION::STRING) >= Gem::Version.new("4.0")
34 | expect(component).to eq_html <<~HTML
35 |
36 | HTML
37 | else
38 | expect(component).to eq_html <<~HTML
39 |
40 | HTML
41 | end
42 | end
43 | end
44 |
45 | it_behaves_like "component with custom html classes"
46 | it_behaves_like "component with custom data attributes"
47 | end
48 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | if ENV.fetch("COVERAGE", false)
4 | require "simplecov"
5 | SimpleCov.start do
6 | add_filter "/spec"
7 |
8 | minimum_coverage 89
9 | maximum_coverage_drop 2
10 | end
11 | end
12 |
13 | require "view_component/engine"
14 | require "view_component/form"
15 | require "view_component/version"
16 |
17 | require "combustion"
18 |
19 | Combustion.path = "spec/internal"
20 |
21 | modules = %i[action_controller action_view active_record active_storage]
22 | modules << :action_text if ENV.fetch("VIEW_COMPONENT_FORM_USE_ACTIONTEXT", "false") == "true"
23 |
24 | Combustion.initialize!(*modules) do
25 | config.logger = ActiveSupport::TaggedLogging.new(Logger.new(nil))
26 | config.log_level = :fatal
27 | end
28 |
29 | require "generator_spec"
30 |
31 | class ApplicationController < ActionController::Base
32 | end
33 |
34 | require "view_component/test_helpers"
35 | require "view_component/form/test_helpers"
36 | require "capybara/rspec"
37 | require "ostruct"
38 |
39 | Dir["./spec/support/**/*.rb"].each { |f| require f }
40 |
41 | RSpec.configure do |config|
42 | # Enable flags like --only-failures and --next-failure
43 | config.example_status_persistence_file_path = ".rspec_status"
44 |
45 | # Disable RSpec exposing methods globally on `Module` and `main`
46 | config.disable_monkey_patching!
47 |
48 | config.expect_with :rspec do |c|
49 | c.syntax = :expect
50 | end
51 |
52 | config.include ViewComponent::TestHelpers, type: :component
53 | config.include ViewComponent::Form::TestHelpers, type: :component
54 | config.include ViewComponent::Form::TestHelpers, type: :builder
55 | config.include Capybara::RSpecMatchers, type: :component
56 | config.include RSpecHtmlMatchers, type: :component
57 |
58 | config.include ActiveSupport::Testing::TimeHelpers
59 |
60 | config.after { travel_back }
61 | end
62 |
--------------------------------------------------------------------------------
/spec/view_component/form/grouped_collection_select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::GroupedCollectionSelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:collection) do
7 | [
8 | OpenStruct.new(
9 | name: "Europe",
10 | countries: [
11 | OpenStruct.new(name: "Belgium", code: "BE"),
12 | OpenStruct.new(name: "France", code: "FR")
13 | ]
14 | ),
15 | OpenStruct.new(
16 | name: "Asia",
17 | countries: [
18 | OpenStruct.new(name: "Japan", code: "JP")
19 | ]
20 | )
21 | ]
22 | end
23 | let(:options) { {} }
24 | let(:html_options) { {} }
25 |
26 | let(:component) do
27 | render_inline(described_class.new(
28 | form,
29 | object_name,
30 | :country,
31 | collection,
32 | :countries,
33 | :name,
34 | :code,
35 | :name,
36 | options,
37 | html_options
38 | ))
39 | end
40 | let(:component_html_attributes) { component.css("select").last.attributes }
41 |
42 | context "with simple args" do
43 | it do
44 | if Gem::Version.new(ViewComponent::VERSION::STRING) >= Gem::Version.new("4.0")
45 | expect(component).to eq_html <<~HTML
46 |
48 | HTML
49 | else
50 | expect(component).to eq_html <<~HTML
51 |
56 | HTML
57 | end
58 | end
59 | end
60 | # rubocop:enable RSpec/ExampleLength
61 |
62 | it_behaves_like "component with custom html classes", :html_options
63 | it_behaves_like "component with custom data attributes", :html_options
64 | end
65 |
--------------------------------------------------------------------------------
/spec/view_component/form/select_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::SelectComponent, type: :component do
4 | let(:object) { OpenStruct.new }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:html_options) { {} }
8 |
9 | let(:component) do
10 | render_inline(described_class.new(
11 | form,
12 | object_name,
13 | :role,
14 | [["Admin", :admin], ["Manager", :manager]],
15 | options,
16 | html_options
17 | ))
18 | end
19 | let(:component_html_attributes) { component.css("select").first.attributes }
20 |
21 | context "with simple args" do
22 | it do
23 | expect(component).to eq_html <<~HTML
24 |
26 | HTML
27 | end
28 | end
29 |
30 | context "with selected value" do
31 | let(:options) { { selected: "manager" } }
32 |
33 | it do
34 | if Gem::Version.new(ViewComponent::VERSION::STRING) >= Gem::Version.new("4.0")
35 | expect(component).to eq_html <<~HTML
36 |
38 | HTML
39 | else
40 | expect(component).to eq_html <<~HTML
41 |
43 | HTML
44 | end
45 | end
46 | end
47 |
48 | context "with multiple select" do
49 | let(:html_options) { { multiple: true } }
50 |
51 | it { expect(component.to_html).to have_tag("input", with: { type: "hidden", value: "", name: "user[role][]" }) }
52 |
53 | it do
54 | expect(component.to_html).to have_tag("select", with: { name: "user[role][]", id: "user_role" }) do
55 | with_tag "option", with: { value: "admin" }, text: "Admin"
56 | with_tag "option", with: { value: "manager" }, text: "Manager"
57 | end
58 | end
59 | end
60 |
61 | it_behaves_like "component with custom html classes", :html_options
62 | it_behaves_like "component with custom data attributes", :html_options
63 | end
64 |
--------------------------------------------------------------------------------
/lib/view_component/form/renderer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | module Renderer
6 | class Error < StandardError; end
7 | class NamespaceAlreadyAddedError < Error; end
8 | class NotImplementedComponentError < Error; end
9 |
10 | # rubocop:disable Metrics/MethodLength
11 | def self.included(base)
12 | base.class_eval do
13 | original_initialize_method = instance_method(:initialize)
14 |
15 | define_method(:initialize) do |*args, &block|
16 | @__component_klass_cache = {}
17 |
18 | original_initialize_method.bind_call(self, *args, &block)
19 | end
20 |
21 | class_attribute :lookup_namespaces, default: [ViewComponent::Form]
22 |
23 | class << self
24 | def inherited(base)
25 | base.lookup_namespaces = lookup_namespaces.dup
26 |
27 | super
28 | end
29 |
30 | def namespace(namespace)
31 | if lookup_namespaces.include?(namespace)
32 | raise NamespaceAlreadyAddedError, "The component namespace '#{namespace}' is already added"
33 | end
34 |
35 | lookup_namespaces.prepend namespace
36 | end
37 | end
38 | end
39 | end
40 | # rubocop:enable Metrics/MethodLength
41 |
42 | private
43 |
44 | def render_component(component_name, *, &)
45 | component = component_klass(component_name).new(self, *)
46 | component.render_in(@template, &)
47 | end
48 |
49 | def objectify_options(options)
50 | @default_options.merge(options.merge(object: @object))
51 | end
52 |
53 | def component_klass(component_name)
54 | @__component_klass_cache[component_name] ||= begin
55 | component_klass = ViewComponent::Form.configuration.lookup_chain.lazy.map do |lookup|
56 | lookup.call(component_name, namespaces: lookup_namespaces)
57 | end.find(&:itself)
58 |
59 | unless component_klass.is_a?(Class) && component_klass < ViewComponent::Base
60 | raise NotImplementedComponentError, "Component named #{component_name} doesn't exist " \
61 | "or is not a ViewComponent::Base class"
62 | end
63 |
64 | component_klass
65 | end
66 | end
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Ruby
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 |
10 | jobs:
11 | build:
12 | name: "Ruby ${{ matrix.versions.ruby }}, Rails ${{ matrix.versions.rails }}, VC ${{ matrix.view_component }}, ${{ matrix.action_text }} ActionText"
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | versions:
17 | - { ruby: "3.2", rails: "7.2", rubygems: "default" }
18 | - { ruby: "3.3", rails: "7.2", rubygems: "default" }
19 | - { ruby: "3.4", rails: "7.2", rubygems: "default" }
20 | - { ruby: "3.3", rails: "8.0", rubygems: "default" }
21 | - { ruby: "3.4", rails: "8.0", rubygems: "default" }
22 | - { ruby: "3.3", rails: "8.1", rubygems: "default" }
23 | - { ruby: "3.4", rails: "8.1", rubygems: "default" }
24 | - { ruby: "3.3", rails: "head", rubygems: "latest" }
25 | - { ruby: "3.4", rails: "head", rubygems: "latest" }
26 | action_text: ["with", "without"]
27 | view_component: ["3.0", "4.0"]
28 | exclude:
29 | # view_component 3.x doesn't support Rails 8.1 (requires activesupport < 8.1)
30 | - versions: { ruby: "3.3", rails: "8.1" }
31 | view_component: "3.0"
32 | - versions: { ruby: "3.4", rails: "8.1" }
33 | view_component: "3.0"
34 | - versions: { ruby: "3.3", rails: "head" }
35 | view_component: "3.0"
36 | - versions: { ruby: "3.4", rails: "head" }
37 | view_component: "3.0"
38 |
39 | env:
40 | BUNDLE_GEMFILE: gemfiles/rails_${{ matrix.versions.rails }}_vc_${{ matrix.view_component }}.gemfile
41 | VIEW_COMPONENT_FORM_USE_ACTIONTEXT: ${{ matrix.action_text == 'with' && 'true' || 'false' }}
42 |
43 | steps:
44 | - uses: actions/checkout@v4
45 |
46 | - name: Set up Ruby
47 | uses: ruby/setup-ruby@v1
48 | with:
49 | ruby-version: ${{ matrix.versions.ruby }}
50 | rubygems: ${{ matrix.versions.rubygems }}
51 | bundler-cache: true
52 |
53 | - name: Test with Rake
54 | run: |
55 | COVERAGE=true bundle exec rake
56 |
57 | - name: Upload coverage results
58 | uses: actions/upload-artifact@v4
59 | if: always()
60 | with:
61 | name: coverage-report-ruby-${{ matrix.versions.ruby }}-rails-${{ matrix.versions.rails }}-vc-${{ matrix.view_component }}-actiontext-${{ matrix.action_text }}
62 | path: coverage
63 |
--------------------------------------------------------------------------------
/spec/view_component/form/label_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::LabelComponent, type: :component do
4 | let(:object) { OpenStruct.new(errors: []) }
5 | let(:form) { form_with(object) }
6 | let(:options) { {} }
7 | let(:block) { nil }
8 |
9 | let(:component) { render_inline(described_class.new(form, object_name, :first_name, options), &block) }
10 | let(:component_html_attributes) { component.css("label").first.attributes }
11 |
12 | context "with simple args" do
13 | it do
14 | expect(component).to eq_html <<~HTML
15 |
16 | HTML
17 | end
18 | end
19 |
20 | context "with content and options" do
21 | let(:options) { { class: "custom-label" } }
22 | let(:component) { render_inline(described_class.new(form, object_name, :first_name, "Your first name", options)) }
23 |
24 | it do
25 | expect(component).to eq_html <<~HTML
26 |
27 | HTML
28 | end
29 | end
30 |
31 | context "with a block" do
32 | let(:block) do
33 | proc do
34 | "Your first name".html_safe
35 | end
36 | end
37 |
38 | it do
39 | expect(component).to eq_html <<~HTML
40 |
41 | HTML
42 | end
43 | end
44 |
45 | context "with a block and translation param" do
46 | let(:block) do
47 | proc do |component|
48 | "#{component.translation}".html_safe
49 | end
50 | end
51 |
52 | it do
53 | expect(component).to eq_html <<~HTML
54 |
55 | HTML
56 | end
57 | end
58 |
59 | context "with a block and builder param" do
60 | let(:block) do
61 | proc do |component|
62 | "" \
63 | "#{component.builder.translation}".html_safe
64 | end
65 | end
66 |
67 | it do
68 | expect(component).to eq_html <<~HTML
69 |
70 | HTML
71 | end
72 | end
73 |
74 | it_behaves_like "component with custom html classes"
75 | it_behaves_like "component with custom data attributes"
76 | end
77 |
--------------------------------------------------------------------------------
/spec/view_component/form/collection_radio_buttons_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::CollectionRadioButtonsComponent, type: :component do
4 | subject(:component) do
5 | render_inline(described_class.new(
6 | form,
7 | object_name,
8 | :nationality,
9 | collection,
10 | :code,
11 | :name,
12 | options,
13 | html_options
14 | ))
15 | end
16 |
17 | let(:object) { OpenStruct.new }
18 | let(:component_html_attributes) { component.css("input").last.attributes }
19 | let(:form) { form_with(object) }
20 | let(:collection) { [OpenStruct.new(name: "Belgium", code: "BE"), OpenStruct.new(name: "France", code: "FR")] }
21 | let(:options) { {} }
22 | let(:html_options) { {} }
23 |
24 | context "with simple args" do
25 | it do
26 | expect(component.to_html).to have_tag("input", with: { type: "hidden", value: "", name: "user[nationality]" })
27 | end
28 |
29 | it do
30 | expect(component.to_html)
31 | .to have_tag("input", with: {
32 | type: "radio", value: "BE",
33 | id: "user_nationality_be", name: "user[nationality]"
34 | })
35 | end
36 |
37 | it do
38 | expect(component.to_html)
39 | .to have_tag("input", with: {
40 | type: "radio", value: "FR",
41 | id: "user_nationality_fr", name: "user[nationality]"
42 | })
43 | end
44 |
45 | it do
46 | expect(component.to_html)
47 | .to have_tag("label", with: { for: "user_nationality_be" }, text: "Belgium")
48 | end
49 |
50 | it do
51 | expect(component.to_html)
52 | .to have_tag("label", with: { for: "user_nationality_fr" }, text: "France")
53 | end
54 | end
55 |
56 | context "with an element proc" do
57 | let(:element_proc) do
58 | proc do |b|
59 | "
60 | #{b.radio_button}
61 | #{b.label}
62 |
".html_safe
63 | end
64 | end
65 |
66 | %i[options html_options].each do |option_arg|
67 | context "when passed via #{option_arg}" do
68 | before do
69 | public_send(option_arg)[:element_proc] = element_proc
70 | end
71 |
72 | it do
73 | expect(component.to_html)
74 | .to have_tag(".wrapper input", with: {
75 | type: "radio", value: "BE",
76 | id: "user_nationality_be", name: "user[nationality]"
77 | })
78 | end
79 | end
80 | end
81 |
82 | context "when passed via both options and html_options" do
83 | before do
84 | options[:element_proc] = element_proc
85 | html_options[:element_proc] = element_proc
86 | end
87 |
88 | it do
89 | expect { component }
90 | .to raise_error(ArgumentError,
91 | "ViewComponent::Form::CollectionRadioButtonsComponent " \
92 | "received :element_proc twice, expected only once")
93 | end
94 | end
95 | end
96 |
97 | it_behaves_like "component with custom html classes", :html_options
98 | it_behaves_like "component with custom data attributes", :html_options
99 | end
100 |
--------------------------------------------------------------------------------
/spec/view_component/form/collection_check_boxes_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::CollectionCheckBoxesComponent, type: :component do
4 | subject(:component) do
5 | render_inline(described_class.new(
6 | form,
7 | object_name,
8 | :nationalities,
9 | collection,
10 | :code,
11 | :name,
12 | options,
13 | html_options
14 | ))
15 | end
16 |
17 | let(:object) { OpenStruct.new }
18 | let(:component_html_attributes) { component.css("input").last.attributes }
19 | let(:form) { form_with(object) }
20 | let(:collection) { [OpenStruct.new(name: "Belgium", code: "BE"), OpenStruct.new(name: "France", code: "FR")] }
21 | let(:options) { {} }
22 | let(:html_options) { {} }
23 |
24 | context "with simple args" do
25 | it do
26 | expect(component.to_html).to have_tag("input", with: { type: "hidden", value: "", name: "user[nationalities][]" })
27 | end
28 |
29 | it do
30 | expect(component.to_html)
31 | .to have_tag("input", with: {
32 | type: "checkbox", value: "BE",
33 | id: "user_nationalities_be", name: "user[nationalities][]"
34 | })
35 | end
36 |
37 | it do
38 | expect(component.to_html)
39 | .to have_tag("input", with: {
40 | type: "checkbox", value: "FR",
41 | id: "user_nationalities_fr", name: "user[nationalities][]"
42 | })
43 | end
44 |
45 | it do
46 | expect(component.to_html)
47 | .to have_tag("label", with: { for: "user_nationalities_be" }, text: "Belgium")
48 | end
49 |
50 | it do
51 | expect(component.to_html)
52 | .to have_tag("label", with: { for: "user_nationalities_fr" }, text: "France")
53 | end
54 | end
55 |
56 | context "with an element proc" do
57 | let(:element_proc) do
58 | proc do |b|
59 | "
60 | #{b.check_box}
61 | #{b.label}
62 |
".html_safe
63 | end
64 | end
65 |
66 | %i[options html_options].each do |option_arg|
67 | context "when passed via #{option_arg}" do
68 | before do
69 | public_send(option_arg)[:element_proc] = element_proc
70 | end
71 |
72 | it do
73 | expect(component.to_html)
74 | .to have_tag(".wrapper input", with: {
75 | type: "checkbox", value: "BE",
76 | id: "user_nationalities_be", name: "user[nationalities][]"
77 | })
78 | end
79 | end
80 | end
81 |
82 | context "when passed via both options and html_options" do
83 | before do
84 | options[:element_proc] = element_proc
85 | html_options[:element_proc] = element_proc
86 | end
87 |
88 | it do
89 | expect { component }
90 | .to raise_error(ArgumentError,
91 | "ViewComponent::Form::CollectionCheckBoxesComponent " \
92 | "received :element_proc twice, expected only once")
93 | end
94 | end
95 | end
96 |
97 | it_behaves_like "component with custom html classes", :html_options
98 | it_behaves_like "component with custom data attributes", :html_options
99 | end
100 |
--------------------------------------------------------------------------------
/app/components/view_component/form/field_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | class FieldComponent < BaseComponent
6 | class_attribute :tag_klass, instance_reader: false, instance_writer: false, instance_accessor: false,
7 | instance_predicate: false
8 |
9 | attr_reader :method_name
10 |
11 | delegate :validation_context, to: :form
12 |
13 | def initialize(form, object_name, method_name, options = {})
14 | # See: https://github.com/rails/rails/blob/83217025a171593547d1268651b446d3533e2019/actionview/lib/action_view/helpers/tags/base.rb#L13
15 | @method_name = method_name.to_s.dup
16 |
17 | super(form, object_name, options)
18 | end
19 |
20 | def call
21 | raise "`self.tag_klass' should be defined in #{self.class.name}" unless self.class.tag_klass
22 |
23 | self.class.tag_klass.new(object_name, method_name, @view_context, options).render
24 | end
25 |
26 | def method_errors
27 | return [] unless method_errors?
28 |
29 | @method_errors ||= object_errors.to_hash
30 | .fetch_values(*object_method_names) { nil }
31 | .flatten.compact
32 | .map(&:upcase_first)
33 | end
34 |
35 | def method_errors?
36 | return false unless object_errors
37 |
38 | object_errors.attribute_names.intersect?(object_method_names)
39 | end
40 |
41 | def value
42 | object.public_send(method_name)
43 | end
44 |
45 | def object_method_names
46 | @object_method_names ||= begin
47 | object_method_names = [method_name.to_sym]
48 | if method_name.end_with?("_id") && object.respond_to?(singular_association_method_name)
49 | object_method_names << singular_association_method_name
50 | elsif method_name.end_with?("_ids") && object.respond_to?(collection_association_method_name)
51 | object_method_names << collection_association_method_name
52 | end
53 | object_method_names
54 | end
55 | end
56 |
57 | # From https://github.com/rails/rails/blob/497ab719d04a2d505f4d6a76c9d359b3d7f8e502/actionview/lib/action_view/helpers/tags/label.rb#L18-L27
58 | def label_text
59 | content ||= ActionView::Helpers::Tags::Translator.new(object, object_name, method_name,
60 | scope: "helpers.label").translate
61 | content ||= method_name.humanize
62 | content
63 | end
64 |
65 | def optional?(context: validation_context)
66 | return false if object.nil?
67 |
68 | !required?(context: context)
69 | end
70 |
71 | def required?(context: validation_context)
72 | return false if object.nil?
73 |
74 | validators(context: context).any?(ActiveModel::Validations::PresenceValidator)
75 | end
76 |
77 | def validators(context: validation_context)
78 | method_validators.select do |validator|
79 | if context.nil?
80 | validator.options[:on].blank?
81 | else
82 | Array(validator.options[:on]).include?(context&.to_sym)
83 | end
84 | end
85 | end
86 |
87 | private
88 |
89 | def singular_association_method_name
90 | @singular_association_method_name ||= method_name.to_s.sub(/_id$/, "").to_sym
91 | end
92 |
93 | def collection_association_method_name
94 | @collection_association_method_name ||= method_name.to_s.sub(/_ids$/, "").pluralize.to_sym
95 | end
96 |
97 | def method_validators
98 | @method_validators ||= if object.nil?
99 | []
100 | else
101 | object.class.validators_on(method_name)
102 | end
103 | end
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | * The use of sexualized language or imagery, and sexual attention or
22 | advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email
26 | address, without their explicit permission
27 | * Other conduct which could reasonably be considered inappropriate in a
28 | professional setting
29 |
30 | ## Enforcement Responsibilities
31 |
32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33 |
34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35 |
36 | ## Scope
37 |
38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39 |
40 | ## Enforcement
41 |
42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at n.brousse@pantographe.studio. All complaints will be reviewed and investigated promptly and fairly.
43 |
44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45 |
46 | ## Enforcement Guidelines
47 |
48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49 |
50 | ### 1. Correction
51 |
52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53 |
54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55 |
56 | ### 2. Warning
57 |
58 | **Community Impact**: A violation through a single incident or series of actions.
59 |
60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61 |
62 | ### 3. Temporary Ban
63 |
64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65 |
66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67 |
68 | ### 4. Permanent Ban
69 |
70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71 |
72 | **Consequence**: A permanent ban from any sort of public interaction within the community.
73 |
74 | ## Attribution
75 |
76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78 |
79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80 |
81 | [homepage]: https://www.contributor-covenant.org
82 |
83 | For answers to common questions about this code of conduct, see the FAQ at
84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
85 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ## [0.3.0] - 2025-10-24
10 | ### Added
11 | - Support for Rails 8.1
12 | - Support for view_component 4.x
13 | ### Removed
14 | - Drop Ruby 3.1 support (EOL March 31, 2025)
15 | - Drop Rails 7.0 support (EOL)
16 | - Drop Rails 7.1 support (EOL)
17 |
18 | ## [0.2.11] - 2025-04-05
19 | ### Fixed
20 | - Fix Rails 8 integration (#189)
21 |
22 | ## [0.2.10] - 2025-03-17 (yanked)
23 | ### Added
24 | - Support for Ruby 3.4 (#186)
25 | ### Removed
26 | - Drop Ruby 3.0 support (#186)
27 | - Drop Rails 6.1 support (#186)
28 | ### Fixed
29 | - Add `text_area` alias to `textarea` helper to follow Rails 8.0 change (#181)
30 |
31 | ## [0.2.9] - 2024-11-09
32 | ### Added
33 | - Support for Rails 8.0 (#179)
34 | ### Fixed
35 | - Fix `object_errors` when `object` is `false` (#173)
36 |
37 | ## [0.2.8] - 2024-08-20
38 | ### Added
39 | - Support for Rails 7.2 (#168)
40 |
41 | ## [0.2.7] - 2024-07-18
42 | ### Added
43 | - Added parent_component configuration for field components (#160)
44 | - Added Ruby 3.3 support (#164)
45 | - Add `lookup_chain` customizability (#162)
46 |
47 | ### Removed
48 | - Drop Ruby 2.7 support (#164)
49 | - Drop Rails 6.0 support (#164)
50 |
51 | ## [0.2.6] - 2023-10-11
52 | ### Added
53 | - Support for Rails 7.1 (#151)
54 | - Add `element_proc` option to `CollectionCheckBoxesComponent` and `CollectionRadioButtonsComponent` to customize the way the elements will be shown (#142)
55 |
56 | ## [0.2.5] - 2023-05-01
57 | ### Changed
58 | - Split `Form::Builder` into modules, to allow including only some modules instead of inheriting the whole class (#134)
59 | - Using the `Form::Builder` generator now creates the file in `app/helpers` by default, instead of `lib` previously, so that it's autoloaded by Rails without further configuration (#137)
60 | - Support for `view_component` 3.0 (#136, #147)
61 |
62 | ### Fixed
63 | - Update dependencies (#128)
64 | - Add Ruby 3.2 to CI (#128)
65 | - Fix specs for Rails 7.1 (#128)
66 |
67 | ## [0.2.4] - 2022-04-27
68 | ### Changed
69 | - Add Ruby 3.1 to CI (#123)
70 |
71 | ### Fixed
72 | - Fix `FileFieldComponent` options for `direct_upload` and `include_hidden` (#122)
73 |
74 | ## [0.2.3] - 2022-03-24
75 | ### Fixed
76 | - Declare empty RichTextAreaComponent if ActionText is not installed, to fix Zeitwerk error (#120)
77 |
78 | ## [0.2.2] - 2022-03-23
79 | ### Changed
80 | - Improve conditional ActionText support (#118)
81 |
82 | ## [0.2.1] - 2022-03-17
83 | ### Added
84 | - Conditional ActionText support (#117)
85 |
86 | ### Fixed
87 | - Fix broken gem initialization due to missing ViewComponent::Form constant (#114)
88 | - Initialize empty WeekdaySelectComponent if Rails < 7 to fix Zeitwerk error (#115)
89 |
90 | ## [0.2.0] - 2022-03-02
91 | ### Added
92 | - Test BuilderGenerator with generator\_spec (#64)
93 | - Add HintComponent and ErrorMessageComponent (#98)
94 | - Add `validation_context` option to `Form::Builder` (#101)
95 | - Add `validators` helper to `FieldComponent` (#101)
96 | - Add `optional?` and `required?` helpers to `FieldComponent` (#101)
97 | - Add `label_text` helper (#103)
98 | - Add `field_id` helper, backported from Rails 7.0 (#104)
99 | - Add `weekday_select` helper (#105)
100 | - Add README section about supported helpers (#106)
101 | - Setup zeitwerk (#107)
102 | - Add documentation for tests (#108)
103 |
104 | ## [0.1.3] - 2022-01-11
105 | ### Fixed
106 | - Update dependencies for Rails 7.0.0 (#96)
107 | - Bump to Rails 7 in Gemfile.lock (#99)
108 |
109 | ## [0.1.2] - 2021-12-07
110 | ### Added
111 | - Add missing component specs (#75)
112 | - Add missing builder specs for return values (#76)
113 | - Add accurate test cases for all helpers from ActionView::Helpers::FormBuilder
114 | documentation (#85)
115 |
116 | ### Changed
117 | - Cross-documented Rails form helpers (#84)
118 | - Made tag_klass optional when inheriting from a component class (#87)
119 | - Improve README: generator, html_class example (#88)
120 | - Make rails version condition used the same way (#92)
121 | - Add rails 7.0 and make rails head works (#94)
122 | - Allow `Base` and `FieldComponent` to support forms without objects (#95)
123 |
124 | ### Fixed
125 | - Fix `phone_field` helper (#74)
126 | - Fix `datetime_local_field` helper (#76)
127 | - Fix `time_zone_select` helper (#76)
128 | - Resolve Rails 6.1 deprecation on ActiveModel::Errors#keys call (#91)
129 |
130 | ## [0.1.1] - 2021-09-27
131 |
132 | ### Changed
133 | - Setup rspec-html-matchers and use it for complex components specs (#65)
134 |
135 | ### Fixed
136 | - Fix errors methods in `BaseComponent` and `FieldComponent` (#71)
137 |
138 | ## [0.1.0] - 2021-09-16
139 |
140 | ### Added
141 | - `FormBuilder`: add `.namespace` method to allow local lookup of components (#54)
142 | - Add basic `ViewComponent::Form::Builder` that can be used in place of Rails' `ActionView::Helpers::FormBuilder` (#1)
143 | - Add all standard FormBuilder helpers provided by Rails, implemented as ViewComponents (#4)
144 | - Add a custom FormBuilder generator (#34)
145 | - Add CHANGELOG (#50)
146 | - Add CI (#2)
147 |
148 | [Unreleased]: https://github.com/pantographe/view_component-form/compare/v0.2.11...HEAD
149 | [0.2.11]: https://github.com/pantographe/view_component-form/compare/v0.2.10...v0.2.11
150 | [0.2.10]: https://github.com/pantographe/view_component-form/compare/v0.2.9...v0.2.10
151 | [0.2.9]: https://github.com/pantographe/view_component-form/compare/v0.2.8...v0.2.9
152 | [0.2.8]: https://github.com/pantographe/view_component-form/compare/v0.2.7...v0.2.8
153 | [0.2.7]: https://github.com/pantographe/view_component-form/compare/v0.2.6...v0.2.7
154 | [0.2.6]: https://github.com/pantographe/view_component-form/compare/v0.2.5...v0.2.6
155 | [0.2.5]: https://github.com/pantographe/view_component-form/compare/v0.2.4...v0.2.5
156 | [0.2.4]: https://github.com/pantographe/view_component-form/compare/v0.2.3...v0.2.4
157 | [0.2.3]: https://github.com/pantographe/view_component-form/compare/v0.2.2...v0.2.3
158 | [0.2.2]: https://github.com/pantographe/view_component-form/compare/v0.2.1...v0.2.2
159 | [0.2.1]: https://github.com/pantographe/view_component-form/compare/v0.2.0...v0.2.1
160 | [0.2.0]: https://github.com/pantographe/view_component-form/compare/v0.1.3...v0.2.0
161 | [0.1.3]: https://github.com/pantographe/view_component-form/compare/v0.1.2...v0.1.3
162 | [0.1.2]: https://github.com/pantographe/view_component-form/compare/v0.1.1...v0.1.2
163 | [0.1.1]: https://github.com/pantographe/view_component-form/compare/v0.1.0...v0.1.1
164 | [0.1.0]: https://github.com/pantographe/view_component-form/releases/tag/v0.1.0
165 |
--------------------------------------------------------------------------------
/lib/view_component/form/helpers/rails.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ViewComponent
4 | module Form
5 | module Helpers
6 | # rubocop:disable Metrics/ModuleLength
7 | module Rails
8 | # rubocop:disable Metrics/MethodLength
9 | def self.included(base)
10 | base.class_eval do # rubocop:disable Metrics/BlockLength
11 | (field_helpers - %i[
12 | text_area
13 | textarea
14 | check_box
15 | checkbox
16 | datetime_field
17 | datetime_local_field
18 | fields
19 | fields_for
20 | file_field
21 | hidden_field
22 | label
23 | phone_field
24 | radio_button
25 | ]).each do |selector|
26 | class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
27 | def #{selector}(method, options = {}) # def text_field(method, options = {})
28 | render_component( # render_component(
29 | :#{selector}, # :text_field,
30 | @object_name, # @object_name,
31 | method, # method,
32 | objectify_options(options), # objectify_options(options),
33 | ) # )
34 | end # end
35 | RUBY_EVAL
36 | end
37 |
38 | alias_method :phone_field, :telephone_field
39 | end
40 | end
41 | # rubocop:enable Metrics/MethodLength
42 |
43 | def text_area(method, options = {})
44 | render_component(:text_area, @object_name, method, objectify_options(options))
45 | end
46 |
47 | # See: https://github.com/rails/rails/blob/33d60cb02dcac26d037332410eabaeeb0bdc384c/actionview/lib/action_view/helpers/form_helper.rb#L2280
48 | def label(method, text = nil, options = {}, &)
49 | render_component(:label, @object_name, method, text, objectify_options(options), &)
50 | end
51 |
52 | def datetime_field(method, options = {})
53 | render_component(
54 | :datetime_local_field, @object_name, method, objectify_options(options)
55 | )
56 | end
57 | alias datetime_local_field datetime_field
58 |
59 | def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
60 | render_component(:check_box, @object_name, method, checked_value, unchecked_value, objectify_options(options))
61 | end
62 |
63 | def radio_button(method, tag_value, options = {})
64 | render_component(
65 | :radio_button, @object_name, method, tag_value, objectify_options(options)
66 | )
67 | end
68 |
69 | def file_field(method, options = {})
70 | self.multipart = true
71 | render_component(:file_field, @object_name, method, objectify_options(options))
72 | end
73 |
74 | def submit(value = nil, options = {})
75 | if value.is_a?(Hash)
76 | options = value
77 | value = nil
78 | end
79 | value ||= submit_default_value
80 | render_component(:submit, value, options)
81 | end
82 |
83 | def button(value = nil, options = {}, &)
84 | if value.is_a?(Hash)
85 | options = value
86 | value = nil
87 | end
88 | value ||= submit_default_value
89 | render_component(:button, value, options, &)
90 | end
91 |
92 | # See: https://github.com/rails/rails/blob/fe76a95b0d252a2d7c25e69498b720c96b243ea2/actionview/lib/action_view/helpers/form_options_helper.rb
93 | def select(method, choices = nil, options = {}, html_options = {}, &)
94 | render_component(
95 | :select, @object_name, method, choices, objectify_options(options),
96 | @default_html_options.merge(html_options), &
97 | )
98 | end
99 |
100 | # rubocop:disable Metrics/ParameterLists
101 | def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
102 | render_component(
103 | :collection_select, @object_name, method, collection, value_method, text_method,
104 | objectify_options(options), @default_html_options.merge(html_options)
105 | )
106 | end
107 |
108 | def grouped_collection_select(
109 | method, collection,
110 | group_method, group_label_method, option_key_method, option_value_method,
111 | options = {}, html_options = {}
112 | )
113 | render_component(
114 | :grouped_collection_select, @object_name, method, collection, group_method,
115 | group_label_method, option_key_method, option_value_method,
116 | objectify_options(options), @default_html_options.merge(html_options)
117 | )
118 | end
119 |
120 | def collection_check_boxes(method, collection, value_method, text_method, options = {}, html_options = {},
121 | &)
122 | render_component(
123 | :collection_check_boxes, @object_name, method, collection, value_method, text_method,
124 | objectify_options(options), @default_html_options.merge(html_options), &
125 | )
126 | end
127 |
128 | def collection_radio_buttons(
129 | method, collection,
130 | value_method, text_method,
131 | options = {}, html_options = {},
132 | &
133 | )
134 | render_component(
135 | :collection_radio_buttons, @object_name, method, collection, value_method, text_method,
136 | objectify_options(options), @default_html_options.merge(html_options), &
137 | )
138 | end
139 | # rubocop:enable Metrics/ParameterLists
140 |
141 | def date_select(method, options = {}, html_options = {})
142 | render_component(
143 | :date_select, @object_name, method,
144 | objectify_options(options), @default_html_options.merge(html_options)
145 | )
146 | end
147 |
148 | def datetime_select(method, options = {}, html_options = {})
149 | render_component(
150 | :datetime_select, @object_name, method,
151 | objectify_options(options), @default_html_options.merge(html_options)
152 | )
153 | end
154 |
155 | def time_select(method, options = {}, html_options = {})
156 | render_component(
157 | :time_select, @object_name, method,
158 | objectify_options(options), @default_html_options.merge(html_options)
159 | )
160 | end
161 |
162 | def time_zone_select(method, priority_zones = nil, options = {}, html_options = {})
163 | render_component(
164 | :time_zone_select, @object_name, method, priority_zones,
165 | objectify_options(options), @default_html_options.merge(html_options)
166 | )
167 | end
168 |
169 | if defined?(ActionView::Helpers::Tags::ActionText)
170 | def rich_text_area(method, options = {})
171 | render_component(:rich_text_area, @object_name, method, objectify_options(options))
172 | end
173 | end
174 | end
175 | # rubocop:enable Metrics/ModuleLength
176 | end
177 | end
178 | end
179 |
--------------------------------------------------------------------------------
/spec/view_component/form/field_component_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe ViewComponent::Form::FieldComponent, type: :component do
4 | let(:object_klass) do
5 | Class.new do
6 | include ActiveModel::Model
7 |
8 | attr_accessor :first_name, :last_name, :email, :city
9 |
10 | validates :first_name, presence: true, length: { minimum: 2 }
11 | validates :email, presence: true, on: :custom_context
12 | validates :city, presence: true, on: %i[custom_context another_context]
13 |
14 | class << self
15 | def name
16 | "User"
17 | end
18 | end
19 | end
20 | end
21 |
22 | let(:object) { object_klass.new }
23 | let(:form) { form_with(object) }
24 | let(:method_name) { :first_name }
25 | let(:options) { {} }
26 |
27 | let(:component) { described_class.new(form, object_name, method_name, options) }
28 |
29 | describe "#tag_klass" do
30 | subject { Class.new(ChildClass).tag_klass }
31 |
32 | before do
33 | stub_const("ChildClass", Class.new(described_class) do
34 | self.tag_klass = ActionView::Helpers::Tags::TextField
35 | end)
36 | end
37 |
38 | it { is_expected.to be ActionView::Helpers::Tags::TextField }
39 | end
40 |
41 | describe "#method_errors" do
42 | context "with valid object" do
43 | let(:object) { object_klass.new(first_name: "John") }
44 |
45 | before { object.validate }
46 |
47 | it { expect(component.method_errors).to eq([]) }
48 | end
49 |
50 | context "with invalid object" do
51 | let(:object) { object_klass.new(first_name: "") }
52 |
53 | before { object.validate }
54 |
55 | it {
56 | expect(component.method_errors).to eq([I18n.t("errors.messages.blank").upcase_first,
57 | "Is too short (minimum is 2 characters)"])
58 | }
59 | end
60 |
61 | context "without object" do
62 | let(:object) { nil }
63 |
64 | it { expect(component.method_errors).to eq([]) }
65 | end
66 | end
67 |
68 | describe "#method_errors?" do
69 | context "with valid object" do
70 | let(:object) { object_klass.new(first_name: "John") }
71 |
72 | before { object.validate }
73 |
74 | it { expect(component.method_errors?).to be(false) }
75 | end
76 |
77 | context "with invalid object" do
78 | let(:object) { object_klass.new(first_name: "") }
79 |
80 | before { object.validate }
81 |
82 | it { expect(component.method_errors?).to be(true) }
83 | end
84 |
85 | context "without object" do
86 | let(:object) { nil }
87 |
88 | it { expect(component.method_errors?).to be(false) }
89 | end
90 |
91 | context "with false" do
92 | let(:object) { false }
93 |
94 | it { expect(component.method_errors?).to be(false) }
95 | end
96 | end
97 |
98 | describe "#value" do
99 | let(:object) { object_klass.new(first_name: "John") }
100 |
101 | it { expect(component.value).to eq("John") }
102 | end
103 |
104 | describe "#object_method_names" do
105 | it { expect(component.object_method_names).to eq(%i[first_name]) }
106 |
107 | it "works with belongs_to for _id", skip: "still to be implemented"
108 | it "works with has_many for _ids", skip: "still to be implemented"
109 | end
110 |
111 | describe "#label_text" do
112 | context "without translation" do
113 | it { expect(component.label_text).to eq("First name") }
114 | end
115 |
116 | context "with custom translation" do
117 | include_context "with translations"
118 |
119 | let(:translations) { { helpers: { label: { user: { first_name: "Your first name" } } } } }
120 |
121 | it { expect(component.label_text).to eq("Your first name") }
122 | end
123 | end
124 |
125 | describe "#optional?" do
126 | let(:object) { object_klass.new(first_name: "John", last_name: "Doe") }
127 |
128 | context "with required method name" do
129 | let(:method_name) { :first_name }
130 |
131 | it { expect(component.optional?).to be(false) }
132 | end
133 |
134 | context "with optional method name" do
135 | let(:method_name) { :last_name }
136 |
137 | it { expect(component.optional?).to be(true) }
138 | end
139 |
140 | context "with context" do
141 | let(:method_name) { :email }
142 |
143 | it { expect(component.optional?).to be(true) }
144 | it { expect(component.optional?(context: :custom_context)).to be(false) }
145 | end
146 |
147 | context "with context from the form" do
148 | let(:form) { form_with(object, validation_context: :custom_context) }
149 | let(:method_name) { :email }
150 |
151 | it { expect(component.optional?).to be(false) }
152 | end
153 |
154 | context "with multiple contexts" do
155 | let(:method_name) { :city }
156 |
157 | it { expect(component.optional?).to be(true) }
158 | it { expect(component.optional?(context: :custom_context)).to be(false) }
159 | it { expect(component.optional?(context: :another_context)).to be(false) }
160 | end
161 | end
162 |
163 | describe "#required?" do
164 | let(:object) { object_klass.new(first_name: "John", last_name: "Doe") }
165 |
166 | context "with required method name" do
167 | let(:method_name) { :first_name }
168 |
169 | it { expect(component.required?).to be(true) }
170 | end
171 |
172 | context "with optional method name" do
173 | let(:method_name) { :last_name }
174 |
175 | it { expect(component.required?).to be(false) }
176 | end
177 |
178 | context "with context" do
179 | let(:method_name) { :email }
180 |
181 | it { expect(component.required?).to be(false) }
182 | it { expect(component.required?(context: :custom_context)).to be(true) }
183 | end
184 |
185 | context "with context from the form" do
186 | let(:form) { form_with(object, validation_context: :custom_context) }
187 | let(:method_name) { :email }
188 |
189 | it { expect(component.required?).to be(true) }
190 | end
191 |
192 | context "with multiple contexts" do
193 | let(:method_name) { :city }
194 |
195 | it { expect(component.required?).to be(false) }
196 | it { expect(component.required?(context: :custom_context)).to be(true) }
197 | it { expect(component.required?(context: :another_context)).to be(true) }
198 | end
199 | end
200 |
201 | describe "#validators" do
202 | let(:object) { object_klass.new(first_name: "John", last_name: "Doe") }
203 |
204 | it { expect(component.validators.first).to be_a(ActiveModel::Validations::PresenceValidator) }
205 | it { expect(component.validators.last).to be_a(ActiveModel::Validations::LengthValidator) }
206 |
207 | context "with context" do
208 | let(:method_name) { :email }
209 |
210 | it { expect(component.validators).to eq([]) }
211 |
212 | it do
213 | expect(component.validators(context: :custom_context).first)
214 | .to be_a(ActiveModel::Validations::PresenceValidator)
215 | end
216 | end
217 |
218 | context "with context from the form" do
219 | let(:form) { form_with(object, validation_context: :custom_context) }
220 | let(:method_name) { :email }
221 |
222 | it do
223 | expect(component.validators.first)
224 | .to be_a(ActiveModel::Validations::PresenceValidator)
225 | end
226 | end
227 |
228 | context "with multiple contexts" do
229 | let(:method_name) { :city }
230 |
231 | it { expect(component.validators).to eq([]) }
232 |
233 | it do
234 | expect(component.validators(context: :custom_context).first)
235 | .to be_a(ActiveModel::Validations::PresenceValidator)
236 | end
237 |
238 | it do
239 | expect(component.validators(context: :another_context).first)
240 | .to be_a(ActiveModel::Validations::PresenceValidator)
241 | end
242 | end
243 | end
244 |
245 | describe "#validation_context" do
246 | context "without context" do
247 | it { expect(component.validation_context).to be_nil }
248 | end
249 |
250 | context "with context from the form" do
251 | let(:form) { form_with(object, validation_context: :custom_context) }
252 |
253 | it { expect(component.validation_context).to eq(:custom_context) }
254 | end
255 | end
256 | end
257 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | view_component-form (0.3.0)
5 | actionview (>= 7.2.0)
6 | activesupport (>= 7.2.0)
7 | view_component (>= 2.34.0, < 5.0)
8 | zeitwerk (~> 2.5)
9 |
10 | GEM
11 | remote: https://rubygems.org/
12 | specs:
13 | actioncable (8.0.2.1)
14 | actionpack (= 8.0.2.1)
15 | activesupport (= 8.0.2.1)
16 | nio4r (~> 2.0)
17 | websocket-driver (>= 0.6.1)
18 | zeitwerk (~> 2.6)
19 | actionmailbox (8.0.2.1)
20 | actionpack (= 8.0.2.1)
21 | activejob (= 8.0.2.1)
22 | activerecord (= 8.0.2.1)
23 | activestorage (= 8.0.2.1)
24 | activesupport (= 8.0.2.1)
25 | mail (>= 2.8.0)
26 | actionmailer (8.0.2.1)
27 | actionpack (= 8.0.2.1)
28 | actionview (= 8.0.2.1)
29 | activejob (= 8.0.2.1)
30 | activesupport (= 8.0.2.1)
31 | mail (>= 2.8.0)
32 | rails-dom-testing (~> 2.2)
33 | actionpack (8.0.2.1)
34 | actionview (= 8.0.2.1)
35 | activesupport (= 8.0.2.1)
36 | nokogiri (>= 1.8.5)
37 | rack (>= 2.2.4)
38 | rack-session (>= 1.0.1)
39 | rack-test (>= 0.6.3)
40 | rails-dom-testing (~> 2.2)
41 | rails-html-sanitizer (~> 1.6)
42 | useragent (~> 0.16)
43 | actiontext (8.0.2.1)
44 | actionpack (= 8.0.2.1)
45 | activerecord (= 8.0.2.1)
46 | activestorage (= 8.0.2.1)
47 | activesupport (= 8.0.2.1)
48 | globalid (>= 0.6.0)
49 | nokogiri (>= 1.8.5)
50 | actionview (8.0.2.1)
51 | activesupport (= 8.0.2.1)
52 | builder (~> 3.1)
53 | erubi (~> 1.11)
54 | rails-dom-testing (~> 2.2)
55 | rails-html-sanitizer (~> 1.6)
56 | activejob (8.0.2.1)
57 | activesupport (= 8.0.2.1)
58 | globalid (>= 0.3.6)
59 | activemodel (8.0.2.1)
60 | activesupport (= 8.0.2.1)
61 | activerecord (8.0.2.1)
62 | activemodel (= 8.0.2.1)
63 | activesupport (= 8.0.2.1)
64 | timeout (>= 0.4.0)
65 | activestorage (8.0.2.1)
66 | actionpack (= 8.0.2.1)
67 | activejob (= 8.0.2.1)
68 | activerecord (= 8.0.2.1)
69 | activesupport (= 8.0.2.1)
70 | marcel (~> 1.0)
71 | activesupport (8.0.2.1)
72 | base64
73 | benchmark (>= 0.3)
74 | bigdecimal
75 | concurrent-ruby (~> 1.0, >= 1.3.1)
76 | connection_pool (>= 2.2.5)
77 | drb
78 | i18n (>= 1.6, < 2)
79 | logger (>= 1.4.2)
80 | minitest (>= 5.1)
81 | securerandom (>= 0.3)
82 | tzinfo (~> 2.0, >= 2.0.5)
83 | uri (>= 0.13.1)
84 | addressable (2.8.7)
85 | public_suffix (>= 2.0.2, < 7.0)
86 | appraisal (2.5.0)
87 | bundler
88 | rake
89 | thor (>= 0.14.0)
90 | appraisal-run (1.1.0)
91 | ast (2.4.3)
92 | base64 (0.3.0)
93 | benchmark (0.5.0)
94 | bigdecimal (3.3.1)
95 | builder (3.3.0)
96 | capybara (3.40.0)
97 | addressable
98 | matrix
99 | mini_mime (>= 0.1.3)
100 | nokogiri (~> 1.11)
101 | rack (>= 1.6.0)
102 | rack-test (>= 0.6.3)
103 | regexp_parser (>= 1.5, < 3.0)
104 | xpath (~> 3.2)
105 | combustion (1.3.7)
106 | activesupport (>= 3.0.0)
107 | railties (>= 3.0.0)
108 | thor (>= 0.14.6)
109 | concurrent-ruby (1.3.4)
110 | connection_pool (2.5.4)
111 | crass (1.0.6)
112 | date (3.4.1)
113 | diff-lcs (1.6.2)
114 | docile (1.4.1)
115 | drb (2.2.3)
116 | erb (5.0.1)
117 | erubi (1.13.1)
118 | generator_spec (0.10.0)
119 | activesupport (>= 3.0.0)
120 | railties (>= 3.0.0)
121 | globalid (1.3.0)
122 | activesupport (>= 6.1)
123 | i18n (1.14.7)
124 | concurrent-ruby (~> 1.0)
125 | io-console (0.8.0)
126 | irb (1.15.2)
127 | pp (>= 0.6.0)
128 | rdoc (>= 4.0.0)
129 | reline (>= 0.4.2)
130 | json (2.12.2)
131 | language_server-protocol (3.17.0.5)
132 | lint_roller (1.1.0)
133 | logger (1.7.0)
134 | loofah (2.24.1)
135 | crass (~> 1.0.2)
136 | nokogiri (>= 1.12.0)
137 | mail (2.8.1)
138 | mini_mime (>= 0.1.1)
139 | net-imap
140 | net-pop
141 | net-smtp
142 | marcel (1.1.0)
143 | matrix (0.4.2)
144 | method_source (1.1.0)
145 | mini_mime (1.1.5)
146 | mini_portile2 (2.8.9)
147 | minitest (5.26.0)
148 | net-imap (0.5.8)
149 | date
150 | net-protocol
151 | net-pop (0.1.2)
152 | net-protocol
153 | net-protocol (0.2.2)
154 | timeout
155 | net-smtp (0.5.1)
156 | net-protocol
157 | nio4r (2.7.4)
158 | nokogiri (1.18.10)
159 | mini_portile2 (~> 2.8.2)
160 | racc (~> 1.4)
161 | nokogiri (1.18.10-x86_64-darwin)
162 | racc (~> 1.4)
163 | nokogiri (1.18.10-x86_64-linux-gnu)
164 | racc (~> 1.4)
165 | parallel (1.27.0)
166 | parser (3.3.8.0)
167 | ast (~> 2.4.1)
168 | racc
169 | pp (0.6.2)
170 | prettyprint
171 | prettyprint (0.2.0)
172 | prism (1.4.0)
173 | psych (5.2.6)
174 | date
175 | stringio
176 | public_suffix (6.0.2)
177 | racc (1.8.1)
178 | rack (3.2.3)
179 | rack-session (2.1.1)
180 | base64 (>= 0.1.0)
181 | rack (>= 3.0.0)
182 | rack-test (2.2.0)
183 | rack (>= 1.3)
184 | rackup (2.2.1)
185 | rack (>= 3)
186 | rails (8.0.2.1)
187 | actioncable (= 8.0.2.1)
188 | actionmailbox (= 8.0.2.1)
189 | actionmailer (= 8.0.2.1)
190 | actionpack (= 8.0.2.1)
191 | actiontext (= 8.0.2.1)
192 | actionview (= 8.0.2.1)
193 | activejob (= 8.0.2.1)
194 | activemodel (= 8.0.2.1)
195 | activerecord (= 8.0.2.1)
196 | activestorage (= 8.0.2.1)
197 | activesupport (= 8.0.2.1)
198 | bundler (>= 1.15.0)
199 | railties (= 8.0.2.1)
200 | rails-dom-testing (2.3.0)
201 | activesupport (>= 5.0.0)
202 | minitest
203 | nokogiri (>= 1.6)
204 | rails-html-sanitizer (1.6.2)
205 | loofah (~> 2.21)
206 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
207 | railties (8.0.2.1)
208 | actionpack (= 8.0.2.1)
209 | activesupport (= 8.0.2.1)
210 | irb (~> 1.13)
211 | rackup (>= 1.0.0)
212 | rake (>= 12.2)
213 | thor (~> 1.0, >= 1.2.2)
214 | zeitwerk (~> 2.6)
215 | rainbow (3.1.1)
216 | rake (13.3.0)
217 | rdoc (6.14.0)
218 | erb
219 | psych (>= 4.0.0)
220 | regexp_parser (2.10.0)
221 | reline (0.6.1)
222 | io-console (~> 0.5)
223 | rspec (3.13.1)
224 | rspec-core (~> 3.13.0)
225 | rspec-expectations (~> 3.13.0)
226 | rspec-mocks (~> 3.13.0)
227 | rspec-core (3.13.4)
228 | rspec-support (~> 3.13.0)
229 | rspec-expectations (3.13.5)
230 | diff-lcs (>= 1.2.0, < 2.0)
231 | rspec-support (~> 3.13.0)
232 | rspec-html-matchers (0.10.0)
233 | nokogiri (~> 1)
234 | rspec (>= 3.0.0.a)
235 | rspec-mocks (3.13.5)
236 | diff-lcs (>= 1.2.0, < 2.0)
237 | rspec-support (~> 3.13.0)
238 | rspec-rails (8.0.0)
239 | actionpack (>= 7.2)
240 | activesupport (>= 7.2)
241 | railties (>= 7.2)
242 | rspec-core (~> 3.13)
243 | rspec-expectations (~> 3.13)
244 | rspec-mocks (~> 3.13)
245 | rspec-support (~> 3.13)
246 | rspec-support (3.13.4)
247 | rubocop (1.76.0)
248 | json (~> 2.3)
249 | language_server-protocol (~> 3.17.0.2)
250 | lint_roller (~> 1.1.0)
251 | parallel (~> 1.10)
252 | parser (>= 3.3.0.2)
253 | rainbow (>= 2.2.2, < 4.0)
254 | regexp_parser (>= 2.9.3, < 3.0)
255 | rubocop-ast (>= 1.45.0, < 2.0)
256 | ruby-progressbar (~> 1.7)
257 | unicode-display_width (>= 2.4.0, < 4.0)
258 | rubocop-ast (1.45.0)
259 | parser (>= 3.3.7.2)
260 | prism (~> 1.4)
261 | rubocop-performance (1.25.0)
262 | lint_roller (~> 1.1)
263 | rubocop (>= 1.75.0, < 2.0)
264 | rubocop-ast (>= 1.38.0, < 2.0)
265 | rubocop-rspec (3.6.0)
266 | lint_roller (~> 1.1)
267 | rubocop (~> 1.72, >= 1.72.1)
268 | ruby-progressbar (1.13.0)
269 | securerandom (0.4.1)
270 | simplecov (0.22.0)
271 | docile (~> 1.1)
272 | simplecov-html (~> 0.11)
273 | simplecov_json_formatter (~> 0.1)
274 | simplecov-html (0.13.1)
275 | simplecov_json_formatter (0.1.4)
276 | sqlite3 (2.6.0)
277 | mini_portile2 (~> 2.8.0)
278 | sqlite3 (2.6.0-x86_64-darwin)
279 | sqlite3 (2.6.0-x86_64-linux-gnu)
280 | stringio (3.1.7)
281 | thor (1.4.0)
282 | timeout (0.4.3)
283 | tzinfo (2.0.6)
284 | concurrent-ruby (~> 1.0)
285 | unicode-display_width (3.1.4)
286 | unicode-emoji (~> 4.0, >= 4.0.4)
287 | unicode-emoji (4.0.4)
288 | uri (1.0.4)
289 | useragent (0.16.11)
290 | view_component (3.23.2)
291 | activesupport (>= 5.2.0, < 8.1)
292 | concurrent-ruby (~> 1)
293 | method_source (~> 1.0)
294 | websocket-driver (0.8.0)
295 | base64
296 | websocket-extensions (>= 0.1.0)
297 | websocket-extensions (0.1.5)
298 | xpath (3.2.0)
299 | nokogiri (~> 1.8)
300 | zeitwerk (2.7.3)
301 |
302 | PLATFORMS
303 | ruby
304 | x86_64-darwin-19
305 | x86_64-linux
306 |
307 | DEPENDENCIES
308 | appraisal (~> 2)
309 | appraisal-run (~> 1.0)
310 | capybara
311 | combustion (~> 1.3.7)
312 | concurrent-ruby (= 1.3.4)
313 | generator_spec
314 | rails
315 | rake (~> 13.0)
316 | rspec (~> 3.0)
317 | rspec-html-matchers
318 | rspec-rails
319 | rubocop
320 | rubocop-performance
321 | rubocop-rspec
322 | simplecov
323 | sqlite3 (~> 2.1)
324 | view_component-form!
325 |
326 | BUNDLED WITH
327 | 2.6.9
328 |
--------------------------------------------------------------------------------
/spec/view_component/form/builder_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "../../fixtures/test_model"
4 |
5 | RSpec.describe ViewComponent::Form::Builder, type: :builder do
6 | let(:object) { OpenStruct.new }
7 | let(:form) { form_with(object) }
8 | let(:options) { {} }
9 |
10 | rails8_or_newer = Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new("8.0")
11 |
12 | shared_examples "the default form builder" do |method_name, *args, rspec_around: lambda(&:run), **kwargs, &block|
13 | around(&rspec_around)
14 | subject { form.public_send(method_name, *args, **kwargs, &block) }
15 |
16 | let(:default_form_builder) { form_with(object, builder: ActionView::Helpers::FormBuilder) }
17 | let(:default_form_builder_output) { default_form_builder.public_send(method_name, *args, **kwargs, &block) }
18 |
19 | context "when calling ##{method_name}" do
20 | # NOTE: freeze time to force #time_select test use same time
21 | it do
22 | freeze_time do
23 | expect(subject).to eq(default_form_builder_output)
24 | end
25 | end
26 | end
27 | end
28 |
29 | shared_examples "it renders a component" do |component_klass, method_name, *args, rspec_around: lambda(&:run), **kwargs, &block| # rubocop:disable Layout/LineLength
30 | around(&rspec_around)
31 | subject { form.public_send(method_name, *args, **kwargs, &block) }
32 |
33 | context "when calling ##{method_name}" do
34 | it "renders the #{component_klass}" do
35 | instance_spy = instance_spy(component_klass)
36 | allow(component_klass).to receive(:new).and_return(instance_spy)
37 |
38 | subject
39 |
40 | expect(instance_spy).to have_received(:render_in).once
41 | end
42 | end
43 | end
44 |
45 | it_behaves_like "the default form builder", :check_box, "validated"
46 | it_behaves_like "the default form builder", :check_box, "gooddog", {}, "yes", "no"
47 | it_behaves_like "the default form builder", :check_box, "accepted", { class: "eula_check" }, "yes", "no"
48 |
49 | it_behaves_like "it renders a component", ViewComponent::Form::CheckBoxComponent, :check_box, "validated"
50 | if rails8_or_newer
51 | it_behaves_like "the default form builder", :checkbox, "validated"
52 | it_behaves_like "it renders a component", ViewComponent::Form::CheckBoxComponent, :checkbox, "validated"
53 | end
54 |
55 | context "with model-dependent fields" do
56 | before do
57 | Author.create(name_with_initial: "Touma K.")
58 | Author.create(name_with_initial: "Rintaro S.")
59 | Author.create(name_with_initial: "Kento F.")
60 | end
61 |
62 | it_behaves_like "the default form builder", :collection_check_boxes, :author_ids, Author.all, :id,
63 | :name_with_initial
64 | it_behaves_like "it renders a component", ViewComponent::Form::CollectionCheckBoxesComponent,
65 | :collection_check_boxes, :author_ids, Author.all, :id, :name_with_initial
66 |
67 | if rails8_or_newer
68 | it_behaves_like "it renders a component", ViewComponent::Form::CollectionCheckBoxesComponent,
69 | :collection_checkboxes, :author_ids, Author.all, :id, :name_with_initial
70 | it_behaves_like "the default form builder", :collection_checkboxes,
71 | :author_ids, Author.all, :id, :name_with_initial
72 | end
73 |
74 | it_behaves_like "the default form builder", :collection_radio_buttons, :author_id, Author.all, :id,
75 | :name_with_initial
76 | it_behaves_like "the default form builder", :collection_select, :person_id, Author.all, :id, :name_with_initial,
77 | { prompt: true }
78 | end
79 |
80 | it_behaves_like "the default form builder", :color_field, :favorite_color
81 | it_behaves_like "the default form builder", :date_field, :born_on
82 | it_behaves_like "the default form builder", :date_select, :birth_date
83 | it_behaves_like "the default form builder", :datetime_field, :graduation_day
84 | it_behaves_like "the default form builder", :datetime_local_field, :graduation_day
85 | it_behaves_like "the default form builder", :datetime_select, :last_request_at
86 | it_behaves_like "the default form builder", :email_field, :address
87 | it_behaves_like "the default form builder", :file_field, :avatar
88 | it_behaves_like "the default form builder", :file_field, :image, { multiple: true }
89 | it_behaves_like "the default form builder", :file_field, :attached, { accept: "text/html" }
90 | it_behaves_like "the default form builder", :file_field, :image, { accept: "image/png,image/gif,image/jpeg" }
91 | it_behaves_like "the default form builder", :file_field, :file, { class: "file_input" }
92 |
93 | context "with fields dependent on Continent" do
94 | let(:object) { City.new(country: Country.find_by!(name: "Denmark")) }
95 |
96 | before do
97 | Continent.create(name: "Africa")
98 | .countries.tap do |countries|
99 | countries.create(name: "South Africa")
100 | countries.create(name: "Somalia")
101 | end
102 | Continent.create(name: "Europe")
103 | .countries.tap do |countries|
104 | countries.create(name: "Denmark")
105 | countries.create(name: "Ireland")
106 | end
107 | end
108 |
109 | it_behaves_like "the default form builder", :grouped_collection_select,
110 | :country_id, Continent.all, :countries, :name, :id, :name
111 | end
112 |
113 | context "with values from the object" do
114 | let(:object) { HiddenFieldTest.new(pass_confirm: true, tag_list: "blog, ruby", token: "abcde") }
115 |
116 | it_behaves_like "the default form builder", :hidden_field, :pass_confirm
117 | it_behaves_like "the default form builder", :hidden_field, :tag_list
118 | it_behaves_like "the default form builder", :hidden_field, :token
119 | end
120 |
121 | it_behaves_like "the default form builder", :label, :title
122 | it_behaves_like "the default form builder", :label, :body
123 | # rubocop:disable RSpec/PendingWithoutReason
124 | skip "This would demonstrate translations via i18n.yml" do
125 | it_behaves_like "the default form builder", :label, :cost
126 | end
127 | # rubocop:enable RSpec/PendingWithoutReason
128 |
129 | it_behaves_like "the default form builder", :label, :title, "A short title"
130 | it_behaves_like "the default form builder", :label, :privacy, "Public Post", value: "public"
131 |
132 | # rubocop:disable RSpec/PendingWithoutReason
133 | skip "These helpers also take blocks" do
134 | it_behaves_like("the default form builder", :label, [:cost]) do |translation|
135 | content_tag(:span, translation, class: "cost_label")
136 | end
137 | it_behaves_like("the default form builder", :label, [:cost]) do |builder|
138 | content_tag(:span, builder, class: "cost_label")
139 | end
140 | it_behaves_like("the default form builder", :label, [:cost]) do |builder|
141 | content_tag(:span, builder.translation, class: [
142 | "cost_label",
143 | ("error_label" if builder.object.errors.include?(:cost))
144 | ])
145 | end
146 | it_behaves_like("the default form builder", :label, [:terms]) { raw('Accept Terms.') }
147 | end
148 | # rubocop:enable RSpec/PendingWithoutReason
149 | # rubocop:enable RSpec/ExampleLength
150 |
151 | it_behaves_like "the default form builder", :month_field, :birthday_month
152 | it_behaves_like "the default form builder", :number_field, :age
153 | it_behaves_like "the default form builder", :password_field, :password
154 | it_behaves_like "the default form builder", :phone_field, :phone
155 | it_behaves_like "the default form builder", :telephone_field, :phone
156 | it_behaves_like "the default form builder", :radio_button, "category", "rails"
157 | it_behaves_like "the default form builder", :radio_button, "category", "java"
158 | it_behaves_like "the default form builder", :radio_button, "receive_newsletter", "yes"
159 | it_behaves_like "the default form builder", :radio_button, "receive_newsletter", "no"
160 | it_behaves_like "the default form builder", :range_field, :age
161 |
162 | context "with fields dependent on Person" do
163 | before do
164 | Person.create(name: "Touma")
165 | Person.create(name: "Rintaro")
166 | Person.create(name: "Kento")
167 | end
168 |
169 | it_behaves_like "the default form builder", :select, :person_id, Person.pluck(:name, :id), { include_blank: true }
170 | end
171 |
172 | it_behaves_like "the default form builder", :submit
173 |
174 | it_behaves_like "the default form builder", :text_area, :detail
175 | it_behaves_like "it renders a component", ViewComponent::Form::TextAreaComponent, :text_area, :detail
176 | if rails8_or_newer
177 | it_behaves_like "the default form builder", :textarea, :detail
178 | it_behaves_like "it renders a component", ViewComponent::Form::TextAreaComponent, :textarea, :detail
179 | end
180 |
181 | if defined?(ActionView::Helpers::Tags::ActionText)
182 | context "with ActionText" do
183 | let(:object) { TestModel.new }
184 |
185 | it_behaves_like "the default form builder", :rich_text_area, :foo,
186 | data: { direct_upload_url: ".", blob_url_template: "." }
187 | it_behaves_like "it renders a component", ViewComponent::Form::RichTextAreaComponent, :rich_text_area, :foo,
188 | data: { direct_upload_url: ".", blob_url_template: "." }
189 | if rails8_or_newer
190 | it_behaves_like "the default form builder", :rich_textarea, :foo,
191 | data: { direct_upload_url: ".", blob_url_template: "." }
192 | it_behaves_like "it renders a component", ViewComponent::Form::RichTextAreaComponent, :rich_textarea, :foo,
193 | data: { direct_upload_url: ".", blob_url_template: "." }
194 | end
195 | end
196 | end
197 |
198 | it_behaves_like "the default form builder", :text_field, :name
199 | it_behaves_like "the default form builder", :time_field, :born_at
200 | it_behaves_like "the default form builder", :time_select, :average_lap
201 | it_behaves_like "the default form builder", :time_zone_select, :time_zone, nil, { include_blank: true }
202 | it_behaves_like "the default form builder", :url_field, :homepage
203 | it_behaves_like "the default form builder", :week_field, :birthday_week
204 | it_behaves_like "the default form builder", :weekday_select, :weekday, { include_blank: true }
205 |
206 | describe "#component_klass" do
207 | context "with gem Builder" do
208 | let(:builder) { described_class.new(object_name, object, template, options) }
209 |
210 | it { expect(builder.send(:component_klass, :label)).to eq(ViewComponent::Form::LabelComponent) }
211 | it { expect(builder.send(:component_klass, :text_field)).to eq(ViewComponent::Form::TextFieldComponent) }
212 | it { expect(builder.send(:component_klass, :submit)).to eq(ViewComponent::Form::SubmitComponent) }
213 | end
214 |
215 | context "with custom Builder" do
216 | let(:builder) { CustomFormBuilder.new(object_name, object, template, options) }
217 |
218 | it { expect(builder.send(:component_klass, :label)).to eq(Form::LabelComponent) }
219 | it { expect(builder.send(:component_klass, :text_field)).to eq(Form::TextFieldComponent) }
220 | it { expect(builder.send(:component_klass, :submit)).to eq(ViewComponent::Form::SubmitComponent) }
221 | end
222 |
223 | context "with embeded builder" do
224 | let(:builder) { InlineCustomFormBuilder.new(object_name, object, template, options) }
225 |
226 | it { expect(builder.send(:component_klass, :label)).to eq(InlineForm::LabelComponent) }
227 | it { expect(builder.send(:component_klass, :text_field)).to eq(Form::TextFieldComponent) }
228 | it { expect(builder.send(:component_klass, :submit)).to eq(ViewComponent::Form::SubmitComponent) }
229 | end
230 |
231 | context "with a custom lookup_chain" do
232 | let(:builder) { CustomFormBuilder.new(object_name, object, template, options) }
233 |
234 | around do |example|
235 | original = ViewComponent::Form.configuration.lookup_chain
236 | ViewComponent::Form.configuration.lookup_chain.prepend(lambda do |component_name, namespaces: []|
237 | namespaces.lazy.map do |namespace|
238 | "#{namespace}::#{component_name.to_s.camelize}".safe_constantize
239 | end.find(&:itself)
240 | end)
241 |
242 | example.run
243 |
244 | ViewComponent::Form.configuration.lookup_chain = original
245 | end
246 |
247 | it { expect(builder.send(:component_klass, :label)).to eq(Form::LabelComponent) }
248 | it { expect(builder.send(:component_klass, :text_field)).to eq(Form::TextField) }
249 | it { expect(builder.send(:component_klass, :submit)).to eq(ViewComponent::Form::SubmitComponent) }
250 | end
251 | end
252 |
253 | describe "#field_id" do
254 | it_behaves_like "the default form builder", :field_id, :first_name, :hint
255 | end
256 |
257 | describe "#validation_context" do
258 | let(:builder) { described_class.new(object_name, object, template, options) }
259 |
260 | context "without context" do
261 | it { expect(builder.send(:validation_context)).to be_nil }
262 | end
263 |
264 | context "with context" do
265 | let(:options) { { validation_context: :create } }
266 |
267 | it { expect(builder.send(:validation_context)).to eq(:create) }
268 | end
269 | end
270 |
271 | describe "base component parent" do
272 | subject(:field) { described_class.new(object_name, object, template, options).send(:component_klass, :text_field) }
273 |
274 | it "is configured via initializer" do
275 | expect(field.ancestors).to include(ApplicationFormComponent)
276 | end
277 | end
278 | end
279 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ViewComponent::Form
2 |
3 | **`ViewComponent::Form`** is a customizable form builder using the same interface as [`ActionView::Helpers::FormBuilder`](https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html) but with extensible [ViewComponent](https://github.com/github/view_component) components.
4 |
5 | Development of this gem is sponsored by:
6 |
7 |
8 |
9 | ## Compatibility
10 |
11 | > [!WARNING]
12 | > **This is an early release, and the API is subject to change until `v1.0.0`.**
13 |
14 | This gem is tested on:
15 |
16 | - Rails 7.2+ (with or without ActionText)
17 | - Ruby 3.2+
18 |
19 | ## Installation
20 |
21 | ```shell
22 | bundle add view_component-form
23 | ```
24 |
25 | ### Configuration
26 |
27 | ```ruby
28 | # config/initializers/vcf.rb
29 |
30 | ViewComponent::Form.configure do |config|
31 | config.parent_component = 'ApplicationFormComponent'
32 | end
33 | ```
34 |
35 | | Attribute | Purpose | Default |
36 | | --------------------------- | ----------------------------------------------------- | ----------------------- |
37 | | `parent_component` (string) | Parent class for all `ViewComponent::Form` components | `"ViewComponent::Base"` |
38 |
39 | #### Configuring component lookup
40 |
41 | `ViewComponent::Form` will automatically infer the component class with a `Component` suffix. You can customize the lookup using the `lookup_chain`:
42 |
43 | ```rb
44 | # config/initializers/vcf.rb
45 |
46 | ViewComponent::Form.configure do |config|
47 | without_component_suffix = lambda do |component_name, namespaces: []|
48 | namespaces.lazy.map do |namespace|
49 | "#{namespace}::#{component_name.to_s.camelize}".safe_constantize
50 | end.find(&:itself)
51 | end
52 |
53 | config.lookup_chain.prepend(without_component_suffix)
54 | end
55 | ```
56 |
57 | `ViewComponent::Form` will iterate through the `lookup_chain` until a value is returned. By using `prepend` we can fallback on the default `ViewComponent::Form` lookup.
58 |
59 | ## Usage
60 |
61 | Add your own form builder.
62 |
63 | ```shell
64 | bin/rails generate vcf:builder FormBuilder
65 | create app/helpers/form_builder.rb
66 | ```
67 |
68 | To use the form builder:
69 |
70 | - add a `builder` param to your `form_for`, `form_with`, `fields_for` or `fields`:
71 |
72 | ```diff
73 | - <%= form_for @user do |f| %>
74 | + <%= form_for @user, builder: FormBuilder do |f| %>
75 | ```
76 |
77 | - or; set it as a default in your controller using [default_form_builder](https://api.rubyonrails.org/classes/ActionController/FormBuilder.html#method-i-default_form_builder).
78 |
79 | ```ruby
80 | # app/controllers/application_controller.rb
81 | class ApplicationController < ActionController::Base
82 | default_form_builder FormBuilder
83 | end
84 | ```
85 |
86 | Then use ActionView form builder helpers as you would normally:
87 |
88 | ```erb
89 | <%# app/views/users/_form.html.erb %>
90 | <%= form_for @user, builder: ViewComponent::Form::Builder do |f| %>
91 | <%= f.label :first_name %> <%# renders a ViewComponent::Form::LabelComponent %>
92 | <%= f.text_field :first_name %> <%# renders a ViewComponent::Form::TextFieldComponent %>
93 |
94 | <%= f.label :last_name %> <%# renders a ViewComponent::Form::LabelComponent %>
95 | <%= f.text_field :last_name %> <%# renders a ViewComponent::Form::TextFieldComponent %>
96 |
97 | <%= f.label :email %> <%# renders a ViewComponent::Form::LabelComponent %>
98 | <%= f.email_field :email %> <%# renders a ViewComponent::Form::EmailFieldComponent %>
99 |
100 | <%= f.label :password %> <%# renders a ViewComponent::Form::LabelComponent %>
101 | <%= f.password_field :password, aria: { describedby: f.field_id(:password, :description) } %>
102 | <%# renders a ViewComponent::Form::PasswordFieldComponent %>
103 |
104 | <%= f.hint :password, 'The password should be at least 8 characters long' %>
105 | <%# renders a ViewComponent::Form::HintComponent %>
106 | <%= f.error_message :password %> <%# renders a ViewComponent::Form::ErrorMessageComponent %>
107 |
108 | <% end %>
109 | ```
110 |
111 | ### Customizing built-in components
112 |
113 | The `ViewComponent::Form::Builder` will use the provided `namespace` to find any components you've customized.
114 |
115 | ```ruby
116 | # app/helpers/form_builder.rb
117 | class FormBuilder < ViewComponent::Form::Builder
118 | namespace Form
119 | end
120 | ```
121 |
122 | Let's customize the `text_field` helper by generating a new [ViewComponent](https://github.com/github/view_component) in the namespace defined within the builder.
123 |
124 | ```shell
125 | bin/rails generate component Form::TextField --parent ViewComponent::Form::TextFieldComponent --inline
126 | ```
127 |
128 | ```ruby
129 | # app/components/form/text_field_component.rb
130 | class Form::TextFieldComponent < ViewComponent::Form::TextFieldComponent
131 | def html_class
132 | class_names("custom-text-field", "has-error": method_errors?)
133 | end
134 | end
135 | ```
136 |
137 | In this case we're leveraging the [`#class_names`](https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-class_names) helper to:
138 |
139 | - always add the `custom-text-field` class;
140 | - add the `has-error` class if there is an error on the attribute (using `ViewComponent::Form::FieldComponent#method_errors?`).
141 |
142 | ### Adding your own custom helpers and components
143 |
144 | Add the helper method to your `ViewComponent::Form::Builder`
145 |
146 | ```rb
147 | # app/helpers/form_builder.rb
148 | class FormBuilder < ViewComponent::Form::Builder
149 | def year_field(method, options = {})
150 | render_component(:year_field, @object_name, method, objectify_options(options))
151 | end
152 |
153 | def money_field(method, currencies = [], options = {})
154 | render_component(:money_field, @object_name, method, currencies, objectify_options(options))
155 | end
156 | end
157 | ```
158 |
159 | Add your component which can optionally inherit from:
160 |
161 | - `ViewComponent::Form::FieldComponent` (suggested when adding a field because of helpers)
162 | - `ViewComponent::Form::BaseComponent`
163 | - or any of the `ViewComponent::Form::*Component` such as `ViewComponent::Form::TextFieldComponent`
164 |
165 | ```rb
166 | # app/components/form/year_field_component.rb
167 | class Form::YearFieldComponent < ViewComponent::Form::FieldComponent # or ViewComponent::Form::BaseComponent
168 | end
169 | ```
170 |
171 | When inheriting from `ViewComponent::Form::FieldComponent`, you get access to the following helpers:
172 |
173 | #### `#label_text`
174 |
175 | Returns the translated text for the label of the field (looking up for `helpers.label.OBJECT.METHOD_NAME`), or humanized version of the method name if not available.
176 |
177 | ```rb
178 | # app/components/custom/form/group_component.rb
179 | class Custom::Form::GroupComponent < ViewComponent::Form::FieldComponent
180 | end
181 | ```
182 |
183 | ```erb
184 | <%# app/components/custom/form/group_component.html.erb %>
185 |
186 |
190 |
191 | ```
192 |
193 | ```erb
194 | <%# app/views/users/_form.html.erb %>
195 | <%= form_for @user do |f| %>
196 | <%= f.group :first_name do %>
197 | <%= f.text_field :first_name %>
198 | <% end %>
199 | <% end %>
200 | ```
201 |
202 | ```yml
203 | # config/locales/en.yml
204 | en:
205 | helpers:
206 | label:
207 | user:
208 | first_name: Your first name
209 | ```
210 |
211 | Renders:
212 |
213 | ```html
214 |
232 | ```
233 |
234 | ### Validations
235 |
236 | Let's consider the following model for the examples below.
237 |
238 | ```rb
239 | # app/models/user.rb
240 | class User < ActiveRecord::Base
241 | validates :first_name, presence: true, length: { minimum: 2, maximum: 255 }
242 | end
243 | ```
244 |
245 | ##### Accessing validations with `#validators`
246 |
247 | Returns all validators for the method name.
248 |
249 | ```rb
250 | # app/components/custom/form/group_component.rb
251 | class Custom::Form::GroupComponent < ViewComponent::Form::FieldComponent
252 | private
253 |
254 | def validation_hint
255 | if length_validator
256 | "between #{length_validator.options[:minimum]} and #{length_validator.options[:maximum]} chars"
257 | end
258 | end
259 |
260 | def length_validator
261 | validators.find { |v| v.is_a?(ActiveModel::Validations::LengthValidator) }
262 | end
263 | end
264 | ```
265 |
266 | ```erb
267 | <%# app/components/custom/form/group_component.html.erb %>
268 |
286 | ```
287 |
288 | ##### Validation contexts
289 |
290 | When using [validation contexts](https://guides.rubyonrails.org/active_record_validations.html#on), you can specify a context to the helpers above.
291 |
292 | ```rb
293 | # app/models/user.rb
294 | class User < ActiveRecord::Base
295 | validates :first_name, presence: true, length: { minimum: 2, maximum: 255 }
296 | validates :email, presence: true, on: :registration
297 | end
298 | ```
299 |
300 | ```erb
301 | <%# app/views/users/_form_.html.erb %>
302 | <%= form_with model: @user,
303 | builder: ViewComponent::Form::Builder,
304 | validation_context: :registration do |f| %>
305 | <%= f.group :email do %>
306 | <%= f.email_field :email %>
307 | <% end %>
308 | <% end %>
309 | ```
310 |
311 | In this case, `ViewComponent::Form::Builder` accepts a `validation_context` option and passes it as a default value to the `#validators`, `#required?` and `#optional?` helpers.
312 |
313 | Alternatively, you can pass the context to the helpers:
314 |
315 | ```erb
316 | <%= "(required)" if required?(context: :registration) %>
317 | ```
318 |
319 | ```rb
320 | def length_validator
321 | validators(context: :registration).find { |v| v.is_a?(ActiveModel::Validations::LengthValidator) }
322 | end
323 | ```
324 |
325 | ### Setting up your own base component class
326 |
327 | 1. Setup some base component from which the form components will inherit from
328 |
329 | ```rb
330 | class ApplicationFormComponent < ViewComponent::Base
331 | end
332 | ```
333 |
334 | 2. Configure the parent component class
335 |
336 | ```rb
337 | # config/initializers/vcf.rb
338 |
339 | ViewComponent::Form.configure do |config|
340 | config.parent_component = 'ApplicationFormComponent'
341 | end
342 | ```
343 |
344 | ### Using your form components without a backing model
345 |
346 | If you want to ensure that your fields display consistently across your app, you'll need to lean on Rails' own helpers. You may be used to using form tag helpers such as `text_field_tag` to generate tags, or even writing out plain HTML tags. These can't be integrated with a form builder, so they won't offer you the benefits of this gem.
347 |
348 | You'll most likely want to use either:
349 |
350 | - [`form_with`](https://api.rubyonrails.org/v6.1.4/classes/ActionView/Helpers/FormHelper.html#method-i-form_with) and supply a route as the endpoint, e.g. `form_with url: users_path do |f| ...`, or
351 | - [`fields`](https://api.rubyonrails.org/v6.1.4/classes/ActionView/Helpers/FormHelper.html#method-i-fields), supplying a namespace if necessary. `fields do |f| ...` ought to work in the most basic case.
352 |
353 | [`fields_for`](https://api.rubyonrails.org/v6.1.4/classes/ActionView/Helpers/FormHelper.html#method-i-fields_for) may also be of interest. To make consistent use of `view_component-form`, you'll want to be using these three helpers to build your forms wherever possible.
354 |
355 | ## Supported helpers
356 |
357 | The following helpers are currently supported by `ViewComponent::Form`.
358 |
359 | ### `ActionView::Helpers::FormBuilder`
360 |
361 | **Supported:** `button` `check_box` `collection_check_boxes` `collection_radio_buttons` `collection_select` `color_field` `date_field` `date_select` `datetime_field` `datetime_local_field` `datetime_select` `email_field` `fields` `fields_for` `file_field` `field_id` `grouped_collection_select` `hidden_field` `month_field` `number_field` `password_field` `phone_field` `radio_button` `range_field` `search_field` `select` `submit` `telephone_field` `textarea` (formerly `text_area` before Rails 8) `text_field` `time_field` `time_select` `time_zone_select` `to_model` `to_partial_path` `url_field` `week_field` `weekday_select`
362 |
363 | **Partially supported:** `label` (blocks not supported) `rich_textarea` (formerly`rich_text_area` before Rails 8) (untested)
364 |
365 | **Unsupported for now:** `field_name`
366 |
367 | ### Specific to `ViewComponent::Form`
368 |
369 | **Supported:** `error_message` `hint`
370 |
371 | ## Testing your components
372 |
373 | ### RSpec
374 |
375 | #### Configuration
376 |
377 | This assumes your already have read and configured [tests for `view_component`](https://viewcomponent.org/guide/testing.html#rspec-configuration).
378 |
379 | ```rb
380 | # spec/rails_helper.rb
381 | require "view_component/test_helpers"
382 | require "view_component/form/test_helpers"
383 | require "capybara/rspec"
384 |
385 | RSpec.configure do |config|
386 | config.include ViewComponent::TestHelpers, type: :component
387 | config.include ViewComponent::Form::TestHelpers, type: :component
388 | config.include Capybara::RSpecMatchers, type: :component
389 | end
390 | ```
391 |
392 | #### Example
393 |
394 | ```rb
395 | # spec/components/form/text_field_component_spec.rb
396 | RSpec.describe Form::TextFieldComponent, type: :component do
397 | let(:object) { User.new } # replace with a model of your choice
398 | let(:form) { form_with(object) }
399 | let(:options) { {} }
400 |
401 | let(:component) { render_inline(described_class.new(form, object_name, :first_name, options)) }
402 |
403 | context "with simple args" do
404 | it do
405 | expect(component.to_html)
406 | .to have_tag("input", with: { name: "user[first_name]", id: "user_first_name", type: "text" })
407 | end
408 | end
409 | end
410 | ```
411 |
412 | For more complex components, we recommend the [`rspec-html-matchers` gem](https://github.com/kucaahbe/rspec-html-matchers).
413 |
414 | ## Development
415 |
416 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
417 |
418 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, run `bin/release x.x.x`, which will update the `version.rb` file, open the changelog for edition, create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
419 |
420 | ## Contributing
421 |
422 | Bug reports and pull requests are welcome on GitHub at https://github.com/pantographe/view_component-form. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/pantographe/view_component-form/blob/master/CODE_OF_CONDUCT.md).
423 |
424 | ## License
425 |
426 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
427 |
428 | ## Code of Conduct
429 |
430 | Everyone interacting in the ViewComponent::Form project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/pantographe/view_component-form/blob/master/CODE_OF_CONDUCT.md).
431 |
--------------------------------------------------------------------------------