├── lib ├── form_props │ ├── version.rb │ ├── inputs │ │ ├── tel_field.rb │ │ ├── url_field.rb │ │ ├── file_field.rb │ │ ├── email_field.rb │ │ ├── range_field.rb │ │ ├── hidden_field.rb │ │ ├── time_field.rb │ │ ├── month_field.rb │ │ ├── week_field.rb │ │ ├── date_field.rb │ │ ├── datetime_local_field.rb │ │ ├── password_field.rb │ │ ├── number_field.rb │ │ ├── submit.rb │ │ ├── color_field.rb │ │ ├── search_field.rb │ │ ├── text_field.rb │ │ ├── datetime_field.rb │ │ ├── text_area.rb │ │ ├── time_zone_select.rb │ │ ├── collection_radio_buttons.rb │ │ ├── weekday_select.rb │ │ ├── collection_check_boxes.rb │ │ ├── collection_select.rb │ │ ├── grouped_collection_select.rb │ │ ├── radio_button.rb │ │ ├── select.rb │ │ ├── collection_helpers.rb │ │ ├── check_box.rb │ │ └── base.rb │ ├── select_renderer.rb │ ├── action_view_extensions │ │ └── form_helper.rb │ ├── form_options_helper.rb │ └── form_builder.rb └── form_props.rb ├── .gitignore ├── Gemfile.70 ├── Gemfile.71 ├── Gemfile.72 ├── Gemfile.80 ├── Gemfile.main ├── CODE_OF_CONDUCT.md ├── Rakefile ├── components ├── Extras.js ├── CheckBox.js ├── CollectionRadioButtons.js ├── CollectionCheckBoxes.js └── Select.js ├── .github └── workflows │ ├── dynamic-security.yml │ └── build.yml ├── package.json ├── CODEOWNERS ├── test ├── inputs │ ├── tel_field_test.rb │ ├── email_field_test.rb │ ├── url_field_test.rb │ ├── password_field_test.rb │ ├── search_field_test.rb │ ├── range_field_test.rb │ ├── number_field_test.rb │ ├── color_field_test.rb │ ├── hidden_field_test.rb │ ├── week_field_test.rb │ ├── month_field_test.rb │ ├── submit_test.rb │ ├── radio_button_test.rb │ ├── grouped_collection_select_test.rb │ ├── weekday_select_test.rb │ ├── time_field_test.rb │ ├── input_options_test.rb │ ├── file_field_test.rb │ ├── date_field_test.rb │ ├── datetime_field_test.rb │ ├── collection_select_test.rb │ ├── text_area_test.rb │ ├── text_field_test.rb │ ├── checkbox_field_test.rb │ └── time_zone_select_test.rb ├── test_helper.rb └── form_props_test.rb ├── SECURITY.md ├── form_props.gemspec ├── LICENSE.md └── yarn.lock /lib/form_props/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | VERSION = "0.2.2" 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ruby-version 2 | .byebug_history 3 | benchmark.rb 4 | performance/*/*.png 5 | *.gem 6 | .tool-versions 7 | node_modules 8 | build 9 | -------------------------------------------------------------------------------- /Gemfile.70: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "mocha" 4 | gem "pry-byebug" 5 | gem "standard", ">= 1.0" 6 | gem "rails", "~> 7.0.0" 7 | 8 | gemspec 9 | -------------------------------------------------------------------------------- /Gemfile.71: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "mocha" 4 | gem "pry-byebug" 5 | gem "standard", ">= 1.0" 6 | gem "rails", "~> 7.1.0" 7 | gem "uri", ">= 0.13.1" 8 | 9 | gemspec 10 | -------------------------------------------------------------------------------- /Gemfile.72: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "mocha" 4 | gem "pry-byebug" 5 | gem "standard", ">= 1.0" 6 | gem "rails", "~> 7.2.0" 7 | gem "uri", ">= 0.13.1" 8 | 9 | gemspec 10 | -------------------------------------------------------------------------------- /Gemfile.80: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "mocha" 4 | gem "pry-byebug" 5 | gem "standard", ">= 1.0" 6 | gem "rails", "~> 8.0.0" 7 | gem "uri", ">= 0.13.1" 8 | 9 | gemspec 10 | -------------------------------------------------------------------------------- /Gemfile.main: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "mocha" 4 | gem "pry-byebug" 5 | gem "standard", ">= 1.0" 6 | gem "rails", git: 'https://github.com/rails/rails', ref: 'main' 7 | 8 | gemspec 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of conduct 2 | 3 | By participating in this project, you agree to abide by the 4 | [thoughtbot code of conduct][1]. 5 | 6 | [1]: https://thoughtbot.com/open-source-code-of-conduct 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "minitest/test_task" 2 | require "standard/rake" 3 | 4 | Minitest::TestTask.create(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_globs = ["test/**/*_test.rb"] 8 | end 9 | 10 | task default: %i[test] 11 | -------------------------------------------------------------------------------- /lib/form_props/inputs/tel_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class TelField < TextField 6 | private 7 | 8 | def field_type 9 | "tel" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/form_props/inputs/url_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class UrlField < TextField 6 | private 7 | 8 | def field_type 9 | "url" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/form_props/inputs/file_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class FileField < TextField 6 | private 7 | 8 | def field_type 9 | "file" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/form_props/inputs/email_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class EmailField < TextField 6 | private 7 | 8 | def field_type 9 | "email" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/form_props/inputs/range_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class RangeField < NumberField 6 | private 7 | 8 | def field_type 9 | "range" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /components/Extras.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default (hiddenInputAttributes) => { 4 | const hiddenProps = Object.values(hiddenInputAttributes); 5 | const hiddenInputs = hiddenProps.map((props) => ( 6 | 7 | )); 8 | 9 | return ( 10 | <>{hiddenInputs} 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /lib/form_props/inputs/hidden_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class HiddenField < TextField 6 | def render 7 | @options[:auto_complete] = "off" 8 | super 9 | end 10 | 11 | def field_type 12 | "hidden" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/form_props/inputs/time_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class TimeField < DatetimeField 6 | private 7 | 8 | def format_date(value) 9 | value&.strftime("%T.%L") 10 | end 11 | 12 | def field_type 13 | "time" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/form_props/inputs/month_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class MonthField < DatetimeField 6 | private 7 | 8 | def format_date(value) 9 | value&.strftime("%Y-%m") 10 | end 11 | 12 | def field_type 13 | "month" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/form_props/inputs/week_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class WeekField < DatetimeField 6 | private 7 | 8 | def format_date(value) 9 | value&.strftime("%Y-W%V") 10 | end 11 | 12 | def field_type 13 | "week" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/form_props/inputs/date_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class DateField < DatetimeField 6 | private 7 | 8 | def format_date(value) 9 | value.presence&.strftime("%Y-%m-%d") 10 | end 11 | 12 | def field_type 13 | "date" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/form_props/inputs/datetime_local_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class DatetimeLocalField < DatetimeField 6 | private 7 | 8 | def format_date(value) 9 | value&.strftime("%Y-%m-%dT%T") 10 | end 11 | 12 | def field_type 13 | "datetime-local" 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/form_props/inputs/password_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class PasswordField < TextField 6 | def render 7 | @options = {value: nil}.merge!(@options) 8 | super 9 | end 10 | 11 | private 12 | 13 | def field_type 14 | "password" 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /components/CheckBox.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default ({includeHidden = true, name=null, uncheckedValue=null, children, ...rest}) => { 4 | return ( 5 | <> 6 | {includeHidden && } 7 | 8 | {children} 9 | 10 | 11 | ) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/dynamic-security.yml: -------------------------------------------------------------------------------- 1 | name: update-security 2 | 3 | on: 4 | push: 5 | paths: 6 | - SECURITY.md 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update-security: 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | pages: write 17 | uses: thoughtbot/templates/.github/workflows/dynamic-security.yaml@main 18 | secrets: 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "form_props", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "repository": "git@github.com:thoughtbot/form_props.git", 6 | "scripts": { 7 | "build": "esbuild index.js --bundle --loader:.js=jsx --outfile=build/out.js" 8 | }, 9 | "author": "Johny Ho ", 10 | "license": "MIT", 11 | "dependencies": { 12 | "esbuild": "^0.17.5", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /lib/form_props/inputs/number_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class NumberField < TextField 6 | def render 7 | if (range = @options.delete("in") || @options.delete("within")) 8 | @options.update("min" => range.min, "max" => range.max) 9 | end 10 | 11 | super 12 | end 13 | 14 | private 15 | 16 | def field_type 17 | "number" 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | # Global rule: 15 | * @jho406 16 | -------------------------------------------------------------------------------- /lib/form_props/inputs/submit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class Submit < Base 6 | def initialize(template_object, options) 7 | @template_object = template_object 8 | @options = options.with_indifferent_access 9 | end 10 | 11 | def render 12 | @options[:type] = field_type 13 | 14 | json.set!(:submit) do 15 | input_props(@options) 16 | end 17 | end 18 | 19 | private 20 | 21 | def field_type 22 | "submit" 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/form_props/inputs/color_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class ColorField < TextField 6 | def render 7 | @options["value"] ||= validate_color_string(value) 8 | super 9 | end 10 | 11 | private 12 | 13 | def validate_color_string(string) 14 | regex = /#[0-9a-fA-F]{6}/ 15 | if regex.match?(string) 16 | string.downcase 17 | else 18 | "#000000" 19 | end 20 | end 21 | 22 | def field_type 23 | "color" 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/inputs/tel_field_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class TelFieldTest < ActionView::TestCase 4 | include FormProps::ActionViewExtensions::FormHelper 5 | 6 | setup :setup_test_fixture 7 | 8 | def test_tel_field 9 | @post.title = "2125559090" 10 | form_props(model: @post) do |f| 11 | f.tel_field(:title) 12 | end 13 | 14 | result = json.result!.strip 15 | expected = { 16 | "type" => "tel", 17 | "defaultValue" => "2125559090", 18 | "name" => "post[title]", 19 | "id" => "post_title" 20 | } 21 | 22 | assert_equal(JSON.parse(result)["inputs"]["title"], expected) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/inputs/email_field_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class EmailFieldTest < ActionView::TestCase 4 | include FormProps::ActionViewExtensions::FormHelper 5 | 6 | setup :setup_test_fixture 7 | 8 | def test_url_field 9 | @post.title = "james@smith.com" 10 | form_props(model: @post) do |f| 11 | f.email_field(:title) 12 | end 13 | 14 | result = json.result!.strip 15 | expected = { 16 | "type" => "email", 17 | "defaultValue" => "james@smith.com", 18 | "name" => "post[title]", 19 | "id" => "post_title" 20 | } 21 | 22 | assert_equal(JSON.parse(result)["inputs"]["title"], expected) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/inputs/url_field_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class UrlFieldTest < ActionView::TestCase 4 | include FormProps::ActionViewExtensions::FormHelper 5 | 6 | setup :setup_test_fixture 7 | 8 | def test_url_field 9 | @post.title = "http://example.com" 10 | form_props(model: @post) do |f| 11 | f.url_field(:title) 12 | end 13 | 14 | result = json.result!.strip 15 | expected = { 16 | "type" => "url", 17 | "defaultValue" => "http://example.com", 18 | "name" => "post[title]", 19 | "id" => "post_title" 20 | } 21 | 22 | assert_equal(JSON.parse(result)["inputs"]["title"], expected) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/form_props/inputs/search_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class SearchField < FormProps::Inputs::TextField 6 | def render 7 | if @options[:autosave] 8 | if @options[:autosave] == true 9 | @options[:autosave] = request.host.split(".").reverse.join(".") 10 | end 11 | @options[:results] ||= 10 12 | end 13 | 14 | if @options[:onsearch] 15 | @options[:incremental] = true unless @options.has_key?(:incremental) 16 | end 17 | 18 | super 19 | end 20 | 21 | private 22 | 23 | def field_type 24 | "search" 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # Security Policy 3 | 4 | ## Supported Versions 5 | 6 | Only the the latest version of this project is supported at a given time. If 7 | you find a security issue with an older version, please try updating to the 8 | latest version first. 9 | 10 | If for some reason you can't update to the latest version, please let us know 11 | your reasons so that we can have a better understanding of your situation. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | For security inquiries or vulnerability reports, visit 16 | . 17 | 18 | If you have any suggestions to improve this policy, visit . 19 | 20 | 21 | -------------------------------------------------------------------------------- /components/CollectionRadioButtons.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default ({includeHidden = true, collection=[], ...rest}) => { 4 | if (collection.length == 0) { 5 | return null; 6 | } 7 | 8 | const checkboxes = collection.map((options) => { 9 | const { id } = options; 10 | const {label, ...inputOptions} = options; 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | ) 18 | }); 19 | 20 | const {name} = collection[0] 21 | 22 | return ( 23 | <> 24 | {includeHidden && } 25 | {checkboxes} 26 | 27 | ) 28 | } 29 | 30 | -------------------------------------------------------------------------------- /components/CollectionCheckBoxes.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default ({includeHidden = true, collection=[], ...rest}) => { 4 | if (collection.length == 0) { 5 | return null; 6 | } 7 | 8 | const checkboxes = collection.map((options) => { 9 | const { id } = options; 10 | const {uncheckedValue, label, ...inputOptions} = options; 11 | 12 | return ( 13 | <> 14 | 15 | 16 | 17 | ) 18 | }); 19 | 20 | const {name} = collection[0] 21 | 22 | return ( 23 | <> 24 | {includeHidden && } 25 | {checkboxes} 26 | 27 | ) 28 | } 29 | 30 | -------------------------------------------------------------------------------- /lib/form_props/inputs/text_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "action_view/helpers/tags/placeholderable" 4 | 5 | module FormProps 6 | module Inputs 7 | class TextField < Base 8 | include ActionView::Helpers::Tags::Placeholderable 9 | 10 | def render 11 | @options[:size] = @options[:max_length] unless @options.key?(:size) 12 | @options[:type] ||= field_type 13 | @options[:value] = @options.fetch(:value) { value_before_type_cast } unless field_type == "file" 14 | 15 | json.set!(sanitized_key) do 16 | add_default_name_and_field(@options) 17 | input_props(@options) 18 | end 19 | end 20 | 21 | private 22 | 23 | def field_type 24 | "text" 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/form_props/inputs/datetime_field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FormProps 4 | module Inputs 5 | class DatetimeField < TextField 6 | def render 7 | @options[:value] ||= format_date(value) 8 | @options[:min] = format_date(datetime_value(@options["min"])) 9 | @options[:max] = format_date(datetime_value(@options["max"])) 10 | super 11 | end 12 | 13 | private 14 | 15 | def format_date(value) 16 | raise NotImplementedError 17 | end 18 | 19 | def datetime_value(value) 20 | if value.is_a? String 21 | begin 22 | DateTime.parse(value) 23 | rescue 24 | nil 25 | end 26 | else 27 | value 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /form_props.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.expand_path("lib", __dir__) 2 | require "form_props/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "form_props" 6 | s.version = FormProps::VERSION 7 | s.author = "Johny Ho" 8 | s.email = "johny@thoughtbot.com" 9 | s.license = "MIT" 10 | s.homepage = "https://github.com/thoughtbot/form_props/" 11 | s.summary = "Form props is a Rails form builder that renders form attributes in JSON" 12 | s.description = "Form props is a Rails form builder that renders form attributes in JSON" 13 | s.files = Dir["MIT-LICENSE", "README.md", "lib/**/*"] 14 | 15 | s.required_ruby_version = ">= 2.7" 16 | 17 | s.add_dependency "activesupport", ">= 7.0", "< 9.0" 18 | s.add_dependency "actionview", ">= 7.0", "< 9.0" 19 | s.add_dependency "props_template", ">= 0.30.0" 20 | end 21 | -------------------------------------------------------------------------------- /components/Select.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default ({includeHidden= true, name=null, id=null, children, options=[], multiple=false, disabled=false, type=null, ...rest}) => { 4 | const addHidden = includeHidden && multiple 5 | 6 | const optionElements = options.map((option) => { 7 | if (option.hasOwnProperty('options')) { 8 | return ( 9 | 10 | {option.options.map((opt) => 12 | ) 13 | } else { 14 | return