Skip to content

Instantly share code, notes, and snippets.

@danieldraper
Last active September 10, 2020 06:17
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save danieldraper/a0916e7f8a62aeea6a293f79ba1555ce to your computer and use it in GitHub Desktop.
Save danieldraper/a0916e7f8a62aeea6a293f79ba1555ce to your computer and use it in GitHub Desktop.
Service object and form object using ActiveModel and dry-rb
require "dry/monads/result"
class ApplicationForm
include Dry::Monads::Result::Mixin
include ActiveModel::Model
def self.attribute(name, options = {})
self.send(:attr_accessor, name)
_attributes << Attribute.new(name, options)
end
def self._attributes
@_attributes ||= []
end
def initialize(attributes = {})
super
self.class._attributes.each do |attribute|
attribute.apply_default_to(self)
end
end
def attributes
attrs = {}
self.class._attributes.each do |a|
attrs[a.name] = self.send(a.name)
end
attrs
end
def attributes_for_model
attrs = {}
self.class._attributes.select { |attr| attr.options[:virtual] == false }.each do |attr|
attrs[attr.name] = self.send(attr.name)
end
attrs
end
end
require "dry/monads/result"
class ApplicationOperation
include Dry::Monads::Result::Mixin
end
class Attribute
attr_accessor :name, :options
def initialize(name, options = {})
@name = name
@options = options
extract_options
end
def apply_default_to(form)
if form.send(@name).nil?
form.send("#{@name}=", default_value(form)) if @apply_default
end
end
private
def extract_options
@apply_default = true
@default = options.fetch(:default) { @apply_default = false; nil }
@skip_validations = options.fetch(:skip_validations, false)
options[:virtual] ||= false
end
def default_value(context)
if @default.respond_to?(:call)
context.instance_eval(&@default)
else
@default
end
end
end
class Manage::CreateMemberForm < ApplicationForm
include ActiveModel::Validations::Callbacks
attribute :first_name
attribute :last_name
attribute :dob
attribute :reference
attribute :status
attribute :joined_on
attribute :account_id
attribute :email
attribute :mobile
attribute :country_code, virtual: true
phony_normalize :mobile, default_country_code: "AU"
validates :first_name, presence: true
validates :dob, allow_blank: true, date: { before: -> { Date.current } }
validates :joined_on, allow_blank: true, date: { on_or_before: -> { Date.current } }
validates :email, allow_blank: true, email: true
validates :mobile, allow_blank: true, phony_plausible: true
validates :account_id, presence: true
validates :country_code, presence: true
def call
ActiveRecord::Base.transaction do
begin
if valid?
Success(persist!)
else
Failure([:validation, self])
end
rescue => exception
Failure([:exception, exception])
end
end
end
private
def persist!
Member.create(attributes_for_model)
end
end
class Manage::CreateMemberOperation < ApplicationOperation
include Dry::Matcher.for(:call, with: ResultMatcher)
attr_reader :form_class
def initialize(form_class)
@form_class = form_class
end
def call(form_params)
form_class.new(form_params).call do |on|
on.success do |result|
Success(result)
end
on.failure do |form|
Failure([:validation, form])
end
end
end
end
class Manage::MembersController < Manage::ApplicationController
def new
@form = create_form.new
end
def create
create_operation.(create_params) do |on|
on.success do
redirect_to manage_members_path, notice: t(".success")
end
on.failure :validation do |form|
@form = form
render :new
end
end
end
private
def permitted_params(form_key)
params.require(form_key).permit(
:first_name,
:last_name,
:dob,
:reference,
:status,
:joined_on,
:email,
:mobile,
)
end
def create_params
permitted_params(:manage_create_member_form).merge(
account_id: current_account.id,
country_code: current_account.country_code
)
end
def update_params
permitted_params(:manage_update_member_form).merge(
id: params[:id],
country_code: current_account.country_code
)
end
end
<%= simple_form_for(@form, url: manage_members_path, method: :post, html: { autocomplete: "off" }) do |form| %>
<div class="card card__form">
<div class="card-header">Personal Details</div>
<div class="card-body">
<div class="col-lg-6 col-xl-4">
<div class="form-row">
<%= form.input :first_name, label: t("manage.members.forms.labels.first_name"), wrapper_html: { class: "col-md-6" } %>
<%= form.input :last_name, label: t("manage.members.forms.labels.last_name"), wrapper_html: { class: "col-md-6" } %>
</div>
<%= form.input :dob, as: :string, label: t("manage.members.forms.labels.dob"), input_html: { class: "js-date" } %>
</div>
</div>
</div>
<div class="card card__form">
<div class="card-header">Membership Details</div>
<div class="card-body">
<div class="col-lg-6 col-xl-4">
<%= form.input :reference, label: t("manage.members.forms.labels.reference"), help: { title: t(".help.reference.title"), content: t(".help.reference.content") } %>
<%= form.input :status, label: t("manage.members.forms.labels.status") %>
<%= form.input :joined_on, as: :string, label: t("manage.members.forms.labels.joined_on"), input_html: { class: "js-date" } %>
</div>
</div>
</div>
<div class="card card__form">
<div class="card-header">Contact Details</div>
<div class="card-body">
<div class="col-lg-6 col-xl-4">
<%= form.input :email, label: t("manage.members.forms.labels.email") %>
<%= form.input :mobile, label: t("manage.members.forms.labels.mobile") %>
</div>
</div>
<div class="card-footer">
<%= form.button :submit, t("manage.members.forms.labels.submit"), class: "btn btn-primary" %>
<%= link_to t("manage.members.forms.labels.cancel"), manage_members_path, class: "btn btn-secondary" %>
</div>
</div>
<% end %>
ResultMatcher = Dry::Matcher.new(
success: Dry::Matcher::Case.new(
match: -> result, *pattern {
result = result.to_result
result.success?
},
resolve: -> result {
result = result.to_result
result.value!
},
),
failure: Dry::Matcher::Case.new(
match: -> result, *pattern {
result = result.to_result
result.failure? && (pattern.any? ? pattern.include?(result.failure.first) : true)
},
resolve: -> result {
result = result.to_result
result.failure.last
},
)
)
class Manage::UpdateMemberForm < ApplicationForm
include ActiveModel::Validations::Callbacks
attribute :id, virtual: true
attribute :first_name
attribute :last_name
attribute :dob
attribute :reference
attribute :status
attribute :joined_on
attribute :email
attribute :mobile
attribute :country_code, virtual: true
phony_normalize :mobile, default_country_code: "AU"
validates :first_name, presence: true
validates :dob, allow_blank: true, date: { before: -> { Date.current } }
validates :joined_on, allow_blank: true, date: { on_or_before: -> { Date.current } }
validates :email, allow_blank: true, email: true
validates :mobile, allow_blank: true, phony_plausible: true
validates :country_code, presence: true
def initialize(attributes = {})
super
# TODO: Find a better abstraction...
@first_name ||= member.first_name
@last_name ||= member.last_name
@dob ||= member.dob
@reference ||= member.reference
@status ||= member.status
@joined_on ||= member.joined_on
@email ||= member.email
@mobile ||= member.mobile
end
def call
ActiveRecord::Base.transaction do
begin
if valid?
Success(persist!)
else
Failure([:validation, self])
end
rescue => exception
Failure([:exception, exception])
end
end
end
private
def persist!
member.update_attributes(attributes_for_model)
end
def member
@member ||= Member.find(id)
end
end
class Manage::UpdateMemberOperation < ApplicationOperation
include Dry::Matcher.for(:call, with: ResultMatcher)
attr_reader :form_class
def initialize(form_class)
@form_class = form_class
end
def call(form_params)
form_class.new(form_params).call do |on|
on.success do |result|
Success(result)
end
on.failure do |form|
Failure([:validation, form])
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment