diff --git a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx index 97fd3f2f3..8dfcb325e 100644 --- a/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx +++ b/src/components/Coaching/CoachingDetail/CoachingDetail.test.tsx @@ -11,7 +11,18 @@ import { beforeTestResizeObserver, } from 'src/utils/tests/windowResizeObserver'; import { AccountListTypeEnum, CoachingDetail } from './CoachingDetail'; -import { LoadCoachingDetailQuery } from './LoadCoachingDetail.generated'; +import { + LoadAccountListCoachingDetailQuery, + LoadCoachingDetailQuery, +} from './LoadCoachingDetail.generated'; +import { + LoadAccountListCoachingCommitmentsQuery, + LoadCoachingCommitmentsQuery, +} from './OutstandingCommitments/OutstandingCommitments.generated'; +import { + LoadAccountListCoachingNeedsQuery, + LoadCoachingNeedsQuery, +} from './OutstandingNeeds/OutstandingNeeds.generated'; jest.mock('./AppointmentResults/AppointmentResults'); @@ -34,7 +45,14 @@ const TestComponent: React.FC = ({ }) => ( - + mocks={{ LoadCoachingDetail: { coachingAccountList: { @@ -50,6 +68,38 @@ const TestComponent: React.FC = ({ monthlyGoal, }, }, + LoadCoachingCommitments: { + coachingAccountList: { + contacts: { + nodes: [{ pledgeCurrency: 'USD' }], + }, + }, + }, + LoadAccountListCoachingCommitments: { + accountList: { + contacts: { + nodes: [{ pledgeCurrency: 'USD' }], + }, + }, + }, + LoadCoachingNeeds: { + coachingAccountList: { + primaryAppeal: { + pledges: { + nodes: [{ amountCurrency: 'USD' }], + }, + }, + }, + }, + LoadAccountListCoachingNeeds: { + accountList: { + primaryAppeal: { + pledges: { + nodes: [{ amountCurrency: 'USD' }], + }, + }, + }, + }, }} > = ({ currency={accountListData?.currency} primaryAppeal={accountListData?.primaryAppeal ?? undefined} /> + + diff --git a/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.graphql b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.graphql new file mode 100644 index 000000000..c970eef22 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.graphql @@ -0,0 +1,39 @@ +query LoadCoachingCommitments($coachingAccountListId: ID!, $after: String) { + coachingAccountList(id: $coachingAccountListId) { + id + contacts(first: 8, after: $after, filter: { pledge: "outstanding" }) { + nodes { + id + name + pledgeAmount + pledgeCurrency + pledgeStartDate + pledgeFrequency + } + pageInfo { + endCursor + hasNextPage + } + } + } +} + +query LoadAccountListCoachingCommitments($accountListId: ID!, $after: String) { + accountList(id: $accountListId) { + id + contacts(first: 8, after: $after, filter: { pledge: "outstanding" }) { + nodes { + id + name + pledgeAmount + pledgeCurrency + pledgeStartDate + pledgeFrequency + } + pageInfo { + endCursor + hasNextPage + } + } + } +} diff --git a/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.test.tsx b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.test.tsx new file mode 100644 index 000000000..d76f736aa --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.test.tsx @@ -0,0 +1,207 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AccountListTypeEnum } from '../CoachingDetail'; +import { OutstandingCommitments } from './OutstandingCommitments'; +import { + LoadAccountListCoachingCommitmentsQuery, + LoadCoachingCommitmentsQuery, +} from './OutstandingCommitments.generated'; + +const accountListId = 'account-list-1'; + +describe('OutstandingCommitments', () => { + it('renders skeleton while loading initial account data', () => { + const { getAllByTestId } = render( + + mocks={{ + LoadAccountListCoachingCommitments: { + accountList: { + id: accountListId, + contacts: { + nodes: [ + { + pledgeCurrency: 'USD', + }, + ], + }, + }, + }, + }} + > + + , + ); + + expect(getAllByTestId('MultilineSkeletonLine')).toHaveLength(8); + }); + + it('Renders overdue years in own outstanding recurring commitments correctly', async () => { + const { findByText } = render( + + mocks={{ + LoadAccountListCoachingCommitments: { + accountList: { + id: accountListId, + contacts: { + nodes: [ + { + pledgeCurrency: 'USD', + pledgeStartDate: '2000-10-31', + pledgeAmount: 100, + name: 'Mac McDonald', + }, + ], + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Frequency')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Mac McDonald')).toBeInTheDocument(); + expect(await findByText('$100')).toBeInTheDocument(); + expect(await findByText('10/31/2000 (19 years ago)')).toBeInTheDocument(); + }); + + it('Renders overdue months in coaching outstanding recurring commitments correctly', async () => { + const { findByText } = render( + + mocks={{ + LoadCoachingCommitments: { + coachingAccountList: { + id: accountListId, + contacts: { + nodes: [ + { + pledgeCurrency: 'USD', + pledgeStartDate: '2019-08-04', + pledgeAmount: 12.43, + name: 'Country Mac', + }, + ], + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Frequency')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Country Mac')).toBeInTheDocument(); + expect(await findByText('$12.43')).toBeInTheDocument(); + expect(await findByText('8/4/2019 (5 months ago)')).toBeInTheDocument(); + }); + + it('renders outstanding recurring coaching commitments with missing data correctly', async () => { + const { findByText } = render( + + mocks={{ + LoadCoachingCommitments: { + coachingAccountList: { + id: accountListId, + contacts: { + nodes: [ + { + pledgeCurrency: 'CAD', + pledgeStartDate: '', + pledgeAmount: 0, + name: 'Frank Reynolds', + }, + ], + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Frequency')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Frank Reynolds')).toBeInTheDocument(); + expect(await findByText('N/A')).toBeInTheDocument(); + }); + + it('renders more outstanding recurring coaching commitments on fetchMore', async () => { + const { findByText, getByRole, getAllByRole } = render( + + mocks={{ + LoadCoachingCommitments: { + coachingAccountList: { + id: accountListId, + contacts: { + nodes: [...Array(15)].map((x, i) => { + return { + pledgeStartDate: DateTime.local() + .minus({ month: i }) + .toISO() + .toString(), + pledgeCurrency: 'USD', + pledgeAmount: 10, + }; + }), + pageInfo: { + hasNextPage: true, + }, + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Frequency')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + userEvent.click(getByRole('button')); + expect(getAllByRole('row')).toHaveLength(16); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.tsx b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.tsx new file mode 100644 index 000000000..90e725bc2 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingCommitments/OutstandingCommitments.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { + Box, + Button, + CardContent, + CardHeader, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import AnimatedCard from 'src/components/AnimatedCard'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat, dateFormatShort } from 'src/lib/intlFormat'; +import theme from 'src/theme'; +import { getLocalizedPledgeFrequency } from 'src/utils/functions/getLocalizedPledgeFrequency'; +import { MultilineSkeleton } from '../../../Shared/MultilineSkeleton'; +import { AccountListTypeEnum } from '../CoachingDetail'; +import { + useLoadAccountListCoachingCommitmentsQuery, + useLoadCoachingCommitmentsQuery, +} from './OutstandingCommitments.generated'; + +const ContentContainer = styled(CardContent)(({ theme }) => ({ + padding: theme.spacing(2), + overflowX: 'scroll', +})); + +const AlignedTableCell = styled(TableCell)({ + border: 'none', + textAlign: 'right', + ':first-of-type': { + textAlign: 'unset', + }, +}); + +const LoadMoreButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(1), +})); + +interface OutstandingCommitmentsProps { + accountListId: string; + // Whether the account list belongs to the user or someone that the user coaches + accountListType: AccountListTypeEnum; +} + +export const OutstandingCommitments: React.FC = ({ + accountListId, + accountListType, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const { + data: ownData, + loading: ownLoading, + fetchMore: ownFetchMore, + } = useLoadAccountListCoachingCommitmentsQuery({ + variables: { accountListId }, + skip: accountListType !== AccountListTypeEnum.Own, + }); + + const { + data: coachingData, + loading: coachingLoading, + fetchMore: coachingFetchMore, + } = useLoadCoachingCommitmentsQuery({ + variables: { coachingAccountListId: accountListId }, + skip: accountListType !== AccountListTypeEnum.Coaching, + }); + + const loading = + accountListType === AccountListTypeEnum.Own ? ownLoading : coachingLoading; + const fetchMore = + accountListType === AccountListTypeEnum.Own + ? ownFetchMore + : coachingFetchMore; + const accountListData = + accountListType === AccountListTypeEnum.Own + ? ownData?.accountList + : coachingData?.coachingAccountList; + + const checkDueDate = ( + expectedDate: string | null | undefined, + ): { color: string; overdue: string } => { + if (expectedDate) { + const start = DateTime.fromISO(expectedDate); + const end = DateTime.now(); + + const months = Math.round(end.diff(start, 'months').months); + const years = Math.round(end.diff(start, 'years').years); + + let color = '', + overdue = ''; + + if (months >= 12) { + color = theme.palette.statusDanger.main; + overdue = + years === 1 + ? `(${t('1 year ago')})` + : `(${t('{{years}} years ago', { years })})`; + } else if (months > 0) { + color = theme.palette.statusWarning.main; + overdue = + months === 1 + ? `(${t('1 month ago')})` + : `(${t('{{months}} months ago', { months })})`; + } + + return { color, overdue }; + } else { + const color = '', + overdue = t('Start Date Not Set'); + return { color, overdue }; + } + }; + + return ( + + + {t('Outstanding Recurring Commitments')} + {accountListData?.contacts.pageInfo.hasNextPage && ( + + fetchMore({ + variables: { + after: accountListData?.contacts.pageInfo.endCursor, + }, + }) + } + > + {t('Load More')} + + )} + + } + /> + + {loading && !accountListData ? ( + + ) : ( + + + + + {t('Name')} + {t('Amount')} + {t('Frequency')} + {t('Expected Date')} + + + + {accountListData?.contacts.nodes.map((contact) => ( + + {contact.name} + + {contact.pledgeAmount + ? currencyFormat( + contact.pledgeAmount, + contact.pledgeCurrency || 'USD', + locale, + ) + : t('N/A')} + + + {contact.pledgeFrequency + ? getLocalizedPledgeFrequency( + t, + contact.pledgeFrequency, + ) + : t('N/A')} + + + {`${ + contact.pledgeStartDate + ? dateFormatShort( + DateTime.fromISO(contact.pledgeStartDate), + locale, + ) + : '' + } ${checkDueDate(contact.pledgeStartDate)['overdue']}`} + + + ))} + +
+
+ )} +
+
+ ); +}; diff --git a/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.graphql b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.graphql new file mode 100644 index 000000000..7c7df04c8 --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.graphql @@ -0,0 +1,49 @@ +query LoadCoachingNeeds($coachingAccountListId: ID!, $after: String) { + coachingAccountList(id: $coachingAccountListId) { + id + primaryAppeal { + id + pledges(first: 8, after: $after) { + nodes { + id + amount + amountCurrency + expectedDate + contact { + id + name + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } +} + +query LoadAccountListCoachingNeeds($accountListId: ID!, $after: String) { + accountList(id: $accountListId) { + id + primaryAppeal { + id + pledges(first: 8, after: $after) { + nodes { + id + amount + amountCurrency + expectedDate + contact { + id + name + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } +} diff --git a/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.test.tsx b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.test.tsx new file mode 100644 index 000000000..b4190304a --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.test.tsx @@ -0,0 +1,219 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateTime } from 'luxon'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AccountListTypeEnum } from '../CoachingDetail'; +import { OutstandingNeeds } from './OutstandingNeeds'; +import { + LoadAccountListCoachingNeedsQuery, + LoadCoachingNeedsQuery, +} from './OutstandingNeeds.generated'; + +const accountListId = 'account-list-1'; + +describe('OutstandingNeeds', () => { + it('renders skeleton while loading initial account data', () => { + const { getAllByTestId } = render( + + mocks={{ + LoadAccountListCoachingNeeds: { + accountList: { + id: accountListId, + primaryAppeal: { + pledges: { + nodes: [ + { + amountCurrency: 'USD', + }, + ], + }, + }, + }, + }, + }} + > + + , + ); + + expect(getAllByTestId('MultilineSkeletonLine')).toHaveLength(8); + }); + + it('renders overdue years in own outstanding needs correctly', async () => { + const { findByText } = render( + + mocks={{ + LoadAccountListCoachingNeeds: { + accountList: { + id: accountListId, + primaryAppeal: { + pledges: { + nodes: [ + { + amount: 32.29, + amountCurrency: 'USD', + expectedDate: '2017-02-15', + contact: { + name: 'Dennis Reynolds', + }, + }, + ], + }, + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Dennis Reynolds')).toBeInTheDocument(); + expect(await findByText('$32.29')).toBeInTheDocument(); + expect(await findByText('2/15/2017 (3 years ago)')).toBeInTheDocument(); + }); + + it('renders overdue months in coaching outstanding needs correctly', async () => { + const { findByText } = render( + + mocks={{ + LoadAccountListCoachingNeeds: { + accountList: { + id: accountListId, + primaryAppeal: { + pledges: { + nodes: [ + { + amount: 32.29, + amountCurrency: 'USD', + expectedDate: '2019-02-15', + contact: { + name: 'Dennis Reynolds', + }, + }, + ], + }, + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Dennis Reynolds')).toBeInTheDocument(); + expect(await findByText('$32.29')).toBeInTheDocument(); + expect(await findByText('2/15/2019 (11 months ago)')).toBeInTheDocument(); + }); + + it('renders outstanding needs with missing data', async () => { + const { findByText } = render( + + mocks={{ + LoadCoachingNeeds: { + coachingAccountList: { + id: accountListId, + primaryAppeal: { + pledges: { + nodes: [ + { + amount: 0, + amountCurrency: null, + expectedDate: '', + contact: { + name: 'Charlie Kelly', + }, + }, + ], + }, + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + expect(await findByText('Charlie Kelly')).toBeInTheDocument(); + expect(await findByText('N/A')).toBeInTheDocument(); + expect(await findByText('Start Date Not Set')).toBeInTheDocument(); + }); + + it('renders more outstanding needs on fetchMore', async () => { + const { findByText, getAllByRole, getByRole } = render( + + mocks={{ + LoadCoachingNeeds: { + coachingAccountList: { + id: accountListId, + primaryAppeal: { + pledges: { + nodes: [...Array(15)].map((x, i) => { + return { + expectedDate: DateTime.local() + .minus({ month: i }) + .toISO() + .toString(), + amountCurrency: 'USD', + }; + }), + pageInfo: { + hasNextPage: true, + }, + }, + }, + }, + }, + }} + > + + , + ); + + expect(await findByText('Name')).toBeInTheDocument(); + expect(await findByText('Amount')).toBeInTheDocument(); + expect(await findByText('Expected Date')).toBeInTheDocument(); + + userEvent.click(getByRole('button')); + expect(getAllByRole('row')).toHaveLength(16); + }); +}); diff --git a/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.tsx b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.tsx new file mode 100644 index 000000000..20559e4dd --- /dev/null +++ b/src/components/Coaching/CoachingDetail/OutstandingNeeds/OutstandingNeeds.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { + Box, + Button, + CardContent, + CardHeader, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; +import { useTranslation } from 'react-i18next'; +import AnimatedCard from 'src/components/AnimatedCard'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat, dateFormatShort } from 'src/lib/intlFormat'; +import theme from 'src/theme'; +import { MultilineSkeleton } from '../../../Shared/MultilineSkeleton'; +import { AccountListTypeEnum } from '../CoachingDetail'; +import { + useLoadAccountListCoachingNeedsQuery, + useLoadCoachingNeedsQuery, +} from './OutstandingNeeds.generated'; + +const ContentContainer = styled(CardContent)(({ theme }) => ({ + padding: theme.spacing(2), + overflowX: 'scroll', +})); + +const AlignedTableCell = styled(TableCell)({ + border: 'none', + textAlign: 'right', + ':first-of-type': { + textAlign: 'unset', + }, +}); + +const LoadMoreButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(1), +})); +interface OutstandingNeedsProps { + accountListId: string; + // Whether the account list belongs to the user or someone that the user coaches + accountListType: AccountListTypeEnum; +} + +export const OutstandingNeeds: React.FC = ({ + accountListId, + accountListType, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const { + data: ownData, + loading: ownLoading, + fetchMore: ownFetchMore, + } = useLoadAccountListCoachingNeedsQuery({ + variables: { accountListId }, + skip: accountListType !== AccountListTypeEnum.Own, + }); + + const { + data: coachingData, + loading: coachingLoading, + fetchMore: coachingFetchMore, + } = useLoadCoachingNeedsQuery({ + variables: { coachingAccountListId: accountListId }, + skip: accountListType !== AccountListTypeEnum.Coaching, + }); + + const loading = + accountListType === AccountListTypeEnum.Own ? ownLoading : coachingLoading; + const fetchMore = + accountListType === AccountListTypeEnum.Own + ? ownFetchMore + : coachingFetchMore; + const accountListData = + accountListType === AccountListTypeEnum.Own + ? ownData?.accountList + : coachingData?.coachingAccountList; + + const checkDueDate = ( + expectedDate: string | null | undefined, + ): { color: string; overdue: string } => { + if (expectedDate) { + const start = DateTime.fromISO(expectedDate); + const end = DateTime.now(); + + const months = Math.round(end.diff(start, 'months').months); + const years = Math.round(end.diff(start, 'years').years); + + let color = '', + overdue = ''; + + if (months >= 12) { + color = theme.palette.statusDanger.main; + overdue = + years === 1 + ? `(${t('1 year ago')})` + : `(${t('{{years}} years ago', { years })})`; + } else if (months > 0) { + color = theme.palette.statusWarning.main; + overdue = + months === 1 + ? `(${t('1 month ago')})` + : `(${t('{{months}} months ago', { months })})`; + } + + return { color, overdue }; + } else { + const color = '', + overdue = t('Start Date Not Set'); + return { color, overdue }; + } + }; + + return ( + + + {t('Outstanding Special Needs')} + {accountListData?.primaryAppeal?.pledges.pageInfo.hasNextPage && ( + + fetchMore({ + variables: { + after: + accountListData?.primaryAppeal?.pledges.pageInfo + .endCursor, + }, + }) + } + > + {t('Load More')} + + )} + + } + /> + + {loading && !accountListData ? ( + + ) : ( + + + + + {t('Name')} + {t('Amount')} + {t('Expected Date')} + + + + {accountListData?.primaryAppeal?.pledges.nodes.map((need) => ( + + {need.contact.name} + + {need.amount + ? currencyFormat( + need.amount, + need.amountCurrency || 'USD', + locale, + ) + : t('N/A')} + + + {`${ + need.expectedDate + ? dateFormatShort( + DateTime.fromISO(need.expectedDate), + locale, + ) + : '' + } ${checkDueDate(need.expectedDate)['overdue']}`} + + + ))} + +
+
+ )} +
+
+ ); +}; diff --git a/src/lib/client.ts b/src/lib/client.ts index 3ddf32c8c..528f2c81e 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -28,7 +28,30 @@ const paginationFieldPolicy = relayStylePaginationWithNodes((args) => export const cache = new InMemoryCache({ possibleTypes: generatedIntrospection.possibleTypes, typePolicies: { - AccountList: { merge: true }, + Appeal: { + fields: { + pledges: paginationFieldPolicy, + }, + merge: true, + }, + CoachingAppeal: { + fields: { + pledges: paginationFieldPolicy, + }, + merge: true, + }, + AccountList: { + fields: { + contacts: paginationFieldPolicy, + }, + merge: true, + }, + CoachingAccountList: { + fields: { + contacts: paginationFieldPolicy, + }, + merge: true, + }, User: { merge: true }, Contact: { fields: {