diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4bf28..3226e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,145 @@ ## [Unreleased] +### Breaking Changes + +This release includes several breaking changes to improve consistency with Phlex 2.x conventions and better organize the codebase. + +#### Form Instance Architecture Changes + +The framework now passes form instances instead of field classes throughout the namespace hierarchy: + +- `Namespace` constructor now accepts `form:` parameter instead of `field_class:` +- `NamespaceCollection` constructor now accepts `form:` parameter instead of `field_class:` +- Form instances must implement a `build_field` method for field creation +- Rails forms now pass themselves as form instances to namespaces + +This change enables better encapsulation and allows forms to customize field creation logic. + +#### Component Class Naming Changes + +All Rails component classes have been renamed to match Phlex 2.x conventions by removing the "Component" suffix: + +- `Superform::Rails::Components::BaseComponent` → `Superform::Rails::Components::Base` +- `Superform::Rails::Components::FieldComponent` → `Superform::Rails::Components::Field` +- `Superform::Rails::Components::InputComponent` → `Superform::Rails::Components::Input` +- `Superform::Rails::Components::ButtonComponent` → `Superform::Rails::Components::Button` +- `Superform::Rails::Components::CheckboxComponent` → `Superform::Rails::Components::Checkbox` +- `Superform::Rails::Components::TextareaComponent` → `Superform::Rails::Components::Textarea` +- `Superform::Rails::Components::SelectField` → `Superform::Rails::Components::Select` +- `Superform::Rails::Components::LabelComponent` → `Superform::Rails::Components::Label` + +#### File Structure Changes + +Rails classes have been moved into separate files for better organization: + +- Components are now in individual files under `lib/superform/rails/components/` +- Core classes like `Form` are now in `lib/superform/rails/form.rb` + +#### Phlex Rails Dependency + +- Now requires `phlex-rails ~> 2.0` (was `>= 1.0, < 3.0`) + +### How to Upgrade + +#### Custom Form Classes + +Custom form classes with Rails now automatically pass themselves as form instances (no changes needed for basic usage). + +#### Update Component Class Names + +Update component class names in your custom form classes: + + ```ruby + # Before (0.5.x) + class MyInput < Superform::Rails::Components::InputComponent + # ... + end + + class Field < Superform::Rails::Form::Field + def input(**attributes) + MyInput.new(self, attributes: attributes) + end + end + ``` + + ```ruby + # After (0.6.0) + class MyInput < Superform::Rails::Components::Input + # ... + end + + class Field < Superform::Rails::Form::Field + def input(**attributes) + MyInput.new(self, attributes: attributes) + end + end + ``` + +#### Update Your Gemfile + +Update your Gemfile to ensure compatibility: + + ```ruby + gem 'phlex-rails', '~> 2.0' + gem 'superform', '~> 0.6.0' + ``` + +#### Run Bundle Update + +Run bundle update to update dependencies: + + ```bash + bundle update phlex-rails superform + ``` + +### Added + +- Form instance architecture for better encapsulation and customization +- `Superform::Form` class for basic form behavior without Rails dependencies +- `build_field` method delegation to form instances +- Better file organization with Rails classes in separate files +- Improved Phlex 2.x compatibility and conventions +- Strong Parameters support with `Superform::Rails::StrongParameters` module: + - `permit(form)` method for assigning permitted params without saving + - `save(form)` method for saving models with permitted params + - `save!(form)` method for saving with exception handling on validation failure + - Automatic parameter filtering based on form field declarations + - Safe mass assignment protection against unauthorized attributes +- Field input type helper methods for Rails forms: + - `field.email` for email input type + - `field.password` for password input type + - `field.url` for URL input type + - `field.tel` (with `phone` alias) for telephone input type + - `field.number` for number input type + - `field.range` for range input type + - `field.date` for date input type + - `field.time` for time input type + - `field.datetime` for datetime-local input type + - `field.month` for month input type + - `field.week` for week input type + - `field.color` for color input type + - `field.search` for search input type + - `field.file` for file input type + - `field.hidden` for hidden input type + - `field.radio(value)` for radio button input type +- Readonly field functionality: + - `field.readonly` and `field.readonly(true/false)` methods to mark fields as read-only + - `field.read_only = true` alias for setting readonly state + - `field.read_only?` method to check if field is readonly + - Automatic readonly detection from Rails model `readonly_attributes` + - Readonly attribute support in input components (renders `readonly` HTML attribute) + - Disabled attribute support for select and checkbox components when readonly + - Readonly fields are automatically excluded from strong parameter assignment + - Input type methods accept `readonly: true` attribute (e.g., `field.email(readonly: true)`) + +### Changed + +- **Breaking**: `Namespace` and `NamespaceCollection` constructors now accept `form:` instead of `field_class:` +- **Breaking**: Form instances must implement `build_field` method +- Rails component classes moved to individual files +- Component class names simplified to match Phlex conventions +- Dependency updated to require phlex-rails 2.x + ## [0.1.0] - 2023-06-23 - Initial release diff --git a/Gemfile b/Gemfile index e8842c0..c365e02 100644 --- a/Gemfile +++ b/Gemfile @@ -9,4 +9,12 @@ gem "rake", "~> 13.0" # Run tests gem "rspec", "~> 3.0" +gem "rspec-rails", "~> 6.0" gem "guard-rspec", "~> 4.7" + +# Minimal Rails for testing +gem "rails", "~> 8.0" +gem "actionpack", "~> 8.0" + +# Database for in-memory ActiveRecord tests +gem "sqlite3" diff --git a/Gemfile.lock b/Gemfile.lock index 644e87e..34c8412 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,32 +1,74 @@ PATH remote: . specs: - superform (0.5.1) + superform (0.6.0) phlex-rails (~> 2.0) zeitwerk (~> 2.6) GEM remote: https://rubygems.org/ specs: - actionpack (7.2.1) - actionview (= 7.2.1) - activesupport (= 7.2.1) + actioncable (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) + actionmailer (8.0.2.1) + actionpack (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activesupport (= 8.0.2.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.2.1) + actionview (= 8.0.2.1) + activesupport (= 8.0.2.1) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.2) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actionview (7.2.1) - activesupport (= 7.2.1) + actiontext (8.0.2.1) + actionpack (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.2.1) + activesupport (= 8.0.2.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activesupport (7.2.1) + activejob (8.0.2.1) + activesupport (= 8.0.2.1) + globalid (>= 0.3.6) + activemodel (8.0.2.1) + activesupport (= 8.0.2.1) + activerecord (8.0.2.1) + activemodel (= 8.0.2.1) + activesupport (= 8.0.2.1) + timeout (>= 0.4.0) + activestorage (8.0.2.1) + actionpack (= 8.0.2.1) + activejob (= 8.0.2.1) + activerecord (= 8.0.2.1) + activesupport (= 8.0.2.1) + marcel (~> 1.0) + activesupport (8.0.2.1) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) @@ -36,19 +78,24 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) base64 (0.2.0) + benchmark (0.4.1) bigdecimal (3.1.8) builder (3.3.0) coderay (1.1.3) concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) + date (3.4.1) diff-lcs (1.5.1) drb (2.2.1) - erubi (1.13.0) + erubi (1.13.1) ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-linux-gnu) formatador (1.1.0) + globalid (1.2.1) + activesupport (>= 6.1) guard (2.18.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -73,16 +120,33 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.1) - loofah (2.22.0) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) lumberjack (1.2.10) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.4) method_source (1.1.0) + mini_mime (1.1.5) minitest (5.25.1) nenv (0.3.0) - nokogiri (1.18.3-arm64-darwin) + net-imap (0.5.9) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.4) + nokogiri (1.18.9-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.3-x86_64-linux-gnu) + nokogiri (1.18.9-x86_64-linux-gnu) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) @@ -100,23 +164,38 @@ GEM stringio racc (1.8.1) rack (3.1.8) - rack-session (2.0.0) + rack-session (2.1.1) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rackup (2.1.0) rack (>= 3) webrick (~> 1.8) - rails-dom-testing (2.2.0) + rails (8.0.2.1) + actioncable (= 8.0.2.1) + actionmailbox (= 8.0.2.1) + actionmailer (= 8.0.2.1) + actionpack (= 8.0.2.1) + actiontext (= 8.0.2.1) + actionview (= 8.0.2.1) + activejob (= 8.0.2.1) + activemodel (= 8.0.2.1) + activerecord (= 8.0.2.1) + activestorage (= 8.0.2.1) + activesupport (= 8.0.2.1) + bundler (>= 1.15.0) + railties (= 8.0.2.1) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.2.1) - actionpack (= 7.2.1) - activesupport (= 7.2.1) + 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) + railties (8.0.2.1) + actionpack (= 8.0.2.1) + activesupport (= 8.0.2.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -142,15 +221,31 @@ GEM rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) + rspec-rails (6.1.5) + actionpack (>= 6.1) + activesupport (>= 6.1) + railties (>= 6.1) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) rspec-support (3.13.1) securerandom (0.3.1) shellany (0.0.1) + sqlite3 (2.7.3-arm64-darwin) + sqlite3 (2.7.3-x86_64-linux-gnu) stringio (3.1.1) thor (1.3.2) + timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - useragent (0.16.10) + uri (1.0.3) + useragent (0.16.11) webrick (1.8.2) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) zeitwerk (2.7.0) PLATFORMS @@ -160,9 +255,13 @@ PLATFORMS x86_64-linux DEPENDENCIES + actionpack (~> 8.0) guard-rspec (~> 4.7) + rails (~> 8.0) rake (~> 13.0) rspec (~> 3.0) + rspec-rails (~> 6.0) + sqlite3 superform! BUNDLED WITH diff --git a/README.md b/README.md index 7162836..8ee34da 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Superform -Superform aims to be the best way to build forms in Rails applications. Here's what it does differently. +**The best Rails form library.** Whether you're using ERB, HAML, or Phlex, Superform makes building forms delightful. -* **Everything is a component.** Superform is built on top of [Phlex](https://phlex.fun), so every bit of HTML in the form can be customized to your precise needs. Use it with your own CSS Framework or go crazy customizing every last bit of TailwindCSS. +* **No more strong parameters headaches.** Add a field to your form and it automatically gets permitted. Never again wonder why your new field isn't saving. Superform handles parameter security for you. -* **Automatic strong parameters.** Superform automatically permits form fields so you don't have to facepalm yourself after adding a field, wondering why it doesn't persist, only to realize you forgot to add the parameter to your controller. No more! Superform was architected with safety & security in mind, meaning it can automatically permit your form parameters. +* **Works beautifully with ERB.** Start using Superform in your existing Rails app without changing a single ERB template. All the power, zero migration pain. -* **Compose complex forms with Plain 'ol Ruby Objects.** Superform is built on top of POROs, so you can easily compose classes, modules, & ruby code together to create complex forms. You can even extend forms to create new forms with a different look and feel. +* **Concise field helpers.** `field(:publish_at).date`, `field(:email).email`, `field(:price).number` — intuitive methods that generate the right input types with proper validation. -It's a complete rewrite of Rails form's internals that's inspired by Reactive component design patterns. +* **RESTful controller helpers** Superform's `save` and `save!` methods work exactly like ActiveRecord, making controller code predictable and Rails-like. + +Superform is a complete reimagining of Rails forms, built on solid Ruby foundations with modern component architecture under the hood. [![Maintainability](https://api.codeclimate.com/v1/badges/0e4dfe2a1ece26e3a59e/maintainability)](https://codeclimate.com/github/rubymonolith/superform/maintainability) [![Ruby](https://github.com/rubymonolith/superform/actions/workflows/main.yml/badge.svg)](https://github.com/rubymonolith/superform/actions/workflows/main.yml) @@ -32,36 +34,148 @@ This will install both Phlex Rails and Superform. ## Usage -Superform streamlines the development of forms on Rails applications by making everything a component. +### Start with inline forms in your ERB templates + +Superform works instantly in your existing Rails ERB templates. Here's what a form for a blog post might look like: + +```erb + +

