├── .gitignore ├── .rspec ├── .travis.yml ├── .yardopts ├── CHANGELOG.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── bin └── console ├── formalist.gemspec ├── lib ├── formalist.rb └── formalist │ ├── child_forms │ ├── builder.rb │ ├── child_form.rb │ ├── params_processor.rb │ └── validity_check.rb │ ├── definition.rb │ ├── element.rb │ ├── element │ ├── attributes.rb │ └── class_interface.rb │ ├── elements.rb │ ├── elements │ ├── attr.rb │ ├── compound_field.rb │ ├── field.rb │ ├── form_field.rb │ ├── group.rb │ ├── many.rb │ ├── many_forms.rb │ ├── section.rb │ ├── standard.rb │ └── standard │ │ ├── check_box.rb │ │ ├── date_field.rb │ │ ├── date_time_field.rb │ │ ├── hidden_field.rb │ │ ├── multi_selection_field.rb │ │ ├── multi_upload_field.rb │ │ ├── number_field.rb │ │ ├── radio_buttons.rb │ │ ├── rich_text_area.rb │ │ ├── search_multi_selection_field.rb │ │ ├── search_selection_field.rb │ │ ├── select_box.rb │ │ ├── selection_field.rb │ │ ├── tags_field.rb │ │ ├── text_area.rb │ │ ├── text_field.rb │ │ └── upload_field.rb │ ├── form.rb │ ├── form │ └── validity_check.rb │ ├── rich_text │ ├── embedded_form_compiler.rb │ ├── embedded_forms_container.rb │ ├── embedded_forms_container │ │ ├── mixin.rb │ │ └── registration.rb │ ├── rendering │ │ ├── embedded_form_renderer.rb │ │ ├── html_compiler.rb │ │ └── html_renderer.rb │ └── validity_check.rb │ └── version.rb └── spec ├── integration ├── dependency_injection_spec.rb └── form_spec.rb ├── spec_helper.rb ├── support └── constants.rb └── unit ├── child_forms ├── builder_spec.rb ├── child_form_spec.rb ├── params_processor_spec.rb └── validity_check_spec.rb ├── elements ├── many_forms_spec.rb └── standard │ └── check_box_spec.rb ├── form └── validity_check_spec.rb └── rich_text ├── embedded_form_compiler_spec.rb ├── rendering ├── embedded_form_renderer_spec.rb └── html_compiler_spec.rb └── validity_check_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.byebug_history 2 | /.yardoc 3 | /doc 4 | /coverage 5 | /spec/examples.txt 6 | /pkg 7 | /Gemfile.lock 8 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | dist: trusty 3 | sudo: false 4 | cache: bundler 5 | before_install: 6 | - gem update bundler 7 | script: 8 | - bundle exec rake 9 | after_success: 10 | # Send coverage report from the job #1 == current MRI release 11 | - '[ "${TRAVIS_JOB_NUMBER#*.}" = "1" ] && [ "$TRAVIS_BRANCH" = "master" ] && bundle exec codeclimate-test-reporter' 12 | rvm: 13 | - 2.4.0 14 | - 2.3.3 15 | - 2.2.6 16 | - jruby-9.1.6.0 17 | addons: 18 | code_climate: 19 | repo_token: 2c2c7c253435c02667371778ca886b069d4d24590748fbd7396b9b080016bfa7 20 | notifications: 21 | email: false 22 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.9.0 / 2022-06-30 2 | 3 | ### Added 4 | 5 | - Added a `#with` method to `EmbeddedFormRenderer` to support additional call time options 6 | 7 | # 0.8.0 / 2022-06-29 8 | 9 | ### Changed 10 | 11 | - Update to ruby to version 3 12 | - Update dry-configurable to version 0.13 13 | 14 | # 0.7.0 / 2022-04-07 15 | 16 | ### Added 17 | 18 | - Add support a field with an arbitrary list of forms. Supporting updates in formalist-standard-react@^4.2.0 19 | - Add namespace and paths options to embedded form renderer 20 | - Support dry-schema/dry-validation 1.0 21 | 22 | # 0.6.0 / 2020-05-06 23 | 24 | ### Changed 25 | 26 | - Removed dry-types dependency from core gem 27 | 28 | # 0.5.4 / 2018-11-28 29 | 30 | ### Fixed 31 | 32 | - Fixed the rendering of validation errors when validating a `Many` element itself 33 | 34 | # 0.5.3 / 2018-09-25 35 | 36 | ### Added 37 | 38 | - Added `initial_attributes_url` attribute to `UploadField` and `MultiUploadField` 39 | 40 | # 0.5.2 / 2018-08-06 41 | 42 | ### Added 43 | 44 | - Added `clear_query_on_selection` attribute to `SearchMultiSelectionField` 45 | 46 | # 0.5.1 / 2018-07-19 47 | 48 | ### Added 49 | 50 | - Add `disabled` attribute to `TextField` 51 | - Add `time_format` and `human_time_format` attributes to `DateTimeField` 52 | - Add `sortable` and `max_height` attributes to `Many`, `MultiSelectionField`, `MultiUploadField`, and `SearchMultiSelectionField` 53 | 54 | ### Fixed 55 | 56 | - Allow falsey attribute values to be passed through in AST 57 | 58 | # 0.5.0 / 2018-07-04 59 | 60 | ### Added 61 | 62 | - Add `render_option_control_as` option to search select fields 63 | 64 | ### Changed 65 | 66 | - dry-types dependency updated to 0.13 67 | 68 | # 0.4.2 / 2018-07-03 69 | 70 | ### Fixed 71 | 72 | - Errors passed to `#fill` are now properly stored on the form's elements 73 | 74 | ### Changed 75 | 76 | - Private form methods are accessible from within definition blocks 77 | 78 | # 0.4.1 / 2018-04-17 79 | 80 | ### Fixed 81 | 82 | - Fixed issue with Form::Validity check crashing while processing `many` elements 83 | 84 | # 0.4.0 / 2018-03-28 85 | 86 | ### Added 87 | 88 | - `rich_text_area` field type, support for embedding and validating forms within rich text areas, and rendering the rich text from its native draft.js AST format to HTML 89 | - `search_selection_field` and `multi_search_selection_field` field types 90 | - Various improvements to the upload fields, including support for passing `presign_options` 91 | 92 | ### Changed 93 | 94 | - [BREAKING] Form definition blocks are now evaluated within the context of the form instance's `self`, which mean dependencies can be injected into the form object and accessed from the form definition block. `#dep` within the definition block has thusly been removed. 95 | - [BREAKING] `Formalist::Form` should now be instantiated (`form = MyForm.new`) and then filled with `form.fill(input: input_data, errors: error_messages)`. `Form#fill` returns a copy of the form object with all the elements filled with the input data and error messages as required. `Form#call` has been removed, along with `Formalist::Form::Result` (which was the object returned from `#call`). 96 | 97 | ### Removed 98 | 99 | - Form elements no longer have a `permitted_children` config. For now, this should be handled by convention only. 100 | 101 | # 0.3.0 / 2016-05-04 102 | 103 | Add support for upload and multi upload fields. 104 | 105 | # 0.2.3 / 2016-04-07 106 | 107 | Default check_box element values to false. 108 | 109 | # 0.2.2 / 2016-02-23 110 | 111 | Remove local type coercion using dry-data. We rely on a `Dry::Validation::Schema` to do this now. The form definition API has not yet changed, though. We still require field types to be specified, but there is no longer any restriction over what is entered. We'll remove this in a future release, once we can infer types from the schema. 112 | 113 | # 0.2.1 / 2016-02-23 114 | 115 | Fix issue where form could not be built with input data with native data types (it presuming input would be HTML form-style input everywhere). 116 | 117 | Add default (empty) input data argument for `Form#build`. This allows you simply to call `MyForm.build` for creating a "new" form (i.e. one without any existing input data). 118 | 119 | # 0.2.0 / 2016-02-22 120 | 121 | Require a dry-validation schema for each form. The schema should completely represent the form's expected data structure. 122 | 123 | Update the API to better support the two main use cases for a form. First, to prepare a form with sane, initial input from your database and send it to a view for rendering: 124 | 125 | ```ruby 126 | my_form = MyForm.new(my_schema) 127 | my_form.build(input) # returns a `Formalist::Form::Result` 128 | ``` 129 | 130 | Then, to receive the data from the posted form, coerce it and validate it (and potentially re-display the form with errors): 131 | 132 | ```ruby 133 | my_form = MyForm.new(my_schema) 134 | my_form.receive(input).validate # returns a `Formalist::Form::ValidatedResult` 135 | ``` 136 | 137 | The main differences are as such: 138 | 139 | * `#build` expects already-clean (e.g. properly structured and typed) data 140 | * `#receive` expects the kind of data that your form will submit, and will then coerce it according to the validation schema, and then send the sanitised output to `#build` 141 | * Calling `#validate` on a result object will validate the input data and include any error messages in its AST 142 | 143 | # 0.1.0 / 2016-01-18 144 | 145 | Initial release. 146 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "codeclimate-test-reporter", require: nil 7 | gem "dry-auto_inject" 8 | gem "dry-validation", "~> 1.0" 9 | end 10 | 11 | group :tools do 12 | gem "pry" 13 | gem "byebug", platform: :mri 14 | end 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2015-2016 [Icelab](http://icelab.com.au/). 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [gem]: https://rubygems.org/gems/formalist 2 | [travis]: https://travis-ci.org/icelab/formalist 3 | [code_climate]: https://codeclimate.com/github/icelab/formalist 4 | [inch]: http://inch-ci.org/github/icelab/formalist 5 | 6 | # Formalist 7 | 8 | [![Gem Version](https://img.shields.io/gem/v/formalist.svg)][gem] 9 | [![Build Status](https://travis-ci.org/icelab/formalist.svg?branch=master)][travis] 10 | [![Code Climate](https://img.shields.io/codeclimate/github/icelab/formalist.svg)][code_climate] 11 | [![Test Coverage](https://img.shields.io/codeclimate/coverage/github/icelab/formalist.svg)][code_climate] 12 | [![API Documentation Coverage](http://inch-ci.org/github/icelab/formalist.svg)][inch] 13 | 14 | ## Installation 15 | 16 | Add this line to your application’s `Gemfile`: 17 | 18 | ```ruby 19 | gem "formalist" 20 | ``` 21 | 22 | Run `bundle` to install the gems. 23 | 24 | ## Contributing 25 | 26 | Bug reports and pull requests are welcome on [GitHub](http://github.com/icelab/formalist). 27 | 28 | ## Credits 29 | 30 | Formalist is developed and maintained by [Icelab](http://icelab.com.au/). 31 | 32 | ## License 33 | 34 | Copyright © 2015-2016 [Icelab](http://icelab.com.au/). Formalist is free software, and may be redistributed under the terms specified in the [license](LICENSE.md). 35 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | require "rspec/core/rake_task" 4 | RSpec::Core::RakeTask.new 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | 5 | require "pry" 6 | begin 7 | require "byebug" 8 | rescue LoadError; end 9 | require "formalist" 10 | require "formalist/elements/standard" 11 | require "dry-validation" 12 | 13 | Pry.start 14 | -------------------------------------------------------------------------------- /formalist.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "formalist/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "formalist" 7 | spec.version = Formalist::VERSION 8 | spec.authors = ["Tim Riley"] 9 | spec.email = ["tim@icelab.com.au"] 10 | spec.license = "MIT" 11 | 12 | spec.summary = "Flexible form builder" 13 | spec.homepage = "https://github.com/icelab/formalist" 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 16 | spec.bindir = 'exe' 17 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 18 | spec.require_paths = ['lib'] 19 | 20 | spec.required_ruby_version = ">= 3.0.0" 21 | 22 | spec.add_runtime_dependency "dry-configurable", "~> 0.13" 23 | spec.add_runtime_dependency "dry-core", "~> 0.4" 24 | spec.add_runtime_dependency "dry-container", "~> 0.6" 25 | spec.add_runtime_dependency "inflecto" 26 | 27 | spec.add_development_dependency "bundler" 28 | spec.add_development_dependency "rake", "~> 10.4" 29 | spec.add_development_dependency "rspec", "~> 3.3.0" 30 | spec.add_development_dependency "simplecov", "~> 0.13.0" 31 | spec.add_development_dependency "yard" 32 | end 33 | -------------------------------------------------------------------------------- /lib/formalist.rb: -------------------------------------------------------------------------------- 1 | require "formalist/form" 2 | -------------------------------------------------------------------------------- /lib/formalist/child_forms/builder.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require_relative "child_form" 3 | 4 | module Formalist 5 | module ChildForms 6 | class Builder 7 | attr_reader :embedded_forms 8 | MissingFormDefinitionError = Class.new(StandardError) 9 | 10 | def initialize(embedded_form_collection) 11 | @embedded_forms = embedded_form_collection 12 | end 13 | 14 | def call(input) 15 | return input if input.nil? 16 | input.map { |node| visit(node) } 17 | end 18 | alias_method :[], :call 19 | 20 | private 21 | 22 | def visit(node) 23 | name, data = node.values_at(:name, :data) 24 | 25 | embedded_form = embedded_forms[name] 26 | if embedded_form.nil? 27 | raise MissingFormDefinitionError, "Form +#{embedded_forms[name]}+ is missing from the embeddable forms collection" 28 | end 29 | child_form(name, embedded_form).fill(input: data) 30 | end 31 | 32 | def child_form(name, embedded_form) 33 | ChildForm.build( 34 | name: name, 35 | attributes: { 36 | label: embedded_form.label, 37 | form: embedded_form.form, 38 | schema: embedded_form.schema, 39 | input_processor: embedded_form.input_processor, 40 | preview_image_url: embedded_form.preview_image_url 41 | } 42 | ) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/formalist/child_forms/child_form.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | module ChildForms 5 | class ChildForm < Element 6 | DEFAULT_INPUT_PROCESSOR = -> input { input }.freeze 7 | 8 | attribute :label 9 | attribute :form 10 | attribute :schema 11 | attribute :input_processor, default: DEFAULT_INPUT_PROCESSOR 12 | attribute :preview_image_url 13 | 14 | def fill(input: {}, errors: {}) 15 | super(input: form_input_ast(input), errors: errors.to_a) 16 | end 17 | 18 | def attributes 19 | super.merge(form: form_attribute_ast) 20 | end 21 | 22 | def form_attribute_ast 23 | @attributes[:form].to_ast 24 | end 25 | 26 | def form_input_ast(data) 27 | # Run the raw data through the validation schema 28 | validation = @attributes[:schema].(data) 29 | 30 | # And then through the embedded form's input processor (which may add 31 | # extra system-generated information necessary for the form to render 32 | # fully) 33 | input = @attributes[:input_processor].(validation.to_h) 34 | 35 | @attributes[:form].fill(input: input, errors: validation.errors.to_h).to_ast 36 | end 37 | 38 | def to_ast 39 | [:child_form, [ 40 | name, 41 | type, 42 | input, 43 | Element::Attributes.new(attributes).to_ast, 44 | ]] 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/formalist/child_forms/params_processor.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require_relative "builder" 3 | 4 | module Formalist 5 | module ChildForms 6 | class ParamsProcessor 7 | attr_reader :embedded_forms 8 | 9 | def initialize(embedded_form_collection) 10 | @embedded_forms = embedded_form_collection 11 | end 12 | 13 | def call(input) 14 | return input if input.nil? 15 | input.inject([]) { |output, node| output.push(process(node)) } 16 | end 17 | alias_method :[], :call 18 | 19 | private 20 | 21 | def process(node) 22 | name, data = node.values_at(:name, :data) 23 | 24 | validation = embedded_forms[name].schema.(data) 25 | node.merge(data: validation.to_h) 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/formalist/child_forms/validity_check.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | require_relative "builder" 3 | 4 | module Formalist 5 | module ChildForms 6 | class ValidityCheck 7 | attr_reader :embedded_forms 8 | 9 | def initialize(embedded_form_collection) 10 | @embedded_forms = embedded_form_collection 11 | end 12 | 13 | def call(input) 14 | return input if input.nil? 15 | input.map { |node| valid?(node) }.all? 16 | end 17 | alias_method :[], :call 18 | 19 | private 20 | 21 | def valid?(node) 22 | name, data = node.values_at(:name, :data) 23 | 24 | validation = embedded_forms[name].schema 25 | validation.(data).success? 26 | end 27 | 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/formalist/definition.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | class Definition 3 | DuplicateElementError = Class.new(StandardError) 4 | 5 | attr_reader :form 6 | attr_reader :config 7 | attr_reader :elements 8 | 9 | def initialize(form, config, &block) 10 | @form = form 11 | @config = config 12 | @elements = [] 13 | 14 | instance_eval(&block) if block 15 | end 16 | 17 | def with(**new_options, &block) 18 | new_config = new_options.each_with_object(config.dup) { |(key, value), config| 19 | config.send :"#{key}=", value 20 | } 21 | 22 | self.class.new(form, new_config, &block) 23 | end 24 | 25 | def method_missing(name, *args, &block) 26 | if element_type?(name) 27 | add_element(name, *args, &block) 28 | elsif form.respond_to?(name, include_private = true) 29 | form.send(name, *args, &block) 30 | else 31 | super 32 | end 33 | end 34 | 35 | private 36 | 37 | def respond_to_missing?(name, _include_private = false) 38 | element_type?(name) || form.respond_to?(name, _include_private = true) || super 39 | end 40 | 41 | def element_type?(type) 42 | config.elements_container.key?(type) 43 | end 44 | 45 | def add_element(type, *args, &block) 46 | element_name = args.shift unless args.first.is_a?(Hash) 47 | element_attrs = args.last.is_a?(Hash) ? args.last : {} 48 | 49 | if element_name && elements.any? { |element| element.name == element_name } 50 | raise DuplicateElementError, "element +#{element_name}+ is already defined in this context" 51 | end 52 | 53 | element_class = config.elements_container[type] 54 | element_children = with(&block).elements 55 | 56 | element = element_class.build( 57 | name: element_name, 58 | attributes: element_attrs, 59 | children: element_children, 60 | ) 61 | 62 | elements << element 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/formalist/element.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element/attributes" 2 | require "formalist/element/class_interface" 3 | 4 | module Formalist 5 | class Element 6 | extend ClassInterface 7 | 8 | # @api private 9 | attr_reader :name, :attributes, :children, :input, :errors 10 | 11 | # @api private 12 | def self.build(**args) 13 | new(**args) 14 | end 15 | 16 | # @api private 17 | def self.fill(input: {}, errors: {}, **args) 18 | new(**args).fill(input: input, errors: errors) 19 | end 20 | 21 | # @api private 22 | def initialize(name: nil, attributes: {}, children: [], input: nil, errors: []) 23 | @name = name&.to_sym 24 | 25 | @attributes = self.class.attributes_schema.each_with_object({}) { |(name, defn), hsh| 26 | value = attributes.fetch(name) { defn[:default] } 27 | hsh[name] = value unless value.nil? 28 | } 29 | 30 | @children = children 31 | @input = input 32 | @errors = errors 33 | end 34 | 35 | def fill(input: {}, errors: {}, **args) 36 | return self if input == @input && errors == @errors 37 | 38 | args = { 39 | name: @name, 40 | attributes: @attributes, 41 | children: @children, 42 | input: input, 43 | errors: errors, 44 | }.merge(args) 45 | 46 | self.class.new(**args) 47 | end 48 | 49 | def type 50 | self.class.type 51 | end 52 | 53 | def ==(other) 54 | name && type == other.type && name == other.name 55 | end 56 | 57 | # @abstract 58 | def to_ast 59 | raise NotImplementedError 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/formalist/element/attributes.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | class Element 3 | class Attributes 4 | # Returns the attributes hash. 5 | attr_reader :attrs 6 | 7 | # Creates an attributes object from the supplied hash. 8 | # 9 | # @param attrs [Hash] hash of form element attributes 10 | def initialize(attrs = {}) 11 | @attrs = attrs 12 | end 13 | 14 | # Returns the attributes as an abstract syntax tree. 15 | # 16 | # @return [Array] the abstract syntax tree 17 | def to_ast 18 | deep_to_ast(deep_simplify(attrs)) 19 | end 20 | 21 | private 22 | 23 | def deep_to_ast(value) 24 | case value 25 | when Hash 26 | [:object, [value.map { |k,v| [k.to_sym, deep_to_ast(v)] }].reject(&:empty?).flatten(1)] 27 | when Array 28 | [:array, value.map { |v| deep_to_ast(v) }] 29 | when String, Numeric, TrueClass, FalseClass, NilClass 30 | [:value, [value]] 31 | else 32 | [:value, [value.to_s]] 33 | end 34 | end 35 | 36 | def deep_simplify(value) 37 | case value 38 | when Hash 39 | value.each_with_object({}) { |(k,v), output| output[k] = deep_simplify(v) } 40 | when Array 41 | value.map { |v| deep_simplify(v) } 42 | when String, Numeric, TrueClass, FalseClass, NilClass 43 | value 44 | else 45 | if value.respond_to?(:to_h) 46 | deep_simplify(value.to_h) 47 | else 48 | value.to_s 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/formalist/element/class_interface.rb: -------------------------------------------------------------------------------- 1 | require "inflecto" 2 | 3 | module Formalist 4 | class Element 5 | # Class-level API for form elements. 6 | module ClassInterface 7 | def type 8 | Inflecto.underscore(Inflecto.demodulize(name)).to_sym 9 | end 10 | 11 | def attribute(name, default: nil) 12 | attributes(name => {default: default}) 13 | end 14 | 15 | def attributes_schema 16 | super_schema = superclass.respond_to?(:attributes_schema) ? superclass.attributes_schema : {} 17 | super_schema.merge(@attributes_schema || {}) 18 | end 19 | 20 | private 21 | 22 | def attributes(new_schema) 23 | prev_schema = @attributes_schema || {} 24 | @attributes_schema = prev_schema.merge(new_schema) 25 | 26 | self 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/formalist/elements.rb: -------------------------------------------------------------------------------- 1 | require "dry-container" 2 | require "formalist/elements/attr" 3 | require "formalist/elements/compound_field" 4 | require "formalist/elements/field" 5 | require "formalist/elements/group" 6 | require "formalist/elements/many" 7 | require "formalist/elements/many_forms" 8 | require "formalist/elements/form_field" 9 | require "formalist/elements/section" 10 | 11 | module Formalist 12 | class Elements 13 | extend Dry::Container::Mixin 14 | 15 | register :attr, Attr 16 | register :compound_field, CompoundField 17 | register :field, Field 18 | register :group, Group 19 | register :many, Many 20 | register :many_forms, ManyForms 21 | register :form_field, FormField 22 | register :section, Section 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/formalist/elements/attr.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class Attr < Element 6 | attribute :label 7 | 8 | def fill(input:, errors:) 9 | input = input[name] || {} 10 | errors = errors[name] || {} 11 | 12 | children = self.children.map { |child| 13 | child.fill(input: input, errors: errors) 14 | } 15 | 16 | super(input: input, errors: errors, children: children) 17 | end 18 | 19 | # Converts the attribute into an abstract syntax tree. 20 | # 21 | # It takes the following format: 22 | # 23 | # ``` 24 | # [:attr, [params]] 25 | # ``` 26 | # 27 | # With the following parameters: 28 | # 29 | # 1. Attribute name 30 | # 2. Custom element type (or `:attr` otherwise) 31 | # 3. Error messages 32 | # 4. Form element attributes 33 | # 5. Child form elements 34 | # 35 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 36 | # 37 | # @example "metadata" attr 38 | # attr.to_ast 39 | # # => [:attr, [ 40 | # :metadata, 41 | # :attr, 42 | # ["metadata is missing"], 43 | # [:object, []], 44 | # [...child elements...] 45 | # ]] 46 | # 47 | # @return [Array] the attribute as an abstract syntax tree. 48 | def to_ast 49 | local_errors = errors.is_a?(Array) ? errors : [] 50 | 51 | [:attr, [ 52 | name, 53 | type, 54 | local_errors, 55 | Element::Attributes.new(attributes).to_ast, 56 | children.map(&:to_ast), 57 | ]] 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/formalist/elements/compound_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class CompoundField < Element 6 | def fill(input: {}, errors: {}) 7 | children = self.children.map { |child| 8 | child.fill(input: input, errors: errors) 9 | } 10 | 11 | super(input: input, errors: errors, children: children) 12 | end 13 | 14 | # Converts the compound field into an abstract syntax tree. 15 | # 16 | # It takes the following format: 17 | # 18 | # ``` 19 | # [:compound_field, [params]] 20 | # ``` 21 | # 22 | # With the following parameters: 23 | # 24 | # 1. Custom element type (or `:compound_field` otherwise) 25 | # 2. Form element attributes 26 | # 3. Child form elements 27 | # 28 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 29 | # 30 | # @example 31 | # compound_field.to_ast 32 | # # => [:compound_field, [ 33 | # :content, 34 | # :compound_field, 35 | # [:object, []], 36 | # [...child elements...], 37 | # ]] 38 | # 39 | # @return [Array] the compound field as an abstract syntax tree. 40 | def to_ast 41 | [:compound_field, [ 42 | type, 43 | Element::Attributes.new(attributes).to_ast, 44 | children.map(&:to_ast), 45 | ]] 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/formalist/elements/field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class Field < Element 6 | attribute :label 7 | attribute :hint 8 | attribute :placeholder 9 | attribute :inline 10 | attribute :validation 11 | 12 | def fill(input: {}, errors: {}) 13 | super( 14 | input: input[name], 15 | errors: errors[name].to_a, 16 | ) 17 | end 18 | 19 | # Converts the field into an abstract syntax tree. 20 | # 21 | # It takes the following format: 22 | # 23 | # ``` 24 | # [:field, [params]] 25 | # ``` 26 | # 27 | # With the following parameters: 28 | # 29 | # 1. Field name 30 | # 2. Custom form element type (or `:field` otherwise) 31 | # 3. Associated form input data 32 | # 4. Error messages 33 | # 5. Form element attributes 34 | # 35 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 36 | # 37 | # @example "email" field 38 | # field.to_ast 39 | # # => [:field, [ 40 | # :email, 41 | # :field, 42 | # "jane@doe.org", 43 | # [], 44 | # [:object, []], 45 | # ]] 46 | # 47 | # @return [Array] the field as an abstract syntax tree. 48 | def to_ast 49 | # errors looks like this 50 | # {:field_name => [["pages is missing", "another error message"], nil]} 51 | 52 | [:field, [ 53 | name, 54 | type, 55 | input, 56 | errors, 57 | Element::Attributes.new(attributes).to_ast, 58 | ]] 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/formalist/elements/form_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/child_forms/child_form" 2 | 3 | module Formalist 4 | class Elements 5 | class FormField < ChildForms::ChildForm 6 | attribute :hint 7 | 8 | def fill(input: {}, errors: {}) 9 | input = input[name] 10 | errors = errors[name].to_a 11 | 12 | super(input: input, errors: errors) 13 | end 14 | 15 | def to_ast 16 | [:form_field, [ 17 | name, 18 | type, 19 | input, 20 | Element::Attributes.new(attributes).to_ast, 21 | ]] 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/formalist/elements/group.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class Group < Element 6 | attribute :label 7 | 8 | def fill(input: {}, errors: {}) 9 | children = self.children.map { |child| 10 | child.fill(input: input, errors: errors) 11 | } 12 | 13 | super(input: input, errors: errors, children: children) 14 | end 15 | 16 | # Converts the group into an abstract syntax tree. 17 | # 18 | # It takes the following format: 19 | # 20 | # ``` 21 | # [:group, [params]] 22 | # ``` 23 | # 24 | # With the following parameters: 25 | # 26 | # 1. Custom form element type (or `:group` otherwise) 27 | # 2. Form element attributes 28 | # 3. Child form elements 29 | # 30 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 31 | # 32 | # @example 33 | # group.to_ast 34 | # # => [:group, [ 35 | # :group, 36 | # [:object, []], 37 | # [...child elements...] 38 | # ]] 39 | # 40 | # @return [Array] the group as an abstract syntax tree. 41 | def to_ast 42 | [:group, [ 43 | type, 44 | Element::Attributes.new(attributes).to_ast, 45 | children.map(&:to_ast), 46 | ]] 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/formalist/elements/many.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class Many < Element 6 | attribute :action_label 7 | attribute :sortable 8 | attribute :moveable 9 | attribute :label 10 | attribute :max_height 11 | attribute :placeholder 12 | attribute :validation 13 | attribute :allow_create, default: true 14 | attribute :allow_update, default: true 15 | attribute :allow_destroy, default: true 16 | attribute :allow_reorder, default: true 17 | 18 | # @api private 19 | attr_reader :child_template 20 | 21 | # @api private 22 | def self.build(children: [], **args) 23 | child_template = children.dup 24 | super(child_template: child_template, **args) 25 | end 26 | 27 | # @api private 28 | def initialize(child_template:, **args) 29 | @child_template = child_template 30 | super(**args) 31 | end 32 | 33 | # @api private 34 | def fill(input: {}, errors: {}) 35 | input = input.fetch(name) { [] } 36 | errors = errors[name] || {} 37 | 38 | # Errors look like this when they are on the array itself: ["size cannot be greater than 2"] 39 | # Errors look like this when they are on children: {0=>{:summary=>["must be filled"]} 40 | 41 | children = input.each_with_index.map { |child_input, index| 42 | child_errors = errors.is_a?(Hash) ? errors.fetch(index) { {} } : {} 43 | 44 | child_template.map { |child| child.fill(input: child_input, errors: child_errors) } 45 | } 46 | 47 | super( 48 | input: input, 49 | errors: errors, 50 | children: children, 51 | child_template: child_template, 52 | ) 53 | end 54 | 55 | # Converts a collection of "many" repeating elements into an abstract 56 | # syntax tree. 57 | # 58 | # It takes the following format: 59 | # 60 | # ``` 61 | # [:many, [params]] 62 | # ``` 63 | # 64 | # With the following parameters: 65 | # 66 | # 1. Collection name 67 | # 2. Custom form element type (or `:many` otherwise) 68 | # 3. Collection-level error messages 69 | # 4. Form element attributes 70 | # 5. Child element "template" (i.e. the form elements comprising a 71 | # single entry in the collection of "many" elements, without any user 72 | # data associated) 73 | # 6. Child elements, one for each of the entries in the input data (or 74 | # none, if there is no or empty input data) 75 | # 76 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 77 | # 78 | # @example "locations" collection 79 | # many.to_ast 80 | # # => [:many, [ 81 | # :locations, 82 | # :many, 83 | # ["locations size cannot be less than 3"], 84 | # [:object, [ 85 | # [:allow_create, [:value, [true]]], 86 | # [:allow_update, [:value, [true]]], 87 | # [:allow_destroy, [:value, [true]]], 88 | # [:allow_reorder, [:value, [true]]] 89 | # ]], 90 | # [ 91 | # [:field, [:name, :field, nil, [], [], [:object, []]]], 92 | # [:field, [:address, :field, nil, [], [], [:object, []]]] 93 | # ], 94 | # [ 95 | # [ 96 | # [:field, [:name, :field, "Icelab Canberra", [], [], [:object, []]]], 97 | # [:field, [:address, :field, "Canberra, ACT, Australia", [], [], [:object, []]]] 98 | # ], 99 | # [ 100 | # [:field, [:name, :field, "Icelab Melbourne", [], [], [:object, []]]], 101 | # [:field, [:address, :field, "Melbourne, VIC, Australia", [], [], [:object, []]]] 102 | # ] 103 | # ] 104 | # ]] 105 | # 106 | # @return [Array] the collection as an abstract syntax tree. 107 | def to_ast 108 | local_errors = errors.is_a?(Array) ? errors : [] 109 | 110 | [:many, [ 111 | name, 112 | type, 113 | local_errors, 114 | Element::Attributes.new(attributes).to_ast, 115 | child_template.map(&:to_ast), 116 | children.map { |el_list| el_list.map(&:to_ast) }, 117 | ]] 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/formalist/elements/many_forms.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/child_forms/builder" 3 | 4 | module Formalist 5 | class Elements 6 | class ManyForms < Element 7 | attribute :action_label 8 | attribute :sortable 9 | attribute :moveable 10 | attribute :label 11 | attribute :max_height 12 | attribute :placeholder 13 | attribute :embeddable_forms 14 | attribute :validation 15 | attribute :allow_create, default: true 16 | attribute :allow_update, default: true 17 | attribute :allow_destroy, default: true 18 | attribute :allow_reorder, default: true 19 | 20 | # FIXME: it would be tidier to have a reader method for each attribute 21 | def attributes 22 | super.merge(embeddable_forms: embeddable_forms_ast) 23 | end 24 | 25 | # @api private 26 | def fill(input: {}, errors: {}) 27 | input = input[name] || [] 28 | errors = errors[name].to_a 29 | 30 | children = child_form_builder.(input) 31 | 32 | super( 33 | input: input, 34 | errors: errors, 35 | children: children, 36 | ) 37 | end 38 | 39 | # Replace the form objects with their AST 40 | def embeddable_forms_ast 41 | @attributes[:embeddable_forms].to_h.map { |key, attrs| 42 | template_attrs = attrs.slice(:label, :preview_image_url) 43 | 44 | [ 45 | key, 46 | attrs.merge( 47 | form: attrs[:form].to_ast, 48 | attributes_template: Element::Attributes.new(template_attrs).to_ast 49 | ) 50 | ] 51 | }.to_h 52 | end 53 | 54 | def child_form_builder 55 | ChildForms::Builder.new(@attributes[:embeddable_forms]) 56 | end 57 | 58 | # Converts a collection of "many" repeating elements into an abstract 59 | # syntax tree. 60 | # 61 | # It takes the following format: 62 | # 63 | # ``` 64 | # [:many_forms, [params]] 65 | # ``` 66 | # 67 | # With the following parameters: 68 | # 69 | # 1. Collection name 70 | # 2. Custom form element type (or `:many_forms` otherwise) 71 | # 3. Collection-level error messages 72 | # 4. Form element attributes 73 | # 6. Child elements, one for each of the entries in the input data (or 74 | # none, if there is no or empty input data) 75 | # 76 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 77 | # 78 | # @example "components" collection 79 | # many_forms.to_ast 80 | # # => [:many_forms, [ 81 | # :components, 82 | # :many_forms, 83 | # ["components size cannot be less than 3"], 84 | # [:object, [ 85 | # [:allow_create, [:value, [true]]], 86 | # [:allow_update, [:value, [true]]], 87 | # [:allow_destroy, [:value, [true]]], 88 | # [:allow_reorder, [:value, [true]]] 89 | # ]], 90 | # [ 91 | # [ 92 | # [:child_form, 93 | # [:image_with_captions, 94 | # :child_form, 95 | # [[:field, [:image_id, :text_field, "", ["must be filled"], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]], 96 | # [:object, []] 97 | # ] 98 | # ]] 99 | # 100 | # @return [Array] the collection as an abstract syntax tree. 101 | def to_ast 102 | local_errors = errors.is_a?(Array) ? errors : [] 103 | 104 | [:many_forms, [ 105 | name, 106 | type, 107 | local_errors, 108 | Element::Attributes.new(attributes).to_ast, 109 | children.map(&:to_ast) 110 | ]] 111 | end 112 | 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/formalist/elements/section.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | 3 | module Formalist 4 | class Elements 5 | class Section < Element 6 | attribute :label 7 | 8 | def fill(input: {}, errors: {}) 9 | super( 10 | input: input, 11 | errors: errors, 12 | children: children.map { |child| child.fill(input: input, errors: errors) }, 13 | ) 14 | end 15 | 16 | # Converts the section into an abstract syntax tree. 17 | # 18 | # It takes the following format: 19 | # 20 | # ``` 21 | # [:section, [params]] 22 | # ``` 23 | # 24 | # With the following parameters: 25 | # 26 | # 1. Section name 27 | # 2. Custom form element type (or `:section` otherwise) 28 | # 3. Form element attributes 29 | # 4. Child form elements 30 | # 31 | # @see Formalist::Element::Attributes#to_ast "Form element attributes" structure 32 | # 33 | # @example "content" section 34 | # section.to_ast 35 | # # => [:section, [ 36 | # :content, 37 | # :section, 38 | # [:object, []], 39 | # [...child elements...] 40 | # ]] 41 | # 42 | # @return [Array] the section as an abstract syntax tree. 43 | def to_ast 44 | [:section, [ 45 | name, 46 | type, 47 | Element::Attributes.new(attributes).to_ast, 48 | children.map(&:to_ast), 49 | ]] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard.rb: -------------------------------------------------------------------------------- 1 | require "formalist/elements/standard/check_box" 2 | require "formalist/elements/standard/date_field" 3 | require "formalist/elements/standard/date_time_field" 4 | require "formalist/elements/standard/hidden_field" 5 | require "formalist/elements/standard/multi_selection_field" 6 | require "formalist/elements/standard/multi_upload_field" 7 | require "formalist/elements/standard/number_field" 8 | require "formalist/elements/standard/radio_buttons" 9 | require "formalist/elements/standard/rich_text_area" 10 | require "formalist/elements/standard/search_selection_field" 11 | require "formalist/elements/standard/search_multi_selection_field" 12 | require "formalist/elements/standard/select_box" 13 | require "formalist/elements/standard/selection_field" 14 | require "formalist/elements/standard/tags_field" 15 | require "formalist/elements/standard/text_area" 16 | require "formalist/elements/standard/text_field" 17 | require "formalist/elements/standard/upload_field" 18 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/check_box.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class CheckBox < Field 7 | attribute :question_text 8 | 9 | def initialize(**) 10 | super 11 | 12 | # Ensure value is a boolean (also: default to false for nil values) 13 | @input = !!@input 14 | end 15 | end 16 | 17 | register :check_box, CheckBox 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/date_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class DateField < Field 7 | end 8 | 9 | register :date_field, DateField 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/date_time_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class DateTimeField < Field 7 | attribute :time_format 8 | attribute :human_time_format 9 | end 10 | 11 | register :date_time_field, DateTimeField 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/hidden_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class HiddenField < Field 7 | end 8 | 9 | register :hidden_field, HiddenField 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/multi_selection_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class MultiSelectionField < Field 7 | attribute :sortable 8 | attribute :max_height 9 | attribute :options 10 | attribute :render_option_as 11 | attribute :render_selection_as 12 | attribute :selector_label 13 | end 14 | 15 | register :multi_selection_field, MultiSelectionField 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/multi_upload_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class MultiUploadField < Field 7 | attribute :initial_attributes_url 8 | attribute :sortable 9 | attribute :max_file_size_message 10 | attribute :max_file_size 11 | attribute :max_height 12 | attribute :permitted_file_type_message 13 | attribute :permitted_file_type_regex 14 | attribute :presign_options 15 | attribute :presign_url 16 | attribute :render_uploaded_as 17 | attribute :upload_action_label 18 | attribute :upload_prompt 19 | end 20 | 21 | register :multi_upload_field, MultiUploadField 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/number_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class NumberField < Field 7 | attribute :step 8 | attribute :min 9 | attribute :max 10 | end 11 | 12 | register :number_field, NumberField 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/radio_buttons.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class RadioButtons < Field 7 | attribute :options 8 | end 9 | 10 | register :radio_buttons, RadioButtons 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/rich_text_area.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | require "formalist/rich_text/embedded_form_compiler" 4 | 5 | module Formalist 6 | class Elements 7 | class RichTextArea < Field 8 | attribute :box_size, default: "normal" 9 | attribute :inline_formatters 10 | attribute :block_formatters 11 | attribute :embeddable_forms 12 | 13 | # FIXME: it would be tidier to have a reader method for each attribute 14 | def attributes 15 | super.merge(embeddable_forms: embeddable_forms_config) 16 | end 17 | 18 | def input 19 | input_compiler.(@input) 20 | end 21 | 22 | private 23 | 24 | # Replace the form objects with their AST 25 | def embeddable_forms_config 26 | @attributes[:embeddable_forms].to_h.map { |key, attrs| 27 | [key, attrs.merge(form: attrs[:form].to_ast)] 28 | }.to_h 29 | end 30 | 31 | # TODO: make compiler configurable somehow? 32 | def input_compiler 33 | RichText::EmbeddedFormCompiler.new(@attributes[:embeddable_forms]) 34 | end 35 | end 36 | 37 | register :rich_text_area, RichTextArea 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/search_multi_selection_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class SearchMultiSelectionField < Field 7 | attribute :clear_query_on_selection 8 | attribute :sortable 9 | attribute :max_height 10 | attribute :render_option_as 11 | attribute :render_option_control_as 12 | attribute :render_selection_as 13 | attribute :search_params 14 | attribute :search_per_page 15 | attribute :search_threshold 16 | attribute :search_url 17 | attribute :selections 18 | attribute :selector_label 19 | end 20 | 21 | register :search_multi_selection_field, SearchMultiSelectionField 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/search_selection_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class SearchSelectionField < Field 7 | attribute :selector_label 8 | attribute :render_option_as 9 | attribute :render_option_control_as 10 | attribute :render_selection_as 11 | attribute :search_url 12 | attribute :search_per_page 13 | attribute :search_params 14 | attribute :search_threshold 15 | attribute :selection 16 | end 17 | 18 | register :search_selection_field, SearchSelectionField 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/select_box.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class SelectBox < Field 7 | attribute :options 8 | end 9 | 10 | register :select_box, SelectBox 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/selection_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class SelectionField < Field 7 | attribute :options 8 | attribute :selector_label 9 | attribute :render_option_as 10 | attribute :render_selection_as 11 | end 12 | 13 | register :selection_field, SelectionField 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/tags_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class TagsField < Field 7 | attribute :search_url 8 | attribute :search_per_page 9 | attribute :search_params 10 | attribute :search_threshold 11 | end 12 | 13 | register :tags_field, TagsField 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/text_area.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class TextArea < Field 7 | attribute :text_size, default: "normal" 8 | attribute :box_size, default: "normal" 9 | attribute :code 10 | end 11 | 12 | register :text_area, TextArea 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/text_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class TextField < Field 7 | attribute :password 8 | attribute :code 9 | attribute :disabled 10 | end 11 | 12 | register :text_field, TextField 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/formalist/elements/standard/upload_field.rb: -------------------------------------------------------------------------------- 1 | require "formalist/element" 2 | require "formalist/elements" 3 | 4 | module Formalist 5 | class Elements 6 | class UploadField < Field 7 | attribute :initial_attributes_url 8 | attribute :presign_url 9 | attribute :presign_options 10 | attribute :render_uploaded_as 11 | attribute :upload_prompt 12 | attribute :upload_action_label 13 | attribute :max_file_size 14 | attribute :max_file_size_message 15 | attribute :permitted_file_type_message 16 | attribute :permitted_file_type_regex 17 | end 18 | 19 | register :upload_field, UploadField 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/formalist/form.rb: -------------------------------------------------------------------------------- 1 | require "dry/configurable" 2 | require "dry/core/constants" 3 | require "formalist/elements" 4 | require "formalist/definition" 5 | 6 | module Formalist 7 | class Form 8 | extend Dry::Configurable 9 | include Dry::Core::Constants 10 | 11 | setting :elements_container, default: Elements 12 | 13 | class << self 14 | attr_reader :definition 15 | 16 | def define(&block) 17 | @definition = block 18 | end 19 | end 20 | 21 | attr_reader :elements 22 | attr_reader :input 23 | attr_reader :errors 24 | attr_reader :dependencies 25 | 26 | def initialize(elements: Undefined, input: {}, errors: {}, **dependencies) 27 | @input = input 28 | @errors = errors 29 | 30 | @elements = 31 | if elements == Undefined 32 | Definition.new(self, self.class.config, &self.class.definition).elements 33 | else 34 | elements 35 | end 36 | 37 | @dependencies = dependencies 38 | end 39 | 40 | def fill(input: {}, errors: {}) 41 | return self if input == @input && errors == @errors 42 | 43 | self.class.new( 44 | elements: @elements.map { |element| element.fill(input: input, errors: errors) }, 45 | input: input, 46 | errors: errors, 47 | **@dependencies, 48 | ) 49 | end 50 | 51 | def with(**new_dependencies) 52 | self.class.new( 53 | elements: @elements, 54 | input: @input, 55 | errors: @errors, 56 | **@dependencies.merge(new_dependencies) 57 | ) 58 | end 59 | 60 | def to_ast 61 | elements.map(&:to_ast) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/formalist/form/validity_check.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | class Form 3 | class ValidityCheck 4 | def call(form_ast) 5 | form_ast.map { |node| visit(node) }.all? 6 | end 7 | alias_method :[], :call 8 | 9 | private 10 | 11 | def visit(node) 12 | name, nodes = node 13 | 14 | send(:"visit_#{name}", nodes) 15 | end 16 | 17 | def visit_attr(node) 18 | _name, _type, errors, _attributes, children = node 19 | 20 | errors.empty? && children.map { |child| visit(child) }.all? 21 | end 22 | 23 | def visit_compound_field(node) 24 | _type, _attributes, children = node 25 | 26 | children.map { |child| visit(child) }.all? 27 | end 28 | 29 | def visit_field(node) 30 | _name, _type, _input, errors, _attributes = node 31 | 32 | errors.empty? 33 | end 34 | 35 | def visit_group(node) 36 | _type, _attributes, children = node 37 | 38 | children.map { |child| visit(child) }.all? 39 | end 40 | 41 | def visit_many(node) 42 | _name, _type, errors, _attributes, _child_template, children = node 43 | 44 | # The `children parameter for `many` elements is nested since there are 45 | # many groups of elements, we need to flatten to traverse them all 46 | errors.empty? && children.flatten(1).map { |child| visit(child) }.all? 47 | end 48 | 49 | # TODO work out what to do with this. 50 | # I think it's only relevant to many_forms 51 | # nested in rich text ast 52 | def visit_many_forms(node) 53 | _name, _type, errors, _attributes, children = node 54 | 55 | errors.empty? && children.map { |child| visit(child[:form]) }.all? 56 | end 57 | 58 | def visit_section(node) 59 | _name, _type, _attributes, children = node 60 | 61 | children.map { |child| visit(child) }.all? 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/embedded_form_compiler.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module Formalist 4 | module RichText 5 | 6 | # Our input data looks like this example, which consists of 3 elements: 7 | # 8 | # 1. A text line 9 | # 2. embedded form data 10 | # 3. Another text line 11 | # 12 | # [ 13 | # ["block",["unstyled","b14hd",[["inline",[[],"Before!"]]]]], 14 | # ["block",["atomic","48b4f",[["entity",["formalist","1","IMMUTABLE",{"name":"image_with_caption","label":"Image with caption","data":{"image_id":"5678","caption":"Large panda"}},[["inline",[[],"¶"]]]]]]]], 15 | # ["block",["unstyled","aivqi",[["inline",[[],"After!"]]]]] 16 | # ] 17 | # 18 | # We want to intercept the embededed form data and transform them into full 19 | # form ASTs, complete with validation messages. 20 | 21 | class EmbeddedFormCompiler 22 | attr_reader :embedded_forms 23 | 24 | def initialize(embedded_form_collection) 25 | @embedded_forms = embedded_form_collection 26 | end 27 | 28 | def call(ast) 29 | return ast if ast.nil? 30 | 31 | ast = ast.is_a?(String) ? JSON.parse(ast) : ast 32 | 33 | ast.map { |node| visit(node) } 34 | end 35 | alias_method :[], :call 36 | 37 | private 38 | 39 | def visit(node) 40 | name, nodes = node 41 | 42 | handler = :"visit_#{name}" 43 | 44 | if respond_to?(handler, true) 45 | send(handler, nodes) 46 | else 47 | [name, nodes] 48 | end 49 | end 50 | 51 | # We need to visit blocks in order to get to the formalist entities nested within them 52 | def visit_block(node) 53 | type, id, children = node 54 | 55 | ["block", [type, id, children.map { |child| visit(child) }]] 56 | end 57 | 58 | def visit_entity(node) 59 | type, key, mutability, entity_data, children = node 60 | 61 | return ["entity", node] unless type == "formalist" 62 | 63 | embedded_form = embedded_forms[entity_data["name"]] 64 | 65 | compiled_entity_data = entity_data.merge( 66 | "label" => embedded_form.label, 67 | "form" => prepare_form_ast(embedded_form, entity_data["data"]) 68 | ) 69 | 70 | ["entity", [type, key, mutability, compiled_entity_data, children]] 71 | end 72 | 73 | def prepare_form_ast(embedded_form, data) 74 | # Run the raw data through the validation schema 75 | validation = embedded_form.schema.(data) 76 | 77 | # And then through the embedded form's input processor (which may add 78 | # extra system-generated information necessary for the form to render 79 | # fully) 80 | input = embedded_form.input_processor.(validation.to_h) 81 | 82 | embedded_form.form.fill(input: input, errors: validation.errors.to_h).to_ast 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/embedded_forms_container.rb: -------------------------------------------------------------------------------- 1 | require "formalist/rich_text/embedded_forms_container/mixin" 2 | 3 | module Formalist 4 | module RichText 5 | class EmbeddedFormsContainer 6 | include Mixin 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/embedded_forms_container/mixin.rb: -------------------------------------------------------------------------------- 1 | require "dry-container" 2 | require "formalist/rich_text/embedded_forms_container/registration" 3 | 4 | module Formalist 5 | module RichText 6 | class EmbeddedFormsContainer 7 | module Mixin 8 | def self.included(base) 9 | base.class_eval do 10 | include ::Dry::Container::Mixin 11 | include Methods 12 | end 13 | end 14 | 15 | def self.extended(base) 16 | base.class_eval do 17 | extend ::Dry::Container::Mixin 18 | extend Methods 19 | end 20 | end 21 | 22 | module Methods 23 | def resolve(key) 24 | super(key.to_s) 25 | end 26 | 27 | def register(key, **attrs) 28 | super(key.to_s, Registration.new(**attrs)) 29 | end 30 | 31 | def to_h 32 | keys.each_with_object({}) { |key, output| 33 | output[key] = self[key].to_h 34 | } 35 | end 36 | 37 | # TODO: methods to return filtered sets of registrations 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/embedded_forms_container/registration.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | module RichText 3 | class EmbeddedFormsContainer 4 | class Registration 5 | DEFAULT_INPUT_PROCESSOR = -> input { input }.freeze 6 | 7 | attr_reader :label 8 | attr_reader :form 9 | attr_reader :schema 10 | attr_reader :input_processor 11 | attr_reader :preview_image_url 12 | 13 | def initialize(label:, form:, schema:, preview_image_url: nil, input_processor: DEFAULT_INPUT_PROCESSOR) 14 | @label = label 15 | @form = form 16 | @schema = schema 17 | @input_processor = input_processor 18 | @preview_image_url = preview_image_url 19 | end 20 | 21 | def to_h 22 | { 23 | label: label, 24 | form: form, 25 | schema: schema, 26 | input_processor: input_processor, 27 | preview_image_url: preview_image_url 28 | } 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/rendering/embedded_form_renderer.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | module RichText 3 | module Rendering 4 | class EmbeddedFormRenderer 5 | attr_reader :container 6 | attr_reader :namespace 7 | attr_reader :paths 8 | attr_reader :options 9 | 10 | def initialize(container = {}, namespace: nil, paths: [], **options) 11 | @container = container 12 | @namespace = namespace 13 | @paths = paths 14 | @options = options 15 | end 16 | 17 | def call(form_data) 18 | type, data = form_data.values_at(:name, :data) 19 | 20 | key = resolve_key(type) 21 | 22 | if key 23 | container[key].(data, **options) 24 | else 25 | "" 26 | end 27 | end 28 | 29 | def with(**context_options) 30 | self.class.new( 31 | container, 32 | namespace: namespace, 33 | paths: paths, 34 | **options.merge(context_options) 35 | ) 36 | end 37 | 38 | private 39 | 40 | def resolve_key(type) 41 | paths.each do |path| 42 | path_key = path.tr("/", ".") 43 | key = [namespace, path_key, type].compact.join(".") 44 | return key if container.key?(key) 45 | end 46 | 47 | key = [namespace, type].compact.join(".") 48 | return key if container.key?(key) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/rendering/html_compiler.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | module RichText 3 | module Rendering 4 | class HTMLCompiler 5 | EMBEDDED_FORM_TYPE = "formalist".freeze 6 | LIST_ITEM_TYPES = %w[unordered-list-item ordered-list-item].freeze 7 | 8 | attr_reader :html_renderer 9 | attr_reader :embedded_form_renderer 10 | 11 | def initialize(html_renderer:, embedded_form_renderer:) 12 | @html_renderer = html_renderer 13 | @embedded_form_renderer = embedded_form_renderer 14 | end 15 | 16 | def call(ast) 17 | html_renderer.nodes(wrap_lists(ast)) do |node| 18 | visit(node) 19 | end 20 | end 21 | 22 | private 23 | 24 | def visit(node) 25 | type, content = node 26 | 27 | send(:"visit_#{type}", content) 28 | end 29 | 30 | def visit_block(data) 31 | type, key, children = data 32 | 33 | html_renderer.block(type, key, wrap_lists(children)) do |child| 34 | visit(child) 35 | end 36 | end 37 | 38 | def visit_wrapper(data) 39 | type, children = data 40 | 41 | html_renderer.wrapper(type, children) do |child| 42 | visit(child) 43 | end 44 | end 45 | 46 | def visit_inline(data) 47 | styles, text = data 48 | 49 | html_renderer.inline(styles, text) 50 | end 51 | 52 | def visit_entity(data) 53 | type, key, _mutability, data, children = data 54 | 55 | # FIXME 56 | # Temporary fix to handle data that comes through with keys as 57 | # strings instead of symbols 58 | data = data.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} 59 | 60 | if type == EMBEDDED_FORM_TYPE 61 | embedded_form_renderer.(data) 62 | else 63 | html_renderer.entity(type, key, data, wrap_lists(children)) do |child| 64 | visit(child) 65 | end 66 | end 67 | end 68 | 69 | def wrap_lists(nodes) 70 | chunked = nodes.chunk do |node| 71 | type, content = node 72 | 73 | if type == "block" 74 | content[0] # return the block's own type 75 | else 76 | type 77 | end 78 | end 79 | 80 | chunked.inject([]) { |output, (type, chunk)| 81 | if list_item?(type) 82 | output << convert_to_wrapper_node(type, chunk) 83 | else 84 | # Flatten again by appending chunk onto array 85 | output + chunk 86 | end 87 | } 88 | end 89 | 90 | def convert_to_wrapper_node(type, children) 91 | ["wrapper", [type, children]] 92 | end 93 | 94 | def list_item?(type) 95 | LIST_ITEM_TYPES.include?(type) 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/rendering/html_renderer.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | module RichText 3 | module Rendering 4 | class HTMLRenderer 5 | # A DraftJS HTML renderer must have the following rendering methods implemented: 6 | # 1. inline 7 | # 2. block 8 | # 3. entity 9 | # 4. wrapper 10 | # 5. nodes 11 | 12 | # block and entity must iterate over the children and yield each of the children back to the compiler 13 | 14 | BLOCK_ELEMENTS_MAP = { 15 | "header-one" => "h1", 16 | "header-two" => "h2", 17 | "header-three" => "h3", 18 | "header-four" => "h4", 19 | "header-five" => "h5", 20 | "header-six" => "h6", 21 | "unordered-list-item" => "li", 22 | "ordered-list-item" => "li", 23 | "blockquote" => "blockquote", 24 | "pullquote" => "aside", 25 | "code-block" => "pre", 26 | "horizontal-rule" => "hr", 27 | }.freeze 28 | 29 | DEFAULT_BLOCK_ELEMENT = "p".freeze 30 | 31 | INLINE_ELEMENTS_MAP = { 32 | "bold" => "strong", 33 | "italic" => "em", 34 | "strikethrough" => "del", 35 | "code" => "code", 36 | "underline" => "u", 37 | } 38 | 39 | DEFAULT_INLINE_ELEMENT = "span".freeze 40 | 41 | def initialize(options = {}) 42 | @options = options 43 | end 44 | 45 | # Defines how to handle a list of nodes 46 | def nodes(nodes) 47 | nodes = nodes.map { |node| yield(node) } if block_given? 48 | nodes.join 49 | end 50 | 51 | # Defines how to handle a block node 52 | def block(type, key, children) 53 | rendered_children = children.map { |child| yield(child) } 54 | 55 | if type == 'atomic' 56 | block_atomic(key, rendered_children) 57 | else 58 | render_block_element(type, rendered_children) 59 | end 60 | end 61 | 62 | # Defines how to handle a list of blocks with a list type 63 | def wrapper(type, children) 64 | type_for_method = type.gsub("-", "_") 65 | 66 | rendered_children = children.map { |child| yield(child) } 67 | 68 | send(:"wrapper_#{type_for_method}", rendered_children) 69 | end 70 | 71 | def inline(styles, content) 72 | return content if styles.nil? || styles.empty? 73 | out = content 74 | styles.each do |style| 75 | out = render_inline_element(style, out) 76 | end 77 | out 78 | end 79 | 80 | def entity(type, key, data, children) 81 | rendered_children = children.map { |child| yield(child) } 82 | 83 | handler = :"entity_#{type.downcase}" 84 | if respond_to?(handler, _include_private=true) 85 | send(handler, data, rendered_children) 86 | else 87 | rendered_children 88 | end 89 | end 90 | 91 | private 92 | 93 | def block_atomic(key, children) 94 | children.join 95 | end 96 | 97 | def wrapper_unordered_list_item(children) 98 | html_tag(:ul) do 99 | children.join 100 | end 101 | end 102 | 103 | def wrapper_ordered_list_item(children) 104 | html_tag(:ol) do 105 | children.join 106 | end 107 | end 108 | 109 | def entity_link(data, children) 110 | link_attrs = { 111 | href: data[:url] 112 | } 113 | link_attrs = link_attrs.merge( 114 | target: "_blank", 115 | rel: "noopener" 116 | ) if data[:newWindow] 117 | html_tag(:a, link_attrs) do 118 | children.join 119 | end 120 | end 121 | 122 | def entity_image(data, children) 123 | html_tag(:img, src: data[:src]) 124 | end 125 | 126 | def entity_video(data, children) 127 | html_tag(:video, src: data[:src]) 128 | end 129 | 130 | def entity_default(attrs, children) 131 | html_tag(:div, attrs) do 132 | children.join 133 | end 134 | end 135 | 136 | def render_block_element(type, content) 137 | elem = BLOCK_ELEMENTS_MAP.fetch(type.downcase, DEFAULT_BLOCK_ELEMENT) 138 | 139 | html_tag(elem) do 140 | if content.is_a?(Array) 141 | content.join 142 | else 143 | content 144 | end 145 | end 146 | end 147 | 148 | def render_inline_element(type, content) 149 | elem = INLINE_ELEMENTS_MAP.fetch(type.downcase, DEFAULT_INLINE_ELEMENT) 150 | 151 | html_tag(elem, class: "inline--#{type.downcase}") do 152 | if content.is_a?(Array) 153 | content.join 154 | else 155 | content 156 | end 157 | end 158 | end 159 | 160 | def html_tag(tag, options = {}) 161 | options_string = html_options_string(options) 162 | out = "<#{tag} #{options_string}".strip 163 | 164 | content = block_given? ? yield : "" 165 | 166 | if content.nil? || content.empty? 167 | out << "/>" 168 | else 169 | out << ">#{replace_soft_newlines(content)}" 170 | end 171 | end 172 | 173 | def html_options_string(options) 174 | opts = options.map do |key, val| 175 | "#{key}='#{val}'" 176 | end 177 | opts.join(" ") 178 | end 179 | 180 | def replace_soft_newlines(content) 181 | content.gsub(/\n/, '
') 182 | end 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/formalist/rich_text/validity_check.rb: -------------------------------------------------------------------------------- 1 | require "formalist/form/validity_check" 2 | 3 | module Formalist 4 | module RichText 5 | class ValidityCheck 6 | AST = Struct.new(:ast) 7 | 8 | def call(ast) 9 | forms = ast.map { |node| visit(node) }.flatten 10 | 11 | form_validity_check = Form::ValidityCheck.new 12 | forms.all? { |form_ast| form_validity_check.(form_ast.ast) } 13 | end 14 | alias_method :[], :call 15 | 16 | private 17 | 18 | def visit(node) 19 | name, nodes = node 20 | 21 | handler = :"visit_#{name}" 22 | 23 | if respond_to?(handler, true) 24 | send(handler, nodes) 25 | else 26 | [] 27 | end 28 | end 29 | 30 | # We need to visit blocks in order to get to the formalist entities nested within them 31 | def visit_block(node) 32 | type, id, children = node 33 | 34 | children.map { |child| visit(child) } 35 | end 36 | 37 | def visit_entity(node) 38 | type, key, mutability, entity_data, children = node 39 | 40 | if type == "formalist" 41 | [AST.new(entity_data["form"])] 42 | else 43 | [] 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/formalist/version.rb: -------------------------------------------------------------------------------- 1 | module Formalist 2 | VERSION = "0.9.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/integration/dependency_injection_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry-auto_inject" 2 | require "formalist/elements/standard" 3 | 4 | RSpec.describe "Dependency injection" do 5 | let(:schema) { 6 | Dry::Validation.Schema do 7 | key(:status).required 8 | end 9 | } 10 | 11 | subject(:form) { 12 | Class.new(Formalist::Form) do 13 | define do 14 | select_box :status, options: status_options 15 | end 16 | 17 | attr_reader :status_repo 18 | 19 | def initialize(status_repo:, **args) 20 | @status_repo = status_repo 21 | super(**args) 22 | end 23 | 24 | def status_options 25 | status_repo.statuses.map { |status| [status, status.capitalize] } 26 | end 27 | end.new(status_repo: status_repo) 28 | } 29 | 30 | let(:status_repo) { 31 | Class.new do 32 | def statuses 33 | %w[draft published] 34 | end 35 | end.new 36 | } 37 | 38 | it "supports dependency injection via the initializer's options hash" do 39 | expect(form.to_ast).to eql [ 40 | [:field, [ 41 | :status, 42 | :select_box, 43 | nil, 44 | [], 45 | [:object, [ 46 | [:options, [:array, [ 47 | [:array, [[:value, ["draft"]], [:value, ["Draft"]]]], 48 | [:array, [[:value, ["published"]], [:value, ["Published"]]]] 49 | ]]] 50 | ]] 51 | ]] 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/integration/form_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry/validation/contract" 2 | 3 | RSpec.describe Formalist::Form do 4 | let(:contract) { 5 | Class.new(Dry::Validation::Contract) do 6 | schema do 7 | required(:title).filled(:string) 8 | required(:rating).filled(:integer) 9 | 10 | required(:reviews).array(:hash) do 11 | required(:summary).filled(:string) 12 | required(:rating).filled(:integer, gteq?: 1, lteq?: 10) 13 | end 14 | 15 | required(:meta).hash do 16 | required(:pages).filled(:integer, gteq?: 1) 17 | end 18 | end 19 | end.new 20 | } 21 | 22 | subject(:form_class) { 23 | Class.new(Formalist::Form) do 24 | define do 25 | compound_field do 26 | field :title, validate: {filled: true} 27 | field :rating, validate: {filled: true} 28 | end 29 | 30 | many :reviews do 31 | field :summary, validate: {filled: true} 32 | field :rating, validate: {filled: true} 33 | end 34 | 35 | attr :meta do 36 | field :pages, validate: {filled: true} 37 | end 38 | end 39 | end 40 | } 41 | 42 | let(:input) { 43 | { 44 | title: "Aurora", 45 | rating: "10", 46 | reviews: [ 47 | { 48 | summary: "", 49 | rating: 10 50 | }, 51 | { 52 | summary: "Great!", 53 | rating: 0 54 | } 55 | ], 56 | meta: { 57 | pages: 0 58 | } 59 | } 60 | } 61 | 62 | it "outputs an AST" do 63 | form = form_class.new.fill(input: input, errors: contract.(input).errors) 64 | 65 | expect(form.to_ast).to eq [ 66 | [:compound_field, [ 67 | :compound_field, 68 | [:object, []], 69 | [ 70 | [:field, [:title, :field, "Aurora", [], [:object, []]]], 71 | [:field, [:rating, :field, "10", ["must be an integer"], [:object, []]]] 72 | ] 73 | ]], 74 | [:many, [ 75 | :reviews, 76 | :many, 77 | [], 78 | [:object, [ 79 | [:allow_create, [:value, [true]]], 80 | [:allow_update, [:value, [true]]], 81 | [:allow_destroy, [:value, [true]]], 82 | [:allow_reorder, [:value, [true]]] 83 | ]], 84 | [ 85 | [:field, [:summary, :field, nil, [], [:object, []]]], 86 | [:field, [:rating, :field, nil, [], [:object, []]]] 87 | ], 88 | [ 89 | [ 90 | [:field, [:summary, :field, "", ["must be filled"], [:object, []]]], 91 | [:field, [:rating, :field, 10, [], [:object, []]]] 92 | ], 93 | [ 94 | [:field, [:summary, :field, "Great!", [], [:object, []]]], 95 | [:field, [:rating, :field, 0, ["must be greater than or equal to 1"], [:object, []]]] 96 | ] 97 | ] 98 | ]], 99 | [:attr, [ 100 | :meta, 101 | :attr, 102 | [], 103 | [:object, []], 104 | [ 105 | [:field, [:pages, :field, 0, ["must be greater than or equal to 1"], [:object, []]]] 106 | ] 107 | ]] 108 | ] 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if RUBY_ENGINE == "ruby" 2 | require "simplecov" 3 | SimpleCov.start do 4 | add_filter "/spec/" 5 | end 6 | end 7 | 8 | begin 9 | require "byebug" 10 | rescue LoadError; end 11 | 12 | require "formalist" 13 | require "dry-validation" 14 | 15 | # Requires supporting ruby files with custom matchers and macros, etc, in 16 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 17 | # run as spec files by default. This means that files in spec/support that end 18 | # in _spec.rb will both be required and run as specs, causing the specs to be 19 | # run twice. It is recommended that you do not name files matching this glob to 20 | # end with _spec.rb. You can configure this pattern with the --pattern 21 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 22 | # 23 | # The following line is provided for convenience purposes. It has the downside 24 | # of increasing the boot-up time by auto-requiring all files in the support 25 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 26 | # require only the support files necessary. 27 | Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each do |f| require f end 28 | 29 | # This file was generated by the `rspec --init` command. Conventionally, all 30 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 31 | # The generated `.rspec` file contains `--require spec_helper` which will cause 32 | # this file to always be loaded, without a need to explicitly require it in any 33 | # files. 34 | # 35 | # Given that it is always loaded, you are encouraged to keep this file as 36 | # light-weight as possible. Requiring heavyweight dependencies from this file 37 | # will add to the boot time of your test suite on EVERY test run, even for an 38 | # individual file that may not need all of that loaded. Instead, consider making 39 | # a separate helper file that requires the additional dependencies and performs 40 | # the additional setup, and require it from the spec files that actually need 41 | # it. 42 | # 43 | # The `.rspec` file also contains a few flags that are not defaults but that 44 | # users commonly want. 45 | # 46 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 47 | RSpec.configure do |config| 48 | # rspec-expectations config goes here. You can use an alternate 49 | # assertion/expectation library such as wrong or the stdlib/minitest 50 | # assertions if you prefer. 51 | config.expect_with :rspec do |expectations| 52 | # This option will default to `true` in RSpec 4. It makes the `description` 53 | # and `failure_message` of custom matchers include text for helper methods 54 | # defined using `chain`, e.g.: 55 | # be_bigger_than(2).and_smaller_than(4).description 56 | # # => "be bigger than 2 and smaller than 4" 57 | # ...rather than: 58 | # # => "be bigger than 2" 59 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 60 | end 61 | 62 | # rspec-mocks config goes here. You can use an alternate test double 63 | # library (such as bogus or mocha) by changing the `mock_with` option here. 64 | config.mock_with :rspec do |mocks| 65 | # Prevents you from mocking or stubbing a method that does not exist on 66 | # a real object. This is generally recommended, and will default to 67 | # `true` in RSpec 4. 68 | mocks.verify_partial_doubles = true 69 | end 70 | 71 | # Allows RSpec to persist some state between runs in order to support 72 | # the `--only-failures` and `--next-failure` CLI options. We recommend 73 | # you configure your source control system to ignore this file. 74 | config.example_status_persistence_file_path = "spec/examples.txt" 75 | 76 | # Limits the available syntax to the non-monkey patched syntax that is 77 | # recommended. 78 | config.disable_monkey_patching! 79 | 80 | # This setting enables warnings. It's recommended, but in some cases may 81 | # be too noisy due to issues in dependencies. 82 | # config.warnings = true 83 | config.warnings = false 84 | 85 | # Many RSpec users commonly either run the entire suite or an individual 86 | # file, and it's useful to allow more verbose output when running an 87 | # individual spec file. 88 | if config.files_to_run.one? 89 | # Use the documentation formatter for detailed output, 90 | # unless a formatter has already been configured 91 | # (e.g. via a command-line flag). 92 | config.default_formatter = "doc" 93 | end 94 | 95 | # Run specs in random order to surface order dependencies. If you find an 96 | # order dependency and want to debug it, you can fix the order by providing 97 | # the seed, which is printed after each run. 98 | # --seed 1234 99 | config.order = :random 100 | 101 | # Seed global randomization in this process using the `--seed` CLI option. 102 | # Setting this allows you to use `--seed` to deterministically reproduce 103 | # test failures related to randomization by passing the same `--seed` value 104 | # as the one that triggered the failure. 105 | Kernel.srand config.seed 106 | end 107 | -------------------------------------------------------------------------------- /spec/support/constants.rb: -------------------------------------------------------------------------------- 1 | module Test 2 | def self.remove_constants 3 | constants.each(&method(:remove_const)) 4 | end 5 | end 6 | 7 | RSpec.configure do |config| 8 | config.after do 9 | Test.remove_constants 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/unit/child_forms/builder_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry/validation/contract" 2 | require "formalist/form" 3 | require "formalist/elements/standard" 4 | require "formalist/rich_text/embedded_forms_container" 5 | require "formalist/child_forms/builder" 6 | 7 | RSpec.describe Formalist::ChildForms::Builder do 8 | let(:builder) { described_class.new(embedded_forms) } 9 | 10 | let(:embedded_forms) { 11 | Formalist::RichText::EmbeddedFormsContainer.new.tap do |collection| 12 | collection.register :image_with_caption, label: "Image with caption", form: form, schema: schema 13 | end 14 | } 15 | 16 | let(:form) { 17 | Class.new(Formalist::Form) do 18 | define do 19 | text_field :image_id 20 | text_field :caption 21 | end 22 | end.new 23 | } 24 | 25 | let(:schema) { 26 | Class.new(Dry::Validation::Contract) do 27 | params do 28 | required(:image_id).filled(:integer) 29 | required(:caption).filled(:string) 30 | end 31 | end.new 32 | } 33 | 34 | describe "valid data" do 35 | let(:input) { 36 | [ 37 | {:name => "image_with_caption",:label => "Image with caption",:data => {"image_id" => 5678,"caption" => "Large panda"}}, 38 | ] 39 | } 40 | 41 | let(:output) { 42 | builder.(input) 43 | } 44 | 45 | it "builds a list of form elements with input AST containing form data" do 46 | expect(output.count).to eq 1 47 | expect(output.first).to be_a_kind_of(Formalist::ChildForms::ChildForm) 48 | expect(output.first.input).to eq([[:field, [:image_id, :text_field, 5678, [], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]) 49 | end 50 | end 51 | 52 | describe "invalid data" do 53 | let(:input) { 54 | [ 55 | {:name => "image_with_caption",:label => "Image with caption",:data => {"image_id" => "","caption" => "Large panda"}}, 56 | ] 57 | } 58 | 59 | let(:output) { 60 | builder.(input) 61 | } 62 | 63 | it "builds a list of form elements with input ast containing form data and errors" do 64 | expect(output.count).to eq 1 65 | expect(output.first).to be_a_kind_of(Formalist::ChildForms::ChildForm) 66 | expect(output.first.input).to eq([[:field, [:image_id, :text_field, "", ["must be filled"], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]) 67 | end 68 | end 69 | 70 | describe "missing form definition" do 71 | let(:input) { 72 | [ 73 | {:name => "deprecated_form",:label => "Old Image with caption", :data => {"image_url" => "/assets/large-panda", "caption" => "Large panda"}}, 74 | ] 75 | } 76 | 77 | it "raises a missing form definition error" do 78 | expect { builder.(input).to raise_error(MissingFormDefinitionError) } 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/unit/child_forms/child_form_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "formalist/child_forms/child_form" 3 | require "dry/validation/contract" 4 | 5 | RSpec.describe Formalist::ChildForms::ChildForm do 6 | subject(:child_form) { 7 | Formalist::ChildForms::ChildForm.new( 8 | name: :cta, 9 | attributes: attributes, 10 | ).fill( 11 | input: input, 12 | errors: errors, 13 | ) 14 | } 15 | 16 | let(:form) { 17 | Class.new(Formalist::Form) do 18 | define do 19 | field :image_id 20 | field :caption 21 | end 22 | end.new 23 | } 24 | 25 | let(:schema) { 26 | Class.new(Dry::Validation::Contract) do 27 | params do 28 | required(:image_id).filled(:integer) 29 | required(:caption).filled(:string) 30 | end 31 | end.new 32 | } 33 | 34 | let(:attributes) { {label: "Call to action", form: form, schema: schema} } 35 | let(:errors) { {} } 36 | 37 | describe "input" do 38 | context "is valid form data" do 39 | let(:input) { {image_id: 12, caption: "A cute panda"} } 40 | 41 | it "converts the input data to valid form AST" do 42 | expect(child_form.input).to eql [ 43 | [:field, [:image_id, :field, 12, [], [:object, []]]], 44 | [:field, [:caption, :field, "A cute panda", [], [:object, []]]] 45 | ] 46 | end 47 | end 48 | 49 | context "is invalid form data" do 50 | let(:input) { {image_id: nil, caption: "A cute panda"} } 51 | 52 | it "converts the input data to valid form AST with validation errors" do 53 | expect(child_form.input).to eql [ 54 | [:field, [:image_id, :field, nil, ["must be filled"], [:object, []]]], 55 | [:field, [:caption, :field, "A cute panda", [], [:object, []]]] 56 | ] 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/unit/child_forms/params_processor_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry/validation/contract" 2 | require "formalist/child_forms/params_processor" 3 | 4 | RSpec.describe Formalist::ChildForms::ParamsProcessor do 5 | subject(:processor) { described_class.new(embeddable_forms) } 6 | 7 | let(:embedded_form) { double(:embedded_form) } 8 | 9 | let(:contract) { 10 | Class.new(Dry::Validation::Contract) do 11 | params do 12 | required(:title).filled(:string) 13 | required(:visible).filled(:bool) 14 | end 15 | end.new 16 | } 17 | 18 | let(:embeddable_forms) { 19 | {call_to_action: embedded_form} 20 | } 21 | 22 | before do 23 | allow(embedded_form).to receive(:schema).and_return(contract) 24 | end 25 | 26 | describe "#call" do 27 | subject(:result) { processor.(input) } 28 | 29 | context "valid forms list" do 30 | let(:input) { 31 | [ 32 | { 33 | name: :call_to_action, 34 | label: "Call to action", 35 | data: { 36 | title: "Find out more!", 37 | visible: "true" 38 | } 39 | }, 40 | { 41 | name: :call_to_action, 42 | label: "Call to action", 43 | data: { 44 | title: "Another call!", 45 | visible: "false" 46 | } 47 | } 48 | ] 49 | } 50 | 51 | it { is_expected.to eql([ 52 | { 53 | name: :call_to_action, 54 | label: "Call to action", 55 | data: { 56 | title: "Find out more!", 57 | visible: true 58 | } 59 | }, 60 | { 61 | name: :call_to_action, 62 | label: "Call to action", 63 | data: { 64 | title: "Another call!", 65 | visible: false 66 | } 67 | } 68 | ]) 69 | 70 | } 71 | end 72 | 73 | context "at least one invalid form input" do 74 | let(:input) { 75 | [ 76 | { 77 | name: :call_to_action, 78 | label: "Call to action", 79 | data: { 80 | title: "Find out more!", 81 | visible: true 82 | } 83 | }, 84 | { 85 | name: :call_to_action, 86 | label: "Call to action", 87 | data: { 88 | title: "" 89 | } 90 | }, 91 | ] 92 | } 93 | 94 | it { is_expected.to eq( 95 | [ 96 | { 97 | name: :call_to_action, 98 | label: "Call to action", 99 | data: { 100 | title: "Find out more!", 101 | visible: true 102 | } 103 | }, 104 | { 105 | name: :call_to_action, 106 | label: "Call to action", 107 | data: { 108 | title: "" 109 | } 110 | }, 111 | ] 112 | )} 113 | end 114 | 115 | context "empty input" do 116 | let(:input) { [] } 117 | 118 | it { is_expected.to eq [] } 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /spec/unit/child_forms/validity_check_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry/validation/contract" 2 | require "formalist/child_forms/validity_check" 3 | 4 | RSpec.describe Formalist::ChildForms::ValidityCheck do 5 | subject(:check) { described_class.new(embeddable_forms) } 6 | 7 | let(:embedded_form) { double(:embedded_form) } 8 | 9 | let(:contract) { 10 | Class.new(Dry::Validation::Contract) do 11 | schema do 12 | required(:title).filled(:string) 13 | end 14 | end.new 15 | } 16 | 17 | let(:embeddable_forms) { 18 | {call_to_action: embedded_form} 19 | } 20 | 21 | before do 22 | allow(embedded_form).to receive(:schema).and_return(contract) 23 | end 24 | 25 | describe "#call" do 26 | subject(:result) { check.(input) } 27 | 28 | context "valid forms list" do 29 | let(:input) { 30 | [ 31 | { 32 | name: :call_to_action, 33 | label: "Call to action", 34 | data: { 35 | title: "Find out more!" 36 | } 37 | }, 38 | { 39 | name: :call_to_action, 40 | label: "Call to action", 41 | data: { 42 | title: "Another call!" 43 | } 44 | } 45 | ] 46 | } 47 | 48 | it { is_expected.to be true } 49 | end 50 | 51 | context "at least one invalid form input" do 52 | let(:input) { 53 | [ 54 | { 55 | name: :call_to_action, 56 | label: "Call to action", 57 | data: { 58 | title: "Find out more!" 59 | } 60 | }, 61 | { 62 | name: :call_to_action, 63 | label: "Call to action", 64 | data: { 65 | title: "" 66 | } 67 | }, 68 | ] 69 | } 70 | 71 | it { is_expected.to be false } 72 | end 73 | 74 | context "empty input" do 75 | let(:input) { [] } 76 | 77 | it { is_expected.to be true } 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/unit/elements/many_forms_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "formalist/elements/many_forms" 3 | require "dry/validation/contract" 4 | require "formalist/rich_text/embedded_forms_container" 5 | 6 | 7 | RSpec.describe Formalist::Elements::ManyForms do 8 | subject(:many_forms) { 9 | Formalist::Elements::ManyForms.new( 10 | name: :components, 11 | attributes: attributes, 12 | ).fill( 13 | input: input, 14 | errors: errors, 15 | ) 16 | } 17 | 18 | let(:child_form) { 19 | Class.new(Formalist::Form) do 20 | define do 21 | field :image_id 22 | field :caption 23 | end 24 | end.new 25 | } 26 | 27 | let(:child_schema) { 28 | Class.new(Dry::Validation::Contract) do 29 | params do 30 | required(:image_id).filled(:integer) 31 | required(:caption).filled(:string) 32 | end 33 | end.new 34 | } 35 | 36 | let(:embedded_forms) { 37 | Formalist::RichText::EmbeddedFormsContainer.new.tap do |collection| 38 | collection.register :image_with_caption, label: "Image with caption", form: child_form, schema: child_schema 39 | end 40 | } 41 | 42 | let(:attributes) { {label: "Components", embeddable_forms: embedded_forms} } 43 | let(:errors) { {} } 44 | 45 | describe "input" do 46 | context "is valid form data" do 47 | let(:input) { 48 | { 49 | title: "Aurora", 50 | components: [ 51 | { 52 | name: :image_with_caption, 53 | label: "Image with caption", 54 | data: { 55 | image_id: 1234, 56 | caption: "Cute cat" 57 | } 58 | } 59 | ], 60 | } 61 | } 62 | 63 | it "converts the input data to valid form AST" do 64 | expect(many_forms.children.count).to eql 1 65 | 66 | expect(many_forms.children.first.input).to eql [ 67 | [:field, [:image_id, :field, 1234, [], [:object, []]]], 68 | [:field, [:caption, :field, "Cute cat", [], [:object, []]]] 69 | ] 70 | end 71 | end 72 | 73 | context "is invalid form data" do 74 | let(:input) { 75 | { 76 | title: "Aurora", 77 | components: [ 78 | { 79 | name: :image_with_caption, 80 | label: "Image with caption", 81 | data: { 82 | image_id: nil, 83 | caption: "Cute cat" 84 | } 85 | } 86 | ], 87 | } 88 | } 89 | 90 | it "converts the input data to valid form AST with validation errors" do 91 | expect(many_forms.children.first.input).to eql [ 92 | [:field, [:image_id, :field, nil, ["must be filled"], [:object, []]]], 93 | [:field, [:caption, :field, "Cute cat", [], [:object, []]]] 94 | ] 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/unit/elements/standard/check_box_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "formalist/elements/standard/check_box" 3 | 4 | RSpec.describe Formalist::Elements::CheckBox do 5 | subject(:check_box) { 6 | Formalist::Elements::CheckBox.new( 7 | name: :published, 8 | attributes: attributes, 9 | ).fill( 10 | input: {published: input_value}, 11 | errors: errors, 12 | ) 13 | } 14 | 15 | let(:attributes) { {} } 16 | let(:errors) { {} } 17 | 18 | describe "input" do 19 | context "is nil" do 20 | let(:input_value) { nil } 21 | 22 | specify { expect(check_box.input).to eql false } 23 | end 24 | 25 | context "is false" do 26 | let(:input_value) { false } 27 | 28 | specify { expect(check_box.input).to eql false } 29 | end 30 | 31 | context "is true" do 32 | let(:input_value) { true } 33 | 34 | specify { expect(check_box.input).to eql true } 35 | end 36 | 37 | context "is any other value" do 38 | let(:input_value) { "something" } 39 | 40 | specify { expect(check_box.input).to eql true } 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/unit/form/validity_check_spec.rb: -------------------------------------------------------------------------------- 1 | require "formalist/elements/standard" 2 | require "formalist/form/validity_check" 3 | 4 | RSpec.describe Formalist::Form::ValidityCheck do 5 | subject(:check) { described_class.new } 6 | 7 | subject(:form_ast) { 8 | form = Class.new(Formalist::Form) do 9 | define do 10 | text_field :title 11 | end 12 | end.new.fill(input: input, errors: errors).to_ast 13 | } 14 | 15 | describe "#call" do 16 | subject(:result) { check.(form_ast) } 17 | 18 | context "valid form" do 19 | let(:input) { 20 | {title: "The Martian"} 21 | } 22 | 23 | let(:errors) { {} } 24 | 25 | it { is_expected.to be true } 26 | end 27 | 28 | context "invalid form" do 29 | let(:input) { 30 | {title: "The Martian"} 31 | } 32 | 33 | let(:errors) { 34 | {title: ["Must be Terran"]} 35 | } 36 | 37 | it { is_expected.to be false } 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/unit/rich_text/embedded_form_compiler_spec.rb: -------------------------------------------------------------------------------- 1 | require "dry/schema" 2 | require "formalist/form" 3 | require "formalist/elements/standard" 4 | require "formalist/rich_text/embedded_forms_container" 5 | require "formalist/rich_text/embedded_form_compiler" 6 | 7 | RSpec.describe Formalist::RichText::EmbeddedFormCompiler do 8 | let(:compiler) { described_class.new(embedded_forms) } 9 | 10 | let(:embedded_forms) { 11 | Formalist::RichText::EmbeddedFormsContainer.new.tap do |collection| 12 | collection.register :image_with_caption, label: "Image with caption", form: form, schema: schema 13 | end 14 | } 15 | 16 | let(:form) { 17 | Class.new(Formalist::Form) do 18 | define do 19 | text_field :image_id 20 | text_field :caption 21 | end 22 | end.new 23 | } 24 | 25 | let(:schema) { 26 | Dry::Schema.Params do 27 | required(:image_id).filled(:integer) 28 | required(:caption).filled(:string) 29 | end 30 | } 31 | 32 | describe "valid data" do 33 | let(:input) { 34 | [ 35 | ["block",["unstyled","b14hd",[["inline",[[],"Before!"]]]]], 36 | ["block",["atomic","48b4f",[["entity",["formalist","1","IMMUTABLE",{"name" => "image_with_caption","label" => "Image with caption","data" => {"image_id" => 5678,"caption" => "Large panda"}},[["inline",[[],"¶"]]]]]]]], 37 | ["block",["unstyled","aivqi",[["inline",[[],"After!"]]]]] 38 | ] 39 | } 40 | 41 | let(:output) { 42 | compiler.(input) 43 | } 44 | 45 | it "builds a form AST with the data incorporated" do 46 | expect(output[1]).to eq ["block", ["atomic", "48b4f", [["entity", ["formalist", "1", "IMMUTABLE", {"name" => "image_with_caption", "label" => "Image with caption", "data" => {"image_id" => 5678, "caption" => "Large panda"}, "form" => [[:field, [:image_id, :text_field, 5678, [], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]}, [["inline", [[], "¶"]]]]]]]] 47 | end 48 | 49 | it "leaves the rest of the data unchanged" do 50 | expect(output[0]).to eq input[0] 51 | expect(output[2]).to eq input[2] 52 | expect(output.length).to eq 3 53 | end 54 | end 55 | 56 | describe "invalid data" do 57 | let(:input) { 58 | [ 59 | ["block",["unstyled","b14hd",[["inline",[[],"Before!"]]]]], 60 | ["block",["atomic","48b4f",[["entity",["formalist","1","IMMUTABLE",{"name" => "image_with_caption","label" => "Image with caption","data" => {"image_id" => "","caption" => "Large panda"}},[["inline",[[],"¶"]]]]]]]], 61 | ["block",["unstyled","aivqi",[["inline",[[],"After!"]]]]] 62 | ] 63 | } 64 | 65 | let(:output) { 66 | compiler.(input) 67 | } 68 | 69 | it "builds a form AST with the data and validation messages incorporated" do 70 | expect(output[1]).to eq ["block", ["atomic", "48b4f", [["entity", ["formalist", "1", "IMMUTABLE", {"name" => "image_with_caption", "label" => "Image with caption", "data" => {"image_id" => "", "caption" => "Large panda"}, "form" => [[:field, [:image_id, :text_field, "", ["must be filled"], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]}, [["inline", [[], "¶"]]]]]]]] 71 | end 72 | 73 | it "leaves the rest of the data unchanged" do 74 | expect(output[0]).to eq input[0] 75 | expect(output[2]).to eq input[2] 76 | expect(output.length).to eq 3 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/unit/rich_text/rendering/embedded_form_renderer_spec.rb: -------------------------------------------------------------------------------- 1 | require "formalist/rich_text/rendering/embedded_form_renderer" 2 | 3 | RSpec.describe Formalist::RichText::Rendering::EmbeddedFormRenderer do 4 | subject(:renderer) { described_class.new(container, namespace: namespace, paths: paths, **options) } 5 | 6 | let(:container) {{ 7 | "article" => -> (*_) { "top_level_article" }, 8 | "embedded_forms.article" => -> (*_) { "namespaced_article" }, 9 | "embedded_forms.newsletter.article" => -> (*_) { "newsletter_article" }, 10 | "embedded_forms.newsletter.components.article" => -> (*_) { "newsletter_components_article" }, 11 | "embedded_forms.general.article" => -> (*_) { "general_article" }, 12 | }} 13 | 14 | let(:options) { 15 | { render_context: "general" } 16 | } 17 | let(:namespace) { nil } 18 | let(:paths) { [] } 19 | 20 | describe "#call" do 21 | context "no namespace or paths configured" do 22 | let(:namespace) { nil } 23 | let(:paths) { [] } 24 | 25 | it "returns the top level result" do 26 | expect(renderer.(name: "article")).to eq "top_level_article" 27 | end 28 | end 29 | 30 | 31 | context "namespace configured" do 32 | let(:namespace) { "embedded_forms" } 33 | let(:paths) { [] } 34 | 35 | it "returns the namespaced result" do 36 | expect(renderer.(name: "article")).to eq "namespaced_article" 37 | end 38 | end 39 | 40 | context "namespace and paths configured" do 41 | let(:namespace) { "embedded_forms" } 42 | let(:paths) { ["newsletter/components", "newsletter", "general"] } 43 | 44 | it "returns the result in the first path" do 45 | expect(renderer.(name: "article")).to eq "newsletter_components_article" 46 | end 47 | end 48 | 49 | context "key not in paths" do 50 | let(:namespace) { "embedded_forms" } 51 | let(:paths) { ["other", "path"] } 52 | 53 | it "returns the result in the namespace" do 54 | expect(renderer.(name: "article")).to eq "namespaced_article" 55 | end 56 | end 57 | 58 | context "key not in namespace" do 59 | let(:namespace) { "embedded_forms" } 60 | let(:paths) { ["other", "path"] } 61 | let(:container) { { "article" => "top_level_article" } } 62 | 63 | it "does not return the result outside the namespace" do 64 | expect(renderer.(name: "article")).to eq "" 65 | end 66 | end 67 | end 68 | 69 | describe "#with" do 70 | it "returns an object with options merged" do 71 | expect(renderer.with(context: "new").options).to eq({render_context: "general", context: "new"}) 72 | end 73 | end 74 | end -------------------------------------------------------------------------------- /spec/unit/rich_text/rendering/html_compiler_spec.rb: -------------------------------------------------------------------------------- 1 | require "formalist/rich_text/rendering/html_compiler" 2 | require "formalist/rich_text/rendering/html_renderer" 3 | require "formalist/rich_text/rendering/embedded_form_renderer" 4 | 5 | RSpec.describe Formalist::RichText::Rendering::HTMLCompiler do 6 | let(:ast) { 7 | block1 = ["block", ["header-two", "a34sd", [["inline", [[], "Heading"]]]]] 8 | block2 = ["block", ["unordered-list-item", "dodnk", [["inline", [[], "I am more "]], ["entity", ["LINK", "3", "MUTABLE", {url: "http://makenosound.com"}, [["inline", [[], "content"]]]]], ["inline", [[], "."]]]]] 9 | 10 | [block1, block2, block2, block1] 11 | } 12 | 13 | describe "with default HTML renderer" do 14 | let(:compiler) { 15 | described_class.new( 16 | html_renderer: Formalist::RichText::Rendering::HTMLRenderer.new, 17 | embedded_form_renderer: Formalist::RichText::Rendering::EmbeddedFormRenderer.new, 18 | ) 19 | } 20 | 21 | describe "#call" do 22 | subject(:html) { compiler.(ast) } 23 | 24 | it "compiles to HTML" do 25 | is_expected.to eq <<-HTML.gsub(/^\s+/, "").gsub("\n", "") 26 |

Heading

27 | 31 |

Heading

32 | HTML 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/unit/rich_text/validity_check_spec.rb: -------------------------------------------------------------------------------- 1 | require "formalist/rich_text/validity_check" 2 | 3 | RSpec.describe Formalist::RichText::ValidityCheck do 4 | subject(:check) { described_class.new } 5 | 6 | describe "#call" do 7 | subject(:result) { check.(input) } 8 | 9 | context "embedded form valid" do 10 | let(:input) { 11 | [ 12 | ["block",["unstyled","b14hd",[["inline",[[],"Before!"]]]]], 13 | ["block", ["atomic", "48b4f", [["entity", ["formalist", "1", "IMMUTABLE", {"name"=>"image_with_caption", "label"=>"Image with caption", "data"=>{"image_id"=>"5678", "caption"=>"Large panda"}, "form"=>[[:field, [:image_id, :text_field, 5678, [], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]}, [["inline", [[], "¶"]]]]]]]], 14 | ["block",["unstyled","aivqi",[["inline",[[],"After!"]]]]] 15 | ] 16 | } 17 | 18 | it { is_expected.to be true } 19 | end 20 | 21 | context "embedded form invalid" do 22 | let(:input) { 23 | [ 24 | ["block",["unstyled","b14hd",[["inline",[[],"Before!"]]]]], 25 | ["block", ["atomic", "48b4f", [["entity", ["formalist", "1", "IMMUTABLE", {"name"=>"image_with_caption", "label"=>"Image with caption", "data"=>{"image_id"=>"", "caption"=>"Large panda"}, "form"=>[[:field, [:image_id, :text_field, nil, ["must be filled"], [:object, []]]], [:field, [:caption, :text_field, "Large panda", [], [:object, []]]]]}, [["inline", [[], "¶"]]]]]]]], 26 | ["block",["unstyled","aivqi",[["inline",[[],"After!"]]]]] 27 | ] 28 | } 29 | 30 | it { is_expected.to be false } 31 | end 32 | end 33 | end 34 | --------------------------------------------------------------------------------