├── spec ├── internal │ ├── .keep │ ├── log │ │ └── .gitignore │ ├── config │ │ ├── database.yml │ │ ├── storage.yml │ │ ├── routes.rb │ │ └── initializers │ │ │ └── view_component_form.rb │ ├── app │ │ ├── models │ │ │ ├── author.rb │ │ │ ├── person.rb │ │ │ ├── continent.rb │ │ │ ├── city.rb │ │ │ ├── country.rb │ │ │ └── hidden_field_test.rb │ │ ├── components │ │ │ ├── application_form_component.rb │ │ │ ├── form │ │ │ │ ├── text_field.rb │ │ │ │ ├── label_component.rb │ │ │ │ └── text_field_component.rb │ │ │ └── inline_form │ │ │ │ └── label_component.rb │ │ └── forms │ │ │ ├── custom_form_builder.rb │ │ │ └── inline_custom_form_builder.rb │ └── db │ │ └── schema.rb ├── fixtures │ └── test_model.rb ├── support │ ├── shared_context │ │ └── with_translations.rb │ ├── shared_examples │ │ ├── custom_value.rb │ │ ├── custom_data_attributes.rb │ │ └── custom_html_classes.rb │ └── matchers │ │ └── eq_html.rb ├── view_component │ ├── form_spec.rb │ ├── form │ │ ├── radio_button_component_spec.rb │ │ ├── hint_component_spec.rb │ │ ├── range_field_component_spec.rb │ │ ├── text_field_component_spec.rb │ │ ├── search_field_component_spec.rb │ │ ├── url_field_component_spec.rb │ │ ├── number_field_component_spec.rb │ │ ├── password_field_component_spec.rb │ │ ├── telephone_field_component_spec.rb │ │ ├── email_field_component_spec.rb │ │ ├── color_field_component_spec.rb │ │ ├── weekday_select_component_spec.rb │ │ ├── check_box_component_spec.rb │ │ ├── time_zone_select_component_spec.rb │ │ ├── submit_component_spec.rb │ │ ├── rich_text_area_component_spec.rb │ │ ├── date_field_component_spec.rb │ │ ├── month_field_component_spec.rb │ │ ├── week_field_component_spec.rb │ │ ├── button_component_spec.rb │ │ ├── time_select_component_spec.rb │ │ ├── time_field_component_spec.rb │ │ ├── configuration_spec.rb │ │ ├── date_select_component_spec.rb │ │ ├── text_area_component_spec.rb │ │ ├── collection_select_component_spec.rb │ │ ├── base_component_spec.rb │ │ ├── datetime_select_component_spec.rb │ │ ├── error_message_component_spec.rb │ │ ├── file_field_component_spec.rb │ │ ├── grouped_collection_select_component_spec.rb │ │ ├── select_component_spec.rb │ │ ├── label_component_spec.rb │ │ ├── collection_radio_buttons_component_spec.rb │ │ ├── collection_check_boxes_component_spec.rb │ │ ├── field_component_spec.rb │ │ └── builder_spec.rb │ └── generators │ │ └── vcf │ │ └── generators │ │ └── builder_generator_spec.rb └── spec_helper.rb ├── .rspec ├── lib ├── view_component │ ├── form │ │ ├── version.rb │ │ ├── engine.rb │ │ ├── helpers │ │ │ ├── custom.rb │ │ │ ├── rails8.rb │ │ │ └── rails.rb │ │ ├── builder.rb │ │ ├── configuration.rb │ │ ├── validation_context.rb │ │ ├── test_helpers.rb │ │ └── renderer.rb │ └── form.rb └── generators │ └── vcf │ └── builder │ ├── builder_generator.rb │ └── templates │ └── builder.rb.erb ├── bin ├── setup ├── console ├── release └── rspec ├── config.ru ├── .rubocop_todo.yml ├── Rakefile ├── app └── components │ ├── view_component │ └── form │ │ ├── text_area_component.rb │ │ ├── url_field_component.rb │ │ ├── color_field_component.rb │ │ ├── date_field_component.rb │ │ ├── email_field_component.rb │ │ ├── month_field_component.rb │ │ ├── range_field_component.rb │ │ ├── text_field_component.rb │ │ ├── time_field_component.rb │ │ ├── week_field_component.rb │ │ ├── number_field_component.rb │ │ ├── search_field_component.rb │ │ ├── telephone_field_component.rb │ │ ├── password_field_component.rb │ │ ├── datetime_local_field_component.rb │ │ ├── rich_text_area_component.rb │ │ ├── submit_component.rb │ │ ├── button_component.rb │ │ ├── file_field_component.rb │ │ ├── error_message_component.rb │ │ ├── radio_button_component.rb │ │ ├── check_box_component.rb │ │ ├── date_select_component.rb │ │ ├── time_select_component.rb │ │ ├── datetime_select_component.rb │ │ ├── weekday_select_component.rb │ │ ├── select_component.rb │ │ ├── time_zone_select_component.rb │ │ ├── hint_component.rb │ │ ├── collection_select_component.rb │ │ ├── base_component.rb │ │ ├── collection_check_boxes_component.rb │ │ ├── label_component.rb │ │ ├── collection_radio_buttons_component.rb │ │ ├── grouped_collection_select_component.rb │ │ └── field_component.rb │ └── concerns │ └── view_component │ └── form │ └── element_proc.rb ├── .editorconfig ├── .gitignore ├── .github └── workflows │ ├── push_gem.yml │ └── main.yml ├── gemfiles ├── rails_7.2_vc_3.0.gemfile ├── rails_7.2_vc_4.0.gemfile ├── rails_8.0_vc_3.0.gemfile ├── rails_8.0_vc_4.0.gemfile ├── rails_8.1_vc_4.0.gemfile ├── rails_head_vc_3.0.gemfile └── rails_head_vc_4.0.gemfile ├── .rubocop.yml ├── Gemfile ├── Appraisals ├── LICENSE.txt ├── view_component-form.gemspec ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── Gemfile.lock └── README.md /spec/internal/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: db/combustion_test.sqlite 4 | -------------------------------------------------------------------------------- /spec/internal/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("/tmp/storage") %> 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/author.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Author < ActiveRecord::Base; end 4 | -------------------------------------------------------------------------------- /spec/internal/app/models/person.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Person < ActiveRecord::Base; end 4 | -------------------------------------------------------------------------------- /spec/fixtures/test_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestModel 4 | include ActiveModel::Model 5 | 6 | attr_accessor :foo 7 | end 8 | -------------------------------------------------------------------------------- /lib/view_component/form/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | VERSION = "0.3.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/internal/app/components/application_form_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationFormComponent < ViewComponent::Base 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/internal/app/forms/custom_form_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomFormBuilder < ViewComponent::Form::Builder 4 | namespace Form 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/app/models/continent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Continent < ActiveRecord::Base 4 | has_many :countries 5 | # attribs: id, name 6 | end 7 | -------------------------------------------------------------------------------- /spec/internal/app/forms/inline_custom_form_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InlineCustomFormBuilder < CustomFormBuilder 4 | namespace InlineForm 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/app/models/city.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class City < ActiveRecord::Base 4 | belongs_to :country 5 | # attribs: id, name, country_id 6 | end 7 | -------------------------------------------------------------------------------- /spec/internal/app/models/country.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Country < ActiveRecord::Base 4 | belongs_to :continent 5 | # attribs: id, name, continent_id 6 | end 7 | -------------------------------------------------------------------------------- /spec/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # Add your own routes here, or remove this file if you don't have need for it. 5 | end 6 | -------------------------------------------------------------------------------- /spec/internal/config/initializers/view_component_form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ViewComponent::Form.configure do |config| 4 | config.parent_component = "ApplicationFormComponent" 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rubygems" 4 | require "bundler" 5 | 6 | Bundler.require :default, :development 7 | 8 | Combustion.initialize! :all 9 | run Combustion::Application 10 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | 4 | Metrics/ClassLength: 5 | Enabled: false 6 | 7 | Style/OpenStructUse: 8 | Enabled: false 9 | 10 | RSpec/ExampleLength: 11 | Enabled: false 12 | -------------------------------------------------------------------------------- /spec/internal/app/models/hidden_field_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class HiddenFieldTest < ActiveRecord::Base 4 | attribute :pass_confirm, :boolean 5 | attribute :tag_list, :string 6 | attribute :token, :string 7 | end 8 | -------------------------------------------------------------------------------- /spec/internal/app/components/form/text_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Form 4 | class TextField < ViewComponent::Form::LabelComponent 5 | def call 6 | "my custom text_field" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/internal/app/components/form/label_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Form 4 | class LabelComponent < ViewComponent::Form::LabelComponent 5 | def call 6 | "my custom label" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/internal/app/components/form/text_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Form 4 | class TextFieldComponent < ViewComponent::Form::LabelComponent 5 | def call 6 | "my custom text_field" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /spec/internal/app/components/inline_form/label_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module InlineForm 4 | class LabelComponent < ViewComponent::Form::LabelComponent 5 | def call 6 | "my custom label for inline form" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/text_area_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class TextAreaComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::TextArea 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/url_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class UrlFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::UrlField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/color_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class ColorFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::ColorField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/date_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class DateFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::DateField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/email_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class EmailFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::EmailField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/month_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class MonthFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::MonthField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/range_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class RangeFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::RangeField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/text_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class TextFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::TextField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/time_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class TimeFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::TimeField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/week_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class WeekFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::WeekField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/number_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class NumberFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::NumberField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/search_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class SearchFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::SearchField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/telephone_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class TelephoneFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::TelField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/view_component/form/password_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class PasswordFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::PasswordField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; top-most EditorConfig file 2 | root = true 3 | 4 | ; Unix-style newlines 5 | [*] 6 | end_of_line = LF 7 | indent_style = space 8 | indent_size = 2 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /app/components/view_component/form/datetime_local_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class DatetimeLocalFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::DatetimeLocalField 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/shared_context/with_translations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "with translations" do 4 | let(:translations) { {} } 5 | 6 | around do |example| 7 | I18n.backend.store_translations(:en, translations) 8 | example.run 9 | I18n.backend.reload! 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/shared_examples/custom_value.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "component with custom value" do |value: "Hello World"| 4 | context "with custom value" do 5 | let(:options) { { value: value } } 6 | 7 | it { expect(component_html_attributes["value"].to_s).to include(value) } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/view_component/form/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | # :nodoc: 6 | class Engine < ::Rails::Engine 7 | config.autoload_once_paths = %W[ 8 | #{root}/app/components 9 | #{root}/app/components/concerns 10 | #{root}/app/lib 11 | ] 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/internal/log 9 | /spec/internal/public 10 | /spec/internal/db/*.sqlite 11 | /spec/internal/db/*.sqlite-* 12 | /tmp/ 13 | /vendor/ 14 | 15 | # appraisal 16 | /gemfiles/*.gemfile.lock 17 | /gemfiles/.bundle/ 18 | 19 | # rspec failure tracking 20 | .rspec_status 21 | -------------------------------------------------------------------------------- /app/components/view_component/form/rich_text_area_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class RichTextAreaComponent < FieldComponent 6 | if defined?(ActionView::Helpers::Tags::ActionText) # rubocop:disable Style/IfUnlessModifier 7 | self.tag_klass = ActionView::Helpers::Tags::ActionText 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/shared_examples/custom_data_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "component with custom data attributes" do |options_keyword_arg_name = :options| 4 | context "with custom data attributes" do 5 | let(options_keyword_arg_name) { { data: { key: "value" } } } 6 | 7 | it { expect(component_html_attributes["data-key"].to_s).to include("value") } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/shared_examples/custom_html_classes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "component with custom html classes" do |options_keyword_arg_name = :options| 4 | context "with custom html classes" do 5 | let(options_keyword_arg_name) { { class: "custom-css-class" } } 6 | 7 | it { expect(component_html_attributes["class"].to_s).to include("custom-css-class") } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "view_component/form" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /app/components/view_component/form/submit_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class SubmitComponent < BaseComponent 6 | attr_reader :value 7 | 8 | def initialize(form, value, options = {}) 9 | @value = value 10 | 11 | super(form, nil, options) 12 | end 13 | 14 | def call 15 | submit_tag(value, options) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/components/view_component/form/button_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class ButtonComponent < BaseComponent 6 | attr_reader :value 7 | 8 | def initialize(form, value, options = {}) 9 | @value = value 10 | 11 | super(form, nil, options) 12 | end 13 | 14 | def call 15 | button_tag(content || value, options) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/components/view_component/form/file_field_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class FileFieldComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::FileField 7 | 8 | def before_render 9 | @options = { include_hidden: multiple_file_field_include_hidden }.merge!(options) 10 | @options = convert_direct_upload_option_to_url(@options.dup) 11 | 12 | super 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/view_component/form/helpers/custom.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | module Helpers 6 | module Custom 7 | def error_message(method, options = {}) 8 | render_component(:error_message, @object_name, method, objectify_options(options)) 9 | end 10 | 11 | def hint(method, text = nil, options = {}, &) 12 | render_component(:hint, @object_name, method, text, objectify_options(options), &) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/view_component/form/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class Builder < ActionView::Helpers::FormBuilder 6 | include ViewComponent::Form::Renderer 7 | include ViewComponent::Form::ValidationContext 8 | include ViewComponent::Form::Helpers::Rails 9 | 10 | if Gem::Version.new(::Rails::VERSION::STRING) >= Gem::Version.new("8.0") 11 | include ViewComponent::Form::Helpers::Rails8 12 | end 13 | include ViewComponent::Form::Helpers::Custom 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/view_component/form.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "view_component" 4 | require "zeitwerk" 5 | 6 | module ViewComponent 7 | module Form 8 | class << self 9 | def configuration 10 | @configuration ||= Configuration.new 11 | end 12 | 13 | def configure 14 | yield configuration 15 | end 16 | end 17 | end 18 | end 19 | 20 | loader = Zeitwerk::Loader.for_gem 21 | form = "#{__dir__}/form.rb" 22 | loader.ignore(form) 23 | loader.push_dir("#{__dir__}/form", namespace: ViewComponent::Form) 24 | loader.setup 25 | 26 | require_relative "form/engine" 27 | -------------------------------------------------------------------------------- /lib/view_component/form/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class Configuration 6 | attr_accessor :parent_component, :lookup_chain 7 | 8 | def initialize 9 | @parent_component = "ViewComponent::Base" 10 | @lookup_chain = [ 11 | lambda do |component_name, namespaces: []| 12 | namespaces.lazy.map do |namespace| 13 | "#{namespace}::#{component_name.to_s.camelize}Component".safe_constantize 14 | end.find(&:itself) 15 | end 16 | ] 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/view_component/form/validation_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | module ValidationContext 6 | attr_reader :validation_context 7 | 8 | def self.included(base) 9 | base.class_eval do 10 | original_initialize_method = instance_method(:initialize) 11 | 12 | define_method(:initialize) do |*args, &block| 13 | original_initialize_method.bind_call(self, *args, &block) 14 | 15 | @validation_context = options[:validation_context] 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/components/view_component/form/error_message_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class ErrorMessageComponent < FieldComponent 6 | class_attribute :tag, instance_reader: false, instance_writer: false, instance_accessor: false, 7 | instance_predicate: false 8 | 9 | self.tag = :div 10 | 11 | def call 12 | tag.public_send(self.class.tag, messages, **options) 13 | end 14 | 15 | def render? 16 | method_errors? 17 | end 18 | 19 | def messages 20 | safe_join(method_errors, tag.br) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/view_component/form_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ViewComponent::Form do 4 | it "has a version number" do 5 | expect(ViewComponent::Form::VERSION).not_to be_nil 6 | end 7 | 8 | it "is configurable" do 9 | expect { |block| described_class.configure(&block) }.to yield_with_args(ViewComponent::Form::Configuration) 10 | end 11 | 12 | if ENV.fetch("VIEW_COMPONENT_FORM_USE_ACTIONTEXT", "false") == "true" 13 | it "loads ActionText" do 14 | expect(defined?(ActionView::Helpers::Tags::ActionText)).to eq("constant") 15 | end 16 | else 17 | it "does not load ActionText" do 18 | expect(defined?(ActionView::Helpers::Tags::ActionText)).to be_nil 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/components/view_component/form/radio_button_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | class RadioButtonComponent < FieldComponent 6 | self.tag_klass = ActionView::Helpers::Tags::RadioButton 7 | 8 | attr_reader :value 9 | 10 | def initialize(form, object_name, method_name, value, options = {}) 11 | @value = value 12 | 13 | super(form, object_name, method_name, options) 14 | end 15 | 16 | def call 17 | ActionView::Helpers::Tags::RadioButton.new( 18 | object_name, 19 | method_name, 20 | @view_context, 21 | value, 22 | options 23 | ).render 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/push_gem.yml: -------------------------------------------------------------------------------- 1 | name: Push Gem 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | push: 13 | if: github.repository == 'pantographe/view_component-form' 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: write 18 | id-token: write 19 | 20 | steps: 21 | # Set up 22 | - name: Harden Runner 23 | uses: step-security/harden-runner@v2 24 | with: 25 | egress-policy: audit 26 | 27 | - uses: actions/checkout@v4 28 | - name: Set up Ruby 29 | uses: ruby/setup-ruby@v1 30 | with: 31 | bundler-cache: true 32 | ruby-version: ruby 33 | 34 | # Release 35 | - uses: rubygems/release-gem@v1 36 | -------------------------------------------------------------------------------- /lib/generators/vcf/builder/builder_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Vcf 4 | module Generators 5 | class BuilderGenerator < Rails::Generators::NamedBase 6 | source_root File.join(File.dirname(__FILE__), "templates") 7 | 8 | class_option :namespace, default: "Form" 9 | class_option :path, default: "app/helpers" 10 | 11 | def create_builder_from_template 12 | template "builder.rb.erb", destination 13 | end 14 | 15 | protected 16 | 17 | def class_name 18 | name.camelize 19 | end 20 | 21 | def components_namespace 22 | options[:namespace].camelize 23 | end 24 | 25 | def destination 26 | "#{options[:path]}/#{name.underscore}.rb" 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2_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", "~> 7.2.0" 7 | gem "rake", "~> 13.0" 8 | gem "sqlite3", "~> 1.4", 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_7.2_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", "~> 7.2.0" 7 | gem "rake", "~> 13.0" 8 | gem "sqlite3", "~> 1.4", 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 | -------------------------------------------------------------------------------- /gemfiles/rails_8.0_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", "~> 8.0.0" 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_8.0_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", "~> 8.0.0" 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 | -------------------------------------------------------------------------------- /gemfiles/rails_8.1_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", "~> 8.1.0" 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 | -------------------------------------------------------------------------------- /app/components/concerns/view_component/form/element_proc.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ViewComponent 4 | module Form 5 | module ElementProc 6 | attr_reader :element_proc 7 | 8 | def initialize(*args, **kwargs) 9 | super 10 | set_element_proc! 11 | end 12 | 13 | protected 14 | 15 | def set_element_proc! 16 | options_element_proc = options.delete(:element_proc) 17 | html_options_element_proc = html_options.delete(:element_proc) 18 | 19 | if options_element_proc && html_options_element_proc 20 | raise ArgumentError, "#{self.class.name} received :element_proc twice, expected only once" 21 | end 22 | 23 | @element_proc = options_element_proc || html_options_element_proc 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/view_component/form/test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_controller" 4 | require "action_controller/test_case" 5 | require "action_view" 6 | 7 | class TestView < ActionView::Base 8 | include ActionText::TagHelper if defined?(ActionText) 9 | end 10 | 11 | module ViewComponent 12 | module Form 13 | module TestHelpers 14 | def form_with(object, builder: ViewComponent::Form::Builder, **options) 15 | builder.new(object_name, object, template, options) 16 | end 17 | 18 | def object_name 19 | :user 20 | end 21 | 22 | def template 23 | lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths) 24 | 25 | TestView.new(lookup_context, {}, ApplicationController.new) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/view_component/form/radio_button_component_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ViewComponent::Form::RadioButtonComponent, 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, :civility, "mrs", 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 | end 22 | -------------------------------------------------------------------------------- /spec/view_component/form/hint_component_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ViewComponent::Form::HintComponent, type: :component do 4 | subject(:rendered_component) { render_inline(component) } 5 | 6 | let(:component) { described_class.new(form, object_name, :birth_date, "this is my hint for you", options) } 7 | let(:object) { OpenStruct.new } 8 | let(:form) { form_with(object) } 9 | let(:options) { {} } 10 | let(:component_html_attributes) { rendered_component.css("div").first.attributes } 11 | 12 | context "with simple args" do 13 | it { is_expected.to eq_html "
this is my hint for you
" } 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 | Sponsored by Etamin Studio      Sponsored by Pantographe 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 |
221 | 222 | 231 |
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 |
269 | 273 |
274 | ``` 275 | 276 | ##### Using `#required?` and `#optional?` 277 | 278 | ```erb 279 | <%# app/components/custom/form/group_component.html.erb %> 280 |
281 | 285 |
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 | --------------------------------------------------------------------------------