From 2dbab8ec50072776ab92bb7ec848f190b5bd090b Mon Sep 17 00:00:00 2001
From: Chris Klimas <chris.klimas@fundjournalism.org>
Date: Tue, 7 Jan 2025 15:59:59 -0500
Subject: [PATCH] DEV-5640: Migrate DReason to TypeScript

---
 spa/cypress/e2e/01-donationPage.cy.js         |  19 +--
 .../pageContent/DReason/DReason.tsx           |  60 +++++++
 .../DReason/ReasonFields.styled.ts            |  46 +++++
 .../pageContent/DReason/ReasonFields.tsx      |  94 ++++++++++
 .../DReason/TributeFields.styled.ts           |  46 +++++
 .../pageContent/DReason/TributeFields.tsx     | 160 ++++++++++++++++++
 .../donationPage/pageContent/DReason/index.ts |   1 +
 .../{DReason.jsx => DReasonOld.jsx}           |   2 +-
 ...DReason.styled.js => DReasonOld.styled.js} |   0
 .../pageContent/dynamicElements.js            |   2 +-
 .../paymentProviders/stripe/stripeFns.ts      |   9 +-
 11 files changed, 419 insertions(+), 20 deletions(-)
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/DReason.tsx
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/ReasonFields.styled.ts
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/ReasonFields.tsx
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/TributeFields.styled.ts
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/TributeFields.tsx
 create mode 100644 spa/src/components/donationPage/pageContent/DReason/index.ts
 rename spa/src/components/donationPage/pageContent/{DReason.jsx => DReasonOld.jsx} (99%)
 rename spa/src/components/donationPage/pageContent/{DReason.styled.js => DReasonOld.styled.js} (100%)

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<typeof DReasonPropTypes> {
+  element: ReasonElement;
+}
+
+export function DReason({ element }: DReasonProps) {
+  const { errors } = usePage();
+  const [selectedReason, setSelectedReason] = useState<string>('');
+  const [reasonText, setReasonText] = useState<string>('');
+  const [tributeType, setTributeType] = useState<TributeFieldsProps['tributeType']>(null);
+  const [tributeName, setTributeName] = useState('');
+  const { t } = useTranslation();
+  const required = useMemo(() => element.requiredFields.includes('reason_for_giving'), [element.requiredFields]);
+
+  return (
+    <DElement label={t('donationPage.dReason.reasonForGiving')} data-testid="d-reason">
+      <ReasonFields
+        onChangeOption={setSelectedReason}
+        onChangeText={setReasonText}
+        optionError={errors.reason_for_giving}
+        options={element.content.reasons}
+        required={required}
+        selectedOption={selectedReason}
+        text={reasonText}
+        textError={errors.reason_other}
+      />
+      {(element.content.askHonoree || element.content.askInMemoryOf) && (
+        <TributeFields
+          askHonoree={element.content.askHonoree}
+          askInMemoryOf={element.content.askInMemoryOf}
+          error={errors.reason_for_giving}
+          onChangeTributeName={setTributeName}
+          onChangeTributeType={setTributeType}
+          tributeName={tributeName}
+          tributeType={tributeType}
+        />
+      )}
+    </DElement>
+  );
+}
+
+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<typeof ReasonFieldsPropTypes> {
+  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 (
+    <Root>
+      {optionsWithOther.length > 0 && (
+        <Select
+          data-testid="reason-for-giving-reason-select"
+          error={!!optionError}
+          fullWidth
+          helperText={optionError}
+          label={t('donationPage.dReason.supportWork')}
+          name="reason_for_giving"
+          onChange={(e) => onChangeOption(e.target.value)}
+          options={optionsWithOther}
+          required={!!required}
+          value={selectedOption}
+        ></Select>
+      )}
+      <Collapse in={optionsWithOther.length === 0 || selectedOption === otherReasonLabel} unmountOnExit>
+        <TextField
+          error={!!textError}
+          fullWidth
+          helperText={textError}
+          label={t('donationPage.dReason.tellUsWhy')}
+          name="reason_other"
+          onChange={(event) => onChangeText(event.target.value)}
+          required={!!required}
+          value={text}
+        />
+      </Collapse>
+    </Root>
+  );
+}
+
+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<typeof TributeFieldsPropTypes> {
+  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<string>();
+
+  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 ? (
+      <RadioGroup aria-label={t('donationPage.dReason.tributeSelector.isTribute')} row>
+        {typeRadios.map(({ label, value }) => (
+          <RadioFormControlLabel
+            key={label}
+            label={t(label)}
+            value={value}
+            control={
+              <Radio
+                checked={tributeType === value}
+                data-testid={`tribute-type-${value}`}
+                name="tribute_type"
+                onChange={() => onChangeTributeType(value as TributeType)}
+                value={typeValues[tributeType!] ?? ''}
+              />
+            }
+          />
+        ))}
+      </RadioGroup>
+    ) : (
+      <BaseFormControlLabel
+        control={
+          <Checkbox
+            checked={!!tributeType}
+            name="tribute_type"
+            onChange={(event) =>
+              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 (
+    <Root>
+      <div>
+        <Prompt>{t('donationPage.dReason.tributeSelector.isTribute')}</Prompt>
+        {typeControl}
+      </div>
+      <Collapse in={!!tributeType} unmountOnExit>
+        <TextField
+          data-testid="tribute-input"
+          error={!!error}
+          fullWidth
+          helperText={error}
+          label={fieldLabel}
+          name={tributeType === 'inMemoryOf' ? 'in_memory_of' : 'honoree'}
+          onChange={(event) => onChangeTributeName(event.target.value)}
+          required
+          value={tributeName}
+        />
+      </Collapse>
+    </Root>
+  );
+}
+
+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<string, File | boolean | null | string> = {};
   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;