Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CAPT-1633 Form objects for reminders | CAPT-1634 Encapsulate pre/post form rendering/submission logic per-journey (Part 1) #2742

Merged
merged 8 commits into from
May 20, 2024
167 changes: 167 additions & 0 deletions app/controllers/concerns/form_submittable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
module FormSubmittable
extend ActiveSupport::Concern

#
# This concern provides a way of handling form submissions for a generic slug sequence.
#
# The `new`, `show`, `create`, and `update` actions can be overridden only if necessary,
# but it's discouraged as you could easily break the rendering cycle and callback chain.
# If you need to override actions, you are probably not dealing with a form-based page sequence.
#
# The average use case will most likely only require to override slug-specific callbacks
# that are used to execute custom logic before, after, or around certain actions.
# In some cases, you may not need to define any callbacks at all.
#
# Default behaviour summary for each action:
#
# controller#new: `redirect_to_first_slug`
# controller#show: `before_show` -> `render_template_for_current_slug`
# controller#update: `before_update` -> `handle_form_submission` ->
# -> `form#save` succeded? ->
# -> `execute_callback_if_exists(:after_form_save_success)` OR `redirect_to_next_slug`
# -> `form#save failed?` ->
# -> `execute_callback_if_exists(:after_form_save_failure)` OR `render_template_for_current_slug
# controller#create: same as controller#update
#
# When including this concern make sure that these methods are accessible in the controller:
#
# - `slugs`, `current_slug`, `next_slug` (normally from `PageSequence`, or overridden)
# - `journey` (from `PartOfJourneyConcern`)
# - `current_data_object`, required to load the form object
# - any slug-specific callbacks (via a separate mixin)
#
# Important: `current_slug` is used to generate callbacks and assumed safe to use, i.e. derived
# from constrained, validated or sanitised user input.
#
# Important: If there are other callbacks in the controller, you should be including this concern
# **after** all the callbacks are defined. Placing this at the top is most likely a bad idea.
# In most cases, form submission callbacks are meant to kick in last.
#
# See the implementation of `_set_slug_specific_callbacks` below for more details.

included do
before_action :_set_slug_specific_callbacks, only: [:show, :update, :create]
before_action :before_show, only: :show
before_action :before_update, only: [:update, :create]
before_action :load_form_if_exists, only: [:show, :update, :create]
around_action :handle_form_submission, only: [:update, :create]

def current_data_object
# This is the instance of the main resource handled by the form object,
# and should always be defined in the controller.
nil
end

def new
redirect_to_first_slug
end

def show
render_template_for_current_slug
end

def create
# Note: if implemented, this action will be yielded at the end of `handle_form_submission`
end

def update
# Note: if implemented, this action will be yielded at the end of `handle_form_submission`
end

private

#
# Slug-specific callbacks are generated and executed around `show`, `update`, `create` actions,
# For example, for the "personal-details" slug, the following callbacks are available:
#
# `personal_details_before_show`
# `personal_details_before_update`
# `personal_details_after_form_save_success` (*)
your marked this conversation as resolved.
Show resolved Hide resolved
# `personal_details_after_form_save_failure` (*)
#
# Ensure that the callbacks are implemented only where really needed.
# Consider organizing the callback methods in one mixin per journey and controller.
#
# (*) If you need to define these callbacks, the default rendering behaviour will not be
# followed, so you'll have to explicitly define what to do next (render/redirect_to).

def _set_slug_specific_callbacks
%i[before_show before_update after_form_save_success after_form_save_failure].each do |callback_name|
self.class.send(:define_method, callback_name) do
execute_callback_if_exists(callback_name)
end
end
end

def redirect_to_slug(slug)
raise NoMethodError, "End of sequence: you must define #{current_slug.underscore}_after_form_save_success" unless next_slug
raise NoMethodError, "Missing path helper for resource: \"#{path_helper_resource}\"; try overriding it with #path_helper_resource" unless respond_to?(:"#{path_helper_resource}_path")

redirect_to send(:"#{path_helper_resource}_path", current_journey_routing_name, slug)
end

def redirect_to_next_slug
redirect_to_slug(next_slug)
end

def redirect_to_first_slug
redirect_to_slug(first_slug)
end

def path_helper_resource
controller_name.singularize
end

def render_template_for_current_slug
render current_template
end

def current_template
current_slug.underscore
end

def slugs
journey.slug_sequence::SLUGS
end

def first_slug
slugs.first.to_sym
end

def execute_callback_if_exists(callback_name)
callback_name = :"#{current_slug.underscore}_#{callback_name}"
if respond_to?(callback_name)
log_event(callback_name) { send(callback_name) }
return true
end
false
end

def handle_form_submission
log_event(__method__)

if @form.present?
if @form.save
return if execute_callback_if_exists(:after_form_save_success)
redirect_to_next_slug
else
return if execute_callback_if_exists(:after_form_save_failure)
render_template_for_current_slug
end
else
redirect_to_next_slug
end

yield
end

def log_event(callback_name)
logger.info "Executing callback ##{callback_name}"
yield if block_given?
end

def load_form_if_exists
@form ||= journey.form(claim: current_data_object, journey_session:, params:)
end
end
end
Original file line number Diff line number Diff line change
@@ -1,59 +1,21 @@
module Journeys
module AdditionalPaymentsForTeaching
class RemindersController < BasePublicController
helper_method :current_reminder
after_action :reminder_set_email, :clear_sessions, only: [:show]

def new
# Skip the OTP process if the current_claim already has email_verified
# - transfer the email_verified state to the reminder (done in #current_reminder)
# - jump straight to reminder set
if current_reminder.email_verified? && current_reminder.save
redirect_to reminder_path(current_journey_routing_name, "set")
return
end
include PartOfClaimJourney

render "reminders/#{first_template_in_sequence}"
end

def create
current_reminder.attributes = reminder_params

begin
one_time_password
rescue Notifications::Client::BadRequestError => e
if notify_email_error?(e.message)
render "reminders/#{first_template_in_sequence}"
return
else
raise
end
end
after_action :clear_sessions, only: :show
helper_method :current_reminder

if current_reminder.save(context: current_slug.to_sym)
session[:reminder_id] = current_reminder.to_param
redirect_to reminder_path(current_journey_routing_name, next_slug)
else
render "reminders/#{first_template_in_sequence}"
end
end
include FormSubmittable
include RemindersFormCallbacks

def show
render "reminders/#{current_template}"
end
private

def update
current_reminder.attributes = reminder_params
one_time_password
if current_reminder.save(context: current_slug.to_sym)
redirect_to reminder_path(current_journey_routing_name, next_slug)
else
show
end
# Wrapping `current_reminder` with an abstract method that is fed to the form object.
def current_data_object
current_reminder
end

private

def claim_from_session
return unless session.key?(:claim_id) || session.key?(:submitted_claim_id)

Expand All @@ -69,18 +31,6 @@ def slugs
journey.slug_sequence::REMINDER_SLUGS
end

def first_template_in_sequence
slugs.first.underscore
end

def current_template
current_slug.underscore
end

def next_template
next_slug.underscore
end

def next_slug
slugs[current_slug_index + 1]
end
Expand All @@ -93,6 +43,10 @@ def current_slug_index
slugs.index(params[:slug]) || 0
end

def current_template
"reminders/#{current_slug.underscore}"
end

def current_reminder
@current_reminder ||=
reminder_from_session ||
Expand Down Expand Up @@ -127,51 +81,12 @@ def next_academic_year
journey_configuration.current_academic_year + 1
end

def reminder_params
params.require(:reminder).permit(:full_name, :email_address, :one_time_password)
end

def one_time_password
case current_slug
when "personal-details"
if current_reminder.valid?(:"personal-details")
ReminderMailer.email_verification(current_reminder, otp.code).deliver_now
session[:sent_one_time_password_at] = Time.now
end
when "email-verification"
current_reminder.update(sent_one_time_password_at: session[:sent_one_time_password_at])
end
end

def otp
@otp ||= OneTimePassword::Generator.new
end

def reminder_set_email
return unless current_slug == "set" && current_reminder.email_verified?

ReminderMailer.reminder_set(current_reminder).deliver_now
end

def clear_sessions
return unless current_template == "set"
return unless current_slug == "set"

session.delete(:claim_id)
session.delete(:reminder_id)
end

def notify_email_error?(msg)
case msg
when "ValidationError: email_address is a required property"
current_reminder.add_invalid_email_error("Enter an email address in the correct format, like name@example.com")
true
when "BadRequestError: Can’t send to this recipient using a team-only API key"
current_reminder.add_invalid_email_error("Only authorised email addresses can be used when using a team-only API key")
true
else
false
end
end
end
end
end
Loading
Loading