diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe793d431..5c8b535d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v.0.11.0 + +### Updated + +- Verify Existing Member screen to only show members whose institutions support the requested products. + ## v.0.10.4 ### Fixed diff --git a/package.json b/package.json index 3c15c72543..5dbecb33b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mxenabled/connect-widget", "description": "A simple ui library for React", - "version": "0.10.4", + "version": "0.11.0", "module": "dist/index.es.js", "types": "dist/index.d.ts", "type": "module", diff --git a/src/views/verification/VerifyExistingMember.tsx b/src/views/verification/VerifyExistingMember.tsx index 5773e89447..de49c37027 100644 --- a/src/views/verification/VerifyExistingMember.tsx +++ b/src/views/verification/VerifyExistingMember.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' import PropTypes from 'prop-types' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { useTokens } from '@kyper/tokenprovider' import { Text } from '@kyper/mui' @@ -19,6 +19,8 @@ import { PrivateAndSecure } from 'src/components/PrivateAndSecure' import { LoadingSpinner } from 'src/components/LoadingSpinner' import { GenericError } from 'src/components/GenericError' import { useApi } from 'src/context/ApiContext' +import { selectConfig } from 'src/redux/reducers/configSlice' +import { instutionSupportRequestedProducts } from 'src/utilities/Institution' interface VerifyExistingMemberProps { members: MemberResponseType[] @@ -28,56 +30,86 @@ interface VerifyExistingMemberProps { const VerifyExistingMember: React.FC = (props) => { useAnalyticsPath(...PageviewInfo.CONNECT_VERIFY_EXISTING_MEMBER) const { api } = useApi() + const config = useSelector(selectConfig) const dispatch = useDispatch() const { members, onAddNew } = props - const iavMembers = members.filter( - (member) => member.verification_is_enabled && member.is_managed_by_user, // Only show user-managed members that support verification - ) - const [selectedMember, setSelectedMember] = useState(null) - const [{ isLoadingInstitution, institutionError }, setInstitution] = useState({ - isLoadingInstitution: false, - institutionError: null, - }) + + const iavMembers = useMemo(() => { + return members.filter( + (member) => member.verification_is_enabled && member.is_managed_by_user, // Only show user-managed members that support verification + ) + }, [members]) + + const [institutions, setInstitutions] = useState>(new Map()) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const tokens = useTokens() const styles = getStyles(tokens) - const handleMemberClick = (selectedMember: MemberResponseType) => { - setSelectedMember(selectedMember) - setInstitution((state) => ({ ...state, isLoadingInstitution: true })) - } + const handleMemberClick = useCallback( + (selectedMember: MemberResponseType) => { + const institution = institutions.get(selectedMember.institution_guid) + if (institution) { + if (selectedMember.is_oauth) { + dispatch(startOauth(selectedMember, institution)) + } else { + dispatch(verifyExistingConnection(selectedMember, institution)) + } + } + }, + [dispatch, institutions], + ) useEffect(() => { focusElement(document.getElementById('connect-select-institution')) }, []) useEffect(() => { - if (!isLoadingInstitution || !selectedMember) return - - api - .loadInstitutionByGuid(selectedMember.institution_guid) - .then((institution) => { - if (selectedMember.is_oauth) { - dispatch(startOauth(selectedMember, institution)) - } else { - dispatch(verifyExistingConnection(selectedMember, institution)) + const fetchInstitutionsProgressively = async () => { + setLoading(true) + setError(null) + + const institutionMap = new Map() + + for (const member of iavMembers) { + try { + const institution = await api.loadInstitutionByGuid(member.institution_guid) + if (institution) { + institutionMap.set(member.institution_guid, institution) + } + } catch (err) { + setError(err) } - }) - .catch((error) => { - setInstitution((state) => ({ - ...state, - isLoadingInstitution: false, - institutionError: error, - })) - }) - }, [isLoadingInstitution, selectedMember]) - - if (isLoadingInstitution) { + } + + setInstitutions(new Map(institutionMap)) + setLoading(false) + } + + if (iavMembers.length > 0) { + fetchInstitutionsProgressively() + } else { + setLoading(false) + } + }, [api, iavMembers]) + + const productSupportingMembers = useMemo(() => { + return iavMembers.filter((member) => { + const institution = institutions.get(member.institution_guid) + if (institution) { + return instutionSupportRequestedProducts(config, institution) + } + return false + }) + }, [config, institutions, iavMembers]) + + if (loading) { return } - if (institutionError) { + if (error) { return ( {}} @@ -119,11 +151,11 @@ const VerifyExistingMember: React.FC = (props) => { {_n( '%1 Connected institution', '%1 Connected institutions', - iavMembers.length, - iavMembers.length, + productSupportingMembers.length, + productSupportingMembers.length, )} - {iavMembers.map((member) => { + {productSupportingMembers.map((member) => { return ( { - const onAddNewMock = vi.fn() - beforeEach(() => { - render() - }) - afterEach(() => { - vi.clearAllMocks() - }) - - it('should render a list of members, only those that support IAV and are managed by the user', () => { - expect(screen.getByText('Member 1')).toBeInTheDocument() - expect(screen.queryByText('Member 2')).not.toBeInTheDocument() - expect(screen.queryByText('Member 3')).not.toBeInTheDocument() - }) - - it('should render a title and paragraph', () => { - expect(screen.getByText('Select your institution')).toBeInTheDocument() - expect( - screen.getByText( - 'Choose an institution that’s already connected and select accounts to share, or search for a different one.', - ), - ).toBeInTheDocument() - }) - - it('should render a count of connected institutions', () => { - expect(screen.getByText('1 Connected institution')).toBeInTheDocument() - }) - - it('should render a button to search for more institutions', () => { - const button = screen.getByText('Search more institutions') - expect(button).toBeInTheDocument() - - button.click() - expect(onAddNewMock).toHaveBeenCalled() - }) -}) +import { ApiProvider } from 'src/context/ApiContext' +import { initialState } from 'src/services/mockedData' +import { COMBO_JOB_DATA_TYPES } from 'src/const/comboJobDataTypes' const mockMembers = [ { guid: 'MBR-123', name: 'Member 1', + institution_guid: 'INS-123', verification_is_enabled: true, is_managed_by_user: true, aggregation_status: 1, connection_status: ReadableStatuses.CONNECTED, - institution_guid: 'INS-123', institution_url: 'https://example.com', is_being_aggregated: false, is_manual: false, @@ -59,11 +25,11 @@ const mockMembers = [ { guid: 'MBR-456', name: 'Member 2', + institution_guid: 'INS-456', verification_is_enabled: true, - is_managed_by_user: false, + is_managed_by_user: true, aggregation_status: 6, connection_status: ReadableStatuses.CONNECTED, - institution_guid: 'INS-456', institution_url: 'https://example.com', is_being_aggregated: false, is_manual: false, @@ -73,11 +39,11 @@ const mockMembers = [ { guid: 'MBR-789', name: 'Member 3', + institution_guid: 'INS-789', verification_is_enabled: false, is_managed_by_user: true, aggregation_status: 6, connection_status: ReadableStatuses.CONNECTED, - institution_guid: 'INS-789', institution_url: 'https://example.com', is_being_aggregated: false, is_manual: false, @@ -85,3 +51,119 @@ const mockMembers = [ user_guid: 'USR-789', }, ] + +const mockInstitutions = new Map([ + [ + 'INS-123', + { + guid: 'INS-123', + name: 'Institution 1', + account_verification_is_enabled: true, + account_identification_is_enabled: false, + }, + ], + [ + 'INS-456', + { + guid: 'INS-456', + name: 'Institution 2', + account_verification_is_enabled: true, + account_identification_is_enabled: true, + }, + ], + [ + 'INS-789', + { + guid: 'INS-789', + name: 'Institution 3', + account_identification_is_enabled: true, + account_verification_is_enabled: false, + }, + ], +]) + +describe('VerifyExistingMember Component', () => { + beforeEach(() => { + vi.resetAllMocks() // Clear mocks before each test + }) + + const loadInstitutionByGuidMock = (guid) => { + return Promise.resolve(mockInstitutions.get(guid)) + } + + const onAddNewMock = vi.fn() + + // Helper function to render the component with consistent setup + const renderComponent = (config = {}) => { + return render( + + + , + { + preloadedState: { + config: { + ...initialState.config, + ...config, + }, + }, + }, + ) + } + + it('should render a list of members, only those that support IAV and are managed by the user', async () => { + renderComponent({ + data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_NUMBER] }, + }) + + await waitFor(() => screen.getByText('Member 1')) + + expect(screen.getByText('Member 1')).toBeInTheDocument() + expect(screen.queryByText('Member 2')).toBeInTheDocument() + expect(screen.queryByText('Member 3')).not.toBeInTheDocument() + }) + + it('should render a list of members, only those that support ACCOUNT_OWNER and are managed by the user', async () => { + renderComponent({ + data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_OWNER] }, + }) + + await waitFor(() => screen.getByText('Member 2')) + + expect(screen.getByText('Member 2')).toBeInTheDocument() + expect(screen.queryByText('Member 1')).not.toBeInTheDocument() + expect(screen.queryByText('Member 3')).not.toBeInTheDocument() + }) + + it('should render a title and paragraph', async () => { + renderComponent() + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /Select your institution/i })).toBeInTheDocument() + expect( + screen.getByText( + 'Choose an institution that’s already connected and select accounts to share, or search for a different one.', + ), + ).toBeInTheDocument() + }) + }) + + it('should render a count of connected institutions', async () => { + renderComponent({ + data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_OWNER] }, + }) + + await waitFor(() => screen.getByText('1 Connected institution')) + expect(screen.getByText('1 Connected institution')).toBeInTheDocument() + }) + + it('should call onAddNew when the search more institutions button is clicked', async () => { + const { user } = renderComponent() + + await waitFor(async () => { + const button = screen.getByRole('button', { name: /Search more institutions/i }) + await user.click(button) + + expect(onAddNewMock).toHaveBeenCalledTimes(1) + }) + }) +})