Skip to content

Commit

Permalink
Merge pull request #47 from BookingSync/new-form-object
Browse files Browse the repository at this point in the history
Introduce new form object
  • Loading branch information
pyromaniac authored Jul 28, 2024
2 parents 1c1c6ab + c027992 commit ccaeac9
Show file tree
Hide file tree
Showing 19 changed files with 907 additions and 190 deletions.
16 changes: 11 additions & 5 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2024-01-01 09:54:28 UTC using RuboCop version 1.59.0.
# on 2024-05-05 08:51:48 UTC using RuboCop version 1.59.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand All @@ -14,22 +14,28 @@ Gemspec/DevelopmentDependencies:
Exclude:
- 'operations.gemspec'

# Offense count: 4
# Offense count: 5
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
Metrics/AbcSize:
Max: 28

# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ClassLength:
Max: 163
Max: 145

# Offense count: 1
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 111
Max: 142

# Offense count: 1
# Offense count: 2
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Max: 7

# Offense count: 1
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/operations/form_spec.rb'
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Changelog

## [0.6.3](https://github.com/BookingSync/operations/tree/main)
## [0.7.0](https://github.com/BookingSync/operations/tree/main)

### Added

- Implement new forms system detaching it from operations [\#47](https://github.com/BookingSync/operations/pull/47) ([pyromaniac](https://github.com/pyromaniac))

### Improvements

- Better inspect for all the opjects [\#45](https://github.com/BookingSync/operations/pull/45) ([pyromaniac](https://github.com/pyromaniac))
- Use #to_hash instead of #as_json [\#44](https://github.com/BookingSync/operations/pull/44) ([pyromaniac](https://github.com/pyromaniac))

### Fixes

Expand Down
122 changes: 93 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ end

The operation body can be any callable object (respond to the `call` method), even a lambda. But it is always better to define it as a class since there might be additional instance methods and [dependency injections](#dependency-injection).

In any event, the operation body should return a Dry::Monad::Result instance. In case of a Failure, it will be converted into an `Operation::Result#error` and in case of Success(), its content will be merged into the operation context.
In any event, the operation body should return a Dry::Monads::Result instance. In case of a Failure, it will be converted into an `Operation::Result#error` and in case of Success(), its content will be merged into the operation context.

**Important:** since the Success result payload is merged inside of a context, it is supposed to be a hash.

Expand Down Expand Up @@ -305,7 +305,7 @@ end

Dependency injection can be used to provide IO clients with the operation. It could be DB repositories or API clients. The best way is to use Dry::Initializer for it since it provides the ability to define acceptable types.

If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::Monad-compatible interfaces.
If you still prefer to use ActiveRecord, it is worth creating a wrapper around it providing Dry::Monads-compatible interfaces.

```ruby
class ActiveRecordRepository
Expand Down Expand Up @@ -858,31 +858,38 @@ While we normally recommend using frontend-backend separation, it is still possi
```ruby
class PostsController < ApplicationController
def edit
@post_update = Post::Update.default.callable(
{ post_id: params[:id] },
current_user: current_user
)
@post_update_form = Post::Update.default_form.build(params, current_user: current_user)

respond_with @post_update
respond_with @post_update_form
end

def update
# With operations we don't need strong parameters as the operation contract takes care of this.
@post_update = Post::Update.default.call(
{ **params[:post_update_default_form], post_id: params[:id] },
current_user: current_user
)
@post_update_form = Post::Update.default_form.persist(params, current_user: current_user)

respond_with @post_update_form, location: edit_post_url(@post_update_form.operation_result.context[:post])
end
end
```

Where the form class is defined this way:

```ruby
class Post::Update
def self.default
@default ||= Operations::Command.new(...)
end

respond_with @post_update, location: edit_post_url(@post_update.context[:post])
def self.default_form
@default_form ||= Operations::Form.new(default)
end
end
```

The key here is to use `Operations::Result#form` method for the form builder.
Then, the form can be used as any other form object:

```erb
# views/posts/edit.html.erb
<%= form_for @post_update.form, url: post_url(@post_update.context[:post]), method: :patch do |f| %>
<%= form_for @post_update_form, url: post_url(@post_update_form.operation_result.context[:post]) do |f| %>
<%= f.input :title %>
<%= f.text_area :body %>
<% end %>
Expand All @@ -892,16 +899,16 @@ In cases when we need to populate the form data, it is possible to pass `form_hy

```ruby
class Post::Update
def self.default
@default ||= Operations::Command.new(
...,
form_hydrator: Post::Update::Hydrator.new
def self.default_form
@default_form ||= Operations::Form.new(
default,
hydrator: Post::Update::Hydrator.new
)
end
end

class Post::Update::Hydrator
def call(form_class, params, post:, **)
def call(form_class, params, post:, **_context)
value_attributes = form_class.attributes.keys - %i[post_id]
data = value_attributes.index_with { |name| post.public_send(name) }

Expand All @@ -912,23 +919,80 @@ end

The general idea here is to figure out attributes we have in the contract (those attributes are also defined automatically in a generated form class) and then fetch those attributes from the model and merge them with the params provided within the request.

Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with an optional `form_model_map:` operation option:
Also, in the case of, say, [simple_form](https://github.com/heartcombo/simple_form), we need to provide additional attributes information, like data type. It is possible to do this with `model_map:` option:

```ruby
class Post::Update
def self.default
@default ||= Operations::Command.new(
...,
form_hydrator: Post::Update::Hydrator.new,
form_model_map: {
[%r{.+}] => "Post"
}
def self.default_form
@default_form ||= Operations::Form.new(
default,
model_map: Post::Update::ModelMap.new,
hydrator: Post::Update::Hydrator.new
)
end
end

class Post::Update::ModelMap
def call(_path)
Post
end
end
```

Here we define all the fields mapping to a model class with a regexp or just string values.
In forms, params input is already transformed to extract the nested data with the form name. `form_for @post_update_form` will generate the form that send params nested under the `params[:post_update_form]` key. By default operation forms extract this form data and send it to the operation at the top level, so `{ id: 42, post_update_form: { title: "Post Title" } }` params will be sent to the operation as `{ id: 42, title: "Post Title" }`. Strong params are also accepted by the form, though they are being converted with `to_unsafe_hash`.

It is possible to add more params transfomations to the form in cases when operation contract is different from the params structure:

```ruby
class Post::Update
def self.default_form
@default_form ||= Operations::Form.new(
default,
model_name: "post_update_form", # form name can be customized
params_transformations: [
ParamsMap.new(id: :post_id),
NestedAttributes.new(:sections)
]
)
end

contract do
required(:post_id).filled(:integer)
optional(:title).filled(:string)
optional(:sections).array(:hash) do
optional(:id).filled(:integer)
optional(:content).filled(:string)
optional(:_destroy).filled(:bool)
end
end
end

# This will transform `{ id: 42, title: "Post Title" }` params to `{ post_id: 42, title: "Post Title" }`
class ParamsMap
extend Dry::Initializer

param :params_map

def call(_form_class, params, **_context)
params.transform_keys { |key| params_map[key] || key }
end
end

# And this will transform nested attributes hash from the form to an array acceptable by the operation:
# from
# `{ id: 42, sections_attributes: { '0' => { id: 1, content: "First paragraph" }, 'new' => { content: 'New Paragraph' } } }`
# into
# `{ id: 42, sections: [{ id: 1, content: "First paragraph" }, { content: 'New Paragraph' }] }`
class NestedAttributes
extend Dry::Initializer

param :name, Types::Coercible::Symbol

def call(_form_class, params, **_context)
params[name] = params[:"#{name}_attrbutes"].values
end
end
```

## Development

Expand Down
1 change: 1 addition & 0 deletions lib/operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
require "operations/form/base"
require "operations/form/attribute"
require "operations/form/builder"
require "operations/form/deprecated_legacy_model_map_implementation"
require "operations/command"
require "operations/result"

Expand Down
16 changes: 5 additions & 11 deletions lib/operations/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,11 @@
# which contains all the artifacts and the information about the errors
# should they ever happen.
class Operations::Command
UNDEFINED = Object.new.freeze
EMPTY_HASH = {}.freeze
COMPONENTS = %i[contract policies idempotency preconditions operation on_success on_failure].freeze
FORM_HYDRATOR = ->(_form_class, params, **_context) { params }

extend Dry::Initializer
include Dry::Core::Constants
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad, :execute_operation)
include Dry::Equalizer(*COMPONENTS)
Expand Down Expand Up @@ -158,12 +157,7 @@ def sentry_context
option :preconditions, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
option :on_success, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
option :on_failure, Operations::Types::Array.of(Operations::Types.Interface(:call)), default: -> { [] }
option :form_model_map, Operations::Types::Hash.map(
Operations::Types::Coercible::Array.of(
Operations::Types::String | Operations::Types::Symbol | Operations::Types.Instance(Regexp)
),
Operations::Types::String
), default: proc { {} }
option :form_model_map, Operations::Form::DeprecatedLegacyModelMapImplementation::TYPE, default: proc { {} }
option :form_base, Operations::Types::Class, default: proc { ::Operations::Form::Base }
option :form_class, Operations::Types::Class.optional, default: proc {}, reader: false
option :form_hydrator, Operations::Types.Interface(:call), default: proc { FORM_HYDRATOR }
Expand Down Expand Up @@ -196,11 +190,11 @@ def self.build(operation, contract = nil, **deps)
end

def initialize(
operation, policy: UNDEFINED, policies: [UNDEFINED],
operation, policy: Undefined, policies: [Undefined],
precondition: nil, preconditions: [], after: [], **options
)
policies_sum = Array.wrap(policy) + policies
result_policies = policies_sum - [UNDEFINED] unless policies_sum == [UNDEFINED, UNDEFINED]
result_policies = policies_sum - [Undefined] unless policies_sum == [Undefined, Undefined]
options[:policies] = result_policies if result_policies

preconditions.push(precondition) if precondition.present?
Expand Down Expand Up @@ -391,7 +385,7 @@ def build_form_class
.new(base_class: form_base)
.build(
key_map: contract.class.schema.key_map,
model_map: form_model_map,
model_map: Operations::Form::DeprecatedLegacyModelMapImplementation.new(form_model_map),
namespace: operation.class,
class_name: form_class_name
)
Expand Down
2 changes: 1 addition & 1 deletion lib/operations/contract/messages_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ class Operations::Contract::MessagesResolver < Dry::Validation::Messages::Resolv
def call(message:, meta: Dry::Schema::EMPTY_HASH, **rest)
meta = meta.merge(code: message) if message.is_a?(Symbol)

super(message: message, meta: meta, **rest)
super
end
end
74 changes: 73 additions & 1 deletion lib/operations/form.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
# frozen_string_literal: true

# Configures and defines a form object factory.
# Forms can be defined on top of commants and used in the user-facing controllers.
# Form objects are Rails-specific and support everything that is needed for Rails'
# form rendering helpers. They are designed to replace direct usage of ActiveRecord
# models in controllers and views and act as an integration bridge from Rails
# application to the Operations framework.
#
# @example
#
# command = Operations::Command.new(...)
# form = Operations::Form.new(command)
#
# @form_object = form.build(params)
#
# form_for @form_object, url: ...
#
# @form_object = form.persist(params)
# respond_with @form_object
#
class Operations::Form
include Dry::Core::Constants
include Dry::Equalizer(:command, :model_map, :params_transformations, :hydrator, :form_class)
include Operations::Inspect.new(:model_name, :model_map, :params_transformations, :hydrator, :form_class)

# We need to make deprecated inheritance from Operations::Form act exactly the
# same way as from Operations::Form::Base. In order to do this, we are encapsulating all the
# inheritable functionality in 2 modules and removing methods defined in Operations::Form
Expand All @@ -19,6 +41,56 @@ def self.inherited(subclass)
end

subclass.extend Operations::Form::Base::ClassMethods
subclass.include Operations::Form::Base::InstanceMethods
subclass.prepend Operations::Form::Base::InstanceMethods
end

include Dry::Initializer.define(lambda do
param :command, type: Operations::Types.Interface(:operation, :contract, :call)
option :model_name, type: Operations::Types::String.optional, default: proc {}, reader: false
option :model_map, type: Operations::Types.Interface(:call).optional, default: proc {}
option :params_transformations, type: Operations::Types::Coercible::Array.of(Operations::Types.Interface(:call)),
default: proc { [] }
option :hydrator, type: Operations::Types.Interface(:call).optional, default: proc {}
option :base_class, type: Operations::Types::Class, default: proc { ::Operations::Form::Base }
end)

def build(params = EMPTY_HASH, **context)
instantiate_form(command.callable(transform_params(params, **context), **context))
end

def persist(params = EMPTY_HASH, **context)
instantiate_form(command.call(transform_params(params, **context), **context))
end

def form_class
@form_class ||= Operations::Form::Builder.new(base_class: base_class)
.build(key_map: key_map, model_map: model_map, model_name: model_name)
end

private

def transform_params(params, **context)
params = params.to_unsafe_hash if params.respond_to?(:to_unsafe_hash)
params = params.deep_symbolize_keys
params = params.merge(params[form_class.model_name.param_key.to_sym] || {})
params_transformations.inject(params) do |value, transformation|
transformation.call(form_class, value, **context)
end
end

def instantiate_form(operation_result)
form_class.new(
hydrator&.call(form_class, operation_result.params, **operation_result.context) || {},
messages: operation_result.errors.to_h,
operation_result: operation_result
)
end

def key_map
@key_map ||= command.contract.schema.key_map
end

def model_name
@model_name ||= ("#{command.operation.class.name.underscore}_form" if command.operation.class.name)
end
end
Loading

0 comments on commit ccaeac9

Please sign in to comment.