Support for grape and onboarding via react
This commit is contained in:
parent
9c162d3f0d
commit
4c5b997d65
133 changed files with 14283 additions and 14420 deletions
12
.bootstraprc
Normal file
12
.bootstraprc
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"bootstrapVersion": 3,
|
||||
"styleLoaders": ["style", "css", "sass"],
|
||||
"extractStyles": true,
|
||||
"styles": {
|
||||
"mixins": true,
|
||||
"grid": true,
|
||||
"forms": true
|
||||
},
|
||||
|
||||
"scripts": false
|
||||
}
|
10
Gemfile
10
Gemfile
|
@ -112,7 +112,7 @@ gem 'countries'
|
|||
group :development do
|
||||
gem 'traceroute'
|
||||
gem 'debase'
|
||||
gem 'ruby-debug-ide', '0.6.0'
|
||||
gem 'ruby-debug-ide'
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
|
@ -155,3 +155,11 @@ gem 'foreman'
|
|||
group :production do
|
||||
gem 'rails_autoscale_agent'
|
||||
end
|
||||
|
||||
gem 'grape'
|
||||
gem 'grape-entity', git: 'https://github.com/ruby-grape/grape-entity.git', ref: '0e04aa561373b510c2486282979085eaef2ae663'
|
||||
gem 'grape-swagger'
|
||||
gem 'grape-swagger-entity'
|
||||
gem 'grape_url_validator'
|
||||
gem 'grape_logging'
|
||||
gem 'grape_devise', git: 'https://github.com/ericschultz/grape_devise.git'
|
||||
|
|
87
Gemfile.lock
87
Gemfile.lock
|
@ -23,6 +23,24 @@ GIT
|
|||
multi_json (~> 1.0)
|
||||
stripe (>= 1.31.0, <= 1.58.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/ericschultz/grape_devise.git
|
||||
revision: f1cdf2576476f0f9bf0b4f5c3e7cf07295933871
|
||||
specs:
|
||||
grape_devise (0.1.1)
|
||||
devise (>= 2.2.8, < 4)
|
||||
grape (> 0.7)
|
||||
rails (> 3.2, < 5)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/ruby-grape/grape-entity.git
|
||||
revision: 0e04aa561373b510c2486282979085eaef2ae663
|
||||
ref: 0e04aa561373b510c2486282979085eaef2ae663
|
||||
specs:
|
||||
grape-entity (0.7.1)
|
||||
activesupport (>= 3.0.0)
|
||||
multi_json (>= 1.3.2)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
|
@ -71,7 +89,11 @@ GEM
|
|||
mail (> 2.2.5)
|
||||
mime-types
|
||||
xml-simple
|
||||
bcrypt (3.1.10)
|
||||
axiom-types (0.1.1)
|
||||
descendants_tracker (~> 0.0.4)
|
||||
ice_nine (~> 0.11.0)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
bcrypt (3.1.11)
|
||||
binding_of_caller (0.7.2)
|
||||
debug_inspector (>= 0.0.1)
|
||||
bootsnap (1.1.7)
|
||||
|
@ -95,6 +117,8 @@ GEM
|
|||
simplecov
|
||||
url
|
||||
coderay (1.1.2)
|
||||
coercible (1.0.0)
|
||||
descendants_tracker (~> 0.0.1)
|
||||
colorize (0.8.1)
|
||||
concurrent-ruby (1.0.5)
|
||||
config (1.7.0)
|
||||
|
@ -115,7 +139,7 @@ GEM
|
|||
database_cleaner (1.6.1)
|
||||
debase (0.2.2)
|
||||
debase-ruby_core_source (>= 0.10.2)
|
||||
debase-ruby_core_source (0.10.2)
|
||||
debase-ruby_core_source (0.10.3)
|
||||
debug_inspector (0.0.2)
|
||||
deep_merge (1.2.1)
|
||||
delayed_job (4.1.2)
|
||||
|
@ -123,7 +147,9 @@ GEM
|
|||
delayed_job_active_record (4.1.1)
|
||||
activerecord (>= 3.0, < 5.1)
|
||||
delayed_job (>= 3.0, < 5)
|
||||
devise (3.4.1)
|
||||
descendants_tracker (0.0.4)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
devise (3.5.10)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 3.2.6, < 5)
|
||||
|
@ -166,6 +192,7 @@ GEM
|
|||
dry-equalizer (~> 0.2)
|
||||
dry-logic (~> 0.4, >= 0.4.0)
|
||||
dry-types (~> 0.12.0)
|
||||
equalizer (0.0.11)
|
||||
erubis (2.7.0)
|
||||
execjs (2.5.2)
|
||||
factory_bot (4.8.2)
|
||||
|
@ -190,6 +217,23 @@ GEM
|
|||
plissken
|
||||
geocoder (1.2.11)
|
||||
get_process_mem (0.2.1)
|
||||
grape (1.0.3)
|
||||
activesupport
|
||||
builder
|
||||
mustermann-grape (~> 1.0.0)
|
||||
rack (>= 1.3.0)
|
||||
rack-accept
|
||||
virtus (>= 1.0.0)
|
||||
grape-swagger (0.28.0)
|
||||
grape (>= 0.16.2)
|
||||
grape-swagger-entity (0.2.3)
|
||||
grape-entity (>= 0.5.0)
|
||||
grape-swagger (>= 0.20.4)
|
||||
grape_logging (1.8.0)
|
||||
grape
|
||||
rack
|
||||
grape_url_validator (1.0.0)
|
||||
grape (>= 0.12.0)
|
||||
hamster (3.0.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
hashie (3.4.1)
|
||||
|
@ -201,10 +245,12 @@ GEM
|
|||
httparty (0.13.3)
|
||||
json (~> 1.8)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (0.8.6)
|
||||
i18n (0.9.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-js (3.0.2)
|
||||
i18n (~> 0.6, >= 0.6.6)
|
||||
i18n_data (0.8.0)
|
||||
ice_nine (0.11.2)
|
||||
inflecto (0.0.2)
|
||||
journey (1.0.4)
|
||||
json (1.8.6)
|
||||
|
@ -226,9 +272,12 @@ GEM
|
|||
money (6.10.0)
|
||||
i18n (>= 0.6.4, < 1.0)
|
||||
msgpack (1.2.0)
|
||||
multi_json (1.12.1)
|
||||
multi_json (1.13.1)
|
||||
multi_xml (0.5.5)
|
||||
multipart-post (2.0.0)
|
||||
mustermann (1.0.2)
|
||||
mustermann-grape (1.0.0)
|
||||
mustermann (~> 1.0.0)
|
||||
nearest_time_zone (0.0.4)
|
||||
andand
|
||||
kdtree
|
||||
|
@ -258,9 +307,11 @@ GEM
|
|||
rabl (0.11.6)
|
||||
activesupport (>= 2.3.14)
|
||||
rack (1.4.7)
|
||||
rack-accept (0.4.5)
|
||||
rack (>= 0.4)
|
||||
rack-attack (4.2.0)
|
||||
rack
|
||||
rack-cache (1.7.0)
|
||||
rack-cache (1.7.2)
|
||||
rack (>= 0.4)
|
||||
rack-ssl (1.3.4)
|
||||
rack
|
||||
|
@ -292,7 +343,7 @@ GEM
|
|||
rake (>= 0.8.7)
|
||||
rdoc (~> 3.4)
|
||||
thor (>= 0.14.6, < 2.0)
|
||||
rake (12.0.0)
|
||||
rake (12.3.1)
|
||||
rdoc (3.12.2)
|
||||
json (~> 1.4)
|
||||
require_all (1.3.2)
|
||||
|
@ -329,7 +380,7 @@ GEM
|
|||
rspec-mocks (~> 3.5.0)
|
||||
rspec-support (~> 3.5.0)
|
||||
rspec-support (3.5.0)
|
||||
ruby-debug-ide (0.6.0)
|
||||
ruby-debug-ide (0.6.1)
|
||||
rake (>= 0.8.1)
|
||||
ruby-prof (0.15.9)
|
||||
safe_yaml (1.0.4)
|
||||
|
@ -359,7 +410,7 @@ GEM
|
|||
test-unit (3.2.7)
|
||||
power_assert
|
||||
thor (0.19.4)
|
||||
thread_safe (0.3.5)
|
||||
thread_safe (0.3.6)
|
||||
tilt (1.4.1)
|
||||
timecop (0.7.3)
|
||||
traceroute (0.5.0)
|
||||
|
@ -367,7 +418,7 @@ GEM
|
|||
treetop (1.4.15)
|
||||
polyglot
|
||||
polyglot (>= 0.3.1)
|
||||
tzinfo (0.3.53)
|
||||
tzinfo (0.3.54)
|
||||
uglifier (2.7.1)
|
||||
execjs (>= 0.3.0)
|
||||
json (>= 1.8.0)
|
||||
|
@ -377,7 +428,12 @@ GEM
|
|||
unicode_utils (1.4.0)
|
||||
url (0.3.2)
|
||||
vcr (2.9.3)
|
||||
warden (1.2.3)
|
||||
virtus (1.0.5)
|
||||
axiom-types (~> 0.1)
|
||||
coercible (~> 1.0)
|
||||
descendants_tracker (~> 0.0, >= 0.0.3)
|
||||
equalizer (~> 0.0, >= 0.0.9)
|
||||
warden (1.2.7)
|
||||
rack (>= 1.0)
|
||||
webmock (1.21.0)
|
||||
addressable (>= 2.3.6)
|
||||
|
@ -418,6 +474,13 @@ DEPENDENCIES
|
|||
foreman
|
||||
fullcontact
|
||||
geocoder
|
||||
grape
|
||||
grape-entity!
|
||||
grape-swagger
|
||||
grape-swagger-entity
|
||||
grape_devise!
|
||||
grape_logging
|
||||
grape_url_validator
|
||||
hamster
|
||||
heroku-deflater
|
||||
httparty
|
||||
|
@ -446,7 +509,7 @@ DEPENDENCIES
|
|||
roadie-rails
|
||||
rspec
|
||||
rspec-rails
|
||||
ruby-debug-ide (= 0.6.0)
|
||||
ruby-debug-ide
|
||||
ruby-prof (= 0.15.9)
|
||||
sass (= 3.2.19)
|
||||
sass-rails (= 3.2.6)
|
||||
|
|
5
app/api/houdini/api.rb
Normal file
5
app/api/houdini/api.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::API < Grape::API
|
||||
format :json
|
||||
mount Houdini::V1::API => '/v1'
|
||||
end
|
21
app/api/houdini/v1/api.rb
Normal file
21
app/api/houdini/v1/api.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
require 'houdini/v1/validations'
|
||||
class Houdini::V1::API < Grape::API
|
||||
logger.formatter = GrapeLogging::Formatters::Rails.new
|
||||
use GrapeLogging::Middleware::RequestLogger, { logger: logger }
|
||||
content_type :json, 'application/json'
|
||||
default_format :json
|
||||
rescue_from Grape::Exceptions::ValidationErrors do |e|
|
||||
output = {errors: e}
|
||||
error! output, 400
|
||||
end
|
||||
|
||||
#include Houdini::V1::Helpers::ApplicationHelper
|
||||
mount Houdini::V1::Nonprofit => '/nonprofit'
|
||||
# Additional mounts are added via generators above this line
|
||||
# DON'T REMOVE THIS OR THE PREVIOUS LINES!!!
|
||||
uriForHost = URI.parse(Settings.cdn.url)
|
||||
add_swagger_documentation \
|
||||
host: "#{uriForHost.host}#{Settings.cdn.port ? ":#{Settings.cdn.port}" : ""}",
|
||||
schemes: [uriForHost.scheme],
|
||||
base_path: '/api/v1'
|
||||
end
|
30
app/api/houdini/v1/base_api.rb
Normal file
30
app/api/houdini/v1/base_api.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::BaseAPI < Grape::API
|
||||
#helpers ApplicationHelper
|
||||
# helpers do
|
||||
# def session
|
||||
# env['rack.session']
|
||||
# end
|
||||
#
|
||||
# def protect_against_forgery
|
||||
# unless verified_request?
|
||||
# error!('Unauthorized', 401)
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# def verified_request?
|
||||
# !protect_against_forgery? || request.get? || request.head? ||
|
||||
# form_authenticity_token == request.headers['X-CSRF-Token'] ||
|
||||
# form_authenticity_token == request.headers['X-Csrf-Token']
|
||||
# end
|
||||
#
|
||||
# def form_authenticity_token
|
||||
# session[:_csrf_token] ||= SecureRandom.base64(32)
|
||||
# end
|
||||
#
|
||||
# def protect_against_forgery?
|
||||
# allow_forgery_protection = Rails.configuration.action_controller.allow_forgery_protection
|
||||
# allow_forgery_protection.nil? || allow_forgery_protection
|
||||
# end
|
||||
# end
|
||||
end
|
4
app/api/houdini/v1/entities/nonprofit.rb
Normal file
4
app/api/houdini/v1/entities/nonprofit.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Entities::Nonprofit < Grape::Entity
|
||||
expose :id
|
||||
end
|
5
app/api/houdini/v1/entities/validation_error.rb
Normal file
5
app/api/houdini/v1/entities/validation_error.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Entities::ValidationError < Grape::Entity
|
||||
expose :params, documentation: {type: 'String', desc: 'Params where the following had an error.', is_array: true}
|
||||
expose :messages, documentation: {type:'String', desc: 'The validation messages for the params', is_array: true}
|
||||
end
|
4
app/api/houdini/v1/entities/validation_errors.rb
Normal file
4
app/api/houdini/v1/entities/validation_errors.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Entities::ValidationErrors < Grape::Entity
|
||||
expose :errors, documentation: {type: ValidationError, desc: 'errors', is_array:true}
|
||||
end
|
45
app/api/houdini/v1/helpers/application_helper.rb
Normal file
45
app/api/houdini/v1/helpers/application_helper.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
module Houdini::V1::Helpers::ApplicationHelper
|
||||
extend Grape::API::Helpers
|
||||
|
||||
|
||||
def session
|
||||
env['rack.session']
|
||||
end
|
||||
|
||||
def protect_against_forgery
|
||||
unless verified_request?
|
||||
error!('Unauthorized', 401)
|
||||
end
|
||||
end
|
||||
|
||||
def verified_request?
|
||||
!protect_against_forgery? || request.get? || request.head? ||
|
||||
form_authenticity_token == request.headers['X-CSRF-Token'] ||
|
||||
form_authenticity_token == request.headers['X-Csrf-Token']
|
||||
end
|
||||
|
||||
def form_authenticity_token
|
||||
session[:_csrf_token] ||= SecureRandom.base64(32)
|
||||
end
|
||||
|
||||
def protect_against_forgery?
|
||||
allow_forgery_protection = Rails.configuration.action_controller.allow_forgery_protection
|
||||
allow_forgery_protection.nil? || allow_forgery_protection
|
||||
end
|
||||
|
||||
|
||||
# def rescue_ar_invalid( *class_to_hash)
|
||||
# rescue_with ActiveRecord::RecordInvalid do |error|
|
||||
# output = []
|
||||
# error.record.errors do |attr,message|
|
||||
# output.push({params: "#{class_to_hash[error.record.class]}['#{attr}']",
|
||||
# message: message})
|
||||
# end
|
||||
# raise Grape::Exceptions::ValidationErrors.new(output)
|
||||
#
|
||||
# end
|
||||
# end
|
||||
|
||||
end
|
||||
|
19
app/api/houdini/v1/helpers/rescue_helper.rb
Normal file
19
app/api/houdini/v1/helpers/rescue_helper.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
module Houdini::V1::Helpers::RescueHelper
|
||||
require 'active_support/concern'
|
||||
|
||||
extend ActiveSupport::Concern
|
||||
include Grape::DSL::Configuration
|
||||
module ClassMethods
|
||||
def rescue_ar_invalid( *class_to_hash)
|
||||
rescue_with ActiveRecord::RecordInvalid do |error|
|
||||
output = []
|
||||
error.record.errors do |attr,message|
|
||||
output.push({params: "#{class_to_hash[error.record.class]}['#{attr}']",
|
||||
message: message})
|
||||
end
|
||||
raise Grape::Exceptions::ValidationErrors.new(output)
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
113
app/api/houdini/v1/nonprofit.rb
Normal file
113
app/api/houdini/v1/nonprofit.rb
Normal file
|
@ -0,0 +1,113 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Nonprofit < Houdini::V1::BaseAPI
|
||||
helpers Houdini::V1::Helpers::ApplicationHelper, Houdini::V1::Helpers::RescueHelper
|
||||
|
||||
before do
|
||||
protect_against_forgery
|
||||
end
|
||||
|
||||
desc 'Return a nonprofit.' do
|
||||
success Houdini::V1::Entities::Nonprofit
|
||||
end
|
||||
params do
|
||||
requires :id, type: Integer, desc: 'Status id.'
|
||||
end
|
||||
route_param :id do
|
||||
get do
|
||||
np = Nonprofit.find(params[:id])
|
||||
present np, as: Houdini::V1::Entities::Nonprofit
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Register a nonprofit' do
|
||||
success Houdini::V1::Entities::Nonprofit
|
||||
|
||||
#this needs to be a validation an array
|
||||
failure [{code:400, message:'Validation Errors', model: Houdini::V1::Entities::ValidationErrors}]
|
||||
end
|
||||
|
||||
params do
|
||||
|
||||
requires :nonprofit, type: Hash do
|
||||
requires :name, type:String, desc: 'Organization Name', allow_blank: false, documentation: { param_type: 'body' }
|
||||
optional :url, type:String, desc: 'Organization website URL', allow_blank:true, regexp: URI::regexp, documentation: { param_type: 'body' }
|
||||
requires :zip_code, type:String, allow_blank: false, desc: "Organization Address ZIP Code", documentation: { param_type: 'body' }
|
||||
requires :state_code, type:String, allow_blank: false, desc: "Organization Address State Code", documentation: { param_type: 'body' }
|
||||
requires :city, type:String, allow_blank: false, desc: "Organization Address City", documentation: { param_type: 'body' }
|
||||
optional :email, type:String, desc: 'Organization email (public)', regexp: Email::Regex, documentation: { param_type: 'body' }
|
||||
optional :phone, type:String, desc: 'Organization phone (public)', documentation: { param_type: 'body' }
|
||||
end
|
||||
|
||||
requires :user, type: Hash do
|
||||
requires :name, type:String, desc: 'Full name', allow_blank:false, documentation: { param_type: 'body' }
|
||||
requires :email, type:String, desc: 'Username', allow_blank: false, documentation: { param_type: 'body' }
|
||||
requires :password, type:String, desc: 'Password', allow_blank: false, is_equal_to: :password_confirmation, documentation: { param_type: 'body' }
|
||||
requires :password_confirmation, type:String, desc: 'Password confirmation', allow_blank: false, documentation: { param_type: 'body' }
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
post do
|
||||
np = nil
|
||||
u = nil
|
||||
Qx.transaction do
|
||||
begin
|
||||
np = Nonprofit.new(OnboardAccounts.set_nonprofit_defaults(params[:nonprofit]))
|
||||
|
||||
begin
|
||||
np.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
if (e.record.errors[:slug])
|
||||
begin
|
||||
slug = SlugNonprofitNamingAlgorithm.new(np.state_code_slug, np.city_slug).create_copy_name(np.slug)
|
||||
np.slug = slug
|
||||
np.save!
|
||||
rescue UnableToCreateNameCopyError
|
||||
raise Grape::Exceptions::ValidationErrors.new(errors:[Grape::Exceptions::Validation.new(
|
||||
|
||||
params: ["nonprofit[name]"],
|
||||
message: "has an invalid slug. Contact support for help."
|
||||
)])
|
||||
end
|
||||
else
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
u = User.new(params[:user])
|
||||
u.save!
|
||||
|
||||
role = u.roles.build(host: np, name: 'nonprofit_admin')
|
||||
role.save!
|
||||
|
||||
billing_plan = BillingPlan.find(Settings.default_bp.id)
|
||||
b_sub = np.build_billing_subscription(billing_plan: billing_plan, status: 'active')
|
||||
b_sub.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
class_to_name = {Nonprofit => 'nonprofit', User => 'user'}
|
||||
if class_to_name[e.record.class]
|
||||
errors = e.record.errors.keys.map {|k|
|
||||
|
||||
errors = e.record.errors[k].uniq
|
||||
errors.map{|error| Grape::Exceptions::Validation.new(
|
||||
|
||||
params: ["#{class_to_name[e.record.class]}[#{k.to_s}]"],
|
||||
message: error
|
||||
|
||||
)}
|
||||
}
|
||||
|
||||
raise Grape::Exceptions::ValidationErrors.new(errors:errors.flatten)
|
||||
else
|
||||
raise e
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
#onboard callback
|
||||
present np, with: Houdini::V1::Entities::Nonprofit
|
||||
end
|
||||
|
||||
|
||||
|
||||
end
|
2
app/api/houdini/v1/validations.rb
Normal file
2
app/api/houdini/v1/validations.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
require 'houdini/v1/validators/is_equal_to'
|
8
app/api/houdini/v1/validators/is_equal_to.rb
Normal file
8
app/api/houdini/v1/validators/is_equal_to.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Validators::IsEqualTo < Grape::Validations::Base
|
||||
def validate_param!(attr_name, params)
|
||||
if params[attr_name] != params[@option]
|
||||
fail Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: 'MESSAGE'
|
||||
end
|
||||
end
|
||||
end
|
2
app/assets/javascripts/onboard.js
Normal file
2
app/assets/javascripts/onboard.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Place all the behaviors and hooks related to the matching controller here.
|
||||
// All this logic will automatically be available in application.js.
|
6
app/controllers/onboard_controller.rb
Normal file
6
app/controllers/onboard_controller.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
class OnboardController < ApplicationController
|
||||
layout 'layouts/apified'
|
||||
def index
|
||||
|
||||
end
|
||||
end
|
|
@ -1,13 +1,13 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Users::SessionsController < Devise::SessionsController
|
||||
|
||||
def create
|
||||
def create
|
||||
respond_to do |format|
|
||||
format.html { super }
|
||||
format.json {
|
||||
warden.authenticate!(:scope => resource_name, :recall => "#{controller_path}#new")
|
||||
render :status => 200, :json => { :status => "Success" }
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
2
app/helpers/onboard_helper.rb
Normal file
2
app/helpers/onboard_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module OnboardHelper
|
||||
end
|
|
@ -82,7 +82,7 @@ class Nonprofit < ActiveRecord::Base
|
|||
validates :city, presence: true
|
||||
validates :state_code, presence: true
|
||||
validates :email, format: { with: Email::Regex }, allow_blank: true
|
||||
validates_uniqueness_of :slug, scope: [:city, :state_code]
|
||||
validates_uniqueness_of :slug, scope: [:city_slug, :state_code_slug]
|
||||
validates_presence_of :slug
|
||||
|
||||
scope :vetted, -> {where(vetted: true)}
|
||||
|
@ -140,9 +140,16 @@ class Nonprofit < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def set_slugs
|
||||
self.slug = Format::Url.convert_to_slug self.name
|
||||
self.city_slug = Format::Url.convert_to_slug self.city
|
||||
self.state_code_slug = Format::Url.convert_to_slug self.state_code
|
||||
unless (self.slug)
|
||||
self.slug = Format::Url.convert_to_slug self.name
|
||||
end
|
||||
unless (self.city_slug)
|
||||
self.city_slug = Format::Url.convert_to_slug self.city
|
||||
end
|
||||
|
||||
unless (self.state_code_slug)
|
||||
self.state_code_slug = Format::Url.convert_to_slug self.state_code
|
||||
end
|
||||
self
|
||||
end
|
||||
|
||||
|
|
27
app/views/layouts/apified.html.erb
Normal file
27
app/views/layouts/apified.html.erb
Normal file
|
@ -0,0 +1,27 @@
|
|||
<%- # License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later -%>
|
||||
<!doctype html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
|
||||
|
||||
<%= IncludeAsset.js '/client/js/i18n.js' %>
|
||||
<script>
|
||||
I18n.defaultLocale = "<%= I18n.default_locale %>"
|
||||
I18n.locale = "<%= I18n.locale %>"
|
||||
window._csrf = "<%= form_authenticity_token %>"
|
||||
</script>
|
||||
|
||||
<%= IncludeAsset.js 'app/react.js' %>
|
||||
<%= IncludeAsset.js 'app/react-dom.js' %>
|
||||
<%= IncludeAsset.js 'app/vendor.js' %>
|
||||
<%= yield :javascripts %>
|
||||
<%= render 'layouts/stylesheets' %>
|
||||
<%= IncludeAsset.css 'client/css/global/page.css' %>
|
||||
<%= IncludeAsset.css 'client/css/bootstrap.css' %>
|
||||
</head>
|
||||
<body>
|
||||
<%= yield %>
|
||||
|
||||
</body>
|
||||
</html>
|
7
app/views/onboard/index.html.erb
Normal file
7
app/views/onboard/index.html.erb
Normal file
|
@ -0,0 +1,7 @@
|
|||
<% content_for :javascripts do %>
|
||||
<%= IncludeAsset.js 'app/registration_pagex.js' %>
|
||||
<% end %>
|
||||
|
||||
<div id="outlet"></div>
|
||||
|
||||
<script>LoadReactPage(document.getElementById('outlet'))</script>
|
|
@ -1,10 +1,10 @@
|
|||
/* License: LGPL-3.0-or-later */
|
||||
[class*="container"] {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/*[class*="container"] {*/
|
||||
/*margin-left: auto;*/
|
||||
/*margin-right: auto;*/
|
||||
/*}*/
|
||||
|
||||
.container { max-width: 60rem; }
|
||||
.container--medium { max-width: 50rem; }
|
||||
.container--narrow { max-width: 40rem; }
|
||||
/*.container { max-width: 60rem; }*/
|
||||
/*.container--medium { max-width: 50rem; }*/
|
||||
/*.container--narrow { max-width: 40rem; }*/
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
@import 'commons.css'; /* npm */
|
||||
@import 'colors.css'; /* contains variables */
|
||||
@import 'shadows.css'; /* contains variables */
|
||||
@import 'typography.css';
|
||||
/*@import 'typography.css';*/
|
||||
@import 'icons.css';
|
||||
@import 'containers.css';
|
||||
@import 'buttons.css';
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
/* License: LGPL-3.0-or-later */
|
||||
@font-face {
|
||||
font-family: 'OpenSans';
|
||||
src: url('/fonts/OpenSans/OpenSans-Regular.ttf') format('truetype');
|
||||
font-family: 'Open Sans';
|
||||
src: url('/fonts/Open_Sans/opensans-regular-webfont.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OpenSans';
|
||||
src: url('/fonts/OpenSans/OpenSans-Semibold.ttf') format('truetype');
|
||||
font-family: 'Open Sans';
|
||||
src: url('/fonts/Open_Sans/OpenSans-Semibold.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'OpenSans';
|
||||
src: url('/fonts/OpenSans/OpenSans-Bold.ttf') format('truetype');
|
||||
font-family: 'Open Sans';
|
||||
src: url('/fonts/Open_Sans/opensans-bold-webfont.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
font-family:
|
||||
OpenSans,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Helvetica Neue,
|
||||
Helvetica,
|
||||
OpenSans,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--black);
|
||||
|
|
|
@ -16,6 +16,10 @@ module Commitchange
|
|||
# Custom directories with classes and modules you want to be autoloadable.
|
||||
# config.autoload_paths += %W(#{config.root}/extras)
|
||||
config.autoload_paths += Dir["#{config.root}/lib/**/"]
|
||||
|
||||
config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb')
|
||||
config.autoload_paths += Dir[Rails.root.join('app', 'api', '*')]
|
||||
|
||||
# Only load the plugins named here, in the order given (default is alphabetical).
|
||||
# :all can be used as a placeholder for all plugins not explicitly named.
|
||||
# config.plugins = [ :exception_notification, :ssl_requirement, :all ]
|
||||
|
|
|
@ -8,13 +8,14 @@ Encoding.default_internal = Encoding::UTF_8
|
|||
require 'dotenv'
|
||||
Dotenv.load ".env"
|
||||
@env = Rails.env || 'development'
|
||||
puts "config files .env .env.#{@env} ./config/settings.#{@env}.yml#{ @env != 'test' ? " ./config/#{ENV.fetch('ORG_NAME')}.yml": " "} #{ @env != 'test' ? " ./config/#{ENV.fetch('ORG_NAME')}.#{@env}.yml": " "} #{ @env == 'test' ? "./config/settings.test.yml" : ""}"
|
||||
@org_name = ENV['ORG_NAME'] || 'default_organization'
|
||||
puts "config files .env .env.#{@env} ./config/settings.#{@env}.yml#{ @env != 'test' ? " ./config/#{@org_name}.yml": " "} #{ @env != 'test' ? " ./config/#{@org_name}.#{@env}.yml": " "} #{ @env == 'test' ? "./config/settings.test.yml" : ""}"
|
||||
Dotenv.load ".env.#{@env}" if File.file?(".env.#{@env}")
|
||||
if Rails.env == 'test'
|
||||
Settings.add_source!("./config/settings.test.yml")
|
||||
else
|
||||
Settings.add_source!("./config/#{ENV.fetch('ORG_NAME')}.yml")
|
||||
Settings.add_source!("./config/#{ENV.fetch('ORG_NAME')}.#{Rails.env}.yml")
|
||||
Settings.add_source!("./config/#{@org_name}.yml")
|
||||
Settings.add_source!("./config/#{@org_name}.#{Rails.env}.yml")
|
||||
end
|
||||
|
||||
|
||||
|
|
|
@ -39,4 +39,6 @@ Commitchange::Application.configure do
|
|||
config.assets.debug = true
|
||||
|
||||
config.log_level = :debug
|
||||
|
||||
config.action_controller.allow_forgery_protection = false
|
||||
end
|
||||
|
|
11
config/initializers/reload_api.rb
Normal file
11
config/initializers/reload_api.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
if Rails.env.development?
|
||||
ActiveSupport::Dependencies.explicitly_unloadable_constants << 'Houdini::V1'
|
||||
|
||||
api_files = Dir[Rails.root.join('app', 'api', '**', '*.rb')]
|
||||
api_reloader = ActiveSupport::FileUpdateChecker.new(api_files) do
|
||||
Rails.application.reload_routes!
|
||||
end
|
||||
ActionDispatch::Callbacks.to_prepare do
|
||||
api_reloader.execute_if_updated
|
||||
end
|
||||
end
|
|
@ -139,3 +139,31 @@ en:
|
|||
twitter: "Tweet"
|
||||
twitter_message: "Join me in supporting"
|
||||
finish: "Finish"
|
||||
registration:
|
||||
get_started:
|
||||
header: "Get started"
|
||||
description: "Let's get started with Houdini. To begin, fill out your initial info nonprofit and user info."
|
||||
wizard:
|
||||
tabs:
|
||||
nonprofit: "Nonprofit"
|
||||
contact: "Contact"
|
||||
nonprofit:
|
||||
name: "Organization Name"
|
||||
website: "Website URL"
|
||||
email: "Org Email (public)"
|
||||
phone: "Org Phone (public)"
|
||||
city: "City"
|
||||
state: "State"
|
||||
zip: "Zip Code"
|
||||
contact:
|
||||
name: "Your Name"
|
||||
email: "Your Email (used for login)"
|
||||
password: "New Password"
|
||||
password_confirmation: "Retype Password"
|
||||
phone: "Your Phone (for account recovery)"
|
||||
save_and_finish: "Save & Finish"
|
||||
next: "Next"
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
Commitchange::Application.routes.draw do
|
||||
mount Houdini::API => '/api'
|
||||
|
||||
if Rails.env == 'development'
|
||||
get '/button_debug/embedded' => 'button_debug#embedded'
|
||||
get '/button_debug/button' => 'button_debug#button'
|
||||
get '/button_debug/embedded/:id' => 'button_debug#embedded'
|
||||
get '/button_debug/button/:id' => 'button_debug#button'
|
||||
end
|
||||
end
|
||||
get 'onboard' => 'onboard#index'
|
||||
|
||||
resources(:emails, {only: [:create]})
|
||||
resources(:settings, {only: [:index]})
|
||||
resources(:pricing, {only: [:index]})
|
||||
|
|
|
@ -48,7 +48,7 @@ page_editor:
|
|||
editor: 'quill'
|
||||
|
||||
language: 'en'
|
||||
available_locales: ['en']
|
||||
available_locales: ['en', 'de']
|
||||
|
||||
intntl:
|
||||
currencies: ["usd"]
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
|
||||
// require a root component here. This will be treated as the root of a webpack package
|
||||
|
||||
import "../src/components/registration_page/registration_page"
|
16
javascripts/app/registration_page.tsx
Normal file
16
javascripts/app/registration_page.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
|
||||
// require a root component here. This will be treated as the root of a webpack package
|
||||
import Root from "../src/components/common/Root"
|
||||
import RegistrationPage from "../src/components/registration_page/RegistrationPage"
|
||||
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import * as React from 'react'
|
||||
|
||||
function LoadReactPage(element:HTMLElement) {
|
||||
ReactDOM.render(<Root><RegistrationPage/></Root>, element)
|
||||
}
|
||||
|
||||
|
||||
(window as any).LoadReactPage = LoadReactPage
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import {shallow} from 'enzyme'
|
||||
import toJson from 'enzyme-to-json'
|
||||
import LabeledFieldComponent from './LabeledFieldComponent'
|
||||
|
||||
describe('LabeledFieldComponent', () => {
|
||||
test('In Error with Children', () => {
|
||||
let result = shallow(<LabeledFieldComponent inputId={"ID"} labelText={"Our Label"} inError={true}
|
||||
error={"errorMessage"}>
|
||||
<hr/>
|
||||
</LabeledFieldComponent>)
|
||||
expect(toJson(result)).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('has error checked but no message so not really in error', () => {
|
||||
let result = shallow(<LabeledFieldComponent inputId={"ID"} labelText={"Our Label"} inError={true} error={null}>
|
||||
<hr/>
|
||||
</LabeledFieldComponent>)
|
||||
expect(toJson(result)).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('no error', () => {
|
||||
let result = shallow(<LabeledFieldComponent inputId={"ID"} labelText={"Our Label"} inError={false}>
|
||||
<hr/>
|
||||
</LabeledFieldComponent>)
|
||||
expect(toJson(result)).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('add extra classNames', () => {
|
||||
let result = shallow(<LabeledFieldComponent inputId={"ID"} labelText={"Our Label"} inError={false}
|
||||
className={"a_class another_class"}>
|
||||
<hr/>
|
||||
</LabeledFieldComponent>)
|
||||
expect(toJson(result)).toMatchSnapshot()
|
||||
})
|
||||
})
|
31
javascripts/src/components/common/LabeledFieldComponent.tsx
Normal file
31
javascripts/src/components/common/LabeledFieldComponent.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import StandardFieldComponent from "./StandardFieldComponent";
|
||||
import { observer } from 'mobx-react';
|
||||
import {Field} from "../../../../types/mobx-react-form";
|
||||
import {injectIntl, InjectedIntl} from 'react-intl';
|
||||
|
||||
|
||||
export interface LabeledFieldComponentProps
|
||||
{
|
||||
inputId: string
|
||||
labelText: string
|
||||
inError:boolean
|
||||
error?:string
|
||||
className?:string
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class LabeledFieldComponent extends React.Component<LabeledFieldComponentProps, {}> {
|
||||
render() {
|
||||
let className = this.props.className || ""
|
||||
let inError = this.props.inError && this.props.error !== null && this.props.error !== "";
|
||||
className += " form-group"
|
||||
className += inError ? " has-error" : ""
|
||||
return <fieldset className={className}><label htmlFor={this.props.inputId} className="control-label">{this.props.labelText}</label>
|
||||
<StandardFieldComponent inError={inError} error={this.props.error} >{this.props.children}</StandardFieldComponent>
|
||||
</fieldset>;
|
||||
}
|
||||
}
|
||||
|
||||
|
44
javascripts/src/components/common/Root.tsx
Normal file
44
javascripts/src/components/common/Root.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { observer, Provider } from 'mobx-react';
|
||||
import { IntlProvider, addLocaleData} from 'react-intl';
|
||||
const enLocaleData = require('react-intl/locale-data/en');
|
||||
const deLocaleData = require('react-intl/locale-data/de');
|
||||
const I18n = require('i18n')
|
||||
import {convert} from 'dotize'
|
||||
import {ApiManager} from "../../lib/api_manager";
|
||||
import {APIS} from "../../../api";
|
||||
import {CSRFInterceptor} from "../../lib/csrf_interceptor";
|
||||
|
||||
import * as CustomAPIS from "../../lib/apis"
|
||||
|
||||
addLocaleData([...enLocaleData, ...deLocaleData])
|
||||
|
||||
interface RootProps
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
@observer
|
||||
export default class Root extends React.Component<RootProps, {}> {
|
||||
|
||||
apiManager: ApiManager
|
||||
|
||||
render() {
|
||||
if (!this.apiManager){
|
||||
this.apiManager = new ApiManager(APIS.concat(CustomAPIS.APIS as Array<any>), CSRFInterceptor)
|
||||
}
|
||||
|
||||
return <IntlProvider locale={I18n.locale} defaultLocale={I18n.defaultLocale} messages={convert(I18n.translations[I18n.locale])}>
|
||||
<Provider ApiManager={this.apiManager}>
|
||||
{this.props.children}
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import {shallow,render} from 'enzyme'
|
||||
const TestRenderer = require('react-test-renderer')
|
||||
import StandardFieldComponent from './StandardFieldComponent'
|
||||
import toJson from 'enzyme-to-json';
|
||||
|
||||
describe('StandardFieldComponent', () => {
|
||||
test('works with no children', () => {
|
||||
var field = shallow(<StandardFieldComponent inError={false} />)
|
||||
|
||||
|
||||
expect(toJson(field)).toMatchSnapshot()
|
||||
})
|
||||
test('works with a child', () => {
|
||||
var field = shallow(<StandardFieldComponent inError={false}><input/></StandardFieldComponent>);
|
||||
|
||||
expect(toJson(field)).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('sets error message properly', () => {
|
||||
var field = shallow(<StandardFieldComponent inError={true} error={"Something more"}><input/></StandardFieldComponent>);
|
||||
|
||||
expect(toJson(field)).toMatchSnapshot()
|
||||
})
|
||||
})
|
35
javascripts/src/components/common/StandardFieldComponent.tsx
Normal file
35
javascripts/src/components/common/StandardFieldComponent.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
|
||||
export interface StandardFieldComponentProps
|
||||
{
|
||||
inError:boolean
|
||||
error?:string
|
||||
children?:React.ReactNode
|
||||
[additional_properties:string]: any
|
||||
}
|
||||
|
||||
export default class StandardFieldComponent extends React.Component<StandardFieldComponentProps, {}> {
|
||||
constructor(props:StandardFieldComponentProps){
|
||||
super(props)
|
||||
}
|
||||
renderChildren(){
|
||||
return React.Children.map(this.props.children, child => {
|
||||
return React.cloneElement(child as React.ReactElement<any>, {
|
||||
className: "form-control"
|
||||
})
|
||||
})
|
||||
}
|
||||
render() {
|
||||
let errorMessage = this.props.inError ? this.props.error : undefined
|
||||
let errorDiv = this.props.inError? <div className="help-block" role="alert">{errorMessage}</div> : ""
|
||||
|
||||
return <div>
|
||||
{this.renderChildren()}
|
||||
{errorDiv}
|
||||
</div>
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`LabeledFieldComponent In Error with Children 1`] = `
|
||||
<fieldset
|
||||
className=" form-group has-error"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="ID"
|
||||
>
|
||||
Our Label
|
||||
</label>
|
||||
<StandardFieldComponent
|
||||
error="errorMessage"
|
||||
inError={true}
|
||||
>
|
||||
<hr />
|
||||
</StandardFieldComponent>
|
||||
</fieldset>
|
||||
`;
|
||||
|
||||
exports[`LabeledFieldComponent add extra classNames 1`] = `
|
||||
<fieldset
|
||||
className="a_class another_class form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="ID"
|
||||
>
|
||||
Our Label
|
||||
</label>
|
||||
<StandardFieldComponent
|
||||
inError={false}
|
||||
>
|
||||
<hr />
|
||||
</StandardFieldComponent>
|
||||
</fieldset>
|
||||
`;
|
||||
|
||||
exports[`LabeledFieldComponent has error checked but no message so not really in error 1`] = `
|
||||
<fieldset
|
||||
className=" form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="ID"
|
||||
>
|
||||
Our Label
|
||||
</label>
|
||||
<StandardFieldComponent
|
||||
error={null}
|
||||
inError={false}
|
||||
>
|
||||
<hr />
|
||||
</StandardFieldComponent>
|
||||
</fieldset>
|
||||
`;
|
||||
|
||||
exports[`LabeledFieldComponent no error 1`] = `
|
||||
<fieldset
|
||||
className=" form-group"
|
||||
>
|
||||
<label
|
||||
className="control-label"
|
||||
htmlFor="ID"
|
||||
>
|
||||
Our Label
|
||||
</label>
|
||||
<StandardFieldComponent
|
||||
inError={false}
|
||||
>
|
||||
<hr />
|
||||
</StandardFieldComponent>
|
||||
</fieldset>
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`StandardFieldComponent sets error message properly 1`] = `
|
||||
<div>
|
||||
<input
|
||||
className="form-control"
|
||||
key=".0"
|
||||
/>
|
||||
<div
|
||||
className="help-block"
|
||||
role="alert"
|
||||
>
|
||||
Something more
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`StandardFieldComponent works with a child 1`] = `
|
||||
<div>
|
||||
<input
|
||||
className="form-control"
|
||||
key=".0"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`StandardFieldComponent works with no children 1`] = `<div />`;
|
16
javascripts/src/components/common/fields.tsx
Normal file
16
javascripts/src/components/common/fields.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import {observer} from "mobx-react";
|
||||
import * as _ from 'lodash'
|
||||
import {Field} from "../../../../types/mobx-react-form";
|
||||
import LabeledFieldComponent from "./LabeledFieldComponent";
|
||||
import {injectIntl, InjectedIntl} from 'react-intl';
|
||||
|
||||
|
||||
export const BasicField = injectIntl(observer((props:{field:Field, intl?:InjectedIntl, wrapperClassName?:string}) =>{
|
||||
return <LabeledFieldComponent
|
||||
inputId={props.field.id} labelText={props.field.label} inError={props.field.hasError} error={props.field.error} className={props.wrapperClassName} >
|
||||
|
||||
<input {...props.field.bind()} className="form-control"/>
|
||||
</LabeledFieldComponent>
|
||||
}))
|
38
javascripts/src/components/common/layout.tsx
Normal file
38
javascripts/src/components/common/layout.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import {observer} from "mobx-react";
|
||||
import * as _ from 'lodash'
|
||||
|
||||
export const TwoColumnFields = observer((props:{children:Array<React.ReactElement<any>>}) => {
|
||||
return <div className="clearfix">
|
||||
{
|
||||
_.take(props.children, 2).map((i:React.ReactElement<any>) => {
|
||||
let className = "col-left-6"
|
||||
if (_.last(props.children) !== i){
|
||||
className += " u-paddingRight--10"
|
||||
}
|
||||
if (i.props['className']){
|
||||
className += i.props['className']
|
||||
}
|
||||
|
||||
return React.cloneElement(i, {wrapperClassName: className})
|
||||
})}
|
||||
</div>
|
||||
})
|
||||
|
||||
export const ThreeColumnFields = observer((props:{children:React.ReactElement<any>[]}) => {
|
||||
return <div className="clearfix">
|
||||
{
|
||||
_.take(props.children, 3).map((i:React.ReactElement<any>) => {
|
||||
let className = "col-left-4"
|
||||
if (_.last(props.children) !== i){
|
||||
className += " u-paddingRight--10"
|
||||
}
|
||||
if (i.props['className']){
|
||||
className += i.props['className']
|
||||
}
|
||||
|
||||
return React.cloneElement(i, {wrapperClassName: className})
|
||||
})}
|
||||
</div>
|
||||
})
|
53
javascripts/src/components/common/wizard/ManagedWrapper.ts
Normal file
53
javascripts/src/components/common/wizard/ManagedWrapper.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react'
|
||||
import * as RAT from "react-aria-tabpanel";
|
||||
import {TabManager} from "./manager";
|
||||
var PropTypes = require('prop-types');
|
||||
|
||||
var innerCreateManager = require('react-aria-tabpanel/lib/createManager');
|
||||
var specialAssign = require('react-aria-tabpanel/lib/specialAssign');
|
||||
|
||||
interface AddManagerInterface {
|
||||
manager?: TabManager
|
||||
}
|
||||
|
||||
|
||||
var checkedProps = {
|
||||
children: PropTypes.node.isRequired,
|
||||
activeTabId: PropTypes.string,
|
||||
letterNavigation: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
tag: PropTypes.string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Works just like the normal Wrapper but provides a tool for passing in our own TabManager
|
||||
*/
|
||||
export class ManagedWrapper extends RAT.Wrapper<AddManagerInterface>
|
||||
{
|
||||
manager: TabManager
|
||||
constructor(props:RAT.WrapperProps & AddManagerInterface){
|
||||
super(props)
|
||||
|
||||
if (props.manager)
|
||||
this.manager = this.props.manager
|
||||
}
|
||||
|
||||
componentWillMount(){
|
||||
|
||||
console.log('seomte')
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
var props = this.props;
|
||||
var elProps = {};
|
||||
specialAssign(elProps, props, checkedProps);
|
||||
return React.createElement(props.tag, elProps, props.children);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
111
javascripts/src/components/common/wizard/Wizard.spec.tsx
Normal file
111
javascripts/src/components/common/wizard/Wizard.spec.tsx
Normal file
|
@ -0,0 +1,111 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import * as Component from './Wizard'
|
||||
import {WizardState, WizardTabPanelState} from "./wizard_state";
|
||||
import {Form} from "mobx-react-form";
|
||||
import {computed, observable, action} from 'mobx';
|
||||
import {Wizard} from "./Wizard";
|
||||
import {shallow} from 'enzyme';
|
||||
import {WizardPanel} from "./WizardPanel";
|
||||
import toJson from 'enzyme-to-json';
|
||||
|
||||
class MockableTabPanelState extends WizardTabPanelState
|
||||
{
|
||||
@observable
|
||||
customIsValid: boolean
|
||||
|
||||
@action.bound
|
||||
setValid(validity:boolean){
|
||||
this.customIsValid = validity;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isValid():boolean {
|
||||
return this.customIsValid
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EasyWizardState extends WizardState{
|
||||
constructor(){
|
||||
super(MockableTabPanelState)
|
||||
}
|
||||
createForm(i: any): Form {
|
||||
return new Form(i)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
describe('Wizard', () => {
|
||||
let data =
|
||||
{
|
||||
tab1: {
|
||||
tabName: "Tab1",
|
||||
label: "Label1",
|
||||
subFormDef: {extra: "nothing" }
|
||||
},
|
||||
tab2: {
|
||||
tabName: "Tab2",
|
||||
label: "Label2",
|
||||
subFormDef: {extra: "not" }
|
||||
},
|
||||
tab3: {
|
||||
tabName: "Tab3",
|
||||
label: "Label3",
|
||||
subFormDef: {extra: "no3t" }
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
|
||||
let state:EasyWizardState = null
|
||||
let tab1: MockableTabPanelState, tab2: MockableTabPanelState, tab3 : MockableTabPanelState = null
|
||||
|
||||
beforeEach(() => {
|
||||
state = new EasyWizardState()
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.addTab(data.tab3.tabName, data.tab3.label, data.tab3.subFormDef)
|
||||
state.initialize()
|
||||
tab1 = (state.tabsByName[data.tab1.tabName] as MockableTabPanelState)
|
||||
tab1.setValid(true)
|
||||
tab2 = (state.tabsByName[data.tab2.tabName] as MockableTabPanelState)
|
||||
tab2.setValid(true)
|
||||
tab3 = (state.tabsByName[data.tab3.tabName] as MockableTabPanelState)
|
||||
})
|
||||
|
||||
function createWizard(disabledTabs:boolean) {
|
||||
return <Wizard wizardState={state} disableTabs={disabledTabs}>
|
||||
<WizardPanel tab={tab1} />
|
||||
<WizardPanel tab={tab2} />
|
||||
<WizardPanel tab={tab3} />
|
||||
</Wizard>
|
||||
|
||||
}
|
||||
|
||||
test('Mounts the first item only', () => {
|
||||
const tree = shallow(createWizard(false) )
|
||||
let panels = tree.find(WizardPanel)
|
||||
expect(panels.length).toBe(1)
|
||||
expect(panels.first().props().tab).toBe(tab1)
|
||||
})
|
||||
|
||||
test('Mounts the second tab only', () => {
|
||||
state.activateTab(tab2)
|
||||
const tree = shallow(createWizard(false) )
|
||||
let panels = tree.find(WizardPanel)
|
||||
expect(panels.length).toBe(1)
|
||||
expect(panels.first().props().tab).toBe(tab2)
|
||||
})
|
||||
|
||||
test('Mounts the third tab only', () => {
|
||||
state.activateTab(tab2)
|
||||
state.activateTab(tab3)
|
||||
const tree = shallow(createWizard(false) )
|
||||
let panels = tree.find(WizardPanel)
|
||||
expect(panels.length).toBe(1)
|
||||
expect(panels.first().props().tab).toBe(tab3)
|
||||
})
|
||||
|
||||
})
|
43
javascripts/src/components/common/wizard/Wizard.tsx
Normal file
43
javascripts/src/components/common/wizard/Wizard.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import {observer} from "mobx-react"
|
||||
import WizardTabList from "./WizardTabList";
|
||||
import {WizardState} from './wizard_state';
|
||||
import {ManagedWrapper} from "./ManagedWrapper";
|
||||
import {WizardTabPanelProps} from "./WizardPanel";
|
||||
|
||||
export interface WizardProps
|
||||
{
|
||||
|
||||
wizardState: WizardState
|
||||
disableTabs: boolean
|
||||
children: Array<React.ReactElement<WizardTabPanelProps>>
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Wizard extends React.Component<WizardProps, {}> {
|
||||
|
||||
render() {
|
||||
return <ManagedWrapper onChange={this.props.wizardState.handleTabChange}
|
||||
letterNavigation={true}
|
||||
activeTabId={this.props.wizardState.activeTab.id}
|
||||
tag="section"
|
||||
style={{display: 'table'}} className="wizard-steps" manager={this.props.wizardState.manager}>
|
||||
<WizardTabList wizardState={this.props.wizardState} disableTabs={this.props.disableTabs}>
|
||||
</WizardTabList>
|
||||
<div className="modal-body">
|
||||
|
||||
<form onSubmit={this.props.wizardState.form.onSubmit} >
|
||||
|
||||
{this.props.children.filter((i) =>
|
||||
i.props.tab == this.props.wizardState.activeTab
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</ManagedWrapper>;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
|
||||
import * as Component from './WizardPanel'
|
||||
|
||||
import {Form, Field} from 'mobx-react-form';
|
||||
import { shallow, render } from 'enzyme';
|
||||
import {TabPanel, Wrapper} from 'react-aria-tabpanel'
|
||||
import toJson from 'enzyme-to-json';
|
||||
import {WizardState} from "./wizard_state";
|
||||
const TestRenderer = require('react-test-renderer')
|
||||
|
||||
class EasyWizardState extends WizardState{
|
||||
|
||||
createForm(i: any): Form {
|
||||
return new Form(i)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
describe('WizardPanel', () => {
|
||||
test('shallow render', () => {
|
||||
let fields = [{name: 'fun', id: 'fun', label: 'alsofun'}]
|
||||
const form = new Form({fields});
|
||||
|
||||
const ws = new EasyWizardState()
|
||||
ws.addTab('something', 'something label',{} )
|
||||
ws.initialize()
|
||||
|
||||
const tree = shallow(<Component.WizardPanel tab={ws.tabsByName['something']}><hr/></Component.WizardPanel>)
|
||||
|
||||
|
||||
expect(toJson(tree)).toMatchSnapshot()
|
||||
})
|
||||
})
|
36
javascripts/src/components/common/wizard/WizardPanel.tsx
Normal file
36
javascripts/src/components/common/wizard/WizardPanel.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { TabPanel } from "react-aria-tabpanel";
|
||||
import { observer } from 'mobx-react'
|
||||
import { WizardTabPanelState} from './wizard_state';
|
||||
import {computed} from 'mobx';
|
||||
|
||||
|
||||
export interface WizardTabPanelProps {
|
||||
tab: WizardTabPanelState
|
||||
}
|
||||
|
||||
export interface WizardPanelProps extends WizardTabPanelProps {
|
||||
[props:string]:any
|
||||
}
|
||||
|
||||
@observer
|
||||
export class WizardPanel extends React.Component<WizardPanelProps, {}> {
|
||||
@computed
|
||||
get tab():WizardTabPanelState{
|
||||
return this.props.tab
|
||||
}
|
||||
@computed
|
||||
get isActive(){
|
||||
return this.tab.active
|
||||
}
|
||||
render() {
|
||||
|
||||
let props = this.props.props ? this.props.props : {}
|
||||
return <TabPanel tabId={this.tab.id} active={this.isActive}
|
||||
{...props} className="wizard-step">
|
||||
{this.props.children}
|
||||
</TabPanel>
|
||||
}
|
||||
}
|
||||
|
49
javascripts/src/components/common/wizard/WizardTab.spec.tsx
Normal file
49
javascripts/src/components/common/wizard/WizardTab.spec.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import WizardTab from './WizardTab'
|
||||
import { Tab } from 'react-aria-tabpanel';
|
||||
import {shallowWithIntl} from "../../../lib/tests/helpers";
|
||||
|
||||
describe('WizardTab', () => {
|
||||
function runTest(enabled:boolean, active:boolean, widthPercentage:number){
|
||||
let tab = {active:active, enabled: enabled, label: "A label", id: "our_id"}
|
||||
let result = shallowWithIntl(<WizardTab widthPercentage={widthPercentage} tab={tab}/>)
|
||||
|
||||
let ourWrapper = result.find(Tab).first()
|
||||
expect(ourWrapper.prop('id')).toEqual("our_id")
|
||||
let classes = ourWrapper.prop('className').split(' ')
|
||||
expect(classes).toContain("wizard-index-label")
|
||||
|
||||
|
||||
if (active){
|
||||
expect(classes).toContain("is-current")
|
||||
}
|
||||
else{
|
||||
expect(classes).not.toContain("is-current")
|
||||
}
|
||||
|
||||
if (enabled){
|
||||
expect(classes).toContain("is-accessible")
|
||||
}
|
||||
else{
|
||||
expect(classes).not.toContain("is-accessible")
|
||||
}
|
||||
expect(ourWrapper.prop('style').width).toEqual(`${widthPercentage}%`)
|
||||
expect(ourWrapper.prop('tag')).toEqual('span')
|
||||
expect(ourWrapper.childAt(0).text()).toEqual('A label')
|
||||
}
|
||||
|
||||
|
||||
test('inactive, non-current tab displays', () =>{
|
||||
runTest(false, false, 20)
|
||||
})
|
||||
|
||||
test('active, non-current tab displays', () =>{
|
||||
runTest(false, true, 33.33333)
|
||||
})
|
||||
|
||||
test('active, current tab displays', () =>{
|
||||
runTest(true, true, 50)
|
||||
})
|
||||
})
|
47
javascripts/src/components/common/wizard/WizardTab.tsx
Normal file
47
javascripts/src/components/common/wizard/WizardTab.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { Tab } from 'react-aria-tabpanel';
|
||||
import {FormattedMessage, injectIntl, InjectedIntlProps} from 'react-intl';
|
||||
import {observer} from 'mobx-react';
|
||||
import {WizardTabPanelState} from "./wizard_state";
|
||||
|
||||
interface MiniTabInfo{
|
||||
active:boolean
|
||||
enabled:boolean
|
||||
label:string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface WizardTabProps
|
||||
{
|
||||
tab: MiniTabInfo
|
||||
widthPercentage:number
|
||||
style?: any
|
||||
disableTabs?: boolean
|
||||
}
|
||||
|
||||
|
||||
class WizardTab extends React.Component<WizardTabProps & InjectedIntlProps, {}> {
|
||||
render() {
|
||||
let percentageToString = this.props.widthPercentage.toString() + "%"
|
||||
let style= {width: percentageToString}
|
||||
|
||||
|
||||
let className = "wizard-index-label"
|
||||
if (this.props.tab.active){
|
||||
className += " is-current"
|
||||
}
|
||||
let disableOverrideTab = this.props.disableTabs
|
||||
|
||||
if (this.props.tab.enabled || disableOverrideTab){
|
||||
className += " is-accessible"
|
||||
}
|
||||
|
||||
return <Tab tag={'span'} active={this.props.tab.active} className={className} style={style} id={this.props.tab.id}>
|
||||
|
||||
{this.props.intl.formatMessage({id:this.props.tab.label})}
|
||||
</Tab>
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(observer(WizardTab))
|
26
javascripts/src/components/common/wizard/WizardTabList.tsx
Normal file
26
javascripts/src/components/common/wizard/WizardTabList.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import WizardTab from './WizardTab';
|
||||
import { TabList } from 'react-aria-tabpanel';
|
||||
import {observer} from 'mobx-react';
|
||||
import {WizardState} from "./wizard_state";
|
||||
|
||||
|
||||
export interface WizardTabListProps
|
||||
{
|
||||
wizardState: WizardState
|
||||
disableTabs: boolean
|
||||
}
|
||||
|
||||
@observer
|
||||
export default class WizardTabList extends React.Component<WizardTabListProps, {}> {
|
||||
render() {
|
||||
let widthOfTab = 100 / this.props.wizardState.panels.length
|
||||
let output = this.props.wizardState.panels.map((i) => {
|
||||
return <WizardTab tab={i} widthPercentage={widthOfTab} key={i.id + "key"} disableTabs={this.props.disableTabs}></WizardTab>})
|
||||
return <TabList tag={"div"} className="wizard-index">
|
||||
{output}
|
||||
</TabList>;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`WizardPanel shallow render 1`] = `
|
||||
<AriaTabPanel-TabPanel
|
||||
active={true}
|
||||
className="wizard-step"
|
||||
tabId="tab1"
|
||||
tag="div"
|
||||
>
|
||||
<hr />
|
||||
</AriaTabPanel-TabPanel>
|
||||
`;
|
120
javascripts/src/components/common/wizard/manager.ts
Normal file
120
javascripts/src/components/common/wizard/manager.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
// License: ISC
|
||||
// from https://github.com/davidtheclark/react-aria-tabpanel
|
||||
var createFocusGroup = require('focus-group');
|
||||
|
||||
/**
|
||||
* Exact same as normal TabManager but includes a callback for verifying we can actually change to a
|
||||
* new tab
|
||||
*/
|
||||
export class TabManager {
|
||||
options: any
|
||||
focusGroup: any
|
||||
tabs: any
|
||||
activeTabId: any
|
||||
tabPanels: any
|
||||
|
||||
constructor(options: any) {
|
||||
this.options = options;
|
||||
|
||||
var focusGroupOptions = {
|
||||
wrap: true,
|
||||
forwardArrows: ['down', 'right'],
|
||||
backArrows: ['up', 'left'],
|
||||
stringSearch: options.letterNavigation,
|
||||
};
|
||||
|
||||
this.focusGroup = createFocusGroup(focusGroupOptions);
|
||||
|
||||
// These component references are added when the relevant components mount
|
||||
this.tabs = [];
|
||||
this.tabPanels = [];
|
||||
|
||||
this.activeTabId = options.activeTabId;
|
||||
}
|
||||
|
||||
activate() {
|
||||
this.focusGroup.activate();
|
||||
};
|
||||
|
||||
memberStartsActive(tabId: any) {
|
||||
if (this.activeTabId === tabId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.activeTabId === undefined) {
|
||||
this.activeTabId = tabId;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
registerTab(tabMember: any) {
|
||||
if (tabMember.index === undefined) {
|
||||
this.tabs.push(tabMember);
|
||||
} else {
|
||||
this.tabs.splice(tabMember.index, 0, tabMember);
|
||||
}
|
||||
|
||||
var focusGroupMember = (tabMember.letterNavigationText) ? {
|
||||
node: tabMember.node,
|
||||
text: tabMember.letterNavigationText,
|
||||
} : tabMember.node;
|
||||
|
||||
this.focusGroup.addMember(focusGroupMember, tabMember.index);
|
||||
|
||||
this.activateTab(this.activeTabId || tabMember.id);
|
||||
};
|
||||
|
||||
registerTabPanel(tabPanelMember: any) {
|
||||
this.tabPanels.push(tabPanelMember);
|
||||
this.activateTab(this.activeTabId);
|
||||
|
||||
this.activateTab(this.activeTabId || tabPanelMember.tabId);
|
||||
};
|
||||
|
||||
activateTab(nextActiveTabId: any) {
|
||||
if (this.options.canChangeTo) {
|
||||
if (!this.options.canChangeTo(nextActiveTabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (nextActiveTabId === this.activeTabId) return;
|
||||
this.activeTabId = nextActiveTabId;
|
||||
|
||||
if (this.options.onChange) {
|
||||
this.options.onChange(nextActiveTabId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tabPanels.forEach(function (tabPanelMember: any) {
|
||||
tabPanelMember.update(nextActiveTabId === tabPanelMember.tabId);
|
||||
});
|
||||
this.tabs.forEach(function (tabMember: any) {
|
||||
tabMember.update(nextActiveTabId === tabMember.id);
|
||||
});
|
||||
}
|
||||
|
||||
handleTabFocus(focusedTabId: any) {
|
||||
this.activateTab(focusedTabId);
|
||||
};
|
||||
|
||||
focusTab(tabId: any) {
|
||||
var tabMemberToFocus = this.tabs.find(function (tabMember: any) {
|
||||
return tabMember.id === tabId;
|
||||
});
|
||||
if (!tabMemberToFocus) return;
|
||||
tabMemberToFocus.node.focus();
|
||||
};
|
||||
|
||||
destroy() {
|
||||
this.focusGroup.deactivate();
|
||||
};
|
||||
|
||||
getTabPanelId(tabId: any) {
|
||||
return tabId + '-panel';
|
||||
}
|
||||
}
|
253
javascripts/src/components/common/wizard/wizard_state.spec.ts
Normal file
253
javascripts/src/components/common/wizard/wizard_state.spec.ts
Normal file
|
@ -0,0 +1,253 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import 'jest';
|
||||
import {Form} from "mobx-react-form"
|
||||
import {WizardState, WizardTabPanelState} from "./wizard_state";
|
||||
import {computed, observable, action} from 'mobx';
|
||||
class MockableTabPanelState extends WizardTabPanelState
|
||||
{
|
||||
@observable
|
||||
customIsValid: boolean
|
||||
|
||||
@action.bound
|
||||
setValid(validity:boolean){
|
||||
this.customIsValid = validity;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isValid():boolean {
|
||||
return this.customIsValid
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EasyWizardState extends WizardState{
|
||||
constructor(){
|
||||
super(MockableTabPanelState)
|
||||
}
|
||||
createForm(i: any): Form {
|
||||
return new Form(i)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
describe("WizardState", () =>{
|
||||
let data =
|
||||
{
|
||||
tab1: {
|
||||
tabName: "Tab1",
|
||||
label: "Label1",
|
||||
subFormDef: {extra: "nothing" }
|
||||
},
|
||||
tab2: {
|
||||
tabName: "Tab2",
|
||||
label: "Label2",
|
||||
subFormDef: {extra: "not" }
|
||||
},
|
||||
tab3: {
|
||||
tabName: "Tab3",
|
||||
label: "Label3",
|
||||
subFormDef: {extra: "no3t" }
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
it('adds tab properly', () =>{
|
||||
let state = new EasyWizardState()
|
||||
|
||||
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
state.initialize()
|
||||
|
||||
let tab = state.tabsByName[data.tab1.tabName]
|
||||
expect(tab.tabName).toBe(data.tab1.tabName)
|
||||
expect(tab.label).toBe(data.tab1.label)
|
||||
expect(tab.form.extra).toBe(data.tab1.subFormDef.extra)
|
||||
expect(tab.enabled).toBe(true)
|
||||
expect(tab.previous).toBe(null)
|
||||
expect(tab.next).toBe(null)
|
||||
})
|
||||
|
||||
it('prevents going to next if next isnt enabled', () =>{
|
||||
let state = new EasyWizardState()
|
||||
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.initialize()
|
||||
|
||||
expect(state.activeTab).toBe(state.tabsByName[data.tab1.tabName])
|
||||
state.activateTab(state.tabsByName[data.tab2.tabName].id)
|
||||
expect(state.activeTab).toBe(state.tabsByName[data.tab1.tabName])
|
||||
expect(state.tabsByName[data.tab2.tabName].active).toBeFalsy()
|
||||
})
|
||||
|
||||
describe('go to next and back', () => {
|
||||
let state = new EasyWizardState()
|
||||
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.addTab(data.tab3.tabName, data.tab3.label, data.tab3.subFormDef)
|
||||
state.initialize()
|
||||
|
||||
let tab1 = (state.tabsByName[data.tab1.tabName] as MockableTabPanelState)
|
||||
tab1.setValid(true)
|
||||
let tab2 = (state.tabsByName[data.tab2.tabName] as MockableTabPanelState)
|
||||
tab2.setValid(true)
|
||||
let tab3 = state.tabsByName[data.tab3.tabName]
|
||||
it('go to next', () =>{
|
||||
expect(state.nextTab).toBe(tab2)
|
||||
expect(state.previousTab).toBeNull()
|
||||
|
||||
state.moveToNextTab()
|
||||
|
||||
expect(state.activeTab).toBe(tab2)
|
||||
expect(state.manager.activeTabId).toBe(tab2.id)
|
||||
expect(state.previousTab).toBe(tab1)
|
||||
expect(state.nextTab).toBe(tab3)
|
||||
|
||||
expect(tab1.active).toBeFalsy()
|
||||
expect(tab3.active).toBeFalsy()
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('handle moving back to tabs when one is disabled', () => {
|
||||
|
||||
let state:EasyWizardState = null
|
||||
let tab1: MockableTabPanelState, tab2: MockableTabPanelState, tab3 : MockableTabPanelState = null
|
||||
|
||||
beforeEach(() => {
|
||||
state = new EasyWizardState()
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.addTab(data.tab3.tabName, data.tab3.label, data.tab3.subFormDef)
|
||||
state.initialize()
|
||||
tab1 = (state.tabsByName[data.tab1.tabName] as MockableTabPanelState)
|
||||
tab1.setValid(true)
|
||||
tab2 = (state.tabsByName[data.tab2.tabName] as MockableTabPanelState)
|
||||
tab2.setValid(true)
|
||||
tab3 = (state.tabsByName[data.tab3.tabName]as MockableTabPanelState)
|
||||
})
|
||||
|
||||
it ('move back to previous tab if the current one is disabled', () =>{
|
||||
|
||||
state.activateTab(tab3)
|
||||
|
||||
expect(tab3.active).toBe(true)
|
||||
expect(state.manager.activeTabId).toBe(tab3.id)
|
||||
expect(tab1.active).toBe(false)
|
||||
expect(tab2.active).toBe(false)
|
||||
tab2.setValid(false)
|
||||
|
||||
expect(tab3.active).toBe(false)
|
||||
expect(tab2.active).toBe(true)
|
||||
expect(state.activeTab).toBe(tab2)
|
||||
expect(state.manager.activeTabId).toBe(tab2.id)
|
||||
|
||||
})
|
||||
|
||||
it ('move back to first tab if all but that one is disabled', () =>{
|
||||
state.activateTab(tab3)
|
||||
|
||||
expect(tab3.active).toBe(true)
|
||||
expect(state.manager.activeTabId).toBe(tab3.id)
|
||||
tab2.setValid(false)
|
||||
tab1.setValid(false)
|
||||
|
||||
expect(tab3.active).toBe(false)
|
||||
expect(tab2.active).toBe(false)
|
||||
expect(tab1.active).toBe(true)
|
||||
expect(state.activeTab).toBe(tab1)
|
||||
expect(state.manager.activeTabId).toBe(tab1.id)
|
||||
|
||||
tab1.setValid(true)
|
||||
expect(state.activeTab).toBe(tab1)
|
||||
expect(state.manager.activeTabId).toBe(tab1.id)
|
||||
|
||||
tab1.setValid(false)
|
||||
expect(state.activeTab).toBe(tab1)
|
||||
expect(state.manager.activeTabId).toBe(tab1.id)
|
||||
|
||||
state.moveToNextTab()
|
||||
expect(state.activeTab).toBe(tab1)
|
||||
expect(state.manager.activeTabId).toBe(tab1.id)
|
||||
|
||||
tab2.setValid(true)
|
||||
expect(state.activeTab).toBe(tab1)
|
||||
expect(state.manager.activeTabId).toBe(tab1.id)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
describe("before works properly", () =>{
|
||||
let state:EasyWizardState = null
|
||||
let tab1: MockableTabPanelState, tab2: MockableTabPanelState, tab3 : MockableTabPanelState = null
|
||||
beforeEach(() => {
|
||||
state = new EasyWizardState()
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
|
||||
tab1 = (state.tabsByName[data.tab1.tabName] as MockableTabPanelState)
|
||||
})
|
||||
|
||||
it("handles before when nothing else in it",() =>{
|
||||
|
||||
let tab2 = new MockableTabPanelState()
|
||||
state.initialize()
|
||||
expect(tab1.before(tab2)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("handles before before When nothing else in it",() =>{
|
||||
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.initialize();
|
||||
tab2 = (state.tabsByName[data.tab2.tabName] as MockableTabPanelState)
|
||||
expect(tab1.before(tab2)).toBeTruthy()
|
||||
expect(tab2.before(tab1)).toBeFalsy()
|
||||
tab3 = new MockableTabPanelState()
|
||||
|
||||
expect(tab2.before(tab3)).toBeFalsy()
|
||||
expect(tab3.before(tab2)).toBeFalsy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("after works properly", () =>{
|
||||
let state:EasyWizardState = null
|
||||
let tab1: MockableTabPanelState, tab2: MockableTabPanelState, tab3 : MockableTabPanelState = null
|
||||
beforeEach(() => {
|
||||
state = new EasyWizardState()
|
||||
state.addTab(data.tab1.tabName, data.tab1.label, data.tab1.subFormDef)
|
||||
|
||||
tab1 = (state.tabsByName[data.tab1.tabName] as MockableTabPanelState)
|
||||
})
|
||||
|
||||
it("handles before when nothing else in it",() =>{
|
||||
|
||||
let tab2 = new MockableTabPanelState()
|
||||
state.initialize()
|
||||
expect(tab1.after(tab2)).toBeFalsy()
|
||||
expect(tab2.after(tab1)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("handles before before When nothing else in it",() =>{
|
||||
|
||||
state.addTab(data.tab2.tabName, data.tab2.label, data.tab2.subFormDef)
|
||||
state.initialize();
|
||||
tab2 = (state.tabsByName[data.tab2.tabName] as MockableTabPanelState)
|
||||
expect(tab2.after(tab1)).toBeTruthy()
|
||||
expect(tab1.after(tab2)).toBeFalsy()
|
||||
tab3 = new MockableTabPanelState()
|
||||
|
||||
expect(tab2.before(tab3)).toBeFalsy()
|
||||
expect(tab3.before(tab2)).toBeFalsy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
310
javascripts/src/components/common/wizard/wizard_state.ts
Normal file
310
javascripts/src/components/common/wizard/wizard_state.ts
Normal file
|
@ -0,0 +1,310 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import {observable, action, computed, toJS, reaction, runInAction} from "mobx";
|
||||
import {Field, Form, FieldDefinition, FieldHandlers, FieldHooks} from "mobx-react-form";
|
||||
import _ = require("lodash");
|
||||
import {TabManager} from "./manager"
|
||||
import {Wizard} from "./Wizard";
|
||||
|
||||
interface SubFormDefinition {
|
||||
related?: string[]
|
||||
bindings?: any
|
||||
options?: any
|
||||
extra?: any
|
||||
hooks?: FieldHooks
|
||||
handlers?: FieldHandlers
|
||||
fields?: Array<FieldDefinition>
|
||||
}
|
||||
|
||||
export abstract class WizardState<PanelStateType extends WizardTabPanelState = WizardTabPanelState> {
|
||||
panelType: { new(): PanelStateType }
|
||||
|
||||
constructor(panelType: { new(): PanelStateType } = null) {
|
||||
this.panelType = panelType
|
||||
}
|
||||
|
||||
@observable
|
||||
lastRequestedTab: WizardTabPanelState
|
||||
|
||||
@observable panels = new Array<WizardTabPanelState>()
|
||||
@observable form: Form
|
||||
|
||||
@observable manager: TabManager
|
||||
|
||||
abstract createForm(i: any): Form;
|
||||
|
||||
@action.bound
|
||||
private createChildState(): WizardTabPanelState {
|
||||
if (this.panelType)
|
||||
return new this.panelType()
|
||||
else
|
||||
return new WizardTabPanelState()
|
||||
}
|
||||
|
||||
@action.bound
|
||||
addTab(tabName: string, label: string, tabFieldDefinition: SubFormDefinition): WizardTabPanelState {
|
||||
|
||||
var newTab = this.createChildState()
|
||||
newTab.id = _.uniqueId('tab')
|
||||
newTab.tabName = tabName
|
||||
newTab.label = label
|
||||
if (this.panels.length == 0) {
|
||||
this.activeTab = newTab
|
||||
}
|
||||
newTab.parent = this
|
||||
newTab.panelFormDefinition = tabFieldDefinition as FieldDefinition
|
||||
this.panels.push(newTab)
|
||||
|
||||
if (!this.manager) {
|
||||
this.manager = new TabManager({
|
||||
onChange: this.handleTabChange, letterNavigation: true, activeTabId: this.activeTab.id,
|
||||
canChangeTo: this.canChangeTo
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
reaction(() => this.lastConsistentlyEnabledTab, (data, react) => {
|
||||
if (data.before(this.activeTab)) {
|
||||
this.activateTab(data)
|
||||
}
|
||||
})
|
||||
return newTab;
|
||||
}
|
||||
|
||||
@action.bound
|
||||
initialize(): void {
|
||||
if (this.panels.length > 0) {
|
||||
//let's create the forms
|
||||
let lastIndex = this.panels.length
|
||||
for (let i = 0; i < lastIndex; i++) {
|
||||
let ourPanel = this.panels[i]
|
||||
|
||||
if (!ourPanel.panelFormDefinition.hooks)
|
||||
ourPanel.panelFormDefinition.hooks = {}
|
||||
|
||||
ourPanel.originalOnSuccessHook = toJS(ourPanel.panelFormDefinition.hooks['onSuccess'])
|
||||
|
||||
ourPanel.panelFormDefinition.hooks['onSuccess'] = this.onSuccessForPanel
|
||||
|
||||
/// this won't work because the hook is already replaced
|
||||
if (ourPanel.panelFormDefinition.hooks)
|
||||
ourPanel.originalOnErrorHook = ourPanel.panelFormDefinition.hooks['onError']
|
||||
ourPanel.panelFormDefinition.hooks['onError'] = this.onErrorForPanel
|
||||
|
||||
ourPanel.panelFormDefinition.name = ourPanel.tabName
|
||||
}
|
||||
|
||||
//we need to change these back to JS objects because they're likely observable and fieldDefinitions
|
||||
// can't handle that
|
||||
let fieldDefinition = toJS(this.panels.map((i) => toJS(i.panelFormDefinition)))
|
||||
this.form = this.createForm({fields: fieldDefinition})
|
||||
|
||||
_.forEach(this.panels, (i) => {
|
||||
//add the form to each panel
|
||||
i.parentForm = this.form
|
||||
i.form = this.form.$(i.tabName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@computed
|
||||
get tabsByName(): { [name: string]: WizardTabPanelState } {
|
||||
return _.fromPairs(this.panels.map((i) => [i.tabName, i]));
|
||||
}
|
||||
|
||||
@observable activeTab: WizardTabPanelState
|
||||
|
||||
activateTab(tab: WizardTabPanelState | string) {
|
||||
let tabId: string = null
|
||||
if (tab instanceof WizardTabPanelState) {
|
||||
tabId = tab.id
|
||||
}
|
||||
else {
|
||||
tabId = tab;
|
||||
}
|
||||
|
||||
this.manager.activateTab(tabId)
|
||||
}
|
||||
|
||||
@action.bound
|
||||
handleTabChange(tabId: string): WizardTabPanelState {
|
||||
let self = this
|
||||
|
||||
let tabInfo = _.find(self.panels, (i) => i.id == tabId)
|
||||
if (tabInfo && tabInfo.enabled) {
|
||||
this.activeTab = tabInfo
|
||||
|
||||
return self.activeTab === tabInfo ? tabInfo : null;
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@action.bound
|
||||
private canChangeTo(tabId: string): boolean {
|
||||
|
||||
let tab = _.find(this.panels, (i) => i.id == tabId)
|
||||
return tab && tab.enabled
|
||||
|
||||
}
|
||||
|
||||
@action.bound
|
||||
moveToNextTab() {
|
||||
let self = this
|
||||
|
||||
if (this.nextTab) {
|
||||
self.manager.activateTab(this.nextTab.id)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@action.bound
|
||||
onSuccessForPanel(a: Field): void {
|
||||
|
||||
if (this.activeTab.originalOnSuccessHook) {
|
||||
this.activeTab.originalOnSuccessHook(a)
|
||||
}
|
||||
|
||||
if (a.submitting) {
|
||||
if (this.nextTab)
|
||||
this.moveToNextTab()
|
||||
else
|
||||
this.form.submit()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@action.bound
|
||||
onErrorForPanel(a: Field): any {
|
||||
if (this.activeTab.originalOnErrorHook) {
|
||||
this.activeTab.originalOnErrorHook(a)
|
||||
}
|
||||
}
|
||||
|
||||
@computed
|
||||
get firstDisabledTab(): WizardTabPanelState {
|
||||
return _.find(this.panels, (i) => !i.enabled)
|
||||
}
|
||||
|
||||
@computed
|
||||
get lastConsistentlyEnabledTab(): WizardTabPanelState {
|
||||
return this.firstDisabledTab ? this.firstDisabledTab.previous : _.last(this.panels)
|
||||
}
|
||||
|
||||
|
||||
@computed
|
||||
get nextTab(): WizardTabPanelState {
|
||||
return this.activeTab.next
|
||||
}
|
||||
|
||||
@computed
|
||||
get previousTab(): WizardTabPanelState {
|
||||
return this.activeTab.previous
|
||||
}
|
||||
}
|
||||
|
||||
export class WizardTabPanelState {
|
||||
@observable parent: WizardState
|
||||
|
||||
@observable parentForm: Form
|
||||
|
||||
@observable form: Field
|
||||
|
||||
|
||||
@observable id: string
|
||||
@observable tabName: string
|
||||
@observable label: string
|
||||
|
||||
@observable originalOnSuccessHook: Function
|
||||
@observable originalOnErrorHook: Function
|
||||
|
||||
panelFormDefinition: FieldDefinition
|
||||
|
||||
/**
|
||||
* Whether this tab's form is valid. We override this in a mock so we can manually set the validity
|
||||
* via a simple function call
|
||||
* @returns {boolean} true if this tab's form is valid, otherwise false
|
||||
*/
|
||||
@computed
|
||||
get isValid(): boolean {
|
||||
return this.form.isValid
|
||||
}
|
||||
|
||||
@computed
|
||||
get active(): boolean {
|
||||
return this.parent.activeTab === this
|
||||
}
|
||||
|
||||
@computed
|
||||
get enabled(): boolean {
|
||||
|
||||
let previous = this.previous
|
||||
let next = this.next
|
||||
|
||||
if (previous) {
|
||||
let enabled = previous.enabled
|
||||
let valid = previous.isValid;
|
||||
return enabled && valid
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@computed
|
||||
get previous(): WizardTabPanelState {
|
||||
if (!this.parent || !this.parent.panels)
|
||||
return null;
|
||||
let index = _.findIndex(this.parent.panels, (i) => i == this)
|
||||
if (index === null) {
|
||||
// return null but we have a problem here
|
||||
return null
|
||||
}
|
||||
if (index === 0) {
|
||||
// there is no previous one because we're first!
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parent.panels[index - 1]
|
||||
}
|
||||
|
||||
@computed
|
||||
get next(): WizardTabPanelState {
|
||||
if (!this.parent || !this.parent.panels)
|
||||
return null;
|
||||
|
||||
let index = _.findIndex(this.parent.panels, (i) => i == this)
|
||||
let panelLength = this.parent.panels.length
|
||||
if (index === null) {
|
||||
// return null but we have a problem here
|
||||
return null
|
||||
}
|
||||
|
||||
if (index + 1 >= panelLength) {
|
||||
//we have no advanced
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.parent.panels[index + 1]
|
||||
}
|
||||
|
||||
before(tab: WizardTabPanelState): boolean {
|
||||
let testItem: WizardTabPanelState = this
|
||||
while (testItem.next != tab) {
|
||||
if (!testItem.next)
|
||||
return false;
|
||||
testItem = testItem.next
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
after(tab: WizardTabPanelState): boolean {
|
||||
let testItem: WizardTabPanelState = this
|
||||
while (testItem.previous != tab) {
|
||||
if (!testItem.previous)
|
||||
return false;
|
||||
testItem = testItem.previous
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
jest.useFakeTimers()
|
||||
import NonprofitInfoForm, * as NIF from './NonprofitInfoForm'
|
||||
import {runInAction} from 'mobx'
|
||||
import {Field, FieldDefinition, Form} from "mobx-react-form";
|
||||
import {mount, ReactWrapper} from 'enzyme'
|
||||
import * as Component from "./NonprofitInfoPanel";
|
||||
import {mountWithIntl} from "../../lib/tests/helpers";
|
||||
import {HoudiniForm} from "../../lib/houdini_form";
|
||||
|
||||
|
||||
function createSubFormInitialization(name:string, subfieldDefinitions:Array<FieldDefinition>): Array<FieldDefinition>{
|
||||
let ret: FieldDefinition = {
|
||||
name: name,
|
||||
fields: subfieldDefinitions
|
||||
}
|
||||
return [ret]
|
||||
}
|
||||
describe('NonprofitInfoForm', () => {
|
||||
let outerForm:HoudiniForm
|
||||
let form:Field
|
||||
let wrapper: ReactWrapper
|
||||
|
||||
test('pointless test for this to pass', () => {})
|
||||
// beforeEach(() => {
|
||||
// outerForm = new HoudiniForm({fields: createSubFormInitialization('none', NIF.FieldDefinitions)}, {
|
||||
// validateOnInit: true,
|
||||
// validateOnChange: true,
|
||||
// retrieveOnlyDirtyValues: true,
|
||||
// retrieveOnlyEnabledFields: true
|
||||
// });
|
||||
// form = outerForm.$('none')
|
||||
// })
|
||||
// afterEach(() => {
|
||||
// wrapper.detach();
|
||||
// })
|
||||
// test('validations', async () => {
|
||||
// wrapper = mountWithIntl(<NonprofitInfoForm form={form} buttonText={"none.none"}/>)
|
||||
// let organization_name = form.$('organization_name')
|
||||
// let city = form.$('city')
|
||||
// let state = form.$('state')
|
||||
//
|
||||
// try {
|
||||
// //await organization_name.validate()
|
||||
// }
|
||||
// catch(e){
|
||||
// console.log(e)
|
||||
// }
|
||||
// wrapper.find(`#${organization_name.id}`).simulate('focus')
|
||||
// wrapper.find(`#${organization_name.id}`).simulate('blur')
|
||||
// wrapper.find(`#${state.id}`).simulate('click')
|
||||
// organization_name.focus()
|
||||
// state.focus()
|
||||
//
|
||||
// //jest.runTimersToTime(100000);
|
||||
// try {
|
||||
// await organization_name.validate()
|
||||
// }
|
||||
// catch(e){
|
||||
// console.log(e)
|
||||
// }
|
||||
// expect(organization_name.error).toBe(false)
|
||||
// expect(state.hasError).toBe(true)
|
||||
// expect(city.hasError).toBe(true)
|
||||
//
|
||||
//
|
||||
// console.log(wrapper.html())
|
||||
// })
|
||||
})
|
|
@ -0,0 +1,83 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import {InjectedIntlProps, injectIntl} from 'react-intl';
|
||||
import {Field, FieldDefinition} from "../../../../types/mobx-react-form";
|
||||
import {BasicField} from "../common/fields";
|
||||
import {ThreeColumnFields, TwoColumnFields} from "../common/layout";
|
||||
import {Validations} from "../../lib/vjf_rules";
|
||||
|
||||
export interface NonprofitInfoFormProps
|
||||
{
|
||||
form:Field
|
||||
buttonText:string
|
||||
}
|
||||
|
||||
export const FieldDefinitions : Array<FieldDefinition> = [
|
||||
{
|
||||
name: 'organization_name',
|
||||
label: 'registration.wizard.nonprofit.name',
|
||||
type: 'text',
|
||||
validators: [Validations.isFilled]
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
label: 'registration.wizard.nonprofit.website',
|
||||
validators: [Validations.optional(Validations.isUrl)]
|
||||
},
|
||||
{
|
||||
name: 'org_email',
|
||||
label: 'registration.wizard.nonprofit.email',
|
||||
validators: [Validations.optional(Validations.isEmail)]
|
||||
},
|
||||
{
|
||||
name: 'org_phone',
|
||||
label: 'registration.wizard.nonprofit.phone',
|
||||
type: "tel"
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
label: 'registration.wizard.nonprofit.city',
|
||||
validators: [Validations.isFilled]
|
||||
},
|
||||
{
|
||||
name: 'state',
|
||||
label: 'registration.wizard.nonprofit.state',
|
||||
type: 'text',
|
||||
validators: [Validations.isFilled]
|
||||
|
||||
},
|
||||
{
|
||||
name: 'zip',
|
||||
label: 'registration.wizard.nonprofit.zip',
|
||||
validators: [Validations.isFilled]
|
||||
}
|
||||
]
|
||||
|
||||
class NonprofitInfoForm extends React.Component<NonprofitInfoFormProps & InjectedIntlProps, {}> {
|
||||
|
||||
|
||||
render() {
|
||||
return <fieldset >
|
||||
<BasicField field={this.props.form.$("organization_name")}/>
|
||||
<BasicField field={this.props.form.$('website')}/>
|
||||
<TwoColumnFields>
|
||||
<BasicField field={this.props.form.$('org_email')}/>
|
||||
<BasicField field={this.props.form.$('org_phone')}/>
|
||||
</TwoColumnFields>
|
||||
|
||||
<ThreeColumnFields>
|
||||
<BasicField field={this.props.form.$('city')}/>
|
||||
<BasicField field={this.props.form.$('state')}/>
|
||||
<BasicField field={this.props.form.$('zip')}/>
|
||||
</ThreeColumnFields>
|
||||
<button onClick={this.props.form.onSubmit} className="button" disabled={!this.props.form.isValid || this.props.form.submitting}>
|
||||
{this.props.intl.formatMessage({id: this.props.buttonText})}</button>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(observer(NonprofitInfoForm))
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import {WizardPanel, WizardPanelProps, WizardTabPanelProps} from "../common/wizard/WizardPanel";
|
||||
|
||||
import {Form, Field, FieldHooks} from 'mobx-react-form'
|
||||
|
||||
import { inject, observer } from 'mobx-react';
|
||||
import { action, computed } from 'mobx';
|
||||
import {WizardState, WizardTabPanelState} from "../common/wizard/wizard_state";
|
||||
import {InjectedIntlProps, injectIntl, InjectedIntl} from 'react-intl';
|
||||
import {ThreeColumnFields, TwoColumnFields} from "../common/layout";
|
||||
|
||||
import {BasicField} from "../common/fields";
|
||||
import {Validations} from "../../lib/vjf_rules";
|
||||
import NonprofitInfoForm from "./NonprofitInfoForm";
|
||||
|
||||
export interface NonprofitInfoPanelProps extends WizardTabPanelProps
|
||||
{
|
||||
buttonText:string
|
||||
}
|
||||
|
||||
class NonprofitInfoPanel extends React.Component<NonprofitInfoPanelProps & InjectedIntlProps, {}> {
|
||||
|
||||
@computed
|
||||
get wizardTab(): WizardTabPanelState {
|
||||
return this.props.tab
|
||||
}
|
||||
|
||||
@computed
|
||||
get form():Field{
|
||||
return this.wizardTab.form
|
||||
}
|
||||
@computed
|
||||
get submit(){
|
||||
return this.form.onSubmit
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
var self = this
|
||||
return <WizardPanel
|
||||
tab={this.wizardTab} key={this.wizardTab.tabName}
|
||||
>
|
||||
<NonprofitInfoForm form={this.form} buttonText={this.props.buttonText}/>
|
||||
</WizardPanel>
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(observer(NonprofitInfoPanel))
|
|
@ -0,0 +1,26 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import RegistrationWizard from "./RegistrationWizard";
|
||||
|
||||
import {configure} from 'mobx'
|
||||
import {observer} from 'mobx-react';
|
||||
import {InjectedIntlProps, injectIntl, InjectedIntl, FormattedMessage} from 'react-intl';
|
||||
|
||||
|
||||
export interface RegistrationPageProps
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
class RegistrationPage extends React.Component<RegistrationPageProps & InjectedIntlProps, {}> {
|
||||
|
||||
|
||||
|
||||
render() {
|
||||
return <div className="container"><h1><FormattedMessage id="registration.get_started.header"/></h1><p><FormattedMessage id="registration.get_started.description"/></p><RegistrationWizard/></div>
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(observer(RegistrationPage))
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
|
||||
import {observer, inject} from 'mobx-react'
|
||||
import NonprofitInfoPanel from './NonprofitInfoPanel'
|
||||
import {action, observable, computed} from 'mobx';
|
||||
import {Wizard} from '../common/wizard/Wizard'
|
||||
|
||||
import {Form} from 'mobx-react-form';
|
||||
import {FormattedMessage, injectIntl, InjectedIntlProps} from 'react-intl';
|
||||
import {WizardState} from "../common/wizard/wizard_state";
|
||||
import UserInfoPanel, * as UserInfo from "./UserInfoPanel";
|
||||
import {
|
||||
NonprofitApi,
|
||||
PostNonprofit,
|
||||
ValidationErrorsException
|
||||
} from "../../../api";
|
||||
|
||||
import {initializationDefinition} from "../../../../types/mobx-react-form";
|
||||
import {ApiManager} from "../../lib/api_manager";
|
||||
import {HoudiniForm, StaticFormToErrorAndBackConverter} from "../../lib/houdini_form";
|
||||
import {WebUserSignInOut} from "../../lib/api/sign_in";
|
||||
import {Validations} from "../../lib/vjf_rules";
|
||||
import * as NonprofitInfoForm from "./NonprofitInfoForm";
|
||||
import * as UserInfoForm from "./UserInfoForm";
|
||||
|
||||
export interface RegistrationWizardProps {
|
||||
ApiManager?: ApiManager
|
||||
}
|
||||
|
||||
|
||||
export class RegistrationPageForm extends HoudiniForm {
|
||||
converter: StaticFormToErrorAndBackConverter<PostNonprofit>
|
||||
|
||||
constructor(definition: initializationDefinition, options?: any) {
|
||||
super(definition, options)
|
||||
this.converter = new StaticFormToErrorAndBackConverter<PostNonprofit>(this.inputToForm)
|
||||
}
|
||||
|
||||
nonprofitApi: NonprofitApi
|
||||
signinApi: WebUserSignInOut
|
||||
|
||||
options() {
|
||||
return {
|
||||
validateOnInit: true,
|
||||
validateOnChange: true,
|
||||
retrieveOnlyDirtyValues: true,
|
||||
retrieveOnlyEnabledFields: true
|
||||
}
|
||||
}
|
||||
|
||||
inputToForm = {
|
||||
'nonprofit[name]': 'nonprofitTab.organization_name',
|
||||
'nonprofit[url]': 'nonprofitTab.website',
|
||||
'nonprofit[email]': 'nonprofitTab.org_email',
|
||||
'nonprofit[phone]': 'nonprofitTab.org_phone',
|
||||
'nonprofit[city]': 'nonprofitTab.city',
|
||||
'nonprofit[state_code]': 'nonprofitTab.state',
|
||||
'nonprofit[zip_code]': 'nonprofitTab.zip',
|
||||
'user[name]': 'userTab.name',
|
||||
'user[email]': 'userTab.email',
|
||||
'user[password]': 'userTab.password',
|
||||
'user[password_confirmation]': 'userTab.password_confirmation'
|
||||
}
|
||||
|
||||
hooks() {
|
||||
return {
|
||||
onSuccess: async (f: Form) => {
|
||||
let input = this.converter.convertFormToObject(f)
|
||||
|
||||
|
||||
try {
|
||||
let r = await this.nonprofitApi.postNonprofit(input)
|
||||
|
||||
await this.signinApi.postLogin({email: input.user.email, password: input.user.password})
|
||||
window.location.href = `/nonprofits/${r.id}/dashboard`
|
||||
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e)
|
||||
if (e instanceof ValidationErrorsException) {
|
||||
this.converter.convertErrorToForm(e, f)
|
||||
}
|
||||
//set error to the form
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class RegistrationWizardState extends WizardState {
|
||||
@action.bound
|
||||
createForm(i: any): Form {
|
||||
return new RegistrationPageForm(i)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export class InnerRegistrationWizard extends React.Component<RegistrationWizardProps & InjectedIntlProps, {}> {
|
||||
|
||||
constructor(props: RegistrationWizardProps & InjectedIntlProps) {
|
||||
super(props)
|
||||
|
||||
this.setRegistrationWizardState()
|
||||
this.createForm()
|
||||
}
|
||||
|
||||
|
||||
@observable registrationWizardState: RegistrationWizardState
|
||||
|
||||
@computed
|
||||
get form(): RegistrationPageForm {
|
||||
return (this.registrationWizardState && this.registrationWizardState.form)as RegistrationPageForm
|
||||
}
|
||||
|
||||
|
||||
@action.bound
|
||||
setRegistrationWizardState() {
|
||||
this.registrationWizardState = new RegistrationWizardState()
|
||||
}
|
||||
|
||||
|
||||
@action.bound
|
||||
createForm() {
|
||||
this.registrationWizardState.addTab("nonprofitTab", 'registration.wizard.tabs.nonprofit', {
|
||||
fields:
|
||||
NonprofitInfoForm.FieldDefinitions,
|
||||
hooks: {
|
||||
onError: (f: any) => {
|
||||
console.log(f)
|
||||
},
|
||||
onSuccess: (f: any) => {
|
||||
console.log(f)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
this.registrationWizardState.addTab("userTab", 'registration.wizard.tabs.contact', {
|
||||
fields:
|
||||
UserInfoForm.FieldDefinitions,
|
||||
hooks: {
|
||||
onError: (f: any) => {
|
||||
|
||||
},
|
||||
onSuccess: (f: any) => {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
this.registrationWizardState.initialize()
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
if (!this.form.nonprofitApi) {
|
||||
this.form.nonprofitApi = this.props.ApiManager.get(NonprofitApi)
|
||||
|
||||
}
|
||||
if(!this.form.signinApi){
|
||||
this.form.signinApi = this.props.ApiManager.get(WebUserSignInOut)
|
||||
}
|
||||
|
||||
//set up labels
|
||||
let label: {[props:string]: string} = {
|
||||
'nonprofitTab[organization_name]': "registration.wizard.nonprofit.name",
|
||||
"nonprofitTab[website]": 'registration.wizard.nonprofit.website',
|
||||
"nonprofitTab[org_email]": 'registration.wizard.nonprofit.email',
|
||||
'nonprofitTab[org_phone]': 'registration.wizard.nonprofit.phone',
|
||||
'nonprofitTab[city]': 'registration.wizard.nonprofit.city',
|
||||
'nonprofitTab[state]': 'registration.wizard.nonprofit.state',
|
||||
'nonprofitTab[zip]': 'registration.wizard.nonprofit.zip',
|
||||
'userTab[name]': 'registration.wizard.contact.name',
|
||||
'userTab[email]': 'registration.wizard.contact.email',
|
||||
'userTab[password]': 'registration.wizard.contact.password',
|
||||
'userTab[password_confirmation]': 'registration.wizard.contact.password_confirmation'
|
||||
}
|
||||
for (let key in label){
|
||||
this.form.$(key).set('label', this.props.intl.formatMessage({id: label[key]}))
|
||||
}
|
||||
|
||||
return <Wizard wizardState={this.registrationWizardState} disableTabs={this.form.submitting}>
|
||||
<NonprofitInfoPanel tab={this.registrationWizardState.tabsByName['nonprofitTab']}
|
||||
buttonText="registration.wizard.next"/>
|
||||
|
||||
<UserInfoPanel tab={this.registrationWizardState.tabsByName['userTab']}
|
||||
buttonText="registration.wizard.save_and_finish"/>
|
||||
</Wizard>
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(
|
||||
inject('ApiManager')
|
||||
(observer( InnerRegistrationWizard)
|
||||
)
|
||||
)
|
|
@ -0,0 +1,66 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import {InjectedIntlProps, injectIntl} from 'react-intl';
|
||||
import {Validations} from "../../lib/vjf_rules";
|
||||
import {Field, FieldDefinition} from "mobx-react-form";
|
||||
import {TwoColumnFields} from "../common/layout";
|
||||
import {BasicField} from "../common/fields";
|
||||
|
||||
export const FieldDefinitions : Array<FieldDefinition> = [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'registration.wizard.contact.name',
|
||||
validators: [Validations.isFilled]
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
label: 'registration.wizard.contact.email',
|
||||
validators: [Validations.isEmail]
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'registration.wizard.contact.password',
|
||||
type: 'password',
|
||||
validators: [Validations.isFilled],
|
||||
related: ['userTab.password_confirmation']
|
||||
},
|
||||
{
|
||||
name: 'password_confirmation',
|
||||
label: 'registration.wizard.contact.password_confirmation',
|
||||
type: 'password',
|
||||
validators: [Validations.shouldBeEqualTo("userTab.password")]
|
||||
}
|
||||
]
|
||||
|
||||
export interface UserInfoFormProps
|
||||
{
|
||||
form: Field
|
||||
buttonText:string
|
||||
}
|
||||
|
||||
|
||||
|
||||
class UserInfoForm extends React.Component<UserInfoFormProps & InjectedIntlProps, {}> {
|
||||
render() {
|
||||
return <fieldset>
|
||||
<TwoColumnFields>
|
||||
<BasicField field={this.props.form.$("name")}/>
|
||||
<BasicField field={this.props.form.$('email')}/>
|
||||
</TwoColumnFields>
|
||||
|
||||
<BasicField field={this.props.form.$('password')}/>
|
||||
<BasicField field={this.props.form.$('password_confirmation')}/>
|
||||
|
||||
|
||||
<button onClick={this.props.form.onSubmit} className="button" disabled={!this.props.form.isValid || this.props.form.submitting}>
|
||||
{this.props.intl.formatMessage({id: this.props.buttonText})}
|
||||
</button>
|
||||
</fieldset>;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(observer(UserInfoForm))
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import {observer} from 'mobx-react';
|
||||
import {InjectedIntlProps, injectIntl} from 'react-intl';
|
||||
import {Field} from "mobx-react-form";
|
||||
import {computed} from 'mobx';
|
||||
import {WizardPanel, WizardTabPanelProps} from "../common/wizard/WizardPanel";
|
||||
import {WizardTabPanelState} from "../common/wizard/wizard_state";
|
||||
|
||||
export interface UserInfoPanelProps extends WizardTabPanelProps {
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
class UserInfoPanel extends React.Component<UserInfoPanelProps & InjectedIntlProps, {}> {
|
||||
|
||||
@computed
|
||||
get wizardTab(): WizardTabPanelState {
|
||||
return this.props.tab
|
||||
}
|
||||
|
||||
@computed
|
||||
get form():Field{
|
||||
return this.wizardTab.form
|
||||
}
|
||||
|
||||
@computed
|
||||
get submit() {
|
||||
return this.form.onSubmit
|
||||
}
|
||||
|
||||
@computed
|
||||
get tabName() {
|
||||
return this.wizardTab.tabName;
|
||||
}
|
||||
|
||||
render() {
|
||||
let parentForm = this.form.container() || this.form.state.form
|
||||
let submitting = parentForm.submitting
|
||||
|
||||
return <WizardPanel
|
||||
tab={this.wizardTab} key={this.wizardTab.tabName}
|
||||
>
|
||||
|
||||
|
||||
</WizardPanel>;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(observer(UserInfoPanel))
|
||||
|
||||
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import * as Component from './registration_page'
|
||||
|
||||
describe('RegistrationPage', () => {
|
||||
test('your test here', () => {
|
||||
expect(false).toBe(true)
|
||||
})
|
||||
})
|
|
@ -1,14 +0,0 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
|
||||
export interface RegistrationPageProps
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export default class RegistrationPage extends React.Component<RegistrationPageProps, {}> {
|
||||
render() {
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
|
88
javascripts/src/lib/api/sign_in.ts
Normal file
88
javascripts/src/lib/api/sign_in.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as $ from 'jquery';
|
||||
|
||||
import * as models from "../../../api/model/models";
|
||||
import {Configuration} from "../../../api/configuration";
|
||||
|
||||
export class WebUserSignInOut {
|
||||
protected basePath = '/';
|
||||
public defaultHeaders: Array<string> = [];
|
||||
public defaultExtraJQueryAjaxSettings?: JQueryAjaxSettings = null;
|
||||
public configuration: Configuration = new Configuration();
|
||||
|
||||
constructor(basePath?: string, configuration?: Configuration, defaultExtraJQueryAjaxSettings?: JQueryAjaxSettings) {
|
||||
if (basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
if (configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
if (defaultExtraJQueryAjaxSettings) {
|
||||
this.defaultExtraJQueryAjaxSettings = defaultExtraJQueryAjaxSettings;
|
||||
}
|
||||
}
|
||||
|
||||
public postLogin(loginInfo: WebLoginModel, extraJQueryAjaxSettings?: JQueryAjaxSettings): Promise<any> {
|
||||
let localVarPath = this.basePath + 'users/sign_in.json';
|
||||
|
||||
let queryParameters: any = {};
|
||||
let headerParams: any = {};
|
||||
// verify required parameter 'nonprofit' is not null or undefined
|
||||
if (loginInfo === null || loginInfo === undefined) {
|
||||
throw new Error('Required parameter nonprofit was null or undefined when calling postNonprofit.');
|
||||
}
|
||||
|
||||
|
||||
localVarPath = localVarPath + "?" + $.param(queryParameters);
|
||||
// to determine the Content-Type header
|
||||
let consumes: string[] = [
|
||||
'application/json'
|
||||
];
|
||||
|
||||
// to determine the Accept header
|
||||
let produces: string[] = [
|
||||
'application/json'
|
||||
];
|
||||
|
||||
|
||||
headerParams['Content-Type'] = 'application/json';
|
||||
|
||||
let requestOptions: JQueryAjaxSettings = {
|
||||
url: localVarPath,
|
||||
type: 'POST',
|
||||
headers: headerParams,
|
||||
processData: false
|
||||
};
|
||||
|
||||
requestOptions.data = JSON.stringify({user:loginInfo});
|
||||
if (headerParams['Content-Type']) {
|
||||
requestOptions.contentType = headerParams['Content-Type'];
|
||||
}
|
||||
|
||||
if (extraJQueryAjaxSettings) {
|
||||
requestOptions = (<any>Object).assign(requestOptions, extraJQueryAjaxSettings);
|
||||
}
|
||||
|
||||
if (this.defaultExtraJQueryAjaxSettings) {
|
||||
requestOptions = (<any>Object).assign(requestOptions, this.defaultExtraJQueryAjaxSettings);
|
||||
}
|
||||
|
||||
let dfd = $.Deferred();
|
||||
$.ajax(requestOptions).then(
|
||||
(data: any, textStatus: string, jqXHR: JQueryXHR) =>
|
||||
dfd.resolve(jqXHR, data),
|
||||
(xhr: JQueryXHR, textStatus: string, errorThrown: string) => {
|
||||
|
||||
|
||||
dfd.reject(errorThrown)
|
||||
|
||||
}
|
||||
);
|
||||
return dfd.promise();
|
||||
}
|
||||
}
|
||||
|
||||
interface WebLoginModel {
|
||||
email:string
|
||||
password:string
|
||||
}
|
84
javascripts/src/lib/api_manager.spec.ts
Normal file
84
javascripts/src/lib/api_manager.spec.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import {ApiManager, ApiMissingException} from "./api_manager";
|
||||
import 'jest';
|
||||
describe('ApiManager', () => {
|
||||
|
||||
class A{}
|
||||
class B{}
|
||||
class C {}
|
||||
var manager: ApiManager = null
|
||||
describe('simple non-intercepted API', () => {
|
||||
beforeEach(() => {
|
||||
manager = new ApiManager([A, B])})
|
||||
|
||||
test('it errors when API is missing', () => {
|
||||
expect(() =>{
|
||||
let c = manager.get(C)
|
||||
}
|
||||
).toThrow(ApiMissingException)
|
||||
})
|
||||
|
||||
test('it gets API of type A', () => {
|
||||
let a = manager.get(A)
|
||||
expect(a).toBeInstanceOf(A)
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
describe('handle interceptor', () => {
|
||||
let interceptorValue0:boolean
|
||||
let interceptor0 = () => {interceptorValue0 = true}
|
||||
let interceptorValue1:boolean
|
||||
let interceptor1 = () => {interceptorValue1 = true}
|
||||
class A {
|
||||
defaultExtraJQueryAjaxSettings?: JQuery.AjaxSettings
|
||||
}
|
||||
class B {
|
||||
defaultExtraJQueryAjaxSettings?: JQuery.AjaxSettings
|
||||
}
|
||||
class C{}
|
||||
beforeEach(() => {
|
||||
|
||||
interceptorValue0 = false
|
||||
interceptorValue1 = false
|
||||
|
||||
})
|
||||
|
||||
|
||||
test('returns A with no interceptor', () => {
|
||||
manager = new ApiManager([A, B])
|
||||
let a = manager.get(A)
|
||||
|
||||
expect(a).toBeInstanceOf(A)
|
||||
expect(a.defaultExtraJQueryAjaxSettings).toBeUndefined()
|
||||
expect(interceptorValue0).toBe(false)
|
||||
expect(interceptorValue1).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
test('returns B with proper interceptor0', () => {
|
||||
manager = new ApiManager([A, B], interceptor0)
|
||||
let b = manager.get(B)
|
||||
expect(b).toBeInstanceOf(B)
|
||||
b.defaultExtraJQueryAjaxSettings.beforeSend(null, null)
|
||||
expect(interceptorValue0).toBe(true)
|
||||
expect(interceptorValue1).toBe(false)
|
||||
})
|
||||
|
||||
test('returns A with two proper interceptors', () => {
|
||||
manager = new ApiManager([A, B], interceptor1, interceptor0)
|
||||
let a = manager.get(A)
|
||||
expect(a).toBeInstanceOf(A)
|
||||
a.defaultExtraJQueryAjaxSettings.beforeSend(null, null)
|
||||
expect(interceptorValue0).toBe(true)
|
||||
expect(interceptorValue1).toBe(true)
|
||||
})
|
||||
|
||||
test('returns error on invalid class', () => {
|
||||
expect(() =>{
|
||||
let c = manager.get(C)
|
||||
}
|
||||
).toThrow(ApiMissingException)
|
||||
})
|
||||
})
|
||||
})
|
78
javascripts/src/lib/api_manager.ts
Normal file
78
javascripts/src/lib/api_manager.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as _ from 'lodash'
|
||||
|
||||
import * as JQuery from 'jquery'
|
||||
|
||||
interface ApiWithSettings {
|
||||
defaultExtraJQueryAjaxSettings?: JQuery.AjaxSettings
|
||||
}
|
||||
|
||||
export type Interceptor = (this: any, jqXHR: JQuery.jqXHR, settings: JQuery.AjaxSettings<any>) => false | void
|
||||
|
||||
/**
|
||||
* A service locator for creating getting a prepared instance of the API
|
||||
*/
|
||||
export class ApiManager {
|
||||
apis: any[] = []
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{new(): ApiWithSettings}[]} apis a list of APIs. Normally this will be initialized
|
||||
* by the Root component with the APIS const from the generated API folder
|
||||
* @param {Interceptor[]} beforeSendInterceptors the interceptors that run before your XHR request is made.
|
||||
*/
|
||||
constructor(apis: { new(): ApiWithSettings }[], ...beforeSendInterceptors: Interceptor[]) {
|
||||
_.forEach(apis, (i) => {
|
||||
let newed = new i()
|
||||
if (beforeSendInterceptors && beforeSendInterceptors.length > 0) {
|
||||
let a: JQuery.AjaxSettings<any> = {
|
||||
beforeSend: <any>((jqXHR:JQuery.jqXHR, settings:JQuery.AjaxSettings<any>) : false|void => {
|
||||
_.forEach(beforeSendInterceptors, (i:Interceptor) => i(jqXHR, settings))
|
||||
return
|
||||
})
|
||||
}
|
||||
newed.defaultExtraJQueryAjaxSettings = a
|
||||
}
|
||||
|
||||
this.apis.push(newed)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the Api instance for class you request
|
||||
*
|
||||
* @example
|
||||
* //returns the nonprofit API
|
||||
* api.get(NonprofitApi)
|
||||
* @param {{new(): T}} c class of the Api you'd like to use
|
||||
* @throws ApiMissingException when you pass in a class which isn't in the list of managed APIs
|
||||
* @returns {T} instance of the Api
|
||||
*/
|
||||
get<T>(c: { new(): T }): T {
|
||||
let result = _.find(this.apis, (i) => {
|
||||
return i instanceof c
|
||||
})
|
||||
if (result) {
|
||||
return result as T
|
||||
}
|
||||
|
||||
throw new ApiMissingException(`No API of type ${c.toString()}`)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* An error for when the class you requested from ApiManager is missing
|
||||
*/
|
||||
export class ApiMissingException implements Error {
|
||||
constructor(message: string) {
|
||||
this.message = message
|
||||
}
|
||||
|
||||
message: string;
|
||||
name: string;
|
||||
stack: string;
|
||||
|
||||
}
|
4
javascripts/src/lib/apis.ts
Normal file
4
javascripts/src/lib/apis.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import {WebUserSignInOut} from "./api/sign_in";
|
||||
|
||||
export const APIS = [WebUserSignInOut]
|
11
javascripts/src/lib/csrf_interceptor.ts
Normal file
11
javascripts/src/lib/csrf_interceptor.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
/**
|
||||
* An Interceptor for ApiManager which adds the CSRF token to API calls
|
||||
* @param {JQuery.jqXHR} jqXHR
|
||||
* @param {JQuery.AjaxSettings<any>} settings
|
||||
* @returns {false | void}
|
||||
*/
|
||||
export function CSRFInterceptor(this:any, jqXHR:JQuery.jqXHR, settings: JQuery.AjaxSettings<any>): false|void {
|
||||
jqXHR.setRequestHeader('X-CSRF-Token', (<any>window)._csrf)
|
||||
}
|
||||
|
87
javascripts/src/lib/houdini_form.ts
Normal file
87
javascripts/src/lib/houdini_form.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import {Form} from "mobx-react-form";
|
||||
import {action, runInAction} from 'mobx'
|
||||
import validator = require("validator")
|
||||
import * as _ from 'lodash'
|
||||
import {ValidationErrorsException} from "../../api";
|
||||
|
||||
|
||||
export class HoudiniForm extends Form {
|
||||
plugins() {
|
||||
return {
|
||||
vjf: validator
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface PathToFormField {
|
||||
[props: string]: string
|
||||
}
|
||||
|
||||
type FormFieldToPath = PathToFormField
|
||||
|
||||
/**
|
||||
* tool for converting between the form's data structure
|
||||
* to the AJAX datastructure and properly assigning
|
||||
* errors if AJAX request fails
|
||||
* As an example for the, consider the the following form structure:
|
||||
* {
|
||||
* // a tab in a Wizard
|
||||
* nonprofitTab: {
|
||||
* organization_name: {some field info}
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* We want to create a data structure for AJAX like so:
|
||||
*
|
||||
* In the database
|
||||
*
|
||||
*/
|
||||
export class StaticFormToErrorAndBackConverter<T> {
|
||||
|
||||
pathToForm: PathToFormField
|
||||
formToPath: FormFieldToPath
|
||||
|
||||
|
||||
constructor(pathToForm: PathToFormField) {
|
||||
this.pathToForm = pathToForm
|
||||
this.formToPath = _.invert(pathToForm)
|
||||
}
|
||||
|
||||
convertFormToObject(form: Form): T {
|
||||
let output = {}
|
||||
for (let pathToFormKey in this.pathToForm) {
|
||||
if (this.pathToForm.hasOwnProperty(pathToFormKey)) {
|
||||
let formPath = this.pathToForm[pathToFormKey]
|
||||
if (form.$(formPath).value && _.trim(form.$(formPath).value) !== "")
|
||||
_.set(output, pathToFormKey, form.$(formPath).value)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return output as T
|
||||
|
||||
}
|
||||
|
||||
@action.bound
|
||||
convertErrorToForm(errorException: ValidationErrorsException, form: Form): void {
|
||||
runInAction(() => {
|
||||
_.forEach(errorException.item.errors, (error) => {
|
||||
let message = error.messages.join(", ")
|
||||
_.forEach(error.params, (p) => {
|
||||
if (this.pathToForm[p])
|
||||
form.$(this.pathToForm[p]).invalidate(message)
|
||||
else {
|
||||
console.warn(`We couldn't find a form element for path: "${p}"`)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
37
javascripts/src/lib/regex.spec.ts
Normal file
37
javascripts/src/lib/regex.spec.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as Regex from './regex'
|
||||
import 'jest';
|
||||
|
||||
|
||||
describe('Regex.Email', () => {
|
||||
test("rejects empty",() =>
|
||||
expect(Regex.Email.test("")).toBeFalsy()
|
||||
)
|
||||
|
||||
test("rejects blank",() =>
|
||||
expect(Regex.Email.test(" ")).toBeFalsy()
|
||||
)
|
||||
|
||||
test("rejects no before @ part",() =>
|
||||
expect(Regex.Email.test("@h.n")).toBeFalsy()
|
||||
)
|
||||
test("rejects no after @ part",() =>
|
||||
expect(Regex.Email.test("something@")).toBeFalsy()
|
||||
)
|
||||
|
||||
test("rejects with space in before @ part",() =>
|
||||
expect(Regex.Email.test("somethi ng@s.c")).toBeFalsy()
|
||||
)
|
||||
|
||||
test("rejects with space in after @ part",() =>
|
||||
expect(Regex.Email.test("something@s j.c")).toBeFalsy()
|
||||
)
|
||||
|
||||
test("accepts basic email",() =>
|
||||
expect(Regex.Email.test("something+f.3+3@s.com")).toBeTruthy()
|
||||
)
|
||||
|
||||
test("accepts IDN and Unicode email",() =>
|
||||
expect(Regex.Email.test("македонија+f.и+3@বাংলাদেশ.icom.museum")).toBeTruthy()
|
||||
)
|
||||
})
|
2
javascripts/src/lib/regex.ts
Normal file
2
javascripts/src/lib/regex.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
export const Email = /^[^ ]+@[^ ]+\.[^ ]+$/i
|
125
javascripts/src/lib/tests/helpers.ts
Normal file
125
javascripts/src/lib/tests/helpers.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { IntlProvider, intlShape } from 'react-intl';
|
||||
import { mount, shallow, ShallowRendererProps, MountRendererProps, ShallowWrapper } from 'enzyme';
|
||||
|
||||
// Create the IntlProvider to retrieve context for wrapping around.
|
||||
const intlProvider = new IntlProvider({ locale: 'en'}, {});
|
||||
const { intl } = intlProvider.getChildContext();
|
||||
|
||||
/**
|
||||
* When using React-Intl `injectIntl` on components, props.intl is required.
|
||||
*/
|
||||
function nodeWithIntlProp(node:any) {
|
||||
return React.cloneElement(node, { intl });
|
||||
}
|
||||
|
||||
export function shallowWithIntl(node:any, options?:ShallowRendererProps) {
|
||||
let context = {}
|
||||
|
||||
if (options ) {
|
||||
context = options.context
|
||||
|
||||
}
|
||||
return shallow(
|
||||
nodeWithIntlProp(node),
|
||||
{
|
||||
...options,
|
||||
context: (Object as any).assign({}, context, {intl})
|
||||
}
|
||||
).dive();
|
||||
}
|
||||
|
||||
|
||||
export function mountWithIntl(node:any, options?:MountRendererProps) {
|
||||
let context = {}
|
||||
let additionalOptions:Array<any> = []
|
||||
let childContextTypes = {}
|
||||
|
||||
if (options) {
|
||||
context = options.context
|
||||
childContextTypes = options.childContextTypes
|
||||
}
|
||||
return mount(
|
||||
nodeWithIntlProp(node),
|
||||
{
|
||||
...options,
|
||||
context:(Object as any).assign({},context, {intl}),
|
||||
childContextTypes: (Object as any).assign({}, { intl: intlShape }, childContextTypes)
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface shallowUntilTargetProps {
|
||||
maxTries?: number
|
||||
shallowOptions?: ShallowRendererProps,
|
||||
_shallow?: Function
|
||||
}
|
||||
|
||||
/* from: https://github.com/mozilla/addons-frontend/blob/18f433f2199fb3d68109ef4d0a164ba1af37520a/tests/unit/helpers.js
|
||||
* Repeatedly render a component tree using enzyme.shallow() until
|
||||
* finding and rendering TargetComponent.
|
||||
*
|
||||
* This is useful for testing a component wrapped in one or more
|
||||
* HOCs (higher order components).
|
||||
*
|
||||
* The `componentInstance` parameter is a React component instance.
|
||||
* Example: <MyComponent {...props} />
|
||||
*
|
||||
* The `TargetComponent` parameter is the React class (or function) that
|
||||
* you want to retrieve from the component tree.
|
||||
*/
|
||||
export function shallowUntilTarget<T>(componentInstance:React.ReactElement<any>, TargetComponent:{new(): T}, props:shallowUntilTargetProps): ShallowWrapper<T> {
|
||||
if (!componentInstance) {
|
||||
throw new Error('componentInstance parameter is required');
|
||||
}
|
||||
if (!TargetComponent) {
|
||||
throw new Error('TargetComponent parameter is required');
|
||||
}
|
||||
|
||||
|
||||
let maxTries = props.maxTries || 10
|
||||
let shallowOptions = props.shallowOptions || null
|
||||
let _shallow = props._shallow || shallow
|
||||
|
||||
let root = _shallow(componentInstance, shallowOptions);
|
||||
|
||||
if (typeof root.type() === 'string') {
|
||||
// If type() is a string then it's a DOM Node.
|
||||
// If it were wrapped, it would be a React component.
|
||||
throw new Error(
|
||||
'Cannot unwrap this component because it is not wrapped');
|
||||
}
|
||||
|
||||
for (let tries = 1; tries <= maxTries; tries++) {
|
||||
if (root.is(TargetComponent)) {
|
||||
// Now that we found the target component, render it.
|
||||
return root.shallow(shallowOptions);
|
||||
}
|
||||
// Unwrap the next component in the hierarchy.
|
||||
root = root.dive();
|
||||
}
|
||||
|
||||
throw new Error(`Could not find ${TargetComponent} in rendered
|
||||
instance: ${componentInstance}; gave up after ${maxTries} tries`
|
||||
);
|
||||
}
|
||||
|
||||
export function shallowUntilTargetWithIntl(node:any, TargetComponent:any, options?:ShallowRendererProps): ShallowWrapper<any> {
|
||||
let context = {}
|
||||
|
||||
if (options ) {
|
||||
context = options.context
|
||||
|
||||
}
|
||||
return shallowUntilTarget(
|
||||
nodeWithIntlProp(node),
|
||||
TargetComponent,
|
||||
{
|
||||
shallowOptions: {
|
||||
...options,
|
||||
context: (Object as any).assign({}, context, {intl})
|
||||
}})
|
||||
}
|
57
javascripts/src/lib/vjf_rules.ts
Normal file
57
javascripts/src/lib/vjf_rules.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as Regex from './regex'
|
||||
import {Field, Form} from "mobx-react-form";
|
||||
|
||||
|
||||
interface ValidationInput {
|
||||
field: Field
|
||||
validator?: ValidatorJS.ValidatorStatic
|
||||
form?: Form
|
||||
|
||||
}
|
||||
|
||||
interface StringBoolTuple extends Array<boolean|string>{0:boolean, 1:string}
|
||||
|
||||
interface Validation {
|
||||
(input:ValidationInput): StringBoolTuple
|
||||
}
|
||||
|
||||
|
||||
export class Validations {
|
||||
static isEmail({field}:ValidationInput) : StringBoolTuple {
|
||||
return [field.value.match(Regex.Email) !== null,
|
||||
`${field.label} is not a valid email`]
|
||||
}
|
||||
|
||||
static shouldBeEqualTo (targetPath: string): Validation {
|
||||
return ({field, form}:ValidationInput) => {
|
||||
const fieldsAreEquals = (form.$(targetPath).value === field.value);
|
||||
return [fieldsAreEquals, `${field.label} and ${form.$(targetPath).label} must be the same`]
|
||||
}
|
||||
}
|
||||
|
||||
static isUrl({field, validator}:ValidationInput):StringBoolTuple {
|
||||
return [validator.isURL(field.value),
|
||||
`${field.label} must be a valid URL`]
|
||||
}
|
||||
|
||||
static isFilled({field, validator}:ValidationInput) :StringBoolTuple {
|
||||
return [
|
||||
!validator.isEmpty(field.value),
|
||||
`${field.label} must be filled out`
|
||||
]
|
||||
}
|
||||
|
||||
static optional(validation:Validation) : Validation {
|
||||
return ({field, form, validator}:ValidationInput) => {
|
||||
if (!field.value || validator.isEmpty(field.value)){
|
||||
return [true, ""]
|
||||
}
|
||||
else{
|
||||
return validation({field: field, form: form, validator: validator})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -39,7 +39,7 @@ class CopyNamingAlgorithm
|
|||
end
|
||||
}
|
||||
|
||||
raise ArgumentError.new("It's not possible to generate a UNIQUE name using name_to_copy: #{name_to_copy} copy_addition: #{self.copy_addition} separator_before_copy_number: #{self.separator_before_copy_number} max_copy_num: #{self.max_copies} max_length: #{self.max_length}")
|
||||
raise UnableToCreateNameCopyError.new("It's not possible to generate a UNIQUE name using name_to_copy: #{name_to_copy} copy_addition: #{self.copy_addition} separator_before_copy_number: #{self.separator_before_copy_number} max_copy_num: #{self.max_copies} max_length: #{self.max_length}")
|
||||
end
|
||||
|
||||
def generate_name(name_to_copy, copy_num)
|
||||
|
@ -47,7 +47,7 @@ class CopyNamingAlgorithm
|
|||
|
||||
# is what_to_add longer than max length? If so, it's not possible to create a copy
|
||||
if (what_to_add.length > self.max_length)
|
||||
raise ArgumentError.new("It's not possible to generate a name using name_to_copy: #{name_to_copy} copy_addition: #{self.copy_addition} separator_before_copy_number: #{self.separator_before_copy_number} copy_num: #{copy_num} max_length: #{self.max_length}")
|
||||
raise UnableToCreateNameCopyError.new("It's not possible to generate a name using name_to_copy: #{name_to_copy} copy_addition: #{self.copy_addition} separator_before_copy_number: #{self.separator_before_copy_number} copy_num: #{copy_num} max_length: #{self.max_length}")
|
||||
end
|
||||
max_length_for_name_to_copy = self.max_length - what_to_add.length
|
||||
name_to_copy[0..max_length_for_name_to_copy-1] + what_to_add
|
||||
|
@ -58,4 +58,8 @@ class CopyNamingAlgorithm
|
|||
"%0#{number_of_digits}d" % unprefixed_copy_number
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
class UnableToCreateNameCopyError < ArgumentError
|
||||
|
||||
end
|
|
@ -1,7 +1,7 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
module Email
|
||||
|
||||
Regex ||= /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i
|
||||
PsqlRegex ||= '^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'
|
||||
Regex ||= /^[^ ]+@[^ ]+\.[^ ]+/i
|
||||
#PsqlRegex ||= '^[A-Za-z0-9._%-]+@[A-Za-z0-9.-]+[.][A-Za-z]+$'
|
||||
|
||||
end
|
||||
|
|
9
lib/generators/api/entity/USAGE
Normal file
9
lib/generators/api/entity/USAGE
Normal file
|
@ -0,0 +1,9 @@
|
|||
Description:
|
||||
Generates a new entity to be returned by the API
|
||||
|
||||
Example:
|
||||
rails generate api:entity EntityName
|
||||
|
||||
This will create:
|
||||
A new subclass of Grape::Entity called Houdini::V1::Entities::EntityName
|
||||
in app/api/houdini/v1/entities/entity_name.rb
|
7
lib/generators/api/entity/entity_generator.rb
Normal file
7
lib/generators/api/entity/entity_generator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Api::EntityGenerator < Rails::Generators::NamedBase
|
||||
source_root File.expand_path('../templates', __FILE__)
|
||||
def copy_to_entity
|
||||
template 'entity.rb.erb', File.join("app/api/houdini/v1/entities", "#{name.underscore}.rb")
|
||||
end
|
||||
end
|
4
lib/generators/api/entity/templates/entity.rb.erb
Normal file
4
lib/generators/api/entity/templates/entity.rb.erb
Normal file
|
@ -0,0 +1,4 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Entities::<%= name.camelcase %> < Grape::Entity
|
||||
|
||||
end
|
10
lib/generators/api/resource/USAGE
Normal file
10
lib/generators/api/resource/USAGE
Normal file
|
@ -0,0 +1,10 @@
|
|||
Description:
|
||||
Creates a new resource for API usage
|
||||
|
||||
Example:
|
||||
rails generate api:resource ResourceName
|
||||
|
||||
This will create:
|
||||
* A new subclass of Grape::API called Houdini::V1::ResourceName
|
||||
at app/api/houdini/v1/resource_name.rb
|
||||
* Houdini::V1::ResourceName mounted to the api in app/api/houdini/v1/api.rb
|
10
lib/generators/api/resource/resource_generator.rb
Normal file
10
lib/generators/api/resource/resource_generator.rb
Normal file
|
@ -0,0 +1,10 @@
|
|||
class Api::ResourceGenerator < Rails::Generators::NamedBase
|
||||
source_root File.expand_path('../templates', __FILE__)
|
||||
def copy_to_resource
|
||||
template 'resource.rb.erb', File.join("app/api/houdini/v1", "#{name.underscore}.rb")
|
||||
end
|
||||
|
||||
def add_to_root_api
|
||||
inject_into_file "app/api/houdini/v1/api.rb", "\tmount Houdini::V1::#{ name.camelcase}\n", after: "class Houdini::V1::API < Grape::API\n"
|
||||
end
|
||||
end
|
3
lib/generators/api/resource/templates/resource.rb.erb
Normal file
3
lib/generators/api/resource/templates/resource.rb.erb
Normal file
|
@ -0,0 +1,3 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::<%= name.camelcase %> < Grape::API
|
||||
end
|
8
lib/generators/api/validator/USAGE
Normal file
8
lib/generators/api/validator/USAGE
Normal file
|
@ -0,0 +1,8 @@
|
|||
Description:
|
||||
Explain the generator
|
||||
|
||||
Example:
|
||||
rails generate validator Thing
|
||||
|
||||
This will create:
|
||||
what/will/it/create
|
6
lib/generators/api/validator/templates/validator.rb.erb
Normal file
6
lib/generators/api/validator/templates/validator.rb.erb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Houdini::V1::Validators::<%= name.camelcase %> < Grape::Validations::Base
|
||||
def validate_param!(attr_name, params)
|
||||
fail Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: 'MESSAGE'
|
||||
end
|
||||
end
|
15
lib/generators/api/validator/validator_generator.rb
Normal file
15
lib/generators/api/validator/validator_generator.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Api::ValidatorGenerator < Rails::Generators::NamedBase
|
||||
source_root File.expand_path('../templates', __FILE__)
|
||||
|
||||
def copy_to_validators
|
||||
post_api_part = File.join("houdini/v1/validators", "#{name.underscore}.rb")
|
||||
output_file = File.join("app/api", post_api_part )
|
||||
template 'validator.rb.erb', output_file
|
||||
end
|
||||
|
||||
def add_to_root_validations
|
||||
post_api_part = File.join("houdini/v1/validators", "#{name.underscore}")
|
||||
append_to_file "app/api/houdini/v1/validations.rb", "\nrequire '#{post_api_part}'"
|
||||
end
|
||||
end
|
|
@ -3,7 +3,7 @@ class React::ComponentGenerator < Rails::Generators::NamedBase
|
|||
source_root File.expand_path('../templates', __FILE__)
|
||||
|
||||
def copy_file_to_component
|
||||
template 'component.tsx.erb', File.join("javascripts/src/components", *(class_path + ["#{file_name.underscore}.tsx"]))
|
||||
template 'component.spec.ts.erb', File.join("javascripts/src/components", *(class_path + ["#{file_name.underscore}.spec.ts"]))
|
||||
template 'component.tsx.erb', File.join("javascripts/src/components", *(class_path + ["#{file_name.camelize}.tsx"]))
|
||||
template 'component.spec.tsx.erb', File.join("javascripts/src/components", *(class_path + ["#{file_name.camelize}.spec.tsx"]))
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import * as Component from './<%= file_name.underscore%>'
|
||||
|
||||
describe('<%= file_name.camelize %>', () => {
|
||||
test('your test here', () => {
|
||||
expect(false).toBe(true)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,10 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import 'jest';
|
||||
import <%= file_name.camelize %> from './<%= file_name.camelize%>'
|
||||
|
||||
describe('<%= file_name.camelize %>', () => {
|
||||
test('your test here', () => {
|
||||
expect(false).toBe(true)
|
||||
})
|
||||
})
|
|
@ -1,14 +1,20 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
import * as React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import {InjectedIntlProps, injectIntl} from 'react-intl';
|
||||
|
||||
export interface <%= file_name.camelize %>Props
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
export default class <%= file_name.camelize %> extends React.Component<<%= file_name.camelize %>Props, {}> {
|
||||
class <%= file_name.camelize %> extends React.Component<<%= file_name.camelize %>Props & InjectedIntlProps, {}> {
|
||||
render() {
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(observer(<%= file_name.camelize %>))
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'jest';
|
|||
import * as Lib from './<%= file_name.underscore%>'
|
||||
|
||||
describe('<%= file_name.camelize %>', () => {
|
||||
test('your test here', () => {
|
||||
expect(false).toBe(true)
|
||||
})
|
||||
test('your test here', () => {
|
||||
expect(false).toBe(true)
|
||||
})
|
||||
})
|
|
@ -1,5 +1,14 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
|
||||
// require a root component here. This will be treated as the root of a webpack package
|
||||
import Root from "../src/components/common/Root"
|
||||
import RegistrationPage from "../src/components/<%= file_name.underscore%>/<%= file_name.underscore%>"
|
||||
|
||||
import "../src/components/<%= file_name.underscore%>/<%= file_name.underscore%>"
|
||||
import * as ReactDOM from 'react-dom'
|
||||
import * as React from 'react'
|
||||
|
||||
function LoadReactPage(element:HTMLElement) {
|
||||
ReactDOM.render(<Root><RegistrationPage/></Root>, element)
|
||||
}
|
||||
|
||||
|
||||
(window as any).LoadReactPage = LoadReactPage
|
8
lib/generators/ts/declaration/USAGE
Normal file
8
lib/generators/ts/declaration/USAGE
Normal file
|
@ -0,0 +1,8 @@
|
|||
Description:
|
||||
Creates a new Typescript declaration file for modules which don't have them
|
||||
|
||||
Example:
|
||||
rails generate ts:declaration module_name
|
||||
|
||||
This will create:
|
||||
A new basic Typescript declaration file at 'types/module_name/index.d.ts'
|
7
lib/generators/ts/declaration/declaration_generator.rb
Normal file
7
lib/generators/ts/declaration/declaration_generator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class Ts::DeclarationGenerator < Rails::Generators::NamedBase
|
||||
source_root File.expand_path('../templates', __FILE__)
|
||||
def copy_template
|
||||
template 'template.d.ts.erb', File.join("types", name, 'index.d.ts')
|
||||
end
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
// License: LGPL-3.0-or-later
|
||||
declare module "<%= name %>"
|
32
lib/slug_nonprofit_naming_algorithm.rb
Normal file
32
lib/slug_nonprofit_naming_algorithm.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
|
||||
class SlugNonprofitNamingAlgorithm < CopyNamingAlgorithm
|
||||
|
||||
attr_accessor :state_slug, :city_slug
|
||||
|
||||
def initialize( state_slug, city_slug)
|
||||
@state_slug = state_slug
|
||||
@city_slug = city_slug
|
||||
end
|
||||
|
||||
def copy_addition
|
||||
""
|
||||
end
|
||||
|
||||
def max_copies
|
||||
99
|
||||
end
|
||||
|
||||
def separator_before_copy_number
|
||||
'-'
|
||||
end
|
||||
|
||||
def get_name_for_entity(name_entity)
|
||||
name_entity.slug
|
||||
end
|
||||
|
||||
def get_already_used_name_entities(base_name)
|
||||
end_name = "\\-\\d{2}"
|
||||
Nonprofit.method(:where).call('slug SIMILAR TO ? AND state_code_slug = ? AND city_slug = ?', base_name + end_name, @state_slug, @city_slug).select('slug')
|
||||
end
|
||||
|
||||
end
|
45
lib/swagger-typescript-jquery/README.mustache
Normal file
45
lib/swagger-typescript-jquery/README.mustache
Normal file
|
@ -0,0 +1,45 @@
|
|||
## {{npmName}}@{{npmVersion}}
|
||||
|
||||
This generator creates TypeScript/JavaScript client that utilizes [jQuery](https://jquery.com/). The generated Node module can be used in the following environments:
|
||||
|
||||
Environment
|
||||
* Node.js
|
||||
* Webpack
|
||||
* Browserify
|
||||
|
||||
Language level
|
||||
* ES5 - you must have a Promises/A+ library installed
|
||||
* ES6
|
||||
|
||||
Module system
|
||||
* CommonJS
|
||||
* ES6 module system
|
||||
|
||||
It can be used in both TypeScript and JavaScript. In TypeScript, the definition should be automatically resolved via `package.json`. ([Reference](http://www.typescriptlang.org/docs/handbook/typings-for-npm-packages.html))
|
||||
|
||||
### Building
|
||||
|
||||
To build an compile the typescript sources to javascript use:
|
||||
```
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Publishing
|
||||
|
||||
First build the package then run ```npm publish```
|
||||
|
||||
### Consuming
|
||||
|
||||
navigate to the folder of your consuming project and run one of the following commands.
|
||||
|
||||
_published:_
|
||||
|
||||
```
|
||||
npm install {{npmName}}@{{npmVersion}} --save
|
||||
```
|
||||
|
||||
_unPublished (not recommended):_
|
||||
|
||||
```
|
||||
npm install PATH_TO_GENERATED_PACKAGE --save
|
260
lib/swagger-typescript-jquery/api.mustache
Normal file
260
lib/swagger-typescript-jquery/api.mustache
Normal file
|
@ -0,0 +1,260 @@
|
|||
{{>licenseInfo}}
|
||||
|
||||
{{#jqueryAlreadyImported}}
|
||||
declare var $ : any;
|
||||
{{/jqueryAlreadyImported}}
|
||||
{{^jqueryAlreadyImported}}
|
||||
import * as $ from 'jquery';
|
||||
{{/jqueryAlreadyImported}}
|
||||
import * as models from '../model/models';
|
||||
import { COLLECTION_FORMATS } from '../variables';
|
||||
import { Configuration } from '../configuration';
|
||||
|
||||
/* tslint:disable:no-unused-variable member-ordering */
|
||||
|
||||
{{#operations}}
|
||||
|
||||
|
||||
|
||||
{{#description}}
|
||||
/**
|
||||
* {{&description}}
|
||||
*/
|
||||
{{/description}}
|
||||
export class {{classname}} {
|
||||
protected basePath = '{{{basePath}}}';
|
||||
public defaultHeaders: Array<string> = [];
|
||||
public defaultExtraJQueryAjaxSettings?: JQueryAjaxSettings = null;
|
||||
public configuration: Configuration = new Configuration();
|
||||
|
||||
constructor(basePath?: string, configuration?: Configuration, defaultExtraJQueryAjaxSettings?: JQueryAjaxSettings) {
|
||||
if (basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
if (configuration) {
|
||||
this.configuration = configuration;
|
||||
}
|
||||
if (defaultExtraJQueryAjaxSettings) {
|
||||
this.defaultExtraJQueryAjaxSettings = defaultExtraJQueryAjaxSettings;
|
||||
}
|
||||
}
|
||||
|
||||
private extendObj<T1, T2 extends T1>(objA: T2, objB: T2): T1|T2 {
|
||||
for (let key in objB) {
|
||||
if (objB.hasOwnProperty(key)) {
|
||||
objA[key] = objB[key];
|
||||
}
|
||||
}
|
||||
return objA;
|
||||
}
|
||||
|
||||
{{#operation}}
|
||||
|
||||
/**
|
||||
* {{¬es}}
|
||||
{{#summary}}
|
||||
* @summary {{&summary}}
|
||||
{{/summary}}
|
||||
{{#allParams}}
|
||||
* @param {{paramName}} {{description}}
|
||||
{{/allParams}}
|
||||
*/
|
||||
public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}extraJQueryAjaxSettings?: JQueryAjaxSettings): JQueryPromise<{{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}any{{/returnType}} > {
|
||||
let localVarPath = this.basePath + '{{{path}}}'{{#pathParams}}.replace('{' + '{{baseName}}' + '}', encodeURIComponent(String({{paramName}}))){{/pathParams}};
|
||||
|
||||
let queryParameters: any = {};
|
||||
let headerParams: any = {};
|
||||
{{#hasFormParams}}
|
||||
let formParams = new FormData();
|
||||
let reqHasFile = false;
|
||||
|
||||
{{/hasFormParams}}
|
||||
{{#allParams}}
|
||||
{{#required}}
|
||||
// verify required parameter '{{paramName}}' is not null or undefined
|
||||
if ({{paramName}} === null || {{paramName}} === undefined) {
|
||||
throw new Error('Required parameter {{paramName}} was null or undefined when calling {{nickname}}.');
|
||||
}
|
||||
|
||||
{{/required}}
|
||||
{{/allParams}}
|
||||
{{#queryParams}}
|
||||
{{#isListContainer}}
|
||||
if ({{paramName}}) {
|
||||
{{#isCollectionFormatMulti}}
|
||||
{{paramName}}.forEach((element: any) => {
|
||||
queryParameters['{{baseName}}'].push(element);
|
||||
});
|
||||
{{/isCollectionFormatMulti}}
|
||||
{{^isCollectionFormatMulti}}
|
||||
queryParameters['{{baseName}}'] = {{paramName}}.join(COLLECTION_FORMATS['{{collectionFormat}}']);
|
||||
{{/isCollectionFormatMulti}}
|
||||
}
|
||||
{{/isListContainer}}
|
||||
{{^isListContainer}}
|
||||
if ({{paramName}} !== null && {{paramName}} !== undefined) {
|
||||
{{#isDateTime}}
|
||||
queryParameters['{{baseName}}'] = {{paramName}}.toISOString();
|
||||
{{/isDateTime}}
|
||||
{{^isDateTime}}
|
||||
{{#isDate}}
|
||||
queryParameters['{{baseName}}'] = {{paramName}}.toISOString();
|
||||
{{/isDate}}
|
||||
{{^isDate}}
|
||||
queryParameters['{{baseName}}'] = <string><any>{{paramName}};
|
||||
{{/isDate}}
|
||||
{{/isDateTime}}
|
||||
}
|
||||
{{/isListContainer}}
|
||||
{{/queryParams}}
|
||||
|
||||
localVarPath = localVarPath + "?" + $.param(queryParameters);
|
||||
{{#formParams}}
|
||||
{{#isFile}}
|
||||
reqHasFile = true;
|
||||
formParams.append("{{baseName}}", {{paramName}});
|
||||
{{/isFile}}
|
||||
{{^isFile}}
|
||||
{{#isListContainer}}
|
||||
if ({{paramName}}) {
|
||||
{{#isCollectionFormatMulti}}
|
||||
{{paramName}}.forEach((element: any) => {
|
||||
formParams.append('{{baseName}}', element);
|
||||
});
|
||||
{{/isCollectionFormatMulti}}
|
||||
{{^isCollectionFormatMulti}}
|
||||
formParams.append('{{baseName}}', {{paramName}}.join(COLLECTION_FORMATS['{{collectionFormat}}']));
|
||||
{{/isCollectionFormatMulti}}
|
||||
}
|
||||
{{/isListContainer}}
|
||||
{{^isListContainer}}
|
||||
if ({{paramName}} !== null && {{paramName}} !== undefined) {
|
||||
formParams.append('{{baseName}}', <any>{{paramName}});
|
||||
}
|
||||
{{/isListContainer}}
|
||||
{{/isFile}}
|
||||
{{/formParams}}
|
||||
{{#headerParams}}
|
||||
{{#isListContainer}}
|
||||
if ({{paramName}}) {
|
||||
headerParams['{{baseName}}'] = {{paramName}}.join(COLLECTION_FORMATS['{{collectionFormat}}']);
|
||||
}
|
||||
{{/isListContainer}}
|
||||
{{^isListContainer}}
|
||||
headerParams['{{baseName}}'] = String({{paramName}});
|
||||
{{/isListContainer}}
|
||||
|
||||
{{/headerParams}}
|
||||
// to determine the Content-Type header
|
||||
let consumes: string[] = [
|
||||
{{#consumes}}
|
||||
'{{{mediaType}}}'{{#hasMore}}, {{/hasMore}}
|
||||
{{/consumes}}
|
||||
];
|
||||
|
||||
// to determine the Accept header
|
||||
let produces: string[] = [
|
||||
{{#produces}}
|
||||
'{{{mediaType}}}'{{#hasMore}}, {{/hasMore}}
|
||||
{{/produces}}
|
||||
];
|
||||
|
||||
{{#authMethods}}
|
||||
// authentication ({{name}}) required
|
||||
{{#isApiKey}}
|
||||
{{#isKeyInHeader}}
|
||||
if (this.configuration.apiKey) {
|
||||
headerParams['{{keyParamName}}'] = this.configuration.apiKey;
|
||||
}
|
||||
|
||||
{{/isKeyInHeader}}
|
||||
{{#isKeyInQuery}}
|
||||
if (this.configuration.apiKey) {
|
||||
queryParameters.set('{{keyParamName}}', this.configuration.apiKey);
|
||||
}
|
||||
|
||||
{{/isKeyInQuery}}
|
||||
{{/isApiKey}}
|
||||
{{#isBasic}}
|
||||
// http basic authentication required
|
||||
if (this.configuration.username || this.configuration.password) {
|
||||
headerParams['Authorization'] = 'Basic ' + btoa(this.configuration.username + ':' + this.configuration.password);
|
||||
}
|
||||
|
||||
{{/isBasic}}
|
||||
{{#isOAuth}}
|
||||
// oauth required
|
||||
if (this.configuration.accessToken) {
|
||||
let accessToken = typeof this.configuration.accessToken === 'function'
|
||||
? this.configuration.accessToken()
|
||||
: this.configuration.accessToken;
|
||||
headerParams['Authorization'] = 'Bearer ' + accessToken;
|
||||
}
|
||||
|
||||
{{/isOAuth}}
|
||||
{{/authMethods}}
|
||||
{{#hasFormParams}}
|
||||
if (!reqHasFile) {
|
||||
headerParams['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
{{/hasFormParams}}
|
||||
|
||||
{{#bodyParam}}
|
||||
headerParams['Content-Type'] = 'application/json';
|
||||
|
||||
{{/bodyParam}}
|
||||
let requestOptions: JQueryAjaxSettings = {
|
||||
url: localVarPath,
|
||||
type: '{{httpMethod}}',
|
||||
headers: headerParams,
|
||||
processData: false
|
||||
};
|
||||
|
||||
{{#bodyParam}}
|
||||
requestOptions.data = JSON.stringify({{paramName}});
|
||||
{{/bodyParam}}
|
||||
if (headerParams['Content-Type']) {
|
||||
requestOptions.contentType = headerParams['Content-Type'];
|
||||
}
|
||||
{{#hasFormParams}}
|
||||
requestOptions.data = formParams;
|
||||
if (reqHasFile) {
|
||||
requestOptions.contentType = false;
|
||||
}
|
||||
{{/hasFormParams}}
|
||||
|
||||
if (extraJQueryAjaxSettings) {
|
||||
requestOptions = (<any>Object).assign(requestOptions, extraJQueryAjaxSettings);
|
||||
}
|
||||
|
||||
if (this.defaultExtraJQueryAjaxSettings) {
|
||||
requestOptions = (<any>Object).assign(requestOptions, this.defaultExtraJQueryAjaxSettings);
|
||||
}
|
||||
|
||||
let dfd = $.Deferred();
|
||||
$.ajax(requestOptions).then(
|
||||
(data: {{#returnType}}{{{returnType}}}{{/returnType}}{{^returnType}}any{{/returnType}}, textStatus: string, jqXHR: JQueryXHR) =>
|
||||
dfd.resolve(jqXHR, data),
|
||||
(xhr: JQueryXHR, textStatus: string, errorThrown: string) => {
|
||||
if(false){}
|
||||
{{#responses}}
|
||||
else if (xhr.status == {{{code}}} && {{{code}}} >= 400)
|
||||
{
|
||||
dfd.reject(new {{dataType}}Exception(<{{dataType}}>xhr.responseJSON))
|
||||
}
|
||||
|
||||
{{/responses}}
|
||||
else
|
||||
{
|
||||
|
||||
dfd.reject(errorThrown)
|
||||
}
|
||||
}
|
||||
);
|
||||
return dfd.promise();
|
||||
}
|
||||
|
||||
{{/operation}}
|
||||
}
|
||||
{{/operations}}
|
9
lib/swagger-typescript-jquery/apis.mustache
Normal file
9
lib/swagger-typescript-jquery/apis.mustache
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{#apiInfo}}
|
||||
{{#apis}}
|
||||
{{#operations}}
|
||||
export * from './{{ classFilename }}';
|
||||
import { {{ classname }} } from './{{ classFilename }}';
|
||||
{{/operations}}
|
||||
{{/apis}}
|
||||
export const APIS = [{{#apis}}{{#operations}}{{ classname }}{{/operations}}{{^-last}}, {{/-last}}{{/apis}}];
|
||||
{{/apiInfo}}
|
6
lib/swagger-typescript-jquery/configuration.mustache
Normal file
6
lib/swagger-typescript-jquery/configuration.mustache
Normal file
|
@ -0,0 +1,6 @@
|
|||
export class Configuration {
|
||||
apiKey: string;
|
||||
username: string;
|
||||
password: string;
|
||||
accessToken: string | (() => string);
|
||||
}
|
52
lib/swagger-typescript-jquery/git_push.sh.mustache
Executable file
52
lib/swagger-typescript-jquery/git_push.sh.mustache
Executable file
|
@ -0,0 +1,52 @@
|
|||
#!/bin/sh
|
||||
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
|
||||
#
|
||||
# Usage example: /bin/sh ./git_push.sh wing328 swagger-petstore-perl "minor update"
|
||||
|
||||
git_user_id=$1
|
||||
git_repo_id=$2
|
||||
release_note=$3
|
||||
|
||||
if [ "$git_user_id" = "" ]; then
|
||||
git_user_id="{{{gitUserId}}}"
|
||||
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
|
||||
fi
|
||||
|
||||
if [ "$git_repo_id" = "" ]; then
|
||||
git_repo_id="{{{gitRepoId}}}"
|
||||
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
|
||||
fi
|
||||
|
||||
if [ "$release_note" = "" ]; then
|
||||
release_note="{{{releaseNote}}}"
|
||||
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
|
||||
fi
|
||||
|
||||
# Initialize the local directory as a Git repository
|
||||
git init
|
||||
|
||||
# Adds the files in the local repository and stages them for commit.
|
||||
git add .
|
||||
|
||||
# Commits the tracked changes and prepares them to be pushed to a remote repository.
|
||||
git commit -m "$release_note"
|
||||
|
||||
# Sets the new remote
|
||||
git_remote=`git remote`
|
||||
if [ "$git_remote" = "" ]; then # git remote not defined
|
||||
|
||||
if [ "$GIT_TOKEN" = "" ]; then
|
||||
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
|
||||
git remote add origin https://github.com/${git_user_id}/${git_repo_id}.git
|
||||
else
|
||||
git remote add origin https://${git_user_id}:${GIT_TOKEN}@github.com/${git_user_id}/${git_repo_id}.git
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
git pull origin master
|
||||
|
||||
# Pushes (Forces) the changes in the local repository up to the remote repository
|
||||
echo "Git pushing to https://github.com/${git_user_id}/${git_repo_id}.git"
|
||||
git push origin master 2>&1 | grep -v 'To https'
|
||||
|
4
lib/swagger-typescript-jquery/index.mustache
Normal file
4
lib/swagger-typescript-jquery/index.mustache
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './api/api';
|
||||
export * from './model/models';
|
||||
export * from './variables';
|
||||
export * from './configuration';
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue