Commit 7711fc25 authored by Greg Gard's avatar Greg Gard

js: ajax form processing

parent a8821f12
......@@ -21,3 +21,6 @@
//= require bootstrap
//= require activestorage
//= require_tree .
// top-level app namespace - useful for singleton spa etc
const EMT = {};
\ No newline at end of file
$(function(){
//
// common ajax form processing
// - use event.target not $this else u break on mutiple forms same page
// - each form is self-contained with it's own status and errors
// - https://github.com/rails/jquery-ujs/wiki/ajax
// - can run callbacks/redirects for both success and error as well as
// use callback to alter data/cancel redirect
//
$('.emt-form').on('ajax:before', (ev) => {
let scope = $(ev.target);
let msg = 'working...';
$('.emt-ajax-status').show().text(msg);
$('#form_status', scope).show().text(msg);
})
.on('ajax:complete', (ev) => {
$('.emt-ajax-status').fadeOut(1000);
})
.on("ajax:success", (ev, data) => {
let scope = $(ev.target);
$('#form_status', scope).show().text(data.message);
data.callback && data.callback(ev, scope, data);
data.redirect_to && (window.location = data.redirect_to);
})
.on("ajax:error", (ev, xhr) => {
let data = xhr.responseText ? JSON.parse(xhr.responseText) : {
message: 'Error',
errors: ['We were unable to process that request.']
};
let scope = $(ev.target);
data.callback && data.callback(ev, scope, data);
data.redirect_to && (window.location = data.redirect_to);
$('#form_status', scope).show().text(data.message);
$('#form_errors', scope).show().html($.map(data.errors, (e) => $('<li>').text(e)));
})
// FIXME: jquery-ujs and/or rails returns malformed json response on destroy that gets rendered as string
// remove this handler once we figure out why. falling back to old faithful...
// - maybe todo with jsonapi -- chrome says response is fine but jquery doesn't see it as json.
$('.emt-form #delete_button').on('click', (ev) => {
ev.preventDefault();
if (!confirm('Are you sure?')){ return false };
let slf = $(ev.target)
let scope = slf.closest('form');
let msg = 'working...';
$('.emt-ajax-status').show().text(msg);
$('#form_status', scope).show().text(msg);
$.post(scope.attr('action'), {'_method': 'delete'}, (data) => {
data.callback && data.callback(ev, scope, data);
data.redirect_to && (window.location = data.redirect_to);
});
})
});
\ No newline at end of file
......@@ -9,11 +9,14 @@
@extend .nav, .navbar-nav;
}
}
.brand {
@extend .navbar-brand;
color: $white;
}
.emt-ajax-status {
@extend .navbar-text;
display: none;
}
}
&-main {
......@@ -77,30 +80,25 @@
> div {
@extend .form-group;
> input, > textarea, > select, > checkbox { @extend .form-control; }
> input, > textarea, > select, > checkbox {
@extend .form-control;
}
}
&-errors {
@extend .well, .well-sm;
#form_errors {
@extend .well, .well-sm, .list-unstyled;
background-color: $warn !important;
> h5 {
padding: 0;
margin: 0;
}
display: none; // don't use important as it breaks jq hide/show by class
}
.save_button {
min-width: 50px;
#form_status {
display:none;
}
}
&-page-notifications {
@extend .well, .well-sm;
background-color: $info;
> h5 {
padding: 0;
margin: 0;
}
@extend .well, .well-sm, .list-unstyled;
background-color: $info !important;
display: none; // don't use important as it breaks jq hide/show by class
}
......
class ApplicationController < ActionController::Base
class UserError < EMT::UserError; end
before_action :set_jbuilder
before_action :set_presenter
# but not index or any other non-rest eg dashboard
......@@ -27,8 +30,11 @@ class ApplicationController < ActionController::Base
#
private
attr_accessor :presenter, :resource, :resource_params
attr_accessor :presenter, :resource, :resource_params, :jbuilder
# *****************************
# error handling w/ ajax
#
# more sugar
def user_errors
EMT.user_errors
......@@ -38,15 +44,53 @@ class ApplicationController < ActionController::Base
logger.error "[STACK] #{self.class.name}##{action_name}: #{e}"
end
def stack_error_message(action = nil)
msg = EMT.stack_error_message
def stack_error_message(message = nil, action = nil)
_message = message.presence || EMT.stack_error_message
case action
when :now
flash.now[:error] = msg
flash.now[:error] = _message
when :redirect
flash[:error] = _message
end
msg
_message
end
def set_ajax_user_error(er, resource, _jbuilder = jbuilder)
_jbuilder.message er.message
_jbuilder.errors resource.errors.full_messages if resource.errors.present?
response.status = :unprocessable_entity
end
def set_ajax_stack_error(er, resource, _jbuilder = jbuilder)
log_stack_error er
_jbuilder.message stack_error_message
response.status = :unprocessable_entity
end
# *************************************
# json responses
#
def set_jbuilder
self.jbuilder = Jbuilder.new
end
alias jb jbuilder # sugar
# abstract away jbuilder syntax for boilerplate controllers
# allow param else use controller jb instance eg render_json is usually all u need
def render_json(_jbuilder = jbuilder)
render json: _jbuilder.target!
end
# abstract flash implemntation out of controllers
def set_ajax_data(opts = {}, _jbuilder = jbuilder)
flash[:notice] = opts[:message] if opts[:message].presence && opts[:redirect_to]
_jbuilder.merge! opts
end
# **********************************
# extension patterns: resource autoloading and presenters
#
# all controllers get a presenter even if just base class
def set_presenter(klass = self.class.default_presenter)
self.presenter = klass.new(
......@@ -90,8 +134,15 @@ class ApplicationController < ActionController::Base
end
# *****************************************
# action view tweaks
# - controllers get an instance of view base
#
# sugar so we don't have to deal with @ivars all the time. rails imports ivars
# from controller into view class so these get set through that mechanism.
class ActionView::Base
attr_accessor :presenter, :resource, :resource_params
class UserError < EMT::UserError; end
attr_accessor :presenter, :resource, :resource_params, :jbuilder
alias jb jbuilder
end
\ No newline at end of file
#
# rails 5.2 ajax by default
#
class FacilitiesController < ApplicationController
#
def index
@facilities = Facility.kept.order(:name).page params[:page]
# json.array! @products, partial: 'products/product', as: :product
end
#
def new
end
#
def create
@facility.save!
render :edit, status: :created, location: @facility
# rescue *user_errors => e
# render json: @facility.errors, status: :unprocessable_entity
#
# rescue => e
# log_stack_error e
# render json: @facility.errors, status: :unprocessable_entity
end
set_ajax_data(
message: "Facility created.",
facility: @facility.ajax_attributes,
redirect_to: facilities_path
)
rescue *user_errors => er
set_ajax_user_error er, @facility
rescue => er
set_ajax_stack_error er, @facility
ensure
render_json
end
#
def show
redirect_to edit_facility_path(@facility)
redirect_to facilities_path, notice: "That view isn't implemented."
end
#
def edit
rescue *user_errors => e
redirect_to facilities_path, notice: e
rescue => e
log_stack_error e
redirect_to facilities_path, notice: stack_error_message
end
#
def update
@facility.update! resource_params
set_ajax_data(
message: "Facility updated.",
facility: @facility.ajax_attributes,
redirect_to: facilities_path
)
# json = Jbuilder.new
# json.extract! @facility, :id, :name, :description, :created_at, :updated_at
# json.status :ok
# json.location "/facilities/3/edit" #, format: :json)
#
json = {
status: 'ok',
location: facilities_path,
notice: "hi"
}
logger.debug "**********************:#{json.inspect}"
render json: json.to_json
#flash[:notice] = "Good job!"
#render js: "window.location = #{facilities_path.to_json}"
#render :edit, status: :created, location: @facility
# rescue *user_errors => e
# render :edit
#
# rescue => e
# log_stack_error e
# stack_error_message(:now)
# render :edit
rescue *user_errors => er
set_ajax_user_error er, @facility
rescue => er
set_ajax_stack_error er, @facility
ensure
render_json
end
# TODO: undiscard
def destroy
@facility.discard
redirect_to facilities_path, notice: "Facility deleted"
rescue *user_errors => e
redirect_to facilities_path, notice: e
rescue => e
log_stack_error e
redirect_to facilities_path, notice: stack_error_message
end
@facility.discard || raise("Unable to delete")
set_ajax_data(
message: "Facility deleted.",
facility: @facility.ajax_attributes,
redirect_to: facilities_path
)
rescue *user_errors => er
set_ajax_user_error er, @facility
rescue => er
set_ajax_stack_error er, @facility
ensure
render_json
end
end
\ No newline at end of file
#
# old school get/post non-ajax "break my back button" controller
# TODO: need a jquery/bootstrap/sass friendly datepicker
#
class PatientsController < ApplicationController
def index
......@@ -11,59 +7,70 @@ class PatientsController < ApplicationController
def new
end
#
def create
@patient.save!
redirect_to patients_path, notice: "Patient created"
rescue *user_errors => e
render :new
set_ajax_data(
message: "Patient created.",
patient: @patient.ajax_attributes,
redirect_to: patients_path
)
rescue => e
log_stack_error e
render :new
end
rescue *user_errors => er
set_ajax_user_error er, @patient
rescue => er
set_ajax_stack_error er, @patient
ensure
render_json
end
#
def show
redirect_to edit_patient_path(@patient)
redirect_to patients_path, notice: "That view isn't implemented."
end
#
def edit
rescue *user_errors => e
redirect_to patients_path, notice: e
rescue => e
log_stack_error e
redirect_to patients_path, notice: stack_error_message
end
#
def update
@patient.update! resource_params
redirect_to patients_path, notice: "Patient updated"
rescue *user_errors => e
render :edit
rescue => e
log_stack_error e
stack_error_message(:now)
render :edit
set_ajax_data(
message: "Patient updated.",
patient: @patient.ajax_attributes,
redirect_to: patients_path
)
rescue *user_errors => er
set_ajax_user_error er, @patient
rescue => er
set_ajax_stack_error er, @patient
ensure
render_json
end
# TODO: undiscard
def destroy
@patient.discard
redirect_to patients_path, notice: "Patient deleted"
rescue *user_errors => e
redirect_to patients_path, notice: e
rescue => e
log_stack_error e
redirect_to patients_path, notice: stack_error_message
@patient.discard || raise("Unable to delete")
set_ajax_data(
message: "Patient deleted.",
patient: @patient.ajax_attributes,
redirect_to: patients_path
)
rescue *user_errors => er
set_ajax_user_error er, @patient
rescue => er
set_ajax_stack_error er, @patient
ensure
render_json
end
end
\ No newline at end of file
......@@ -23,6 +23,7 @@ module ApplicationHelper
#{link_to "gardwired", EMT::GARDWIRED_URL}
</li>
<li><span class='emt-ajax-status'></span></li>
</ul>
</div>
</div>].html_safe
......@@ -39,44 +40,73 @@ module ApplicationHelper
tag.h4 title, class: 'emt-page-title'
end
# dump flash/flash.now to message list
# dump flash/flash.now to message list - ajax calls need to toggle show class/display block
# TODO: push notifications via ActionCable - toaster intetgrated with navbar?
# TODO: colorize based on priority/severity with related icons
def emt_page_notifications
return if skip_page_notifications
# dump and clear
messages = flash.to_hash.values.reject(&:blank?)
messages = flash.to_hash.values.reject(&:blank?).map{|m| tag.li m}.join
show = "show" unless messages.empty?
flash.clear
return if messages.empty?
%Q[<div class='emt-page-notifications'>
<ul>#{messages.map{|m| tag.li m}.join}</ul>
</div>].html_safe
%Q[<ul class='emt-page-notifications #{show}'>#{messages}</ul>].html_safe
end
# global status -- eg toaster
def emt_ajax_status
"<span class='emt-ajax-status'></span>"
end
# expects model from controller
# ***************************************
# forms
#
# render regardless so ajax has
def emt_form_errors(resource)
return unless resource && !resource.errors.empty?
#return unless resource && !resource.errors.empty?
# <h5>Unable to process your request due to these error(s):</h5>
#{resource.errors.full_messages.map{|msg| tag.li msg}.join}
%Q[<div class="emt-form-errors">
<h5>Unable to process your request due to these error(s):</h5>
<ul>#{resource.errors.full_messages.map{|msg| tag.li msg}.join}</ul>
</div>].html_safe
%Q[<ul id="form_errors"></ul>].html_safe
end
def emt_form_buttons(*args)
[
emt_form_button(*args),
emt_delete_button(*args),
emt_form_status
].join.html_safe
end
def emt_form_button(resource)
submit_tag "Save", class: 'emt-btn emt-brand save_button', data: 'disable'
submit_tag "Save", id: 'save_button', class: 'emt-btn emt-brand', data: 'disable'
end
# FIXME: see form.js use this once fixed. destroy's json gets rendered as string
# def emt_delete_button(resource)
# return if !resource || resource.new_record?
# link_to('Delete', resource,
# method: :delete,
# data: { confirm: 'Are you sure?' },
# id: 'delete_button',
# class: 'emt-btn emt-danger'
# )
# end
#
def emt_delete_button(resource)
return if !resource || resource.new_record?
link_to('Delete', resource,
method: :delete,
data: { confirm: 'Are you sure?' },
id: 'delete_button',
class: 'emt-btn emt-danger'
)
end
#
def emt_form_status
"<span id='form_status'></span>"
end
end
......@@ -10,10 +10,9 @@ module EMT
# custom errors
DEFAULT_STACK_ERROR_MESSAGE = "There was a system error."
class UserError < StandardError
end
class Error < StandardError; end
class StackError < Error; end
class UserError < Error; end
def self.stack_error_message
DEFAULT_STACK_ERROR_MESSAGE
......
......@@ -11,9 +11,9 @@ module EMT
# TODO: abstract out the limits on these so they can be used in tooltips/validation error messages
# or make these hashes with message -- maybe a validate_name(m, v, opts) which also sets error in validation block
REGEXES = {
name: /^[0-9a-zA-Z.-]{1,200}$/,
person_name_part: /^[0-9a-zA-Z.-]{1,50}$/,
description: /^[0-9a-zA-Z.-]{1,500}$/
name: /^[0-9a-zA-Z.\- ]{1,200}$/,
person_name_part: /^[0-9a-zA-Z.\- ]{1,50}$/,
description: /^[0-9a-zA-Z.\- ]{1,500}$/
}.freeze
......@@ -26,6 +26,8 @@ module EMT
# - pass in model so that we have access to _before_type_cast values for dates and
# have consistent api across all data types
# - methods return a match object or valid date etc else nil
# - we treat " ".blank? (= true) as empty as we want a response - also means if we allow
# a regex with spaces we don't have to test that the response is all spaces.
module ValidationMethods
def self.included(klass)
klass.extend self
......@@ -35,7 +37,8 @@ module EMT
class_eval <<-EVAL
def valid_#{name}(m, v, required: true)
_v = m.send("\#{v}_before_type_cast").to_s
return true if _v.blank? && !required
return !required if _v.blank?
#{pattern.inspect}.match(_v)
rescue
nil
......@@ -48,8 +51,9 @@ module EMT
# - if valid and block given passes in valid date and returns value of block
def valid_date(m, v, required: true)
_v = m.send("#{v}_before_type_cast").to_s
return true if _v.blank? && !required
_d = Date.parse(_v)
return !required if _v.blank?
d = Date.parse(_v)
block_given? ? yield(_d) : _d
rescue
nil
......
class ApplicationRecord < ActiveRecord::Base
class UserError < EMT::UserError; end
self.abstract_class = true
include EMT::Validations::ValidationMethods
......@@ -18,4 +20,10 @@ class ApplicationRecord < ActiveRecord::Base
def self.app_tables
APP_TABLES
end
# keeps us from having to say json.extract! in controllers for routine exports
# - override in kids
def ajax_attributes(attrs = {})
{}
end
end
......@@ -10,14 +10,16 @@ class Facility < ApplicationRecord
#
validate do |m|
m.errors.add :name, "missing/invalid" unless valid_name(m, :name)
m.errors.add :first_name, "missing/invalid" unless valid_description(m, :description, required: false)
m.errors.add :description, "missing/invalid" unless valid_description(m, :description, required: false)
end
# ****************************
# instance methods
#
has_many :admissions
def ajax_attributes
{id: id, name: name, description: description}
end
end
\ No newline at end of file
......@@ -56,4 +56,9 @@ class Patient < ApplicationRecord
def fmt_dob
dob&.to_formatted_s(:dob)
end
# would turn off ssn, user_id, password etc
def ajax_attributes
attributes
end
end
\ No newline at end of file
<div class='emt-facilities-form'>
<%= form_with model: facility, class: 'emt-form' do |f| %>
<%= emt_form_errors(facility) %>
<ul id='tabs' class='nav nav-tabs'>
<li><a href='#form' data-toggle='tab'>Form</a></li>
<li><a href='#test' data-toggle='tab'>Test</a></li>
</ul>
<div class='tab-content'>
<div id='form' class='tab-pane active'>
<%= form_with model: facility, class: 'emt-facilities-form emt-form' do |f| %>
<%= emt_form_errors(facility) %>
<div>
<%= f.label :name %>
<%= f.text_field :name %>
</div>
<div>
<%= f.label :description %>
<%= f.text_field :description %>
</div>
<%= emt_form_button(facility) %>
<%= emt_delete_button(facility) %>
<% end %>
</div>
<div id='test' class='tab-pane'>
Foo
</div>
</div>
<div>
<%= f.label :name %>
<%= f.text_field :name %>
</div>
<div>
<%= f.label :description %>
<%= f.text_field :description %>
</div>
<%= emt_form_buttons(facility) %>
<% end %>
</div>
\ No newline at end of file
<%= form_with model: patient, local: true, class: 'emt-patients-form emt-form' do |f| %>
<%= form_with model: patient, class: 'emt-patients-form emt-form' do |f| %>
<%= emt_form_errors(patient) %>
<div>
......
......@@ -4,24 +4,21 @@
<div>
<div class='emt-card'>