diff --git a/src/Connect.tsx b/src/Connect.tsx index f44c31faad..267e07bff1 100644 --- a/src/Connect.tsx +++ b/src/Connect.tsx @@ -328,25 +328,19 @@ export const Connect: React.FC = ({ return (
- {state.memberToDelete && ( - { - setState({ ...state, memberToDelete: null }) - }} - onDeleteSuccess={(deletedMember) => { - postMessageFunctions.onPostMessage('connect/memberDeleted', { - member_guid: deletedMember.guid, - }) - onMemberDeleted(deletedMember.guid) - - setState((prevState) => { - dispatch(connectActions.stepToDeleteMemberSuccess(deletedMember.guid)) - return { ...prevState, memberToDelete: null } - }) - }} - /> - )} + setState({ ...state, memberToDelete: null })} + onMemberDeleted={(memberGuid) => { + postMessageFunctions.onPostMessage('connect/memberDeleted', { + member_guid: memberGuid, + }) + onMemberDeleted(memberGuid) + dispatch(connectActions.stepToDeleteMemberSuccess(memberGuid)) + setState({ ...state, memberToDelete: null }) + }} + /> dispatch(handleGoBackWithSideEffects())} diff --git a/src/components/ConnectInstitutionHeader-test.tsx b/src/components/ConnectInstitutionHeader-test.tsx new file mode 100644 index 0000000000..e0bda198d5 --- /dev/null +++ b/src/components/ConnectInstitutionHeader-test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { ConnectInstitutionHeader } from 'src/components/ConnectInstitutionHeader' +import { COLOR_SCHEME } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +describe('ConnectInstitutionHeader', () => { + const createPreloadedState = (colorScheme: string) => ({ + ...initialState, + config: { + ...initialState.config, + color_scheme: colorScheme, + }, + }) + + it('renders light backdrop when color scheme is LIGHT', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('backdrop-light')).toBeInTheDocument() + expect(screen.queryByTestId('backdrop-dark')).not.toBeInTheDocument() + }) + + it('renders dark backdrop when color scheme is DARK', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.DARK) + render(, { preloadedState }) + + expect(screen.getByTestId('backdrop-dark')).toBeInTheDocument() + expect(screen.queryByTestId('backdrop-light')).not.toBeInTheDocument() + }) + + it('renders HeaderDevice', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('device-container')).toBeInTheDocument() + }) + + it('renders InstitutionLogo when institutionGuid is provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { + preloadedState, + }) + + expect(screen.getByTestId('institution-logo-container')).toBeInTheDocument() + expect(screen.queryByTestId('default-institution-icon')).not.toBeInTheDocument() + }) + + it('renders default institution icon when institutionGuid is not provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('default-institution-icon')).toBeInTheDocument() + }) +}) diff --git a/src/components/ConnectInstitutionHeader.js b/src/components/ConnectInstitutionHeader.js index 6539ad5098..d03cec0588 100644 --- a/src/components/ConnectInstitutionHeader.js +++ b/src/components/ConnectInstitutionHeader.js @@ -22,16 +22,20 @@ export const ConnectInstitutionHeader = (props) => { return (
-
- {colorScheme === COLOR_SCHEME.LIGHT ? : } -
+
+ {colorScheme === COLOR_SCHEME.LIGHT ? ( + + ) : ( + + )} +
-
+
{props.institutionGuid ? ( ) : ( - + )}
diff --git a/src/components/Container-test.tsx b/src/components/Container-test.tsx new file mode 100644 index 0000000000..7e9821df16 --- /dev/null +++ b/src/components/Container-test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render } from 'src/utilities/testingLibrary' +import { Container } from 'src/components/Container' +import { STEPS } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +describe('Container', () => { + const preloadedState = initialState + + it('renders', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + expect(containerDiv).not.toHaveStyle({ maxHeight: '100%' }) + }) + + it('applies maxHeight when step is SEARCH', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toHaveStyle({ maxHeight: '100%' }) + }) +}) diff --git a/src/components/Container.js b/src/components/Container.js deleted file mode 100644 index 51d48c7c1c..0000000000 --- a/src/components/Container.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { useTokens } from '@kyper/tokenprovider' - -import { STEPS } from 'src/const/Connect' -/** - * Our root container to handle our widgets min/max widths, positioning and padding for all views - */ -export const Container = (props) => { - const tokens = useTokens() - const styles = getStyles(tokens, props.step) - - return ( -
-
{props.children}
-
- ) -} -Container.propTypes = { - step: PropTypes.string, -} - -const getStyles = (tokens, step) => { - return { - container: { - backgroundColor: tokens.BackgroundColor.Container, - minHeight: '100%', - maxHeight: step === STEPS.SEARCH ? '100%' : null, - display: 'flex', - justifyContent: 'center', - }, - content: { - maxWidth: '400px', // Our max content width (does not include side margin) - minWidth: '270px', // Our min content width (does not include side margin) - width: '100%', // We want this container to shrink and grow between our min-max - margin: tokens.Spacing.Large, - }, - } -} diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 7fa7c9f0ae..b44d8766a7 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -1,9 +1,11 @@ import React from 'react' import { useTokens } from '@kyper/tokenprovider' +import { STEPS } from 'src/const/Connect' interface ContainerProps { children?: React.ReactNode + step?: string } /** @@ -11,7 +13,7 @@ interface ContainerProps { */ export const Container: React.FC = (props) => { const tokens = useTokens() - const styles = getStyles(tokens) + const styles = getStyles(tokens, props.step) return (
@@ -21,11 +23,12 @@ export const Container: React.FC = (props) => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const getStyles = (tokens: any) => { +const getStyles = (tokens: any, step?: string) => { return { container: { backgroundColor: tokens.BackgroundColor.Container, minHeight: '100%', + maxHeight: step === STEPS.SEARCH ? '100%' : undefined, display: 'flex', justifyContent: 'center', }, diff --git a/src/components/DeleteMemberSurvey-test.tsx b/src/components/DeleteMemberSurvey-test.tsx new file mode 100644 index 0000000000..8126183264 --- /dev/null +++ b/src/components/DeleteMemberSurvey-test.tsx @@ -0,0 +1,250 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { DeleteMemberSurvey, DELETE_REASONS } from 'src/components/DeleteMemberSurvey' +import { initialState, CONNECTED_MEMBER } from 'src/services/mockedData' +import userEvent from '@testing-library/user-event' +import { apiValue as mockApiValue } from 'src/const/apiProviderMock' +import { ReadableStatuses } from 'src/const/Statuses' + +describe('DeleteMemberSurvey', () => { + const preloadedState = initialState + + it('does not render when isOpen is false', () => { + const { container } = render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(container.firstChild).toBeNull() + }) + + it('renders when isOpen is true', () => { + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText('Disconnect institution')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-disclaimer').textContent).toContain('Chase Bank') + }) + + it('calls onClose when cancel button clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-cancel-button')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('shows connected member reasons', () => { + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText(DELETE_REASONS.NO_LONGER_USE_ACCOUNT)).toBeInTheDocument() + expect(screen.getByText(DELETE_REASONS.DONT_WANT_SHARE_DATA)).toBeInTheDocument() + expect(screen.queryByText(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT)).not.toBeInTheDocument() + }) + + it('shows non-connected member reasons', () => { + const nonConnectedMember = { + ...CONNECTED_MEMBER, + connection_status: ReadableStatuses.PREVENTED, + } + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT)).toBeInTheDocument() + expect(screen.getByText(DELETE_REASONS.ACCOUNT_INFORMATION_OLD)).toBeInTheDocument() + expect(screen.queryByText(DELETE_REASONS.NO_LONGER_USE_ACCOUNT)).not.toBeInTheDocument() + }) + + it('shows validation error when no reason selected', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + }) + + it('allows selecting a reason', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + + expect(firstReason).toBeChecked() + }) + + it('clears validation error after selecting a reason', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + + expect(screen.queryByText('Choose a reason for deleting')).not.toBeInTheDocument() + }) + + it('successfully deletes member when reason selected', async () => { + const user = userEvent.setup() + const deleteMemberSpy = vi.fn(() => Promise.resolve()) + const onClose = vi.fn() + const onMemberDeleted = vi.fn() + const apiValue = { + ...mockApiValue, + deleteMember: deleteMemberSpy, + } + + render( + , + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(deleteMemberSpy).toHaveBeenCalledWith(CONNECTED_MEMBER) + }) + + await waitFor(() => { + expect(onMemberDeleted).toHaveBeenCalledWith(CONNECTED_MEMBER.guid) + expect(onClose).toHaveBeenCalled() + }) + }) + + it('shows error message when delete fails', async () => { + const user = userEvent.setup() + const apiValue = { + ...mockApiValue, + deleteMember: vi.fn(() => Promise.reject(new Error('Delete failed'))), + } + + render( + {}} + onMemberDeleted={() => {}} + />, + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByTestId('disconnect-error-header')).toBeInTheDocument() + }) + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-error-message')).toBeInTheDocument() + }) + + it('dismisses error dialog when ok clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const apiValue = { + ...mockApiValue, + deleteMember: vi.fn(() => Promise.reject(new Error('Delete failed'))), + } + + render( + {}} + />, + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByTestId('disconnect-error-header')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('disconnect-ok-button')) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/components/DeleteMemberSurvey.js b/src/components/DeleteMemberSurvey.js index 9d8565541f..6b76b5587f 100644 --- a/src/components/DeleteMemberSurvey.js +++ b/src/components/DeleteMemberSurvey.js @@ -18,8 +18,18 @@ import useAnalyticsPath from 'src/hooks/useAnalyticsPath' import { PageviewInfo } from 'src/const/Analytics' import { ReadableStatuses } from 'src/const/Statuses' +export const DELETE_REASONS = { + NO_LONGER_USE_ACCOUNT: "I no longer use this account or it's not mine", + DONT_WANT_SHARE_DATA: "I don't want to share my data", + ACCOUNT_INFORMATION_OLD: 'The account information is old or inaccurate', + UNABLE_CONNECT_ACCOUNT: 'I am unable to connect this account here', + DONT_WANT_TO_USE_APP: "I don't want to use this app", + DONT_WANT_ACCOUNT_CONNECTED: "I don't want this account connected here", + OTHER_REASON: 'Other', +} + export const DeleteMemberSurvey = (props) => { - const { member, onCancel, onDeleteSuccess } = props + const { isOpen, member, onClose, onMemberDeleted } = props const containerRef = useRef(null) useAnalyticsPath(...PageviewInfo.CONNECT_DELETE_MEMBER_SURVEY) const { api } = useApi() @@ -32,39 +42,34 @@ export const DeleteMemberSurvey = (props) => { const tokens = useTokens() const styles = getStyles(tokens) - const DELETE_REASONS = { - NO_LONGER_USE_ACCOUNT: __("I no longer use this account or it's not mine"), - DONT_WANT_SHARE_DATA: __("I don't want to share my data"), - ACCOUNT_INFORMATION_OLD: __('The account information is old or inaccurate'), - UNABLE_CONNECT_ACCOUNT: __('I am unable to connect this account here'), - DONT_WANT_TO_USE_APP: __("I don't want to use this app"), - DONT_WANT_ACCOUNT_CONNECTED: __("I don't want this account connected here"), - OTHER_REASON: __('Other'), - } - const CONNECTED_REASONS = [ - DELETE_REASONS.NO_LONGER_USE_ACCOUNT, - DELETE_REASONS.DONT_WANT_SHARE_DATA, - DELETE_REASONS.DONT_WANT_TO_USE_APP, - DELETE_REASONS.OTHER_REASON, + __(DELETE_REASONS.NO_LONGER_USE_ACCOUNT), + __(DELETE_REASONS.DONT_WANT_SHARE_DATA), + __(DELETE_REASONS.DONT_WANT_TO_USE_APP), + __(DELETE_REASONS.OTHER_REASON), ] const NON_CONECTED_REASONS = [ - DELETE_REASONS.UNABLE_CONNECT_ACCOUNT, - DELETE_REASONS.ACCOUNT_INFORMATION_OLD, - DELETE_REASONS.DONT_WANT_ACCOUNT_CONNECTED, - DELETE_REASONS.OTHER_REASON, + __(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT), + __(DELETE_REASONS.ACCOUNT_INFORMATION_OLD), + __(DELETE_REASONS.DONT_WANT_ACCOUNT_CONNECTED), + __(DELETE_REASONS.OTHER_REASON), ] useEffect(() => { if (deleteMemberState.loading === false) return () => {} const request$ = defer(() => api.deleteMember(member)).subscribe( - () => onDeleteSuccess(member), + () => { + onMemberDeleted(member.guid) + onClose() + }, (err) => updateDeleteMemberState({ loading: false, error: err }), ) return () => request$.unsubscribe() - }, [deleteMemberState.loading]) + }, [deleteMemberState.loading, api, member, onMemberDeleted, onClose]) + + if (!isOpen || !member) return null let reasonList @@ -109,7 +114,7 @@ export const DeleteMemberSurvey = (props) => {