Support for grape and onboarding via react

This commit is contained in:
Eric Schultz 2018-05-21 15:03:46 -05:00
parent 9c162d3f0d
commit 4c5b997d65
133 changed files with 14283 additions and 14420 deletions

12
.bootstraprc Normal file
View file

@ -0,0 +1,12 @@
{
"bootstrapVersion": 3,
"styleLoaders": ["style", "css", "sass"],
"extractStyles": true,
"styles": {
"mixins": true,
"grid": true,
"forms": true
},
"scripts": false
}

10
Gemfile
View file

@ -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'

View file

@ -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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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'

View 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

View 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.

View file

@ -0,0 +1,6 @@
class OnboardController < ApplicationController
layout 'layouts/apified'
def index
end
end

View file

@ -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

View file

@ -0,0 +1,2 @@
module OnboardHelper
end

View file

@ -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

View 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>

View 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>

View file

@ -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; }*/

View file

@ -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';

View file

@ -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);

View file

@ -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 ]

View file

@ -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

View file

@ -39,4 +39,6 @@ Commitchange::Application.configure do
config.assets.debug = true
config.log_level = :debug
config.action_controller.allow_forgery_protection = false
end

View 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

View file

@ -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"

View file

@ -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]})

View file

@ -48,7 +48,7 @@ page_editor:
editor: 'quill'
language: 'en'
available_locales: ['en']
available_locales: ['en', 'de']
intntl:
currencies: ["usd"]

View file

@ -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"

View 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

View file

@ -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()
})
})

View 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>;
}
}

View 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>
}
}

View file

@ -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()
})
})

View 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>
}
}

View file

@ -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>
`;

View file

@ -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 />`;

View 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>
}))

View 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>
})

View 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);
}
}

View 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)
})
})

View 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>;
}
}

View file

@ -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()
})
})

View 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>
}
}

View 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)
})
})

View 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))

View 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>;
}
}

View file

@ -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>
`;

View 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';
}
}

View 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()
})
})
})

View 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
}
}

View file

@ -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())
// })
})

View file

@ -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))

View file

@ -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))

View file

@ -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))

View file

@ -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)
)
)

View file

@ -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))

View file

@ -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))

View file

@ -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)
})
})

View file

@ -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>;
}
}

View 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
}

View 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)
})
})
})

View 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;
}

View file

@ -0,0 +1,4 @@
// License: LGPL-3.0-or-later
import {WebUserSignInOut} from "./api/sign_in";
export const APIS = [WebUserSignInOut]

View 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)
}

View 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}"`)
}
})
})
})
}
}

View 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()
)
})

View file

@ -0,0 +1,2 @@
// License: LGPL-3.0-or-later
export const Email = /^[^ ]+@[^ ]+\.[^ ]+$/i

View 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})
}})
}

View 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})
}
};
}
}

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -0,0 +1,8 @@
Description:
Explain the generator
Example:
rails generate validator Thing
This will create:
what/will/it/create

View 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

View 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

View file

@ -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

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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 %>))

View file

@ -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)
})
})

View file

@ -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

View 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'

View 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

View file

@ -0,0 +1,2 @@
// License: LGPL-3.0-or-later
declare module "<%= name %>"

View 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

View 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

View 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}}
/**
* {{&notes}}
{{#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}}

View 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}}

View file

@ -0,0 +1,6 @@
export class Configuration {
apiKey: string;
username: string;
password: string;
accessToken: string | (() => string);
}

View 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'

View 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