diff --git a/pages/accountLists/[accountListId]/settings/organizations/OrganizationsContext.tsx b/pages/accountLists/[accountListId]/settings/organizations/OrganizationsContext.tsx index b17f9560a..8eb5e8635 100644 --- a/pages/accountLists/[accountListId]/settings/organizations/OrganizationsContext.tsx +++ b/pages/accountLists/[accountListId]/settings/organizations/OrganizationsContext.tsx @@ -2,6 +2,7 @@ import React, { Dispatch, SetStateAction } from 'react'; export type OrganizationsContextType = { selectedOrganizationId: string; + selectedOrganizationName: string; search: string; setSearch: Dispatch>; clearFilters: () => void; @@ -12,16 +13,30 @@ export const OrganizationsContext = interface OrganizationsContextProviderProps { children: React.ReactNode; selectedOrganizationId: string; + selectedOrganizationName: string; search: string; setSearch: Dispatch>; clearFilters: () => void; } export const OrganizationsContextProvider: React.FC< OrganizationsContextProviderProps -> = ({ children, selectedOrganizationId, search, setSearch, clearFilters }) => { +> = ({ + children, + selectedOrganizationId, + selectedOrganizationName, + search, + setSearch, + clearFilters, +}) => { return ( {children} diff --git a/pages/accountLists/[accountListId]/settings/organizations/accountLists.page.tsx b/pages/accountLists/[accountListId]/settings/organizations/accountLists.page.tsx index 0a7d23db3..8efcc3512 100644 --- a/pages/accountLists/[accountListId]/settings/organizations/accountLists.page.tsx +++ b/pages/accountLists/[accountListId]/settings/organizations/accountLists.page.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import PersonSearchIcon from '@mui/icons-material/PersonSearch'; import { Autocomplete, @@ -6,6 +6,7 @@ import { InputAdornment, Skeleton, TextField, + Tooltip, } from '@mui/material'; import { styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -29,23 +30,39 @@ const HeaderAndDropdown = styled(Box)(() => ({ const AccountListsOrganizations = (): ReactElement => { const { t } = useTranslation(); - const [search, setSearch] = useState(''); - const matches = useMediaQuery('(max-width:600px)'); const [selectedOrganization, setSelectedOrganization] = useState< SettingsOrganizationFragment | null | undefined - >(); + >(null); + + const [search, setSearch] = useState(''); + const isNarrowScreen = useMediaQuery('(max-width:600px)'); + const { data } = useOrganizationsQuery(); const organizations = data?.getOrganizations.organizations; const contactSearch = useDebouncedValue(search, 1000); const clearFilters = () => { setSearch(''); - setSelectedOrganization(undefined); + }; + + useEffect(() => { + if (!window?.localStorage) { + return; + } + const savedOrg = window.localStorage.getItem('admin-org'); + savedOrg && setSelectedOrganization(JSON.parse(savedOrg)); + }, []); + + const handleSelectedOrgChange = (organization): void => { + const org = organizations?.find((org) => org?.id === organization); + setSelectedOrganization(org); + org && window.localStorage.setItem(`admin-org`, JSON.stringify(org)); }; return ( { {organizations?.length && ( - setSearch(e.target.value)} - fullWidth - multiline - inputProps={{ 'aria-label': 'Search Account Lists' }} - style={{ - width: matches ? '150px' : '250px', - }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> + {selectedOrganization && ( + + { + setSearch(e.target.value); + }} + fullWidth + inputProps={{ 'aria-label': 'Search Account Lists' }} + style={{ + width: isNarrowScreen ? '150px' : '250px', + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + )} org?.id) || []} getOptionLabel={(orgId) => @@ -104,12 +129,9 @@ const AccountListsOrganizations = (): ReactElement => { /> )} value={selectedOrganization?.id ?? null} - onChange={(_, organization): void => { - const org = organizations?.find( - (org) => org?.id === organization, - ); - setSelectedOrganization(org); - }} + onChange={(_, organization): void => + handleSelectedOrgChange(organization) + } /> diff --git a/pages/accountLists/[accountListId]/settings/organizations/contacts.page.tsx b/pages/accountLists/[accountListId]/settings/organizations/contacts.page.tsx index ae57b173b..77e9faa26 100644 --- a/pages/accountLists/[accountListId]/settings/organizations/contacts.page.tsx +++ b/pages/accountLists/[accountListId]/settings/organizations/contacts.page.tsx @@ -6,6 +6,7 @@ import { InputAdornment, Skeleton, TextField, + Tooltip, } from '@mui/material'; import { styled } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; @@ -30,7 +31,7 @@ const HeaderAndDropdown = styled(Box)(() => ({ const OrganizationsContacts = (): ReactElement => { const { t } = useTranslation(); const [search, setSearch] = useState(''); - const matches = useMediaQuery('(max-width:600px)'); + const isNarrowScreen = useMediaQuery('(max-width:600px)'); const [selectedOrganization, setSelectedOrganization] = useState< SettingsOrganizationFragment | null | undefined >(); @@ -47,6 +48,7 @@ const OrganizationsContacts = (): ReactElement => { return ( { {selectedOrganization && ( - setSearch(e.target.value)} - fullWidth - multiline - inputProps={{ 'aria-label': 'Search Contacts' }} - style={{ - width: matches ? '150px' : '250px', - }} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> + + setSearch(e.target.value)} + fullWidth + inputProps={{ 'aria-label': 'Search Contacts' }} + style={{ + width: isNarrowScreen ? '150px' : '250px', + }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + )} org?.id) || []} getOptionLabel={(orgId) => diff --git a/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/SearchOrganizationsAccountLists.graphql b/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/SearchOrganizationsAccountLists.graphql index 2b8e4fdce..06002813b 100644 --- a/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/SearchOrganizationsAccountLists.graphql +++ b/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/SearchOrganizationsAccountLists.graphql @@ -18,6 +18,7 @@ type SearchOrganizationsAccountListsResponse { type OrganizationsAccountList { id: ID! name: String! + organizationCount: Int designationAccounts: [AccountListDesignationAccounts] accountListUsers: [AccountListUsers] accountListUsersInvites: [AccountListInvites] @@ -36,7 +37,10 @@ type AccountListUsers { userFirstName: String userLastName: String allowDeletion: Boolean + userId: ID + lastSyncedAt: String userEmailAddresses: [AccountListEmailAddresses] + organizationCount: Int } type AccountListInvites { diff --git a/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/datahandler.ts b/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/datahandler.ts index 543ec9d9c..7312d0ab0 100644 --- a/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/datahandler.ts +++ b/pages/api/Schema/Settings/Organizations/SearchOrganizationsAccountLists/datahandler.ts @@ -76,6 +76,7 @@ type AccountListInvite = { type SearchOrganizationsAccountListsAccountList = { name: string; id: string; + organizationCount: number; designationAccounts: { displayName: string; id: string; @@ -89,6 +90,9 @@ type SearchOrganizationsAccountListsAccountList = { userFirstName: string; userLastName: string; allowDeletion: boolean; + userId: string; + lastSyncedAt: string; + organizationCount: number; userEmailAddresses: EmailAddress[]; }[]; accountListUsersInvites: AccountListInvite[]; diff --git a/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/SearchOrganizationsContacts.graphql b/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/SearchOrganizationsContacts.graphql index 3d68b99b5..6c4663992 100644 --- a/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/SearchOrganizationsContacts.graphql +++ b/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/SearchOrganizationsContacts.graphql @@ -17,7 +17,6 @@ type SearchOrganizationsContactsResponse { type OrganizationsContact { id: ID! - allowDeletion: Boolean! name: String! squareAvatar: String people: [ContactPeople]! @@ -67,8 +66,8 @@ type ContactPeopleAccountLists { type ContactPeopleAccountListsUsers { id: ID - firstName: String - lastName: String - emailAddresses: [ContactPeopleEmailAddresses] + userFirstName: String + userLastName: String + userEmailAddresses: [ContactPeopleEmailAddresses] phoneNumbers: [ContactPeoplePhoneNumbers] } diff --git a/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/datahandler.ts b/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/datahandler.ts index 38e66475d..8a5996414 100644 --- a/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/datahandler.ts +++ b/pages/api/Schema/Settings/Organizations/SearchOrganizationsContacts/datahandler.ts @@ -82,9 +82,9 @@ type SearchOrganizationsContactsContact = { name: string; accountListUsers: { id: string; - firstName: string; - lastName: string; - emailAddresses: EmailAddress[]; + userFirstName: string; + userLastName: string; + userEmailAddresses: EmailAddress[]; }[]; }; addresses: { diff --git a/pages/api/graphql-rest.page.ts b/pages/api/graphql-rest.page.ts index 2e3bc3fdc..436faf42e 100644 --- a/pages/api/graphql-rest.page.ts +++ b/pages/api/graphql-rest.page.ts @@ -1202,7 +1202,7 @@ class MpdxRestApi extends RESTDataSource { ) { const include = 'people,people.email_addresses,people.phone_numbers,addresses,account_list,' + - 'account_list.account_list_users,account_list.account_list_users.email_addresses'; + 'account_list.account_list_users,account_list.account_list_users.user_email_addresses'; const filters = `filter[organization_id]=${organizationId}` + `&filter[wildcard_search]=${search}` + @@ -1213,7 +1213,7 @@ class MpdxRestApi extends RESTDataSource { '&fields[email_addresses]=email,primary,historic' + '&fields[phone_numbers]=number,primary,historic' + '&fields[account_lists]=name,account_list_users' + - '&fields[account_list_users]=first_name,last_name,email_addresses' + + '&fields[account_list_users]=user_first_name,user_last_name,user_email_addresses' + '&fields[addresses]=primary_mailing_address,street,city,state,postal_code'; const data: SearchOrganizationsContactsResponse = await this.get( @@ -1256,9 +1256,9 @@ class MpdxRestApi extends RESTDataSource { : ''; const filters = `filter[wildcard_search]=${search}` + organizationIdFilter; const fields = - 'fields[account_lists]=name,account_list_coaches,account_list_users,account_list_invites,designation_accounts' + + 'fields[account_lists]=name,account_list_coaches,account_list_users,account_list_invites,designation_accounts,organization_count' + '&fields[account_list_coaches]=coach_first_name,coach_last_name,coach_email_addresses' + - '&fields[account_list_users]=user_first_name,user_last_name,user_email_addresses,allow_deletion' + + '&fields[account_list_users]=user_first_name,user_last_name,user_email_addresses,allow_deletion,user_id,last_synced_at,organization_count' + '&fields[email_addresses]=email,primary' + '&fields[designation_accounts]=display_name,organization' + '&fields[organizations]=name' + diff --git a/src/components/InfiniteList/InfiniteList.tsx b/src/components/InfiniteList/InfiniteList.tsx index b77010b6c..dc56c3efb 100644 --- a/src/components/InfiniteList/InfiniteList.tsx +++ b/src/components/InfiniteList/InfiniteList.tsx @@ -66,24 +66,24 @@ const GroupLabel = styled(Typography)(({ theme }) => ({ export interface InfiniteListProps { loading: boolean; + disableHover?: boolean; EmptyPlaceholder?: ReactElement | null; ItemOverride?: React.ComponentType | null; itemContent: ItemContent; // eslint-disable-next-line @typescript-eslint/no-explicit-any context?: any; groupBy?: (item: T) => { label: string; order?: number }; - disableHover?: boolean; } export const InfiniteList = ({ loading, + disableHover = false, data = [], EmptyPlaceholder = null, ItemOverride = null, context, groupBy, itemContent, - disableHover = false, ...props }: Omit, 'groupCounts' | 'itemContent'> & InfiniteListProps): ReactElement => { @@ -109,6 +109,11 @@ export const InfiniteList = ({ ScrollSeekPlaceholder: SkeletonItem, ...props.components, }, + scrollSeekConfiguration: { + enter: (velocity) => Math.abs(velocity) > 200, + exit: (velocity) => Math.abs(velocity) < 10, + ...props.scrollSeekConfiguration, + }, overscan: 2000, }; diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.test.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.test.tsx index 07d28214d..a503800ce 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.test.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.test.tsx @@ -6,10 +6,7 @@ import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import theme from '../../../../../../theme'; -import { - AccountListCoachesOrUsers, - AccountListItemType, -} from './AccountListCoachesOrUsers'; +import { AccountListCoachesOrUsers } from './AccountListCoachesOrUsers'; jest.mock('next-auth/react'); @@ -47,6 +44,7 @@ const userAccountListItems = [ userFirstName: 'userFirstName1', userLastName: 'userLastName1', allowDeletion: false, + organizationCount: 1, userEmailAddresses: [ { id: '507548d6', @@ -61,6 +59,7 @@ const userAccountListItems = [ userFirstName: 'userFirstName2', userLastName: 'userLastName2', allowDeletion: true, + organizationCount: 2, userEmailAddresses: [ { id: '930548d6', @@ -87,7 +86,7 @@ const coachAccountListItems = [ }, ]; -describe('AccountLists', () => { +describe('AccountLists Coaches or Users', () => { const handleDelete = jest.fn().mockResolvedValue(true); beforeEach(() => { handleDelete.mockClear(); @@ -98,10 +97,10 @@ describe('AccountLists', () => { , @@ -113,88 +112,113 @@ describe('AccountLists', () => { expect(getByText('userEmail2@cru.org')).toBeInTheDocument(); }); - it('should show user Info buttons', async () => { - const { getByText, getByTestId, queryByTestId } = render( + it('should show user Info buttons, tooltips and delete button', async () => { + const { getByText, getByTestId, getAllByRole } = render( , ); - expect(queryByTestId('DeleteIcon')).not.toBeInTheDocument(); userEvent.hover(getByTestId('InformationButton')); await waitFor(() => { expect( getByText( - 'User has been granted access to this account list by donation services', + 'User has been granted access to this account list by donation services. Last synced:', ), ).toBeVisible(); }); + + userEvent.hover(getByTestId('DeleteForeverIcon')); + await waitFor(() => { + expect(getByText('Permanently delete this user.')).toBeVisible(); + }); + + userEvent.click( + getAllByRole('button', { + name: 'Delete', + })[0], + ); + expect(handleDelete).toHaveBeenCalled(); }); - it('should show user delete buttons and handleDelete()', async () => { - const { getByText, getByTestId } = render( + it('should not show delete button if the user is in more than 1 organization', async () => { + const { queryByTestId } = render( , ); - expect(getByTestId('DeleteIcon')).toBeInTheDocument(); - userEvent.click(getByTestId('DeleteIcon')); - - await waitFor(() => { - expect( - getByText( - 'Are you sure you want to remove {{user}} as a user from {{accountList}}?', - ), - ).toBeInTheDocument(); - userEvent.click(getByText('Yes')); - }); - - await waitFor(() => expect(handleDelete).toHaveBeenCalledTimes(1)); + expect(queryByTestId('DeleteForeverIcon')).not.toBeInTheDocument(); }); - it('should show coach delete buttons and handleDelete()', async () => { - const { getByText, getByTestId } = render( + it('should show coach remove buttons, tooltip and handleDelete()', async () => { + const { getByText, getByTestId, getAllByRole } = render( , ); expect(getByText('coachFirstName1 coachLastName1')).toBeInTheDocument(); expect(getByText('coach1@cru.org')).toBeInTheDocument(); + expect(getByTestId('CheckIcon')).toBeInTheDocument(); + + userEvent.hover(getByTestId('RemoveCoachButton')); + await waitFor(() => { + expect(getByText('Remove this coach from the account.')).toBeVisible(); + }); + + userEvent.click( + getAllByRole('button', { + name: 'Remove Coach', + })[0], + ); + expect(handleDelete).toHaveBeenCalled(); + }); - expect(getByTestId('DeleteIcon')).toBeInTheDocument(); - userEvent.click(getByTestId('DeleteIcon')); + it('should show user remove buttons, tooltip and handleDelete()', async () => { + const { getByText, getByTestId, getAllByRole } = render( + + + + + , + ); + userEvent.hover(getByTestId('RemoveUserButton')); await waitFor(() => { - expect( - getByText( - 'Are you sure you want to remove {{coach}} as a coach from {{accountList}}?', - ), - ).toBeInTheDocument(); - userEvent.click(getByText('Yes')); + expect(getByText('Remove this user from the account.')).toBeVisible(); }); - await waitFor(() => expect(handleDelete).toHaveBeenCalledTimes(1)); + userEvent.click( + getAllByRole('button', { + name: 'Remove User', + })[0], + ); + expect(handleDelete).toHaveBeenCalled(); }); }); diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.tsx index 31b74cd99..0d1e9d5d0 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListCoachesOrUsers/AccountListCoachesOrUsers.tsx @@ -1,52 +1,47 @@ -import Link from 'next/link'; -import React, { useState } from 'react'; +import React, { Dispatch, SetStateAction } from 'react'; import { - Delete as DeleteIcon, + DeleteForever, HelpOutline as HelpOutlineIcon, + PersonRemove, } from '@mui/icons-material'; -import { Box, IconButton, Tooltip, Typography } from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import { Box, IconButton, Link, Tooltip, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; +import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; import { AccountListUsers, Maybe, OrganizationAccountListCoaches, } from 'src/graphql/types.generated'; -import theme from 'src/theme'; - -export enum AccountListItemType { - COACH = 'coach', - USER = 'user', -} - -type UserOrCoach = AccountListUsers | OrganizationAccountListCoaches; +import { useLocale } from 'src/hooks/useLocale'; +import { dateTimeFormat } from 'src/lib/intlFormat'; +import { BorderBottomBox, HeaderBox } from '../accountListRowHelper'; interface Props { - name: string; - accountListItems: Array>; - type: AccountListItemType; - handleDelete: (item: UserOrCoach, type: AccountListItemType) => Promise; + accountListItems: Array< + Maybe + >; + setRemoveUser: Dispatch>; + setDeleteUser: Dispatch>; + setRemoveCoach: Dispatch< + SetStateAction + >; } -const BorderBottomBox = styled(Box)(() => ({ - borderBottom: '1px solid', - borderColor: theme.palette.cruGrayLight.main, -})); - const ContactAddressPrimaryText = styled(Typography)(({ theme }) => ({ - margin: theme.spacing(0, 1), + margin: theme.spacing(0, 0.5), color: theme.palette.text.secondary, })); export const AccountListCoachesOrUsers: React.FC = ({ - name, accountListItems, - type, - handleDelete, + setDeleteUser, + setRemoveUser, + setRemoveCoach, }) => { const { t } = useTranslation(); - const [deleteUserDialogOpen, setDeleteUserDialogOpen] = useState(false); + const locale = useLocale(); return ( <> @@ -67,17 +62,38 @@ export const AccountListCoachesOrUsers: React.FC = ({ return ( - + {item.__typename === 'AccountListUsers' && `${item.userFirstName} ${item.userLastName}`} {item.__typename === 'OrganizationAccountListCoaches' && `${item.coachFirstName} ${item.coachLastName}`} - + {item.__typename === 'AccountListUsers' && + item.organizationCount === 1 && ( + + { + setDeleteUser(item); + }} + > + + + + )} + @@ -90,92 +106,94 @@ export const AccountListCoachesOrUsers: React.FC = ({ return ( - + {email?.email} {email?.primary && ( - - {t('Primary')} + )} ); })} - - {item.__typename === 'AccountListUsers' && item.allowDeletion && ( - setDeleteUserDialogOpen(true)} - > - - - )} - {item.__typename === 'AccountListUsers' && !item.allowDeletion && ( - + {item.__typename === 'AccountListUsers' && + !item.allowDeletion && ( + + + + + )} + {item.__typename === 'AccountListUsers' && ( + + { + setRemoveUser(item); + }} + > + + + + )} + + {item.__typename === 'OrganizationAccountListCoaches' && ( + { + setRemoveCoach(item); + }} size="small" > - + )} - {item.__typename === 'AccountListUsers' && item.allowDeletion && ( - setDeleteUserDialogOpen(false)} - mutation={() => handleDelete(item, type)} - /> - )} - - {item.__typename === 'OrganizationAccountListCoaches' && ( - <> - setDeleteUserDialogOpen(true)} - > - - - setDeleteUserDialogOpen(false)} - mutation={() => handleDelete(item, type)} - /> - - )} diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.test.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.test.tsx index ea97aa2fa..83fcbfd3f 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.test.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.test.tsx @@ -40,8 +40,8 @@ const Components = ({ children }: PropsWithChildren) => ( ); -describe('AccountLists', () => { - it('should show user details', async () => { +describe('AccountList Invites', () => { + it('should show invite details, tooltip and remove invite', async () => { const mutationSpy = jest.fn(); const { getByText, getByTestId } = render( @@ -59,20 +59,19 @@ describe('AccountLists', () => { getByText('Invited by inviteCoachFirstName inviteCoachLastName'), ).toBeInTheDocument(); - expect(getByTestId('DeleteIcon')).toBeInTheDocument(); - userEvent.click(getByTestId('DeleteIcon')); + expect(getByTestId('PersonRemoveIcon')).toBeInTheDocument(); + userEvent.hover(getByTestId('PersonRemoveIcon')); await waitFor(() => { - expect( - getByText( - 'Are you sure you want to remove the invite for {{email}} from {{accountList}}?', - ), - ).toBeInTheDocument(); - userEvent.click(getByText('Yes')); + expect(getByText('Remove this invite from the account.')).toBeVisible(); }); + userEvent.click(getByTestId('PersonRemoveIcon')); + + userEvent.click(getByText('Yes')); + await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith('Successfully deleted user', { + expect(mockEnqueue).toHaveBeenCalledWith('Successfully removed invite', { variant: 'success', }); }); diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.tsx index 821fd7ac9..a40dc5c9f 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListInvites/AccountListInvites.tsx @@ -1,16 +1,15 @@ import React, { useState } from 'react'; -import { Delete as DeleteIcon } from '@mui/icons-material'; -import { Box, IconButton, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; +import { PersonRemove } from '@mui/icons-material'; +import { Box, IconButton, Tooltip, Typography } from '@mui/material'; import { useSnackbar } from 'notistack'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { useAppSettingsContext } from 'src/components/common/AppSettings/AppSettingsProvider'; import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; import { AccountListInvites as AccountListInvitesType, Maybe, } from 'src/graphql/types.generated'; -import theme from 'src/theme'; +import { BorderBottomBox, HeaderBox } from '../accountListRowHelper'; import { useAdminDeleteOrganizationInviteMutation } from './DeleteAccountListInvites.generated'; interface Props { @@ -19,11 +18,6 @@ interface Props { accountListInvites: Maybe[]; } -const BorderBottomBox = styled(Box)(() => ({ - borderBottom: '1px solid', - borderColor: theme.palette.cruGrayLight.main, -})); - export const AccountListInvites: React.FC = ({ name, accountListId, @@ -31,13 +25,14 @@ export const AccountListInvites: React.FC = ({ }) => { const { t } = useTranslation(); const { appName } = useAppSettingsContext(); - const [deleteInviteDialogOpen, setDeleteInviteDialogOpen] = useState(false); + const [deleteInvite, setDeleteInvite] = + useState(null); const [adminDeleteOrganizationInvite] = useAdminDeleteOrganizationInviteMutation(); const { enqueueSnackbar } = useSnackbar(); - const haneleInviteDelete = async (invite) => { + const handleInviteDelete = async (invite) => { if (!invite?.id) { return; } @@ -54,12 +49,12 @@ export const AccountListInvites: React.FC = ({ cache.gc(); }, onCompleted: () => { - enqueueSnackbar(t('Successfully deleted user'), { + enqueueSnackbar(t('Successfully removed invite'), { variant: 'success', }); }, onError: () => { - enqueueSnackbar(t('{{appName}} could not delete user', { appName }), { + enqueueSnackbar(t('{{appName}} could not remove invite', { appName }), { variant: 'error', }); }, @@ -72,48 +67,61 @@ export const AccountListInvites: React.FC = ({ {accountListInvites && accountListInvites?.map((invite, idx) => ( - + - - {invite?.recipientEmail} - - + {invite?.recipientEmail} + {t('Invited by')} {invite?.invitedByUser?.firstName}{' '} {invite?.invitedByUser?.lastName} - - setDeleteInviteDialogOpen(true)} + - - + setDeleteInvite(invite)} + size="small" + > + + + - setDeleteInviteDialogOpen(false)} - mutation={() => haneleInviteDelete(invite)} - /> ))} + + } + mutation={() => handleInviteDelete(deleteInvite)} + handleClose={() => setDeleteInvite(null)} + /> ); }; diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.test.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.test.tsx index 72d001c24..819b7218d 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.test.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.test.tsx @@ -1,12 +1,15 @@ import { ThemeProvider } from '@mui/material/styles'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { ApolloErgonoMockMap } from 'graphql-ergonomock'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { OrganizationsAccountList } from 'src/graphql/types.generated'; +import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from '../../../../../theme'; import { AccountListsMocks } from '../AccountLists.mock'; -import { AccountListRow, AccountListRowProps } from './AccountListRow'; +import { AccountListRow } from './AccountListRow'; jest.mock('next-auth/react'); @@ -18,6 +21,7 @@ const router = { }; const mockEnqueue = jest.fn(); +jest.mock('src/hooks/useGetAppSettings'); jest.mock('notistack', () => ({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -32,12 +36,26 @@ const accountList = new AccountListsMocks().accountList; const mutationSpy = jest.fn(); -const Components: React.FC = ({ accountList }) => ( +type TestComponentProps = { + accountList: OrganizationsAccountList; + mocks?: ApolloErgonoMockMap; + search?: string; + organizationId?: string; +}; + +const Components: React.FC = ({ + accountList, + mocks = {}, +}) => ( - + - + @@ -45,11 +63,16 @@ const Components: React.FC = ({ accountList }) => ( ); describe('AccountLists', () => { + beforeEach(() => { + (useGetAppSettings as jest.Mock).mockReturnValue({ + appName: 'MPDX', + }); + }); it('should show details', () => { const { getByText, queryByText } = render( - , + , ); - expect(getByText('Name1')).toBeInTheDocument(); + expect(getByText('Test Account List Name')).toBeInTheDocument(); expect(getByText('DisplayName (4791.0)')).toBeInTheDocument(); expect(getByText('Agape Bulgaria')).toBeInTheDocument(); @@ -72,11 +95,13 @@ describe('AccountLists', () => { accountListUsersInvites: [], accountListCoachInvites: [], }} + search="" + organizationId="" />, ); - expect(getByText('No users')).toBeInTheDocument(); - expect(getByText('No coaches')).toBeInTheDocument(); + expect(getByText('No Users')).toBeInTheDocument(); + expect(getByText('No Coaches')).toBeInTheDocument(); }); it('should show invites', () => { @@ -87,6 +112,8 @@ describe('AccountLists', () => { accountListUsers: [], accountListCoaches: [], }} + search="" + organizationId="" />, ); @@ -97,42 +124,103 @@ describe('AccountLists', () => { }); describe('Handling Deletions', () => { + it('should delete an accountList', async () => { + const { getByRole, getByText, getByTestId } = render( + , + ); + const reason = 'Because I am an admin'; + + userEvent.hover(getByTestId('DeleteAccountListButton')); + await waitFor(() => { + expect(getByText('Permanently delete this account.')).toBeVisible(); + }); + + userEvent.click(getByRole('button', { name: 'Delete Account' })); + const reasonTextField = getByRole('textbox', { name: 'Reason' }); + userEvent.type(reasonTextField, reason); + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mutationSpy).toHaveGraphqlOperation('DeleteAccountList', { + input: { accountListId: '1111', reason }, + }); + + expect(mockEnqueue).toHaveBeenCalledWith( + 'Deletion process enqueued: Test Account List Name', + { + variant: 'success', + }, + ); + }); + }); + + it('should not show delete icon if the account list is in more than 1 organization', async () => { + const alteredAccountList = { ...accountList }; + alteredAccountList.organizationCount = 2; + const { queryByTestId } = render( + + + + + + + + + , + ); + + expect(queryByTestId('DeleteAccountListButton')).not.toBeInTheDocument(); + }); + it('should delete users', async () => { const { getAllByRole, getByRole } = render( - , + , + ); + const firstDeleteButton = getAllByRole('button', { name: 'Delete' })[0]; + userEvent.click(firstDeleteButton); + userEvent.type( + getAllByRole('textbox', { name: 'Reason' })[0], + 'this is a test', ); - - userEvent.click(getAllByRole('button', { name: 'Delete' })[0]); userEvent.click(getByRole('button', { name: 'Yes' })); await waitFor(() => { - return expect(mutationSpy.mock.calls[0][0]).toMatchObject({ + expect(mutationSpy.mock.calls[0][0]).toMatchObject({ operation: { - operationName: 'AdminDeleteOrganizationUser', + operationName: 'DeleteUser', variables: { input: { - accountListId: '1111', - userId: 'e8a19920', + reason: 'this is a test', + resettedUserId: 'e8a19920', }, }, }, }); - }); - expect(mockEnqueue).toHaveBeenCalledWith('Successfully deleted user', { - variant: 'success', + + expect(mockEnqueue).toHaveBeenCalledWith( + 'Deletion process enqueued: userFirstName userLastName. Check back later to see the updated data.', + { + variant: 'success', + }, + ); }); }); - it('should delete coaches', async () => { + it('should remove coaches', async () => { const { getAllByRole, getByRole } = render( - , + , ); - userEvent.click(getAllByRole('button', { name: 'Delete' })[1]); + userEvent.click(getAllByRole('button', { name: 'Remove Coach' })[0]); + userEvent.click(getByRole('button', { name: 'Yes' })); await waitFor(() => { - return expect(mutationSpy.mock.calls[0][0]).toMatchObject({ + expect(mutationSpy.mock.calls[0][0]).toMatchObject({ operation: { operationName: 'AdminDeleteOrganizationCoach', variables: { @@ -144,8 +232,124 @@ describe('AccountLists', () => { }, }); }); - expect(mockEnqueue).toHaveBeenCalledWith('Successfully deleted coach', { - variant: 'success', + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully removed coach: coachFirstName coachLastName', + { + variant: 'success', + }, + ); + }); + it('should remove users', async () => { + const { getAllByRole, getByRole } = render( + , + ); + + userEvent.click(getAllByRole('button', { name: 'Remove User' })[0]); + + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + return expect(mutationSpy.mock.calls[0][0]).toMatchObject({ + operation: { + operationName: 'RemoveAccountListUser', + variables: { + input: { + accountListId: '1111', + userId: 'e8a19920', + }, + }, + }, + }); + }); + expect(mockEnqueue).toHaveBeenCalledWith( + 'Successfully removed user: userFirstName userLastName', + { + variant: 'success', + }, + ); + }); + }); + describe('handling error state', () => { + it('Should render the error state of deleting a user', async () => { + const { getByRole, getAllByRole } = render( + { + throw new Error('Server Error'); + }, + }} + />, + ); + const firstDeleteButton = getAllByRole('button', { name: 'Delete' })[0]; + userEvent.click(firstDeleteButton); + userEvent.type( + getAllByRole('textbox', { name: 'Reason' })[0], + 'this is a test', + ); + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX could not delete user: userFirstName userLastName', + { + variant: 'error', + }, + ); + }); + }); + + it('Should render the error state of removing a user', async () => { + const { getByRole, getAllByRole } = render( + { + throw new Error('Server Error'); + }, + }} + />, + ); + userEvent.click(getAllByRole('button', { name: 'Remove User' })[0]); + + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX could not remove user: userFirstName userLastName', + { + variant: 'error', + }, + ); + }); + }); + + it('Should render the error state of removing a coach', async () => { + const { getByRole, getAllByRole } = render( + { + throw new Error('Server Error'); + }, + }} + />, + ); + userEvent.click(getAllByRole('button', { name: 'Remove Coach' })[0]); + + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith( + 'MPDX could not remove coach: coachFirstName coachLastName', + { + variant: 'error', + }, + ); }); }); }); diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.tsx index 8ef71707f..d6f2bd7b4 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/AccountListRow.tsx @@ -1,8 +1,17 @@ -import React from 'react'; -import { Box, Grid, ListItemText, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { DeleteForever } from '@mui/icons-material'; +import { + Box, + Grid, + IconButton, + ListItemText, + Tooltip, + Typography, +} from '@mui/material'; import { styled } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; import { AccountListUsers, OrganizationAccountListCoaches, @@ -11,24 +20,28 @@ import { import useGetAppSettings from 'src/hooks/useGetAppSettings'; import theme from 'src/theme'; import { - AccountListCoachesOrUsers, - AccountListItemType, -} from './AccountListCoachesOrUsers/AccountListCoachesOrUsers'; + SearchOrganizationsAccountListsDocument, + SearchOrganizationsAccountListsQuery, + SearchOrganizationsAccountListsQueryVariables, +} from '../AccountLists.generated'; +import { AccountListCoachesOrUsers } from './AccountListCoachesOrUsers/AccountListCoachesOrUsers'; import { AccountListInvites } from './AccountListInvites/AccountListInvites'; +import { DeleteAccountConfirmModal } from './DeleteAccountConfirmModal'; import { useAdminDeleteOrganizationCoachMutation, - useAdminDeleteOrganizationUserMutation, + useDeleteAccountListMutation, + useDeleteUserMutation, + useRemoveAccountListUserMutation, } from './DeleteAccountListsItems.generated'; +import { DeleteUserConfirmModal } from './DeleteUserConfirmModal'; +import { BorderBottomBox, HeaderBox } from './accountListRowHelper'; export interface AccountListRowProps { accountList: OrganizationsAccountList; + search: string; + organizationId: string; } -const BorderBottomBox = styled(Box)(() => ({ - borderBottom: '1px solid', - borderColor: theme.palette.cruGrayLight.main, -})); - const BorderRightGrid = styled(Grid)(() => ({ borderRight: '1px solid', borderColor: theme.palette.cruGrayLight.main, @@ -44,16 +57,26 @@ const NoItemsBox = styled(Box)(() => ({ export const AccountListRow: React.FC = ({ accountList, + search, + organizationId, }) => { const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); const { appName } = useGetAppSettings(); - const [adminDeleteOrganizationUser] = - useAdminDeleteOrganizationUserMutation(); const [adminDeleteOrganizationCoach] = useAdminDeleteOrganizationCoachMutation(); + const [removeAccountListUser] = useRemoveAccountListUserMutation(); + const [deleteUserMutation] = useDeleteUserMutation(); + const [deleteAccountList] = useDeleteAccountListMutation(); + const [removeUser, setRemoveUser] = useState(null); + const [deleteUser, setDeleteUser] = useState(null); + const [removeCoach, setRemoveCoach] = + useState(null); + const [deleteAccountListDialogOpen, setDeleteAccountListDialogOpen] = + useState(false); + const [reason, setReason] = useState(''); const { - id, + id: accountListId, name, designationAccounts, accountListUsers, @@ -62,87 +85,253 @@ export const AccountListRow: React.FC = ({ accountListCoaches, } = accountList; - const handleDelete = async ( - item: AccountListUsers | OrganizationAccountListCoaches, - type: AccountListItemType, - ) => { - if (!item?.id) { - enqueueSnackbar(t('{{appName}} could not delete user', { appName }), { + // handleRemoveUser is different from handleDeleteUser. Deleting a user permanently deletes them. Removing them just removes them as a user from the AccountList. + const handleDeleteUser = async (item: AccountListUsers | null) => { + if (!item) { + setReason(''); + return; + } + const fullName = `${item.userFirstName} ${item.userLastName}`; + const errorMessage = t('{{appName}} could not delete user: {{fullName}}', { + appName, + fullName, + }); + if (!item.userId) { + enqueueSnackbar(errorMessage, { variant: 'error', }); return; } - if (type === AccountListItemType.USER) { - await adminDeleteOrganizationUser({ - variables: { - input: { - accountListId: id, - userId: item.id, - }, - }, - update: (cache) => { - cache.evict({ id: `AccountListUsers:${item.id}` }); - cache.gc(); + await deleteUserMutation({ + variables: { + input: { + reason: reason, + resettedUserId: item.userId, }, - onCompleted: () => { - enqueueSnackbar(t('Successfully deleted user'), { + }, + update: (cache) => { + cache.updateQuery< + SearchOrganizationsAccountListsQuery, + SearchOrganizationsAccountListsQueryVariables + >( + { + query: SearchOrganizationsAccountListsDocument, + variables: { + input: { + organizationId, + search, + }, + }, + }, + (data) => + data && { + ...data, + searchOrganizationsAccountLists: { + ...data.searchOrganizationsAccountLists, + accountLists: + data.searchOrganizationsAccountLists.accountLists.map( + (list) => + list && { + ...list, + accountListUsers: list.accountListUsers?.filter( + (user) => user?.userId !== item.userId, + ), + }, + ), + }, + }, + ); + }, + onCompleted: () => { + enqueueSnackbar( + t( + 'Deletion process enqueued: {{fullName}}. Check back later to see the updated data.', + { fullName }, + ), + { variant: 'success', - }); - }, - onError: () => { - enqueueSnackbar(t('{{appName}} could not delete user', { appName }), { - variant: 'error', - }); - }, - }); - } else if (type === AccountListItemType.COACH) { - await adminDeleteOrganizationCoach({ - variables: { - input: { - accountListId: id, - coachId: item.id, }, + ); + }, + onError: () => { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + }, + }); + }; + + const handleDeleteAccountList = async () => { + const errorMessage = t('{{appName}} could not delete account: {{name}}', { + appName, + name, + }); + + if (!accountListId) { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + return; + } + await deleteAccountList({ + variables: { + input: { + accountListId: accountListId, + reason: reason, }, - update: (cache) => { - cache.evict({ id: `OrganizationAccountListCoaches:${item.id}` }); - cache.gc(); + }, + update: (cache) => { + cache.evict({ id: `OrganizationsAccountList:${accountListId}` }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar(t('Deletion process enqueued: {{name}}', { name }), { + variant: 'success', + }); + }, + onError: () => { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + }, + }); + setReason(''); + }; + + const handleRemoveCoach = async ( + item: OrganizationAccountListCoaches | null, + ) => { + if (!item) { + return; + } + const fullName = item.coachFirstName + ' ' + item.coachLastName; + const errorMessage = t('{{appName}} could not remove coach: {{fullName}}', { + appName, + fullName, + }); + if (!item.id) { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + return; + } + await adminDeleteOrganizationCoach({ + variables: { + input: { + accountListId: accountListId, + coachId: item.id, }, - onCompleted: () => { - enqueueSnackbar(t('Successfully deleted coach'), { + }, + update: (cache) => { + cache.evict({ id: `OrganizationAccountListCoaches:${item.id}` }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar( + t('Successfully removed coach: {{fullName}}', { fullName }), + { variant: 'success', - }); - }, - onError: () => { - enqueueSnackbar( - t('{{appName}} could not delete coach', { appName }), - { - variant: 'error', - }, - ); - }, + }, + ); + }, + onError: () => { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + }, + }); + }; + + // handleRemoveUser is different from handleDeleteUser. Deleting a user permanently deletes them. Removing them just removes them as a user from the AccountList. + const handleRemoveUser = async (item: AccountListUsers | null) => { + if (!item) { + return; + } + const fullName = `${item.userFirstName} ${item.userLastName}`; + const errorMessage = t('{{appName}} could not remove user: {{fullName}}', { + appName, + fullName, + }); + if (!item.userId) { + enqueueSnackbar(errorMessage, { + variant: 'error', }); + return; } + await removeAccountListUser({ + variables: { + input: { + accountListId: accountListId, + userId: item.userId, + }, + }, + update: (cache) => { + cache.evict({ id: `AccountListUsers:${item.id}` }); + cache.gc(); + }, + onCompleted: () => { + enqueueSnackbar( + t('Successfully removed user: {{fullName}}', { fullName }), + { + variant: 'success', + }, + ); + }, + onError: () => { + enqueueSnackbar(errorMessage, { + variant: 'error', + }); + }, + }); }; return ( + {name} + {accountList.__typename === 'OrganizationsAccountList' && + accountList.organizationCount === 1 && ( + <> + + setDeleteAccountListDialogOpen(true)} + > + + + + + + )} } @@ -196,12 +385,8 @@ export const AccountListRow: React.FC = ({ designationAccounts?.map((account, idx) => ( - - {account?.organization?.name} - - - {account?.displayName} - + {account?.organization?.name} + {account?.displayName} ))} @@ -211,20 +396,22 @@ export const AccountListRow: React.FC = ({ {accountListUsers && ( )} {accountListUsersInvites && ( )} {!accountListUsers?.length && !accountListUsersInvites?.length && ( - {t('No users')} + + {t('No Users')} + )} = ({ {accountListCoaches && ( )} {accountListCoachInvites && ( )} {!accountListCoaches?.length && !accountListCoachInvites?.length && ( - {t('No coaches')} + {t('No Coaches')} )} + + } + handleClose={() => setRemoveUser(null)} + mutation={() => handleRemoveUser(removeUser)} + /> + + + } + mutation={() => handleRemoveCoach(removeCoach)} + handleClose={() => setRemoveCoach(null)} + /> ); }; diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountConfirmModal.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountConfirmModal.tsx new file mode 100644 index 000000000..21adc6d62 --- /dev/null +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountConfirmModal.tsx @@ -0,0 +1,127 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { TextField, Typography } from '@mui/material'; +import { TFunction } from 'react-i18next'; +import { + StyledList, + StyledListItem, +} from 'src/components/Shared/Lists/listsHelper'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import theme from 'src/theme'; +import { WarningBox } from './accountListRowHelper'; + +export interface DeleteAccountConfirmModalProps { + t: TFunction; + deleteAccountListDialogOpen: boolean; + setDeleteAccountListDialogOpen: Dispatch>; + reason: string; + setReason: Dispatch>; + handleDeleteAccountList: () => Promise; +} + +export const DeleteAccountConfirmModal: React.FC< + DeleteAccountConfirmModalProps +> = ({ + t, + deleteAccountListDialogOpen, + setDeleteAccountListDialogOpen, + reason, + setReason, + handleDeleteAccountList, +}) => { + return ( + + + + {t( + 'WARNING: Please read the implications of deleting this account.', + )} + + {t('Users')} + + + {t( + 'You are about to delete an account list. It will not delete the user(s).', + )} + + + {t( + 'The user(s) will lose gift data and donor contact data. Consider whether you should notify the user(s).', + )} + + + {t( + 'This is not the place to remove a user’s access to this account.', + )} + + + {t('Designations Accounts')} + + + {t( + 'This will delete all designation accounts that are not shared with other account lists. (including the donations for that designation)', + )} + + + {t( + 'If this account contains ministry designations rather than a staff designation, then consider whether someone else in your organization needs this. If you want to retain the designation account, then share it with the appropriate user.', + )} + + + {t('Donation System')} + + + {t( + 'A blue question icon indicates that the user may be active in the donation system and this account may be automatically recreated. Consider first updating the donation system.', + )} + + + + + {t( + `Are you sure you want to permanently delete the account list: {{accountListName}}?`, + { + accountListName: name, + interpolation: { escapeValue: false }, + }, + )} + + {t('Please explain the reason for deleting this account.')} + setReason(e.target.value)} + sx={{ marginTop: 2 }} + /> + + } + confirmButtonProps={{ disabled: reason.length < 5 }} + handleClose={() => { + setDeleteAccountListDialogOpen(false); + setReason(''); + }} + mutation={() => handleDeleteAccountList()} + /> + ); +}; diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountListsItems.graphql b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountListsItems.graphql index 8302602dd..b862315ea 100644 --- a/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountListsItems.graphql +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteAccountListsItems.graphql @@ -13,3 +13,22 @@ mutation AdminDeleteOrganizationCoach( success } } + +mutation DeleteUser($input: UserDeleteMutationInput!) { + deleteUser(input: $input) { + id + clientMutationId + } +} + +mutation DeleteAccountList($input: AccountListResetMutationInput!) { + resetAccountList(input: $input) { + clientMutationId + } +} + +mutation RemoveAccountListUser($input: AccountListUserDeleteMutationInput!) { + deleteAccountListUser(input: $input) { + id + } +} diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteUserConfirmModal.tsx b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteUserConfirmModal.tsx new file mode 100644 index 000000000..b26d42c96 --- /dev/null +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/DeleteUserConfirmModal.tsx @@ -0,0 +1,117 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { TextField, Typography } from '@mui/material'; +import { TFunction } from 'react-i18next'; +import { + StyledList, + StyledListItem, +} from 'src/components/Shared/Lists/listsHelper'; +import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; +import { AccountListUsers } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { WarningBox } from './accountListRowHelper'; + +export interface DeleteUserConfirmModalProps { + t: TFunction; + deleteUser: AccountListUsers | null; + setDeleteUser: Dispatch>; + reason: string; + setReason: Dispatch>; + handleDeleteUser: (item: AccountListUsers | null) => Promise; + appName: string | undefined; +} + +export const DeleteUserConfirmModal: React.FC = ({ + t, + deleteUser, + setDeleteUser, + reason, + setReason, + handleDeleteUser, + appName, +}) => { + return ( + + + + {t( + 'WARNING: Please read the implications of deleting this user.', + )} + + {t('Accounts')} + + + {t( + 'You are about to delete a user and any unshared associated account(s). Associated accounts will be deleted unless they are shared with other users.', + )} + + + {t( + 'Only delete if you know that this user will not be returning to any other missional organization that uses {{appName}}. You may need to confirm this with them.', + + { appName: appName }, + )} + + + {t('Designations Accounts')} + + + {t( + 'If this user has access to a ministry designation, then consider whether someone else in your organization needs this. If you want to retain the account, then share it with the appropriate user.', + )} + + + {t('Donation System')} + + + {t( + 'A blue question icon indicates that the user may be active in the donation system and this user and account may be automatically recreated. Consider first updating the donation system.', + )} + + + + + {t( + 'Are you sure you want to permanently delete the user: {{first}} {{last}}?', + { + first: deleteUser?.userFirstName, + last: deleteUser?.userLastName, + interpolation: { escapeValue: false }, + }, + )} + + {t('Please explain the reason for deleting this user.')} + setReason(e.target.value)} + sx={{ marginTop: 2 }} + /> + + } + mutation={() => handleDeleteUser(deleteUser)} + handleClose={() => { + setDeleteUser(null); + setReason(''); + }} + /> + ); +}; diff --git a/src/components/Settings/Organization/AccountLists/AccountListRow/accountListRowHelper.ts b/src/components/Settings/Organization/AccountLists/AccountListRow/accountListRowHelper.ts new file mode 100644 index 000000000..440227f26 --- /dev/null +++ b/src/components/Settings/Organization/AccountLists/AccountListRow/accountListRowHelper.ts @@ -0,0 +1,24 @@ +import { Box } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import theme from 'src/theme'; + +export const BorderBottomBox = styled(Box)(() => ({ + borderBottom: '1px solid', + borderColor: theme.palette.cruGrayLight.main, + padding: theme.spacing(1), + '&:last-child': { + borderBottom: '0px', + }, +})); + +export const HeaderBox = styled(Box)(() => ({ + fontWeight: 'bold', + paddingX: '5px', +})); + +export const WarningBox = styled(Box)(() => ({ + padding: '15px', + background: theme.palette.mpdxYellow.main, + maxWidth: 'calc(100% - 20px)', + margin: '10px auto 0', +})); diff --git a/src/components/Settings/Organization/AccountLists/AccountLists.graphql b/src/components/Settings/Organization/AccountLists/AccountLists.graphql index d22abd5c3..069eb42bf 100644 --- a/src/components/Settings/Organization/AccountLists/AccountLists.graphql +++ b/src/components/Settings/Organization/AccountLists/AccountLists.graphql @@ -11,6 +11,7 @@ query SearchOrganizationsAccountLists( accountLists { name id + organizationCount designationAccounts { id displayName @@ -34,11 +35,14 @@ query SearchOrganizationsAccountLists( userFirstName userLastName allowDeletion + userId + lastSyncedAt userEmailAddresses { id email primary } + organizationCount } accountListCoaches { id diff --git a/src/components/Settings/Organization/AccountLists/AccountLists.mock.ts b/src/components/Settings/Organization/AccountLists/AccountLists.mock.ts index 7de0d15df..77d6aa224 100644 --- a/src/components/Settings/Organization/AccountLists/AccountLists.mock.ts +++ b/src/components/Settings/Organization/AccountLists/AccountLists.mock.ts @@ -2,8 +2,10 @@ import { OrganizationsAccountList } from 'src/graphql/types.generated'; export class AccountListsMocks { accountList: OrganizationsAccountList = { + __typename: 'OrganizationsAccountList', id: '1111', - name: 'Name1', + name: 'Test Account List Name', + organizationCount: 1, designationAccounts: [ { id: '297b398f', @@ -33,6 +35,9 @@ export class AccountListsMocks { userFirstName: 'userFirstName', userLastName: 'userLastName', allowDeletion: true, + userId: 'e8a19920', + lastSyncedAt: '', + organizationCount: 1, userEmailAddresses: [ { id: '507548d6', diff --git a/src/components/Settings/Organization/AccountLists/AccountLists.test.tsx b/src/components/Settings/Organization/AccountLists/AccountLists.test.tsx index 578e04f34..a7a1581fb 100644 --- a/src/components/Settings/Organization/AccountLists/AccountLists.test.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountLists.test.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import { VirtuosoMockContext } from 'react-virtuoso'; @@ -22,6 +22,7 @@ const router = { }; const mockEnqueue = jest.fn(); +const mutationSpy = jest.fn(); jest.mock('notistack', () => ({ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -45,6 +46,7 @@ const ComponentsWithNoData = ({ children }: PropsWithChildren) => ( ( { }); it('should show default screen', async () => { - const { getByText, queryByText } = render( + const { queryByText } = render( { await waitFor(() => { expect( - queryByText('Looks like you have no account lists to manage yet'), + queryByText('Start by adding search filters'), ).not.toBeInTheDocument(); - - expect( - getByText('No account lists match your filters.'), - ).toBeInTheDocument(); - }); - - userEvent.click(getByText('Reset All Search Filters')); - - await waitFor(() => { - expect(clearFilters).toHaveBeenCalledTimes(1); }); }); @@ -170,8 +163,12 @@ describe('AccountLists', () => { ).toBeInTheDocument(); expect( - getByText('No account lists match your filters.'), + getByText('No account lists match your search filters.'), ).toBeInTheDocument(); + + userEvent.click(getByText('Reset Search')); + + expect(clearFilters).toHaveBeenCalledTimes(1); }); }); @@ -202,7 +199,91 @@ describe('AccountLists', () => { }); await waitFor(() => { - expect(getByText('Name1')).toBeInTheDocument(); + expect(getByText('Test Account List Name')).toBeInTheDocument(); + }); + }); + + it('takes users off the screen after they are deleted', async () => { + const { getByText, queryByText, getAllByRole, getByRole } = render( + + + mocks={{ + ...SearchOrganizationsAccountListsMock, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => { + expect(getByText('Test Account List Name')).toBeInTheDocument(); + }); + + const view = getByText(/userfirstname userlastname/i); + const firstDeleteButton = await within(view).findByRole('button', { + name: /delete/i, + }); + + userEvent.click(firstDeleteButton); + userEvent.type( + getAllByRole('textbox', { name: 'Reason' })[0], + 'this is a test', + ); + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(mutationSpy.mock.calls[1][0]).toMatchObject({ + operation: { + operationName: 'DeleteUser', + variables: { + input: { + reason: 'this is a test', + resettedUserId: 'e8a19920', + }, + }, + }, + }); + + expect( + queryByText(/userfirstname userlastname/i), + ).not.toBeInTheDocument(); + }); + }); + + it('takes coaches off the screen after they are removed', async () => { + const { getByText, queryByText, getAllByRole, getByRole } = render( + + + mocks={{ + ...SearchOrganizationsAccountListsMock, + }} + onCall={mutationSpy} + > + + + , + ); + + await waitFor(() => { + expect(getByText('Test Account List Name')).toBeInTheDocument(); + }); + + expect(getByText(/coachFirstName coachLastName/i)).toBeInTheDocument(); + + await waitFor(() => { + userEvent.click(getAllByRole('button', { name: /remove coach/i })[0]); + userEvent.click(getByRole('button', { name: 'Yes' })); + }); + await waitFor(() => { + expect( + queryByText(/coachFirstName coachLastName/i), + ).not.toBeInTheDocument(); }); }); }); diff --git a/src/components/Settings/Organization/AccountLists/AccountLists.tsx b/src/components/Settings/Organization/AccountLists/AccountLists.tsx index 9fb1b2d52..1a202f20d 100644 --- a/src/components/Settings/Organization/AccountLists/AccountLists.tsx +++ b/src/components/Settings/Organization/AccountLists/AccountLists.tsx @@ -59,57 +59,79 @@ export const AccountLists: React.FC = () => { data-testid="LoadingSpinner" /> )} - { - return accountList ? ( - - ) : null; - }} - endReached={() => - pagination && - pagination.page < pagination.totalPages && - fetchMore({ - variables: { - input: { - organizationId: selectedOrganizationId, - search: search, - pageNumber: pagination.page + 1, + {!selectedOrganizationId && ( + + + + + {t('Start by adding search filters')} + + + {t('Choose an organization that you administrate.')} + + + + )} + {selectedOrganizationId && ( + { + return accountList ? ( + + ) : null; + }} + endReached={() => + pagination && + pagination.page < pagination.totalPages && + fetchMore({ + variables: { + input: { + organizationId: selectedOrganizationId, + search: search, + pageNumber: pagination.page + 1, + }, }, - }, - }) - } - EmptyPlaceholder={ - - - + }) + } + EmptyPlaceholder={ + + + - {pagination && pagination?.totalCount === 0 && search === '' && ( + {pagination && pagination?.totalCount === 0 && !search && ( + + {t('Looks like you have no account lists to manage yet')} + + )} - {t('Looks like you have no account lists to manage yet')} + {t('No account lists match your search filters.')} - )} - - {t('No account lists match your filters.')} - - - - - } - /> + + + + } + /> + )} ); }; diff --git a/src/components/Settings/Organization/Contacts/Contact.graphql b/src/components/Settings/Organization/Contacts/Contact.graphql index 77e603baf..3c07a0cf5 100644 --- a/src/components/Settings/Organization/Contacts/Contact.graphql +++ b/src/components/Settings/Organization/Contacts/Contact.graphql @@ -1,13 +1,12 @@ -mutation DeleteOrganizationContact($input: DeleteOrganizationContactInput!) { - deleteOrganizationContact(input: $input) { - success +mutation AnonymizeContact($input: ContactAnonymizeMutationInput!) { + anonymizeContact(input: $input) { + clientMutationId } } query SearchOrganizationsContacts($input: SearchOrganizationsContactsInput!) { searchOrganizationsContacts(input: $input) { contacts { - allowDeletion id name squareAvatar @@ -30,9 +29,9 @@ query SearchOrganizationsContacts($input: SearchOrganizationsContactsInput!) { name accountListUsers { id - firstName - lastName - emailAddresses { + userFirstName + userLastName + userEmailAddresses { email primary historic diff --git a/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.test.tsx b/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.test.tsx index 0a1c10175..116f6c6cd 100644 --- a/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.test.tsx +++ b/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.test.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; @@ -44,28 +44,31 @@ describe('ContactRow', () => { const { getByText } = render( - + , ); + //Check Contact person info expect(getByText('Lastname, Firstnames')).toBeInTheDocument(); expect(getByText('firstName lastName')).toBeInTheDocument(); expect(getByText('test@cru.org')).toBeInTheDocument(); expect(getByText('(111) 222-3333')).toBeInTheDocument(); + //Check account user person info expect( getByText('accountListFirstName accountListLastName'), ).toBeInTheDocument(); expect( getByText('accountListFirstName2 accountListLastName2'), ).toBeInTheDocument(); + expect(getByText('accountList.contactOwner@cru.org')).toBeInTheDocument(); }); it('should only show primary address', () => { const { getByText, queryByText } = render( - + , ); @@ -73,106 +76,37 @@ describe('ContactRow', () => { expect(queryByText('222 test, city, FL, 22222')).not.toBeInTheDocument(); }); - it('should only show Delete button', () => { - const { getByText, queryByText } = render( - - - - - , - ); - expect(getByText('Delete')).toBeInTheDocument(); - expect(queryByText('Anonymize')).not.toBeInTheDocument(); - }); - - it('should only show Anonymize button', () => { - const { getByText, queryByText } = render( - - - - - , - ); - expect(getByText('Anonymize')).toBeInTheDocument(); - expect(queryByText('Delete')).not.toBeInTheDocument(); - }); - - it('should handle Delete', async () => { - const mutationSpy = jest.fn(); - const { getByText } = render( - - - - - , - ); - userEvent.click(getByText('Delete')); - - await waitFor(() => { - expect(getByText('Confirm')).toBeInTheDocument(); - expect( - getByText( - 'Are you sure you want to delete {{name}} from {{accountList}}?', - ), - ).toBeInTheDocument(); - }); - - userEvent.click(getByText('Yes')); - - await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith('Contact successfully deleted', { - variant: 'success', - }); - }); - expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( - 'DeleteOrganizationContact', - ); - expect(mutationSpy.mock.calls[0][0].operation.variables.input).toEqual({ - contactId: '2f5d998f', - }); - }); - it('should handle Anonymize', async () => { const mutationSpy = jest.fn(); - const { getByText } = render( + const { getByText, findByRole } = render( , ); userEvent.click(getByText('Anonymize')); - await waitFor(() => { - expect(getByText('Confirm')).toBeInTheDocument(); - expect( - getByText( - 'Are you sure you want to anonymize {{name}} from {{accountList}}?', - ), - ).toBeInTheDocument(); - }); + const modal = await findByRole('dialog'); + expect(within(modal).getByText('Confirm')).toBeInTheDocument(); userEvent.click(getByText('Yes')); await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith('Contact successfully deleted', { - variant: 'success', - }); + expect(mockEnqueue).toHaveBeenCalledWith( + 'Contact successfully anonymized', + { + variant: 'success', + }, + ); }); expect(mutationSpy.mock.calls[0][0].operation.operationName).toEqual( - 'DeleteOrganizationContact', + 'AnonymizeContact', ); expect(mutationSpy.mock.calls[0][0].operation.variables.input).toEqual({ contactId: '2f5d998f', diff --git a/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.tsx b/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.tsx index b2ebdb93d..784e1e7f1 100644 --- a/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.tsx +++ b/src/components/Settings/Organization/Contacts/ContactRow/ContactRow.tsx @@ -1,39 +1,35 @@ -import Link from 'next/link'; import React, { useMemo, useState } from 'react'; -import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; +import { Lock } from '@mui/icons-material'; import { Box, Button, - ButtonBase, Grid, Hidden, + Link, ListItemText, Typography, } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; -import { useTranslation } from 'react-i18next'; +import { Trans, useTranslation } from 'react-i18next'; import { Confirmation } from 'src/components/common/Modal/Confirmation/Confirmation'; import { ContactPeople, ContactPeopleAccountListsUsers, OrganizationsContact, } from 'src/graphql/types.generated'; -import { useDeleteOrganizationContactMutation } from '../Contact.generated'; - -const DeleteOutline = styled(DeleteOutlined)(({ theme }) => ({ - width: '24px', - height: '24px', - marginRight: '10px', - color: theme.palette.common.white, -})); +import useGetAppSettings from 'src/hooks/useGetAppSettings'; +import { useAnonymizeContactMutation } from '../Contact.generated'; interface Props { contact: OrganizationsContact; - useTopMargin?: boolean; + selectedOrganizationName: string; } interface PersonDataProps { - person: ContactPeople | ContactPeopleAccountListsUsers; + person: ContactPeople; +} +interface UserDataProps { + person: ContactPeopleAccountListsUsers; } const StyledBox = styled(Box)(() => ({ @@ -43,24 +39,7 @@ const StyledBox = styled(Box)(() => ({ justifyContent: 'flex-start', })); -const SpaceBetweenBox = styled(Box)(() => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', -})); - -const ListItemButton = styled(ButtonBase, { - shouldForwardProp: (prop) => prop !== 'useTopMargin', -})<{ useTopMargin?: boolean }>(({ theme, useTopMargin }) => ({ - flex: '1 1 auto', - textAlign: 'left', - marginTop: useTopMargin ? '16px' : '0', - padding: theme.spacing(0, 0.5, 0, 2), - [theme.breakpoints.up('sm')]: { - padding: theme.spacing(0, 0.5), - }, -})); -const PersonData: React.FC = ({ person }) => { +const ContactPersonData: React.FC = ({ person }) => { const email = person?.emailAddresses && person?.emailAddresses.find((email) => email?.primary); @@ -73,27 +52,59 @@ const PersonData: React.FC = ({ person }) => { {person?.firstName} {person?.lastName} {email && ( - + {email.email} )} {phone && ( - + {phone.number} )} ); }; +const UserPersonData: React.FC = ({ person }) => { + const email = + person?.userEmailAddresses && + person?.userEmailAddresses.find((email) => email?.primary); + return ( + + + {person?.userFirstName} {person?.userLastName} + + {email && ( + + {email.email} + + )} + + ); +}; -export const ContactRow: React.FC = ({ contact, useTopMargin }) => { +export const ContactRow: React.FC = ({ contact }) => { const { t } = useTranslation(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteOrganizationContact] = useDeleteOrganizationContactMutation(); + const [anonymizeDialogOpen, setAnonymizeDialogOpen] = useState(false); + const [anonymizeContact] = useAnonymizeContactMutation(); const { enqueueSnackbar } = useSnackbar(); + const { appName } = useGetAppSettings(); - const handleDeleteContact = async () => { - await deleteOrganizationContact({ + const handleAnonymizeContact = async () => { + await anonymizeContact({ variables: { input: { contactId: contact.id, @@ -104,19 +115,19 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { cache.gc(); }, onCompleted: () => { - enqueueSnackbar(t('Contact successfully deleted'), { + enqueueSnackbar(t('Contact successfully anonymized'), { variant: 'success', }); }, onError: () => { - enqueueSnackbar(t('Error while trying to delete contact'), { + enqueueSnackbar(t('Error while trying to anonymize contact'), { variant: 'error', }); }, }); }; - const { name, people, addresses, accountList, allowDeletion } = contact; + const { name, people, addresses, accountList } = contact; const primaryAddress = useMemo( () => @@ -125,11 +136,23 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { : null, [addresses], ); + const primaryAddressString = useMemo( + () => + [ + primaryAddress?.street, + primaryAddress?.city, + primaryAddress?.state, + primaryAddress?.postalCode, + ] + .filter(Boolean) + .join(', '), + [primaryAddress], + ); return ( - + <> - + @@ -143,19 +166,17 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { {people?.map( (person, idx) => person && ( - + ), )} {primaryAddress && ( - {[ - primaryAddress.street, - primaryAddress.city, - primaryAddress.state, - primaryAddress.postalCode, - ].join(', ')} + {primaryAddressString} )} @@ -164,60 +185,66 @@ export const ContactRow: React.FC = ({ contact, useTopMargin }) => { /> - - - - - {accountList?.name} - - - } - secondary={accountList?.accountListUsers?.map( - (person, idx) => - person && ( - - ), - )} - /> - - - - - + + + + {accountList?.name} + + + } + secondary={accountList?.accountListUsers?.map( + (person, idx) => + person && ( + + ), + )} + /> + + + + + - + } message={ - allowDeletion - ? t( - 'Are you sure you want to delete {{name}} from {{accountList}}?', - { - name: name, - accountList: accountList?.name, - }, - ) - : t( - 'Are you sure you want to anonymize {{name}} from {{accountList}}?', - { - name: name, - accountList: accountList?.name, - }, - ) + } - handleClose={() => setDeleteDialogOpen(false)} - mutation={handleDeleteContact} + handleClose={() => setAnonymizeDialogOpen(false)} + mutation={handleAnonymizeContact} /> - + ); }; diff --git a/src/components/Settings/Organization/Contacts/Contacts.test.tsx b/src/components/Settings/Organization/Contacts/Contacts.test.tsx index 494ab0514..2b61c5d6b 100644 --- a/src/components/Settings/Organization/Contacts/Contacts.test.tsx +++ b/src/components/Settings/Organization/Contacts/Contacts.test.tsx @@ -1,6 +1,6 @@ -import { PropsWithChildren } 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 { VirtuosoMockContext } from 'react-virtuoso'; import TestRouter from '__tests__/util/TestRouter'; @@ -36,14 +36,18 @@ const setSearch = jest.fn().mockImplementation((value) => { search = value; }); const clearFilters = jest.fn(); -const selectedOrganizationId = 'org111'; -const Components = ({ children }: PropsWithChildren) => ( +const Components = ({ + children, + selectedOrganizationId = 'org111', + selectedOrganizationName = 'Cru', +}) => ( { }); it('should show default screen', async () => { + const { getByText } = render( + + > + + + , + ); + + await waitFor(() => { + expect(getByText('Start by adding search filters')).toBeInTheDocument(); + }); + }); + + it('should show message when not contacts are found', async () => { const { getByText } = render( { await waitFor(() => { expect( - getByText( - 'Unfortunately none of the contacts match your current search or filters.', - ), - ).toBeInTheDocument(); - expect( - getByText('Try searching for a different keyword or organization.'), + getByText('No contacts match your search filters'), ).toBeInTheDocument(); }); }); @@ -141,7 +156,7 @@ describe('Contacts', () => { await waitFor(() => { expect( - queryByText('Try searching for a different keyword or organization.'), + queryByText('No contacts match your search filters'), ).not.toBeInTheDocument(); }); @@ -149,4 +164,32 @@ describe('Contacts', () => { expect(getByText('Lastname, Firstnames')).toBeInTheDocument(); }); }); + + it('should remove contact when anonymized', async () => { + const mutationSpy = jest.fn(); + const { getByText, queryByText, getAllByRole, getByRole } = render( + + + onCall={mutationSpy} + mocks={{ + ...SearchOrganizationsContactsMock, + }} + > + + + , + ); + + await waitFor(() => { + expect(getByText('Lastname, Firstnames')).toBeInTheDocument(); + }); + userEvent.click(getAllByRole('button', { name: 'Anonymize' })[0]); + userEvent.click(getByRole('button', { name: 'Yes' })); + + await waitFor(() => { + expect(queryByText('Lastname, Firstnames')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/Settings/Organization/Contacts/Contacts.tsx b/src/components/Settings/Organization/Contacts/Contacts.tsx index a495b4b64..5bd47fdb0 100644 --- a/src/components/Settings/Organization/Contacts/Contacts.tsx +++ b/src/components/Settings/Organization/Contacts/Contacts.tsx @@ -1,7 +1,6 @@ import { useContext, useEffect, useRef, useState } from 'react'; -import { mdiHome } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, Typography } from '@mui/material'; +import { Search } from '@mui/icons-material'; +import { Box, Button, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { OrganizationsContext, @@ -19,9 +18,12 @@ export const Contacts: React.FC = () => { const [infiniteListHeight, setInfiniteListHeight] = useState( null, ); - const { selectedOrganizationId, search } = useContext( - OrganizationsContext, - ) as OrganizationsContextType; + const { + selectedOrganizationId, + search, + clearFilters, + selectedOrganizationName, + } = useContext(OrganizationsContext) as OrganizationsContextType; const { data, loading, fetchMore } = useSearchOrganizationsContactsQuery({ variables: { @@ -51,53 +53,81 @@ export const Contacts: React.FC = () => { return ( {loading && } - { - return contact ? ( - - ) : null; - }} - endReached={() => - pagination && - pagination.page < pagination.totalPages && - fetchMore({ - variables: { - input: { - organizationId: selectedOrganizationId, - search: search, - pageNumber: pagination.page + 1, + {!loading && !(search && selectedOrganizationId) && ( + + + + + {t('Start by adding search filters')} + + + {t( + 'First, filter by at least one organization that you administrate.', + )} + + + {t( + "You'll also need to search by contact name, email address, phone number, or partner number.", + )} + + + + )} + {search && selectedOrganizationId && ( + { + return contact ? ( + + ) : null; + }} + endReached={() => + pagination && + pagination.page < pagination.totalPages && + fetchMore({ + variables: { + input: { + organizationId: selectedOrganizationId, + search: search, + pageNumber: pagination.page + 1, + }, }, - }, - }) - } - EmptyPlaceholder={ - - - - - {t( - 'Unfortunately none of the contacts match your current search or filters.', - )} - - - {t('Try searching for a different keyword or organization.')} - - - - } - /> + }) + } + EmptyPlaceholder={ + + + + + {t('No contacts match your search filters')} + + + {t('Try searching for a different keyword or organization.')} + + + + + } + /> + )} ); }; diff --git a/src/components/Settings/Organization/Contacts/contactsMocks.ts b/src/components/Settings/Organization/Contacts/contactsMocks.ts index d09583074..9609415e2 100644 --- a/src/components/Settings/Organization/Contacts/contactsMocks.ts +++ b/src/components/Settings/Organization/Contacts/contactsMocks.ts @@ -1,6 +1,5 @@ export class ContactsMocks { contact = { - allowDeletion: true, id: '2f5d998f', name: 'Lastname, Firstnames', squareAvatar: 'https://stage.mpdx.org/images/avatar.png', @@ -37,15 +36,17 @@ export class ContactsMocks { accountListUsers: [ { id: '123456', - firstName: 'accountListFirstName', - lastName: 'accountListLastName', - emailAddresses: [], + userFirstName: 'accountListFirstName', + userLastName: 'accountListLastName', + userEmailAddresses: [ + { email: 'accountList.contactOwner@cru.org', primary: true }, + ], }, { id: '654321', - firstName: 'accountListFirstName2', - lastName: 'accountListLastName2', - emailAddresses: [], + userFirstName: 'accountListFirstName2', + userLastName: 'accountListLastName2', + userEmailAddresses: [], }, ], }, diff --git a/src/components/Shared/Lists/listsHelper.ts b/src/components/Shared/Lists/listsHelper.ts new file mode 100644 index 000000000..6594036b7 --- /dev/null +++ b/src/components/Shared/Lists/listsHelper.ts @@ -0,0 +1,12 @@ +import { List, ListItemText } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const StyledListItem = styled(ListItemText)(() => ({ + display: 'list-item', +})); + +export const StyledList = styled(List)(({ theme }) => ({ + listStyleType: 'disc', + paddingLeft: theme.spacing(4), + paddingTop: theme.spacing(0), +})); diff --git a/src/components/common/Modal/ActionButtons/ActionButtons.tsx b/src/components/common/Modal/ActionButtons/ActionButtons.tsx index f032ce222..58a2e685a 100644 --- a/src/components/common/Modal/ActionButtons/ActionButtons.tsx +++ b/src/components/common/Modal/ActionButtons/ActionButtons.tsx @@ -7,7 +7,7 @@ const StyledButton = styled(Button)(() => ({ fontWeight: 700, })); -interface ActionButtonProps { +export interface ActionButtonProps { onClick?: () => void; size?: ButtonProps['size']; color?: ButtonProps['color']; diff --git a/src/components/common/Modal/Confirmation/Confirmation.tsx b/src/components/common/Modal/Confirmation/Confirmation.tsx index 507b47621..9515de9a0 100644 --- a/src/components/common/Modal/Confirmation/Confirmation.tsx +++ b/src/components/common/Modal/Confirmation/Confirmation.tsx @@ -9,6 +9,7 @@ import { import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { + ActionButtonProps, CancelButton, SubmitButton, } from 'src/components/common/Modal/ActionButtons/ActionButtons'; @@ -17,18 +18,25 @@ import Modal from '../Modal'; const LoadingIndicator = styled(CircularProgress)(({ theme }) => ({ margin: theme.spacing(0, 1, 0, 0), })); +const StyledDialogContentText = styled(DialogContentText)(({ theme }) => ({ + color: theme.palette.cruGrayDark.main, +})); export interface ConfirmationProps { isOpen: boolean; title: string; - message: ReactNode; + subtitle?: ReactNode | string; + message?: ReactNode; mutation: () => Promise; + confirmButtonProps?: ActionButtonProps; handleClose: () => void; } export const Confirmation: React.FC = ({ isOpen, title, + subtitle, + confirmButtonProps, message, mutation, handleClose, @@ -59,9 +67,23 @@ export const Confirmation: React.FC = ({ ) : ( - {message} + <> + {subtitle && ( + + {subtitle} + + )} + {message && ( + {message} + )} + )} + {t('No')} @@ -69,7 +91,8 @@ export const Confirmation: React.FC = ({ {t('Yes')}