Merge pull request #100 from houdiniproject/edit_donation

Edit donation via React. Fixes dedication field. Closes #95
This commit is contained in:
Eric Schultz 2018-10-25 12:55:25 -05:00 committed by GitHub
commit 68aa5b1f53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 2619 additions and 451 deletions

View file

@ -6,6 +6,7 @@
"mixins": true,
"grid": true,
"forms": true,
"input-groups": true,
"responsive-utilities":true,
"panels": true,
"type": true

View file

@ -1,97 +1,9 @@
<%- # License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later -%>
<!-- partial: donations/edit_modal -->
<div class="modal" id='editDonationModal'>
<!--= scope 'payment_details.data' -->
<%= render 'common/modal_header', title: 'Edit Donation' %>
<div class='modal-body'>
<%= render 'payment_info' %>
<form class='u-marginTop--20' parsley-validate>
<!--= on 'submit' (update_donation form_object) -->
<div>
<!--= show_if (eq this.kind 'OffsitePayment') -->
<div class='layout--two'>
<fieldset>
<label>Gross Amount<br><small>This amount should be more than zero</small></label>
<input required type='number' min='0.01' name='gross_amount' step='0.01'>
<!--= set_value (remove_commas (cents_to_dollars this.gross_amount -->
</fieldset>
<fieldset>
<label>Processing Fees <small>(optional)</small><br><small>This amount should be 0 or negative</small></label>
<input required type='number' name='fee_total' max='0' step='0.01'>
<!--= set_value (remove_commas (cents_to_dollars this.fee_total -->
</fieldset>
</div>
<div id='EditPaymentPaneElement'/>
<fieldset>
<label>Date</label>
<input required type='text' name='date'>
<!--= set_value (readable_date_time this.date) -->
</fieldset>
<fieldset>
<label>Check or Payment Number/ID</label>
<input type='text' name='check_number'>
<!--= set_value (this.offsite_payment.check_number) -->
</fieldset>
</div>
<fieldset>
<label>Campaign</label>
<select name='campaign_id'>
<option>
<!--= repeat campaigns.data -->
<!--= set_attr_if (eq payment_details.data.donation.campaign.name this.name) 'selected' true -->
<!--= set_value this.id -->
<!--= put this.name -->
</option>
</select>
</fieldset>
<fieldset>
<label>Event</label>
<select name='event_id'>
<option>
<!--= repeat events.data -->
<!--= set_attr_if (eq payment_details.data.donation.event.name this.name) 'selected' true -->
<!--= set_value this.id -->
<!--= put this.name -->
</option>
</select>
</fieldset>
<input type='hidden' name='id'>
<!--= set_value this.donation.id -->
<div class='layout--two'>
<fieldset>
<label>Dedication <small> (optional)</small></label>
<textarea rows='3' name='dedication' placeholder='Dedication'></textarea>
<!--= set_value this.donation.dedication -->
</fieldset>
<fieldset>
<label>Designation<small> (optional)</small></label>
<textarea rows='3' name='designation' placeholder='Designation'></textarea>
<!--= set_value this.donation.designation -->
</fieldset>
</div>
<fieldset>
<label>Notes <small> (optional) </small> </label>
<textarea name='comment' rows='3' placeholder='Notes'></textarea>
<!--= set_value this.donation.comment -->
</fieldset>
<%= render 'components/forms/submit_button', button_text: 'Save', loading_text: 'Updating...' %>
</form>
</div>
</div>
<!-- end partial: donations/edit_modal -->

View file

@ -1,7 +1,6 @@
<%- # License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later -%>
<!-- partial nonprofits/payments/_side_panel -->
<div class='sidePanel'>
<!--= add_class_if loading 'u-halfOpacity' -->
@ -25,7 +24,7 @@
<div class='u-marginTop--20'>
<a class="button--tiny edit">
<!--= show_if (all (payment_details.data.donation) (not payment_details.data.dispute)) -->
<!--= on 'click' (open_modal 'editDonationModal') -->
<!--= on 'click' (open_donation_modal payment_details) -->
<i class='fa fa-pencil'></i> Edit Donation
</a>

View file

@ -4,13 +4,42 @@
<% content_for :stylesheets do %>
<%= stylesheet_link_tag 'nonprofits/payments/index/page' %>
<%= IncludeAsset.css '/client/css/bootstrap.css' %>
<% end %>
<% content_for :javascripts do %>
<script>
appl.def('has_bank', <%= !!@nonprofit.bank_account %>)
</script>
<%= IncludeAsset.js '/client/js/nonprofits/payments/index/page.js' %>
<%= IncludeAsset.js '/app/react.js' %>
<%= IncludeAsset.js '/app/react-dom.js' %>
<%= IncludeAsset.js '/app/vendor.js' %>
<%= IncludeAsset.js '/app/edit_payment_panex.js' %>
<script>
appl.def('open_donation_modal', function(payment_details) {
function SetupLoadReactEditPaymentPane(modalActive){
LoadReactEditPaymentPane(document.getElementById('EditPaymentPaneElement'),
payment_details.data,
appl.campaigns.data,
appl.events.data,
() => {SetupLoadReactEditPaymentPane(false)},
modalActive,
appl.start_loading,
appl.update_donation__success,
ENV.nonprofitTimezone)
}
SetupLoadReactEditPaymentPane(true)
return appl
})
</script>
<% end %>
<%= render '/components/trial_bar' if QueryBillingSubscriptions.currently_in_trial?(@nonprofit.id) %>

View file

@ -19,13 +19,13 @@ child :donation, object_root: false do
child :campaign, object_root: false do
attributes :name, :url
attributes :name, :url, :id
end
node(:campaign_gift){|d| {name: d.campaign_gifts.any? ? d.campaign_gifts.last.campaign_gift_option.name : nil}}
child :event, object_root: false do
attributes :name, :url
attributes :name, :url, :id
end
child :recurring_donation, object_root: false do
@ -49,7 +49,7 @@ end
node(:ticket) do |payment|
event = GetData.obj(payment.tickets.last, :event)
h = {
event: {name: GetData.obj(event, :name), url: GetData.obj(event, :url)},
event: {name: GetData.obj(event, :name), url: GetData.obj(event, :url), id: GetData.obj(event, :id)},
levels: payment.tickets.map{|t| "#{GetData.chain(t.ticket_level, :name)} (#{t.quantity}x)"}.join(", "),
discount: payment.tickets.map{|t| t.event_discount ? "#{t.event_discount.name} (#{t.event_discount.percent}%)" : nil}.compact.join(", ")
}
@ -68,3 +68,7 @@ child :supporter do
attributes :name, :email, :city, :state_code, :address, :zip_code, :phone, :id, :country
end
child :nonprofit do
attributes :id
end

View file

@ -2,10 +2,42 @@
<% content_for(:dont_load_optimizely) {'true'} %>
<% content_for(:footer_hidden) {'hidden'} %>
<%= content_for(:stylesheets) {stylesheet_link_tag 'nonprofits/supporters/index/page'} %>
<% content_for(:stylesheets) do %>
<%= stylesheet_link_tag 'nonprofits/supporters/index/page' %>
<%= IncludeAsset.css '/client/css/bootstrap.css' %>
<% end %>
<%= content_for :javascripts do %>
<%= IncludeAsset.js '/client/js/nonprofits/supporters/index/page.js' %>
<%= render 'common/froala' %>
<%= IncludeAsset.js '/app/react.js' %>
<%= IncludeAsset.js '/app/react-dom.js' %>
<%= IncludeAsset.js '/app/vendor.js' %>
<%= IncludeAsset.js '/app/create_new_offsite_payment_panex.js' %>
<script>
appl.def('open_donation_modal', function(supporter_id, donation_finish_successful_state_fn) {
$('.modal').removeClass('inView')
function SetupLoadCreateOffsiteDonationPane(modalActive){
LoadReactCreateOffsiteDonationPane(document.getElementById('react-vdom-hack'),
appl.campaigns.data,
appl.events.data,
app.nonprofit_id,
supporter_id,
() => {}, //appl.start_loading,
donation_finish_successful_state_fn, //appl.update_donation__success,
() => {SetupLoadCreateOffsiteDonationPane(false)},
modalActive,
ENV.nonprofitTimezone)
}
SetupLoadCreateOffsiteDonationPane(true)
return appl
})
</script>
<% end %>
<%= render '/components/trial_bar' if nonprofit_in_trial?(@nonprofit.id) %>
@ -58,3 +90,5 @@
<%= render 'donations/new_offline_modal' %>
<div id='js-vdomParty'></div>
<div id="react-vdom-hack"></div>

View file

@ -21,7 +21,7 @@ function view(state) {
, type: 'radio'
, id: radioId1
, value: 'honor'
, selected: data.dedication_type === 'honor'
, checked: !data.dedication_type || data.dedication_type === 'honor'
}})
, h('label', {props: {htmlFor: radioId1}}, I18n.t('nonprofits.donate.dedication.in_honor_label'))
])
@ -31,7 +31,7 @@ function view(state) {
, type: 'radio'
, value: 'memory'
, id: radioId2
, selected: data.dedication_type === 'memory'
, checked: data.dedication_type === 'memory'
}})
, h('label', {props: {htmlFor: radioId2}}, I18n.t('nonprofits.donate.dedication.in_memory_label'))
])

View file

@ -166,7 +166,7 @@ function view(state) {
])
, weekly
, dedic && (dedic.first_name || dedic.last_name)
? h('p.u-centered', `${dedic.dedication_type === 'memory' ? I18n.t('nonprofits.donate.dedication.in_memory_label') : I18n.t('nonprofits.donate.dedication.in_honor_label')} ` + `${dedic.first_name} ${dedic.last_name}`)
? h('p.u-centered', `${dedic.dedication_type === 'memory' ? I18n.t('nonprofits.donate.dedication.in_memory_label') : I18n.t('nonprofits.donate.dedication.in_honor_label')} ` + `${dedic.first_name || ''} ${dedic.last_name || ''}`)
: ''
, paymentTabs(state)
])

View file

@ -127,7 +127,9 @@ const setDonationDedication = (don, dedication) => {
, JSON.stringify({
supporter_id: dedication.supporter.id
, name: dedication.supporter.name
, contact: R.join(" - ", [dedication.supporter.email, dedication.supporter.phone, dedication.supporter.address])
, contact: {email: dedication.supporter.email,
phone: dedication.supporter.phone,
address: dedication.supporter.address}
, note: dedication.note
, type: dedication.type
})

View file

@ -89,27 +89,15 @@ appl.def('get_payment_purchase_object', function(payment) {
}
})
appl.def('update_donation', function(donation) {
if(!donation) return
appl.def('start_loading', function(){
appl.def('loading', true)
donation.gross_amount = format.dollarsToCents(donation.gross_amount)
donation.fee_total = format.dollarsToCents(donation.fee_total)
var formattedDate = appl.readable_date_time_to_iso(donation.date)
if(formattedDate && formattedDate != "Invalid date") {
donation.date = formattedDate
} else {
appl.notify('Please enter a valid date')
appl.def('loading', false)
return
}
request.put('/nonprofits/' + app.nonprofit_id + '/donations/' + donation.id)
.send({donation: donation})
.end(function(err, resp) {
appl.ajax_payment_details.fetch(appl.payment_details.data.id)
appl.def('loading', false)
appl.close_modal()
appl.notify('Donation successfully updated!')
})
})
appl.def('update_donation__success', function() {
appl.ajax_payment_details.fetch(appl.payment_details.data.id)
appl.def('loading', false)
// appl.close_modal()
appl.notify('Donation successfully updated!')
})
appl.def('delete_offline_donation', function() {
@ -161,14 +149,17 @@ appl.def('format_dedication', function(dedic, node) {
var json
try { json = JSON.parse(dedic) } catch(e) {}
if(json) {
let supporter_link = (json.supporter_id && json.supporter_id != '') ?
`<a href='/nonprofits/${app.nonprofit_id}/supporters?sid=${json.supporter_id}'>${json.name}</a>` :
json.name
inner = `
Donation made in ${dedic.type || 'honor'} of
<a href='/nonprofits/${app.nonprofit_id}/supporters?sid=${json.supporter_id}'>${json.name}</a>.
Donation made in ${json.type || 'honor'} of
${supporter_link}.
${json.note ? `<br>Note: <em>${json.note}</em>.` : ''}
`
} else {
// Print plaintext dedication
inner = dedic
inner = ''
}
}
td.innerHTML = inner

View file

@ -31,6 +31,7 @@ const init = _ => {
, newNote$: flyd.stream()
, editNote$: flyd.stream()
, deleteNote$: flyd.stream()
, newDonation$: flyd.stream()
}
const supporterID$ = R.compose(
@ -95,7 +96,6 @@ const init = _ => {
, flyd.map(()=> 'replyGmailModal', state.threadId$)
, flyd.map(()=> 'newSupporterNoteModal', state.editNoteData$)
, flyd.map(()=> null, state.gmail.sendResponse$)
, flyd.map(()=> null, state.offsiteDonationForm.saved$)
, flyd.map(()=> null, state.supporterNoteForm.saved$)
])
@ -143,7 +143,7 @@ const view = state => {
, activities.view(state)
, composeModal(state)
, notification.view(state.notification)
, offsiteDonationForm.view(R.merge(state.offsiteDonationForm, {modalID$: state.modalID$}))
, offsiteDonationForm.view(R.merge(state.offsiteDonationForm))
, supporterNoteForm.view(R.merge(state.supporterNoteForm, {modalID$: state.modalID$}))
, replyModal(state)
, confirm.view(state.confirmDelete, 'Are you sure you want to delete this note?')

View file

@ -8,168 +8,26 @@ const format = require('../../../../common/format')
const moment = require('moment')
const request = require('../../../../common/request')
const serialize = require('form-serialize')
const flyd_filter = require('flyd/module/filter')
const flyd_flatMap = require('flyd/module/flatmap')
const flyd_mergeAll = require('flyd/module/mergeall')
var rootUrl = `/nonprofits/${app.nonprofit_id}`
const getFundraisers = type => {
var response$ = request({
method: 'get'
, path: `${rootUrl}/${type}/name_and_id`
}).load
var body$ = R.compose(
flyd.map(x => x.body)
, flyd_filter(x => x.status === 200 || x.status === 304)
)(response$)
return flyd.merge(flyd.stream([]), body$)
}
function init(parentState) {
var state = {
submit$: flyd.stream()
, supporter$: parentState.supporter$
, campaigns$: getFundraisers('campaigns')
, events$: getFundraisers('events')
, saved$: flyd.stream()
}
const resp$ = flyd_flatMap(
form => request({
method: 'post'
, path: `${rootUrl}/donations/create_offsite`
, send: {donation: setDefaults(serialize(form, {hash: true}))}
}).load
, state.submit$ )
state.saved$ = flyd_filter(resp => resp.status === 200, resp$)
state.error$ = flyd_filter(resp => resp.status !== 200, resp$)
state.loading$ = flyd_mergeAll([
flyd.map(()=> true, state.submit$)
, flyd.map(() => false, resp$)
])
return state
}
const setDefaults = formData =>
R.evolve({
amount: format.dollarsToCents
, date: d => moment(d).format("YYYY-MM-DD")
}, formData)
function view(state) {
var body = form(state)
return h('div', [
modal({
id$: state.modalID$
, thisID: 'newOffsiteDonationModal'
, title: 'New Offsite Contribution'
, body
})
])
return h('div', {id$: 'offsite_donation_form_modal'})
}
const form = state => {
return h('form', {
on: {submit: ev => {ev.preventDefault(); state.submit$(ev.currentTarget)}}
}, [
h('input', {
props: {
type: 'hidden'
, name: 'nonprofit_id'
, value: app.nonprofit_id
}
})
, h('input', {
props: {
type: 'hidden'
, name: 'supporter_id'
, value: state.supporter$().id
}
})
, h('div.layout--four', [
h('fieldset', [
h('label', 'Amount')
, h('div.prepend--dollar', [
h('input', {
props: {
name: 'amount'
, step: 'any'
, type: 'number'
, min: 0
, required: true
}
})
])
])
, h('fieldset', [
h('label', 'Date')
, h('input', {
props: {
id: 'js-offsiteDonationDate'
, name: 'date'
, type: 'text'
, placeholder: 'MM/DD/YYYY'
}
})
])
, h('fieldset', [
h('label', 'Type')
, h('select', {props: {name: 'offsite_payment[kind]'}}, [
h('option', {props: {selected: true, value: 'check'}}, 'Check')
, h('option', {props: {value: 'cash'}}, 'Cash')
, h('option', {props: {value: ''}}, 'Other')
])
])
, h('fieldset', [
h('label', 'Check Number')
, h('input', {
props: {
name: 'offsite_payment[check_number]'
, type: 'text'
, placeholder: '1234'
}
})
])
])
, h('div.layout--two', [
h('fieldset', [
h('label', ['Towards an Event', h('small', ' (optional) ')])
, fundraiserSelects('event', state.events$())
])
, h('fieldset', [
h('label', ['Towards a Campaign ', h('small', ' (optional) ')])
, fundraiserSelects('campaign', state.campaigns$())
])
])
, h('div.layout--two', [
h('fieldset', [
h('label', ['In Memory/Honor Of ', h('small', ' (optional) ')])
, h('textarea', {props: {rows: 3, name: 'dedication', placeholder: 'In Memory/Honor Of'}})
])
, h('fieldset', [
h('label', ['Designation ', h('small', ' (optional) ')])
, h('textarea', {props: {rows: 3, name: 'designation', placeholder: 'Designation'}})
])
])
, h('fieldset', [
h('label', ['Notes ', h('small', ' (optional) ')])
, h('textarea', {props: {rows: 3, name: 'comment', placeholder: 'Notes'}})
])
, h('div.u-centered', [
button({loading$: state.loading$, error$: state.error$})
])
])
}
const fundraiserSelects = (type, arr) =>
h('select', {props: {name: `${type}_id`}}
, R.concat(
[h('option', {props: {value: ''}}, 'Select One')]
, R.map(x => h('option', {props: {value: x.id}}, x.name), arr)
)
)
module.exports = {init, view}

View file

@ -5,6 +5,7 @@ const flyd = require('flyd')
const h = require('snabbdom/h')
flyd.mergeAll = require('flyd/module/mergeall')
const button = (text, stream) =>
h('button.button--tiny.u-marginRight--10', {on: {click: stream}}
, [h('i.fa.fa-plus.u-marginRight--5') , text ])
@ -13,8 +14,12 @@ const view = state =>
h('section.timeline-actions.u-padding--10', [
button('Note', state.newNote$)
, button('Email', state.clickComposing$)
, button('Donation', [state.modalID$, 'newOffsiteDonationModal'])
])
, button('Donation', () => appl.open_donation_modal(state.supporter$().id,
() => {state.offsiteDonationForm.saved$(Math.random())}
)
)
]
)
module.exports = {view}

View file

@ -0,0 +1,30 @@
class CorrectDedications < ActiveRecord::Migration
def up
execute <<-SQL
create or replace function is_valid_json(p_json text)
returns boolean
as
$$
begin
return (p_json::json is not null);
exception
when others then
return false;
end;
$$
language plpgsql
immutable;
SQL
dedications = MaintainDedications.retrieve_non_json_dedications
MaintainDedications.create_json_dedications_from_plain_text(dedications)
dedications = MaintainDedications.retrieve_json_dedications
MaintainDedications.add_honor_to_any_json_dedications_without_type(dedications)
end
def down
end
end

View file

@ -0,0 +1,52 @@
class CorrectDedicationContacts < ActiveRecord::Migration
def up
json_dedications = Qx.select('id', 'dedication').from(:donations)
.where("dedication IS NOT NULL AND dedication != ''")
.and_where("is_valid_json(dedication)").ex
parsed_dedications = json_dedications.map{|i| {id: i['id'], dedication: JSON.parse(i['dedication'])}}
with_contact_to_correct = parsed_dedications.select {|i| !i[:dedication]['contact'].blank? && i[:dedication]['contact'].is_a?(String)}
really_icky_dedications, easy_to_split_strings = with_contact_to_correct.partition{|i| i[:dedication]['contact'].split(" - ").count > 3}
easy_to_split_strings.map do |i|
split_contact = i[:dedication]['contact'].split(' - ')
i[:dedication]['contact'] = {
email: split_contact[0],
phone:split_contact[1],
address: split_contact[2],
}
puts i
i
end.each_with_index do |i, index|
unless (i[:id])
raise Error("Item at index #{index} is invalid. Object:#{i}")
end
Qx.update(:donations).where("id = $id", id:i[:id]).set(dedication: JSON.generate(i[:dedication])).ex
end
puts "Corrected #{easy_to_split_strings.count} records."
puts ""
puts ""
puts "You must manually fix the following dedications: "
really_icky_dedications.each do |i|
puts i
end
end
def down
json_dedications = Qx.select('id', 'dedication').from(:donations)
.where("dedication IS NOT NULL AND dedication != ''")
.and_where("is_valid_json(dedication)").ex
parsed_dedications = json_dedications.map{|i| {'id' => i['id'], 'dedication' => JSON.parse(i['dedication'])}}
with_contact_to_correct = parsed_dedications.select {|i| i['dedication']['contact'].is_a?(Hash)}
puts "#{with_contact_to_correct.count} to revert"
with_contact_to_correct.each do |i|
contact_string = "#{i['dedication']['contact']['email']} - #{i['dedication']['contact']['phone']} - #{i['dedication']['contact']['address']}"
i['dedication']['contact'] = contact_string
Qx.update(:donations).where("id = $id", id:i['id']).set(dedication: JSON.generate(i['dedication'])).ex
end
end
end

View file

@ -3,7 +3,7 @@
--
-- Dumped from database version 9.6.5
-- Dumped by pg_dump version 9.6.9
-- Dumped by pg_dump version 9.6.10
SET statement_timeout = 0;
SET lock_timeout = 0;
@ -57,6 +57,22 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
--
-- Name: is_valid_json(text); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.is_valid_json(p_json text) RETURNS boolean
LANGUAGE plpgsql IMMUTABLE
AS $$
begin
return (p_json::json is not null);
exception
when others then
return false;
end;
$$;
--
-- Name: update_supporter_assoc_search_vectors(); Type: FUNCTION; Schema: public; Owner: -
--
@ -4327,3 +4343,7 @@ INSERT INTO schema_migrations (version) VALUES ('20180713215825');
INSERT INTO schema_migrations (version) VALUES ('20180713220028');
INSERT INTO schema_migrations (version) VALUES ('20181002160627');
INSERT INTO schema_migrations (version) VALUES ('20181003212559');

View file

@ -0,0 +1,33 @@
// 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 CreateOffsitePaymentPane from "../src/components/create_offsite_payment_pane/CreateOffsitePaymentPane"
import * as ReactDOM from 'react-dom'
import * as React from 'react'
export interface FundraiserInfo {
id: number
name: string
}
function LoadReactPage(element:HTMLElement, events: FundraiserInfo[],
campaigns: FundraiserInfo[],
nonprofitId: number,
supporterId:number,
preupdateDonationAction:() => void,
postUpdateSuccess: () => void,
//from ModalProps
onClose: () => void,
modalActive: boolean,
nonprofitTimezone?: string) {
ReactDOM.render(<Root><CreateOffsitePaymentPane campaigns={campaigns}
events={events} onClose={onClose}
modalActive={modalActive} nonprofitTimezone={nonprofitTimezone}
postUpdateSuccess={postUpdateSuccess}
preupdateDonationAction={preupdateDonationAction} nonprofitId={nonprofitId} supporterId={supporterId}/></Root>, element)
}
(window as any).LoadReactCreateOffsiteDonationPane = LoadReactPage

View file

@ -0,0 +1,27 @@
// 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 EditPaymentPane, {FundraiserInfo} from "../src/components/edit_payment_pane/EditPaymentPane"
import * as ReactDOM from 'react-dom'
import * as React from 'react'
function LoadReactPage(element:HTMLElement, data:any, campaigns:FundraiserInfo[],
events:FundraiserInfo[],
onClose:() => void,
modalActive:boolean,
preupdateDonationAction: () => void,
postUpdateSuccess: () => void,
nonprofitTimezone?:string
) {
ReactDOM.render(<Root><EditPaymentPane data={data} campaigns={campaigns}
events={events} onClose={onClose}
modalActive={modalActive} nonprofitTimezone={nonprofitTimezone}
postUpdateSuccess={postUpdateSuccess}
preupdateDonationAction={preupdateDonationAction}
/></Root>, element)
}
(window as any).LoadReactEditPaymentPane = LoadReactPage

View file

@ -0,0 +1,28 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import 'jest';
import Modal, {ModalProps} from './Modal'
import {shallow} from "enzyme";
import {toJS} from "mobx";
import toJson from "enzyme-to-json";
describe('Modal', () => {
test('nothing displayed if inactive', () => {
let modal = shallow(<Modal childGenerator={() => <div/>}/>)
expect(toJson(modal)).toMatchSnapshot()
})
test('active modal displays', () => {
let onCloseWasCalled = false
let modal = shallow(<Modal titleText={"title text"}
focusDialog={true}
modalActive={true}
onClose={() => { onCloseWasCalled = true}}
childGenerator={() => <div/>}/>)
expect(toJson(modal)).toMatchSnapshot()
let modalComponent = modal.instance() as React.Component<ModalProps, {}> //casting to modal didn't work for reasons?
modalComponent.props.onClose()
expect(onCloseWasCalled).toBeTruthy()
})
})

View file

@ -0,0 +1,41 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import { observer } from 'mobx-react';
import AriaModal = require('react-aria-modal');
export interface ModalProps
{
onClose?: () => void // if you want your modal to close, this needs to set modalActive to false
modalActive?: boolean
titleText?: string
focusDialog?:boolean
dialogStyle?:any
childGenerator:() => any
}
class Modal extends React.Component<ModalProps, {}> {
static defaultProps = {
dialogStyle: {minWidth:'768px'}
}
render() {
const modal = this.props.modalActive ?
<AriaModal mounted={this.props.modalActive} titleText={this.props.titleText} focusDialog={this.props.focusDialog}
onExit={this.props.onClose} dialogStyle={this.props.dialogStyle}>
<header className='modal-header'>
<h4 className='modal-header-title'>{this.props.titleText}</h4>
</header>
<div className="modal-body">
{this.props.childGenerator()}
</div>
</AriaModal> : false;
return modal
}
}
export default observer(Modal)

View file

@ -30,7 +30,7 @@ export default class StandardFieldComponent extends React.Component<StandardFiel
let stickyErrorDiv = this.props.inStickyError ? <div className="help-block" role="alert">{stickyErrorMessage}</div> : ""
return <div>
{this.renderChildren()}
{this.props.children}
{errorDiv}
{stickyErrorDiv }
</div>

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Modal active modal displays 1`] = `
<Displaced
dialogStyle={
Object {
"minWidth": "768px",
}
}
focusDialog={true}
mounted={true}
onExit={[Function]}
titleText="title text"
>
<header
className="modal-header"
>
<h4
className="modal-header-title"
>
title text
</h4>
</header>
<div
className="modal-body"
>
<div />
</div>
</Displaced>
`;
exports[`Modal nothing displayed if inactive 1`] = `""`;

View file

@ -2,10 +2,7 @@
exports[`StandardFieldComponent sets error message properly 1`] = `
<div>
<input
className="form-control"
key=".0"
/>
<input />
<div
className="help-block"
role="alert"
@ -17,10 +14,7 @@ exports[`StandardFieldComponent sets error message properly 1`] = `
exports[`StandardFieldComponent works with a child 1`] = `
<div>
<input
className="form-control"
key=".0"
/>
<input />
</div>
`;

View file

@ -3,9 +3,12 @@ import * as React from 'react';
import {observer} from "mobx-react";
import {Field} from "../../../../types/mobx-react-form";
import LabeledFieldComponent from "./LabeledFieldComponent";
import {injectIntl, InjectedIntl} from 'react-intl';
import {HoudiniField} from "../../lib/houdini_form";
import ReactInput from "./form/ReactInput";
import ReactSelect from './form/ReactSelect';
import ReactTextarea from "./form/ReactTextarea";
import ReactMaskedInput from "./form/ReactMaskedInput";
import createNumberMask from "../../lib/createNumberMask";
export const BasicField = observer((props:{field:Field, placeholder?:string, label?:string, wrapperClassName?:string, inputClassNames?:string}) =>{
@ -14,7 +17,55 @@ export const BasicField = observer((props:{field:Field, placeholder?:string, lab
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
inStickyError={field.hasServerError} stickyError={field.serverError}
className={props.wrapperClassName} >
<ReactInput field={field} label={props.label} placeholder={props.placeholder} className={`form-control ${props.inputClassNames || ''}`}/>
</LabeledFieldComponent>
})
})
export const SelectField = observer((props:{field:Field, placeholder?:string, label?:string, wrapperClassName?:string, inputClassNames?:string, options?:Array<{id:any, name:string}>}) =>{
let field = props.field as HoudiniField
return <LabeledFieldComponent
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
inStickyError={field.hasServerError} stickyError={field.serverError}
className={props.wrapperClassName} >
<ReactSelect field={field} label={props.label} placeholder={props.placeholder} className={`form-control ${props.inputClassNames}`} options={props.options}/>
</LabeledFieldComponent>
})
export const TextareaField = observer((props:{field:Field, placeholder?:string, label?:string, wrapperClassName?:string, inputClassNames?:string, rows?:number}) =>{
let field = props.field as HoudiniField
return <LabeledFieldComponent
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
inStickyError={field.hasServerError} stickyError={field.serverError}
className={props.wrapperClassName} >
<ReactTextarea field={field} label={props.label} placeholder={props.placeholder} className={`form-control ${props.inputClassNames}`} rows={props.rows}/>
</LabeledFieldComponent>
})
export const CurrencyField = observer((props:{field:Field,placeholder?:string, label?:string, currencySymbol?:string, wrapperClassName?:string, inputClassNames?:string, mustBeNegative?:boolean, allowNegative?:boolean}) => {
let field = props.field as HoudiniField
let currencySymbol = props.mustBeNegative ? "-$" : "$"
let allowNegative = props.allowNegative || !props.mustBeNegative
return <LabeledFieldComponent
inputId={props.field.id} labelText={field.label} inError={field.hasError} error={field.error}
inStickyError={field.hasServerError} stickyError={field.serverError}
className={props.wrapperClassName} >
<ReactMaskedInput field={field} label={props.label} placeholder={props.placeholder}
className={`form-control ${props.inputClassNames}`} guide={true}
mask={createNumberMask({allowDecimal:true,
requireDecimal:true,
prefix:currencySymbol,
allowNegative:allowNegative,
fixedDecimalScale:true
})}
showMask={true} placeholderChar={'0'}
/>
</LabeledFieldComponent>
});

View file

@ -6,35 +6,36 @@ import {Form} from "mobx-react-form";
import {mount} from 'enzyme';
import {toJS, observable, action, runInAction} from 'mobx';
import {observer} from 'mobx-react';
import {InputHTMLAttributes} from 'react';
import {ReactForm} from "./ReactForm";
@observer
class TestChange extends React.Component{
class TestChange extends React.Component {
@observable
remove:boolean
remove: boolean
@observable
form: Form
@action.bound
componentWillMount(){
this.form = new Form({fields:[{
name: 'name',
extra: null}
]})
componentWillMount() {
this.form = new Form({
fields: [{
name: 'name',
extra: null
}
]
})
}
@action.bound
onClick(){
onClick() {
this.remove = true
}
render() {
let reactInput = !this.remove ? <ReactInput field={this.form.$('name')} label={'label1'} placeholder={"holder"}>
{this.props.children}
let reactInput = !this.remove ? <ReactInput field={this.form.$('name')} label={'label1'} placeholder={"holder"}>
</ReactInput> : undefined
return <ReactForm form={this.form}>
@ -45,18 +46,6 @@ class TestChange extends React.Component{
}
}
class WrappedInput extends React.Component<InputHTMLAttributes<HTMLInputElement>>{
render(){
let notChildren = {...this.props}
delete notChildren.children
return <div>
<input {...notChildren} />
</div>
}
}
describe('ReactInput', () => {
let form: Form
@ -71,94 +60,48 @@ describe('ReactInput', () => {
})
})
describe('no children passed in', () => {
test('gets added properly', () => {
let res = mount(<ReactForm form={form}>
<ReactInput field={form.$('name')} label={"label"}
placeholder={"holder"} value={'snapshot'} aria-required={true}/>
test('gets added properly', () => {
let res = mount(<ReactForm form={form}>
<ReactInput field={form.$('name')} label={"label"}
placeholder={"holder"} value={'snapshot'} aria-required={true}/>
</ReactForm>)
</ReactForm>)
//Did the attributes settings work as expected back to the objects
expect(form.$('name').label).toEqual('label')
expect(form.$('name').placeholder).toEqual('holder')
expect(form.$('name').value).toEqual('')
//Did the attributes settings work as expected back to the objects
expect(form.$('name').label).toEqual('label')
expect(form.$('name').placeholder).toEqual('holder')
expect(form.$('name').value).toEqual('')
//is the aria attribute passted through to the input
let input = res.find('input')
expect(input.prop('aria-required')).toEqual(true)
//is the aria attribute passted through to the input
let input = res.find('input')
expect(input.prop('aria-required')).toEqual(true)
// is the input properly bound?
input.simulate('change', {target: { value: 'something' } })
expect(form.$('name').value).toEqual('something')
})
test('gets removed properly', () => {
let res = mount(<TestChange/>)
// The two casts are needed because Typescript was going blowing up without the 'any' first.
// Why was it? *shrugs*
let f = res.find('ReactForm').instance() as any as ReactForm
expect(f.form.size).toEqual(1)
res.find('input').simulate('change', {target: { value: 'something' } })
expect(f.form.$('name').value).toEqual('something')
res.find('button').simulate('click')
expect(f.form.size).toEqual(1)
expect(toJS(res.find('form'))).toMatchSnapshot()
expect(f.form.$('name').label).toEqual('label1')
expect(f.form.$('name').placeholder).toEqual('holder')
})
// is the input properly bound?
input.simulate('change', {target: {value: 'something'}})
expect(form.$('name').value).toEqual('something')
})
describe('children passed in', () => {
test('gets added properly', () => {
let res = mount(<ReactForm form={form}>
<ReactInput field={form.$('name')} label={"label"}
placeholder={"holder"} value={'snapshot'} aria-required={true}>
<WrappedInput/>
</ReactInput>
test('gets removed properly', () => {
</ReactForm>)
let res = mount(<TestChange/>)
//Did the attributes settings work as expected back to the objects
expect(form.$('name').label).toEqual('label')
expect(form.$('name').placeholder).toEqual('holder')
expect(form.$('name').value).toEqual('')
// The two casts are needed because Typescript was going blowing up without the 'any' first.
// Why was it? *shrugs*
let f = res.find('ReactForm').instance() as any as ReactForm
expect(f.form.size).toEqual(1)
//is the aria attribute passted through to the input
let input = res.find('input')
expect(input.prop('aria-required')).toEqual(true)
res.find('input').simulate('change', {target: {value: 'something'}})
expect(f.form.$('name').value).toEqual('something')
// is the input properly bound?
input.simulate('change', {target: { value: 'something' } })
expect(form.$('name').value).toEqual('something')
})
res.find('button').simulate('click')
expect(f.form.size).toEqual(1)
test('gets removed properly', () => {
let res = mount(<TestChange>
<WrappedInput/>
</TestChange>)
let f = res.find('ReactForm').instance() as any as ReactForm
res.find('input').simulate('change', {target: { value: 'something' } })
expect(f.form.$('name').value).toEqual('something')
expect(f.form.size).toEqual(1)
res.find('button').simulate('click')
expect(f.form.size).toEqual(1)
expect(f.form.$('name').label).toEqual('label1')
expect(f.form.$('name').placeholder).toEqual('holder')
})
expect(toJS(res.find('form'))).toMatchSnapshot()
expect(f.form.$('name').label).toEqual('label1')
expect(f.form.$('name').placeholder).toEqual('holder')
})
})

View file

@ -5,24 +5,18 @@ import {InjectedIntlProps, injectIntl} from 'react-intl';
import {Field} from "mobx-react-form";
import {observable, action, toJS, runInAction} from 'mobx';
import {InputHTMLAttributes} from 'react';
import {ReactInputProps} from "./react_input_props";
import {SelectHTMLAttributes} from "react";
import {ReactSelectProps} from "./ReactSelect";
import {castToNullIfUndef} from "../../../lib/utils";
type InputTypes = ReactInputProps &
InputHTMLAttributes<HTMLInputElement>
export interface ReactInputProps
{
field:Field
label?:string
placeholder?:string
}
class ReactInput extends React.Component<InputTypes, {}> {
function castToNullIfUndef(i:any){
return i === undefined ? null : i
}
class ReactInput extends React.Component<ReactInputProps & InputHTMLAttributes<HTMLInputElement>, {}> {
constructor(props:ReactInputProps){
constructor(props:InputTypes){
super(props)
}
@ -43,7 +37,7 @@ class ReactInput extends React.Component<ReactInputProps & InputHTMLAttributes<H
}
componentDidUpdate(prevProps: Readonly<ReactInputProps>, prevState: Readonly<{}>): void {
componentDidUpdate(prevProps: Readonly<InputTypes>, prevState: Readonly<{}>): void {
this.updateProps()
}
@ -53,18 +47,9 @@ class ReactInput extends React.Component<ReactInputProps & InputHTMLAttributes<H
this.field.set('placeholder', castToNullIfUndef(this.props.placeholder))
}
@action.bound
renderChildren(){
let ourProps = this.winnowProps()
let elem = React.cloneElement(this.props.children as React.ReactElement<any>,
{...ourProps, ...this.field.bind() })
return elem
}
///Removes the properties we don't want to put into the input element
@action.bound
winnowProps(): ReactInputProps & InputHTMLAttributes<HTMLInputElement> {
winnowProps(): InputTypes {
let ourProps = {...this.props}
delete ourProps.field
delete ourProps.value
@ -73,14 +58,7 @@ class ReactInput extends React.Component<ReactInputProps & InputHTMLAttributes<H
}
render() {
if (this.props.children)
{
return this.renderChildren()
}
else {
return <input {...this.winnowProps()} {...this.field.bind()}/>
}
}
}

View file

@ -0,0 +1,81 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import { observer } from 'mobx-react';
import {ReactInputProps} from "./react_input_props";
import {InputHTMLAttributes} from "react";
import {action, observable} from "mobx";
import {Field} from "mobx-react-form";
import {castToNullIfUndef} from "../../../lib/utils";
import MaskedInput, {maskArray} from "react-text-mask";
type InputTypes = ReactInputProps &
InputHTMLAttributes<HTMLInputElement> & {
mask?: maskArray | ((value: string) => maskArray);
guide?: boolean;
placeholderChar?: string;
keepCharPositions?: boolean;
pipe?: (
conformedValue: string,
config: any
) => false | string | { value: string; indexesOfPipedChars: number[] };
showMask?: boolean;
}
class ReactMaskedInput extends React.Component<InputTypes, {}> {
constructor(props:InputTypes){
super(props)
}
@observable
field:Field
@action.bound
componentWillMount(){
this.field = this.props.field
this.updateProps()
}
componentWillUnmount(){
}
componentDidUpdate(prevProps: Readonly<InputTypes>, prevState: Readonly<{}>): void {
this.updateProps()
}
@action.bound
updateProps() {
this.field.set('label', castToNullIfUndef(this.props.label))
this.field.set('placeholder', castToNullIfUndef(this.props.placeholder))
}
///Removes the properties we don't want to put into the input element
@action.bound
winnowProps(): InputTypes {
let ourProps = {...this.props}
delete ourProps.field
delete ourProps.value
return ourProps
}
render() {
return <MaskedInput {...this.winnowProps()} {...this.field.bind()}/>
}
}
export default observer(ReactMaskedInput)

View file

@ -0,0 +1,112 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import 'jest';
import {Form} from "mobx-react-form";
import ReactInput from "./ReactInput";
import {ReactForm} from "./ReactForm";
import {action, observable, toJS} from 'mobx';
import ReactTextarea from './ReactTextarea';
import {observer} from 'mobx-react';
import {mount} from 'enzyme';
import ReactSelect from './ReactSelect';
@observer
class TestChange extends React.Component{
@observable
remove:boolean
@observable
form: Form
@action.bound
componentWillMount(){
this.form = new Form({fields:[{
name: 'name',
extra: null}
]})
}
@action.bound
onClick(){
this.remove = true
}
render() {
let reactInput = !this.remove ? <ReactSelect field={this.form.$('name')} label={'label1'} placeholder={"holder"} options={[{id: null, name:null},
{id: 'something', name: "Something"},
{id: 'another_value', name: "aonther value"}
]}>
</ReactSelect> : undefined
return <ReactForm form={this.form}>
{reactInput}
<button onClick={() => this.onClick()}/>
</ReactForm>
}
}
describe('ReactSelect', () => {
let form: Form
beforeEach(() => {
form = new Form({
fields: [
{
name: 'name',
extra: null
}
]
})
})
test('gets added properly', () => {
let res = mount(<ReactForm form={form}>
<ReactSelect field={form.$('name')} label={"label"}
placeholder={"holder"} value={'snapshot'} aria-required={true}options={[{id: null, name:null},
{id: 'something', name: "Something"},
{id: 'another_value', name: "aonther value"}
]}/>
</ReactForm>)
//Did the attributes settings work as expected back to the objects
expect(form.$('name').label).toEqual('label')
expect(form.$('name').placeholder).toEqual('holder')
expect(form.$('name').value).toEqual('')
//is the aria attribute passted through to the input
let input = res.find('select')
let options = input.find('option')
expect(options.getElements().length).toBe(3)
// is the input properly bound?
input.simulate('change', {target: {value: 'something'}})
expect(form.$('name').value).toEqual('something')
})
test('gets removed properly', () => {
let res = mount(<TestChange/>)
// The two casts are needed because Typescript was going blowing up without the 'any' first.
// Why was it? *shrugs*
let f = res.find('ReactForm').instance() as any as ReactForm
expect(f.form.size).toEqual(1)
res.find('select').simulate('change', {target: {value: 'something'}})
expect(f.form.$('name').value).toEqual('something')
res.find('button').simulate('click')
expect(f.form.size).toEqual(1)
expect(toJS(res.find('form'))).toMatchSnapshot()
expect(f.form.$('name').label).toEqual('label1')
expect(f.form.$('name').placeholder).toEqual('holder')
})
})

View file

@ -0,0 +1,75 @@
// 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 "../../../../../types/mobx-react-form";
import {InputHTMLAttributes} from "react";
import {action, observable} from "mobx";
import {SelectHTMLAttributes} from "react";
import {ReactInputProps} from "./react_input_props";
import {castToNullIfUndef} from "../../../lib/utils";
export interface ReactSelectProps extends ReactInputProps
{
options?:Array<{id:any, name:string}>
}
type InputTypes = ReactSelectProps & SelectHTMLAttributes<HTMLSelectElement>
class ReactSelect extends React.Component<InputTypes, {}> {
constructor(props:InputTypes){
super(props)
}
@action.bound
componentWillMount(){
this.updateProps()
}
componentWillUnmount(){
}
componentDidUpdate(prevProps: Readonly<InputTypes >, prevState: Readonly<{}>): void {
this.updateProps()
}
@action.bound
updateProps() {
this.props.field.set('label', castToNullIfUndef(this.props.label))
this.props.field.set('placeholder', castToNullIfUndef(this.props.placeholder))
}
///Removes the properties we don't want to put into the input element
@action.bound
winnowProps(): InputTypes {
let ourProps = {...this.props}
delete ourProps.field
delete ourProps.value
delete ourProps.options
return ourProps
}
render() {
return <select {...this.winnowProps()} {...this.props.field.bind()}>
{ this.props.options ? this.props.options.map(option =>
<option key={option.id} value={option.id}>{option.name}</option>
) : this.props.children
}
</select>
}
}
export default observer(ReactSelect)

View file

@ -0,0 +1,102 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import 'jest';
import {Form} from "mobx-react-form";
import {ReactForm} from "./ReactForm";
import {action, observable, toJS} from 'mobx';
import ReactTextarea from './ReactTextarea';
import {observer} from 'mobx-react';
import {mount} from 'enzyme';
@observer
class TestChange extends React.Component{
@observable
remove:boolean
@observable
form: Form
@action.bound
componentWillMount(){
this.form = new Form({fields:[{
name: 'name',
extra: null}
]})
}
@action.bound
onClick(){
this.remove = true
}
render() {
let reactInput = !this.remove ? <ReactTextarea field={this.form.$('name')} label={'label1'} placeholder={"holder"} rows={3}>
</ReactTextarea> : undefined
return <ReactForm form={this.form}>
{reactInput}
<button onClick={() => this.onClick()}/>
</ReactForm>
}
}
describe('ReactTextarea', () => {
let form: Form
beforeEach(() => {
form = new Form({
fields: [
{
name: 'name',
extra: null
}
]
})
})
test('gets added properly', () => {
let res = mount(<ReactForm form={form}>
<ReactTextarea field={form.$('name')} label={"label"}
placeholder={"holder"} value={'snapshot'} aria-required={true} rows={3}/>
</ReactForm>)
//Did the attributes settings work as expected back to the objects
expect(form.$('name').label).toEqual('label')
expect(form.$('name').placeholder).toEqual('holder')
expect(form.$('name').value).toEqual('')
//is the aria attribute passted through to the input
let input = res.find('textarea')
expect(input.prop('aria-required')).toEqual(true)
expect(input.prop('rows')).toEqual(3)
// is the input properly bound?
input.simulate('change', {target: {value: 'something'}})
expect(form.$('name').value).toEqual('something')
})
test('gets removed properly', () => {
let res = mount(<TestChange/>)
// The two casts are needed because Typescript was going blowing up without the 'any' first.
// Why was it? *shrugs*
let f = res.find('ReactForm').instance() as any as ReactForm
expect(f.form.size).toEqual(1)
res.find('textarea').simulate('change', {target: {value: 'something'}})
expect(f.form.$('name').value).toEqual('something')
res.find('button').simulate('click')
expect(f.form.size).toEqual(1)
expect(toJS(res.find('form'))).toMatchSnapshot()
expect(f.form.$('name').label).toEqual('label1')
expect(f.form.$('name').placeholder).toEqual('holder')
})
})

View file

@ -0,0 +1,65 @@
// 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 "../../../../../types/mobx-react-form";
import {InputHTMLAttributes, ReactText, TextareaHTMLAttributes} from "react";
import {action, observable} from "mobx";
import {ReactInputProps} from "./react_input_props";
import {castToNullIfUndef} from "../../../lib/utils";
type InputTypes = ReactInputProps & TextareaHTMLAttributes<HTMLTextAreaElement>
class ReactTextarea extends React.Component<InputTypes, {}> {
constructor(props:InputTypes){
super(props)
}
@observable
field:Field
@action.bound
componentWillMount(){
this.field = this.props.field
this.updateProps()
}
componentWillUnmount(){
}
componentDidUpdate(prevProps: Readonly<InputTypes>, prevState: Readonly<{}>): void {
this.updateProps()
}
@action.bound
updateProps() {
this.field.set('label', castToNullIfUndef(this.props.label))
this.field.set('placeholder', castToNullIfUndef(this.props.placeholder))
}
///Removes the properties we don't want to put into the input element
@action.bound
winnowProps(): InputTypes {
let ourProps = {...this.props}
delete ourProps.field
delete ourProps.value
return ourProps
}
render() {
return <textarea {...this.winnowProps()} {...this.field.bind()}/>
}
}
export default observer(ReactTextarea)

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactInput no children passed in gets removed properly 1`] = `
exports[`ReactInput gets removed properly 1`] = `
<form>
<button
onClick={[Function]}

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactSelect gets removed properly 1`] = `
<form>
<button
onClick={[Function]}
/>
</form>
`;

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReactTextarea gets removed properly 1`] = `
<form>
<button
onClick={[Function]}
/>
</form>
`;

View file

@ -0,0 +1,9 @@
// License: LGPL-3.0-or-later
import {Field} from "mobx-react-form";
export interface ReactInputProps
{
field:Field
label?:string
placeholder?:string
}

View file

@ -9,7 +9,7 @@ import {TabList} from "./TabList";
import {TabPanel} from "./TabPanel";
import {Tab} from "./Tab";
import toJson from 'enzyme-to-json';
import {UniqueIdMock} from "../../tests/unique_id_mock";
import {UniqueIdMock} from "../../test/unique_id_mock";
import {mountForMobx, runTestsOnConditions, TriggerAndAction} from '../../test/react_test_helpers';
let uniqueIdMock = new UniqueIdMock();

View file

@ -9,7 +9,7 @@ import {ReactWrapper} from 'enzyme';
import {WizardPanel} from "./WizardPanel";
import {mountForMobxWithIntl, runTestsOnConditions, TriggerAndAction} from "../test/react_test_helpers";
import {UniqueIdMock} from "../tests/unique_id_mock";
import {UniqueIdMock} from "../test/unique_id_mock";
let uniqueIdMock = new UniqueIdMock();
class MockableTabPanelState extends WizardTabPanelState

View file

@ -3,7 +3,7 @@ import * as React from 'react';
import 'jest';
import {AbstractWizardState, AbstractWizardTabPanelState} from './abstract_wizard_state'
import {observable, action, computed} from 'mobx'
import {UniqueIdMock} from "../tests/unique_id_mock";
import {UniqueIdMock} from "../test/unique_id_mock";
let uniqueIdMock = new UniqueIdMock();

View file

@ -0,0 +1,287 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import { observer } from 'mobx-react';
import {InjectedIntlProps, injectIntl} from 'react-intl';
import Modal from "../common/Modal";
import { FundraiserInfo} from "../edit_payment_pane/EditPaymentPane";
import {HoudiniForm} from "../../lib/houdini_form";
import {BasicField, CurrencyField, SelectField, TextareaField} from "../common/fields";
import ProgressableButton from "../common/ProgressableButton";
import {action, computed} from "mobx";
import {NonprofitTimezonedDates} from "../../lib/date";
import {Field, FieldDefinition} from "../../../../types/mobx-react-form";
import {createFieldDefinition} from "../../lib/mobx_utils";
import {centsToDollars, dollarsToCents} from "../../lib/format";
import {Validations} from "../../lib/vjf_rules";
import {serializeDedication} from "../../lib/dedication";
import {ApiManager} from "../../lib/api_manager";
import * as CustomAPIS from "../../lib/apis";
import {CSRFInterceptor} from "../../lib/csrf_interceptor";
import {CreateOffsiteDonation, CreateOffsiteDonationModel} from "../../lib/api/create_offsite_donation";
import blacklist = require("validator/lib/blacklist");
import * as _ from 'lodash'
import moment = require('moment');
import { castToUndefinedIfBlank } from '../../lib/utils';
import ReactInput from "../common/form/ReactInput";
export interface CreateOffsitePaymentPaneProps
{
events: FundraiserInfo[]
campaigns: FundraiserInfo[]
nonprofitId: number
supporterId:number
nonprofitTimezone?: string
preupdateDonationAction:() => void
postUpdateSuccess: () => void
//from ModalProps
onClose: () => void
modalActive: boolean
}
export interface FundraiserInfo {
id: number
name: string
}
class CreateOffsitePaymentPaneForm extends HoudiniForm {
}
class CreateNewOffsitePaymentPane extends React.Component<CreateOffsitePaymentPaneProps & InjectedIntlProps, {}> {
constructor(props: CreateOffsitePaymentPaneProps & InjectedIntlProps) {
super(props);
this.postOffsiteDonation = new ApiManager(CustomAPIS.APIS as Array<any>, CSRFInterceptor).get(CreateOffsiteDonation)
this.loadFormFromData()
}
@computed
get nonprofitTimezonedDates():NonprofitTimezonedDates {
return new NonprofitTimezonedDates(this.props.nonprofitTimezone)
}
postOffsiteDonation : CreateOffsiteDonation
@action.bound
async createOffsiteDonation() {
if (this.props.preupdateDonationAction) {
this.props.preupdateDonationAction()
}
let postData:CreateOffsiteDonationModel = {
nonprofit_id:this.props.nonprofitId,
supporter_id:this.props.supporterId,
amount: dollarsToCents(this.form.$('gross_amount').get('value')),
designation: this.form.$('designation').value,
comment: this.form.$('comment').value,
campaign_id: this.form.$('campaign').get('value'),
event_id: this.form.$('event').get('value'),
date: this.form.$('date').get('value')
}
if (this.form.$('dedication.type').get('value') != '') {
const nameToValueForContact = ['full_address', 'phone', 'email'].map((i) => {
return {
name: i, value: this.form.$(`dedication.${i}`).get('value')
}
});
const contact = _.some(nameToValueForContact, (i) => i.value && i.value != "") ?
_.reduce(nameToValueForContact, (result: any, i) => {
result[i.name] = i.value;
return result;
}, {}) : undefined;
postData.dedication = serializeDedication({
type: this.form.$('dedication.type').get('value'),
supporter_id: this.form.$('dedication.supporter_id').get('value'),
name: this.form.$('dedication.name').get('value'),
contact: contact,
note: this.form.$('dedication.note').get('value')
})
}
else {
postData.dedication = ""
}
if (this.form.has('check_number'))
{
postData.offsite_payment = postData.offsite_payment || {}
postData.offsite_payment.check_number = this.form.$('check_number').value
}
await this.postOffsiteDonation.postDonation(postData, this.props.nonprofitId)
if(this.props.postUpdateSuccess){
try {
this.props.postUpdateSuccess()
}
catch {}
}
this.props.onClose()
}
@action.bound
loadFormFromData() {
let params: {[name:string]:FieldDefinition} = {
'event': {name: 'event', label: 'Event',
output: (id:string) => castToUndefinedIfBlank(id)},
'campaign': {name: 'campaign', label: 'Campaign',
output: (id:string) => castToUndefinedIfBlank(id)},
'gross_amount': createFieldDefinition({name: 'gross_amount',
label: 'Gross Amount',
input: (amount:number) => centsToDollars(amount),
output: (dollarString:string) => parseFloat(blacklist(dollarString, '$,')),
value: 0
}),
// 'fee_total': createFieldDefinition({name: 'fee_total', label: 'Fees',
// input: (amount:number) => centsToDollars(amount),
// output: (dollarString:string) => parseFloat(blacklist(dollarString, '$,')),
// value: 0
// }),
'date': createFieldDefinition({name: 'date', label: 'Date',
input: (isoTime:string) => this.nonprofitTimezonedDates.readable_date(isoTime),
output:(date:string) => this.nonprofitTimezonedDates.readable_date_time_to_iso(date),
value: moment.utc().toISOString()
}),
'dedication': {name: 'dedication', label: 'Dedication', fields: [
createFieldDefinition({name:'type', label: 'Dedication Type'}),
createFieldDefinition({name: 'supporter_id', type: 'hidden'}),
createFieldDefinition({name:'name', label:'Person dedicated for'}),
createFieldDefinition({name: 'full_address', label: 'Full address'}),
createFieldDefinition({name: 'phone', label: 'Phone'}),
createFieldDefinition({name: 'email', label: 'Email'}),
createFieldDefinition({name: 'note', label: 'Dedication Note', type: 'textarea'})
]},
'designation': {name: 'designation', label: 'Designation'},
'comment': {name: 'comment', label: 'Note'}
};
params.check_number = {name: 'check_number', label: 'Check Number'}
params.date.validators = [Validations.isDate('MM/DD/YYYY')]
params.gross_amount.validators = [Validations.isGreaterThanOrEqualTo(0.01)];
// params.fee_total.validators = [Validations.optional(Validations.isLessThanOrEqualTo(0))];
return new CreateOffsitePaymentPaneForm({fields: _.values(params)}, {
hooks: {
onSuccess: async (e: Field) => {
await this.createOffsiteDonation()
}
}
})
}
@computed get form(): CreateOffsitePaymentPaneForm {
//add this.props because we need to reload on prop change
return this.props && this.loadFormFromData()
}
@computed get dateFormatter(): NonprofitTimezonedDates {
return new NonprofitTimezonedDates(this.props.nonprofitTimezone)
}
render() {
this.form.values()
const modal =
<Modal modalActive={this.props.modalActive} titleText={'Create Offsite Donation'} focusDialog={true}
onClose={this.props.onClose} dialogStyle={{minWidth:'768px'}} childGenerator={() => {
return <div className={"tw-bs"}>
<form className='u-marginTop--20'>
<CurrencyField field={this.form.$('gross_amount')} label={"Gross Amount"} currencySymbol={"$"}/>
{/* <CurrencyField field={this.form.$('fee_total')} label={"Processing Fees"} mustBeNegative={true}/> */}
<BasicField field={this.form.$('date')} label={"Date"} />
<SelectField field={this.form.$('campaign')}
label={"Campaign"}
options={this.props.campaigns}/>
<SelectField field={this.form.$('event')}
label={"Event"}
options={this.props.events} />
<TextareaField field={this.form.$('designation')} label={"Designation"} rows={3} />
<div className="panel panel-default">
<div className="panel-heading"><label>Dedication <small> (optional)</small></label></div>
<div className="panel-body">
<SelectField field={this.form.$('dedication.type')} label={"Dedication Type"}
options={[{id: null, name: ''}, {id: 'honor', name: 'In honor of'}, {
id: 'memory',
name: 'In memory of'
}]}/>
{this.form.$('dedication.type').get('value') != '' ? <div>
<div className={"panel panel-default"}>
<div className="panel-heading"><label>Dedicated to:</label></div>
<div className={'panel-body'}>
<table className='table--small u-marginBottom--10'>
<tbody>
<tr>
<th>Name</th>
<td><ReactInput field={this.form.$('dedication.name')} label={'Name'} className={"form-control"}/></td>
</tr>
<tr>
<th>Full Address
</th>
<td><ReactInput field={this.form.$('dedication.full_address')} className={"form-control"}/>
</td>
</tr>
<tr>
<th>Phone Number
</th>
<td><ReactInput field={this.form.$('dedication.phone')} className={"form-control"}/>
</td>
</tr>
<tr>
<th>Email Address
</th>
<td><ReactInput field={this.form.$('dedication.email')} className={"form-control"}/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<TextareaField rows={3} placeholder={"Dedication"} field={this.form.$('dedication.note')}
label={"Dedication Note"}/></div>
: undefined}
</div>
</div>
<TextareaField field={this.form.$('comment')} label={"Notes"} rows={3} />
<ProgressableButton buttonText={'Save'}
buttonTextOnProgress={'Updating...'} className={'button'}
inProgress={this.form.submitting}
disabled={!this.form.isValid}
disableOnProgress={true} onClick={this.form.onSubmit}/>
</form>
</div>
}} />
return <div>{modal}</div>;
}
}
export default injectIntl(observer(CreateNewOffsitePaymentPane))

View file

@ -0,0 +1,495 @@
// License: LGPL-3.0-or-later
import * as React from 'react';
import {observer} from 'mobx-react';
import {InjectedIntlProps, injectIntl} from 'react-intl';
import {action, computed} from "mobx";
import {FieldDefinition} from "mobx-react-form";
import {HoudiniForm} from "../../lib/houdini_form";
import ProgressableButton from "../common/ProgressableButton";
import {centsToDollars, dollarsToCents, readableInterval, readableKind} from "../../lib/format";
import {NonprofitTimezonedDates} from '../../lib/date';
import {UpdateDonationModel, PutDonation} from "../../lib/api/put_donation";
import {ApiManager} from "../../lib/api_manager";
import * as CustomAPIS from "../../lib/apis";
import {CSRFInterceptor} from "../../lib/csrf_interceptor";
import {BasicField, CurrencyField, SelectField, TextareaField} from '../common/fields';
import {TwoColumnFields} from "../common/layout";
import {Validations} from "../../lib/vjf_rules";
import _ = require("lodash");
import {Dedication, parseDedication, serializeDedication} from '../../lib/dedication';
import blacklist = require("validator/lib/blacklist");
import {createFieldDefinition} from "../../lib/mobx_utils";
import Modal from "../common/Modal";
import ReactInput from "../common/form/ReactInput";
interface Charge {
status: string
}
interface RecurringDonation {
interval?: number
time_unit?: string
created_at: string
}
interface Donation {
designation?: string
comment?: string
event?: { id: number }
campaign?: { id: number }
dedication?: string
recurring_donation?: RecurringDonation
id: number
}
interface PaymentData {
gross_amount: number
fee_total: number
date: string
offsite_payment: OffsitePayment
donation: Donation
kind: string
id: string
refund_total: number
net_amount: number
origin_url?: string
charge?: Charge,
nonprofit: { id: number }
}
interface OffsitePayment {
check_number: string
kind: string
}
export interface EditPaymentPaneProps {
data: PaymentData
events: FundraiserInfo[]
campaigns: FundraiserInfo[]
nonprofitTimezone?: string
preupdateDonationAction: () => void
postUpdateSuccess: () => void
//from ModalProps
onClose: () => void
modalActive: boolean
}
export interface FundraiserInfo {
id: number
name: string
}
class EditPaymentPaneForm extends HoudiniForm {
}
@observer
class EditPaymentPane extends React.Component<EditPaymentPaneProps & InjectedIntlProps, {}> {
constructor(props: EditPaymentPaneProps & InjectedIntlProps) {
super(props);
this.putDonation = new ApiManager(CustomAPIS.APIS as Array<any>, CSRFInterceptor).get(PutDonation);
this.loadFormFromData()
}
@computed
get nonprofitTimezonedDates(): NonprofitTimezonedDates {
return new NonprofitTimezonedDates(this.props.nonprofitTimezone)
}
@computed
get dedication(): Dedication | null {
return parseDedication(this.props.data && this.props.data.donation && this.props.data.donation.dedication)
}
putDonation: PutDonation;
@action.bound
async updateDonation() {
if (this.props.preupdateDonationAction) {
this.props.preupdateDonationAction()
}
let updateData: UpdateDonationModel = {
id: Number(this.props.data.donation.id),
donation: {
designation: this.form.$('designation').value,
comment: this.form.$('comment').value,
campaign_id: this.form.$('campaign').value,
event_id: this.form.$('event').value,
gross_amount: dollarsToCents(this.form.$('gross_amount').get('value')),
fee_total: dollarsToCents(this.form.$('fee_total').get('value')),
date: this.form.$('date').get('value')
}
};
if (this.form.$('dedication.type').get('value') != '') {
const nameToValueForContact = ['full_address', 'phone', 'email'].map((i) => {
return {
name: i, value: this.form.$(`dedication.${i}`).get('value')
}
});
const contact = _.some(nameToValueForContact, (i) => i.value && i.value != "") ?
_.reduce(nameToValueForContact, (result: any, i) => {
result[i.name] = i.value;
return result;
}, {}) : undefined;
updateData.donation.dedication = serializeDedication({
type: this.form.$('dedication.type').get('value'),
supporter_id: this.form.$('dedication.supporter_id').get('value'),
name: this.form.$('dedication.name').get('value'),
contact: contact,
note: this.form.$('dedication.note').get('value')
});
}
else {
updateData.donation.dedication = "";
}
if (this.form.has('check_number')) {
updateData.donation.check_number = this.form.$('check_number').value
}
await this.putDonation.putDonation(updateData, this.props.data.nonprofit.id);
if (this.props.postUpdateSuccess) {
try {
this.props.postUpdateSuccess()
}
catch {
}
}
this.props.onClose()
}
@action.bound
loadFormFromData() {
const eventId = this.props.data.donation.event && this.props.data.donation.event.id;
const campaignId = this.props.data.donation.campaign && this.props.data.donation.campaign.id;
let params: { [name: string]: FieldDefinition } = {
'event': {name: 'event', label: 'Event', value: eventId},
'campaign': {name: 'campaign', label: 'Campaign', value: campaignId},
'gross_amount': createFieldDefinition({
name: 'gross_amount', label: 'Gross Amount', value: this.props.data.gross_amount,
input: (amount: number) => centsToDollars(amount),
output: (dollarString: string) => parseFloat(blacklist(dollarString, '$,'))
}),
'fee_total': createFieldDefinition({
name: 'fee_total', label: 'Fees', value: this.props.data.fee_total,
input: (amount: number) => centsToDollars(amount),
output: (dollarString: string) => parseFloat(blacklist(dollarString, '$,'))
}),
'date': createFieldDefinition({
name: 'date', label: 'Date',
value: this.props.data.date,
input: (isoTime: string) => this.nonprofitTimezonedDates.readable_date(isoTime),
output: (date: string) => this.nonprofitTimezonedDates.readable_date_time_to_iso(date)
}),
'dedication': {
name: 'dedication', label: 'Dedication', fields: [
createFieldDefinition({name: 'type', label: 'Dedication Type', value: this.dedication && this.dedication.type}),
createFieldDefinition({name: 'supporter_id', type: 'hidden', value: this.dedication && this.dedication.supporter_id}),
createFieldDefinition({name: 'name', label: 'Person dedicated for', value: this.dedication && this.dedication.name}),
createFieldDefinition({name: 'full_address', label: 'Full address', value: this.dedication && this.dedication.contact && this.dedication.contact.address}),
createFieldDefinition({name: 'phone', label: 'Phone', value: this.dedication && this.dedication.contact && this.dedication.contact.phone}),
createFieldDefinition({name: 'email', label: 'email', value: this.dedication && this.dedication.contact && this.dedication.contact.email}),
createFieldDefinition({name: 'note', value: this.dedication && this.dedication.note})
]
},
'designation': {name: 'designation', label: 'Designation', value: this.props.data.donation.designation},
'comment': {name: 'comment', label: 'Note', value: this.props.data.donation.comment}
};
if (this.props.data.kind == 'OffsitePayment') {
params.check_number = {
name: 'check_number',
label: 'Check Number',
value: this.props.data.offsite_payment.check_number
};
params.date.validators = [Validations.isDate('MM/DD/YYYY')];
params.gross_amount.validators = [Validations.isGreaterThanOrEqualTo(0.01)];
params.fee_total.validators = [Validations.optional(Validations.isLessThanOrEqualTo(0))];
}
return new EditPaymentPaneForm({fields: _.values(params)}, {
hooks: {
onSuccess: async () => {
await this.updateDonation()
}
}
})
}
@computed get form(): EditPaymentPaneForm {
//add this.props because we need to reload on prop change
return this.props && this.loadFormFromData()
}
@computed get dateFormatter(): NonprofitTimezonedDates {
return new NonprofitTimezonedDates(this.props.nonprofitTimezone)
}
@action.bound
innerRender() {
let rd = this.props.data && this.props.data.donation && this.props.data.donation.recurring_donation;
let initialTable = <table className='table--small u-marginBottom--10'>
<thead>
<tr>
<th>Payment Info</th>
<th/>
</tr>
</thead>
<tbody>
<tr>
<td>Date</td>
<td>
{this.dateFormatter.readable_date(this.props.data.date)}
</td>
</tr>
<tr>
<td>Type</td>
<td>
{readableKind(this.props.data.kind)}
{
this.props.data.offsite_payment && this.props.data.offsite_payment && this.props.data.offsite_payment.kind ?
<span>
&nbsp; ({this.props.data.offsite_payment.kind})
</span> : undefined
}
</td>
</tr>
{
this.props.data.kind === 'RecurringDonation' ?
<tr>
<td>Recurring</td>
<td>
{rd ? readableInterval(rd.interval, rd.time_unit) : false}
since
{rd ? this.dateFormatter.readable_date(rd.created_at) : false}
</td>
</tr> : false
}
<tr className='test-grossAmount'>
<td>Gross Amount</td>
<td>
${centsToDollars(this.props.data.gross_amount)}
</td>
</tr>
<tr>
<td>Processing Fees</td>
<td>
${centsToDollars(this.props.data.fee_total)}
</td>
</tr>
{
this.props.data.refund_total && this.props.data.refund_total > 0 ?
<tr>
<td>Total Refunds</td>
<td>
${centsToDollars(this.props.data.fee_total)}
</td>
</tr> : false
}
<tr>
<td>Net Amount</td>
<td>
${centsToDollars(this.props.data.net_amount)}
</td>
</tr>
{
this.props.data.origin_url ?
<tr>
<td>Origin</td>
<td>
<a target='_blank' href={this.props.data.origin_url}>
{this.props.data.origin_url}
</a>
</td>
</tr> : false
}
{
this.props.data.charge ?
<tr>
<td>Status</td>
<td>{this.props.data.charge.status}</td>
</tr> : false
}
{this.props.data.offsite_payment && this.props.data.offsite_payment.check_number ?
<tr>
<td>Check #</td>
<td>
{this.props.data.offsite_payment.check_number}
</td>
</tr> : false
}
<tr>
<td>ID</td>
<td>{this.props.data.id}</td>
</tr>
</tbody>
</table>;
let checkNumber = this.props.data.offsite_payment && this.props.data.offsite_payment.check_number ?
<BasicField field={this.form.$('check_number')} label={"Check or Payment Number/ID"}/> : false;
let offsitePayment = this.props.data.kind === "OffsitePayment" ? (<div>
<TwoColumnFields>
<CurrencyField field={this.form.$('gross_amount')} label={"Gross Amount"} currencySymbol={"$"}/>
<CurrencyField field={this.form.$('fee_total')} label={"Processing Fees"} mustBeNegative={true}/>
</TwoColumnFields>
<BasicField field={this.form.$('date')} label={"Date"}/>
{checkNumber}
</div>) : undefined;
return <div className={"tw-bs"}>
<div>
{initialTable}
<form className='u-marginTop--20'>
{offsitePayment}
<SelectField field={this.form.$('campaign')}
label={"Campaign"}
options={this.props.campaigns}/>
<SelectField field={this.form.$('event')}
label={"Event"}
options={this.props.events}/>
<TextareaField field={this.form.$('designation')} label={"Designation"} rows={3}/>
<div className="panel panel-default">
<div className="panel-heading"><label>Dedication</label></div>
<div className="panel-body">
<SelectField field={this.form.$('dedication.type')} label={"Dedication Type"}
options={[{id: null, name: ''}, {id: 'honor', name: 'In honor of'}, {
id: 'memory',
name: 'In memory of'
}]}/>
{this.form.$('dedication.type').get('value') != '' ? <div>
<div className={"panel panel-default"}>
<div className="panel-heading"><label>Dedicated to:</label></div>
<div className={'panel-body'}>
<table className='table--small u-marginBottom--10'>
<tbody>
<tr>
<th>Name</th>
<td><ReactInput field={this.form.$('dedication.name')} label={'Name'} className={"form-control"}/></td>
</tr>
<tr>
<th> Supporter
ID
</th>
<td>{this.dedication.supporter_id}<input {...this.form.$('dedication.supporter_id').bind()}/>
</td>
</tr>
<tr>
<th>Full Address
</th>
<td><ReactInput field={this.form.$('dedication.full_address')} className={"form-control"}/>
</td>
</tr>
<tr>
<th>Phone Number
</th>
<td><ReactInput field={this.form.$('dedication.phone')} className={"form-control"}/>
</td>
</tr>
<tr>
<th>Email Address
</th>
<td><ReactInput field={this.form.$('dedication.email')} className={"form-control"}/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<TextareaField rows={3} placeholder={"Dedication"} field={this.form.$('dedication.note')}
label={"Dedication Note"}/></div>
: undefined}
</div>
</div>
<TextareaField field={this.form.$('comment')} label={"Notes"} rows={3}/>
<ProgressableButton buttonText={'Save'}
buttonTextOnProgress={'Updating...'} className={'button'}
inProgress={this.form.submitting}
disabled={!this.form.isValid}
disableOnProgress={true} onClick={this.form.onSubmit}/>
</form>
</div>
</div>
}
render() {
//force it to check the form values so this updates
this.form.values()
const modal =
<Modal modalActive={this.props.modalActive} titleText={'Edit Donation'} focusDialog={true}
onClose={this.props.onClose} dialogStyle={{minWidth: '768px'}} childGenerator={() => this.innerRender()}/>
return modal;
}
}
export default injectIntl(observer(EditPaymentPane))

View file

@ -0,0 +1,99 @@
// License: LGPL-3.0-or-later
import * as $ from 'jquery';
import {Configuration} from "../../../api/configuration";
export class CreateOffsiteDonation {
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 postDonation(donation: CreateOffsiteDonationModel, nonprofitId: number, extraJQueryAjaxSettings?: JQueryAjaxSettings): Promise<any> {
let localVarPath = `${this.basePath}nonprofits/${nonprofitId}/donations/create_offsite`;
let queryParameters: any = {};
let headerParams: any = {};
// verify required parameter 'nonprofit' is not null or undefined
if (donation === null || donation === 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({donation:donation});
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(xhr.responseJSON)
}
);
return dfd.promise();
}
}
export interface CreateOffsiteDonationModel {
designation?:string
dedication?:string
comment?:string
amount?: number
supporter_id:number
nonprofit_id:number
date?:string
campaign_id?:string
event_id?: string
offsite_payment?:{check_number?: string}
}

View file

@ -0,0 +1,100 @@
// License: LGPL-3.0-or-later
import * as $ from 'jquery';
import {Configuration} from "../../../api/configuration";
export class PutDonation {
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 putDonation(donation: UpdateDonationModel, nonprofitId: number, extraJQueryAjaxSettings?: JQueryAjaxSettings): Promise<any> {
let localVarPath = `${this.basePath}nonprofits/${nonprofitId}/donations/${donation.id}`;
let queryParameters: any = {};
let headerParams: any = {};
// verify required parameter 'nonprofit' is not null or undefined
if (donation === null || donation === 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: 'PUT',
headers: headerParams,
processData: false
};
requestOptions.data = JSON.stringify({donation:donation.donation});
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(xhr.responseJSON)
}
);
return dfd.promise();
}
}
interface UpdateDonationModelData {
designation?:string
dedication?:string
comment?:string
campaign_id:string
event_id: string
gross_amount?: number
fee_total?: number
check_number?:string
date?:string
}
export interface UpdateDonationModel {
id:number
donation: UpdateDonationModelData
}

View file

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

View file

@ -0,0 +1,236 @@
//from: https://github.com/text-mask/text-mask/pull/760/commits/f66c81b62c4894b7da43862bee5943f659dc7537
// under: Unlicense
import createNumberMask from './createNumberMask'
describe('createNumberMask', () => {
it('can returns a configured currency mask', () => {
let numberMask = createNumberMask()
expect(typeof numberMask).toBe('function')
})
it('takes a prefix', () => {
let numberMask = createNumberMask({prefix: '$'})
expect(numberMask('12')).toEqual(['$', /\d/, /\d/])
})
it('takes a suffix', () => {
let numberMask = createNumberMask({suffix: ' $', prefix: ''})
expect(numberMask('12')).toEqual([/\d/, /\d/, ' ', '$'])
})
it('works when the prefix contains numbers', () => {
let numberMask = createNumberMask({prefix: 'm2 '})
expect(numberMask('m2 1')).toEqual(['m', '2', ' ', /\d/])
})
it('works when the suffix contains numbers', () => {
let numberMask = createNumberMask({prefix: '', suffix: ' m2'})
expect(numberMask('1 m2')).toEqual([/\d/, ' ', 'm', '2'])
})
it('works when there is a decimal and the suffix contains numbers', () => {
let numberMask = createNumberMask({prefix: '', suffix: ' m2', allowDecimal: true})
expect(numberMask('1.2 m2')).toEqual([/\d/, '[]', '.', '[]', /\d/, ' ', 'm', '2'])
})
it('can be configured to add a thousands separator or not', () => {
let numberMaskWithoutThousandsSeparator = createNumberMask({includeThousandsSeparator: false})
expect(numberMaskWithoutThousandsSeparator('1000')).toEqual(['$', /\d/, /\d/, /\d/, /\d/])
let numberMaskWithThousandsSeparator = createNumberMask()
expect(numberMaskWithThousandsSeparator('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
})
it('can be configured with a custom character for the thousands separator', () => {
let numberMask = createNumberMask({thousandsSeparatorSymbol: '.'})
expect(numberMask('1000')).toEqual(['$', /\d/, '.', /\d/, /\d/, /\d/])
})
it('can be configured to accept a fraction and returns the fraction separator with caret traps', () => {
let numberMask = createNumberMask({allowDecimal: true})
expect(numberMask('1000.')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]'])
})
it('rejects fractions by default', () => {
let numberMask = createNumberMask()
expect(numberMask('1000.')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
})
it('can be configured with a custom character for the fraction separator', () => {
let numberMask = createNumberMask({
allowDecimal: true,
decimalSymbol: ',',
thousandsSeparatorSymbol: '.'
})
expect(numberMask('1000,')).toEqual(['$', /\d/, '.', /\d/, /\d/, /\d/, '[]', ',', '[]'])
})
it('can limit the length of the fraction', () => {
let numberMask = createNumberMask({allowDecimal: true, decimalLimit: 2})
expect(numberMask('1000.3823')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
})
it('can require a fraction', () => {
let numberMask = createNumberMask({requireDecimal: true})
expect(numberMask('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/, '[]', '.', '[]'])
})
it('accepts negative integers', function() {
let numberMask = createNumberMask({allowNegative: true})
expect(numberMask('-$12')).toEqual([/-/, '$', /\d/, /\d/])
})
it('ignores multiple minus signs', function() {
let numberMask = createNumberMask({allowNegative: true})
expect(numberMask('--$12')).toEqual([/-/, '$', /\d/, /\d/])
})
it('adds a digit placeholder if the input is nothing but a minus sign in order to attract the caret', () => {
let numberMask = createNumberMask({allowNegative: true})
expect(numberMask('-')).toEqual([/-/, '$', /\d/])
})
it('starts with dot should be considered as decimal input', () => {
let numberMask = createNumberMask({prefix: '$', allowDecimal: true})
expect(numberMask('.')).toEqual(['$', '0', '.', /\d/])
numberMask = createNumberMask({prefix: '#', allowDecimal: true})
expect(numberMask('.')).toEqual(['#', '0', '.', /\d/])
numberMask = createNumberMask({prefix: '', allowDecimal: true})
expect(numberMask('.')).toEqual(['0', '.', /\d/])
numberMask = createNumberMask({allowDecimal: false})
expect(numberMask('.')).toEqual(['$'])
numberMask = createNumberMask({prefix: '', suffix: '$', allowDecimal: true})
expect(numberMask('.')).toEqual(['0', '.', /\d/, '$'])
})
it('can allow leading zeroes', function() {
let numberMask = createNumberMask({allowLeadingZeroes: true})
expect(numberMask('012')).toEqual(['$', /\d/, /\d/, /\d/])
})
it('works with large numbers when leading zeroes is false', function() {
let numberMask = createNumberMask({allowLeadingZeroes: false})
expect(numberMask('111111111111111111111111')).toEqual([
'$', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',',
/\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/
])
})
describe('integer limiting', () => {
it('can limit the length of the integer part', () => {
let numberMask = createNumberMask({integerLimit: 3})
expect(numberMask('1999')).toEqual(['$', /\d/, /\d/, /\d/])
})
it('works when there is a prefix', () => {
let numberMask = createNumberMask({integerLimit: 3, prefix: '$'})
expect(numberMask('$1999')).toEqual(['$', /\d/, /\d/, /\d/])
})
it('works when there is a thousands separator', () => {
expect(createNumberMask({integerLimit: 4, prefix: ''})('1,9995'))
.toEqual([/\d/, ',', /\d/, /\d/, /\d/])
expect(createNumberMask({integerLimit: 7, prefix: ''})('1,000,0001'))
.toEqual([/\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/])
})
it('works when there is a decimal and a prefix', () => {
let numberMask = createNumberMask({integerLimit: 3, allowDecimal: true})
expect(numberMask('$199.34')).toEqual(['$', /\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
})
it('works when there is a decimal and no prefix', () => {
let numberMask = createNumberMask({integerLimit: 3, allowDecimal: true, prefix: ''})
expect(numberMask('199.34')).toEqual([/\d/, /\d/, /\d/, '[]', '.', '[]', /\d/, /\d/])
})
it('works when thousandsSeparatorSymbol is a period', () => {
let numberMask = createNumberMask({
prefix: '',
thousandsSeparatorSymbol: '.',
decimalSymbol: ',',
allowDecimal: true,
requireDecimal: true,
integerLimit: 5,
decimalLimit: 3,
})
expect(numberMask('1234567890,12345678'))
.toEqual([/\d/, /\d/, '.', /\d/, /\d/, /\d/, '[]', ',', '[]', /\d/, /\d/, /\d/])
})
})
describe('numberMask default behavior', () => {
let numberMask:ReturnType<typeof createNumberMask> = null
beforeEach(() => {
numberMask = createNumberMask()
})
it('returns a mask that has the same number of digits as the given number', () => {
expect(numberMask('20382')).toEqual(['$', /\d/, /\d/, ',', /\d/, /\d/, /\d/])
})
it('uses the dollar symbol as the default prefix', () => {
expect(numberMask('1')).toEqual(['$', /\d/])
})
it('adds no suffix by default', () => {
expect(numberMask('1')).toEqual(['$', /\d/])
})
it('returns a mask that appends the currency symbol', () => {
expect(numberMask('1')).toEqual(['$', /\d/])
})
it('adds adds a comma after a thousand', () => {
expect(numberMask('1000')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
})
it('adds as many commas as needed', () => {
expect(numberMask('23984209342084'))
.toEqual(
['$', /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/, ',', /\d/, /\d/, /\d/]
)
})
it('accepts any string and strips out any non-digit characters', () => {
expect(numberMask('h4x0r sp43k')).toEqual(['$', /\d/, ',', /\d/, /\d/, /\d/])
})
it('does not allow leading zeroes', function() {
let numberMask = createNumberMask()
expect(numberMask('012')).toEqual(['$', /\d/, /\d/])
})
it('allows one leading zero followed by a fraction', function() {
let numberMask = createNumberMask({allowDecimal: true})
expect(numberMask('0.12')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/, /\d/])
})
it('ignores fixedDecimalScale when requireDecimal:false', () => {
let numberMask = createNumberMask({allowDecimal: true, fixedDecimalScale:true})
expect(numberMask('0.1')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/])
})
it('fixedDecimalScale expands decimal', () => {
let numberMask = createNumberMask({requireDecimal: true, fixedDecimalScale:true})
expect(numberMask('0.1')).toEqual(['$', /\d/, '[]', '.', '[]', /\d/, /\d/])
})
})
})

View file

@ -0,0 +1,183 @@
// License: Unlicense
// from: https://github.com/text-mask/text-mask/pull/760/commits/f66c81b62c4894b7da43862bee5943f659dc7537
const dollarSign = '$'
const emptyString = ''
const comma = ','
const period = '.'
const minus = '-'
const minusRegExp = /-/
const nonDigitsRegExp = /\D+/g
const number = 'number'
const digitRegExp = /\d/
const caretTrap = '[]'
interface NumberMaskProps {
prefix?: string
suffix?: string
includeThousandsSeparator?: boolean
thousandsSeparatorSymbol?: string
allowDecimal?:boolean
decimalSymbol?: string
decimalLimit?: number
integerLimit?: number
requireDecimal?: boolean
allowNegative?: boolean
allowLeadingZeroes?: boolean
fixedDecimalScale?:boolean
}
export default function createNumberMask({
prefix = dollarSign,
suffix = emptyString,
includeThousandsSeparator = true,
thousandsSeparatorSymbol = comma,
allowDecimal = false,
decimalSymbol = period,
decimalLimit = 2,
requireDecimal = false,
allowNegative = false,
allowLeadingZeroes = false,
fixedDecimalScale = false,
integerLimit = null
}:NumberMaskProps = {}) {
const prefixLength = prefix && prefix.length || 0
const suffixLength = suffix && suffix.length || 0
const thousandsSeparatorSymbolLength = thousandsSeparatorSymbol && thousandsSeparatorSymbol.length || 0
function numberMask(rawValue = emptyString) {
const rawValueLength = rawValue.length
if (
rawValue === emptyString ||
(rawValue[0] === prefix[0] && rawValueLength === 1)
) {
return stringMaskArray(prefix.split(emptyString)).concat([digitRegExp]).concat(suffix.split(emptyString))
} else if (
rawValue === decimalSymbol &&
allowDecimal
) {
return stringMaskArray(prefix.split(emptyString)).concat(['0', decimalSymbol, digitRegExp]).concat(suffix.split(emptyString))
}
const isNegative = ((rawValue[0] === minus) && allowNegative)
//If negative remove "-" sign
if (isNegative) {
rawValue = rawValue.toString().substr(1)
}
const indexOfLastDecimal = rawValue.lastIndexOf(decimalSymbol)
const hasDecimal = indexOfLastDecimal !== -1
let integer
let fraction
let mask
// remove the suffix
if (rawValue.slice(suffixLength * -1) === suffix) {
rawValue = rawValue.slice(0, suffixLength * -1)
}
if (hasDecimal && (allowDecimal || requireDecimal)) {
integer = rawValue.slice(rawValue.slice(0, prefixLength) === prefix ? prefixLength : 0, indexOfLastDecimal)
fraction = rawValue.slice(indexOfLastDecimal + 1, rawValueLength)
fraction = convertToMask(fraction.replace(nonDigitsRegExp, emptyString))
} else {
if (rawValue.slice(0, prefixLength) === prefix) {
integer = rawValue.slice(prefixLength)
} else {
integer = rawValue
}
}
if (integerLimit && typeof integerLimit === number) {
const thousandsSeparatorRegex = thousandsSeparatorSymbol === '.' ? '[.]' : `${thousandsSeparatorSymbol}`
const numberOfThousandSeparators = (integer.match(new RegExp(thousandsSeparatorRegex, 'g')) || []).length
integer = integer.slice(0, integerLimit + (numberOfThousandSeparators * thousandsSeparatorSymbolLength))
}
integer = integer.replace(nonDigitsRegExp, emptyString)
if (!allowLeadingZeroes) {
integer = integer.replace(/^0+(0$|[^0])/, '$1')
}
integer = (includeThousandsSeparator) ? addThousandsSeparator(integer, thousandsSeparatorSymbol) : integer
mask = convertToMask(integer)
if ((hasDecimal && allowDecimal) || requireDecimal === true) {
if (rawValue[indexOfLastDecimal - 1] !== decimalSymbol) {
mask.push(caretTrap)
}
mask.push(decimalSymbol, caretTrap)
if (fraction) {
if (typeof decimalLimit === number) {
fraction = fraction.slice(0, decimalLimit)
}
mask = mask.concat(fraction)
}
if (requireDecimal === true) {
if (fixedDecimalScale === true) {
const decimalLimitRemaining = fraction ? decimalLimit - fraction.length : decimalLimit
for (var i = 0; i < decimalLimitRemaining; i++) {
mask.push(digitRegExp)
}
} else if (rawValue[indexOfLastDecimal - 1] === decimalSymbol) {
mask.push(digitRegExp)
}
}
}
if (prefixLength > 0) {
let res:(string|RegExp)[] = prefix.split(emptyString)
mask = res.concat(mask)
}
if (isNegative) {
// If user is entering a negative number, add a mask placeholder spot to attract the caret to it.
if (mask.length === prefixLength) {
mask.push(digitRegExp)
}
mask = stringMaskArray([minusRegExp]).concat(mask)
}
if (suffix.length > 0) {
mask = mask.concat(suffix.split(emptyString))
}
return mask
}
return numberMask
}
function stringMaskArray(i:(string|(string|RegExp)[])):(string|RegExp)[] {
if (typeof i === 'string'){
return [i]
}
return i
}
function convertToMask(strNumber:string):(string|RegExp)[] {
return strNumber
.split(emptyString)
.map((char) => digitRegExp.test(char) ? digitRegExp : char)
}
// http://stackoverflow.com/a/10899795/604296
function addThousandsSeparator(n:string, thousandsSeparatorSymbol:string) {
return n.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparatorSymbol)
}

View file

@ -0,0 +1,63 @@
// License: LGPL-3.0-or-later
import * as moment from 'moment';
import 'moment-timezone'
function momentTz(date:string, timezone:string='UTC'):moment.Moment {
return moment.tz(date, "YYYY-MM-DD HH:mm:ss", 'UTC').tz(timezone)
}
// Return a date in the format MM/DD/YY for a given date string or moment obj
export function readable_date(date?:string, timezone:string='UTC'):string {
if(!date) return
return momentTz(date,timezone).format("MM/DD/YYYY")
}
// Given a created_at string (eg. Charge.last.created_at.to_s), convert it to a readable date-time string
export function readable_date_time(date?:string, timezone:string='UTC'):string {
if(!date) return
return momentTz(date,timezone).format("MM/DD/YYYY H:mma z")
}
// converts the return value of readable_date_time to it's ISO equivalent
export function readable_date_time_to_iso(date?:string, timezone:string='UTC') {
if(!date) return
return moment.tz(date, 'MM/DD/YYYY H:mma z', timezone)
.tz('UTC')
.toISOString()
}
// Get the month number (eg 01,02...) for the given date string (or moment obj)
export function get_month(date:string|moment.Moment) {
var monthNum = moment(date).month()
return moment().month(monthNum).format('MMM')
}
// Get the year (eg 2017) for the given date string (or moment obj)
export function get_year(date:string|moment.Moment) {
return moment(date).year()
}
// Get the day (number in the month) for the given date string (or moment obj)
export function get_day(date:string|moment.Moment) {
return moment(date).date()
}
export class NonprofitTimezonedDates {
constructor(readonly nonprofitTimezone:string){
}
readable_date(date?:string):string{
return readable_date(date, this.nonprofitTimezone || 'UTC')
}
readable_date_time(date?:string):string {
return readable_date_time(date, this.nonprofitTimezone || 'UTC')
}
readable_date_time_to_iso(date?:string):string {
return readable_date_time_to_iso(date, this.nonprofitTimezone || 'UTC')
}
}

View file

@ -0,0 +1,26 @@
// License: LGPL-3.0-or-later
export interface Dedication {
type?:'honor'|'memory',
supporter_id?: number,
name?:string
contact?: {
email?: string,
phone?:string
address?:string
}
note?:string
}
export function parseDedication(dedication?:string) : Dedication {
if (!dedication || dedication == "")
return {}
return JSON.parse(dedication)
}
export function serializeDedication(dedication:Dedication) : string {
return JSON.stringify(dedication)
}

View file

@ -0,0 +1,15 @@
// License: LGPL-3.0-or-later
export function pluralize(quantity:number, plural_word:string) : string{
var str = String(quantity) + ' '
if(quantity !== 1) return str+plural_word
else return str + to_singular(plural_word)
}
export function to_singular(plural_word:string) : string {
return plural_word
.replace(/ies$/, 'y')
.replace(/oes$/, 'o')
.replace(/s$/, '')
}

View file

@ -0,0 +1,22 @@
// License: LGPL-3.0-or-later
import * as Format from './format'
import 'jest';
describe('Format.dollarsToCents', () => {
const expectedAmount = 120
test("accepts negative amounts",() =>
expect(Format.dollarsToCents("-1.20")).toBe(expectedAmount* -1)
)
test("strips commas and dollar signs",() =>
expect(Format.dollarsToCents("$1,20")).toBe(expectedAmount* 100)
)
test("properly handles slightly shorter than normal decimals",() =>
expect(Format.dollarsToCents("$1.2")).toBe(expectedAmount)
)
})

View file

@ -0,0 +1,57 @@
// License: LGPL-3.0-or-later
import * as deprecated_format from './deprecated_format'
export function centsToDollars(cents:string|number|undefined, options:{noCents?:boolean}={}):string {
if(cents === undefined) return '0'
let centsAsNumber:number = undefined
if (typeof cents === 'string')
{
centsAsNumber = Number(cents)
}
else {
centsAsNumber = cents
}
return numberWithCommas((centsAsNumber / 100.0).toFixed(options.noCents ? 0 : 2).toString()).replace(/\.00$/,'')
}
export function dollarsToCents(dollars:string) {
//strips
dollars = dollars.toString().replace(/[$,]/g, '')
if(dollars.match(/^-?\d+\.\d$/)) {
// could we use toFixed instead? Probably but this is straightforward.
dollars = dollars + "0"
}
if(!dollars.match(/^-?\d+(\.\d\d)?$/)) throw "Invalid dollar amount: " + dollars
return Math.round(Number(dollars) * 100)
}
export function numberWithCommas(n:string|number):string {
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}
export function camelToWords(str:string, os?:any) {
if(!str) return str
return str.replace(/([A-Z])/g, " $1")
}
export function readableKind(kind:string) {
if (kind === "Donation") return "One-Time Donation"
else if (kind === "OffsitePayment") return "Offsite Donation"
else if (kind === "Ticket") return "Ticket Purchase"
else return camelToWords(kind)
}
export function readableInterval(interval:number, time_unit:string) {
if(interval === 1) return time_unit + 'ly'
if(interval === 4 && time_unit === 'year') return 'quarterly'
if(interval === 2 && time_unit === 'year') return 'biannually'
if(interval === 2 && time_unit === 'week') return 'biweekly'
if(interval === 2 && time_unit === 'month') return 'bimonthly'
else return 'every ' + deprecated_format.pluralize(Number(interval), time_unit + 's')
}

View file

@ -0,0 +1,5 @@
import {FieldDefinition} from "mobx-react-form";
export function createFieldDefinition<TInOut>(fieldDef:FieldDefinition<TInOut>) : FieldDefinition<TInOut> {
return fieldDef;
}

View file

@ -0,0 +1,17 @@
// License: LGPL-3.0-or-later
export function castToNullIfUndef<T>(i:T): T | null{
return i === undefined ? null : i
}
export function isBlank(i:null|undefined|string) : boolean {
return i === null || i === undefined || i === '';
}
export function isFilled(i:null|undefined|string) : boolean {
return !isBlank(i)
}
export function castToUndefinedIfBlank(i:null|undefined|string) :
string | undefined {
return isBlank(i) ? undefined : i;
}

View file

@ -55,7 +55,7 @@ export class Validations {
{
return ({field, validator}:ValidationInput) => {
return [
parseFloat(field.value) >= value,
parseFloat(field.get('value')) >= value,
`${field.label} must be at least ${value}`
]
}
@ -64,7 +64,7 @@ export class Validations {
static isLessThanOrEqualTo(value:number, flip:boolean=false) : ({field, validator}:ValidationInput) => StringBoolTuple
{
return ({field, validator}:ValidationInput) => {
let float = parseFloat(field.value)
let float = field.get('value')
return [
(flip ? -1 * float : float) <= value,
`${field.label} must be no more than ${value}`

View file

@ -0,0 +1,44 @@
# License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later
module MaintainDedications
def self.retrieve_json_dedications
return Qx.select('id', 'dedication').from(:donations)
.where("is_valid_json(dedication)").ex
end
def self.retrieve_non_json_dedications(include_blank=false)
temp = Qx.select('id', 'dedication').from(:donations)
temp = temp.where("dedication IS NOT NULL AND dedication != ''") unless include_blank
temp = temp.and_where("NOT is_valid_json(dedication)")
return temp.ex
end
def self.create_json_dedications_from_plain_text(dedications)
dedications.map do |i|
output = {id: i['id']}
if i['dedication'] =~ /(((in (loving )?)?memory of|in memorium)\:? )(.+)/i
output[:dedication] = JSON.generate({type: 'memory', note: $+ })
elsif i['dedication'] =~ /((in honor of|honor of)\:? )(.+)/i
output[:dedication] = JSON.generate({type: 'honor', note: $+ })
else
output[:dedication] = JSON.generate({type: 'honor', note: i['dedication'] })
end
output
end.each do |i|
Qx.update(:donations).where('id = $id', {id: i[:id]}).set({dedication: i[:dedication]}).ex
end
end
def self.add_honor_to_any_json_dedications_without_type(json_dedications)
json_dedications.map{|i| {'id' => i['id'], 'dedication' => JSON::parse(i['dedication']) }}
.select{|i| !%w(honor memory).include?(i['dedication']['type'])}
.map{|i| i['dedication']['type'] = 'honor'; i }
.each do |i|
Qx.update(:donations).where('id = $id', id: i['id'])
.set(dedication: JSON.generate(i['dedication'])).ex
end
end
end

View file

@ -91,7 +91,7 @@ module UpdateDonation
# edits_to_payments
if is_offsite
#if offline, set date, gross_amount, fee_total, net_amount
existing_payment.towards = data[:dedication] if data[:dedication]
existing_payment.towards = data[:designation] if data[:designation]
existing_payment.date = data[:date] if data[:date]
existing_payment.gross_amount = data[:gross_amount] if data[:gross_amount]
existing_payment.fee_total = data[:fee_total] if data[:fee_total]

9
package-lock.json generated
View file

@ -199,6 +199,15 @@
"@types/react": "*"
}
},
"@types/react-text-mask": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/@types/react-text-mask/-/react-text-mask-5.4.2.tgz",
"integrity": "sha512-R4h07wAeOPh6xc6E9qYPMgYpeuUJ1w3rs3oWwp9oWIVPtnAGxPGDuCPEs2+ynlP5syeM7heZeZeM8saAHRgENA==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/sinon": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-4.3.3.tgz",

View file

@ -30,6 +30,7 @@
"@types/react-dom": "^16.0.5",
"@types/react-intl": "^2.3.7",
"@types/react-test-renderer": "^16.0.1",
"@types/react-text-mask": "^5.4.2",
"@types/sinon": "^4.3.3",
"@types/validator": "^9.4.1",
"babel-core": "^6.26.0",

View file

@ -263,7 +263,7 @@ describe UpdateDonation do
expected_donation = donation.attributes.merge({
date: new_date.to_time_in_current_zone,
date: new_date,
amount: new_amount,
designation: new_designation,
@ -279,7 +279,7 @@ describe UpdateDonation do
donation.reload
expect(donation.attributes).to eq expected_donation
expected_p1 = payment.attributes.merge({towards: new_dedication, updated_at: Time.now, date: new_date, gross_amount: new_amount, fee_total: new_fee, net_amount: new_amount-new_fee}).with_indifferent_access
expected_p1 = payment.attributes.merge({towards: new_designation, updated_at: Time.now, date: new_date, gross_amount: new_amount, fee_total: new_fee, net_amount: new_amount-new_fee}).with_indifferent_access
payment.reload
expect(payment.attributes).to eq expected_p1
@ -367,7 +367,7 @@ describe UpdateDonation do
donation.reload
expect(donation.attributes).to eq expected_donation
expected_p1 = payment.attributes.merge({towards: '', updated_at: Time.now, date: new_date, gross_amount: new_amount, fee_total: new_fee, net_amount: new_amount-new_fee}).with_indifferent_access
expected_p1 = payment.attributes.merge({towards: '', updated_at: Time.now, date: new_date.to_time_in_current_zone, gross_amount: new_amount, fee_total: new_fee, net_amount: new_amount-new_fee}).with_indifferent_access
payment.reload
expect(payment.attributes).to eq expected_p1

View file

@ -1,3 +1,5 @@
import { values } from "mobx";
// License: LGPL-3.0-or-later
interface ValidationInput {
@ -315,6 +317,7 @@ export class Form implements Base {
readonly isValid :boolean;
readonly size:number
values(): {[fields:string] : ValuesResponse|string}
}

View file

@ -1,20 +0,0 @@
// License: LGPL-3.0-or-later
import {Component} from 'react'
export interface MaskedInputProps
{
mask: Array<any>|Function|Boolean | {mask: Array<any> | Function, pipe: Function}
guide?: Boolean
value?: String| Number,
pipe?: Function,
placeholderChar?: String,
keepCharPositions?: Boolean,
showMask?: Boolean,
[additionalProps: string] : any
}
export class MaskedInput extends Component<MaskedInputProps, {}> {
}

View file

@ -1,2 +0,0 @@
// License: LGPL-3.0-or-later
declare module "text-mask"