diff --git a/spa/cypress/e2e/01-donationPage.cy.js b/spa/cypress/e2e/01-donationPage.cy.js index ede3bbfe8a..3018e8c6ef 100644 --- a/spa/cypress/e2e/01-donationPage.cy.js +++ b/spa/cypress/e2e/01-donationPage.cy.js @@ -189,15 +189,14 @@ describe('Reason for Giving element', () => { cy.getByTestId('d-reason').should('exist'); }); - it('should render picklist with options', () => { - cy.getByTestId('excited-to-support-picklist').should('exist'); - cy.getByTestId('excited-to-support-picklist').click(); + it('should render select with options', () => { + cy.getByTestId('reason-for-giving-reason-select').should('exist'); + cy.getByTestId('reason-for-giving-reason-select').click(); cy.findByRole('option', { name: 'test1' }).click(); }); it('should not show "honoree/in_memory_of" input if "No" is selected', () => { - // tribute_type "No" has value "", hense `tribute-""`. - cy.getByTestId('tribute-') + cy.getByTestId('tribute-type-null') .get('input') .then(($input) => { cy.log($input); @@ -208,10 +207,10 @@ describe('Reason for Giving element', () => { }); it('should show tribute input if honoree or in_memory_of is selected', () => { - cy.getByTestId('tribute-type_honoree').click(); + cy.getByTestId('tribute-type-honoree').click(); cy.getByTestId('tribute-input').should('exist'); - cy.getByTestId('tribute-type_in_memory_of').click(); + cy.getByTestId('tribute-type-inMemoryOf').click(); cy.getByTestId('tribute-input').should('exist'); }); @@ -247,13 +246,13 @@ describe('Reason for Giving element', () => { cy.wait('@getPageDetail'); // Assert that we don't go to the payment modal if we don't pick a reason. + // We should be blocked by browser validation. fillOutDonorInfoSection(); fillOutAddressSection(); cy.get('form') .findByRole('button', { name: /Continue to Payment/ }) .click(); - cy.get('#dreason-select-helper-text').contains(validationError); cy.get('form #stripe-payment-element').should('not.exist'); }); @@ -454,9 +453,9 @@ function fillOutDonorInfoSection() { } function fillOutReasonForGiving() { - cy.get('[data-testid="excited-to-support-picklist"]').click(); + cy.get('[data-testid="reason-for-giving-reason-select"]').click(); cy.findByRole('option', { name: 'test1' }).click(); - cy.get('[data-testid="excited-to-support-picklist"]').invoke('val').as('reasonValue'); + cy.get('[data-testid="reason-for-giving-reason-select"]').invoke('val').as('reasonValue'); } const fakeEmailHash = 'b4170aca0fd3e60'; diff --git a/spa/src/components/donationPage/pageContent/DReason/DReason.tsx b/spa/src/components/donationPage/pageContent/DReason/DReason.tsx new file mode 100644 index 0000000000..c654b270f7 --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/DReason.tsx @@ -0,0 +1,60 @@ +import PropTypes, { InferProps } from 'prop-types'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { usePage } from 'components/donationPage/DonationPage'; +import { ReasonElement } from 'hooks/useContributionPage'; +import DElement from '../DElement'; +import TributeFields, { TributeFieldsProps } from './TributeFields'; +import ReasonFields from './ReasonFields'; + +const DReasonPropTypes = { + element: PropTypes.object.isRequired +}; + +export interface DReasonProps extends InferProps { + element: ReasonElement; +} + +export function DReason({ element }: DReasonProps) { + const { errors } = usePage(); + const [selectedReason, setSelectedReason] = useState(''); + const [reasonText, setReasonText] = useState(''); + const [tributeType, setTributeType] = useState(null); + const [tributeName, setTributeName] = useState(''); + const { t } = useTranslation(); + const required = useMemo(() => element.requiredFields.includes('reason_for_giving'), [element.requiredFields]); + + return ( + + + {(element.content.askHonoree || element.content.askInMemoryOf) && ( + + )} + + ); +} + +DReason.propTypes = DReasonPropTypes; +DReason.type = 'DReason'; +DReason.displayName = 'Reason for Giving'; +DReason.description = "Collect information about the contributor's reason for giving"; +DReason.required = false; +DReason.unique = true; +export default DReason; diff --git a/spa/src/components/donationPage/pageContent/DReason/ReasonFields.styled.ts b/spa/src/components/donationPage/pageContent/DReason/ReasonFields.styled.ts new file mode 100644 index 0000000000..742bfe9724 --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/ReasonFields.styled.ts @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +import { Select as BaseSelect, TextField as BaseTextField } from 'components/base'; + +export const Root = styled.div` + display: grid; + gap: 25px; +`; + +export const Select = styled(BaseSelect)` + && { + .NreTextFieldInputLabelRoot { + font-weight: 500; + margin-bottom: 0.5em; + } + + .NreTextFieldInputLabelAsterisk { + color: #ff476c; + } + + .NreSelectMenu { + background: ${({ theme }) => theme.colors.cstm_inputBackground}; + border: 1px solid #080708; + border-color: ${({ theme }) => theme.colors.cstm_inputBorder}; + border-radius: 3px; + padding: 1rem; + + &:focus { + border-color: ${({ theme }) => theme.colors.cstm_CTAs}; + } + } + } +`; + +export const TextField = styled(BaseTextField)` + && { + .NreTextFieldInput { + background: ${({ theme }) => theme.colors.cstm_inputBackground}; + border-color: ${({ theme }) => theme.colors.cstm_inputBorder}; + border-width: 1px; + } + + .NreTextFieldInputLabelAsterisk { + color: #ff476c; + } + } +`; diff --git a/spa/src/components/donationPage/pageContent/DReason/ReasonFields.tsx b/spa/src/components/donationPage/pageContent/DReason/ReasonFields.tsx new file mode 100644 index 0000000000..47d9c32097 --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/ReasonFields.tsx @@ -0,0 +1,94 @@ +import { Collapse } from '@material-ui/core'; +import PropTypes, { InferProps } from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { useMemo } from 'react'; +import { Root, Select, TextField } from './ReasonFields.styled'; + +// We need to track both a selected reason and a user-entered reason so that if +// the user types in a reason that exactly matches a pre-selected one, the text +// field doesn't disappear. In other words, if we used a single value, then we +// wouldn't be able to distinguish between a user who chose a pre-selected value +// and one who typed one in that matched a pre-selected value, because we +// conditionally show the text field in this component depending on the state of +// the select. + +const ReasonFieldsPropTypes = { + onChangeText: PropTypes.func.isRequired, + onChangeOption: PropTypes.func.isRequired, + optionError: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + required: PropTypes.bool, + selectedOption: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + textError: PropTypes.string +}; + +export interface ReasonFieldsProps extends InferProps { + onChangeText: (value: string) => void; + onChangeOption: (value: string) => void; +} + +export function ReasonFields({ + onChangeOption, + onChangeText, + optionError, + options, + required, + selectedOption, + text, + textError +}: ReasonFieldsProps) { + const { t } = useTranslation(); + const otherReasonLabel = t('donationPage.dReason.other'); + const optionsWithOther = useMemo(() => { + if (options.length > 0) { + return [ + ...options.map((option) => ({ label: option, value: option })), + { label: otherReasonLabel, value: otherReasonLabel } + ]; + } + + return []; + }, [options, otherReasonLabel]); + + // Names on inputs below must be set exactly in order for the form to be + // submitted properly. + // + // We also need to ensure the text field unmounts when transitioned out so + // it's not present in the DOM at form submission time, so its value doesn't + // appear in the form data. + + return ( + + {optionsWithOther.length > 0 && ( + + )} + + onChangeText(event.target.value)} + required={!!required} + value={text} + /> + + + ); +} + +ReasonFields.propTypes = ReasonFieldsPropTypes; +export default ReasonFields; diff --git a/spa/src/components/donationPage/pageContent/DReason/TributeFields.styled.ts b/spa/src/components/donationPage/pageContent/DReason/TributeFields.styled.ts new file mode 100644 index 0000000000..4b816cff7b --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/TributeFields.styled.ts @@ -0,0 +1,46 @@ +import styled from 'styled-components'; +// Using the MUI core radio controls to match appearance in DFrequency. +import { FormControlLabel as MuiFormControlLabel, Radio as MuiRadio } from '@material-ui/core'; +import { Checkbox as BaseCheckbox, TextField as BaseTextField } from 'components/base'; + +export const Prompt = styled.p` + font-size: ${({ theme }) => theme.fontSizesUpdated.md}; + font-weight: 500; + margin-bottom: 6px; + padding-top: 25px; +`; + +export const Checkbox = styled(BaseCheckbox)` + && { + &.Mui-checked { + color: ${({ theme }) => theme.colors.cstm_CTAs}; + } + } +`; + +export const Radio = styled(MuiRadio)``; + +export const RadioFormControlLabel = styled(MuiFormControlLabel)` + && span { + font-size: ${({ theme }) => theme.fontSizesUpdated.md}; + } +`; + +export const Root = styled.div` + display: grid; + gap: 25px; +`; + +export const TextField = styled(BaseTextField)` + && { + .NreTextFieldInput { + background: ${({ theme }) => theme.colors.cstm_inputBackground}; + border-color: ${({ theme }) => theme.colors.cstm_inputBorder}; + border-width: 1px; + } + + .NreTextFieldInputLabelAsterisk { + color: #ff476c; + } + } +`; diff --git a/spa/src/components/donationPage/pageContent/DReason/TributeFields.tsx b/spa/src/components/donationPage/pageContent/DReason/TributeFields.tsx new file mode 100644 index 0000000000..ccc9904b6d --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/TributeFields.tsx @@ -0,0 +1,160 @@ +import { Collapse } from '@material-ui/core'; +import PropTypes, { InferProps } from 'prop-types'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FormControlLabel as BaseFormControlLabel, RadioGroup } from 'components/base'; +import { Checkbox, RadioFormControlLabel, Prompt, Radio, Root, TextField } from './TributeFields.styled'; + +const TributeFieldsPropTypes = { + askHonoree: PropTypes.bool, + askInMemoryOf: PropTypes.bool, + error: PropTypes.string, + onChangeTributeName: PropTypes.func.isRequired, + onChangeTributeType: PropTypes.func.isRequired, + tributeName: PropTypes.string.isRequired +}; + +export type TributeType = 'honoree' | 'inMemoryOf' | null; + +export interface TributeFieldsProps extends InferProps { + onChangeTributeName: (value: string) => void; + onChangeTributeType: (value: TributeType) => void; + tributeType: TributeType; +} + +/** + * Radio buttons shown when there are multiple types of tribute available. + */ +const typeRadios = [ + { + label: 'common.no', + value: null + }, + { + label: 'donationPage.dReason.tributeSelector.yes.inHonorOf', + value: 'honoree' + }, + { + label: 'donationPage.dReason.tributeSelector.yes.inMemoryOf', + value: 'inMemoryOf' + } +]; + +/** + * Maps the tributeType prop to the input value the backend expects. + */ +const typeValues = { + honoree: 'type_honoree', + inMemoryOf: 'type_in_memory_of' +}; + +export function TributeFields({ + askHonoree, + askInMemoryOf, + error, + onChangeTributeName, + onChangeTributeType, + tributeName, + tributeType +}: TributeFieldsProps) { + const { t } = useTranslation(); + + // We need to remember the last tribute type selected so that as the text + // field transitions out, it maintains the last label it had. + + const [fieldLabel, setFieldLabel] = useState(); + + useEffect(() => { + if (!tributeType) { + return; + } + + setFieldLabel( + tributeType === 'inMemoryOf' + ? t('donationPage.dReason.tributeSelector.inMemoryOf') + : t('donationPage.dReason.tributeSelector.inHonorOf') + ); + }, [tributeType, t]); + + // If only one type of tribute is possible, then show a yes/no checkbox. If + // there are multiple, show a set of radio buttons. + // + // We use different styled components here because we need to match appearance + // with the DFrequency component, which isn't on the base radio button + // component yet. + + const typeControl = + askHonoree && askInMemoryOf ? ( + + {typeRadios.map(({ label, value }) => ( + onChangeTributeType(value as TributeType)} + value={typeValues[tributeType!] ?? ''} + /> + } + /> + ))} + + ) : ( + + onChangeTributeType(event.target.checked ? (askHonoree ? 'honoree' : 'inMemoryOf') : null) + } + value={typeValues[tributeType!] ?? ''} + /> + } + label={t(`donationPage.dReason.tributeSelector.yes.${askHonoree ? 'inHonorOf' : 'inMemoryOf'}`)} + /> + ); + + // We should always be called with one of these props set, but if not, show + // nothing. + + if (!askHonoree && !askInMemoryOf) { + return null; + } + + // Names on inputs below must be set exactly in order for the form to be + // submitted properly. + // + // We also need to ensure the text field unmounts when transitioned out so + // it's not present in the DOM at form submission time, so its value doesn't + // appear in the form data. + + return ( + +
+ {t('donationPage.dReason.tributeSelector.isTribute')} + {typeControl} +
+ + onChangeTributeName(event.target.value)} + required + value={tributeName} + /> + +
+ ); +} + +TributeFields.propTypes = TributeFieldsPropTypes; +export default TributeFields; diff --git a/spa/src/components/donationPage/pageContent/DReason/index.ts b/spa/src/components/donationPage/pageContent/DReason/index.ts new file mode 100644 index 0000000000..25840e80ec --- /dev/null +++ b/spa/src/components/donationPage/pageContent/DReason/index.ts @@ -0,0 +1 @@ +export * from './DReason'; diff --git a/spa/src/components/donationPage/pageContent/DReason.jsx b/spa/src/components/donationPage/pageContent/DReasonOld.jsx similarity index 99% rename from spa/src/components/donationPage/pageContent/DReason.jsx rename to spa/src/components/donationPage/pageContent/DReasonOld.jsx index cd7902279d..ce85a4ab75 100644 --- a/spa/src/components/donationPage/pageContent/DReason.jsx +++ b/spa/src/components/donationPage/pageContent/DReasonOld.jsx @@ -26,7 +26,7 @@ function DReason({ element, ...props }) { const [selectedReason, setSelectedReason] = useState(''); const [reasonOther, setReasonOther] = useState(''); const [tributeState, setTributeState] = useState(defaultTributeState); - const [tributeInput, setTributeInput] = useState(''); + const [tributeInput, setTributeInput] = useState(null); // Form control const handleTributeSelection = (selectedOption, value) => { diff --git a/spa/src/components/donationPage/pageContent/DReason.styled.js b/spa/src/components/donationPage/pageContent/DReasonOld.styled.js similarity index 100% rename from spa/src/components/donationPage/pageContent/DReason.styled.js rename to spa/src/components/donationPage/pageContent/DReasonOld.styled.js diff --git a/spa/src/components/donationPage/pageContent/dynamicElements.js b/spa/src/components/donationPage/pageContent/dynamicElements.js index 4b29ee73cb..39a9cb042e 100644 --- a/spa/src/components/donationPage/pageContent/dynamicElements.js +++ b/spa/src/components/donationPage/pageContent/dynamicElements.js @@ -5,4 +5,4 @@ export { DDonorInfo } from './DDonorInfo'; export { default as DDonorAddress } from './DDonorAddress'; export { default as DPayment } from './DPayment'; export { default as DSwag } from './DSwag'; -export { default as DReason } from './DReason'; +export { DReason } from './DReason'; diff --git a/spa/src/components/paymentProviders/stripe/stripeFns.ts b/spa/src/components/paymentProviders/stripe/stripeFns.ts index 6cda292dd2..aa9138a5c4 100644 --- a/spa/src/components/paymentProviders/stripe/stripeFns.ts +++ b/spa/src/components/paymentProviders/stripe/stripeFns.ts @@ -51,8 +51,7 @@ function serializeForm(form: HTMLFormElement) { throw new Error(`Asked to serialize something not a form element: ${form}, ${JSON.stringify(form)}`); } - const booleans = ['swag_opt_out', 'tribute_type_honoree', 'tribute_type_in_memory_of']; - const tributesToConvert = { tribute_type_honoree: 'type_honoree', tribute_type_in_memory_of: 'type_in_memory_of' }; + const booleans = ['swag_opt_out']; const obj: Record = {}; const formData = new FormData(form); @@ -64,12 +63,6 @@ function serializeForm(form: HTMLFormElement) { } else { obj[key] = formData.get(key); } - - // tribute_type could be either a radio or a checkbox. - // If it's a checkbox, we need to convert the "true" value to the expected value - if (key in tributesToConvert) { - obj.tribute_type = tributesToConvert[key as keyof typeof tributesToConvert]; - } } return obj;