New Post

+ +<%= render Components::Form.new @post do + it.Field(:title).text + it.Field(:body).textarea + it.Field(:publish_at).date + it.Field(:featured).checkbox + it.submit "Create Post" +end %> +``` + +The form automatically generates proper Rails form tags, includes CSRF tokens, and handles validation errors. + +Notice anything missing? Superform doesn't need `<% %>` tags around every single line, unlike all other Rails form helpers. + +### Extract to dedicated form classes -After installing, create a form in `app/views/*/form.rb`. For example, a form for a `Post` resource might look like this. +You probably want to use the same form for creating and editing resources. In superform, you extract forms into their own Ruby classes right along with your views. ```ruby -# ./app/views/posts/form.rb -class Posts::Form < ApplicationForm - def view_template(&) - labeled field(:title).input - labeled field(:body).textarea - labeled field(:blog).select Blog.select(:id, :title) +# app/views/posts/form.rb +class Posts::Form < Components::Form + def view_template + Field(:title).text + Field(:body).textarea(rows: 10) + Field(:publish_at).date + Field(:featured).checkbox + submit end end ``` -Then render it in your templates. Here's what it looks like from an Erb file. +Now your templates stay clean: ```erb -

New post

+ +

New Post

<%= render Posts::Form.new @post %> ``` +Cool, but you're about to score a huge benefit from extracting forms into their own Ruby classes with automatic strong parameters. + +### RESTful controllers with automatic strong parameters + +Include `Superform::Rails::StrongParameters` in your controllers for automatic parameter handling: + +```ruby +class PostsController < ApplicationController + include Superform::Rails::StrongParameters + + def create + @post = Post.new + if save Posts::Form.new(@post) + redirect_to @post, notice: 'Post created!' + else + render :new, status: :unprocessable_entity + end + end + + def update + @post = Post.find(params[:id]) + if save Posts::Form.new(@post) + redirect_to @post, notice: 'Post updated!' + else + render :edit, status: :unprocessable_entity + end + end +end +``` + +The `save` method automatically: +- Permits only the parameters defined in your form +- Assigns them to your model +- Attempts to save the model +- Returns `true` if successful, `false` if validation fails + +Use `save!` for the bang version that raises exceptions on validation failure or `permit` if you want to assign parameters to a model without saving it. + +### Rich input types with smart helpers + +Superform includes helpers for all HTML5 input types: + +```ruby +class UserForm < Components::Form + def view_template + Field(:email).email # type="email" + Field(:password).password # type="password" + Field(:website).url # type="url" + Field(:phone).tel # type="tel" + Field(:age).number(min: 18) # type="number" + Field(:birthday).date # type="date" + Field(:appointment).datetime # type="datetime-local" + Field(:favorite_color).color # type="color" + Field(:bio).textarea(rows: 5) + Field(:terms).checkbox + submit + end +end +``` + +### For Phlex users + +If you're already using Phlex throughout your Rails application, you can leverage Superform's full component architecture: + +```ruby +class Posts::Form < Components::Form + def view_template + div(class: "form-section") do + h2 { "Post Details" } + Field(:title).text(class: "form-control") + Field(:body).textarea(class: "form-control", rows: 10) + end + + div(class: "form-section") do + h2 { "Publishing" } + Field(:publish_at).date(class: "form-control") + Field(:featured).checkbox(class: "form-check-input") + end + + div(class: "form-actions") do + submit "Save Post", class: "btn btn-primary" + end + end +end +``` + +This gives you complete control over markup, styling, and component composition while maintaining all the strong parameter and validation benefits. + ## Customization Superforms are built out of [Phlex components](https://www.phlex.fun/html/components/). The method names correspeond with the HTML tag, its arguments are attributes, and the blocks are the contents of the tag. ```ruby -# ./app/views/forms/application_form.rb -class ApplicationForm < Superform::Rails::Form - class MyInputComponent < Superform::Rails::Components::InputComponent +# ./app/components/form.rb +class Components::Form < Superform::Rails::Form + class MyInput < Superform::Rails::Components::Input def view_template(&) div class: "form-field" do input(**attributes) @@ -72,7 +186,7 @@ class ApplicationForm < Superform::Rails::Form # Redefining the base Field class lets us override every field component. class Field < Superform::Rails::Form::Field def input(**attributes) - MyInputComponent.new(self, attributes: attributes) + MyInput.new(self, attributes: attributes) end end @@ -95,7 +209,7 @@ That looks like a LOT of code, and it is, but look at how easy it is to create f ```ruby # ./app/views/users/form.rb -class Users::Form < ApplicationForm +class Users::Form < Components::Form def view_template(&) labeled field(:name).input labeled field(:email).input(type: :email) @@ -125,19 +239,19 @@ class AccountForm < Superform::Rails::Form # Account#owner returns a single object namespace :owner do |owner| # Renders input with the name `account[owner][name]` - render owner.field(:name).input + owner.Field(:name).text # Renders input with the name `account[owner][email]` - render owner.field(:email).input(type: :email) + owner.Field(:email).email end # Account#members returns a collection of objects collection(:members).each do |member| # Renders input with the name `account[members][0][name]`, # `account[members][1][name]`, ... - render member.field(:name).input + member.Field(:name).input # Renders input with the name `account[members][0][email]`, # `account[members][1][email]`, ... - render member.field(:email).input(type: :email) + member.Field(:email).input(type: :email) # Member#permissions returns an array of values like # ["read", "write", "delete"]. @@ -210,13 +324,13 @@ In practice, many of the calls below you'd put inside of a method. This cuts dow ```ruby # Everything below is intentionally verbose! -class SignupForm < ApplicationForm +class SignupForm < Components::Form def view_template # The most basic type of input, which will be autofocused. - render field(:name).input.focus + Field(:name).input.focus # Input field with a lot more options on it. - render field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) + Field(:email).input(type: :email, placeholder: "We will sell this to third parties", required: true) # You can put fields in a block if that's your thing. field(:reason) do |f| @@ -228,8 +342,8 @@ class SignupForm < ApplicationForm # Let's get crazy with Selects. They can accept values as simple as 2 element arrays. div do - render field(:contact).label { "Would you like us to spam you to death?" } - render field(:contact).select( + Field(:contact).label { "Would you like us to spam you to death?" } + Field(:contact).select( [true, "Yes"], # [false, "No"], # "Hell no", # @@ -238,8 +352,8 @@ class SignupForm < ApplicationForm end div do - render field(:source).label { "How did you hear about us?" } - render field(:source).select do |s| + Field(:source).label { "How did you hear about us?" } + Field(:source).select do |s| # Renders a blank option. s.blank_option # Pretend WebSources is an ActiveRecord scope with a "Social" category that has "Facebook, X, etc" @@ -253,8 +367,8 @@ class SignupForm < ApplicationForm end div do - render field(:agreement).label { "Check this box if you agree to give us your first born child" } - render field(:agreement).checkbox(checked: true) + Field(:agreement).label { "Check this box if you agree to give us your first born child" } + Field(:agreement).checkbox(checked: true) end render button { "Submit" } @@ -266,12 +380,12 @@ end If you want to add file upload fields to your form you will need to initialize your form with the `enctype` attribute set to `multipart/form-data` as shown in the following example code: ```ruby -class User::ImageForm < ApplicationForm +class User::ImageForm < Components::Form def view_template # render label - render field(:image).label { "Choose file" } + Field(:image).label { "Choose file" } # render file input with accept attribute for png and jpeg images - render field(:image).input(type: "file", accept: "image/png, image/jpeg") + Field(:image).input(type: "file", accept: "image/png, image/jpeg") end end @@ -286,7 +400,7 @@ render User::ImageForm.new(@usermodel, enctype: "multipart/form-data") The best part? If you have forms with a completely different look and feel, you can extend the forms just like you would a Ruby class: ```ruby -class AdminForm < ApplicationForm +class AdminForm < Components::Form class AdminInput < Components::Base def view_template(&) input(**attributes) @@ -315,41 +429,50 @@ class Admin::Users::Form < AdminForm end ``` -Since Superforms are just Ruby objects, you can organize them however you want. You can keep your view component classes embedded in your Superform file if you prefer for everything to be in one place, keep the forms in the `app/views/forms/*.rb` folder and the components in `app/views/forms/**/*_component.rb`, use Ruby's `include` and `extend` features to modify different form classes, or put them in a gem and share them with an entire organization or open source community. It's just Ruby code! +Since Superforms are just Ruby objects, you can organize them however you want. You can keep your view component classes embedded in your Superform file if you prefer for everything to be in one place, keep the forms in the `app/components/forms/*.rb` folder and the components in `app/components/forms/**/*_component.rb`, use Ruby's `include` and `extend` features to modify different form classes, or put them in a gem and share them with an entire organization or open source community. It's just Ruby code! ## Automatic strong parameters -Guess what? Superform eliminates the need for Strong Parameters in Rails by assigning the values of the `params` hash _through_ your form via the `assign` method. Here's what it looks like. +Superform eliminates the need to manually define strong parameters. Just include `Superform::Rails::StrongParameters` in your controllers and use the `save`, `save!`, and `permit` methods: ```ruby class PostsController < ApplicationController include Superform::Rails::StrongParameters + # Standard Rails CRUD with automatic strong parameters def create - @post = assign params.require(:post), to: Posts::Form.new(Post.new) - - if @post.save - # Success path + @post = Post.new + if save Posts::Form.new(@post) + redirect_to @post, notice: 'Post created successfully.' else - # Error path + render :new, status: :unprocessable_entity end end def update @post = Post.find(params[:id]) - - assign params.require(:post), to: Posts::Form.new(@post) - - if @post.save - # Success path + if save Posts::Form.new(@post) + redirect_to @post, notice: 'Post updated successfully.' else - # Error path + render :edit, status: :unprocessable_entity end end + + # For cases where you want to assign params without saving + def preview + @post = Post.new + permit Posts::Form.new(@post) # Assigns params but doesn't save + render :preview + end end ``` -How does it work? An instance of the form is created, then the hash is assigned to it. If the params include data outside of what a form accepts, it will be ignored. +**How it works:** Superform automatically permits only the parameters that correspond to fields defined in your form. Attempts to mass-assign other parameters are safely ignored, protecting against parameter pollution attacks. + +**Available methods:** +- `save(form)` - Assigns permitted params and saves the model, returns `true`/`false` +- `save!(form)` - Same as `save` but raises exception on validation failure +- `permit(form)` - Assigns permitted params without saving, returns the model ## Comparisons diff --git a/SPEC_STYLE_GUIDE.md b/SPEC_STYLE_GUIDE.md new file mode 100644 index 0000000..547fffc --- /dev/null +++ b/SPEC_STYLE_GUIDE.md @@ -0,0 +1,146 @@ +# Spec Style Guide + +This document outlines the preferred testing patterns for Superform. Follow these patterns to maintain consistency and readability across the test suite. + +## Generator Testing + +### ✅ Preferred Pattern + +Use integration-style testing that actually runs the generator: + +```ruby +RSpec.describe SomeGenerator, type: :generator do + let(:destination_root) { Dir.mktmpdir } + let(:generator) { described_class.new([], {}, { destination_root: destination_root }) } + + before do + FileUtils.mkdir_p(destination_root) + allow(Rails).to receive(:root).and_return(Pathname.new(destination_root)) + end + + after do + FileUtils.rm_rf(destination_root) if File.exist?(destination_root) + end + + context "when dependencies are met" do + before do + allow(generator).to receive(:gem_in_bundle?).with("some-gem").and_return(true) + end + + it "creates the expected file" do + generator.invoke_all + expect(File.exist?(File.join(destination_root, "path/to/file.rb"))).to be true + end + + describe "generated file" do + subject { File.read(File.join(destination_root, "path/to/file.rb")) } + + before { generator.invoke_all } + + it { is_expected.to include("class SomeClass") } + it { is_expected.to include("def some_method") } + end + end +end +``` + +### ❌ Avoid This Pattern + +Don't unit test individual generator methods in isolation: + +```ruby +# DON'T DO THIS +it "creates file with correct content" do + generator.create_some_file + + content = File.read(file_path) + expect(content).to include("class SomeClass") + expect(content).to include("def some_method") + expect(content).to include("def another_method") + # ... more expectations +end +``` + +## File Content Testing + +### ✅ Use Subject Blocks + +When testing generated file content, use `subject` blocks with `is_expected` matchers: + +```ruby +describe "generated file" do + subject { File.read(file_path) } + + before { run_generator_or_setup } + + it { is_expected.to include("essential content") } + it { is_expected.to include("other essential content") } +end +``` + +### ❌ Don't Repeat File Reading + +Avoid reading the same file multiple times in different tests: + +```ruby +# DON'T DO THIS +it "includes class definition" do + content = File.read(file_path) + expect(content).to include("class SomeClass") +end + +it "includes method definition" do + content = File.read(file_path) # Reading same file again + expect(content).to include("def some_method") +end +``` + +## Test Focus + +### ✅ Test What Matters + +Focus on essential functionality, not implementation details: + +```ruby +# Test the important stuff +it { is_expected.to include("class Base < Superform::Rails::Form") } +it { is_expected.to include("def row(component)") } +``` + +### ❌ Don't Over-Test + +Avoid brittle, line-by-line assertions: + +```ruby +# DON'T DO THIS - too brittle +expect(lines[0]).to eq("module Components") +expect(lines[1]).to eq(" module Forms") +expect(lines[2]).to eq(" class Base < Superform::Rails::Form") +``` + +## Test Structure + +### ✅ Good Test Organization + +- Use contexts to group related scenarios +- Use descriptive test names +- Keep tests focused and minimal +- Use `before` blocks to set up common state + +### ✅ Integration Over Unit + +- Test generators by actually running them +- Test the user experience, not internal methods +- Mock external dependencies, not internal logic + +## Key Principles + +1. **Integration over Unit**: Test how components work together, not in isolation +2. **User Experience**: Test what users actually do and see +3. **Essential over Exhaustive**: Test what matters, not every edge case +4. **Readable over Clever**: Clear, simple tests are better than complex ones +5. **DRY but Clear**: Eliminate repetition without sacrificing readability + +## Example: Good Generator Spec + +See `spec/generators/superform/install_generator_spec.rb` for a complete example of these patterns in action. \ No newline at end of file diff --git a/lib/generators/superform/install/USAGE b/lib/generators/superform/install/USAGE index a98d326..925cf33 100644 --- a/lib/generators/superform/install/USAGE +++ b/lib/generators/superform/install/USAGE @@ -1,8 +1,12 @@ Description: - Installs Phlex Rails and Superform + Installs Superform with proper component structure Example: bin/rails generate superform:install This will create: - app/views/forms/application_form.rb + app/components/form.rb + + Prerequisites: + phlex-rails must be installed. If not installed, run: + bundle add phlex-rails \ No newline at end of file diff --git a/lib/generators/superform/install/install_generator.rb b/lib/generators/superform/install/install_generator.rb index 3f156b9..eb1f614 100644 --- a/lib/generators/superform/install/install_generator.rb +++ b/lib/generators/superform/install/install_generator.rb @@ -3,31 +3,20 @@ class Superform::InstallGenerator < Rails::Generators::Base source_root File.expand_path("templates", __dir__) - APPLICATION_CONFIGURATION_PATH = Rails.root.join("config/application.rb") - - def install_phlex_rails - return if gem_in_bundle? "phlex-rails" - - gem "phlex-rails" - generate "phlex:install" - end - - def autoload_components - return unless APPLICATION_CONFIGURATION_PATH.exist? - - inject_into_class( - APPLICATION_CONFIGURATION_PATH, - "Application", - %( config.autoload_paths << "\#{root}/app/views/forms"\n) - ) + def check_phlex_rails_dependency + unless gem_in_bundle?("phlex-rails") + say "ERROR: phlex-rails is not installed. Please run 'bundle add phlex-rails' first.", :red + exit 1 + end end def create_application_form - template "application_form.rb", Rails.root.join("app/views/forms/application_form.rb") + template "base.rb", Rails.root.join("app/components/form.rb") end private - def gem_in_bundle?(gem_name) - Bundler.load.specs.any? { |spec| spec.name == gem_name } - end -end + + def gem_in_bundle?(gem_name) + Bundler.load.specs.any? { |spec| spec.name == gem_name } + end +end \ No newline at end of file diff --git a/lib/generators/superform/install/templates/application_form.rb b/lib/generators/superform/install/templates/application_form.rb deleted file mode 100644 index 3fff387..0000000 --- a/lib/generators/superform/install/templates/application_form.rb +++ /dev/null @@ -1,31 +0,0 @@ -class ApplicationForm < Superform::Rails::Form - include Phlex::Rails::Helpers::Pluralize - - def row(component) - div do - render component.field.label(style: "display: block;") - render component - end - end - - def around_template(&) - super do - error_messages - yield - submit - end - end - - def error_messages - if model.errors.any? - div(style: "color: red;") do - h2 { "#{pluralize model.errors.count, "error"} prohibited this post from being saved:" } - ul do - model.errors.each do |error| - li { error.full_message } - end - end - end - end - end -end diff --git a/lib/generators/superform/install/templates/base.rb b/lib/generators/superform/install/templates/base.rb new file mode 100644 index 0000000..b80fcff --- /dev/null +++ b/lib/generators/superform/install/templates/base.rb @@ -0,0 +1,33 @@ +module Components + class Form < Superform::Rails::Form + include Phlex::Rails::Helpers::Pluralize + + def row(component) + div do + render component.field.label(style: "display: block;") + render component + end + end + + def around_template(&) + super do + error_messages + yield + submit + end + end + + def error_messages + if model.errors.any? + div(style: "color: red;") do + h2 { "#{pluralize model.errors.count, "error"} prohibited this post from being saved:" } + ul do + model.errors.each do |error| + li { error.full_message } + end + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/field.rb b/lib/superform/field.rb index fb4e787..3e8f5cf 100644 --- a/lib/superform/field.rb +++ b/lib/superform/field.rb @@ -9,6 +9,7 @@ def initialize(key, parent:, object: nil, value: nil) super key, parent: parent @object = object @value = value + @readonly = false @dom = Superform::DOM.new(field: self) yield self if block_given? end @@ -23,6 +24,8 @@ def value alias :serialize :value def assign(value) + return if read_only? + if @object and @object.respond_to? "#{@key}=" @object.send "#{@key}=", value else @@ -36,5 +39,109 @@ def assign(value) def collection(&) @collection ||= FieldCollection.new(field: self, &) end + + # Make the name more obvious for extending or writing docs. + def field + self + end + + # Sets the field as readonly + def readonly(value = true) + @readonly = value + self + end + alias :read_only= :readonly + + # Checks if the field is readonly based on multiple conditions + def read_only? + return true if @readonly + return true if model_attribute_readonly? + false + end + + def kit(form) + self.class::Kit.new(field: self, form: form) + end + + private + + # Check if the model attribute is marked as readonly + def model_attribute_readonly? + return false unless @object + return false unless @object.class.respond_to?(:readonly_attributes) + @object.class.readonly_attributes.include?(@key.to_s) + end + + # High-performance Kit proxy that wraps field methods with form.render calls. + # Uses Ruby class hooks to define methods at the class level for maximum speed: + # - Methods are defined once per Field class, not per Kit instance + # - True Ruby methods with full VM optimization (no method_missing overhead) + # - ~125x faster Kit instantiation compared to instance-level dynamic methods + # - Each Field subclass gets its own isolated Kit class with true isolation: + # * Methods are copied at subclass creation time, not inherited dynamically + # * Adding methods to a parent Field class won't affect existing subclass Kits + # * Perfect for library design where you don't want parent changes affecting subclasses + class Kit + def initialize(field:, form:) + @field = field + @form = form + end + end + + def self.inherited(subclass) + super + # Create a new Kit class for each Field subclass with true isolation + # Copy methods from parent Field classes at creation time, not through inheritance + subclass.const_set(:Kit, Class.new(Field::Kit)) + + # Copy all existing methods from the inheritance chain + field_class = self + while field_class != Field + copy_field_methods_to_kit(field_class, subclass::Kit) + field_class = field_class.superclass + end + end + + def self.method_added(method_name) + super + # Skip if this is the base Field class or if we don't have a Kit class yet + return if self == Field + return unless const_defined?(:Kit, false) + + # Only add method to THIS class's Kit, not subclasses (isolation) + add_method_to_kit(method_name, self::Kit) + end + + private + + def self.copy_field_methods_to_kit(field_class, kit_class) + base_methods = (Object.instance_methods + Node.instance_methods + + [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set + + field_class.instance_methods(false).each do |method_name| + next if method_name.to_s.end_with?('=') + next if base_methods.include?(method_name) + next if kit_class.method_defined?(method_name) + + kit_class.define_method(method_name) do |*args, **kwargs, &block| + result = @field.send(method_name, *args, **kwargs, &block) + @form.render result + end + end + end + + def self.add_method_to_kit(method_name, kit_class) + return if method_name.to_s.end_with?('=') + + base_methods = (Object.instance_methods + Node.instance_methods + + [:dom, :value, :serialize, :assign, :collection, :field, :kit]).to_set + return if base_methods.include?(method_name) + return if kit_class.method_defined?(method_name) + + kit_class.define_method(method_name) do |*args, **kwargs, &block| + result = @field.send(method_name, *args, **kwargs, &block) + @form.render result + end + end end end diff --git a/lib/superform/form.rb b/lib/superform/form.rb new file mode 100644 index 0000000..e7877a2 --- /dev/null +++ b/lib/superform/form.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "phlex" + +module Superform + # A basic form component that inherits from Phlex::HTML and wraps content in a form tag. + # This provides a simple foundation for building forms without Rails dependencies. + # + # Example usage: + # class MyForm < Superform::Form + # def view_template + # div { "Form content goes here" } + # end + # end + # + # form = MyForm.new(action: "/users", method: :post) + # form.call # renders
...
+ class Form < Phlex::HTML + def initialize(action: nil, method: :post, **attributes) + @action = action + @method = method + @attributes = attributes + super() + end + + def around_template(&block) + form(action: @action, method: @method, **@attributes, &block) + end + + def build_field(key, parent:, object: nil, &block) + Field.new(key, parent: parent, object: object, &block) + end + end +end diff --git a/lib/superform/namespace.rb b/lib/superform/namespace.rb index b21146c..d4cc487 100644 --- a/lib/superform/namespace.rb +++ b/lib/superform/namespace.rb @@ -9,12 +9,12 @@ module Superform class Namespace < Node include Enumerable - attr_reader :object, :field_class + attr_reader :object, :form - def initialize(key, parent:, object: nil, field_class: Field) + def initialize(key, parent:, object: nil, form: Superform::Form.new) super(key, parent:) @object = object - @field_class = field_class + @form = form @children = Hash.new yield self if block_given? end @@ -34,7 +34,7 @@ def initialize(key, parent:, object: nil, field_class: Field) # end # ``` def namespace(key, &) - create_child(key, self.class, object: object_for(key:), field_class:, &) + @children[key] ||= build_namespace(key, &) end # Maps the `Object#proprety` and `Object#property=` to a field in a web form that can be @@ -47,9 +47,12 @@ def namespace(key, &) # end # ``` def field(key, &) - create_child(key, field_class, object:, &) + @children[key] ||= build_field(key, &) end + def Field(...) + field(...).kit(@form) + end # Wraps an array of objects in Namespace classes. For example, if `User#addresses` returns # an enumerable or array of `Address` classes: # @@ -67,7 +70,7 @@ def field(key, &) # The object within the block is a `Namespace` object that maps each object within the enumerable # to another `Namespace` or `Field`. def collection(key, &) - create_child(key, NamespaceCollection, field_class:, &) + @children[key] ||= build_collection(key, &) end # Creates a Hash of Hashes and Arrays that represent the fields and collections of the Superform. @@ -113,10 +116,19 @@ def object_for(key:) private - # Checks if the child exists. If it does then it returns that. If it doesn't, it will - # build the child. - def create_child(key, child_class, **, &) - @children.fetch(key) { @children[key] = child_class.new(key, parent: self, **, &) } + # Builds a new field child + def build_field(key, &) + @form.build_field(key, parent: self, object:, &) + end + + # Builds a new namespace child + def build_namespace(key, &) + self.class.new(key, parent: self, object: object_for(key:), form:, &) + end + + # Builds a new collection child + def build_collection(key, &) + NamespaceCollection.new(key, parent: self, form:, &) end end end diff --git a/lib/superform/namespace_collection.rb b/lib/superform/namespace_collection.rb index 04db9fc..2354b1e 100644 --- a/lib/superform/namespace_collection.rb +++ b/lib/superform/namespace_collection.rb @@ -5,12 +5,12 @@ module Superform class NamespaceCollection < Node include Enumerable - attr_reader :field_class + attr_reader :form - def initialize(key, parent:, field_class: Field, &template) + def initialize(key, parent:, form: parent.form, &template) super(key, parent:) @template = template - @field_class = field_class + @form = form @namespaces = enumerate(parent_collection) end @@ -41,7 +41,7 @@ def enumerate(enumerator) end def build_namespace(index, **) - parent.class.new(index, parent: self, field_class:, **, &@template) + parent.class.new(index, parent: self, form:, **, &@template) end def parent_collection diff --git a/lib/superform/rails.rb b/lib/superform/rails.rb index f009b9d..d6c782e 100644 --- a/lib/superform/rails.rb +++ b/lib/superform/rails.rb @@ -16,381 +16,7 @@ def self.base_class const_get SUPERCLASSES.find { |const| const_defined?(const) } end - # Set the base class for the remainder of this library. + # Set the base class for the rem Component = base_class - - # A Phlex::HTML view module that accepts a model and sets a `Superform::Namespace` - # with the `Object#model_name` as the key and maps the object to form fields - # and namespaces. - # - # The `Form::Field` is a class that's meant to be extended so you can customize the `Form` inputs - # to your applications needs. Defaults for the `input`, `button`, `label`, and `textarea` tags - # are provided. - # - # The `Form` component also handles Rails authenticity tokens via the `authenticity_toklen_field` - # method and the HTTP verb via the `_method_field`. - class Form < Component - include Phlex::Rails::Helpers::FormAuthenticityToken - include Phlex::Rails::Helpers::URLFor - - attr_accessor :model - - delegate \ - :field, - :collection, - :namespace, - :assign, - :serialize, - to: :@namespace - - # The Field class is designed to be extended to create custom forms. To override, - # in your subclass you may have something like this: - # - # ```ruby - # class MyForm < Superform::Rails::Form - # class MyLabel < Superform::Rails::Components::LabelComponent - # def view_template(&content) - # label(form: @field.dom.name, class: "text-bold", &content) - # end - # end - # - # class Field < Field - # def label(**attributes) - # MyLabel.new(self, **attributes) - # end - # end - # end - # ``` - # - # Now all calls to `label` will have the `text-bold` class applied to it. - class Field < Superform::Field - def button(**attributes) - Components::ButtonComponent.new(self, attributes:) - end - - def input(**attributes) - Components::InputComponent.new(self, attributes:) - end - - def checkbox(**attributes) - Components::CheckboxComponent.new(self, attributes:) - end - - def label(**attributes, &) - Components::LabelComponent.new(self, attributes:, &) - end - - def textarea(**attributes) - Components::TextareaComponent.new(self, attributes:) - end - - def select(*collection, **attributes, &) - Components::SelectField.new(self, attributes:, collection:, &) - end - - def title - key.to_s.titleize - end - end - - def initialize(model, action: nil, method: nil, **attributes) - @model = model - @action = action - @method = method - @attributes = attributes - @namespace = Namespace.root(key, object: model, field_class:) - end - - def around_template(&) - form_tag do - authenticity_token_field - _method_field - super - end - end - - def form_tag(&) - form action: form_action, method: form_method, **@attributes, & - end - - def view_template(&block) - yield_content(&block) - end - - def submit(value = submit_value, **attributes) - input **attributes.merge( - name: "commit", - type: "submit", - value: value - ) - end - - def key - @model.model_name.param_key - end - - protected - def authenticity_token_field - input( - name: "authenticity_token", - type: "hidden", - value: form_authenticity_token - ) - end - - def _method_field - input( - name: "_method", - type: "hidden", - value: _method_field_value - ) - end - - def _method_field_value - @method || resource_method_field_value - end - - def resource_method_field_value - @model.persisted? ? "patch" : "post" - end - - def submit_value - "#{resource_action.to_s.capitalize} #{@model.model_name}" - end - - def resource_action - @model.persisted? ? :update : :create - end - - def form_action - @action ||= url_for(action: resource_action) - end - - def form_method - @method.to_s.downcase == "get" ? "get" : "post" - end - - def field_class - self.class::Field - end - end - - module StrongParameters - protected - # Assigns params to the form and returns the model. - def assign(params, to:) - form = to - # TODO: Figure out how to render this in a way that doesn't concat a string; just throw everything away. - render_to_string form - form.assign params - form.model - end - end - - # Accept a collection of objects and map them to options suitable for form controls, like `select > options` - class OptionMapper - include Enumerable - - def initialize(collection) - @collection = collection - end - - def each(&options) - @collection.each do |object| - case object - in ActiveRecord::Relation => relation - active_record_relation_options_enumerable(relation).each(&options) - in id, value - options.call id, value - in value - options.call value, value.to_s - end - end - end - - def active_record_relation_options_enumerable(relation) - Enumerator.new do |collection| - relation.each do |object| - attributes = object.attributes - id = attributes.delete(relation.primary_key) - value = attributes.values.join(" ") - collection << [ id, value ] - end - end - end - end - - module Components - class BaseComponent < Component - attr_reader :field, :dom - - delegate :dom, to: :field - - def initialize(field, attributes: {}) - @field = field - @attributes = attributes - end - - def field_attributes - {} - end - - def focus(value = true) - @attributes[:autofocus] = value - self - end - - private - - def attributes - field_attributes.merge(@attributes) - end - end - - class FieldComponent < BaseComponent - def field_attributes - { id: dom.id, name: dom.name } - end - end - - class LabelComponent < BaseComponent - def view_template(&content) - content ||= Proc.new { field.key.to_s.titleize } - label(**attributes, &content) - end - - def field_attributes - { for: dom.id } - end - end - - class ButtonComponent < FieldComponent - def view_template(&content) - content ||= Proc.new { button_text } - button(**attributes, &content) - end - - def button_text - @attributes.fetch(:value, dom.value).titleize - end - - def field_attributes - { id: dom.id, name: dom.name, value: dom.value } - end - end - - class CheckboxComponent < FieldComponent - def view_template(&) - # Rails has a hidden and checkbox input to deal with sending back a value - # to the server regardless of if the input is checked or not. - input(name: dom.name, type: :hidden, value: "0") - # The hard coded keys need to be in here so the user can't overrite them. - input(type: :checkbox, value: "1", **attributes) - end - - def field_attributes - { id: dom.id, name: dom.name, checked: field.value } - end - end - - class InputComponent < FieldComponent - def view_template(&) - input(**attributes) - end - - def field_attributes - { - id: dom.id, - name: dom.name, - type: type, - value: value - } - end - - def has_client_provided_value? - case type.to_s - when "file", "image" - true - else - false - end - end - - def value - dom.value unless has_client_provided_value? - end - - def type - @type ||= ActiveSupport::StringInquirer.new(attribute_type || value_type) - end - - protected - def value_type - case field.value - when URI - "url" - when Integer, Float - "number" - when Date, DateTime - "date" - when Time - "time" - else - "text" - end - end - - def attribute_type - if type = @attributes[:type] || @attributes["type"] - type.to_s - end - end - end - - class TextareaComponent < FieldComponent - def view_template(&content) - content ||= Proc.new { dom.value } - textarea(**attributes, &content) - end - end - - class SelectField < FieldComponent - def initialize(*, collection: [], **, &) - super(*, **, &) - @collection = collection - end - - def view_template(&options) - if block_given? - select(**attributes, &options) - else - select(**attributes) { options(*@collection) } - end - end - - def options(*collection) - map_options(collection).each do |key, value| - option(selected: field.value == key, value: key) { value } - end - end - - def blank_option(&) - option(selected: field.value.nil?, &) - end - - def true_option(&) - option(selected: field.value == true, value: true.to_s, &) - end - - def false_option(&) - option(selected: field.value == false, value: false.to_s, &) - end - - protected - def map_options(collection) - OptionMapper.new(collection) - end - end - end end -end +end \ No newline at end of file diff --git a/lib/superform/rails/components/base.rb b/lib/superform/rails/components/base.rb new file mode 100644 index 0000000..3112b37 --- /dev/null +++ b/lib/superform/rails/components/base.rb @@ -0,0 +1,31 @@ +module Superform + module Rails + module Components + class Base < Component + attr_reader :field, :dom + + delegate :dom, to: :field + + def initialize(field, attributes: {}) + @field = field + @attributes = attributes + end + + def field_attributes + {} + end + + def focus(value = true) + @attributes[:autofocus] = value + self + end + + private + + def attributes + field_attributes.merge(@attributes) + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/button.rb b/lib/superform/rails/components/button.rb new file mode 100644 index 0000000..dd44d56 --- /dev/null +++ b/lib/superform/rails/components/button.rb @@ -0,0 +1,20 @@ +module Superform + module Rails + module Components + class Button < Field + def view_template(&content) + content ||= Proc.new { button_text } + button(**attributes, &content) + end + + def button_text + @attributes.fetch(:value, dom.value).titleize + end + + def field_attributes + { id: dom.id, name: dom.name, value: dom.value } + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/checkbox.rb b/lib/superform/rails/components/checkbox.rb new file mode 100644 index 0000000..628a74a --- /dev/null +++ b/lib/superform/rails/components/checkbox.rb @@ -0,0 +1,21 @@ +module Superform + module Rails + module Components + class Checkbox < Field + def view_template(&) + # Rails has a hidden and checkbox input to deal with sending back a value + # to the server regardless of if the input is checked or not. + input(name: dom.name, type: :hidden, value: "0") + # The hard coded keys need to be in here so the user can't overrite them. + input(type: :checkbox, value: "1", **attributes) + end + + def field_attributes + attrs = { id: dom.id, name: dom.name, checked: field.value } + attrs[:disabled] = true if field.read_only? + attrs + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/field.rb b/lib/superform/rails/components/field.rb new file mode 100644 index 0000000..c509ba2 --- /dev/null +++ b/lib/superform/rails/components/field.rb @@ -0,0 +1,11 @@ +module Superform + module Rails + module Components + class Field < Base + def field_attributes + { id: dom.id, name: dom.name } + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/input.rb b/lib/superform/rails/components/input.rb new file mode 100644 index 0000000..5a441d5 --- /dev/null +++ b/lib/superform/rails/components/input.rb @@ -0,0 +1,61 @@ +module Superform + module Rails + module Components + class Input < Field + def view_template(&) + input(**attributes) + end + + def field_attributes + attrs = { + id: dom.id, + name: dom.name, + type: type, + value: value + } + attrs[:readonly] = true if field.read_only? + attrs + end + + def has_client_provided_value? + case type.to_s + when "file", "image" + true + else + false + end + end + + def value + dom.value unless has_client_provided_value? + end + + def type + @type ||= ActiveSupport::StringInquirer.new(attribute_type || value_type) + end + + protected + def value_type + case field.value + when URI + "url" + when Integer, Float + "number" + when Date, DateTime + "date" + when Time + "time" + else + "text" + end + end + + def attribute_type + if type = @attributes[:type] || @attributes["type"] + type.to_s + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/label.rb b/lib/superform/rails/components/label.rb new file mode 100644 index 0000000..20f13dc --- /dev/null +++ b/lib/superform/rails/components/label.rb @@ -0,0 +1,20 @@ +module Superform + module Rails + module Components + class Label < Base + def view_template(&content) + content ||= Proc.new { label_text } + label(**attributes, &content) + end + + def field_attributes + { for: dom.id } + end + + def label_text + field.key.to_s.titleize + end + end + end + end +end diff --git a/lib/superform/rails/components/select.rb b/lib/superform/rails/components/select.rb new file mode 100644 index 0000000..f1b2613 --- /dev/null +++ b/lib/superform/rails/components/select.rb @@ -0,0 +1,52 @@ +module Superform + module Rails + module Components + class Select < Field + def initialize(*, collection: [], **, &) + super(*, **, &) + @collection = collection + end + + def view_template(&options) + if block_given? + select(**attributes, &options) + else + select(**attributes) { options(*@collection) } + end + end + + def field_attributes + attrs = { + id: dom.id, + name: dom.name + } + attrs[:disabled] = true if field.read_only? + attrs + end + + def options(*collection) + map_options(collection).each do |key, value| + option(selected: field.value == key, value: key) { value } + end + end + + def blank_option(&) + option(selected: field.value.nil?, &) + end + + def true_option(&) + option(selected: field.value == true, value: true.to_s, &) + end + + def false_option(&) + option(selected: field.value == false, value: false.to_s, &) + end + + protected + def map_options(collection) + OptionMapper.new(collection) + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/components/textarea.rb b/lib/superform/rails/components/textarea.rb new file mode 100644 index 0000000..bc67e33 --- /dev/null +++ b/lib/superform/rails/components/textarea.rb @@ -0,0 +1,21 @@ +module Superform + module Rails + module Components + class Textarea < Field + def view_template(&content) + content ||= Proc.new { dom.value } + textarea(**attributes, &content) + end + + def field_attributes + attrs = { + id: dom.id, + name: dom.name + } + attrs[:readonly] = true if field.read_only? + attrs + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/form.rb b/lib/superform/rails/form.rb new file mode 100644 index 0000000..592a8c7 --- /dev/null +++ b/lib/superform/rails/form.rb @@ -0,0 +1,270 @@ +module Superform + module Rails + # A Phlex::HTML view module that accepts a model and sets a `Superform::Namespace` + # with the `Object#model_name` as the key and maps the object to form fields + # and namespaces. + # + # The `Form::Field` is a class that's meant to be extended so you can customize the `Form` inputs + # to your applications needs. Defaults for the `input`, `button`, `label`, and `textarea` tags + # are provided. + # + # The `Form` component also handles Rails authenticity tokens via the `authenticity_toklen_field` + # method and the HTTP verb via the `_method_field`. + class Form < Component + include Phlex::Rails::Helpers::FormAuthenticityToken + include Phlex::Rails::Helpers::URLFor + + attr_accessor :model + + delegate \ + :Field, + :field, + :collection, + :namespace, + :assign, + :serialize, + to: :@namespace + + # The Field class is designed to be extended to create custom forms. To override, + # in your subclass you may have something like this: + # + # ```ruby + # class MyForm < Superform::Rails::Form + # class MyLabel < Superform::Rails::Components::Label + # def view_template(&content) + # label(form: @field.dom.name, class: "text-bold", &content) + # end + # end + # + # class Field < Field + # def label(**attributes) + # MyLabel.new(self, **attributes) + # end + # end + # end + # ``` + # + # Now all calls to `label` will have the `text-bold` class applied to it. + class Field < Superform::Field + def button(**attributes) + Components::Button.new(self, attributes:) + end + + def input(**attributes) + handle_readonly_attribute(attributes) + Components::Input.new(self, attributes:) + end + + def text(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :text, &) + end + + def checkbox(**attributes) + Components::Checkbox.new(self, attributes:) + end + + def label(**attributes, &) + Components::Label.new(self, attributes:, &) + end + + def textarea(**attributes) + handle_readonly_attribute(attributes) + Components::Textarea.new(self, attributes:) + end + + def select(*collection, **attributes, &) + Components::Select.new(self, attributes:, collection:, &) + end + + # HTML5 input type convenience methods - clean API without _field suffix + # Examples: + # field(:email).email(class: "form-input") + # field(:age).number(min: 18, max: 99) + # field(:birthday).date + # field(:secret).hidden(value: "token123") + # field(:gender).radio("male", id: "user_gender_male") + def hidden(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :hidden, &) + end + + def password(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :password, &) + end + + def email(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :email, &) + end + + def url(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :url, &) + end + + def tel(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :tel, &) + end + alias_method :phone, :tel + + def number(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :number, &) + end + + def range(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :range, &) + end + + def date(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :date, &) + end + + def time(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :time, &) + end + + def datetime(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :"datetime-local", &) + end + + def month(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :month, &) + end + + def week(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :week, &) + end + + def color(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :color, &) + end + + def search(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :search, &) + end + + def file(*, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :file, &) + end + + def radio(value, *, **attributes, &) + handle_readonly_attribute(attributes) + input(*, **attributes, type: :radio, value: value, &) + end + + # Rails compatibility aliases + alias_method :check_box, :checkbox + alias_method :text_area, :textarea + + def title + key.to_s.titleize + end + + private + + def handle_readonly_attribute(attributes) + if attributes[:readonly] || attributes['readonly'] + readonly(true) + # Remove from attributes since it's handled by the field + attributes.delete(:readonly) + attributes.delete('readonly') + end + end + end + + def build_field(...) + self.class::Field.new(...) + end + + def initialize(model, action: nil, method: nil, **attributes) + @model = model + @action = action + @method = method + @attributes = attributes + @namespace = Namespace.root(key, object: model, form: self) + end + + def around_template(&) + form_tag do + authenticity_token_field + _method_field + super + end + end + + def form_tag(&) + form action: form_action, method: form_method, **@attributes, & + end + + def view_template(&block) + yield self if block_given? + end + + def submit(value = submit_value, **attributes) + input **attributes.merge( + name: "commit", + type: "submit", + value: value + ) + end + + def key + @model.model_name.param_key + end + + protected + def authenticity_token_field + input( + name: "authenticity_token", + type: "hidden", + value: form_authenticity_token + ) + end + + def _method_field + input( + name: "_method", + type: "hidden", + value: _method_field_value + ) + end + + def _method_field_value + @method || resource_method_field_value + end + + def resource_method_field_value + @model.persisted? ? "patch" : "post" + end + + def submit_value + "#{resource_action.to_s.capitalize} #{@model.model_name}" + end + + def resource_action + @model.persisted? ? :update : :create + end + + def form_action + @action ||= url_for(action: resource_action) + end + + def form_method + @method.to_s.downcase == "get" ? "get" : "post" + end + end + end +end diff --git a/lib/superform/rails/option_mapper.rb b/lib/superform/rails/option_mapper.rb new file mode 100644 index 0000000..3cef6e7 --- /dev/null +++ b/lib/superform/rails/option_mapper.rb @@ -0,0 +1,36 @@ +module Superform + module Rails + # Accept a collection of objects and map them to options suitable for form controls, like `select > options` + class OptionMapper + include Enumerable + + def initialize(collection) + @collection = collection + end + + def each(&options) + @collection.each do |object| + case object + in ActiveRecord::Relation => relation + active_record_relation_options_enumerable(relation).each(&options) + in id, value + options.call id, value + in value + options.call value, value.to_s + end + end + end + + def active_record_relation_options_enumerable(relation) + Enumerator.new do |collection| + relation.each do |object| + attributes = object.attributes + id = attributes.delete(relation.primary_key) + value = attributes.values.join(" ") + collection << [ id, value ] + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/superform/rails/strong_parameters.rb b/lib/superform/rails/strong_parameters.rb new file mode 100644 index 0000000..c79ec63 --- /dev/null +++ b/lib/superform/rails/strong_parameters.rb @@ -0,0 +1,73 @@ +module Superform + module Rails + module StrongParameters + protected + # Assigns permitted params to the given form and returns the model. + # Usage in a controller when you want to build/validate without saving: + # + # def preview + # @post = Post.new + # post = permit PostForm.new(@post) + # # post now has attributes from params, but is not persisted + # render :preview + # end + def permit(form) + form_params = params.require(form.key) + assign(form_params, to: form).model + end + + # Saves the form's underlying model after assigning permitted params. + # Typical Rails controller usage (create): + # + # def create + # @post = Post.new + # if save PostForm.new(@post) + # redirect_to @post + # else + # render :new, status: :unprocessable_entity + # end + # end + # + # Typical Rails controller usage (update): + # + # def update + # @post = Post.find(params[:id]) + # if save PostForm.new(@post) + # redirect_to @post + # else + # render :edit, status: :unprocessable_entity + # end + # end + def save(form) + permit(form).save + end + + # Bang version that raises on validation failure. + # Useful when you prefer exceptions or are in a transaction: + # + # def create + # @post = Post.new + # save! PostForm.new(@post) + # redirect_to @post + # rescue ActiveRecord::RecordInvalid + # render :new, status: :unprocessable_entity + # end + def save!(form) + permit(form).save! + end + + # Assigns params to the form and returns the form. + def assign(params, to:) + to.tap do |form| + # This output of this string goes nowhere since it likely + # won't be used. I'm not sure if I'm right about this though, + # If I'm wrong, then I think I need to encapsulate this module + # into a class that can store the rendered HTML that can be + # rendered later. + render_to_string form + form.assign params + end + end + end + end +end diff --git a/lib/superform/version.rb b/lib/superform/version.rb index 7a3955a..0b0df64 100644 --- a/lib/superform/version.rb +++ b/lib/superform/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Superform - VERSION = "0.5.1" + VERSION = "0.6.0" end diff --git a/spec/generators/superform/install_generator_spec.rb b/spec/generators/superform/install_generator_spec.rb new file mode 100644 index 0000000..636ad67 --- /dev/null +++ b/spec/generators/superform/install_generator_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "spec_helper" +require "rails/generators" +require "generators/superform/install/install_generator" +require "tmpdir" +require "fileutils" + +RSpec.describe Superform::InstallGenerator, type: :generator do + let(:destination_root) { Dir.mktmpdir } + let(:generator) { described_class.new([], {}, { destination_root: destination_root }) } + + before do + FileUtils.mkdir_p(destination_root) + allow(Rails).to receive(:root).and_return(Pathname.new(destination_root)) + end + + after do + FileUtils.rm_rf(destination_root) if File.exist?(destination_root) + end + + context "when phlex-rails is not installed" do + before do + allow(generator).to receive(:gem_in_bundle?).with("phlex-rails").and_return(false) + allow(generator).to receive(:say) + allow(generator).to receive(:exit).and_raise(SystemExit) + end + + it "fails with helpful error message" do + expect { generator.invoke_all }.to raise_error(SystemExit) + end + end + + context "when phlex-rails is installed" do + before do + allow(generator).to receive(:gem_in_bundle?).with("phlex-rails").and_return(true) + end + + it "creates the base form component" do + generator.invoke_all + + expect(File.exist?(File.join(destination_root, "app/components/form.rb"))).to be true + end + + describe "generated file" do + subject { File.read(File.join(destination_root, "app/components/form.rb")) } + + before { generator.invoke_all } + + it { is_expected.to include("module Components") } + it { is_expected.to include("class Form < Superform::Rails::Form") } + it { is_expected.to include("def row(component)") } + it { is_expected.to include("def error_messages") } + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8da36d2..17029da 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,7 +2,8 @@ require "superform" require "phlex" -require "rails" + +require_relative "support/test_app" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/superform/form_spec.rb b/spec/superform/form_spec.rb new file mode 100644 index 0000000..48ad503 --- /dev/null +++ b/spec/superform/form_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Superform::Form do + describe "#call" do + context "with default method" do + subject { Superform::Form.new(action: "/users").call } + + it { is_expected.to include('action="/users"') } + it { is_expected.to include('method="post"') } + end + + context "with custom method and attributes" do + subject { Superform::Form.new(action: "/users", method: :patch, class: "my-form").call } + + it { is_expected.to include('action="/users"') } + it { is_expected.to include('method="patch"') } + it { is_expected.to include('class="my-form"') } + end + + context "without action" do + subject { Superform::Form.new(class: "simple").call } + + it { is_expected.to include('method="post"') } + it { is_expected.to include('class="simple"') } + end + end + + describe "#build_field" do + it "creates a Field instance" do + form = Superform::Form.new + field = form.build_field(:email, parent: nil) + + expect(field).to be_a(Superform::Field) + expect(field.key).to eq(:email) + end + end +end diff --git a/spec/superform/namespace_collection_spec.rb b/spec/superform/namespace_collection_spec.rb index 173a9b8..cf76492 100644 --- a/spec/superform/namespace_collection_spec.rb +++ b/spec/superform/namespace_collection_spec.rb @@ -1,10 +1,13 @@ RSpec.describe Superform::NamespaceCollection do + Bar = Struct.new(:baz, keyword_init: true) + TestObject = Struct.new(:bars, keyword_init: true) + describe "#assign" do it "assigns the value to each namespace" do - object = OpenStruct.new( + object = TestObject.new( bars: [ - OpenStruct.new(baz: "A"), - OpenStruct.new(baz: "B") + Bar.new(baz: "A"), + Bar.new(baz: "B") ] ) diff --git a/spec/superform/rails/components/input_component_spec.rb b/spec/superform/rails/components/input_component_spec.rb index fb400c4..ab1dac8 100644 --- a/spec/superform/rails/components/input_component_spec.rb +++ b/spec/superform/rails/components/input_component_spec.rb @@ -1,4 +1,4 @@ -RSpec.describe Superform::Rails::Components::InputComponent do +RSpec.describe Superform::Rails::Components::Input do let(:field) do object = double("object", "foo=": nil) object = double("object", "foo": value) diff --git a/spec/superform/rails/components/label_spec.rb b/spec/superform/rails/components/label_spec.rb new file mode 100644 index 0000000..57cfce1 --- /dev/null +++ b/spec/superform/rails/components/label_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Superform::Rails::Components::Label do + let(:user) { User.new(first_name: "John") } + let(:form) { Superform::Rails::Form.new(user) } + let(:field) { form.field(:first_name) } + let(:label) { described_class.new(field, attributes: { class: "form-label" }) } + + subject { label.call } + + it "renders label with titleized field name" do + expect(subject).to eq('') + end +end \ No newline at end of file diff --git a/spec/superform/rails/field_convenience_methods_spec.rb b/spec/superform/rails/field_convenience_methods_spec.rb new file mode 100644 index 0000000..c3d9196 --- /dev/null +++ b/spec/superform/rails/field_convenience_methods_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Superform::Rails::Form::Field do + let(:user) { User.new(email: "test@example.com", first_name: "John") } + let(:form) { Superform::Rails::Form.new(user) } + let(:field) { form.field(:email) } + + describe "HTML5 input convenience methods" do + it { expect(field.text.type).to eq("text") } + it { expect(field.hidden.type).to eq("hidden") } + it { expect(field.password.type).to eq("password") } + it { expect(field.email.type).to eq("email") } + it { expect(field.url.type).to eq("url") } + it { expect(field.tel.type).to eq("tel") } + it { expect(field.phone.type).to eq("tel") } + it { expect(field.number.type).to eq("number") } + it { expect(field.range.type).to eq("range") } + it { expect(field.date.type).to eq("date") } + it { expect(field.time.type).to eq("time") } + it { expect(field.datetime.type).to eq("datetime-local") } + it { expect(field.month.type).to eq("month") } + it { expect(field.week.type).to eq("week") } + it { expect(field.color.type).to eq("color") } + it { expect(field.search.type).to eq("search") } + it { expect(field.file.type).to eq("file") } + it { expect(field.radio("male").type).to eq("radio") } + end + + describe "Rails compatibility aliases" do + describe "#check_box" do + it "creates checkbox component" do + component = field.check_box + expect(component).to be_a(Superform::Rails::Components::Checkbox) + end + end + + describe "#text_area" do + it "creates textarea component" do + component = field.text_area + expect(component).to be_a(Superform::Rails::Components::Textarea) + end + end + end + + describe "argument forwarding" do + it "passes through all attributes correctly" do + component = field.email(class: "form-input", required: true, placeholder: "Enter email") + expect(component.type).to eq("email") + end + + it "allows type override with input method" do + component = field.input(type: :search, class: "form-input") + expect(component.type).to eq("search") + end + + it "handles radio button value parameter correctly" do + component = field.radio("female", class: "radio-input", data: { value: "f" }) + expect(component.type).to eq("radio") + end + end +end \ No newline at end of file diff --git a/spec/superform/rails/form_spec.rb b/spec/superform/rails/form_spec.rb new file mode 100644 index 0000000..ca12c24 --- /dev/null +++ b/spec/superform/rails/form_spec.rb @@ -0,0 +1,100 @@ +RSpec.describe Superform::Rails::Form, type: :view do + let(:model) { User.new(first_name: "John", last_name: "Doe", email: "john@example.com") } + let(:form) { described_class.new(model, action: "/users") } + + describe "#initialize" do + it "creates a form with a model" do + expect(form.model).to eq(model) + end + + it "creates a root namespace" do + expect(form.instance_variable_get(:@namespace)).to be_a(Superform::Namespace) + end + end + + describe "#field" do + it "delegates to the namespace" do + field = form.field(:email) + expect(field).to be_a(Superform::Rails::Form::Field) + expect(field.key).to eq(:email) + end + end + + describe "#key" do + it "returns the model's param key" do + expect(form.key).to eq("user") + end + end + + describe "#render" do + subject { render(form) } + + it { is_expected.to include('