diff --git a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx deleted file mode 100644 index 9533e6a6a..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { ThemeProvider } from '@mui/material/styles'; -import { LocalizationProvider } from '@mui/x-date-pickers'; -import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; -import { render, waitFor } from '@testing-library/react'; -import TestRouter from '__tests__/util/TestRouter'; -import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; -import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; -import theme from 'src/theme'; -import { appealInfo } from '../appealMockData'; -import { AppealHeaderInfo, AppealHeaderInfoProps } from './AppealHeaderInfo'; - -const router = { - query: { accountListId: 'aaa' }, - isReady: true, -}; - -const Components = ({ appealInfo, loading }: AppealHeaderInfoProps) => ( - - - - - - - - - - - -); - -describe('AppealHeaderInfo', () => { - it('renders skeletons when loading', () => { - const { getByTestId, getByRole } = render( - , - ); - - expect(getByRole('heading', { name: 'Name:' })).toBeInTheDocument(); - expect(getByTestId('appeal-name-skeleton')).toBeInTheDocument(); - - expect(getByRole('heading', { name: 'Goal:' })).toBeInTheDocument(); - expect(getByTestId('appeal-goal-skeleton')).toBeInTheDocument(); - }); - - it('renders appeal info', async () => { - const { getByText } = render( - , - ); - - await waitFor(() => { - expect(getByText('Test Appeal')).toBeInTheDocument(); - expect(getByText('$100')).toBeInTheDocument(); - expect(getByText(/\$50 \(50%\)/i)).toBeInTheDocument(); - expect(getByText(/\$100 \(100%\)/i)).toBeInTheDocument(); - }); - }); - - // TODO - Build tests for modals opening and saving data -}); diff --git a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.test.tsx b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.test.tsx new file mode 100644 index 000000000..f866f1d26 --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.test.tsx @@ -0,0 +1,86 @@ +import { ThemeProvider } from '@mui/material/styles'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import theme from 'src/theme'; +import { appealInfo } from '../../appealMockData'; +import { AppealHeaderInfo, AppealHeaderInfoProps } from './AppealHeaderInfo'; + +const router = { + query: { accountListId: 'aaa' }, + isReady: true, +}; + +const Components = ({ appealInfo, loading }: AppealHeaderInfoProps) => ( + + + + + + + + + + + + + +); + +describe('AppealHeaderInfo', () => { + it('renders skeletons when loading', () => { + const { getByTestId, getByRole } = render( + , + ); + + expect(getByRole('heading', { name: 'Name:' })).toBeInTheDocument(); + expect(getByTestId('appeal-name-skeleton')).toBeInTheDocument(); + + expect(getByRole('heading', { name: 'Goal:' })).toBeInTheDocument(); + expect(getByTestId('appeal-goal-skeleton')).toBeInTheDocument(); + }); + + it('renders appeal info', async () => { + const { getByText, findByText } = render( + , + ); + + expect(await findByText('Test Appeal')).toBeInTheDocument(); + + expect(getByText('$100')).toBeInTheDocument(); + expect(getByText(/\$50 \(50%\)/i)).toBeInTheDocument(); + expect(getByText(/\$100 \(100%\)/i)).toBeInTheDocument(); + }); + + it('should allow user to open the edit appeal info modal', async () => { + const { findByText, findByRole, getByTestId, getByRole, queryByRole } = + render(); + + expect(await findByText('Test Appeal')).toBeInTheDocument(); + + userEvent.click(getByTestId('edit-appeal-name')); + + expect( + await findByRole('heading', { name: 'Edit Appeal' }), + ).toBeInTheDocument(); + + userEvent.click(getByTestId('edit-appeal-goal')); + + expect( + await findByRole('heading', { name: 'Edit Appeal' }), + ).toBeInTheDocument(); + + userEvent.click(getByRole('button', { name: 'Close' })); + + await waitFor(() => { + expect( + queryByRole('heading', { name: 'Edit Appeal' }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.tsx similarity index 95% rename from src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx rename to src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.tsx index 07e208907..1bd1e8a99 100644 --- a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo/AppealHeaderInfo.tsx @@ -7,11 +7,11 @@ import { EditIcon } from 'src/components/Contacts/ContactDetails/ContactDetailsT import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; -import AppealProgressBar from '../AppealProgressBar'; import { DynamicEditAppealHeaderInfoModal, preloadEditAppealHeaderInfoModal, -} from './EditAppealHeaderInfoModal/DynamicEditAppealHeaderInfoModal'; +} from '../../Modals/EditAppealHeaderInfoModal/DynamicEditAppealHeaderInfoModal'; +import AppealProgressBar from '../AppealProgressBar/AppealProgressBar'; export const appealHeaderInfoHeight = theme.spacing(9); @@ -95,6 +95,7 @@ export const AppealHeaderInfo: React.FC = ({ onClick={() => setIsEditAppealModalOpen(true)} onMouseOver={preloadEditAppealHeaderInfoModal} aria-label={t('Edit Icon')} + data-testid="edit-appeal-name" > @@ -126,6 +127,7 @@ export const AppealHeaderInfo: React.FC = ({ onClick={() => setIsEditAppealModalOpen(true)} onMouseOver={preloadEditAppealHeaderInfoModal} aria-label={t('Edit Icon')} + data-testid="edit-appeal-goal" > diff --git a/src/components/Tool/Appeal/AppealProgressBar.test.tsx b/src/components/Tool/Appeal/AppealDetails/AppealProgressBar/AppealProgressBar.test.tsx similarity index 100% rename from src/components/Tool/Appeal/AppealProgressBar.test.tsx rename to src/components/Tool/Appeal/AppealDetails/AppealProgressBar/AppealProgressBar.test.tsx diff --git a/src/components/Tool/Appeal/AppealProgressBar.tsx b/src/components/Tool/Appeal/AppealDetails/AppealProgressBar/AppealProgressBar.tsx similarity index 99% rename from src/components/Tool/Appeal/AppealProgressBar.tsx rename to src/components/Tool/Appeal/AppealDetails/AppealProgressBar/AppealProgressBar.tsx index e26f08638..8e78b6afa 100644 --- a/src/components/Tool/Appeal/AppealProgressBar.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealProgressBar/AppealProgressBar.tsx @@ -3,7 +3,7 @@ import { Box, Theme, Tooltip, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; -import theme from '../../../theme'; +import theme from 'src/theme'; const useStyles = makeStyles()((theme: Theme) => ({ colorYellow: { diff --git a/src/components/Tool/Appeal/Flow/ContactFlow.tsx b/src/components/Tool/Appeal/Flow/ContactFlow.tsx index f6586c8ef..45aadbde0 100644 --- a/src/components/Tool/Appeal/Flow/ContactFlow.tsx +++ b/src/components/Tool/Appeal/Flow/ContactFlow.tsx @@ -11,7 +11,7 @@ import { ContactFlowDragLayer } from 'src/components/Contacts/ContactFlow/Contac import { ContactFilterSetInput } from 'src/graphql/types.generated'; import i18n from 'src/lib/i18n'; import theme from 'src/theme'; -import { AppealHeaderInfo } from '../AppealDetails/AppealHeaderInfo'; +import { AppealHeaderInfo } from '../AppealDetails/AppealHeaderInfo/AppealHeaderInfo'; import { AppealQuery } from '../AppealDetails/AppealsMainPanel/AppealInfo.generated'; import { AppealStatusEnum } from '../AppealsContext/AppealsContext'; import { ContactFlowColumn } from './ContactFlowColumn/ContactFlowColumn'; diff --git a/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx index 21907636c..e000a25ba 100644 --- a/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx +++ b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx @@ -27,7 +27,7 @@ import { AppealsContext, AppealsType, } from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; -import { appealHeaderInfoHeight } from '../../AppealDetails/AppealHeaderInfo'; +import { appealHeaderInfoHeight } from '../../AppealDetails/AppealHeaderInfo/AppealHeaderInfo'; import { ContactFlowDropZone } from '../ContactFlowDropZone/ContactFlowDropZone'; import { ContactFlowRow } from '../ContactFlowRow/ContactFlowRow'; diff --git a/src/components/Tool/Appeal/InitialPage/Appeal.tsx b/src/components/Tool/Appeal/InitialPage/Appeal.tsx index c66b510df..23a13fb3f 100644 --- a/src/components/Tool/Appeal/InitialPage/Appeal.tsx +++ b/src/components/Tool/Appeal/InitialPage/Appeal.tsx @@ -9,7 +9,7 @@ import { AppealFieldsFragment } from 'pages/accountLists/[accountListId]/tools/G import { useAccountListId } from '../../../../hooks/useAccountListId'; import theme from '../../../../theme'; import AnimatedCard from '../../../AnimatedCard'; -import AppealProgressBar from '../AppealProgressBar'; +import AppealProgressBar from '../AppealDetails/AppealProgressBar/AppealProgressBar'; const useStyles = makeStyles()(() => ({ cardContent: { diff --git a/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx index a8d242821..d8eb9d764 100644 --- a/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx +++ b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx @@ -11,7 +11,7 @@ import theme from 'src/theme'; import { AppealHeaderInfo, appealHeaderInfoHeight, -} from '../../AppealDetails/AppealHeaderInfo'; +} from '../../AppealDetails/AppealHeaderInfo/AppealHeaderInfo'; import { AppealQuery } from '../../AppealDetails/AppealsMainPanel/AppealInfo.generated'; import { AppealStatusEnum, diff --git a/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.test.tsx b/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.test.tsx index 315581657..ebf6bc0f9 100644 --- a/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.test.tsx +++ b/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.test.tsx @@ -12,7 +12,7 @@ import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals import i18n from 'src/lib/i18n'; import theme from 'src/theme'; import { AppealsContext } from '../../AppealsContext/AppealsContext'; -import { AppealQuery } from '../AddContactToAppealModal/appealInfo.generated'; +import { AppealQuery } from '../AddContactToAppealModal/AppealInfo.generated'; import { AddExcludedContactModal } from './AddExcludedContactModal'; const accountListId = 'abc'; diff --git a/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.tsx b/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.tsx index 95f187c35..707fbd3a1 100644 --- a/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.tsx +++ b/src/components/Tool/Appeal/Modals/AddExcludedContactModal/AddExcludedContactModal.tsx @@ -18,7 +18,7 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; -import { useAppealQuery } from '../AddContactToAppealModal/appealInfo.generated'; +import { useAppealQuery } from '../AddContactToAppealModal/AppealInfo.generated'; import { useAssignContactsToAppealMutation } from './AddExcludedContactModal.generated'; const LoadingIndicator = styled(CircularProgress)(({ theme }) => ({ diff --git a/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/DynamicEditAppealHeaderInfoModal.tsx b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/DynamicEditAppealHeaderInfoModal.tsx new file mode 100644 index 000000000..e90ce122d --- /dev/null +++ b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/DynamicEditAppealHeaderInfoModal.tsx @@ -0,0 +1,12 @@ +import dynamic from 'next/dynamic'; +import { DynamicModalPlaceholder } from 'src/components/DynamicPlaceholders/DynamicModalPlaceholder'; + +export const preloadEditAppealHeaderInfoModal = () => + import( + /* webpackChunkName: "EditAppealHeaderInfoModal" */ './EditAppealHeaderInfoModal' + ).then(({ EditAppealHeaderInfoModal }) => EditAppealHeaderInfoModal); + +export const DynamicEditAppealHeaderInfoModal = dynamic( + preloadEditAppealHeaderInfoModal, + { loading: DynamicModalPlaceholder }, +); diff --git a/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppeal.graphql b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppeal.graphql new file mode 100644 index 000000000..00ca6e0d7 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppeal.graphql @@ -0,0 +1,8 @@ +mutation UpdateAppeal($input: AppealUpdateMutationInput!) { + updateAppeal(input: $input) { + appeal { + name + amount + } + } +} diff --git a/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.test.tsx b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.test.tsx new file mode 100644 index 000000000..3eae8d143 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealFieldsFragment } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import theme from 'src/theme'; +import { appealInfo } from '../../appealMockData'; +import { EditAppealHeaderInfoModal } from './EditAppealHeaderInfoModal'; + +const accountListId = 'abc'; +const router = { + query: { accountListId }, + isReady: true, +}; +const handleClose = jest.fn(); +const mutationSpy = jest.fn(); + +const Components = ({ + appeal = appealInfo, +}: { + appeal?: AppealFieldsFragment; +}) => ( + + + + + + + + + + + + + +); + +describe('EditAppealHeaderInfoModal', () => { + it('should show errors', () => { + const { getByRole, getByTestId } = render( + , + ); + + userEvent.clear(getByRole('spinbutton', { name: /goal/i })); + userEvent.tab(); + + expect(getByRole('textbox', { name: /name/i })).toHaveValue(''); + expect(getByRole('spinbutton', { name: /goal/i })).toHaveValue(null); + + expect(getByTestId('nameError')).toBeInTheDocument(); + expect(getByTestId('amountError')).toBeInTheDocument(); + }); + + it('default', () => { + const { getByRole } = render(); + + expect(getByRole('textbox', { name: /name/i })).toHaveValue('Test Appeal'); + expect(getByRole('spinbutton', { name: /goal/i })).toHaveValue(100); + }); + + it('should edit fields and save appeal', async () => { + const { getByRole } = render(); + + const name = getByRole('textbox', { name: /name/i }); + const amount = getByRole('spinbutton', { name: /goal/i }); + + userEvent.clear(name); + userEvent.type(name, 'New Appeal Name'); + userEvent.clear(amount); + userEvent.type(amount, '500'); + + userEvent.click(getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('UpdateAppeal', { + input: { + accountListId, + attributes: { + id: '1', + name: 'New Appeal Name', + amount: 500, + }, + }, + }); + }); + }); + + it('should show amount error', async () => { + const { getByRole, getByText, queryByText } = render(); + + const name = getByRole('textbox', { name: /name/i }); + const amount = getByRole('spinbutton', { name: /goal/i }); + + userEvent.clear(name); + userEvent.type(name, 'New Appeal Name'); + userEvent.clear(amount); + + userEvent.type(amount, '100'); + userEvent.clear(amount); + + await waitFor(() => + expect(getByText(/please enter a goal/i)).toBeInTheDocument(), + ); + + userEvent.clear(amount); + userEvent.type(amount, '-100'); + + await waitFor(() => + expect( + getByText(/must use a positive number for appeal amount/i), + ).toBeInTheDocument(), + ); + userEvent.clear(amount); + userEvent.type(amount, '400'); + await waitFor(() => + expect( + queryByText(/must use a positive number for appeal amount/i), + ).not.toBeInTheDocument(), + ); + + userEvent.click(getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('UpdateAppeal', { + input: { + accountListId, + attributes: { + id: '1', + name: 'New Appeal Name', + amount: 400, + }, + }, + }); + }); + }); +}); diff --git a/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.tsx b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.tsx new file mode 100644 index 000000000..fbec871b7 --- /dev/null +++ b/src/components/Tool/Appeal/Modals/EditAppealHeaderInfoModal/EditAppealHeaderInfoModal.tsx @@ -0,0 +1,161 @@ +import React, { ReactElement } from 'react'; +import { + DialogActions, + DialogContent, + FormHelperText, + TextField, +} from '@mui/material'; +import { Box } from '@mui/system'; +import { Formik } from 'formik'; +import { useSnackbar } from 'notistack'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { AppealFieldsFragment } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; +import { FieldWrapper } from 'src/components/Shared/Forms/FieldWrapper'; +import { + CancelButton, + SubmitButton, +} from 'src/components/common/Modal/ActionButtons/ActionButtons'; +import Modal from 'src/components/common/Modal/Modal'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import i18n from 'src/lib/i18n'; +import { useUpdateAppealMutation } from './EditAppeal.generated'; + +interface EditAppealHeaderInfoModalProps { + handleClose: () => void; + appealInfo: AppealFieldsFragment; +} + +export type EditAppealFormikSchema = { + name: string; + amount: number; +}; + +const EditAppealSchema: yup.SchemaOf = yup.object({ + name: yup.string().required(i18n.t('Please enter a name')), + amount: yup + .number() + .required(i18n.t('Please enter a goal')) + .typeError(i18n.t('Appeal amount must be a valid number')) + .test( + i18n.t('Is positive?'), + i18n.t('Must use a positive number for appeal amount'), + (value) => !value || parseFloat(value as unknown as string) > 0, + ), +}); + +export const EditAppealHeaderInfoModal: React.FC< + EditAppealHeaderInfoModalProps +> = ({ appealInfo, handleClose }) => { + const { t } = useTranslation(); + const accountListId = useAccountListId(); + const { enqueueSnackbar } = useSnackbar(); + const [UpdateAppeal] = useUpdateAppealMutation(); + + const onSubmit = async (attributes: EditAppealFormikSchema) => { + await UpdateAppeal({ + variables: { + input: { + accountListId: accountListId ?? '', + attributes: { + id: appealInfo.id, + name: attributes.name, + amount: attributes.amount, + }, + }, + }, + update: (cache) => { + cache.modify({ + id: cache.identify({ __typename: 'Appeal', id: appealInfo.id }), + fields: { + name() { + return attributes.name; + }, + amount() { + return attributes.amount; + }, + }, + }); + }, + onCompleted: () => { + enqueueSnackbar(t('Successfully updated the appeal'), { + variant: 'success', + }); + handleClose(); + }, + onError: () => { + enqueueSnackbar(t('Failed to update the appeal'), { + variant: 'error', + }); + }, + }); + }; + + return ( + + + {({ + values: { name, amount }, + handleChange, + handleSubmit, + isSubmitting, + isValid, + errors, + }): ReactElement => ( +
+ + + + + + {errors.name} + + + + + + + + {errors.amount} + + + + + + + + + {t('Save')} + + +
+ )} +
+
+ ); +};