diff --git a/bundles/public/apiv2/Models.ts b/bundles/public/apiv2/Models.ts index d65eb5c0..9ac28e08 100644 --- a/bundles/public/apiv2/Models.ts +++ b/bundles/public/apiv2/Models.ts @@ -28,6 +28,9 @@ export interface Event extends ModelBase { timezone: string; receivername: string; receiver_short: string; + receiver_solicitation_text: string; + receiver_logo: string; + receiver_privacy_policy: string; paypalcurrency: string; use_one_step_screening: boolean; allow_donations: boolean; diff --git a/bundles/tracker/donation/DonationConstants.ts b/bundles/tracker/donation/DonationConstants.ts index 5031e717..ab26b631 100644 --- a/bundles/tracker/donation/DonationConstants.ts +++ b/bundles/tracker/donation/DonationConstants.ts @@ -7,10 +7,6 @@ export const EMAIL_OPTIONS = [ name: 'No', value: 'OPTOUT', }, - { - name: 'Use Existing Preference (No if not set)', - value: 'CURR', - }, ]; export const AMOUNT_PRESETS = [25, 50, 75, 100, 250, 500]; diff --git a/bundles/tracker/donation/__tests__/validateDonation.spec.ts b/bundles/tracker/donation/__tests__/validateDonation.spec.ts index 4f236136..d23e0757 100644 --- a/bundles/tracker/donation/__tests__/validateDonation.spec.ts +++ b/bundles/tracker/donation/__tests__/validateDonation.spec.ts @@ -6,6 +6,9 @@ const eventDetails = { csrfToken: 'testing', currency: 'USD', receiverName: 'a beneficiary', + receiverPrivacyPolicy: '', + receiverLogo: '', + receiverSolicitationText: '', prizesUrl: 'https://example.com/prizes', donateUrl: 'https://example.com/donate', minimumDonation: 2.0, diff --git a/bundles/tracker/donation/components/Donate.tsx b/bundles/tracker/donation/components/Donate.tsx index 18836556..d2d75a8c 100644 --- a/bundles/tracker/donation/components/Donate.tsx +++ b/bundles/tracker/donation/components/Donate.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { useLocation } from 'react-router'; import { useConstants } from '@common/Constants'; @@ -7,21 +7,22 @@ import { useCachedCallback } from '@public/hooks/useCachedCallback'; import * as CurrencyUtils from '@public/util/currency'; import Anchor from '@uikit/Anchor'; import Button from '@uikit/Button'; +import Checkbox from '@uikit/Checkbox'; import Container from '@uikit/Container'; import CurrencyInput from '@uikit/CurrencyInput'; import ErrorAlert from '@uikit/ErrorAlert'; import Header from '@uikit/Header'; -import RadioGroup from '@uikit/RadioGroup'; import Text from '@uikit/Text'; import TextInput from '@uikit/TextInput'; +import { Donation } from '@tracker/donation/DonationTypes'; import * as EventDetailsStore from '@tracker/event_details/EventDetailsStore'; import useDispatch from '@tracker/hooks/useDispatch'; import { StoreState } from '@tracker/Store'; import { AnalyticsEvent, track } from '../../analytics/Analytics'; import * as DonationActions from '../DonationActions'; -import { AMOUNT_PRESETS, EMAIL_OPTIONS } from '../DonationConstants'; +import { AMOUNT_PRESETS } from '../DonationConstants'; import * as DonationStore from '../DonationStore'; import DonationIncentives from './DonationIncentives'; import DonationPrizes from './DonationPrizes'; @@ -54,6 +55,7 @@ const Donate = (props: DonateProps) => { commentErrors: DonationStore.getCommentFormErrors(state), donationValidity: DonationStore.validateDonation(state), }), + shallowEqual, ); React.useEffect(() => { @@ -66,11 +68,21 @@ const Donate = (props: DonateProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventId]); - const { currency, receiverName, donateUrl, minimumDonation, maximumDonation, step } = eventDetails; + const { + currency, + receiverSolicitationText, + receiverLogo, + receiverPrivacyPolicy, + receiverName, + donateUrl, + minimumDonation, + maximumDonation, + step, + } = eventDetails; const { name, email, wantsEmails, amount, comment } = donation; const updateDonation = React.useCallback( - (fields = {}) => { + (fields: Partial = {}) => { dispatch(DonationActions.updateDonation(fields)); }, [dispatch], @@ -84,9 +96,9 @@ const Donate = (props: DonateProps) => { const updateName = React.useCallback((name: string) => updateDonation({ name }), [updateDonation]); const updateEmail = React.useCallback((email: string) => updateDonation({ email }), [updateDonation]); - const updateWantsEmails = React.useCallback( - (value: 'CURR' | 'OPTIN' | 'OPTOUT') => updateDonation({ wantsEmails: value }), - [updateDonation], + const toggleWantsEmails = React.useCallback( + () => updateDonation({ wantsEmails: donation.wantsEmails === 'OPTIN' ? 'OPTOUT' : 'OPTIN' }), + [donation.wantsEmails, updateDonation], ); const updateAmount = React.useCallback((amount: number) => updateDonation({ amount }), [updateDonation]); const updateAmountPreset = useCachedCallback( @@ -135,16 +147,20 @@ const Donate = (props: DonateProps) => { - - Do you want to receive emails from {receiverName}? - - - + + {receiverSolicitationText || `Check here to receive emails from ${receiverName}`} + + }> + {receiverPrivacyPolicy && ( + + Click here for the privacy policy for {receiverName} + + )} + @@ -218,6 +234,11 @@ const Donate = (props: DonateProps) => { Donate {amount != null ? CurrencyUtils.asCurrency(amount, { currency }) : null} + {receiverLogo && ( +
+ {receiverName} +
+ )} ); }; diff --git a/bundles/tracker/donation/components/DonateInitializer.tsx b/bundles/tracker/donation/components/DonateInitializer.tsx index 3dba862b..ab8da7f9 100644 --- a/bundles/tracker/donation/components/DonateInitializer.tsx +++ b/bundles/tracker/donation/components/DonateInitializer.tsx @@ -59,6 +59,9 @@ type DonateInitializerProps = { event: { paypalcurrency: string; receivername: string; + receiver_solicitation_text: string; + receiver_logo: string; + receiver_privacy_policy: string; }; step: number; minimumDonation: number; @@ -122,6 +125,9 @@ const DonateInitializer = (props: DonateInitializerProps) => { csrfToken, currency: event.paypalcurrency, receiverName: event.receivername, + receiverLogo: event.receiver_logo, + receiverPrivacyPolicy: event.receiver_privacy_policy, + receiverSolicitationText: event.receiver_solicitation_text, prizesUrl, donateUrl, minimumDonation, diff --git a/bundles/tracker/donation/components/DonationBidForm.tsx b/bundles/tracker/donation/components/DonationBidForm.tsx index 4f45bac4..cb079fce 100644 --- a/bundles/tracker/donation/components/DonationBidForm.tsx +++ b/bundles/tracker/donation/components/DonationBidForm.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import classNames from 'classnames'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { useCachedCallback } from '@public/hooks/useCachedCallback'; import * as CurrencyUtils from '@public/util/currency'; @@ -32,14 +32,17 @@ type DonationBidFormProps = { const DonationBidForm = (props: DonationBidFormProps) => { const { incentiveId, step, total: donationTotal, className, onSubmit } = props; - const { currency, incentive, bidChoices, donation, bids, allocatedTotal } = useSelector((state: StoreState) => ({ - currency: EventDetailsStore.getEventCurrency(state), - incentive: EventDetailsStore.getIncentive(state, incentiveId), - bidChoices: EventDetailsStore.getChildIncentives(state, incentiveId), - donation: DonationStore.getDonation(state), - bids: DonationStore.getBids(state), - allocatedTotal: DonationStore.getAllocatedBidTotal(state), - })); + const { currency, incentive, bidChoices, donation, bids, allocatedTotal } = useSelector( + (state: StoreState) => ({ + currency: EventDetailsStore.getEventCurrency(state), + incentive: EventDetailsStore.getIncentive(state, incentiveId), + bidChoices: EventDetailsStore.getChildIncentives(state, incentiveId), + donation: DonationStore.getDonation(state), + bids: DonationStore.getBids(state), + allocatedTotal: DonationStore.getAllocatedBidTotal(state), + }), + shallowEqual, + ); const remainingDonationTotal = donationTotal - allocatedTotal; const remainingDonationTotalString = CurrencyUtils.asCurrency(remainingDonationTotal, { currency }); diff --git a/bundles/tracker/donation/components/DonationBids.tsx b/bundles/tracker/donation/components/DonationBids.tsx index db15d3a5..82e722fd 100644 --- a/bundles/tracker/donation/components/DonationBids.tsx +++ b/bundles/tracker/donation/components/DonationBids.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import classNames from 'classnames'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { useCachedCallback } from '@public/hooks/useCachedCallback'; import * as CurrencyUtils from '@public/util/currency'; @@ -72,11 +72,14 @@ const DonationBids = (props: DonationBidsProps) => { const { className } = props; const dispatch = useDispatch(); - const { bids, incentives, bidErrors } = useSelector((state: StoreState) => ({ - bids: DonationStore.getBids(state), - incentives: EventDetailsStore.getIncentivesById(state), - bidErrors: DonationStore.getBidsFormErrors(state), - })); + const { bids, incentives, bidErrors } = useSelector( + (state: StoreState) => ({ + bids: DonationStore.getBids(state), + incentives: EventDetailsStore.getIncentivesById(state), + bidErrors: DonationStore.getBidsFormErrors(state), + }), + shallowEqual, + ); const handleDeleteBid = useCachedCallback( incentiveId => { diff --git a/bundles/tracker/donation/components/DonationIncentives.tsx b/bundles/tracker/donation/components/DonationIncentives.tsx index b3ee2fb7..9e0a3bdf 100644 --- a/bundles/tracker/donation/components/DonationIncentives.tsx +++ b/bundles/tracker/donation/components/DonationIncentives.tsx @@ -1,6 +1,6 @@ import React from 'react'; import classNames from 'classnames'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { useCachedCallback } from '@public/hooks/useCachedCallback'; import Button from '@uikit/Button'; @@ -37,11 +37,14 @@ const DonationIncentives = (props: DonationIncentivesProps) => { const [selectedIncentiveId, setSelectedIncentiveId] = React.useState(undefined); const [showForm, setShowForm] = React.useState(false); const setShowFormTrue = React.useCallback(() => setShowForm(true), []); - const { bids, allocatedBidTotal, incentives } = useSelector((state: StoreState) => ({ - bids: DonationStore.getBids(state), - allocatedBidTotal: DonationStore.getAllocatedBidTotal(state), - incentives: EventDetailsStore.getTopLevelIncentives(state), - })); + const { bids, allocatedBidTotal, incentives } = useSelector( + (state: StoreState) => ({ + bids: DonationStore.getBids(state), + allocatedBidTotal: DonationStore.getAllocatedBidTotal(state), + incentives: EventDetailsStore.getTopLevelIncentives(state), + }), + shallowEqual, + ); const searchResults = searchIncentives(incentives, search); const canAddBid = total - allocatedBidTotal > 0; diff --git a/bundles/tracker/event_details/EventDetailsReducer.ts b/bundles/tracker/event_details/EventDetailsReducer.ts index b36de627..a3c54508 100644 --- a/bundles/tracker/event_details/EventDetailsReducer.ts +++ b/bundles/tracker/event_details/EventDetailsReducer.ts @@ -10,6 +10,9 @@ const initialState: EventDetailsState = { csrfToken: '', currency: 'USD', // Default to USD to make tests happy receiverName: '', + receiverSolicitationText: '', + receiverLogo: '', + receiverPrivacyPolicy: '', prizesUrl: '', donateUrl: '', minimumDonation: 1, diff --git a/bundles/tracker/event_details/EventDetailsTypes.ts b/bundles/tracker/event_details/EventDetailsTypes.ts index 559a5c35..ccb59d52 100644 --- a/bundles/tracker/event_details/EventDetailsTypes.ts +++ b/bundles/tracker/event_details/EventDetailsTypes.ts @@ -33,6 +33,9 @@ export type EventDetails = { csrfToken: string; currency: string; receiverName: string; + receiverSolicitationText: string; + receiverLogo: string; + receiverPrivacyPolicy: string; prizesUrl: string; donateUrl: string; minimumDonation: number; diff --git a/bundles/tracker/prizes/components/Prize.tsx b/bundles/tracker/prizes/components/Prize.tsx index 42be784c..c932a415 100644 --- a/bundles/tracker/prizes/components/Prize.tsx +++ b/bundles/tracker/prizes/components/Prize.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { shallowEqual, useSelector } from 'react-redux'; import { useNavigate } from 'react-router'; import { useConstants } from '@common/Constants'; @@ -102,7 +102,7 @@ const Prize = (props: PrizeProps) => { eventId: prize != null ? prize.eventId : undefined, prize, }; - }); + }, shallowEqual); useEffect(() => { dispatch(PrizeActions.fetchPrizes({ id: prizeId })); diff --git a/spec/entry.js b/spec/entry.js index 1c6f9bf4..f87e4615 100644 --- a/spec/entry.js +++ b/spec/entry.js @@ -3,3 +3,23 @@ import './matchers'; const modules = require.context('../bundles', true, /(Spec|\.spec)\.[jt]sx?$/); modules.keys().forEach(modules); + +let consoleLogs = []; + +function failTest(...args) { + if (args[1] !== 'ReactNumeric') { + consoleLogs.push([...args]); + } + // eslint-disable-next-line no-console + console.log(...args); +} + +beforeEach(() => { + consoleLogs = []; + spyOn(console, 'warn').and.callFake(failTest); + spyOn(console, 'error').and.callFake(failTest); +}); + +afterEach(() => { + expect(consoleLogs).toEqual([]); +}); diff --git a/tests/apiv2/test_events.py b/tests/apiv2/test_events.py index 5f1fcc81..3331fb6b 100644 --- a/tests/apiv2/test_events.py +++ b/tests/apiv2/test_events.py @@ -1,7 +1,7 @@ from django.urls import reverse from rest_framework.test import APIClient -from tracker import settings +from tracker import models, settings from tracker.api.serializers import EventSerializer from ..util import APITestCase @@ -9,6 +9,7 @@ class TestEvents(APITestCase): model_name = 'event' + serializer_class = EventSerializer def setUp(self): super().setUp() @@ -16,18 +17,27 @@ def setUp(self): self.client.force_authenticate(user=self.super_user) def test_event_list(self): - events = [self.blank_event, self.event, self.locked_event] - serialized = self.get_paginated_response( - events, EventSerializer(events, many=True).data - ) - response = self.client.get(reverse('tracker:api_v2:event-list')) - self.assertEqual(response.data['results'], serialized.data['results']) + events = models.Event.objects.with_annotations() + + with self.saveSnapshot(): + data = self.get_list() + self.assertExactV2Models(events, data) + + data = self.get_list(data={'totals': ''}) + self.assertExactV2Models( + events, data, serializer_kwargs={'with_totals': True} + ) def test_event_detail(self): - response = self.client.get( - reverse('tracker:api_v2:event-detail', args=(self.event.pk,)) - ) - self.assertEqual(response.data, EventSerializer(self.event).data) + event = models.Event.objects.with_annotations().get(id=self.event.id) + with self.saveSnapshot(): + data = self.get_detail(event) + self.assertV2ModelPresent(event, data) + + data = self.get_detail(event, data={'totals': ''}) + self.assertV2ModelPresent( + event, data, serializer_kwargs={'with_totals': True} + ) def test_nonsense_params(self): # not specific to events, but good enough diff --git a/tracker/admin/event.py b/tracker/admin/event.py index 5503a387..422ce0f9 100644 --- a/tracker/admin/event.py +++ b/tracker/admin/event.py @@ -71,6 +71,9 @@ class EventAdmin(RelatedUserMixin, CustomModelAdmin): 'hashtag', 'receivername', 'receiver_short', + 'receiver_solicitation_text', + 'receiver_logo', + 'receiver_privacy_policy', 'use_one_step_screening', 'minimumdonation', 'auto_approve_threshold', diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index ab548d99..d8251aad 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -733,6 +733,9 @@ class Meta: 'timezone', 'receivername', 'receiver_short', + 'receiver_solicitation_text', + 'receiver_logo', + 'receiver_privacy_policy', 'use_one_step_screening', 'locked', 'allow_donations', diff --git a/tracker/migrations/0063_add_receiver_fields.py b/tracker/migrations/0063_add_receiver_fields.py new file mode 100644 index 00000000..6b861b7c --- /dev/null +++ b/tracker/migrations/0063_add_receiver_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.4 on 2025-02-05 19:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracker', '0062_backfill_accepted_number'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='receiver_logo', + field=models.URLField(blank=True, help_text='URL to display a logo image for the receiver', max_length=256), + ), + migrations.AddField( + model_name='event', + name='receiver_privacy_policy', + field=models.URLField(blank=True, help_text='URL to link to an external privacy policy specific to the receiver', max_length=256), + ), + migrations.AddField( + model_name='event', + name='receiver_solicitation_text', + field=models.CharField(blank=True, help_text='Override the standard messaging on the receive emails checkbox', max_length=128), + ), + ] diff --git a/tracker/models/event.py b/tracker/models/event.py index 8941e95e..e2e4fd7c 100644 --- a/tracker/models/event.py +++ b/tracker/models/event.py @@ -119,6 +119,21 @@ class Event(models.Model): verbose_name='Receiver Name (Short)', help_text='Useful for space constrained displays', ) + receiver_solicitation_text = models.CharField( + max_length=128, + blank=True, + help_text='Override the standard messaging on the receive emails checkbox', + ) + receiver_logo = models.URLField( + max_length=256, + blank=True, + help_text='URL to display a logo image for the receiver', + ) + receiver_privacy_policy = models.URLField( + max_length=256, + blank=True, + help_text='URL to link to an external privacy policy specific to the receiver', + ) minimumdonation = models.DecimalField( decimal_places=2, max_digits=20,