From d0c38ed8b669db8582ddc380c5ad0e9e8887c3f5 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 15:44:50 -0400 Subject: [PATCH 01/18] 1. Set up Appeal Detail pages - Creating Appeal Detail page --- src/components/Tool/Appeal/AppealsDetailsPage.tsx | 7 +++++++ .../Tool/Appeal/DynamicAppealsDetailsPage.tsx | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/components/Tool/Appeal/AppealsDetailsPage.tsx create mode 100644 src/components/Tool/Appeal/DynamicAppealsDetailsPage.tsx diff --git a/src/components/Tool/Appeal/AppealsDetailsPage.tsx b/src/components/Tool/Appeal/AppealsDetailsPage.tsx new file mode 100644 index 000000000..278f61261 --- /dev/null +++ b/src/components/Tool/Appeal/AppealsDetailsPage.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const AppealsDetailsPage: React.FC = () => { + return <>Details Page; +}; + +export default AppealsDetailsPage; diff --git a/src/components/Tool/Appeal/DynamicAppealsDetailsPage.tsx b/src/components/Tool/Appeal/DynamicAppealsDetailsPage.tsx new file mode 100644 index 000000000..2e9ac2f6b --- /dev/null +++ b/src/components/Tool/Appeal/DynamicAppealsDetailsPage.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadAppealsDetailsPage = () => + import( + /* webpackChunkName: "AppealsDetailsPage" */ './AppealsDetailsPage' + ).then((AppealsDetailsPage) => AppealsDetailsPage); + +export const DynamicAppealsDetailsPage = dynamic(preloadAppealsDetailsPage, { + loading: DynamicComponentPlaceholder, +}); From 9a6722e0f5bf5bab2b5cbea6e1e91c4d57fae324 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 15:46:17 -0400 Subject: [PATCH 02/18] 1. Set up Appeal Context --- .../[accountListId]/contacts/Contacts.graphql | 10 +- .../ContactsContext/ContactsContext.tsx | 4 +- .../AppealsContext/AppealsContext.test.tsx | 336 +++++++++++++++++ .../Appeal/AppealsContext/AppealsContext.tsx | 349 ++++++++++++++++++ .../Appeal/AppealsContext/contacts.graphql | 27 ++ 5 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx create mode 100644 src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx create mode 100644 src/components/Tool/Appeal/AppealsContext/contacts.graphql diff --git a/pages/accountLists/[accountListId]/contacts/Contacts.graphql b/pages/accountLists/[accountListId]/contacts/Contacts.graphql index 794ac7a9c..3ecae4e4a 100644 --- a/pages/accountLists/[accountListId]/contacts/Contacts.graphql +++ b/pages/accountLists/[accountListId]/contacts/Contacts.graphql @@ -11,9 +11,7 @@ query Contacts( first: $first ) { nodes { - id - avatar - ...ContactRow + ...contactFragment } totalCount pageInfo { @@ -26,6 +24,12 @@ query Contacts( } } +fragment contactFragment on Contact { + id + avatar + ...ContactRow +} + query ContactFilters($accountListId: ID!) { accountList(id: $accountListId) { id diff --git a/src/components/Contacts/ContactsContext/ContactsContext.tsx b/src/components/Contacts/ContactsContext/ContactsContext.tsx index c4af76d21..d110d769a 100644 --- a/src/components/Contacts/ContactsContext/ContactsContext.tsx +++ b/src/components/Contacts/ContactsContext/ContactsContext.tsx @@ -85,7 +85,7 @@ export type ContactsType = { export const ContactsContext = React.createContext(null); -interface Props { +export interface ContactsContextProps { children?: React.ReactNode; urlFilters?: any; activeFilters: ContactFilterSetInput; @@ -122,7 +122,7 @@ export const ContactsContextSavedFilters = ( ); }; -export const ContactsProvider: React.FC = ({ +export const ContactsProvider: React.FC = ({ children, urlFilters, activeFilters, diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx new file mode 100644 index 000000000..5a7f27a32 --- /dev/null +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx @@ -0,0 +1,336 @@ +import React, { useContext } from 'react'; +import { Box, Button, Typography } from '@mui/material'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ContactFiltersQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { GetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; +import { ContactsContextSavedFilters as AppealsContextSavedFilters } from 'src/components/Contacts/ContactsContext/ContactsContext'; +import { + ListHeaderCheckBoxState, + TableViewModeEnum, +} from 'src/components/Shared/Header/ListHeader'; +import { useMassSelection } from 'src/hooks/useMassSelection'; +import theme from 'src/theme'; +import { AppealsContext, AppealsType } from './AppealsContext'; + +const accountListId = 'account-list-1'; +const appealIdentifier = 'appeal-Id-1'; +const contactId = 'contact-id'; +const push = jest.fn(); +const isReady = true; + +jest.mock('src/hooks/useMassSelection'); + +(useMassSelection as jest.Mock).mockReturnValue({ + selectionType: ListHeaderCheckBoxState.Unchecked, + isRowChecked: jest.fn(), + toggleSelectAll: jest.fn(), + toggleSelectionById: jest.fn(), +}); + +const mockEnqueue = jest.fn(); + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const TestRender: React.FC = () => { + const { + viewMode, + handleViewModeChange, + userOptionsLoading, + appealId, + contactDetailsId, + setContactFocus, + } = useContext(AppealsContext) as AppealsType; + return ( + + {!userOptionsLoading ? ( + <> + appealId: {appealId} + contactDetailsId: {contactDetailsId} + {viewMode} + + + + + + + + ) : ( + <>Loading + )} + + ); +}; + +const TestRenderContactsFilters: React.FC = () => { + const { filterData } = useContext(AppealsContext) as AppealsType; + const savedFilters = AppealsContextSavedFilters(filterData, accountListId); + + return ( + + {!!savedFilters.length && ( +
{savedFilters[0]?.value}
+ )} +
+ ); +}; + +describe('ContactsPageContext', () => { + it('should open a contact and the URL should reflect the opened contact', async () => { + const { getByText } = render( + + + + mocks={{ + GetUserOptions: { + userOptions: [ + { + id: 'test-id', + key: 'contacts_view', + value: 'flows', + }, + ], + }, + }} + > + + + + + + , + ); + + expect(getByText('Loading')).toBeInTheDocument(); + await waitFor(() => + expect(getByText(`appealId: ${appealIdentifier}`)).toBeInTheDocument(), + ); + userEvent.click(getByText('Open Contact')); + await waitFor(() => + expect(getByText(`contactDetailsId: ${contactId}`)).toBeInTheDocument(), + ); + await waitFor(() => + expect(push).toHaveBeenCalledWith({ + pathname: + '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows/contact-id', + query: {}, + }), + ); + }); + + it('should switch views to flows and back to list', async () => { + const { getByText } = render( + + + + mocks={{ + GetUserOptions: { + userOptions: [ + { + id: 'test-id', + key: 'contacts_view', + value: 'flows', + }, + ], + }, + }} + > + + + + + + , + ); + await waitFor(() => expect(getByText('Flows Button')).toBeInTheDocument()); + userEvent.click(getByText('Flows Button')); + await waitFor(() => expect(getByText('flows')).toBeInTheDocument()); + await waitFor(() => + expect(push).toHaveBeenCalledWith({ + pathname: + '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows', + query: {}, + }), + ); + + userEvent.click(getByText('List Button')); + await waitFor(() => expect(getByText('list')).toBeInTheDocument()); + await waitFor(() => + expect(push).toHaveBeenCalledWith({ + pathname: '/accountLists/account-list-1/tools/appeals/appeal-Id-1/list', + query: {}, + }), + ); + }); + + it('should redirect back to flows view on contact page', async () => { + const { getByText } = render( + + + + mocks={{ + GetUserOptions: { + userOptions: [ + { + id: 'test-id', + key: 'contacts_view', + value: 'flows', + }, + ], + }, + }} + > + + + + + + , + ); + expect(getByText('Loading')).toBeInTheDocument(); + await waitFor(() => + expect(getByText(`contactDetailsId: ${contactId}`)).toBeInTheDocument(), + ); + + await waitFor(() => expect(getByText('Close Contact')).toBeInTheDocument()); + userEvent.click(getByText('Close Contact')); + + await waitFor(() => + expect(push).toHaveBeenCalledWith({ + pathname: + '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows', + query: {}, + }), + ); + }); + + it('Saved filters with correct JSON', async () => { + const { queryByTestId } = render( + + + + mocks={{ + ContactFilters: { + userOptions: [ + { + id: '123', + key: 'saved_contacts_filter_My_Cool_Filter', + value: `{"any_tags":false,"account_list_id":"${accountListId}","params":{"status": "true"},"tags":null,"exclude_tags":null,"wildcard_search":""}`, + }, + ], + }, + }} + > + + + + + + , + ); + await waitFor(() => + expect(queryByTestId('savedfilters-testid')).toBeInTheDocument(), + ); + }); + + it('Saved filters with incorrect JSON', async () => { + const { queryByTestId } = render( + + + + mocks={{ + ContactFilters: { + userOptions: [ + { + id: '123', + key: 'saved_contacts_filter_My_Cool_Filter', + value: `{"any_tags":false,"account_list_id":"${accountListId}","params":{"status" error },"tags":null,"exclude_tags":null,"wildcard_search":""}`, + }, + ], + }, + }} + > + + + + + + , + ); + await waitFor(() => + expect(queryByTestId('savedfilters-testid')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx new file mode 100644 index 000000000..6f659aabf --- /dev/null +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx @@ -0,0 +1,349 @@ +import { useRouter } from 'next/router'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { debounce, omit } from 'lodash'; +import { useContactFiltersQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { PageEnum } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { useUpdateUserOptionsMutation } from 'src/components/Contacts/ContactFlow/ContactFlowSetup/UpdateUserOptions.generated'; +import { useGetUserOptionsQuery } from 'src/components/Contacts/ContactFlow/GetUserOptions.generated'; +import { + ContactsContextSavedFilters as AppealsContextSavedFilters, + ContactsContextProps, + ContactsType, +} from 'src/components/Contacts/ContactsContext/ContactsContext'; +import { UserOptionFragment } from 'src/components/Shared/Filters/FilterPanel.generated'; +import { useGetIdsForMassSelectionQuery } from 'src/hooks/GetIdsForMassSelection.generated'; +import { useAccountListId } from 'src/hooks/useAccountListId'; +import { useMassSelection } from 'src/hooks/useMassSelection'; +import { sanitizeFilters } from 'src/lib/sanitizeFilters'; +import { useContactsQuery } from './contacts.generated'; + +export enum AppealStatusEnum { + Excluded = 'excluded', + Asked = 'asked', + NotReceived = 'not_received', + ReceivedNotProcessed = 'received_not_processed', + Processed = 'processed', +} + +export enum TableViewModeEnum { + List = 'list', + Flows = 'flows', +} + +export interface AppealsType + extends Omit< + ContactsType, + | 'selected' + | 'setSelected' + | 'mapRef' + | 'panTo' + | 'mapData' + | 'contactsQueryResult' + | 'setContactFocus' + | 'setViewMode' + > { + setViewMode: (mode: TableViewModeEnum) => void; + setContactFocus: (id?: string | undefined, openDetails?: boolean) => void; + contactsQueryResult: ReturnType; + appealId: string | undefined; + page: PageEnum | undefined; +} + +export const AppealsContext = React.createContext(null); + +interface AppealsContextProps extends ContactsContextProps { + appealId: string | undefined; + page?: PageEnum; +} + +export const AppealsProvider: React.FC = ({ + children, + urlFilters, + activeFilters, + setActiveFilters, + starredFilter, + setStarredFilter, + filterPanelOpen, + setFilterPanelOpen, + appealId, + contactId, + searchTerm, + page, +}) => { + const accountListId = useAccountListId() ?? ''; + const router = useRouter(); + const { query, push, replace, isReady, pathname } = router; + + const [contactDetailsOpen, setContactDetailsOpen] = useState(false); + const [contactDetailsId, setContactDetailsId] = useState(); + const [viewMode, setViewMode] = useState( + TableViewModeEnum.Flows, + ); + + const sanitizedFilters = useMemo( + () => sanitizeFilters(activeFilters), + [activeFilters], + ); + + if (contactId !== undefined && !Array.isArray(contactId)) { + throw new Error('contactId should be an array or undefined'); + } + + //User options for display view + const { loading: userOptionsLoading } = useGetUserOptionsQuery({ + onCompleted: ({ userOptions }) => { + if (contactId?.includes('list')) { + setViewMode(TableViewModeEnum.List); + } else { + setViewMode( + (userOptions.find((option) => option.key === 'contacts_view') + ?.value as TableViewModeEnum) || TableViewModeEnum.Flows, + ); + } + }, + skip: page === PageEnum.InitialPage, + }); + + const contactsFilters = useMemo( + () => ({ + ...sanitizedFilters, + ...starredFilter, + wildcardSearch: searchTerm as string, + ids: [], + appeal: [appealId || ''], + }), + [sanitizedFilters, starredFilter, searchTerm], + ); + + const contactsQueryResult = useContactsQuery({ + variables: { + accountListId: accountListId ?? '', + contactsFilters, + first: 25, + }, + skip: !accountListId || page === PageEnum.InitialPage, + }); + const { data, loading } = contactsQueryResult; + + //#region Mass Actions + + const contactCount = data?.contacts.totalCount ?? 0; + const { data: allContacts } = useGetIdsForMassSelectionQuery({ + variables: { + accountListId, + first: contactCount, + contactsFilters, + }, + skip: contactCount === 0 || page === PageEnum.InitialPage, + }); + const allContactIds = useMemo( + () => allContacts?.contacts.nodes.map((contact) => contact.id) ?? [], + [allContacts], + ); + + const { + ids, + selectionType, + isRowChecked, + toggleSelectAll, + toggleSelectionById, + deselectAll, + } = useMassSelection( + contactCount, + allContactIds, + activeFilters, + searchTerm as string, + starredFilter, + ); + //#endregion + + useEffect(() => { + if (isReady && contactId) { + if ( + contactId[contactId.length - 1] !== 'flows' && + contactId[contactId.length - 1] !== 'list' + ) { + setContactDetailsId(contactId[contactId.length - 1]); + setContactDetailsOpen(true); + } + } else if (isReady && !contactId) { + setContactDetailsId(''); + setContactDetailsOpen(false); + } + }, [isReady, contactId]); + + useEffect(() => { + if (userOptionsLoading) { + return; + } + + setContactFocus( + contactId && + contactId[contactId.length - 1] !== 'flows' && + contactId[contactId.length - 1] !== 'list' + ? contactId[contactId.length - 1] + : undefined, + contactId ? true : false, + ); + }, [loading, viewMode]); + + const { data: filterData, loading: filtersLoading } = useContactFiltersQuery({ + variables: { accountListId: accountListId ?? '' }, + skip: !accountListId || page === PageEnum.InitialPage, + context: { + doNotBatch: true, + }, + }); + + const toggleFilterPanel = () => { + setFilterPanelOpen(!filterPanelOpen); + }; + + const handleClearAll = () => { + setSearchTerm(''); + }; + + const savedFilters: UserOptionFragment[] = AppealsContextSavedFilters( + filterData, + accountListId, + ); + + const isFiltered = + Object.keys(urlFilters ?? {}).length > 0 || + Object.values(urlFilters ?? {}).some( + (filter) => filter !== ([] as Array), + ); + //#endregion + + //#region User Actions + const setContactFocus = (id?: string, openDetails = true) => { + if (page === PageEnum.InitialPage) { + return; + } + const { + accountListId: _accountListId, + contactId: _contactId, + appealId: _appealId, + ...filteredQuery + } = query; + if (urlFilters && urlFilters.ids) { + const newFilters = omit(activeFilters, 'ids'); + if (Object.keys(newFilters).length > 0) { + filteredQuery.filters = encodeURI(JSON.stringify(newFilters)); + } else { + delete filteredQuery['filters']; + } + } + + let pathname = ''; + pathname = `/accountLists/${accountListId}/tools/appeals`; + if (appealId) { + pathname += `/${appealId}`; + } + if (viewMode === TableViewModeEnum.Flows) { + pathname += '/flows'; + } else if (viewMode === TableViewModeEnum.List) { + pathname += '/list'; + } + if (id) { + pathname += `/${id}`; + } + + push({ + pathname, + query: filteredQuery, + }); + if (openDetails) { + id && setContactDetailsId(id); + setContactDetailsOpen(!!id); + } + }; + const setSearchTerm = useCallback( + debounce((searchTerm: string) => { + const { searchTerm: _, ...oldQuery } = query; + if (searchTerm !== '') { + replace({ + pathname, + query: { + ...oldQuery, + accountListId, + ...(searchTerm && { searchTerm }), + }, + }); + } else { + replace({ + pathname, + query: { + ...oldQuery, + accountListId, + }, + }); + } + }, 500), + [accountListId], + ); + + const handleViewModeChange = (_, view: string) => { + setViewMode(view as TableViewModeEnum); + updateOptions(view); + }; + //#endregion + + //#region JSX + + const [updateUserOptions] = useUpdateUserOptionsMutation(); + + const updateOptions = async (view: string): Promise => { + await updateUserOptions({ + variables: { + key: 'contacts_view', + value: view, + }, + }); + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/Tool/Appeal/AppealsContext/contacts.graphql b/src/components/Tool/Appeal/AppealsContext/contacts.graphql new file mode 100644 index 000000000..9579da6b3 --- /dev/null +++ b/src/components/Tool/Appeal/AppealsContext/contacts.graphql @@ -0,0 +1,27 @@ +query Contacts( + $accountListId: ID! + $contactsFilters: ContactFilterSetInput + $after: String + $first: Int +) { + contacts( + accountListId: $accountListId + contactsFilter: $contactsFilters + after: $after + first: $first + ) { + nodes { + name + id + pledgeAmount + pledgeCurrency + pledgeFrequency + pledgeReceived + } + pageInfo { + hasNextPage + endCursor + } + totalCount + } +} From e4608e17a5915165b22fa160289d90b86f14acb0 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 15:46:48 -0400 Subject: [PATCH 03/18] 1. Create Appeal Detail pages --- .../[accountListId]/tools/appeals.page.tsx | 81 ------- .../tools/appeals/AppealsWrapper.tsx | 104 +++++++++ .../tools/appeals/[[...appealId]].page.tsx | 43 ++++ .../tools/appeals/[appealId].page.tsx | 83 ------- .../tools/appeals/appealDetails.test.tsx | 212 ++++++++++++++++++ 5 files changed, 359 insertions(+), 164 deletions(-) delete mode 100644 pages/accountLists/[accountListId]/tools/appeals.page.tsx create mode 100644 pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx create mode 100644 pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx delete mode 100644 pages/accountLists/[accountListId]/tools/appeals/[appealId].page.tsx create mode 100644 pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx diff --git a/pages/accountLists/[accountListId]/tools/appeals.page.tsx b/pages/accountLists/[accountListId]/tools/appeals.page.tsx deleted file mode 100644 index 6c9405c2f..000000000 --- a/pages/accountLists/[accountListId]/tools/appeals.page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { Box, Divider, Grid, Theme, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; -import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import Loading from 'src/components/Loading'; -import AddAppealForm from 'src/components/Tool/Appeal/AddAppealForm'; -import Appeals from 'src/components/Tool/Appeal/Appeals'; -import { ToolsWrapper } from './ToolsWrapper'; -import { useToolsHelper } from './useToolsHelper'; - -const useStyles = makeStyles()((theme: Theme) => ({ - container: { - padding: `${theme.spacing(3)} ${theme.spacing(3)} 0`, - display: 'flex', - }, - outer: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'center', - width: '100%', - }, - loadingIndicator: { - margin: theme.spacing(0, 1, 0, 0), - }, -})); - -const AppealsPage: React.FC = () => { - const { t } = useTranslation(); - const { classes } = useStyles(); - - const { accountListId } = useToolsHelper(); - const pageUrl = 'tools/fixCommitmentInfo'; - - return ( - - {accountListId ? ( - - - - - {t('Appeals')} - - - - - {t( - 'You can track recurring support goals or special need ' + - 'support goals through our appeals wizard. Track the ' + - 'recurring support you raise for an increase ask for example, ' + - 'or special gifts you raise for a summer mission trip or your ' + - 'new staff special gift goal.', - )} - - - - - - - - - - - - - - - ) : ( - - )} - - ); -}; - -export const getServerSideProps = loadSession; - -export default AppealsPage; diff --git a/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx b/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx new file mode 100644 index 000000000..1a4bd61b7 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx @@ -0,0 +1,104 @@ +import { useRouter } from 'next/router'; +import React, { useEffect, useMemo, useState } from 'react'; +import { AppealsProvider } from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; +import { ContactFilterSetInput } from 'src/graphql/types.generated'; +import { suggestArticles } from 'src/lib/helpScout'; +import { sanitizeFilters } from 'src/lib/sanitizeFilters'; + +interface Props { + children?: React.ReactNode; +} + +export enum PageEnum { + InitialPage = 'InitialPage', + DetailsPage = 'DetailsPage', + ContactsPage = 'ContactsPage', +} + +export const AppealsWrapper: React.FC = ({ children }) => { + const router = useRouter(); + const { query, replace, pathname, isReady } = router; + + const urlFilters = + query?.filters && JSON.parse(decodeURI(query.filters as string)); + + const [activeFilters, setActiveFilters] = useState( + urlFilters ?? {}, + ); + const [starredFilter, setStarredFilter] = useState({}); + const [filterPanelOpen, setFilterPanelOpen] = useState(false); + const [page, setPage] = useState(); + const [appealId, setAppealId] = useState(undefined); + const [contactId, setContactId] = useState( + undefined, + ); + const sanitizedFilters = useMemo( + () => sanitizeFilters(activeFilters), + [activeFilters], + ); + + const { appealId: appealIdParams, searchTerm } = query; + + useEffect(() => { + // TODO: Fix these suggested Articles + suggestArticles( + appealIdParams + ? 'HS_CONTACTS_CONTACT_SUGGESTIONS' + : 'HS_CONTACTS_SUGGESTIONS', + ); + if (appealIdParams === undefined) { + setPage(PageEnum.InitialPage); + return; + } + const length = appealIdParams.length; + setAppealId(appealIdParams[0]); + if (length === 1) { + setPage(PageEnum.DetailsPage); + } else if ( + length === 2 && + (appealIdParams[1].toLowerCase() === 'flows' || + appealIdParams[1].toLowerCase() === 'list') + ) { + setPage(PageEnum.DetailsPage); + setContactId(appealIdParams); + } else if (length > 2) { + setPage(PageEnum.ContactsPage); + setContactId(appealIdParams); + } + }, [appealIdParams]); + + useEffect(() => { + if (!isReady) { + return; + } + + const { filters: _, ...oldQuery } = query; + replace({ + pathname, + query: { + ...oldQuery, + ...(Object.keys(sanitizedFilters).length + ? { filters: encodeURI(JSON.stringify(sanitizedFilters)) } + : undefined), + }, + }); + }, [sanitizedFilters, isReady]); + + return ( + + {children} + + ); +}; diff --git a/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx b/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx new file mode 100644 index 000000000..59d42d5f0 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx @@ -0,0 +1,43 @@ +import React, { ReactElement, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { loadSession } from 'pages/api/utils/pagePropsHelpers'; +import { + AppealsContext, + AppealsType, +} from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; +import { DynamicAppealsDetailsPage } from 'src/components/Tool/Appeal/DynamicAppealsDetailsPage'; +import { DynamicAppealsInitialPage } from 'src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage'; +import { ToolsWrapper } from '../ToolsWrapper'; +import { AppealsWrapper, PageEnum } from './AppealsWrapper'; + +const Appeals = (): ReactElement => { + const { t } = useTranslation(); + const { page } = useContext(AppealsContext) as AppealsType; + const pageUrl = 'appeals'; + + return ( + + <> + {page === PageEnum.InitialPage && } + + {(page === PageEnum.DetailsPage || page === PageEnum.ContactsPage) && ( + + )} + + + ); +}; + +const AppealsPage: React.FC = () => ( + + + +); + +export default AppealsPage; + +export const getServerSideProps = loadSession; diff --git a/pages/accountLists/[accountListId]/tools/appeals/[appealId].page.tsx b/pages/accountLists/[accountListId]/tools/appeals/[appealId].page.tsx deleted file mode 100644 index 19330855f..000000000 --- a/pages/accountLists/[accountListId]/tools/appeals/[appealId].page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Head from 'next/head'; -import React, { ReactElement, useState } from 'react'; -import { Box, Container, Theme } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; -import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { AppealProvider } from 'src/components/Tool/Appeal/AppealContextProvider/AppealContextProvider'; -import AppealDetailsMain from 'src/components/Tool/Appeal/AppealDetails/AppealDetailsMain'; -import AppealDrawer from 'src/components/Tool/Appeal/AppealDrawer/AppealDrawer'; -import useGetAppSettings from 'src/hooks/useGetAppSettings'; -import { testAppeal2 } from './testAppeal'; - -const useStyles = makeStyles()((theme: Theme) => ({ - container: { - padding: theme.spacing(3), - marginRight: theme.spacing(2), - display: 'flex', - [theme.breakpoints.down('lg')]: { - paddingLeft: theme.spacing(4), - marginRight: theme.spacing(3), - }, - [theme.breakpoints.down('md')]: { - paddingLeft: theme.spacing(5), - marginRight: theme.spacing(2), - }, - [theme.breakpoints.down('sm')]: { - paddingLeft: theme.spacing(6), - }, - }, - outer: { - display: 'flex', - flexDirection: 'row', - minWidth: '100vw', - }, - loadingIndicator: { - margin: theme.spacing(0, 1, 0, 0), - }, -})); - -const AppealIdPage = (): ReactElement => { - const { t } = useTranslation(); - const [isNavListOpen, setNavListOpen] = useState(true); - const { classes } = useStyles(); - const { appName } = useGetAppSettings(); - - const handleNavListToggle = () => { - setNavListOpen(!isNavListOpen); - }; - - return ( - <> - - - {appName} | {t('Appeals')} - - - - - - - - - - - - - - ); -}; - -export const getServerSideProps = loadSession; - -export default AppealIdPage; diff --git a/pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx b/pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx new file mode 100644 index 000000000..d43eb8d09 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ListHeaderCheckBoxState } from 'src/components/Shared/Header/ListHeader'; +import { AppealQuery } from 'src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.generated'; +import { ContactsQuery } from 'src/components/Tool/Appeal/AppealsContext/contacts.generated'; +import { + PledgeFrequencyEnum, + SendNewsletterEnum, + StatusEnum, +} from 'src/graphql/types.generated'; +import { useMassSelection } from 'src/hooks/useMassSelection'; +import theme from 'src/theme'; +import Appeal from './[[...appealId]].page'; + +const accountListId = 'account-list-1'; + +const defaultRouter = { + query: { accountListId }, + isReady: true, +}; + +const contact = { + id: '1', + name: 'Test Person', + avatar: 'img.png', + primaryAddress: null, + status: StatusEnum.PartnerFinancial, + pledgeAmount: 100, + pledgeFrequency: PledgeFrequencyEnum.Monthly, + pledgeCurrency: 'USD', + pledgeReceived: true, + lateAt: new Date().toISOString(), + sendNewsletter: SendNewsletterEnum.Both, + starred: false, + uncompletedTasksCount: 0, + people: { nodes: [] }, +}; + +const mockResponse = { + contacts: { + nodes: [contact], + totalCount: 1, + pageInfo: { endCursor: 'Mg', hasNextPage: false }, + }, + allContacts: { + totalCount: 1, + }, +}; + +const mockAppealResponse = { + appeal: { + amount: 4531, + amountCurrency: 'USD', + id: '9d660aed-1291-4c5b-874d-409a94b5ed3b', + name: 'End Of Year Gift', + pledgesAmountNotReceivedNotProcessed: 2000, + pledgesAmountProcessed: 50, + pledgesAmountReceivedNotProcessed: 50, + pledgesAmountTotal: 2115.93, + }, +}; + +jest.mock('src/hooks/useMassSelection'); + +(useMassSelection as jest.Mock).mockReturnValue({ + ids: [], + selectionType: ListHeaderCheckBoxState.Unchecked, + isRowChecked: jest.fn(), + toggleSelectAll: jest.fn(), + toggleSelectionById: jest.fn(), +}); + +const mockEnqueue = jest.fn(); + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = ({ router = defaultRouter }: { router?: object }) => ( + + + + + mocks={{ + Contacts: mockResponse, + Appeal: mockAppealResponse, + }} + > + + + + + + + +); + +describe('Appeal navigation', () => { + it('should show initial appeal page', async () => { + const { getByText, getAllByText } = render(); + + await waitFor(() => + expect(getByText('Primary Appeal')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(getAllByText('Add Appeal')[0]).toBeInTheDocument(), + ); + }); + + it('should show list detail appeal page and open filters', async () => { + const { getByText, findByTestId, queryByText, getByRole, queryByRole } = + render( + , + ); + + await waitFor(() => { + expect(queryByText('Primary Appeal')).not.toBeInTheDocument(); + expect(queryByText('Add Appeal')).not.toBeInTheDocument(); + }); + + await waitFor(() => expect(getByText('Test Person')).toBeInTheDocument()); + expect(await findByTestId('rowButton')).toHaveTextContent(contact.name); + + expect(queryByRole('heading', { name: 'Given' })).not.toBeInTheDocument(); + expect( + queryByRole('heading', { name: 'Received' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('heading', { name: 'Committed' }), + ).not.toBeInTheDocument(); + + userEvent.click(getByRole('img', { name: 'Toggle Filter Panel' })); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Given' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Received' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Committed' })).toBeInTheDocument(); + + expect( + getByRole('heading', { name: 'Export to CSV' }), + ).toBeInTheDocument(); + expect( + getByRole('heading', { name: 'Export Emails' }), + ).toBeInTheDocument(); + }); + }); + + it('should show flows detail appeal page and open filters', async () => { + const { queryByText, getByRole } = render( + , + ); + + await waitFor(() => { + expect(queryByText('Primary Appeal')).not.toBeInTheDocument(); + expect(queryByText('Add Appeal')).not.toBeInTheDocument(); + }); + + await waitFor(() => + expect(getByRole('heading', { name: 'Excluded' })).toBeInTheDocument(), + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Excluded' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Asked' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Committed' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Received‌⁠' })).toBeInTheDocument(); + expect(getByRole('heading', { name: 'Given' })).toBeInTheDocument(); + }); + + userEvent.click(getByRole('img', { name: 'Toggle Filter Panel' })); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Filter' })).toBeInTheDocument(); + expect( + getByRole('heading', { name: 'See More Filters' }), + ).toBeInTheDocument(); + }); + }); +}); From c74467c0679dbf27bd707e62c77d45655490d02e Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 15:56:21 -0400 Subject: [PATCH 04/18] 2. Clean up old files - Deleted old files and moved initial Page files into folder --- .../AppealContextProvider.tsx | 51 ------ .../AppealDetails/AppealDetailsAsked.tsx | 135 ---------------- .../AppealDetails/AppealDetailsCommitted.tsx | 140 ---------------- .../AppealDetails/AppealDetailsExcluded.tsx | 127 --------------- .../AppealDetails/AppealDetailsFlow.tsx | 96 ----------- .../AppealDetails/AppealDetailsFlowColumn.tsx | 151 ------------------ .../AppealDetails/AppealDetailsGiven.tsx | 140 ---------------- .../AppealDetails/AppealDetailsHeader.tsx | 149 ----------------- .../AppealDetails/AppealDetailsMain.tsx | 55 ------- .../AppealDetails/AppealDetailsNoData.tsx | 50 ------ .../AppealDetails/AppealDetailsReceived.tsx | 140 ---------------- .../Tool/Appeal/AppealDrawer/AppealDrawer.tsx | 60 ------- .../Appeal/AppealDrawer/AppealDrawerList.tsx | 120 -------------- .../AppealDrawer/Item/AppealDrawerItem.tsx | 97 ----------- .../Item/AppealDrawerItemButton.tsx | 54 ------- .../{ => InitialPage}/AddAppealForm.tsx | 4 +- .../Appeal/{ => InitialPage}/Appeal.test.tsx | 2 +- .../Tool/Appeal/{ => InitialPage}/Appeal.tsx | 8 +- .../Appeal/{ => InitialPage}/Appeals.test.tsx | 2 +- .../Tool/Appeal/{ => InitialPage}/Appeals.tsx | 0 .../Appeal/InitialPage/AppealsInitialPage.tsx | 65 ++++++++ .../ChangePrimaryAppeal.graphql | 0 .../{ => InitialPage}/CreateAppeal.graphql | 0 .../InitialPage/DynamicAppealsInitialPage.tsx | 11 ++ .../{ => InitialPage}/GetContactTags.graphql | 0 .../{ => InitialPage}/NoAppeal.test.tsx | 0 .../Appeal/{ => InitialPage}/NoAppeals.tsx | 2 +- 27 files changed, 85 insertions(+), 1574 deletions(-) delete mode 100644 src/components/Tool/Appeal/AppealContextProvider/AppealContextProvider.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsAsked.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsCommitted.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsExcluded.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsFlow.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsFlowColumn.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsGiven.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsHeader.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsMain.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsNoData.tsx delete mode 100644 src/components/Tool/Appeal/AppealDetails/AppealDetailsReceived.tsx delete mode 100644 src/components/Tool/Appeal/AppealDrawer/AppealDrawer.tsx delete mode 100644 src/components/Tool/Appeal/AppealDrawer/AppealDrawerList.tsx delete mode 100644 src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItem.tsx delete mode 100644 src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItemButton.tsx rename src/components/Tool/Appeal/{ => InitialPage}/AddAppealForm.tsx (99%) rename src/components/Tool/Appeal/{ => InitialPage}/Appeal.test.tsx (96%) rename src/components/Tool/Appeal/{ => InitialPage}/Appeal.tsx (94%) rename src/components/Tool/Appeal/{ => InitialPage}/Appeals.test.tsx (98%) rename src/components/Tool/Appeal/{ => InitialPage}/Appeals.tsx (100%) create mode 100644 src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx rename src/components/Tool/Appeal/{ => InitialPage}/ChangePrimaryAppeal.graphql (100%) rename src/components/Tool/Appeal/{ => InitialPage}/CreateAppeal.graphql (100%) create mode 100644 src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage.tsx rename src/components/Tool/Appeal/{ => InitialPage}/GetContactTags.graphql (100%) rename src/components/Tool/Appeal/{ => InitialPage}/NoAppeal.test.tsx (100%) rename src/components/Tool/Appeal/{ => InitialPage}/NoAppeals.tsx (96%) diff --git a/src/components/Tool/Appeal/AppealContextProvider/AppealContextProvider.tsx b/src/components/Tool/Appeal/AppealContextProvider/AppealContextProvider.tsx deleted file mode 100644 index f3dd1b5f6..000000000 --- a/src/components/Tool/Appeal/AppealContextProvider/AppealContextProvider.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { - ReactElement, - ReactNode, - createContext, - useContext, - useMemo, - useState, -} from 'react'; - -const initialState: StateType = { - display: 'default', - subDisplay: 'received', - selected: [], -}; - -export const AppealContext = createContext({ - appealState: { display: '', subDisplay: '', selected: [] }, - setAppealState: () => undefined, -}); - -export interface StateType { - display: string; - subDisplay: string; - selected: string[]; -} - -export interface AppealProviderContext { - appealState: StateType; - setAppealState: (props: StateType) => void; -} - -export const useAppealContext = (): AppealProviderContext => - useContext(AppealContext); - -const AppealProvider = ({ - children, -}: { - children: ReactNode; -}): ReactElement => { - const [appealState, setAppealState] = useState(initialState); - - const value = useMemo( - () => ({ appealState, setAppealState }), - [appealState, setAppealState], - ); - - return ( - {children} - ); -}; -export { AppealProvider }; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsAsked.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsAsked.tsx deleted file mode 100644 index 28d770155..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsAsked.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiDelete, mdiSquareEditOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton } from '@mui/material'; -import { DataGrid, GridSelectionModel } from '@mui/x-data-grid'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import i18n from 'src/lib/i18n'; -import theme from '../../../../theme'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealDetailsNoData from './AppealDetailsNoData'; - -const useStyles = makeStyles()(() => ({ - container: { - marginTop: 20, - height: '60vh', - '& .MuiDataGrid-row.Mui-odd': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - '& .MuiDataGrid-columnHeader': { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - height: '100%', - '& .MuiCheckbox-colorPrimary.Mui-checked': { - color: 'white', - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 600, - }, - }, - '& .MuiDataGrid-columnHeaderWrapper': { - '& .MuiDataGrid-cell': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: theme.palette.cruGrayMedium.main, - '& .MuiCheckbox-colorPrimary': { - color: 'white', - }, - }, - '& .MuiDataGrid-sortIcon': { - color: 'white', - }, - '& .MuiDataGrid-row.Mui-selected': { - backgroundColor: `${theme.palette.mpdxYellow.main} !important`, - '& .MuiCheckbox-colorPrimary': { - color: `${theme.palette.mpdxBlue.main} !important`, - }, - }, - '& .MuiSvgIcon-fontSizeSmall': { - color: 'white', - }, - }, - actionIconButton: { - color: theme.palette.cruGrayDark.main, - '&:hover': { color: theme.palette.mpdxBlue.main, cursor: 'pointer' }, - }, -})); - -const columns = [ - { - field: 'contact', - headerName: i18n.t('Contact'), - minWidth: 200, - flex: 1, - }, - { - field: 'regularGiving', - headerName: i18n.t('Regular Giving'), - minWidth: 200, - flex: 1, - }, - { - field: 'actions', - headerName: i18n.t('Actions'), - minWidth: 100, - flex: 0.3, - sortable: false, - renderCell: function renderActions() { - const { classes } = useStyles(); - return ( - <> - - - - - - - - ); - }, - }, -]; - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsAsked = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - const { appealState, setAppealState } = useAppealContext(); - - const rows = appeal.asked.map((contact, index) => ({ - id: index, - contact: contact.name, - regularGiving: `${contact.regularGiving?.toFixed(2)} ${contact.currency} ${ - contact.frequency ? contact.frequency : '' - }`, - })); - - const updateSelected = (e: GridSelectionModel): void => { - const temp: string[] = e.map( - (index) => rows[parseInt(index.toString())].contact, - ); - setAppealState({ ...appealState, selected: [...temp] }); - }; - return appeal.asked.length > 0 ? ( - - - - ) : ( - - ); -}; - -export default AppealDetailsAsked; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsCommitted.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsCommitted.tsx deleted file mode 100644 index 43f151984..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsCommitted.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiDelete, mdiSquareEditOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton } from '@mui/material'; -import { DataGrid, GridSelectionModel } from '@mui/x-data-grid'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import i18n from 'src/lib/i18n'; -import theme from '../../../../theme'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealDetailsNoData from './AppealDetailsNoData'; - -const useStyles = makeStyles()(() => ({ - container: { - marginTop: 20, - height: '60vh', - '& .MuiDataGrid-row.Mui-odd': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - '& .MuiDataGrid-columnHeader': { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - height: '100%', - '& .MuiCheckbox-colorPrimary.Mui-checked': { - color: 'white', - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 600, - }, - }, - '& .MuiDataGrid-columnHeaderWrapper': { - '& .MuiDataGrid-cell': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: theme.palette.cruGrayMedium.main, - '& .MuiCheckbox-colorPrimary': { - color: 'white', - }, - }, - '& .MuiDataGrid-sortIcon': { - color: 'white', - }, - '& .MuiDataGrid-row.Mui-selected': { - backgroundColor: `${theme.palette.mpdxYellow.main} !important`, - '& .MuiCheckbox-colorPrimary': { - color: `${theme.palette.mpdxBlue.main} !important`, - }, - }, - '& .MuiSvgIcon-fontSizeSmall': { - color: 'white', - }, - }, - actionIconButton: { - color: theme.palette.cruGrayDark.main, - '&:hover': { color: theme.palette.mpdxBlue.main, cursor: 'pointer' }, - }, -})); - -const columns = [ - { - field: 'contact', - headerName: i18n.t('Contact'), - minWidth: 200, - flex: 1, - }, - { - field: 'committed', - headerName: i18n.t('Amount Committed'), - minWidth: 200, - flex: 1, - }, - { - field: 'date', - headerName: i18n.t('Date Committed'), - minWidth: 200, - flex: 1, - }, - { - field: 'actions', - headerName: i18n.t('Actions'), - minWidth: 100, - flex: 0.3, - sortable: false, - renderCell: function renderActions() { - const { classes } = useStyles(); - return ( - <> - - - - - - - - ); - }, - }, -]; - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsCommitted = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - const { appealState, setAppealState } = useAppealContext(); - - const rows = appeal.committed.map((donation, index) => ({ - id: index, - contact: donation.name, - committed: `${donation.amount?.toFixed(2)} ${donation.currency}`, - date: donation.date, - })); - - const updateSelected = (e: GridSelectionModel): void => { - const temp: string[] = e.map( - (index) => rows[parseInt(index.toString())].contact, - ); - setAppealState({ ...appealState, selected: [...temp] }); - }; - return appeal.committed.length > 0 ? ( - - - - ) : ( - - ); -}; - -export default AppealDetailsCommitted; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsExcluded.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsExcluded.tsx deleted file mode 100644 index e76104f95..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsExcluded.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiAccountPlus } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton } from '@mui/material'; -import { DataGrid } from '@mui/x-data-grid'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import i18n from 'src/lib/i18n'; -import theme from '../../../../theme'; -import AppealDetailsNoData from './AppealDetailsNoData'; - -const useStyles = makeStyles()(() => ({ - container: { - marginTop: 20, - height: '60vh', - '& .MuiDataGrid-row.Mui-odd': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - '& .MuiDataGrid-columnHeader': { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - height: '100%', - '& .MuiCheckbox-colorPrimary.Mui-checked': { - color: 'white', - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 600, - }, - }, - '& .MuiDataGrid-columnHeaderWrapper': { - '& .MuiDataGrid-cell': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: theme.palette.cruGrayMedium.main, - '& .MuiCheckbox-colorPrimary': { - color: 'white', - }, - }, - '& .MuiDataGrid-sortIcon': { - color: 'white', - }, - '& .MuiDataGrid-row.Mui-selected': { - backgroundColor: `${theme.palette.mpdxYellow.main} !important`, - '& .MuiCheckbox-colorPrimary': { - color: `${theme.palette.mpdxBlue.main} !important`, - }, - }, - '& .MuiSvgIcon-fontSizeSmall': { - color: 'white', - }, - }, - actionIconButton: { - color: theme.palette.cruGrayDark.main, - '&:hover': { color: theme.palette.mpdxBlue.main, cursor: 'pointer' }, - }, -})); - -const columns = [ - { - field: 'contact', - headerName: i18n.t('Contact'), - minWidth: 200, - flex: 1, - }, - { - field: 'reason', - headerName: i18n.t('Reason'), - minWidth: 200, - flex: 1, - }, - { - field: 'regularGiving', - headerName: i18n.t('Regular Giving'), - minWidth: 200, - flex: 1, - }, - { - field: 'actions', - headerName: i18n.t('Actions'), - minWidth: 100, - flex: 0.25, - sortable: false, - renderCell: function renderActions() { - const { classes } = useStyles(); - return ( - - - - ); - }, - }, -]; - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsExcluded = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - - const rows = appeal.excluded.map((contact, index) => ({ - id: index, - contact: contact.name, - reason: contact.reason, - regularGiving: `${contact.regularGiving?.toFixed(2)} ${contact.currency} ${ - contact.frequency ? contact.frequency : '' - }`, - })); - - return appeal.excluded.length > 0 ? ( - - - - ) : ( - - ); -}; - -export default AppealDetailsExcluded; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlow.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlow.tsx deleted file mode 100644 index 67a3a7b44..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlow.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { ReactElement } from 'react'; -import { Box } from '@mui/material'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import theme from '../../../../theme'; -import AppealDetailsFlowColumn from './AppealDetailsFlowColumn'; - -interface Props { - appeal: TestAppeal; -} - -//TODO: Move rows mapping to a separate component -//TODO: Make better for responsive -//TODO: Change sidebar to filtering view - -const AppealDetailsFlow = ({ appeal }: Props): ReactElement => { - return ( - - - - - - - - - - - - - - - - - - ); -}; - -export default AppealDetailsFlow; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlowColumn.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlowColumn.tsx deleted file mode 100644 index df8c1fead..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsFlowColumn.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiDelete, mdiDotsVertical, mdiSquareEditOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import StarOutlineIcon from '@mui/icons-material/StarOutline'; -import { Box, Grid, Typography } from '@mui/material'; -import { TestContact } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import theme from '../../../../theme'; - -interface Props { - data: TestContact[]; - borderColor: string; - type: string; - title: string; -} - -const AppealsDetailFlowColumn = ({ - data, - borderColor, - type, - title, -}: Props): ReactElement => { - return ( - - - {title} - - {data.length} - - - - {data.map((entry: TestContact) => - type === 'default' ? ( - - - - - - - - - - {entry.name} - - - - - - {entry.amount?.toFixed(2)} {entry.currency} - - - {entry.date} - - - - - - - - ) : type === 'asked' ? ( - - - - - - - - - - {entry.name} - - - - - ) : ( - - - - - - - - - - {entry.name} - {entry.reason?.map((reason) => ( - - - {reason} - - ))} - - - - - ), - )} - - ); -}; - -export default AppealsDetailFlowColumn; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsGiven.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsGiven.tsx deleted file mode 100644 index 15207cb5e..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsGiven.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiDelete, mdiSquareEditOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton } from '@mui/material'; -import { DataGrid, GridSelectionModel } from '@mui/x-data-grid'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import i18n from 'src/lib/i18n'; -import theme from '../../../../theme'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealDetailsNoData from './AppealDetailsNoData'; - -const useStyles = makeStyles()(() => ({ - container: { - marginTop: 20, - height: '60vh', - '& .MuiDataGrid-row.Mui-odd': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - '& .MuiDataGrid-columnHeader': { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - height: '100%', - '& .MuiCheckbox-colorPrimary.Mui-checked': { - color: 'white', - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 600, - }, - }, - '& .MuiDataGrid-columnHeaderWrapper': { - '& .MuiDataGrid-cell': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: theme.palette.cruGrayMedium.main, - '& .MuiCheckbox-colorPrimary': { - color: 'white', - }, - }, - '& .MuiDataGrid-sortIcon': { - color: 'white', - }, - '& .MuiDataGrid-row.Mui-selected': { - backgroundColor: `${theme.palette.mpdxYellow.main} !important`, - '& .MuiCheckbox-colorPrimary': { - color: `${theme.palette.mpdxBlue.main} !important`, - }, - }, - '& .MuiSvgIcon-fontSizeSmall': { - color: 'white', - }, - }, - actionIconButton: { - color: theme.palette.cruGrayDark.main, - '&:hover': { color: theme.palette.mpdxBlue.main, cursor: 'pointer' }, - }, -})); - -const columns = [ - { - field: 'contact', - headerName: i18n.t('Contact'), - minWidth: 200, - flex: 1, - }, - { - field: 'donation', - headerName: i18n.t('Donation(s)'), - minWidth: 200, - flex: 1, - }, - { - field: 'date', - headerName: i18n.t('Date'), - minWidth: 200, - flex: 1, - }, - { - field: 'actions', - headerName: i18n.t('Actions'), - minWidth: 100, - flex: 0.3, - sortable: false, - renderCell: function renderActions() { - const { classes } = useStyles(); - return ( - <> - - - - - - - - ); - }, - }, -]; - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsGiven = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - const { appealState, setAppealState } = useAppealContext(); - - const rows = appeal.given.map((donation, index) => ({ - id: index, - contact: donation.name, - donation: `${donation.amount?.toFixed(2)} ${donation.currency}`, - date: donation.date, - })); - - const updateSelected = (e: GridSelectionModel): void => { - const temp: string[] = e.map( - (index) => rows[parseInt(index.toString())].contact, - ); - setAppealState({ ...appealState, selected: [...temp] }); - }; - return appeal.given.length > 0 ? ( - - - - ) : ( - - ); -}; - -export default AppealDetailsGiven; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsHeader.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsHeader.tsx deleted file mode 100644 index 40952312e..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsHeader.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import NextLink from 'next/link'; -import React, { ReactElement } from 'react'; -import ArrowBackIos from '@mui/icons-material/ArrowBackIos'; -import List from '@mui/icons-material/List'; -import TableChart from '@mui/icons-material/TableChart'; -import { Box, Button, ButtonGroup, Grid, TextField } from '@mui/material'; -import clsx from 'clsx'; -import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import { useAccountListId } from '../../../../hooks/useAccountListId'; -import theme from '../../../../theme'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealProgressBar from '../AppealProgressBar'; - -const useStyles = makeStyles()(() => ({ - container: { - width: '100%', - }, - row: { - paddingBottom: theme.spacing(3), - }, - secondRow: { - paddingTop: theme.spacing(3), - border: '1px solid', - borderColor: theme.palette.cruGrayMedium.main, - borderRadius: 10, - backgroundColor: theme.palette.cruGrayLight.main, - }, - resize: { - fontSize: 24, - }, - selectedButton: { - backgroundColor: theme.palette.cruGrayLight.main, - boxShadow: '0 0 1px lightgray', - }, -})); - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsHeader = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - const { t } = useTranslation(); - const accountListId = useAccountListId(); - const { appealState, setAppealState } = useAppealContext(); - - return ( - - - - - - - - - {' '} - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export default AppealDetailsHeader; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsMain.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsMain.tsx deleted file mode 100644 index e01943a98..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsMain.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { ReactElement } from 'react'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealDetailsAsked from './AppealDetailsAsked'; -import AppealDetailsCommitted from './AppealDetailsCommitted'; -import AppealDetailsExcluded from './AppealDetailsExcluded'; -import AppealDetailsFlow from './AppealDetailsFlow'; -import AppealDetailsGiven from './AppealDetailsGiven'; -import AppealDetailsHeader from './AppealDetailsHeader'; -import AppealDetailsReceived from './AppealDetailsReceived'; - -export interface Row { - id: number; - contact: string; - amount: string; - date: string; -} - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsMain = ({ appeal }: Props): ReactElement => { - const { appealState } = useAppealContext(); - - const renderDetails = (): ReactElement => { - switch (appealState.subDisplay) { - case 'given': - return ; - case 'received': - return ; - case 'commited': - return ; - case 'asked': - return ; - case 'excluded': - return ; - default: - return ; - } - }; - - return ( - <> - - {appealState.display === 'default' ? ( - renderDetails() - ) : ( - - )} - - ); -}; - -export default AppealDetailsMain; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsNoData.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsNoData.tsx deleted file mode 100644 index 4391300b6..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsNoData.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { ReactElement } from 'react'; -import { Box, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; - -const StyledBox = styled(Box)(({ theme }) => ({ - width: '100%', - border: '1px solid', - borderColor: theme.palette.cruGrayMedium.main, - marginTop: 30, - backgroundColor: theme.palette.cruGrayLight.main, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: theme.spacing(12), - borderRadius: 5, -})); - -export interface Props { - dataType: string; -} - -const renderCase = (dataType: string): string => { - switch (dataType) { - case 'given': - return 'No donations yet towards this appeal'; - case 'received': - return 'No gifts have been received and not yet processed to this appeal'; - case 'commited': - return 'No contacts with commitments have committed to this appeal'; - case 'asked': - return 'No contacts have been asked for this appeal'; - case 'excluded': - return 'No contacts have been excluded from this appeal'; - default: - return ''; - } -}; - -const NoData = (): ReactElement => { - const { appealState } = useAppealContext(); - - return ( - - {renderCase(appealState.subDisplay)} - - ); -}; - -export default NoData; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealDetailsReceived.tsx b/src/components/Tool/Appeal/AppealDetails/AppealDetailsReceived.tsx deleted file mode 100644 index ef375d962..000000000 --- a/src/components/Tool/Appeal/AppealDetails/AppealDetailsReceived.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiDelete, mdiSquareEditOutline } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Box, IconButton } from '@mui/material'; -import { DataGrid, GridSelectionModel } from '@mui/x-data-grid'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import i18n from 'src/lib/i18n'; -import theme from '../../../../theme'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import AppealDetailsNoData from './AppealDetailsNoData'; - -const useStyles = makeStyles()(() => ({ - container: { - marginTop: 20, - height: '60vh', - '& .MuiDataGrid-row.Mui-odd': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - '& .MuiDataGrid-columnHeader': { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - height: '100%', - '& .MuiCheckbox-colorPrimary.Mui-checked': { - color: 'white', - }, - '& .MuiDataGrid-columnHeaderTitle': { - fontWeight: 600, - }, - }, - '& .MuiDataGrid-columnHeaderWrapper': { - '& .MuiDataGrid-cell': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - '& .MuiDataGrid-row:hover': { - backgroundColor: theme.palette.cruGrayMedium.main, - '& .MuiCheckbox-colorPrimary': { - color: 'white', - }, - }, - '& .MuiDataGrid-sortIcon': { - color: 'white', - }, - '& .MuiDataGrid-row.Mui-selected': { - backgroundColor: `${theme.palette.mpdxYellow.main} !important`, - '& .MuiCheckbox-colorPrimary': { - color: `${theme.palette.mpdxBlue.main} !important`, - }, - }, - '& .MuiSvgIcon-fontSizeSmall': { - color: 'white', - }, - }, - actionIconButton: { - color: theme.palette.cruGrayDark.main, - '&:hover': { color: theme.palette.mpdxBlue.main, cursor: 'pointer' }, - }, -})); - -const columns = [ - { - field: 'contact', - headerName: i18n.t('Contact'), - minWidth: 200, - flex: 1, - }, - { - field: 'amount', - headerName: i18n.t('Amount Received'), - minWidth: 150, - flex: 1, - }, - { - field: 'date', - headerName: i18n.t('Date Received'), - minWidth: 200, - flex: 1, - }, - { - field: 'actions', - headerName: i18n.t('Actions'), - minWidth: 100, - flex: 0.3, - sortable: false, - renderCell: function renderActions() { - const { classes } = useStyles(); - return ( - <> - - - - - - - - ); - }, - }, -]; - -export interface Props { - appeal: TestAppeal; -} - -const AppealDetailsReceived = ({ appeal }: Props): ReactElement => { - const { classes } = useStyles(); - const { appealState, setAppealState } = useAppealContext(); - - const rows = appeal.received.map((donation, index) => ({ - id: index, - contact: donation.name, - amount: `${donation.amount?.toFixed(2)} ${donation.currency}`, - date: donation.date, - })); - - const updateSelected = (e: GridSelectionModel): void => { - const temp: string[] = e.map( - (index) => rows[parseInt(index.toString())].contact, - ); - setAppealState({ ...appealState, selected: [...temp] }); - }; - return appeal.received.length > 0 ? ( - - - - ) : ( - - ); -}; - -export default AppealDetailsReceived; diff --git a/src/components/Tool/Appeal/AppealDrawer/AppealDrawer.tsx b/src/components/Tool/Appeal/AppealDrawer/AppealDrawer.tsx deleted file mode 100644 index cdd59fa40..000000000 --- a/src/components/Tool/Appeal/AppealDrawer/AppealDrawer.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React, { ReactElement } from 'react'; -import { Drawer } from '@mui/material'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import theme from '../../../../theme'; -import NavToolDrawerHandle from '../../NavToolList/NavToolDrawerHandle'; -import AppealDrawerList from './AppealDrawerList'; - -const useStyles = makeStyles()(() => ({ - drawer: { - zIndex: 1, - }, - navToggle: { - backgroundColor: theme.palette.mpdxBlue.main, - color: 'white', - '&:hover': { - backgroundColor: theme.palette.mpdxBlue.main, - }, - }, - list: { - width: '290px', - transform: 'translateY(55px)', - [theme.breakpoints.down('xs')]: { - transform: 'translateY(45px)', - }, - }, - li: { - borderBottom: '1px solid', - borderColor: theme.palette.cruGrayDark.main, - }, -})); - -export interface Props { - open: boolean; - toggle: () => void; - selectedId?: string; - appeal: TestAppeal; -} - -const AppealDrawer = ({ open, toggle, appeal }: Props): ReactElement => { - const { classes } = useStyles(); - //const appealId = useAppealId(); - - return ( - <> - - - - - - ); -}; - -export default AppealDrawer; diff --git a/src/components/Tool/Appeal/AppealDrawer/AppealDrawerList.tsx b/src/components/Tool/Appeal/AppealDrawer/AppealDrawerList.tsx deleted file mode 100644 index 5b24106b2..000000000 --- a/src/components/Tool/Appeal/AppealDrawer/AppealDrawerList.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { ReactElement } from 'react'; -import { mdiTrophy } from '@mdi/js'; -import Icon from '@mdi/react'; -import { - Box, - List, - ListItem, - ListItemIcon, - ListItemText, - Theme, -} from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { makeStyles } from 'tss-react/mui'; -import { TestAppeal } from 'pages/accountLists/[accountListId]/tools/appeals/testAppeal'; -import { useAppealContext } from '../AppealContextProvider/AppealContextProvider'; -import { AppealDrawerItem } from './Item/AppealDrawerItem'; -import { AppealDrawerItemButton } from './Item/AppealDrawerItemButton'; - -const useStyles = makeStyles()((theme: Theme) => ({ - list: { - width: '290px', - transform: 'translateY(55px)', - [theme.breakpoints.down('xs')]: { - transform: 'translateY(45px)', - }, - }, - li: { - borderTop: '1px solid', - borderBottom: '1px solid', - borderColor: theme.palette.cruGrayDark.main, - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), - }, - header: { - fontSize: '1.5em', - }, -})); - -interface Props { - appeal: TestAppeal; -} - -const AppealDrawerList = ({ appeal }: Props): ReactElement => { - const { t } = useTranslation(); - const { classes } = useStyles(); - const { appealState } = useAppealContext(); - - const testFunc = (): void => { - // eslint-disable-next-line no-console - console.log(appealState); - }; - - return ( - - - - - - - - - - - - - - - - - - - - ); -}; - -export default AppealDrawerList; diff --git a/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItem.tsx b/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItem.tsx deleted file mode 100644 index 95f3a3d9a..000000000 --- a/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItem.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { ReactElement } from 'react'; -import ArrowForwardIos from '@mui/icons-material/ArrowForwardIos'; -import { Box, ListItem, ListItemText } from '@mui/material'; -import clsx from 'clsx'; -import { makeStyles } from 'tss-react/mui'; -import theme from '../../../../../theme'; -import { useAppealContext } from '../../AppealContextProvider/AppealContextProvider'; - -const useStyles = makeStyles()(() => ({ - li: { - borderBottom: '1px solid black', - }, - liButton: { - '&:hover': { - backgroundColor: theme.palette.cruGrayLight.main, - }, - }, - liSelected: { - backgroundColor: theme.palette.cruGrayMedium.main + ' !important', //TODO: Get around this so we don't need the !important - }, - red: { - backgroundColor: 'red', - }, - green: { - backgroundColor: theme.palette.mpdxGreen.main, - }, - gold: { - backgroundColor: theme.palette.cruYellow.main, - color: theme.palette.cruGrayDark.main + ' !important', - }, - gray: { - backgroundColor: theme.palette.cruGrayMedium.main, - border: '1px solid white', - }, - valueText: { - color: 'white', - borderRadius: 5, - fontWeight: 600, - padding: '2px 8px 2px 8px', - }, -})); - -interface Props { - id: string; - title: string; - isSelected: boolean; - value: number; -} - -export const AppealDrawerItem = ({ - id, - title, - isSelected, - value, -}: Props): ReactElement => { - const { classes } = useStyles(); - const { appealState, setAppealState } = useAppealContext(); - - const changeSubDisplay = (props: string): void => { - setAppealState({ ...appealState, subDisplay: props, selected: [] }); - }; - - return ( - changeSubDisplay(id)} - className={clsx( - classes.li, - isSelected ? classes.liSelected : classes.liButton, - )} - > - - - {value} - - - - ); -}; diff --git a/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItemButton.tsx b/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItemButton.tsx deleted file mode 100644 index 657055f8a..000000000 --- a/src/components/Tool/Appeal/AppealDrawer/Item/AppealDrawerItemButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { ReactElement } from 'react'; -import { Box, Button, ListItem, ListItemText } from '@mui/material'; -import { makeStyles } from 'tss-react/mui'; -import theme from '../../../../../theme'; - -const useStyles = makeStyles()(() => ({ - li: { - borderBottom: '1px solid black', - paddingBottom: theme.spacing(3), - }, - itemButton: { - backgroundColor: theme.palette.cruGrayLight.main, - width: '260px', - textTransform: 'none', - }, -})); - -interface Props { - title: string; - func: () => void; - buttonText: string; - disabled?: boolean; -} - -export const AppealDrawerItemButton = ({ - title, - func, - buttonText, - disabled, -}: Props): ReactElement => { - const { classes } = useStyles(); - - return ( - - - - - - - ); -}; diff --git a/src/components/Tool/Appeal/AddAppealForm.tsx b/src/components/Tool/Appeal/InitialPage/AddAppealForm.tsx similarity index 99% rename from src/components/Tool/Appeal/AddAppealForm.tsx rename to src/components/Tool/Appeal/InitialPage/AddAppealForm.tsx index 0c2a860f1..4aabb96ad 100644 --- a/src/components/Tool/Appeal/AddAppealForm.tsx +++ b/src/components/Tool/Appeal/InitialPage/AddAppealForm.tsx @@ -29,8 +29,8 @@ import { } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; import { MultiselectFilter } from 'src/graphql/types.generated'; import i18n from 'src/lib/i18n'; -import theme from '../../../theme'; -import AnimatedCard from '../../AnimatedCard'; +import theme from '../../../../theme'; +import AnimatedCard from '../../../AnimatedCard'; import { useCreateAppealMutation } from './CreateAppeal.generated'; import { useGetContactTagsQuery } from './GetContactTags.generated'; diff --git a/src/components/Tool/Appeal/Appeal.test.tsx b/src/components/Tool/Appeal/InitialPage/Appeal.test.tsx similarity index 96% rename from src/components/Tool/Appeal/Appeal.test.tsx rename to src/components/Tool/Appeal/InitialPage/Appeal.test.tsx index 353f3ce17..a748b7552 100644 --- a/src/components/Tool/Appeal/Appeal.test.tsx +++ b/src/components/Tool/Appeal/InitialPage/Appeal.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { render } from '@testing-library/react'; import TestWrapper from '__tests__/util/TestWrapper'; -import theme from '../../../theme'; +import theme from '../../../../theme'; import Appeal from './Appeal'; const appeal = { diff --git a/src/components/Tool/Appeal/Appeal.tsx b/src/components/Tool/Appeal/InitialPage/Appeal.tsx similarity index 94% rename from src/components/Tool/Appeal/Appeal.tsx rename to src/components/Tool/Appeal/InitialPage/Appeal.tsx index 95e3a15a2..19173d863 100644 --- a/src/components/Tool/Appeal/Appeal.tsx +++ b/src/components/Tool/Appeal/InitialPage/Appeal.tsx @@ -6,10 +6,10 @@ import { Box, CardContent, IconButton, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import { AppealFieldsFragment } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; -import { useAccountListId } from '../../../hooks/useAccountListId'; -import theme from '../../../theme'; -import AnimatedCard from '../../AnimatedCard'; -import AppealProgressBar from './AppealProgressBar'; +import { useAccountListId } from '../../../../hooks/useAccountListId'; +import theme from '../../../../theme'; +import AnimatedCard from '../../../AnimatedCard'; +import AppealProgressBar from '../AppealProgressBar'; const useStyles = makeStyles()(() => ({ cardContent: { diff --git a/src/components/Tool/Appeal/Appeals.test.tsx b/src/components/Tool/Appeal/InitialPage/Appeals.test.tsx similarity index 98% rename from src/components/Tool/Appeal/Appeals.test.tsx rename to src/components/Tool/Appeal/InitialPage/Appeals.test.tsx index 6ee9a2b9b..c0d866268 100644 --- a/src/components/Tool/Appeal/Appeals.test.tsx +++ b/src/components/Tool/Appeal/InitialPage/Appeals.test.tsx @@ -6,7 +6,7 @@ import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { GetAppealsQuery } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; -import theme from '../../../theme'; +import theme from '../../../../theme'; import Appeals from './Appeals'; const accountListId = 'test121'; diff --git a/src/components/Tool/Appeal/Appeals.tsx b/src/components/Tool/Appeal/InitialPage/Appeals.tsx similarity index 100% rename from src/components/Tool/Appeal/Appeals.tsx rename to src/components/Tool/Appeal/InitialPage/Appeals.tsx diff --git a/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx b/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx new file mode 100644 index 000000000..6ac055cf8 --- /dev/null +++ b/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx @@ -0,0 +1,65 @@ +import React, { useContext } from 'react'; +import { Box, Divider, Grid, Theme, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { makeStyles } from 'tss-react/mui'; +import AddAppealForm from 'src/components/Tool/Appeal/InitialPage/AddAppealForm'; +import Appeals from 'src/components/Tool/Appeal/InitialPage/Appeals'; +import { AppealsContext, AppealsType } from '../AppealsContext/AppealsContext'; + +const useStyles = makeStyles()((theme: Theme) => ({ + container: { + padding: `${theme.spacing(3)} ${theme.spacing(3)} 0`, + display: 'flex', + }, + outer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + width: '100%', + }, + loadingIndicator: { + margin: theme.spacing(0, 1, 0, 0), + }, +})); + +const AppealsInitialPage: React.FC = () => { + const { t } = useTranslation(); + const { classes } = useStyles(); + + const { accountListId } = useContext(AppealsContext) as AppealsType; + + return ( + + + + + {t('Appeals')} + + + + + {t( + 'You can track recurring support goals or special need ' + + 'support goals through our appeals wizard. Track the ' + + 'recurring support you raise for an increase ask for example, ' + + 'or special gifts you raise for a summer mission trip or your ' + + 'new staff special gift goal.', + )} + + + + + + + + + + + + + + + ); +}; + +export default AppealsInitialPage; diff --git a/src/components/Tool/Appeal/ChangePrimaryAppeal.graphql b/src/components/Tool/Appeal/InitialPage/ChangePrimaryAppeal.graphql similarity index 100% rename from src/components/Tool/Appeal/ChangePrimaryAppeal.graphql rename to src/components/Tool/Appeal/InitialPage/ChangePrimaryAppeal.graphql diff --git a/src/components/Tool/Appeal/CreateAppeal.graphql b/src/components/Tool/Appeal/InitialPage/CreateAppeal.graphql similarity index 100% rename from src/components/Tool/Appeal/CreateAppeal.graphql rename to src/components/Tool/Appeal/InitialPage/CreateAppeal.graphql diff --git a/src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage.tsx b/src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage.tsx new file mode 100644 index 000000000..a1abe0179 --- /dev/null +++ b/src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadAppealsInitialPage = () => + import( + /* webpackChunkName: "AppealsInitialPage" */ './AppealsInitialPage' + ).then((AppealsInitialPage) => AppealsInitialPage); + +export const DynamicAppealsInitialPage = dynamic(preloadAppealsInitialPage, { + loading: DynamicComponentPlaceholder, +}); diff --git a/src/components/Tool/Appeal/GetContactTags.graphql b/src/components/Tool/Appeal/InitialPage/GetContactTags.graphql similarity index 100% rename from src/components/Tool/Appeal/GetContactTags.graphql rename to src/components/Tool/Appeal/InitialPage/GetContactTags.graphql diff --git a/src/components/Tool/Appeal/NoAppeal.test.tsx b/src/components/Tool/Appeal/InitialPage/NoAppeal.test.tsx similarity index 100% rename from src/components/Tool/Appeal/NoAppeal.test.tsx rename to src/components/Tool/Appeal/InitialPage/NoAppeal.test.tsx diff --git a/src/components/Tool/Appeal/NoAppeals.tsx b/src/components/Tool/Appeal/InitialPage/NoAppeals.tsx similarity index 96% rename from src/components/Tool/Appeal/NoAppeals.tsx rename to src/components/Tool/Appeal/InitialPage/NoAppeals.tsx index 591fdfd94..a460bd227 100644 --- a/src/components/Tool/Appeal/NoAppeals.tsx +++ b/src/components/Tool/Appeal/InitialPage/NoAppeals.tsx @@ -4,7 +4,7 @@ import Icon from '@mdi/react'; import { Box, CardContent, Theme, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; -import AnimatedCard from '../../AnimatedCard'; +import AnimatedCard from '../../../AnimatedCard'; export interface Props { primary?: boolean; From 31594f762156d2a4c48aab9f9e990358befbc41c Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:27:54 -0400 Subject: [PATCH 05/18] 3. Appeal Details Frame - Building main frame. --- src/components/Shared/Header/ListHeader.tsx | 21 +++- .../AppealLeftPanel/AppealsLeftPanel.tsx | 18 +++ .../AppealsMainPanel/AppealsMainPanel.tsx | 21 ++++ .../AppealsMainPanelHeader.tsx | 117 ++++++++++++++++++ .../Tool/Appeal/AppealsDetailsPage.tsx | 24 +++- 5 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanelHeader.tsx diff --git a/src/components/Shared/Header/ListHeader.tsx b/src/components/Shared/Header/ListHeader.tsx index 647f37abb..8712d0e38 100644 --- a/src/components/Shared/Header/ListHeader.tsx +++ b/src/components/Shared/Header/ListHeader.tsx @@ -82,10 +82,11 @@ export enum PageEnum { Contact = 'contact', Task = 'task', Report = 'report', + Appeal = 'appeal', } interface ListHeaderProps { - page: 'contact' | 'task' | 'report'; + page: 'contact' | 'task' | 'report' | 'appeal'; activeFilters: boolean; headerCheckboxState: ListHeaderCheckBoxState; filterPanelOpen: boolean; @@ -96,6 +97,7 @@ interface ListHeaderProps { onSearchTermChanged: (searchTerm: string) => void; searchTerm?: string | string[]; totalItems?: number; + leftButtonGroup?: ReactElement; buttonGroup?: ReactElement; starredFilter?: ContactFilterSetInput | TaskFilterSetInput; toggleStarredFilter?: ( @@ -117,6 +119,7 @@ export const ListHeader: React.FC = ({ onSearchTermChanged, searchTerm, totalItems, + leftButtonGroup, buttonGroup, starredFilter, toggleStarredFilter, @@ -133,6 +136,7 @@ export const ListHeader: React.FC = ({ {contactsView !== TableViewModeEnum.Map && ( = ({ /> )} + {page === PageEnum.Appeal && leftButtonGroup && ( + {leftButtonGroup} + )} = ({ )} @@ -171,7 +178,7 @@ export const ListHeader: React.FC = ({ - {page === 'contact' && ( + {page === PageEnum.Contact && ( = ({ selectedIds={selectedIds} /> )} - {page === 'report' && ( + {page === PageEnum.Report && ( = ({ /> )} - {page === 'task' && ( + {page === PageEnum.Task && ( = ({ /> )} + {page === PageEnum.Appeal && {buttonGroup}} + {starredFilter && toggleStarredFilter && ( // This hidden doesn't remove from document diff --git a/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx new file mode 100644 index 000000000..07dbb8e98 --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { TableViewModeEnum } from 'src/components/Shared/Header/ListHeader'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; + +export const AppealsLeftPanel: React.FC = () => { + const { filterData, filtersLoading, viewMode } = React.useContext( + AppealsContext, + ) as AppealsType; + + return viewMode !== TableViewModeEnum.Flows ? ( +

List Filters

+ ) : filterData && !filtersLoading ? ( +

Flows Filters

+ ) : null; +}; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx new file mode 100644 index 000000000..bb8c7f974 --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { TableViewModeEnum } from 'src/components/Shared/Header/ListHeader'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { AppealsMainPanelHeader } from './AppealsMainPanelHeader'; + +export const AppealsMainPanel: React.FC = () => { + const { viewMode, userOptionsLoading } = React.useContext( + AppealsContext, + ) as AppealsType; + + return ( + <> + + {!userOptionsLoading && + (viewMode === TableViewModeEnum.List ?

List

:

Flows

)} + + ); +}; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanelHeader.tsx b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanelHeader.tsx new file mode 100644 index 000000000..9cdcaa9c9 --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanelHeader.tsx @@ -0,0 +1,117 @@ +import NextLink from 'next/link'; +import React from 'react'; +import BackIcon from '@mui/icons-material/ArrowBackIos'; +import FormatListBulleted from '@mui/icons-material/FormatListBulleted'; +import ViewColumn from '@mui/icons-material/ViewColumn'; +import { + Box, + Button, + Hidden, + ToggleButton, + ToggleButtonGroup, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import { + ListHeader, + PageEnum, + TableViewModeEnum, +} from 'src/components/Shared/Header/ListHeader'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; + +const ViewSettingsButton = styled(Button)(({ theme }) => ({ + textTransform: 'none', + height: theme.spacing(6), + marginLeft: theme.spacing(1), + marginRight: theme.spacing(2), +})); +const ViewColumnIcon = styled(ViewColumn)(({ theme }) => ({ + color: theme.palette.primary.dark, +})); +const BulletedListIcon = styled(FormatListBulleted)(({ theme }) => ({ + color: theme.palette.primary.dark, +})); +const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + +export const AppealsMainPanelHeader: React.FC = () => { + const { t } = useTranslation(); + + const { + accountListId, + sanitizedFilters, + contactsQueryResult, + toggleFilterPanel, + toggleSelectAll, + setSearchTerm, + searchTerm, + starredFilter, + setStarredFilter, + selectionType, + filterPanelOpen, + contactDetailsOpen, + viewMode, + handleViewModeChange, + selectedIds, + } = React.useContext(AppealsContext) as AppealsType; + + return ( + 0} + filterPanelOpen={filterPanelOpen} + toggleFilterPanel={toggleFilterPanel} + contactDetailsOpen={contactDetailsOpen} + onCheckAllItems={toggleSelectAll} + contactsView={viewMode} + onSearchTermChanged={setSearchTerm} + searchTerm={searchTerm} + totalItems={contactsQueryResult.data?.contacts.totalCount} + starredFilter={starredFilter} + toggleStarredFilter={setStarredFilter} + headerCheckboxState={selectionType} + selectedIds={selectedIds} + showShowingCount={viewMode === TableViewModeEnum.List} + leftButtonGroup={ + + + + + + {t('Appeals')} + + + + + } + buttonGroup={ + + + + + + + + + + + + + } + /> + ); +}; diff --git a/src/components/Tool/Appeal/AppealsDetailsPage.tsx b/src/components/Tool/Appeal/AppealsDetailsPage.tsx index 278f61261..9a6b951ec 100644 --- a/src/components/Tool/Appeal/AppealsDetailsPage.tsx +++ b/src/components/Tool/Appeal/AppealsDetailsPage.tsx @@ -1,7 +1,27 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { AppealsLeftPanel } from './AppealDetails/AppealLeftPanel/AppealsLeftPanel'; +import { AppealsMainPanel } from './AppealDetails/AppealsMainPanel/AppealsMainPanel'; +import { AppealsContext, AppealsType } from './AppealsContext/AppealsContext'; const AppealsDetailsPage: React.FC = () => { - return <>Details Page; + const { filterPanelOpen, contactDetailsOpen } = useContext( + AppealsContext, + ) as AppealsType; + + return ( + } + leftOpen={filterPanelOpen} + leftWidth="290px" + mainContent={} + rightPanel={

Contact

} + rightOpen={contactDetailsOpen} + rightWidth="60%" + headerHeight={headerHeight} + /> + ); }; export default AppealsDetailsPage; From 6cf3b8937b312c6623b45b0d795e8ab3209e7cd7 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:38:00 -0400 Subject: [PATCH 06/18] 3. MainPanel - Appeal header info and infinite scroll --- .../AppealDetails/AppealHeaderInfo.test.tsx | 58 +++++++ .../Appeal/AppealDetails/AppealHeaderInfo.tsx | 147 ++++++++++++++++++ .../AppealsMainPanel/AppealsMainPanel.tsx | 24 ++- .../AppealsMainPanel/appealInfo.graphql | 5 + .../Tool/Appeal/AppealProgressBar.test.tsx | 132 ++++++++++++++++ .../Tool/Appeal/AppealProgressBar.tsx | 4 +- .../Appeal/List/ContactsList/ContactsList.tsx | 67 ++++++++ .../List/ContactsList/DynamicContactsList.tsx | 11 ++ 8 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx create mode 100644 src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.graphql create mode 100644 src/components/Tool/Appeal/AppealProgressBar.test.tsx create mode 100644 src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx create mode 100644 src/components/Tool/Appeal/List/ContactsList/DynamicContactsList.tsx diff --git a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx new file mode 100644 index 000000000..9533e6a6a --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.test.tsx @@ -0,0 +1,58 @@ +import { ThemeProvider } from '@mui/material/styles'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { render, waitFor } from '@testing-library/react'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import theme from 'src/theme'; +import { appealInfo } from '../appealMockData'; +import { AppealHeaderInfo, AppealHeaderInfoProps } from './AppealHeaderInfo'; + +const router = { + query: { accountListId: 'aaa' }, + isReady: true, +}; + +const Components = ({ appealInfo, loading }: AppealHeaderInfoProps) => ( + + + + + + + + + + + +); + +describe('AppealHeaderInfo', () => { + it('renders skeletons when loading', () => { + const { getByTestId, getByRole } = render( + , + ); + + expect(getByRole('heading', { name: 'Name:' })).toBeInTheDocument(); + expect(getByTestId('appeal-name-skeleton')).toBeInTheDocument(); + + expect(getByRole('heading', { name: 'Goal:' })).toBeInTheDocument(); + expect(getByTestId('appeal-goal-skeleton')).toBeInTheDocument(); + }); + + it('renders appeal info', async () => { + const { getByText } = render( + , + ); + + await waitFor(() => { + expect(getByText('Test Appeal')).toBeInTheDocument(); + expect(getByText('$100')).toBeInTheDocument(); + expect(getByText(/\$50 \(50%\)/i)).toBeInTheDocument(); + expect(getByText(/\$100 \(100%\)/i)).toBeInTheDocument(); + }); + }); + + // TODO - Build tests for modals opening and saving data +}); diff --git a/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx new file mode 100644 index 000000000..cbe1a631c --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealHeaderInfo.tsx @@ -0,0 +1,147 @@ +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { Box, Grid, IconButton, Skeleton, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { AppealFieldsFragment } from 'pages/accountLists/[accountListId]/tools/GetAppeals.generated'; +import { EditIcon } from 'src/components/Contacts/ContactDetails/ContactDetailsTab/StyledComponents'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; +import theme from 'src/theme'; +import AppealProgressBar from '../AppealProgressBar'; + +export const appealHeaderInfoHeight = theme.spacing(9); + +const HeaderBarContactWrap = styled(Box)(() => ({ + flex: 1, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), +})); + +const AppealInfoHeader = styled(Typography)(() => ({ + color: theme.palette.cruGrayMedium.main, +})); + +const AppealInfo = styled(Typography)(() => ({ + display: 'inline', + fontWeight: 'bold', +})); + +const AppealInfoContainer = styled(Box)(() => ({ + display: 'flex', + flexWrap: 'nowrap', + alignItems: 'center', + gap: theme.spacing(1), +})); + +const GridContainer = styled(Grid)(() => ({ + height: appealHeaderInfoHeight, + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + borderBottom: `1px solid ${theme.palette.cruGrayLight.main}`, +})); + +export type AppealHeaderInfoProps = { + appealInfo?: AppealFieldsFragment; + loading: boolean; +}; + +export const AppealHeaderInfo: React.FC = ({ + appealInfo, + loading, +}) => { + const { t } = useTranslation(); + const locale = useLocale(); + + const [isEditAppealModalOpen, setIsEditAppealModalOpen] = useState(false); + + const name = appealInfo?.name || ''; + const amount = appealInfo?.amount || 0; + const amountCurrency = appealInfo?.amountCurrency || 'USD'; + const given = appealInfo?.pledgesAmountProcessed || 0; + const received = appealInfo?.pledgesAmountReceivedNotProcessed || 0; + const committed = appealInfo?.pledgesAmountNotReceivedNotProcessed || 0; + + return ( + <> + + + + + + {t('Name')}: + + + {loading || !name ? ( + + ) : ( + <> + {name} + setIsEditAppealModalOpen(true)} + aria-label={t('Edit Icon')} + > + + + + )} + + + + + {t('Goal')}: + + + {loading || !amount ? ( + + ) : ( + <> + + {currencyFormat(amount, amountCurrency, locale)} + + setIsEditAppealModalOpen(true)} + aria-label={t('Edit Icon')} + > + + + + )} + + + + + + + + + + {/* TODO - Build modal */} + {isEditAppealModalOpen &&

Modal

} + + ); +}; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx index bb8c7f974..4ebd41582 100644 --- a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx @@ -4,18 +4,34 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { DynamicContactsList } from '../../List/ContactsList/DynamicContactsList'; import { AppealsMainPanelHeader } from './AppealsMainPanelHeader'; +import { useAppealQuery } from './appealInfo.generated'; export const AppealsMainPanel: React.FC = () => { - const { viewMode, userOptionsLoading } = React.useContext( - AppealsContext, - ) as AppealsType; + const { accountListId, appealId, viewMode, userOptionsLoading } = + React.useContext(AppealsContext) as AppealsType; + + const { data: appealInfo, loading: appealInfoLoading } = useAppealQuery({ + variables: { + accountListId: accountListId ?? '', + appealId: appealId ?? '', + }, + skip: !accountListId || !appealId, + }); return ( <> {!userOptionsLoading && - (viewMode === TableViewModeEnum.List ?

List

:

Flows

)} + (viewMode === TableViewModeEnum.List ? ( + + ) : ( +

Flows

+ ))} ); }; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.graphql b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.graphql new file mode 100644 index 000000000..ab5ea10bc --- /dev/null +++ b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.graphql @@ -0,0 +1,5 @@ +query Appeal($accountListId: ID!, $appealId: ID!) { + appeal(accountListId: $accountListId, id: $appealId) { + ...AppealFields + } +} diff --git a/src/components/Tool/Appeal/AppealProgressBar.test.tsx b/src/components/Tool/Appeal/AppealProgressBar.test.tsx new file mode 100644 index 000000000..51716f410 --- /dev/null +++ b/src/components/Tool/Appeal/AppealProgressBar.test.tsx @@ -0,0 +1,132 @@ +import { ThemeProvider } from '@mui/material/styles'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; +import { render, waitFor } from '@testing-library/react'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import theme from 'src/theme'; +import AppealProgressBar, { AppealProgressBarProps } from './AppealProgressBar'; + +const router = { + query: { accountListId: 'aaa' }, + isReady: true, +}; + +const Components = ({ + given, + received, + committed, + amount, + amountCurrency, +}: AppealProgressBarProps) => ( + + + + + + + + + + + +); + +describe('AppealProgressBar', () => { + describe('Errors', () => { + it('handle string instead of number', async () => { + const { getAllByText } = render( + , + ); + + await waitFor(() => { + expect(getAllByText(/\$0 \(10%\)/i).length).toBe(3); + }); + }); + + it('handle default with no data', async () => { + const { getAllByText } = render( + , + ); + + await waitFor(() => { + expect(getAllByText(/\$0 \(0%\)/i).length).toBe(3); + }); + }); + }); + + describe('Renders correct amounts and currency', () => { + it('renders progress bar in USD', async () => { + const { getByText } = render( + , + ); + + await waitFor(() => { + expect(getByText(/\$100 \(10%\)/i)).toBeInTheDocument(); + expect(getByText(/\$300 \(30%\)/i)).toBeInTheDocument(); + expect(getByText(/\$600 \(60%\)/i)).toBeInTheDocument(); + }); + }); + + it('renders progress bar in Euros', async () => { + const { getByText } = render( + , + ); + + await waitFor(() => { + expect(getByText(/\€100 \(10%\)/i)).toBeInTheDocument(); + expect(getByText(/\€300 \(30%\)/i)).toBeInTheDocument(); + expect(getByText(/\€600 \(60%\)/i)).toBeInTheDocument(); + }); + }); + + it('renders progress bar in NZ dollars', async () => { + const { getByText } = render( + , + ); + + await waitFor(() => { + expect(getByText(/nz\$100 \(10%\)/i)).toBeInTheDocument(); + expect(getByText(/nz\$300 \(30%\)/i)).toBeInTheDocument(); + expect(getByText(/nz\$600 \(60%\)/i)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/Tool/Appeal/AppealProgressBar.tsx b/src/components/Tool/Appeal/AppealProgressBar.tsx index 50b3d6b5c..4d88cc746 100644 --- a/src/components/Tool/Appeal/AppealProgressBar.tsx +++ b/src/components/Tool/Appeal/AppealProgressBar.tsx @@ -30,7 +30,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -export interface Props { +export interface AppealProgressBarProps { given: number; received: number; committed: number; @@ -44,7 +44,7 @@ const AppealProgressBar = ({ committed, amount, amountCurrency, -}: Props): ReactElement => { +}: AppealProgressBarProps): ReactElement => { const { classes } = useStyles(); const locale = useLocale(); const givenAmount = useMemo( diff --git a/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx new file mode 100644 index 000000000..8a3153d6a --- /dev/null +++ b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Box } from '@mui/system'; +import { InfiniteList } from 'src/components/InfiniteList/InfiniteList'; +import { navBarHeight } from 'src/components/Layouts/Primary/Primary'; +import NullState from 'src/components/Shared/Filters/NullState/NullState'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { + AppealHeaderInfo, + appealHeaderInfoHeight, +} from '../../AppealDetails/AppealHeaderInfo'; +import { AppealQuery } from '../../AppealDetails/AppealsMainPanel/appealInfo.generated'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; + +interface ContactsListProps { + appealInfo?: AppealQuery; + appealInfoLoading: boolean; +} + +export const ContactsList: React.FC = ({ + appealInfo, + appealInfoLoading, +}) => { + const { contactsQueryResult, isFiltered, searchTerm, setActiveFilters } = + React.useContext(AppealsContext) as AppealsType; + + const { data, loading, fetchMore } = contactsQueryResult; + + return ( + <> + + +

{contact.name}

} + groupBy={(item) => ({ label: item.name[0].toUpperCase() })} + endReached={() => + data?.contacts?.pageInfo.hasNextPage && + fetchMore({ + variables: { + after: data.contacts?.pageInfo.endCursor, + }, + }) + } + EmptyPlaceholder={ + + + + } + /> + + ); +}; diff --git a/src/components/Tool/Appeal/List/ContactsList/DynamicContactsList.tsx b/src/components/Tool/Appeal/List/ContactsList/DynamicContactsList.tsx new file mode 100644 index 000000000..8f6a53f1d --- /dev/null +++ b/src/components/Tool/Appeal/List/ContactsList/DynamicContactsList.tsx @@ -0,0 +1,11 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadContactsList = () => + import(/* webpackChunkName: "ContactsList" */ './ContactsList').then( + ({ ContactsList }) => ContactsList, + ); + +export const DynamicContactsList = dynamic(preloadContactsList, { + loading: DynamicComponentPlaceholder, +}); From 677c955388c3711ebc13109dd56b98f3d8520692 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:40:35 -0400 Subject: [PATCH 07/18] 3. List filters --- .../AppealLeftPanel/AppealsLeftPanel.tsx | 8 +- .../AppealsListFilterPanel.test.tsx | 116 +++++++++ .../AppealsListFilterPanel.tsx | 228 ++++++++++++++++++ .../AppealsListFilterPanelButton.test.tsx | 47 ++++ .../AppealsListFilterPanelButton.tsx | 67 +++++ .../AppealsListFilterPanelItem.test.tsx | 107 ++++++++ .../AppealsListFilterPanelItem.tsx | 107 ++++++++ .../DynamicAppealsListFilterPanel.tsx | 14 ++ .../contactsCount.graphql | 8 + 9 files changed, 698 insertions(+), 4 deletions(-) create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.test.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.test.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.test.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/DynamicAppealsListFilterPanel.tsx create mode 100644 src/components/Tool/Appeal/List/AppealsListFilterPanel/contactsCount.graphql diff --git a/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx index 07dbb8e98..03ac5e7dc 100644 --- a/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx @@ -4,14 +4,14 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { DynamicAppealsListFilterPanel } from '../../List/AppealsListFilterPanel/DynamicAppealsListFilterPanel'; export const AppealsLeftPanel: React.FC = () => { - const { filterData, filtersLoading, viewMode } = React.useContext( - AppealsContext, - ) as AppealsType; + const { filterData, filtersLoading, toggleFilterPanel, viewMode } = + React.useContext(AppealsContext) as AppealsType; return viewMode !== TableViewModeEnum.Flows ? ( -

List Filters

+ ) : filterData && !filtersLoading ? (

Flows Filters

) : null; diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.test.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.test.tsx new file mode 100644 index 000000000..6da5ec6b3 --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import theme from 'src/theme'; +import { + AppealStatusEnum, + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { AppealsListFilterPanel } from './AppealsListFilterPanel'; +import { ContactsCountQuery } from './contactsCount.generated'; + +const accountListId = 'accountListId'; +const appealId = 'appealId'; +const activeFilters = { status: [AppealStatusEnum.Asked] }; +const selectedIds = ['1', '2']; +const router = { + query: { accountListId, appealId: ['1', 'list'] }, + isReady: true, +}; +const onClose = jest.fn(); +const setActiveFilters = jest.fn(); +const deselectAll = jest.fn(); + +const Components = ({ ids = selectedIds }) => ( + + + + mocks={{ + ContactsCount: { + contacts: { + totalCount: 5, + }, + }, + }} + > + + + + + + + + +); + +describe('AppealsListFilterPanel', () => { + beforeEach(() => { + onClose.mockClear(); + }); + + it('should disable the button if no contacts are selected', async () => { + const { getAllByRole } = render(); + + expect( + getAllByRole('button', { name: 'Export 0 Selected' })[0], + ).toBeDisabled(); + expect( + getAllByRole('button', { name: 'Export 0 Selected' })[1], + ).toBeDisabled(); + }); + + it('default', async () => { + const { getByText, getByRole, getAllByRole } = render(); + + expect(getByText('Given')).toBeInTheDocument(); + expect(getByText('Committed')).toBeInTheDocument(); + expect(getByText('Excluded')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByRole('button', { name: /given 5/i })).toBeInTheDocument(); + expect(getByRole('button', { name: /received 5/i })).toBeInTheDocument(); + expect(getByRole('button', { name: /asked 5/i })).toBeInTheDocument(); + }); + + expect(getByText('Add Contact to Appeal')).toBeInTheDocument(); + expect(getByText('Delete Appeal')).toBeInTheDocument(); + + expect( + getAllByRole('button', { name: 'Export 2 Selected' })[0], + ).toBeInTheDocument(); + expect( + getAllByRole('button', { name: 'Export 2 Selected' })[1], + ).not.toBeDisabled(); + expect(getByRole('button', { name: 'Select Contact' })).toBeInTheDocument(); + }); + + it('should filter contacts on appeal status', async () => { + const { getByText } = render(); + + expect(deselectAll).not.toHaveBeenCalled(); + expect(setActiveFilters).not.toHaveBeenCalled(); + + userEvent.click(getByText('Given')); + expect(deselectAll).toHaveBeenCalled(); + expect(setActiveFilters).toHaveBeenCalledWith({ + ...activeFilters, + appealStatus: AppealStatusEnum.Processed, + }); + }); +}); diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.tsx new file mode 100644 index 000000000..20ad3fea7 --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanel.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import Close from '@mui/icons-material/Close'; +import { + Box, + BoxProps, + IconButton, + List, + Slide, + Typography, +} from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useTranslation } from 'react-i18next'; +import { + AppealStatusEnum, + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { AppealsListFilterPanelButton } from './AppealsListFilterPanelButton'; +import { AppealsListFilterPanelItem } from './AppealsListFilterPanelItem'; +import { useContactsCountQuery } from './contactsCount.generated'; + +const FilterHeader = styled(Box)(({ theme }) => ({ + padding: theme.spacing(2), + borderBottom: '1px solid', + borderBottomColor: theme.palette.grey[200], +})); + +const FilterList = styled(List)(({ theme }) => ({ + '& .MuiListItemIcon-root': { + minWidth: '37px', + }, + '& .FilterListItemMultiselect-root': { + marginBottom: theme.spacing(4), + }, +})); + +export enum ContextTypesEnum { + Contacts = 'contacts', + Appeals = 'appeals', +} +export interface FilterPanelProps { + onClose: () => void; +} + +export const AppealsListFilterPanel: React.FC = ({ + onClose, +}) => { + const { t } = useTranslation(); + const { + accountListId, + appealId, + activeFilters, + setActiveFilters, + selectedIds, + deselectAll, + } = React.useContext(AppealsContext) as AppealsType; + + const { data: askedCount, loading: askedLoading } = useContactsCountQuery({ + variables: { + accountListId: accountListId || '', + contactsFilter: { + appeal: [appealId || ''], + appealStatus: AppealStatusEnum.Asked, + }, + }, + }); + + const { data: excludedCount, loading: excludedLoading } = + useContactsCountQuery({ + variables: { + accountListId: accountListId || '', + contactsFilter: { + appeal: [appealId || ''], + appealStatus: AppealStatusEnum.Excluded, + }, + }, + }); + + const { data: committedCount, loading: committedLoading } = + useContactsCountQuery({ + variables: { + accountListId: accountListId || '', + contactsFilter: { + appeal: [appealId || ''], + appealStatus: AppealStatusEnum.NotReceived, + }, + }, + }); + + const { data: givenCount, loading: givenLoading } = useContactsCountQuery({ + variables: { + accountListId: accountListId || '', + contactsFilter: { + appeal: [appealId || ''], + appealStatus: AppealStatusEnum.Processed, + }, + }, + }); + + const { data: receivedCount, loading: receivedLoading } = + useContactsCountQuery({ + variables: { + accountListId: accountListId || '', + contactsFilter: { + appeal: [appealId || ''], + appealStatus: AppealStatusEnum.ReceivedNotProcessed, + }, + }, + }); + + const handleFilterItemClick = (newAppealListView: AppealStatusEnum) => { + deselectAll(); + setActiveFilters({ + ...activeFilters, + appealStatus: newAppealListView, + }); + }; + + const appealListView = activeFilters.appealStatus; + const noContactsSelected = !selectedIds.length; + + // TODO - Finish this function off + const handleFilterButtonClick = () => {}; + + return ( + +
+ +
+ + + {t('Appeals')} + + + + + + + + + + + + + + + + + + + +
+
+
+
+ ); +}; diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.test.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.test.tsx new file mode 100644 index 000000000..77c25beb7 --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import theme from 'src/theme'; +import { + AppealsListFilterPanelButton, + AppealsListFilterPanelButtonProps, +} from './AppealsListFilterPanelButton'; + +const onClick = jest.fn(); +const initialProps = { + title: 'Test Title', + onClick, + buttonText: 'buttonText', + disabled: false, +}; + +const Components = (props: AppealsListFilterPanelButtonProps) => ( + + + +); + +describe('AppealsListFilterPanelButton', () => { + it('default', () => { + const { getByText } = render(); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByText(initialProps.buttonText)).toBeInTheDocument(); + }); + + it('should be disabled', () => { + const { getByText } = render( + , + ); + expect(getByText(initialProps.buttonText)).toBeDisabled(); + }); + + it('should fire onClick', () => { + const { getByText } = render(); + + expect(onClick).not.toHaveBeenCalled(); + userEvent.click(getByText(initialProps.buttonText)); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.tsx new file mode 100644 index 000000000..514ff5a7b --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelButton.tsx @@ -0,0 +1,67 @@ +import React, { ReactElement } from 'react'; +import { + Box, + Button, + ButtonTypeMap, + ListItem, + ListItemText, +} from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; +import theme from 'src/theme'; + +const useStyles = makeStyles()(() => ({ + li: { + borderBottom: `1px solid ${theme.palette.cruGrayLight.main}`, + paddingBottom: theme.spacing(3), + }, + itemBox: { + width: '100%', + }, + itemButton: { + width: '100%', + textTransform: 'none', + }, +})); + +export interface AppealsListFilterPanelButtonProps { + title: string; + onClick: () => void; + buttonText: string; + buttonError?: ButtonTypeMap['props']['color']; + buttonVariant?: ButtonTypeMap['props']['variant']; + disabled?: boolean; +} + +export const AppealsListFilterPanelButton = ({ + title, + onClick, + buttonText, + buttonError = 'primary', + buttonVariant = 'contained', + disabled, +}: AppealsListFilterPanelButtonProps): ReactElement => { + const { classes } = useStyles(); + + return ( + + + + + + + ); +}; diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.test.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.test.tsx new file mode 100644 index 000000000..39f540ac5 --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import theme from 'src/theme'; +import { AppealStatusEnum } from '../../AppealsContext/AppealsContext'; +import { + AppealsListFilterPanelItem, + AppealsListFilterPanelItemProps, +} from './AppealsListFilterPanelItem'; + +const onClick = jest.fn(); +const initialProps = { + id: AppealStatusEnum.Asked, + title: 'Test Title', + isSelected: false, + count: 15, + loading: false, + onClick, +}; + +const Components = (props: AppealsListFilterPanelItemProps) => ( + + + +); + +describe('AppealsListFilterPanelItem', () => { + it('should show a skeleton when loading', () => { + const props = { ...initialProps, loading: true, count: undefined }; + const { getByText, getByTestId, queryByText } = render( + , + ); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByTestId('panel-item-skeleton')).toBeInTheDocument(); + expect(queryByText(initialProps.count)).not.toBeInTheDocument(); + }); + + it('default', () => { + const { getByText } = render(); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByText(initialProps.count)).toBeInTheDocument(); + }); + + it('should fire onClick', () => { + const { getByText } = render(); + + expect(onClick).not.toHaveBeenCalled(); + userEvent.click(getByText(initialProps.title)); + expect(onClick).toHaveBeenCalled(); + }); + + describe('Count styles', () => { + it('shows excluded count', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByTestId('panel-item-count-box')).toHaveStyle({ + color: '#ffffff', + 'background-color': theme.palette.statusDanger.main, + }); + }); + + it('shows given count', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByTestId('panel-item-count-box')).toHaveStyle({ + color: '#ffffff', + 'background-color': theme.palette.mpdxGreen.main, + }); + }); + + it('shows received count', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByTestId('panel-item-count-box')).toHaveStyle({ + color: theme.palette.cruGrayDark.main, + 'background-color': theme.palette.cruYellow.main, + }); + }); + + it('shows asked count', () => { + const { getByText, getByTestId } = render( + , + ); + + expect(getByText(initialProps.title)).toBeInTheDocument(); + expect(getByTestId('panel-item-count-box')).toHaveStyle({ + color: '#ffffff', + 'background-color': theme.palette.cruGrayMedium.main, + }); + }); + }); +}); diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.tsx new file mode 100644 index 000000000..3f9c9a552 --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/AppealsListFilterPanelItem.tsx @@ -0,0 +1,107 @@ +import React, { ReactElement } from 'react'; +import ArrowForwardIos from '@mui/icons-material/ArrowForwardIos'; +import { + Box, + ListItem, + ListItemText, + Skeleton, + Typography, +} from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; +import theme from 'src/theme'; +import { AppealStatusEnum } from '../../AppealsContext/AppealsContext'; + +const useStyles = makeStyles()(() => ({ + li: { + borderBottom: `1px solid ${theme.palette.cruGrayLight.main}`, + '&:hover': { + backgroundColor: theme.palette.cruGrayLight.main, + }, + }, +})); + +const countBoxStyles = (status: AppealStatusEnum): React.CSSProperties => { + const styles: React.CSSProperties = { + color: '#ffffff', + borderRadius: 5, + fontWeight: 600, + padding: '2px 8px 2px 8px', + }; + switch (status) { + case AppealStatusEnum.Excluded: + return { + ...styles, + backgroundColor: theme.palette.statusDanger.main, + }; + case AppealStatusEnum.Asked: + return { + ...styles, + backgroundColor: theme.palette.cruGrayMedium.main, + border: '1px solid #ffffff', + }; + case AppealStatusEnum.Processed: + return { + ...styles, + backgroundColor: theme.palette.mpdxGreen.main, + }; + default: + return { + ...styles, + backgroundColor: theme.palette.cruYellow.main, + color: theme.palette.cruGrayDark.main, + }; + } +}; + +export interface AppealsListFilterPanelItemProps { + id: AppealStatusEnum; + title: string; + isSelected: boolean; + count?: number; + loading: boolean; + onClick: (newAppealListView: AppealStatusEnum) => void; +} + +export const AppealsListFilterPanelItem = ({ + id, + title, + isSelected, + count, + loading, + onClick, +}: AppealsListFilterPanelItemProps): ReactElement => { + const { classes } = useStyles(); + + const handleClick = () => { + onClick(id); + }; + + return ( + + + + {loading && count === undefined && ( + + )} + + {count} + + + + ); +}; diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/DynamicAppealsListFilterPanel.tsx b/src/components/Tool/Appeal/List/AppealsListFilterPanel/DynamicAppealsListFilterPanel.tsx new file mode 100644 index 000000000..43cd970ad --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/DynamicAppealsListFilterPanel.tsx @@ -0,0 +1,14 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const preloadAppealsListFilterPanel = () => + import( + /* webpackChunkName: "AppealsListFilterPanel" */ './AppealsListFilterPanel' + ).then(({ AppealsListFilterPanel }) => AppealsListFilterPanel); + +export const DynamicAppealsListFilterPanel = dynamic( + preloadAppealsListFilterPanel, + { + loading: DynamicComponentPlaceholder, + }, +); diff --git a/src/components/Tool/Appeal/List/AppealsListFilterPanel/contactsCount.graphql b/src/components/Tool/Appeal/List/AppealsListFilterPanel/contactsCount.graphql new file mode 100644 index 000000000..2ada66d2a --- /dev/null +++ b/src/components/Tool/Appeal/List/AppealsListFilterPanel/contactsCount.graphql @@ -0,0 +1,8 @@ +query ContactsCount( + $accountListId: ID! + $contactsFilter: ContactFilterSetInput +) { + contacts(accountListId: $accountListId, contactsFilter: $contactsFilter) { + totalCount + } +} From 238cd04eb75a1ff8bbc85e96b8c60eb2e7df29aa Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:46:33 -0400 Subject: [PATCH 08/18] 3. List Contact row --- .../Contacts/ContactRow/ContactRow.test.tsx | 90 ++++++------ .../Contacts/ContactRow/ContactRow.tsx | 7 +- .../List/ContactRow/ContactRow.test.tsx | 126 +++++++++++++++++ .../Appeal/List/ContactRow/ContactRow.tsx | 130 ++++++++++++++++++ .../Appeal/List/ContactsList/ContactsList.tsx | 9 +- 5 files changed, 312 insertions(+), 50 deletions(-) create mode 100644 src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx create mode 100644 src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx diff --git a/src/components/Contacts/ContactRow/ContactRow.test.tsx b/src/components/Contacts/ContactRow/ContactRow.test.tsx index cf66fb488..b8ec1fd67 100644 --- a/src/components/Contacts/ContactRow/ContactRow.test.tsx +++ b/src/components/Contacts/ContactRow/ContactRow.test.tsx @@ -8,6 +8,10 @@ import { ContactsWrapper } from 'pages/accountLists/[accountListId]/contacts/Con import { TaskModalEnum } from 'src/components/Task/Modal/TaskModal'; import theme from 'src/theme'; import useTaskModal from '../../../hooks/useTaskModal'; +import { + ContactsContext, + ContactsType, +} from '../ContactsContext/ContactsContext'; import { ContactRow } from './ContactRow'; import { ContactRowFragment, @@ -58,6 +62,34 @@ const contact = gqlMock(ContactRowFragmentDoc, { jest.mock('../../../hooks/useTaskModal'); const openTaskModal = jest.fn(); +const setContactFocus = jest.fn(); +const contactDetailsOpen = true; +const toggleSelectionById = jest.fn(); +const isRowChecked = jest.fn(); + +const Components = () => ( + + + + + + + + + + + +); beforeEach(() => { (useTaskModal as jest.Mock).mockReturnValue({ @@ -68,17 +100,7 @@ beforeEach(() => { describe('ContactsRow', () => { it('default', () => { - const { getByText } = render( - - - - - - - - - , - ); + const { getByText } = render(); expect( getByText( @@ -93,40 +115,19 @@ describe('ContactsRow', () => { }); it('should render check event', async () => { - const { getByRole } = render( - - - - - - - - - , - ); + const { getByRole } = render(); const checkbox = getByRole('checkbox'); expect(checkbox).not.toBeChecked(); userEvent.click(checkbox); - // TODO: Find a way to check that click event was pressed. + expect(checkbox).toBeChecked(); }); it('should open log task modal', async () => { - const { getByTitle } = render( - - - - - - - - - , - ); + const { getByTitle } = render(); const taskButton = getByTitle('Log Task'); userEvent.click(taskButton); - // TODO: Find a way to check that click event was pressed. expect(openTaskModal).toHaveBeenCalledWith({ view: TaskModalEnum.Log, defaultValues: { @@ -136,20 +137,15 @@ describe('ContactsRow', () => { }); it('should render contact select event', () => { - const { getByTestId } = render( - - - - - - - - - , - ); + isRowChecked.mockImplementationOnce((id) => id === contact.id); + + const { getByTestId } = render(); + + expect(setContactFocus).not.toHaveBeenCalled(); const rowButton = getByTestId('rowButton'); userEvent.click(rowButton); - // TODO: Find a way to check that click event was pressed. + + expect(setContactFocus).toHaveBeenCalledWith(contact.id); }); }); diff --git a/src/components/Contacts/ContactRow/ContactRow.tsx b/src/components/Contacts/ContactRow/ContactRow.tsx index ce02d3eb7..e79ba64bf 100644 --- a/src/components/Contacts/ContactRow/ContactRow.tsx +++ b/src/components/Contacts/ContactRow/ContactRow.tsx @@ -23,7 +23,10 @@ import { preloadContactsRightPanel } from '../ContactsRightPanel/DynamicContacts import { StarContactIconButton } from '../StarContactIconButton/StarContactIconButton'; import { ContactRowFragment } from './ContactRow.generated'; -const ListItemButton = styled(ButtonBase)(({ theme }) => ({ +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx + +export const ListItemButton = styled(ButtonBase)(({ theme }) => ({ flex: '1 1 auto', textAlign: 'left', padding: theme.spacing(0, 0.5, 0, 2), @@ -38,7 +41,7 @@ const ListItemButton = styled(ButtonBase)(({ theme }) => ({ }, })); -const StyledCheckbox = styled(Checkbox, { +export const StyledCheckbox = styled(Checkbox, { shouldForwardProp: (prop) => prop !== 'value', })(() => ({ '&:hover': { diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx new file mode 100644 index 000000000..d6cc83858 --- /dev/null +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider, gqlMock } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { + ContactRowFragment, + ContactRowFragmentDoc, +} from 'src/components/Contacts/ContactRow/ContactRow.generated'; +import theme from 'src/theme'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; +import { ContactRow } from './ContactRow'; + +const accountListId = 'account-list-1'; + +const router = { + query: { accountListId }, + isReady: true, +}; + +const contactMock = { + id: 'test-id', + lateAt: null, + name: 'Test, Name', + people: { + nodes: [ + { + anniversaryDay: null, + anniversaryMonth: null, + birthdayDay: null, + birthdayMonth: null, + }, + ], + }, + pledgeAmount: null, + pledgeCurrency: 'CAD', + pledgeFrequency: null, + primaryAddress: { + city: 'Any City', + country: null, + postalCode: 'Test', + state: 'TT', + street: '1111 Test Street', + updatedAt: new Date('2021-06-21T03:40:05-06:00').toISOString(), + }, + starred: false, + status: null, + uncompletedTasksCount: 0, +}; + +const contact = gqlMock(ContactRowFragmentDoc, { + mocks: contactMock, +}); + +const setContactFocus = jest.fn(); +const contactDetailsOpen = true; +const toggleSelectionById = jest.fn(); +const isRowChecked = jest.fn(); + +const Components = () => ( + + + + + + + + + + + +); + +describe('ContactsRow', () => { + it('default', () => { + const { getByText } = render(); + + expect(getByText('Test, Name')).toBeInTheDocument(); + expect(getByText('CA$0')).toBeInTheDocument(); + }); + + it('should render check event', async () => { + const { getByRole } = render(); + + const checkbox = getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + it('should open contact on click', () => { + isRowChecked.mockImplementationOnce((id) => id === contact.id); + + const { getByTestId } = render(); + + expect(setContactFocus).not.toHaveBeenCalled(); + + const rowButton = getByTestId('rowButton'); + userEvent.click(rowButton); + + expect(setContactFocus).toHaveBeenCalledWith(contact.id); + }); + + it('should rendered checked', () => { + isRowChecked.mockImplementationOnce(() => true); + + const { getByRole } = render(); + + const checkbox = getByRole('checkbox'); + expect(checkbox).toBeChecked(); + }); +}); diff --git a/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx new file mode 100644 index 000000000..9eb572b66 --- /dev/null +++ b/src/components/Tool/Appeal/List/ContactRow/ContactRow.tsx @@ -0,0 +1,130 @@ +import React, { useMemo } from 'react'; +import { + Box, + Grid, + Hidden, + ListItemIcon, + ListItemText, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { + ListItemButton, + StyledCheckbox, +} from 'src/components/Contacts/ContactRow/ContactRow'; +import { preloadContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/DynamicContactsRightPanel'; +import { Contact } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; +import { getLocalizedPledgeFrequency } from 'src/utils/functions/getLocalizedPledgeFrequency'; +import { + AppealsContext, + AppealsType, +} from '../../AppealsContext/AppealsContext'; + +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Contacts/ContactRow/ContactRow.tsx + +type ContactRow = Pick< + Contact, + | 'id' + | 'name' + | 'pledgeAmount' + | 'pledgeFrequency' + | 'pledgeCurrency' + | 'pledgeReceived' +>; +interface Props { + contact: ContactRow; + useTopMargin?: boolean; +} + +export const ContactRow: React.FC = ({ contact, useTopMargin }) => { + const { + isRowChecked: isChecked, + contactDetailsOpen, + setContactFocus: onContactSelected, + toggleSelectionById: onContactCheckToggle, + } = React.useContext(AppealsContext) as AppealsType; + const { t } = useTranslation(); + const locale = useLocale(); + + const handleContactClick = () => { + onContactSelected(contact.id); + }; + + const { + id: contactId, + name, + pledgeAmount, + pledgeCurrency, + pledgeFrequency, + } = contact; + + const pledge = useMemo( + () => + pledgeAmount && pledgeCurrency + ? currencyFormat(pledgeAmount, pledgeCurrency, locale) + : pledgeAmount || currencyFormat(0, pledgeCurrency, locale), + [pledgeAmount, pledgeAmount, pledgeCurrency, locale], + ); + const frequency = useMemo( + () => + (pledgeFrequency && getLocalizedPledgeFrequency(t, pledgeFrequency)) || + '', + [pledgeFrequency], + ); + + return ( + + + + event.stopPropagation()} + onChange={() => onContactCheckToggle(contact.id)} + value={isChecked} + /> + + + + + + + {name} + + + } + /> + + + + + + {`${pledge} ${frequency}`} + + + + + + + + + + ); +}; diff --git a/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx index 8a3153d6a..e0a2c17a6 100644 --- a/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx +++ b/src/components/Tool/Appeal/List/ContactsList/ContactsList.tsx @@ -13,6 +13,7 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { ContactRow } from '../ContactRow/ContactRow'; interface ContactsListProps { appealInfo?: AppealQuery; @@ -41,7 +42,13 @@ export const ContactsList: React.FC = ({ style={{ height: `calc(100vh - ${navBarHeight} - ${headerHeight} - ${appealHeaderInfoHeight})`, }} - itemContent={(index, contact) =>

{contact.name}

} + itemContent={(index, contact) => ( + + )} groupBy={(item) => ({ label: item.name[0].toUpperCase() })} endReached={() => data?.contacts?.pageInfo.hasNextPage && From 0cee12b06873ceeec3d01c2f1f9df8dae4e5a342 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:51:27 -0400 Subject: [PATCH 09/18] 3. Contact Right Panel. Context changes were needed on a few contact components to be able to reuse them. --- .../ContactDetails/ContactDetails.tsx | 18 +- .../ContactDetailsHeader.tsx | 4 + .../ContactDetailsMoreActions.tsx | 18 +- .../ContactsRightPanel/ContactsRightPanel.tsx | 9 +- .../Tool/Appeal/AppealsDetailsPage.test.tsx | 260 ++++++++++++++++++ .../Tool/Appeal/AppealsDetailsPage.tsx | 11 +- src/components/Tool/Appeal/appealMockData.ts | 10 + src/lib/contactContextTypes.ts | 4 + 8 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 src/components/Tool/Appeal/AppealsDetailsPage.test.tsx create mode 100644 src/components/Tool/Appeal/appealMockData.ts create mode 100644 src/lib/contactContextTypes.ts diff --git a/src/components/Contacts/ContactDetails/ContactDetails.tsx b/src/components/Contacts/ContactDetails/ContactDetails.tsx index 82e6691d3..f81695958 100644 --- a/src/components/Contacts/ContactDetails/ContactDetails.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetails.tsx @@ -5,6 +5,11 @@ import TabPanel from '@mui/lab/TabPanel'; import { Box, Tab } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; +import { + AppealsContext, + AppealsType, +} from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; +import { ContactContextTypesEnum } from 'src/lib/contactContextTypes'; import theme from '../../../theme'; import { ContactsContext, @@ -33,8 +38,9 @@ import { } from './ContactReferralTab/DynamicContactReferralTab'; import { ContactTasksTab } from './ContactTasksTab/ContactTasksTab'; -interface Props { +interface ContactDetailsProps { onClose: () => void; + contextType?: ContactContextTypesEnum; } const ContactDetailsWrapper = styled(Box)(({}) => ({ @@ -83,7 +89,10 @@ export enum TabKey { Notes = 'Notes', } -export const ContactDetails: React.FC = ({ onClose }) => { +export const ContactDetails: React.FC = ({ + onClose, + contextType = ContactContextTypesEnum.Contacts, +}) => { const { t } = useTranslation(); const [contactDetailsLoaded, setContactDetailsLoaded] = useState(false); @@ -91,7 +100,9 @@ export const ContactDetails: React.FC = ({ onClose }) => { accountListId, contactDetailsId: contactId, setContactFocus, - } = React.useContext(ContactsContext) as ContactsType; + } = contextType === ContactContextTypesEnum.Contacts + ? (React.useContext(ContactsContext) as ContactsType) + : (React.useContext(AppealsContext) as AppealsType); const { selectedTabKey, handleTabChange: handleChange } = React.useContext( ContactDetailContext, @@ -106,6 +117,7 @@ export const ContactDetails: React.FC = ({ onClose }) => { onClose={onClose} contactDetailsLoaded={contactDetailsLoaded} setContactDetailsLoaded={setContactDetailsLoaded} + contextType={contextType} /> )} diff --git a/src/components/Contacts/ContactDetails/ContactDetailsHeader/ContactDetailsHeader.tsx b/src/components/Contacts/ContactDetails/ContactDetailsHeader/ContactDetailsHeader.tsx index 101ea8146..fccd6bf72 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsHeader/ContactDetailsHeader.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsHeader/ContactDetailsHeader.tsx @@ -4,6 +4,7 @@ import { Avatar, Box, IconButton, Skeleton, Typography } from '@mui/material'; import { styled } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { StatusEnum } from 'src/graphql/types.generated'; +import { ContactContextTypesEnum } from 'src/lib/contactContextTypes'; import theme from '../../../../theme'; import { StarContactIconButton } from '../../StarContactIconButton/StarContactIconButton'; import { @@ -27,6 +28,7 @@ interface Props { onClose: () => void; setContactDetailsLoaded: (value: boolean) => void; contactDetailsLoaded: boolean; + contextType?: ContactContextTypesEnum; } const HeaderBar = styled(Box)(({}) => ({ @@ -68,6 +70,7 @@ export const ContactDetailsHeader: React.FC = ({ onClose, setContactDetailsLoaded, contactDetailsLoaded, + contextType, }: Props) => { const { data } = useGetContactDetailsHeaderQuery({ variables: { accountListId, contactId }, @@ -127,6 +130,7 @@ export const ContactDetailsHeader: React.FC = ({ contactId={contactId} status={data?.contact.status ?? StatusEnum.Unresponsive} onClose={onClose} + contextType={contextType} /> void; + contextType?: ContactContextTypesEnum; } export const ContactDetailsMoreAcitions: React.FC< ContactDetailsMoreAcitionsProps -> = ({ contactId, status, onClose }) => { +> = ({ + contactId, + status, + onClose, + contextType = ContactContextTypesEnum.Contacts, +}) => { const { openTaskModal, preloadTaskModal } = useTaskModal(); const { t } = useTranslation(); - const { accountListId } = React.useContext(ContactsContext) as ContactsType; + const { accountListId } = + contextType === ContactContextTypesEnum.Contacts + ? (React.useContext(ContactsContext) as ContactsType) + : (React.useContext(AppealsContext) as AppealsType); const { referralsModalOpen, diff --git a/src/components/Contacts/ContactsRightPanel/ContactsRightPanel.tsx b/src/components/Contacts/ContactsRightPanel/ContactsRightPanel.tsx index 4ce9c2d5f..456cbba22 100644 --- a/src/components/Contacts/ContactsRightPanel/ContactsRightPanel.tsx +++ b/src/components/Contacts/ContactsRightPanel/ContactsRightPanel.tsx @@ -1,14 +1,19 @@ import React from 'react'; +import { ContactContextTypesEnum } from 'src/lib/contactContextTypes'; import { ContactDetailProvider } from '../ContactDetails/ContactDetailContext'; import { ContactDetails } from '../ContactDetails/ContactDetails'; interface Props { onClose: () => void; + contextType?: ContactContextTypesEnum; } -export const ContactsRightPanel: React.FC = ({ onClose }) => { +export const ContactsRightPanel: React.FC = ({ + onClose, + contextType, +}) => { return ( - + ); }; diff --git a/src/components/Tool/Appeal/AppealsDetailsPage.test.tsx b/src/components/Tool/Appeal/AppealsDetailsPage.test.tsx new file mode 100644 index 000000000..ac59647c7 --- /dev/null +++ b/src/components/Tool/Appeal/AppealsDetailsPage.test.tsx @@ -0,0 +1,260 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { ListHeaderCheckBoxState } from 'src/components/Shared/Header/ListHeader'; +import { AppealQuery } from 'src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.generated'; +import { ContactsQuery } from 'src/components/Tool/Appeal/AppealsContext/contacts.generated'; +import { + PledgeFrequencyEnum, + SendNewsletterEnum, + StatusEnum, +} from 'src/graphql/types.generated'; +import { useMassSelection } from 'src/hooks/useMassSelection'; +import theme from 'src/theme'; +import AppealsDetailsPage from './AppealsDetailsPage'; + +const accountListId = 'account-list-1'; + +const defaultRouter = { + query: { accountListId }, + isReady: true, +}; + +const contact = { + id: '1', + name: 'Test Person', + avatar: 'img.png', + primaryAddress: null, + status: StatusEnum.PartnerFinancial, + pledgeAmount: 100, + pledgeFrequency: PledgeFrequencyEnum.Monthly, + pledgeCurrency: 'USD', + pledgeReceived: true, + lateAt: new Date().toISOString(), + sendNewsletter: SendNewsletterEnum.Both, + starred: false, + uncompletedTasksCount: 0, + people: { nodes: [] }, +}; + +const mockResponse = { + contacts: { + nodes: [contact], + totalCount: 1, + pageInfo: { endCursor: 'Mg', hasNextPage: false }, + }, + allContacts: { + totalCount: 1, + }, +}; + +const mockAppealResponse = { + appeal: { + amount: 4531, + amountCurrency: 'USD', + id: '9d660aed-1291-4c5b-874d-409a94b5ed3b', + name: 'End Of Year Gift', + pledgesAmountNotReceivedNotProcessed: 2000, + pledgesAmountProcessed: 50, + pledgesAmountReceivedNotProcessed: 50, + pledgesAmountTotal: 2115.93, + }, +}; + +jest.mock('src/hooks/useMassSelection'); + +(useMassSelection as jest.Mock).mockReturnValue({ + ids: [], + selectionType: ListHeaderCheckBoxState.Unchecked, + isRowChecked: jest.fn(), + toggleSelectAll: jest.fn(), + toggleSelectionById: jest.fn(), +}); + +const mockEnqueue = jest.fn(); + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = ({ router = defaultRouter }: { router?: object }) => ( + + + + + mocks={{ + Contacts: mockResponse, + Appeal: mockAppealResponse, + }} + > + + + + + + + + + +); + +describe('AppealsDetailsPage', () => { + describe('Contact drawer', () => { + it('should open and close on List view', async () => { + const { getByText, findByTestId, getByRole, queryByRole } = render( + , + ); + + await waitFor(() => expect(getByText('Test Person')).toBeInTheDocument()); + + expect(queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument(); + + const contactRow = await findByTestId('rowButton'); + userEvent.click(contactRow); + + await waitFor(() => + expect(getByRole('tab', { name: 'Tasks' })).toBeInTheDocument(), + ); + + userEvent.click(getByRole('img', { name: 'Close' })); + + await waitFor(() => + expect(queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument(), + ); + }); + + it('should open and close on Flows view', async () => { + const { getAllByText, getByRole, queryByRole } = render( + , + ); + + await waitFor(() => expect(getAllByText('Test Person').length).toBe(5)); + + expect(queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument(); + + userEvent.click(getAllByText('Test Person')[0]); + + await waitFor(() => + expect(getByRole('tab', { name: 'Tasks' })).toBeInTheDocument(), + ); + + userEvent.click(getByRole('img', { name: 'Close' })); + + await waitFor(() => + expect(queryByRole('tab', { name: 'Tasks' })).not.toBeInTheDocument(), + ); + }); + }); + + describe('Filters', () => { + it('should open and close on List view', async () => { + const { getByRole, queryByRole } = render( + , + ); + + await waitFor(() => + expect( + getByRole('img', { name: /toggle filter panel/i }), + ).toBeInTheDocument(), + ); + expect( + queryByRole('heading', { name: /export to csv/i }), + ).not.toBeInTheDocument(); + + userEvent.click(getByRole('img', { name: /toggle filter panel/i })); + + await waitFor(() => + expect( + getByRole('heading', { name: /export to csv/i }), + ).toBeInTheDocument(), + ); + + userEvent.click(getByRole('img', { name: 'Close' })); + + await waitFor(() => + expect( + queryByRole('heading', { name: /export to csv/i }), + ).not.toBeInTheDocument(), + ); + }); + + it('should open and close on Flows view', async () => { + const { getByRole, queryByRole } = render( + , + ); + + await waitFor(() => + expect( + getByRole('img', { name: /toggle filter panel/i }), + ).toBeInTheDocument(), + ); + expect( + queryByRole('heading', { name: /see more filters/i }), + ).not.toBeInTheDocument(); + + userEvent.click(getByRole('img', { name: /toggle filter panel/i })); + + await waitFor(() => + expect( + getByRole('heading', { name: /see more filters/i }), + ).toBeInTheDocument(), + ); + + userEvent.click(getByRole('img', { name: 'Close' })); + + await waitFor(() => + expect( + queryByRole('heading', { name: /see more filters/i }), + ).not.toBeInTheDocument(), + ); + }); + }); +}); diff --git a/src/components/Tool/Appeal/AppealsDetailsPage.tsx b/src/components/Tool/Appeal/AppealsDetailsPage.tsx index 9a6b951ec..8021f1515 100644 --- a/src/components/Tool/Appeal/AppealsDetailsPage.tsx +++ b/src/components/Tool/Appeal/AppealsDetailsPage.tsx @@ -1,12 +1,14 @@ import React, { useContext } from 'react'; +import { DynamicContactsRightPanel } from 'src/components/Contacts/ContactsRightPanel/DynamicContactsRightPanel'; import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { ContactContextTypesEnum } from 'src/lib/contactContextTypes'; import { AppealsLeftPanel } from './AppealDetails/AppealLeftPanel/AppealsLeftPanel'; import { AppealsMainPanel } from './AppealDetails/AppealsMainPanel/AppealsMainPanel'; import { AppealsContext, AppealsType } from './AppealsContext/AppealsContext'; const AppealsDetailsPage: React.FC = () => { - const { filterPanelOpen, contactDetailsOpen } = useContext( + const { filterPanelOpen, setContactFocus, contactDetailsOpen } = useContext( AppealsContext, ) as AppealsType; @@ -16,7 +18,12 @@ const AppealsDetailsPage: React.FC = () => { leftOpen={filterPanelOpen} leftWidth="290px" mainContent={} - rightPanel={

Contact

} + rightPanel={ + setContactFocus(undefined, true)} + contextType={ContactContextTypesEnum.Appeals} + /> + } rightOpen={contactDetailsOpen} rightWidth="60%" headerHeight={headerHeight} diff --git a/src/components/Tool/Appeal/appealMockData.ts b/src/components/Tool/Appeal/appealMockData.ts new file mode 100644 index 000000000..f679574ce --- /dev/null +++ b/src/components/Tool/Appeal/appealMockData.ts @@ -0,0 +1,10 @@ +export const appealInfo = { + id: '1', + name: 'Test Appeal', + amount: 100, + amountCurrency: 'USD', + pledgesAmountProcessed: 50, + pledgesAmountReceivedNotProcessed: 25, + pledgesAmountNotReceivedNotProcessed: 25, + pledgesAmountTotal: 100, +}; diff --git a/src/lib/contactContextTypes.ts b/src/lib/contactContextTypes.ts new file mode 100644 index 000000000..ed5bf4cf7 --- /dev/null +++ b/src/lib/contactContextTypes.ts @@ -0,0 +1,4 @@ +export enum ContactContextTypesEnum { + Contacts = 'contacts', + Appeals = 'appeals', +} From 438ec8d09e3ed9cf3400e620bad1f8895b1b09f8 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:57:01 -0400 Subject: [PATCH 10/18] 4. Flows View - Building Flows page --- src/components/Shared/Filters/FilterPanel.tsx | 17 ++- .../AppealLeftPanel/AppealsLeftPanel.tsx | 22 +++- .../AppealsMainPanel/AppealsMainPanel.tsx | 25 +++- .../Tool/Appeal/Flow/ContactFlow.tsx | 113 ++++++++++++++++++ .../Tool/Appeal/Flow/DynamicContactFlow.tsx | 10 ++ 5 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/components/Tool/Appeal/Flow/ContactFlow.tsx create mode 100644 src/components/Tool/Appeal/Flow/DynamicContactFlow.tsx diff --git a/src/components/Shared/Filters/FilterPanel.tsx b/src/components/Shared/Filters/FilterPanel.tsx index e382403ba..46d9b35d2 100644 --- a/src/components/Shared/Filters/FilterPanel.tsx +++ b/src/components/Shared/Filters/FilterPanel.tsx @@ -20,6 +20,10 @@ import { import { styled, useTheme } from '@mui/material/styles'; import { filter } from 'lodash'; import { useTranslation } from 'react-i18next'; +import { + AppealsContext, + AppealsType, +} from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; import { ActivityTypeEnum, ContactFilterNewsletterEnum, @@ -100,6 +104,10 @@ type FilterInput = ContactFilterSetInput & TaskFilterSetInput & ReportContactFilterSetInput; +export enum ContextTypesEnum { + Contacts = 'contacts', + Appeals = 'appeals', +} export interface FilterPanelProps { filters: FilterPanelGroupFragment[]; defaultExpandedFilterGroups?: Set; @@ -108,6 +116,7 @@ export interface FilterPanelProps { onClose: () => void; onSelectedFiltersChanged: (selectedFilters: FilterInput) => void; onHandleClearSearch?: () => void; + contextType?: ContextTypesEnum; } export const FilterPanel: React.FC = ({ @@ -118,16 +127,22 @@ export const FilterPanel: React.FC = ({ selectedFilters, onSelectedFiltersChanged, onHandleClearSearch, + contextType = ContextTypesEnum.Contacts, ...boxProps }) => { const theme = useTheme(); const { t } = useTranslation(); - const { handleClearAll } = React.useContext(ContactsContext) as ContactsType; const [saveFilterModalOpen, setSaveFilterModalOpen] = useState(false); const [deleteFilterModalOpen, setDeleteFilterModalOpen] = useState(false); const [showAll, setShowAll] = useState(false); const [filterToBeDeleted, setFilterToBeDeleted] = useState(null); + + const handleClearAll = + contextType === ContextTypesEnum.Contacts + ? (React.useContext(ContactsContext) as ContactsType).handleClearAll + : (React.useContext(AppealsContext) as AppealsType).handleClearAll; + const updateSelectedFilter = (name: FilterKey, value?: FilterValue) => { if (value && (!Array.isArray(value) || value.length > 0)) { let filterValue = value; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx index 03ac5e7dc..dbcfe97dc 100644 --- a/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealLeftPanel/AppealsLeftPanel.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { DynamicFilterPanel } from 'src/components/Shared/Filters/DynamicFilterPanel'; +import { ContextTypesEnum } from 'src/components/Shared/Filters/FilterPanel'; import { TableViewModeEnum } from 'src/components/Shared/Header/ListHeader'; import { AppealsContext, @@ -7,12 +9,26 @@ import { import { DynamicAppealsListFilterPanel } from '../../List/AppealsListFilterPanel/DynamicAppealsListFilterPanel'; export const AppealsLeftPanel: React.FC = () => { - const { filterData, filtersLoading, toggleFilterPanel, viewMode } = - React.useContext(AppealsContext) as AppealsType; + const { + filterData, + filtersLoading, + savedFilters, + activeFilters, + toggleFilterPanel, + setActiveFilters, + viewMode, + } = React.useContext(AppealsContext) as AppealsType; return viewMode !== TableViewModeEnum.Flows ? ( ) : filterData && !filtersLoading ? ( -

Flows Filters

+ ) : null; }; diff --git a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx index 4ebd41582..5a82b9a36 100644 --- a/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx +++ b/src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/AppealsMainPanel.tsx @@ -4,13 +4,22 @@ import { AppealsContext, AppealsType, } from '../../AppealsContext/AppealsContext'; +import { DynamicContactFlow } from '../../Flow/DynamicContactFlow'; import { DynamicContactsList } from '../../List/ContactsList/DynamicContactsList'; import { AppealsMainPanelHeader } from './AppealsMainPanelHeader'; import { useAppealQuery } from './appealInfo.generated'; export const AppealsMainPanel: React.FC = () => { - const { accountListId, appealId, viewMode, userOptionsLoading } = - React.useContext(AppealsContext) as AppealsType; + const { + accountListId, + appealId, + activeFilters, + starredFilter, + searchTerm, + setContactFocus, + viewMode, + userOptionsLoading, + } = React.useContext(AppealsContext) as AppealsType; const { data: appealInfo, loading: appealInfoLoading } = useAppealQuery({ variables: { @@ -30,7 +39,17 @@ export const AppealsMainPanel: React.FC = () => { appealInfoLoading={appealInfoLoading} /> ) : ( -

Flows

+ ))} ); diff --git a/src/components/Tool/Appeal/Flow/ContactFlow.tsx b/src/components/Tool/Appeal/Flow/ContactFlow.tsx new file mode 100644 index 000000000..f4fe807cc --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlow.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { v4 as uuidv4 } from 'uuid'; +import { ContactFlowDragLayer } from 'src/components/Contacts/ContactFlow/ContactFlowDragLayer/ContactFlowDragLayer'; +import { ContactFilterSetInput } from 'src/graphql/types.generated'; +import i18n from 'src/lib/i18n'; +import theme from 'src/theme'; +import { AppealHeaderInfo } from '../AppealDetails/AppealHeaderInfo'; +import { AppealQuery } from '../AppealDetails/AppealsMainPanel/appealInfo.generated'; +import { AppealStatusEnum } from '../AppealsContext/AppealsContext'; + +export interface ContactFlowProps { + accountListId: string; + selectedFilters: ContactFilterSetInput; + searchTerm?: string | string[]; + appealInfo?: AppealQuery; + appealInfoLoading: boolean; + onContactSelected: ( + contactId: string, + openDetails: boolean, + flows: boolean, + ) => void; +} + +export interface ContactFlowOption { + id: string; + name: string; + status: AppealStatusEnum; + color: string; +} + +export const colorMap: { [key: string]: string } = { + 'color-danger': theme.palette.error.main, + 'color-text': theme.palette.cruGrayDark.main, + 'color-committed': theme.palette.progressBarGray.main, + 'color-given': theme.palette.progressBarYellow.main, + 'color-received‌⁠': theme.palette.progressBarOrange.main, +}; + +const flowOptions: ContactFlowOption[] = [ + { + id: uuidv4(), + name: i18n.t('Excluded'), + status: AppealStatusEnum.Excluded, + color: colorMap['color-danger'], + }, + { + id: uuidv4(), + name: i18n.t('Asked'), + status: AppealStatusEnum.Asked, + color: colorMap['color-text'], + }, + { + id: uuidv4(), + name: i18n.t('Committed'), + status: AppealStatusEnum.NotReceived, + color: colorMap['color-committed'], + }, + { + id: uuidv4(), + name: i18n.t('Received‌⁠'), + status: AppealStatusEnum.ReceivedNotProcessed, + color: colorMap['color-received‌⁠'], + }, + { + id: uuidv4(), + name: i18n.t('Given'), + status: AppealStatusEnum.Processed, + color: colorMap['color-given'], + }, +]; + +export const ContactFlow: React.FC = ({ + appealInfo, + appealInfoLoading, +}: ContactFlowProps) => { + return ( + <> + + + + + {flowOptions.map((column) => ( + +

{column.name}

+
+ ))} +
+
+ + ); +}; diff --git a/src/components/Tool/Appeal/Flow/DynamicContactFlow.tsx b/src/components/Tool/Appeal/Flow/DynamicContactFlow.tsx new file mode 100644 index 000000000..f1920a759 --- /dev/null +++ b/src/components/Tool/Appeal/Flow/DynamicContactFlow.tsx @@ -0,0 +1,10 @@ +import dynamic from 'next/dynamic'; +import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; + +export const DynamicContactFlow = dynamic( + () => + import(/* webpackChunkName: "ContactFlow" */ './ContactFlow').then( + ({ ContactFlow }) => ContactFlow, + ), + { loading: DynamicComponentPlaceholder }, +); From 79deb452398bbd15aa8479ea0bf62020a3494c4e Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:58:29 -0400 Subject: [PATCH 11/18] 4. Building Flows contact row and drag and drop functionality. --- .../[accountListId]/contacts/Contacts.graphql | 8 +- .../ContactFlowColumn/ContactFlowColumn.tsx | 98 +++++----- .../ContactFlowDropZone.tsx | 49 +++-- .../ContactFlowRow/ContactFlowRow.test.tsx | 22 ++- .../ContactFlowRow/ContactFlowRow.tsx | 61 +++--- .../Contacts/ContactRow/ContactRow.graphql | 1 + .../Tool/Appeal/Flow/ContactFlow.test.tsx | 111 +++++++++++ .../Tool/Appeal/Flow/ContactFlow.tsx | 58 +++++- .../ContactFlowColumn.test.tsx | 97 +++++++++ .../ContactFlowColumn/ContactFlowColumn.tsx | 185 ++++++++++++++++++ .../ContactFlowDropZone.tsx | 47 +++++ .../ContactFlowRow/ContactFlowRow.test.tsx | 54 +++++ .../Flow/ContactFlowRow/ContactFlowRow.tsx | 119 +++++++++++ 13 files changed, 796 insertions(+), 114 deletions(-) create mode 100644 src/components/Tool/Appeal/Flow/ContactFlow.test.tsx create mode 100644 src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.test.tsx create mode 100644 src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx create mode 100644 src/components/Tool/Appeal/Flow/ContactFlowDropZone/ContactFlowDropZone.tsx create mode 100644 src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.test.tsx create mode 100644 src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.tsx diff --git a/pages/accountLists/[accountListId]/contacts/Contacts.graphql b/pages/accountLists/[accountListId]/contacts/Contacts.graphql index 3ecae4e4a..a4e94bb3f 100644 --- a/pages/accountLists/[accountListId]/contacts/Contacts.graphql +++ b/pages/accountLists/[accountListId]/contacts/Contacts.graphql @@ -11,7 +11,7 @@ query Contacts( first: $first ) { nodes { - ...contactFragment + ...ContactRow } totalCount pageInfo { @@ -24,12 +24,6 @@ query Contacts( } } -fragment contactFragment on Contact { - id - avatar - ...ContactRow -} - query ContactFilters($accountListId: ID!) { accountList(id: $accountListId) { id diff --git a/src/components/Contacts/ContactFlow/ContactFlowColumn/ContactFlowColumn.tsx b/src/components/Contacts/ContactFlow/ContactFlowColumn/ContactFlowColumn.tsx index 8d2ccd25f..9a8b65a60 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowColumn/ContactFlowColumn.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowColumn/ContactFlowColumn.tsx @@ -6,6 +6,7 @@ import { CircularProgress, Typography, } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { useDrop } from 'react-dnd'; import { useContactsQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; import { @@ -17,14 +18,49 @@ import { ContactFilterStatusEnum, IdValue, } from 'src/graphql/types.generated'; -import theme from '../../../../theme'; +import theme from 'src/theme'; import { useLoadConstantsQuery } from '../../../Constants/LoadConstants.generated'; import { InfiniteList } from '../../../InfiniteList/InfiniteList'; import { ContactRowFragment } from '../../ContactRow/ContactRow.generated'; import { ContactFlowDropZone } from '../ContactFlowDropZone/ContactFlowDropZone'; import { ContactFlowRow } from '../ContactFlowRow/ContactFlowRow'; -interface Props { +export const ContainerBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'color', +})(({ color }: { color: string }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + borderBottom: `5px solid ${color}`, + height: theme.spacing(7), +})); + +export const ColumnTitle = styled(Typography)(() => ({ + fontWeight: 600, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +})); + +export const StyledCardContent = styled(CardContent)(() => ({ + position: 'relative', + height: 'calc(100vh - 260px)', + padding: 0, + background: theme.palette.cruGrayLight.main, +})); + +export const CardContentInner = styled(Box, { + shouldForwardProp: (prop) => prop !== 'canDrop', +})(({ canDrop }: { canDrop: boolean }) => ({ + position: 'absolute', + width: '100%', + height: '100%', + top: '0', + right: '0', + display: canDrop ? 'grid' : 'none', +})); + +export interface ContactFlowColumnProps { data?: ContactRowFragment[]; statuses: ContactFilterStatusEnum[]; selectedFilters: ContactFilterSetInput; @@ -44,7 +80,6 @@ interface Props { } & Pick, ) => Promise; } - export interface StatusStructure { id: string | undefined; value: string | undefined; @@ -52,7 +87,7 @@ export interface StatusStructure { const nullStatus = { id: 'NULL', value: '' }; -export const ContactFlowColumn: React.FC = ({ +export const ContactFlowColumn: React.FC = ({ statuses, title, color, @@ -60,7 +95,7 @@ export const ContactFlowColumn: React.FC = ({ searchTerm, onContactSelected, changeContactStatus, -}: Props) => { +}) => { const { sanitizedFilters } = React.useContext( ContactsContext, ) as ContactsType; @@ -95,49 +130,19 @@ export const ContactFlowColumn: React.FC = ({ ) : ( - + - - {title} - + {title} {data?.contacts.totalCount || 0} - - - + + {statusesStructured.map((status) => ( = ({ changeContactStatus={changeContactStatus} /> ))} - + = ({ itemContent={(_index, contact) => ( constant.id === contact.status, ) || nullStatus } - starred={contact.starred} onContactSelected={onContactSelected} columnWidth={cardContentRef.current?.offsetWidth} - avatar={contact.avatar} /> )} endReached={() => @@ -176,7 +178,7 @@ export const ContactFlowColumn: React.FC = ({ EmptyPlaceholder={undefined} /> - + ); }; diff --git a/src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone.tsx b/src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone.tsx index 6a13a37f8..b6499b350 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone.tsx @@ -1,11 +1,35 @@ import React from 'react'; import { Box, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; import { useDrop } from 'react-dnd'; import { useTranslation } from 'react-i18next'; import { IdValue } from 'src/graphql/types.generated'; import theme from '../../../../theme'; import { DraggedContact } from '../ContactFlowRow/ContactFlowRow'; +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Tool/Appeal/Flow/ContactFlowDropZone/ContactFlowDropZone.tsx + +export const DropZoneBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'canDrop' && prop !== 'isOver', +})(({ canDrop, isOver }: { canDrop: boolean; isOver: boolean }) => ({ + display: 'flex', + height: '100%', + width: '100%', + border: canDrop + ? `3px dashed ${theme.palette.mpdxBlue.main}` + : `3px solid ${theme.palette.cruGrayMedium.main}`, + zIndex: canDrop ? 1 : 0, + color: canDrop ? theme.palette.common.white : theme.palette.cruGrayDark.main, + backgroundColor: canDrop + ? isOver + ? theme.palette.info.main + : theme.palette.info.light + : theme.palette.cruGrayLight.main, + justifyContent: 'center', + alignItems: 'center', +})); + interface Props { status: { __typename?: 'IdValue' | undefined; @@ -38,32 +62,15 @@ export const ContactFlowDropZone: React.FC = ({ const { t } = useTranslation(); return ( - {t('{{status}}', { status: status.value })} - + ); }; diff --git a/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.test.tsx b/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.test.tsx index 8370adbad..0994c0646 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.test.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.test.tsx @@ -7,15 +7,25 @@ import { HTML5Backend } from 'react-dnd-html5-backend'; import TestWrapper from '__tests__/util/TestWrapper'; import { StatusEnum } from 'src/graphql/types.generated'; import theme from '../../../../theme'; +import { ContactRowFragment } from '../../ContactRow/ContactRow.generated'; import { ContactFlowRow } from './ContactFlowRow'; const accountListId = 'abc'; -const id = '123'; -const name = 'Test Name'; const status = { id: StatusEnum.PartnerFinancial, value: 'Partner - Financial', }; +const contact = { + id: '123', + name: 'Test Name', + starred: true, + avatar: 'avatar.jpg', + pledgeAmount: 100, + pledgeCurrency: 'USD', + pledgeReceived: false, + uncompletedTasksCount: 0, +} as ContactRowFragment; + const onContactSelected = jest.fn(); describe('ContactFlowRow', () => { @@ -26,10 +36,8 @@ describe('ContactFlowRow', () => { @@ -47,10 +55,8 @@ describe('ContactFlowRow', () => { diff --git a/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.tsx b/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.tsx index e15623fd2..53cf5c9fe 100644 --- a/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.tsx +++ b/src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.tsx @@ -5,26 +5,27 @@ import { useDrag } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { IdValue } from 'src/graphql/types.generated'; import theme from '../../../../theme'; +import { ContactRowFragment } from '../../ContactRow/ContactRow.generated'; import { StarContactIconButton } from '../../StarContactIconButton/StarContactIconButton'; -interface Props { +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.tsx + +export interface ContactFlowRowProps { accountListId: string; - id: string; - name: string; + contact: ContactRowFragment; status: { __typename?: 'IdValue' | undefined; } & Pick; - starred: boolean; onContactSelected: ( contactId: string, openDetails: boolean, flows: boolean, ) => void; columnWidth?: number; - avatar?: string; } -const ContactLink = styled(Typography)(() => ({ +export const ContactLink = styled(Typography)(() => ({ color: theme.palette.mpdxBlue.main, '&:hover': { textDecoration: 'underline', @@ -32,7 +33,17 @@ const ContactLink = styled(Typography)(() => ({ }, })); -const DraggableBox = styled(Box)(() => ({ +export const ContainerBox = styled(Box, { + shouldForwardProp: (prop) => prop !== 'isDragging', +})(({ isDragging }: { isDragging: boolean }) => ({ + display: 'flex', + width: '100%', + background: 'white', + zIndex: isDragging ? 3 : 0, + opacity: isDragging ? 0 : 1, +})); + +export const DraggableBox = styled(Box)(() => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', @@ -45,6 +56,11 @@ const DraggableBox = styled(Box)(() => ({ }, })); +export const StyledAvatar = styled(Avatar)(() => ({ + width: theme.spacing(4), + height: theme.spacing(4), +})); + export interface DraggedContact { id: string; status: { @@ -55,16 +71,15 @@ export interface DraggedContact { width: number; } -export const ContactFlowRow: React.FC = ({ +export const ContactFlowRow: React.FC = ({ accountListId, - id, - name, + contact, status, - starred, onContactSelected, columnWidth, - avatar, -}: Props) => { +}) => { + const { id, name, starred, avatar } = contact; + const [{ isDragging }, drag, preview] = useDrag( () => ({ type: 'contact', @@ -87,25 +102,13 @@ export const ContactFlowRow: React.FC = ({ }, []); return ( - - + onContactSelected(id, true, true)}> {name} @@ -121,6 +124,6 @@ export const ContactFlowRow: React.FC = ({ /> - + ); }; diff --git a/src/components/Contacts/ContactRow/ContactRow.graphql b/src/components/Contacts/ContactRow/ContactRow.graphql index 42c670a91..fc36fe4c5 100644 --- a/src/components/Contacts/ContactRow/ContactRow.graphql +++ b/src/components/Contacts/ContactRow/ContactRow.graphql @@ -1,5 +1,6 @@ fragment ContactRow on Contact { id + avatar name primaryAddress { id diff --git a/src/components/Tool/Appeal/Flow/ContactFlow.test.tsx b/src/components/Tool/Appeal/Flow/ContactFlow.test.tsx new file mode 100644 index 000000000..249fa8f6b --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlow.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { fireEvent, render, waitFor, within } from '@testing-library/react'; +import { SnackbarProvider } from 'notistack'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ContactsQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { StatusEnum } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { appealInfo } from '../appealMockData'; +import { ContactFlow, ContactFlowProps } from './ContactFlow'; + +const accountListId = 'abc'; +const onContactSelected = jest.fn(); +const contact = { + id: '123', + name: 'Test Person', + status: StatusEnum.NotInterested, + primaryAddress: { + id: 'address', + updatedAt: new Date('2021-06-21T03:40:05-06:00').toISOString(), + }, +}; +const router = { + query: { accountListId }, + isReady: true, +}; + +const initialProps = { + accountListId, + selectedFilters: {}, + onContactSelected, + searchTerm: '', + appealInfo: { + appeal: appealInfo, + }, + appealInfoLoading: false, +}; + +const mutationSpy = jest.fn(); + +const Components = (props: ContactFlowProps) => ( + + + + + + mocks={{ + Contacts: { + contacts: { + nodes: [contact], + pageInfo: { endCursor: 'Mg', hasNextPage: false }, + totalCount: 1, + }, + }, + }} + onCall={mutationSpy} + > + + + + + + + + + + +); + +describe('ContactFlow', () => { + it('default', async () => { + const { getByText } = render(); + + await waitFor(() => expect(getByText('Excluded')).toBeInTheDocument()); + await waitFor(() => expect(getByText('Asked')).toBeInTheDocument()); + await waitFor(() => expect(getByText('Committed')).toBeInTheDocument()); + await waitFor(() => expect(getByText('Received‌⁠')).toBeInTheDocument()); + await waitFor(() => expect(getByText('Given')).toBeInTheDocument()); + }); + + it('Drag and drop', async () => { + const { getAllByText, getByTestId } = render( + , + ); + + await waitFor(() => + expect(getAllByText(contact.name)[1]).toBeInTheDocument(), + ); + + const contactBox = getAllByText(contact.name)[1]; + const column = within(getByTestId('contactsFlowExcluded')).getByTestId( + 'contact-flow-drop-zone', + ); + + fireEvent.dragStart(contactBox); + fireEvent.dragEnter(column); + fireEvent.dragOver(column); + fireEvent.drop(column); + + await waitFor(() => + expect(mutationSpy).toHaveGraphqlOperation('UpdateContactOther'), + ); + }); +}); diff --git a/src/components/Tool/Appeal/Flow/ContactFlow.tsx b/src/components/Tool/Appeal/Flow/ContactFlow.tsx index f4fe807cc..73c887c9c 100644 --- a/src/components/Tool/Appeal/Flow/ContactFlow.tsx +++ b/src/components/Tool/Appeal/Flow/ContactFlow.tsx @@ -1,8 +1,12 @@ import React from 'react'; import { Box } from '@mui/material'; +import { useSnackbar } from 'notistack'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; +import { useTranslation } from 'react-i18next'; import { v4 as uuidv4 } from 'uuid'; +import { ContactsDocument } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { useUpdateContactOtherMutation } from 'src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOther.generated'; import { ContactFlowDragLayer } from 'src/components/Contacts/ContactFlow/ContactFlowDragLayer/ContactFlowDragLayer'; import { ContactFilterSetInput } from 'src/graphql/types.generated'; import i18n from 'src/lib/i18n'; @@ -10,6 +14,7 @@ import theme from 'src/theme'; import { AppealHeaderInfo } from '../AppealDetails/AppealHeaderInfo'; import { AppealQuery } from '../AppealDetails/AppealsMainPanel/appealInfo.generated'; import { AppealStatusEnum } from '../AppealsContext/AppealsContext'; +import { ContactFlowColumn } from './ContactFlowColumn/ContactFlowColumn'; export interface ContactFlowProps { accountListId: string; @@ -73,9 +78,51 @@ const flowOptions: ContactFlowOption[] = [ ]; export const ContactFlow: React.FC = ({ + accountListId, + selectedFilters, + onContactSelected, + searchTerm, appealInfo, appealInfoLoading, }: ContactFlowProps) => { + const { t } = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + + const [updateContactOther] = useUpdateContactOtherMutation(); + + const changeContactStatus = async ( + id: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + appealId: AppealStatusEnum, + ): Promise => { + // TODO Fix this when we have the appeal status added to contact + const attributes = { + id, + }; + await updateContactOther({ + variables: { + accountListId, + attributes, + }, + refetchQueries: () => + flowOptions.map((flowOption) => ({ + query: ContactsDocument, + variables: { + accountListId, + contactsFilters: { + appeal: [appealInfo?.appeal.id], + appealStatus: [flowOption.status], + ...selectedFilters, + }, + }, + })), + }); + enqueueSnackbar(t('Contact status info updated!'), { + variant: 'success', + }); + // TODO - add functionality when appeal status is changed + }; + return ( <> = ({ key={column.name} data-testid={`contactsFlow${column.name}`} > -

{column.name}

+ ))} diff --git a/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.test.tsx b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.test.tsx new file mode 100644 index 000000000..8e8c17cd6 --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { SnackbarProvider } from 'notistack'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ContactsQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { AppealsWrapper } from 'pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper'; +import { StatusEnum } from 'src/graphql/types.generated'; +import theme from 'src/theme'; +import { AppealStatusEnum } from '../../AppealsContext/AppealsContext'; +import { ContactFlowColumn } from './ContactFlowColumn'; + +const accountListId = 'abc'; +const title = 'Test Column'; +const onContactSelected = jest.fn(); +const changeContactStatus = jest.fn(); +const contact = { + id: '123', + name: 'Test Person', + status: StatusEnum.NotInterested, + primaryAddress: { + id: 'address', + updatedAt: new Date('2021-06-21T03:40:05-06:00').toISOString(), + }, +}; +const router = { + query: { accountListId }, + isReady: true, +}; + +const Components = () => ( + + + + + + mocks={{ + Contacts: { + contacts: { + nodes: [contact], + pageInfo: { endCursor: 'Mg', hasNextPage: false }, + totalCount: 1, + }, + }, + }} + > + + + + + + + + + + +); + +describe('ContactFlowColumn', () => { + it('should render a column with correct details', async () => { + const { getByText, getByTestId } = render(); + await waitFor(() => expect(getByText(title)).toBeInTheDocument()); + expect(getByText('1')).toBeInTheDocument(); + expect(getByText('Test Person')).toBeInTheDocument(); + expect(getByTestId('column-header')).toHaveStyle({ + backgroundColor: 'theme.palette.mpdxBlue.main', + }); + }); + + it('should open menu', async () => { + const { getByText, getByTestId, getByRole } = render(); + + await waitFor(() => expect(getByText(title)).toBeInTheDocument()); + + userEvent.click(getByTestId('MoreVertIcon')); + expect(getByText('Not Interested')).toBeInTheDocument(); + + await waitFor(() => { + getByRole('menuitem', { name: 'Select 1 contact' }); + }); + }); +}); diff --git a/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx new file mode 100644 index 000000000..21907636c --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlowColumn/ContactFlowColumn.tsx @@ -0,0 +1,185 @@ +import React, { useRef, useState } from 'react'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { + Box, + Card, + CircularProgress, + IconButton, + Menu, + MenuItem, + Typography, +} from '@mui/material'; +import { useDrop } from 'react-dnd'; +import { useTranslation } from 'react-i18next'; +import { useContactsQuery } from 'pages/accountLists/[accountListId]/contacts/Contacts.generated'; +import { + CardContentInner, + ColumnTitle, + ContactFlowColumnProps, + ContainerBox, + StyledCardContent, +} from 'src/components/Contacts/ContactFlow/ContactFlowColumn/ContactFlowColumn'; +import { InfiniteList } from 'src/components/InfiniteList/InfiniteList'; +import { navBarHeight } from 'src/components/Layouts/Primary/Primary'; +import { headerHeight } from 'src/components/Shared/Header/ListHeader'; +import { + AppealStatusEnum, + AppealsContext, + AppealsType, +} from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; +import { appealHeaderInfoHeight } from '../../AppealDetails/AppealHeaderInfo'; +import { ContactFlowDropZone } from '../ContactFlowDropZone/ContactFlowDropZone'; +import { ContactFlowRow } from '../ContactFlowRow/ContactFlowRow'; + +interface Props + extends Omit { + appealStatus: AppealStatusEnum; + changeContactStatus: (id: string, status: AppealStatusEnum) => Promise; +} + +export const ContactFlowColumn: React.FC = ({ + appealStatus, + title, + color, + accountListId, + searchTerm, + onContactSelected, + changeContactStatus, +}) => { + const { appealId, sanitizedFilters } = React.useContext( + AppealsContext, + ) as AppealsType; + const { t } = useTranslation(); + + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const { data, loading, fetchMore } = useContactsQuery({ + variables: { + accountListId: accountListId ?? '', + contactsFilters: { + ...sanitizedFilters, + appeal: [appealId ?? ''], + appealStatus, + wildcardSearch: searchTerm as string, + }, + }, + skip: !accountListId || !appealStatus, + }); + + const cardContentRef = useRef(); + + const [{ canDrop }, drop] = useDrop(() => ({ + accept: 'contact', + collect: (monitor) => ({ + canDrop: !!monitor.canDrop(), + }), + })); + + const handleColumnHeaderClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSelectAll = () => { + setAnchorEl(null); + // TODO implement select all + }; + + const handleDeselectAll = () => { + setAnchorEl(null); + // TODO implement deselect all + }; + + const totalContacts = data?.contacts.totalCount || 0; + + return loading && !data ? ( + + ) : ( + + + + {title} + + + {data?.contacts.totalCount || 0} + + + + + + + + {totalContacts <= 1 + ? t('Select {{count}} contact', { count: totalContacts }) + : t('Select all {{count}} contacts', { count: totalContacts })} + + {t('Deselect All')} + + + + + + + + + ( + + )} + endReached={() => + data?.contacts.pageInfo.hasNextPage && + fetchMore({ + variables: { after: data.contacts.pageInfo.endCursor }, + }) + } + EmptyPlaceholder={undefined} + /> + + + + ); +}; diff --git a/src/components/Tool/Appeal/Flow/ContactFlowDropZone/ContactFlowDropZone.tsx b/src/components/Tool/Appeal/Flow/ContactFlowDropZone/ContactFlowDropZone.tsx new file mode 100644 index 000000000..9ff64492d --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlowDropZone/ContactFlowDropZone.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Typography } from '@mui/material'; +import { useDrop } from 'react-dnd'; +import { DropZoneBox } from 'src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone'; +import { AppealStatusEnum } from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; +import { DraggedContact } from '../ContactFlowRow/ContactFlowRow'; + +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Contacts/ContactFlow/ContactFlowDropZone/ContactFlowDropZone.tsx + +interface Props { + status: AppealStatusEnum; + changeContactStatus: (id: string, status: AppealStatusEnum) => Promise; +} + +export const ContactFlowDropZone: React.FC = ({ + status, + changeContactStatus, +}: Props) => { + const [{ isOver, canDrop }, drop] = useDrop(() => ({ + accept: 'contact', + canDrop: (contact) => String(contact.status) !== String(status), + drop: (contact: DraggedContact) => { + String(contact.status) !== String(status) + ? changeContactStatus(contact.id, status) + : null; + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), + })); + + return ( + + + {status} + + + ); +}; diff --git a/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.test.tsx b/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.test.tsx new file mode 100644 index 000000000..f808680ba --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import TestWrapper from '__tests__/util/TestWrapper'; +import { ContactRowFragment } from 'src/components/Contacts/ContactRow/ContactRow.generated'; +import theme from 'src/theme'; +import { AppealStatusEnum } from '../../AppealsContext/AppealsContext'; +import { ContactFlowRow } from './ContactFlowRow'; + +const accountListId = 'abc'; +const contact = { + id: '123', + name: 'Test Name', + starred: true, + avatar: 'avatar.jpg', + pledgeAmount: 100, + pledgeCurrency: 'USD', + pledgeReceived: false, + uncompletedTasksCount: 0, +} as ContactRowFragment; +const onContactSelected = jest.fn(); + +const Components = () => ( + + + + + + + +); + +describe('ContactFlowRow', () => { + it('should display contact name and status', () => { + const { getByText, getByTitle } = render(); + expect(getByText('Test Name')).toBeInTheDocument(); + expect(getByTitle('Filled Star Icon')).toBeInTheDocument(); + }); + + it('should call contact selected function', () => { + const { getByText } = render(); + userEvent.click(getByText('Test Name')); + expect(getByText('Test Name')).toBeInTheDocument(); + expect(onContactSelected).toHaveBeenCalledWith('123', true, true); + }); +}); diff --git a/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.tsx b/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.tsx new file mode 100644 index 000000000..eb57712c8 --- /dev/null +++ b/src/components/Tool/Appeal/Flow/ContactFlowRow/ContactFlowRow.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useMemo } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useDrag } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; +import { useTranslation } from 'react-i18next'; +import { + ContactFlowRowProps, + ContactLink, + DraggedContact as ContactsDraggedContact, + ContainerBox, + DraggableBox, + StyledAvatar, +} from 'src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow'; +import { StarContactIconButton } from 'src/components/Contacts/StarContactIconButton/StarContactIconButton'; +import { StatusEnum } from 'src/graphql/types.generated'; +import { useLocale } from 'src/hooks/useLocale'; +import { currencyFormat } from 'src/lib/intlFormat'; +import { getLocalizedContactStatus } from 'src/utils/functions/getLocalizedContactStatus'; +import { AppealStatusEnum } from '../../AppealsContext/AppealsContext'; + +// When making changes in this file, also check to see if you don't need to make changes to the below file +// src/components/Contacts/ContactFlow/ContactFlowRow/ContactFlowRow.tsx + +interface Props extends Omit { + contactStatus?: StatusEnum | null; + appealStatus: AppealStatusEnum; +} + +export interface DraggedContact extends Omit { + status: AppealStatusEnum; +} + +export const ContactFlowRow: React.FC = ({ + accountListId, + contact, + contactStatus, + appealStatus, + onContactSelected, + columnWidth, +}) => { + const { id, name, starred, avatar, pledgeAmount, pledgeCurrency } = contact; + const { t } = useTranslation(); + const locale = useLocale(); + const [{ isDragging }, drag, preview] = useDrag( + () => ({ + type: 'contact', + item: { + id, + appealStatus, + status: contactStatus, + contactStatus, + name, + starred, + width: columnWidth, + }, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + }), + [id], + ); + + useEffect(() => { + preview(getEmptyImage(), { captureDraggingState: true }); + }, []); + + const pledgedAmount = useMemo(() => { + if (pledgeAmount && pledgeCurrency) { + return currencyFormat(pledgeAmount ?? 0, pledgeCurrency, locale); + } else { + return null; + } + }, [pledgeAmount, pledgeCurrency, locale]); + + return ( + + + + + + + onContactSelected(id, true, true)}> + {name} + + + {getLocalizedContactStatus(t, contactStatus)} + + + + + + + + + {pledgedAmount && ( + + {pledgedAmount} + + )} + + + + ); +}; From 3caa16832d1cf126f0486dee02a1470a48236b7a Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 16:59:45 -0400 Subject: [PATCH 12/18] 5. Fixing GraphQL error as two queries were called the same --- .../Tool/FixMailingAddresses/FixMailingAddresses.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Tool/FixMailingAddresses/FixMailingAddresses.test.tsx b/src/components/Tool/FixMailingAddresses/FixMailingAddresses.test.tsx index 1867f10ca..ab79750c2 100644 --- a/src/components/Tool/FixMailingAddresses/FixMailingAddresses.test.tsx +++ b/src/components/Tool/FixMailingAddresses/FixMailingAddresses.test.tsx @@ -437,7 +437,7 @@ describe('FixMailingAddresses', () => { InvalidAddresses: { ...mockInvalidAddressesResponse.InvalidAddresses, }, - UpdateContactAddress: () => { + UpdateContactMailingAddress: () => { throw new Error('Server Error'); }, }} @@ -519,7 +519,7 @@ describe('FixMailingAddresses', () => { ], }, }, - UpdateContactAddress: () => { + UpdateContactMailingAddress: () => { throw new Error('Server Error'); }, }} From 7efb24288ee64825c0a651e7229d2bba404b08f6 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Tue, 30 Jul 2024 17:00:23 -0400 Subject: [PATCH 13/18] 5. Fixing scrolling issue, as scroll was flashing too much --- src/components/InfiniteList/InfiniteList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/InfiniteList/InfiniteList.tsx b/src/components/InfiniteList/InfiniteList.tsx index dc56c3efb..c5194b21b 100644 --- a/src/components/InfiniteList/InfiniteList.tsx +++ b/src/components/InfiniteList/InfiniteList.tsx @@ -110,8 +110,8 @@ export const InfiniteList = ({ ...props.components, }, scrollSeekConfiguration: { - enter: (velocity) => Math.abs(velocity) > 200, - exit: (velocity) => Math.abs(velocity) < 10, + enter: (velocity) => Math.abs(velocity) > 2000, + exit: (velocity) => Math.abs(velocity) < 100, ...props.scrollSeekConfiguration, }, overscan: 2000, From c38d64a688eaf8af24f3b8f064c6abcbcf5a7e82 Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 12 Aug 2024 15:04:45 -0400 Subject: [PATCH 14/18] Fix bug when switching accountLists in appeal. To fix I had to remove the initial appeal page from the appeal detail page to allow for a clean break, otherwise state was being kept and was very buggy and slow. --- .../tools/appeals/AppealsWrapper.tsx | 14 +- .../tools/appeals/[[...appealId]].page.tsx | 43 ------ .../[[...appealId]].page.test.tsx} | 12 -- .../appeals/appeal/[[...appealId]].page.tsx | 31 +++++ .../tools/appeals/index.page.test.tsx | 125 ++++++++++++++++++ .../tools/appeals/index.page.tsx | 24 ++++ .../TopBar/Items/ProfileMenu/ProfileMenu.tsx | 39 ++++-- .../AppealsContext/AppealsContext.test.tsx | 19 +-- .../Appeal/AppealsContext/AppealsContext.tsx | 12 +- .../Tool/Appeal/InitialPage/Appeal.tsx | 2 +- .../Appeal/InitialPage/AppealsInitialPage.tsx | 7 +- 11 files changed, 236 insertions(+), 92 deletions(-) delete mode 100644 pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx rename pages/accountLists/[accountListId]/tools/appeals/{appealDetails.test.tsx => appeal/[[...appealId]].page.test.tsx} (95%) create mode 100644 pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx create mode 100644 pages/accountLists/[accountListId]/tools/appeals/index.page.test.tsx create mode 100644 pages/accountLists/[accountListId]/tools/appeals/index.page.tsx diff --git a/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx b/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx index 1a4bd61b7..3d6b5785d 100644 --- a/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx +++ b/pages/accountLists/[accountListId]/tools/appeals/AppealsWrapper.tsx @@ -10,14 +10,13 @@ interface Props { } export enum PageEnum { - InitialPage = 'InitialPage', DetailsPage = 'DetailsPage', ContactsPage = 'ContactsPage', } export const AppealsWrapper: React.FC = ({ children }) => { const router = useRouter(); - const { query, replace, pathname, isReady } = router; + const { query, replace, push, pathname, isReady } = router; const urlFilters = query?.filters && JSON.parse(decodeURI(query.filters as string)); @@ -37,7 +36,7 @@ export const AppealsWrapper: React.FC = ({ children }) => { [activeFilters], ); - const { appealId: appealIdParams, searchTerm } = query; + const { appealId: appealIdParams, searchTerm, accountListId } = query; useEffect(() => { // TODO: Fix these suggested Articles @@ -47,7 +46,12 @@ export const AppealsWrapper: React.FC = ({ children }) => { : 'HS_CONTACTS_SUGGESTIONS', ); if (appealIdParams === undefined) { - setPage(PageEnum.InitialPage); + push({ + pathname: '/accountLists/[accountListId]/tools/appeals', + query: { + accountListId, + }, + }); return; } const length = appealIdParams.length; @@ -65,7 +69,7 @@ export const AppealsWrapper: React.FC = ({ children }) => { setPage(PageEnum.ContactsPage); setContactId(appealIdParams); } - }, [appealIdParams]); + }, [appealIdParams, accountListId]); useEffect(() => { if (!isReady) { diff --git a/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx b/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx deleted file mode 100644 index 59d42d5f0..000000000 --- a/pages/accountLists/[accountListId]/tools/appeals/[[...appealId]].page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { ReactElement, useContext } from 'react'; -import { useTranslation } from 'react-i18next'; -import { loadSession } from 'pages/api/utils/pagePropsHelpers'; -import { - AppealsContext, - AppealsType, -} from 'src/components/Tool/Appeal/AppealsContext/AppealsContext'; -import { DynamicAppealsDetailsPage } from 'src/components/Tool/Appeal/DynamicAppealsDetailsPage'; -import { DynamicAppealsInitialPage } from 'src/components/Tool/Appeal/InitialPage/DynamicAppealsInitialPage'; -import { ToolsWrapper } from '../ToolsWrapper'; -import { AppealsWrapper, PageEnum } from './AppealsWrapper'; - -const Appeals = (): ReactElement => { - const { t } = useTranslation(); - const { page } = useContext(AppealsContext) as AppealsType; - const pageUrl = 'appeals'; - - return ( - - <> - {page === PageEnum.InitialPage && } - - {(page === PageEnum.DetailsPage || page === PageEnum.ContactsPage) && ( - - )} - - - ); -}; - -const AppealsPage: React.FC = () => ( - - - -); - -export default AppealsPage; - -export const getServerSideProps = loadSession; diff --git a/pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.test.tsx similarity index 95% rename from pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx rename to pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.test.tsx index d43eb8d09..4e7bf39e2 100644 --- a/pages/accountLists/[accountListId]/tools/appeals/appealDetails.test.tsx +++ b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.test.tsx @@ -112,18 +112,6 @@ const Components = ({ router = defaultRouter }: { router?: object }) => ( ); describe('Appeal navigation', () => { - it('should show initial appeal page', async () => { - const { getByText, getAllByText } = render(); - - await waitFor(() => - expect(getByText('Primary Appeal')).toBeInTheDocument(), - ); - - await waitFor(() => - expect(getAllByText('Add Appeal')[0]).toBeInTheDocument(), - ); - }); - it('should show list detail appeal page and open filters', async () => { const { getByText, findByTestId, queryByText, getByRole, queryByRole } = render( diff --git a/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx new file mode 100644 index 000000000..9f82d6e4f --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]].page.tsx @@ -0,0 +1,31 @@ +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { loadSession } from 'pages/api/utils/pagePropsHelpers'; +import AppealsDetailsPage from 'src/components/Tool/Appeal/AppealsDetailsPage'; +import { ToolsWrapper } from '../../ToolsWrapper'; +import { AppealsWrapper } from '../AppealsWrapper'; + +const Appeals = (): ReactElement => { + const { t } = useTranslation(); + const pageUrl = 'appeals'; + + return ( + + + + ); +}; + +const AppealsPage: React.FC = () => ( + + + +); + +export default AppealsPage; + +export const getServerSideProps = loadSession; diff --git a/pages/accountLists/[accountListId]/tools/appeals/index.page.test.tsx b/pages/accountLists/[accountListId]/tools/appeals/index.page.test.tsx new file mode 100644 index 000000000..c4a9e8dbe --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/index.page.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, waitFor } from '@testing-library/react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { VirtuosoMockContext } from 'react-virtuoso'; +import TestRouter from '__tests__/util/TestRouter'; +import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; +import { ListHeaderCheckBoxState } from 'src/components/Shared/Header/ListHeader'; +import { AppealQuery } from 'src/components/Tool/Appeal/AppealDetails/AppealsMainPanel/appealInfo.generated'; +import { ContactsQuery } from 'src/components/Tool/Appeal/AppealsContext/contacts.generated'; +import { + PledgeFrequencyEnum, + SendNewsletterEnum, + StatusEnum, +} from 'src/graphql/types.generated'; +import { useMassSelection } from 'src/hooks/useMassSelection'; +import theme from 'src/theme'; +import AppealsInitialPage from './index.page'; + +const accountListId = 'account-list-1'; + +const defaultRouter = { + query: { accountListId }, + isReady: true, +}; + +const contact = { + id: '1', + name: 'Test Person', + avatar: 'img.png', + primaryAddress: null, + status: StatusEnum.PartnerFinancial, + pledgeAmount: 100, + pledgeFrequency: PledgeFrequencyEnum.Monthly, + pledgeCurrency: 'USD', + pledgeReceived: true, + lateAt: new Date().toISOString(), + sendNewsletter: SendNewsletterEnum.Both, + starred: false, + uncompletedTasksCount: 0, + people: { nodes: [] }, +}; + +const mockResponse = { + contacts: { + nodes: [contact], + totalCount: 1, + pageInfo: { endCursor: 'Mg', hasNextPage: false }, + }, + allContacts: { + totalCount: 1, + }, +}; + +const mockAppealResponse = { + appeal: { + amount: 4531, + amountCurrency: 'USD', + id: '9d660aed-1291-4c5b-874d-409a94b5ed3b', + name: 'End Of Year Gift', + pledgesAmountNotReceivedNotProcessed: 2000, + pledgesAmountProcessed: 50, + pledgesAmountReceivedNotProcessed: 50, + pledgesAmountTotal: 2115.93, + }, +}; + +jest.mock('src/hooks/useMassSelection'); + +(useMassSelection as jest.Mock).mockReturnValue({ + ids: [], + selectionType: ListHeaderCheckBoxState.Unchecked, + isRowChecked: jest.fn(), + toggleSelectAll: jest.fn(), + toggleSelectionById: jest.fn(), +}); + +const mockEnqueue = jest.fn(); + +jest.mock('notistack', () => ({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ...jest.requireActual('notistack'), + useSnackbar: () => { + return { + enqueueSnackbar: mockEnqueue, + }; + }, +})); + +const Components = ({ router = defaultRouter }: { router?: object }) => ( + + + + + mocks={{ + Contacts: mockResponse, + Appeal: mockAppealResponse, + }} + > + + + + + + + +); + +describe('Appeal navigation', () => { + it('should show initial appeal page', async () => { + const { getByText, getAllByText } = render(); + + await waitFor(() => + expect(getByText('Primary Appeal')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(getAllByText('Add Appeal')[0]).toBeInTheDocument(), + ); + }); +}); diff --git a/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx b/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx new file mode 100644 index 000000000..9c6791897 --- /dev/null +++ b/pages/accountLists/[accountListId]/tools/appeals/index.page.tsx @@ -0,0 +1,24 @@ +import React, { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { loadSession } from 'pages/api/utils/pagePropsHelpers'; +import AppealsInitialPage from 'src/components/Tool/Appeal/InitialPage/AppealsInitialPage'; +import { ToolsWrapper } from '../ToolsWrapper'; + +const AppealsPage = (): ReactElement => { + const { t } = useTranslation(); + const pageUrl = 'appeals'; + + return ( + + + + ); +}; + +export default AppealsPage; + +export const getServerSideProps = loadSession; diff --git a/src/components/Layouts/Primary/TopBar/Items/ProfileMenu/ProfileMenu.tsx b/src/components/Layouts/Primary/TopBar/Items/ProfileMenu/ProfileMenu.tsx index b561c7447..8aee0ec50 100644 --- a/src/components/Layouts/Primary/TopBar/Items/ProfileMenu/ProfileMenu.tsx +++ b/src/components/Layouts/Primary/TopBar/Items/ProfileMenu/ProfileMenu.tsx @@ -22,6 +22,7 @@ import { styled } from '@mui/material/styles'; import { signOut } from 'next-auth/react'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; +import { AccountList } from 'src/graphql/types.generated'; import { useRequiredSession } from 'src/hooks/useRequiredSession'; import { clearDataDogUser } from 'src/lib/dataDog'; import { useAccountListId } from '../../../../../../hooks/useAccountListId'; @@ -163,6 +164,32 @@ const ProfileMenu = (): ReactElement => { window.location.href = url.href; }; + const handleAccountListClick = ( + accountList: Pick, + ) => { + if ( + router.pathname === + '/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]]' + ) { + router.push({ + pathname: '/accountLists/[accountListId]/tools/appeals', + query: { + accountListId: accountList.id, + }, + }); + } else { + router.push({ + pathname: accountListId + ? router.pathname + : '/accountLists/[accountListId]/', + query: { + ...queryWithoutContactId, + accountListId: accountList.id, + }, + }); + } + }; + return ( <> { ? theme.palette.cruGrayMedium.main : 'inherit', }} - onClick={() => - router.push({ - pathname: accountListId - ? router.pathname - : '/accountLists/[accountListId]/', - query: { - ...queryWithoutContactId, - accountListId: accountList.id, - }, - }) - } + onClick={() => handleAccountListClick(accountList)} > diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx index 5a7f27a32..6f2ca1f47 100644 --- a/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.test.tsx @@ -112,7 +112,7 @@ describe('ContactsPageContext', () => { router={{ query: { accountListId, appealId: [appealIdentifier, 'flows'] }, pathname: - '/accountLists/[accountListId]/tools/appeals/[[...contactId]]', + '/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]]', isReady, push, }} @@ -149,7 +149,7 @@ describe('ContactsPageContext', () => { await waitFor(() => expect(push).toHaveBeenCalledWith({ pathname: - '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows/contact-id', + '/accountLists/account-list-1/tools/appeals/appeal/appeal-Id-1/flows/contact-id', query: {}, }), ); @@ -162,7 +162,7 @@ describe('ContactsPageContext', () => { router={{ query: { accountListId, appealId: [appealIdentifier, 'list'] }, pathname: - '/accountLists/[accountListId]/tools/appeals/[[...contactId]]', + '/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]]', isReady, push, }} @@ -193,7 +193,7 @@ describe('ContactsPageContext', () => { await waitFor(() => expect(push).toHaveBeenCalledWith({ pathname: - '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows', + '/accountLists/account-list-1/tools/appeals/appeal/appeal-Id-1/flows', query: {}, }), ); @@ -202,7 +202,8 @@ describe('ContactsPageContext', () => { await waitFor(() => expect(getByText('list')).toBeInTheDocument()); await waitFor(() => expect(push).toHaveBeenCalledWith({ - pathname: '/accountLists/account-list-1/tools/appeals/appeal-Id-1/list', + pathname: + '/accountLists/account-list-1/tools/appeals/appeal/appeal-Id-1/list', query: {}, }), ); @@ -217,7 +218,8 @@ describe('ContactsPageContext', () => { accountListId, appealId: [appealIdentifier, 'flows', contactId], }, - pathname: '/accountLists/[accountListId]/contacts/[[...contactId]]', + pathname: + '/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]]', isReady, push, }} @@ -253,7 +255,7 @@ describe('ContactsPageContext', () => { await waitFor(() => expect(push).toHaveBeenCalledWith({ pathname: - '/accountLists/account-list-1/tools/appeals/appeal-Id-1/flows', + '/accountLists/account-list-1/tools/appeals/appeal/appeal-Id-1/flows', query: {}, }), ); @@ -268,7 +270,8 @@ describe('ContactsPageContext', () => { accountListId, appealId: [appealIdentifier, 'flows', contactId], }, - pathname: '/accountLists/[accountListId]/contacts', + pathname: + '/accountLists/[accountListId]/tools/appeals/appeal/[[...appealId]]', isReady, push, }} diff --git a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx index 6f659aabf..24ff92b84 100644 --- a/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx +++ b/src/components/Tool/Appeal/AppealsContext/AppealsContext.tsx @@ -101,7 +101,6 @@ export const AppealsProvider: React.FC = ({ ); } }, - skip: page === PageEnum.InitialPage, }); const contactsFilters = useMemo( @@ -121,7 +120,7 @@ export const AppealsProvider: React.FC = ({ contactsFilters, first: 25, }, - skip: !accountListId || page === PageEnum.InitialPage, + skip: !accountListId, }); const { data, loading } = contactsQueryResult; @@ -134,7 +133,7 @@ export const AppealsProvider: React.FC = ({ first: contactCount, contactsFilters, }, - skip: contactCount === 0 || page === PageEnum.InitialPage, + skip: contactCount === 0, }); const allContactIds = useMemo( () => allContacts?.contacts.nodes.map((contact) => contact.id) ?? [], @@ -189,7 +188,7 @@ export const AppealsProvider: React.FC = ({ const { data: filterData, loading: filtersLoading } = useContactFiltersQuery({ variables: { accountListId: accountListId ?? '' }, - skip: !accountListId || page === PageEnum.InitialPage, + skip: !accountListId, context: { doNotBatch: true, }, @@ -217,9 +216,6 @@ export const AppealsProvider: React.FC = ({ //#region User Actions const setContactFocus = (id?: string, openDetails = true) => { - if (page === PageEnum.InitialPage) { - return; - } const { accountListId: _accountListId, contactId: _contactId, @@ -236,7 +232,7 @@ export const AppealsProvider: React.FC = ({ } let pathname = ''; - pathname = `/accountLists/${accountListId}/tools/appeals`; + pathname = `/accountLists/${accountListId}/tools/appeals/appeal`; if (appealId) { pathname += `/${appealId}`; } diff --git a/src/components/Tool/Appeal/InitialPage/Appeal.tsx b/src/components/Tool/Appeal/InitialPage/Appeal.tsx index 19173d863..c66b510df 100644 --- a/src/components/Tool/Appeal/InitialPage/Appeal.tsx +++ b/src/components/Tool/Appeal/InitialPage/Appeal.tsx @@ -105,7 +105,7 @@ const Appeal = ({ className={classes.nameLink} > {name} diff --git a/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx b/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx index 6ac055cf8..8416dbdaa 100644 --- a/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx +++ b/src/components/Tool/Appeal/InitialPage/AppealsInitialPage.tsx @@ -1,10 +1,10 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { Box, Divider, Grid, Theme, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { makeStyles } from 'tss-react/mui'; import AddAppealForm from 'src/components/Tool/Appeal/InitialPage/AddAppealForm'; import Appeals from 'src/components/Tool/Appeal/InitialPage/Appeals'; -import { AppealsContext, AppealsType } from '../AppealsContext/AppealsContext'; +import { useAccountListId } from 'src/hooks/useAccountListId'; const useStyles = makeStyles()((theme: Theme) => ({ container: { @@ -25,8 +25,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AppealsInitialPage: React.FC = () => { const { t } = useTranslation(); const { classes } = useStyles(); - - const { accountListId } = useContext(AppealsContext) as AppealsType; + const accountListId = useAccountListId(); return ( From 8b4f63c4122fa9a5fe2e93699672ea7ad8a540ba Mon Sep 17 00:00:00 2001 From: Daniel Bisgrove Date: Mon, 12 Aug 2024 15:55:34 -0400 Subject: [PATCH 15/18] Giving each list filter it's own 0 contacts message --- .../Shared/Filters/NullState/NullState.tsx | 23 +++++---- .../Appeal/List/ContactsList/ContactsList.tsx | 50 +++++++++++++++++-- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/components/Shared/Filters/NullState/NullState.tsx b/src/components/Shared/Filters/NullState/NullState.tsx index 18057b330..184f22a69 100644 --- a/src/components/Shared/Filters/NullState/NullState.tsx +++ b/src/components/Shared/Filters/NullState/NullState.tsx @@ -14,6 +14,7 @@ import { TaskFilterSetInput, } from 'src/graphql/types.generated'; import useTaskModal from 'src/hooks/useTaskModal'; +import i18n from 'src/lib/i18n'; import theme from 'src/theme'; import { NullStateBox } from './NullStateBox'; @@ -68,6 +69,8 @@ interface Props { page: 'contact' | 'task'; totalCount: number; filtered: boolean; + title?: string; + paragraph?: string; changeFilters: | Dispatch> | Dispatch>; @@ -78,6 +81,13 @@ const NullState: React.FC = ({ totalCount, filtered, changeFilters, + title = i18n.t('You have {{count}} total {{page}}s', { + count: totalCount, + page, + }), + paragraph = i18n.t( + 'Unfortunately none of them match your current search or filters.', + ), }: Props) => { const { t } = useTranslation(); @@ -89,17 +99,8 @@ const NullState: React.FC = ({ /> {filtered ? ( <> - - - - - {t( - 'Unfortunately none of them match your current search or filters.', - )} - + {title} + {paragraph}