Skip to content

Commit 2eb419d

Browse files
mwclemyClement Mwimo
andauthored
Only show members whose institutions support the requested products in the 'Verify Existing Member' step (#94)
* added config error component * added the error handling logic * added a new method to the test * added the tests * fixed styles * added translations * moved instutionSupportRequestedProducts to a util * fixed the function logic * fixed the test * removed unused code * added more test coverage * updated translations * added the test * fix the re-render * fixed the tests * fix tyoescript * updated version and changelog --------- Co-authored-by: Clement Mwimo <clement.mwimo@mx.com>
1 parent 01e2d2c commit 2eb419d

4 files changed

Lines changed: 201 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v.0.11.0
4+
5+
### Updated
6+
7+
- Verify Existing Member screen to only show members whose institutions support the requested products.
8+
39
## v.0.10.4
410

511
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@mxenabled/connect-widget",
33
"description": "A simple ui library for React",
4-
"version": "0.10.4",
4+
"version": "0.11.0",
55
"module": "dist/index.es.js",
66
"types": "dist/index.d.ts",
77
"type": "module",

src/views/verification/VerifyExistingMember.tsx

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import React, { useState, useEffect } from 'react'
2+
import React, { useState, useEffect, useCallback, useMemo } from 'react'
33
import PropTypes from 'prop-types'
4-
import { useDispatch } from 'react-redux'
4+
import { useDispatch, useSelector } from 'react-redux'
55

66
import { useTokens } from '@kyper/tokenprovider'
77
import { Text } from '@kyper/mui'
@@ -19,6 +19,8 @@ import { PrivateAndSecure } from 'src/components/PrivateAndSecure'
1919
import { LoadingSpinner } from 'src/components/LoadingSpinner'
2020
import { GenericError } from 'src/components/GenericError'
2121
import { useApi } from 'src/context/ApiContext'
22+
import { selectConfig } from 'src/redux/reducers/configSlice'
23+
import { instutionSupportRequestedProducts } from 'src/utilities/Institution'
2224

2325
interface VerifyExistingMemberProps {
2426
members: MemberResponseType[]
@@ -28,56 +30,86 @@ interface VerifyExistingMemberProps {
2830
const VerifyExistingMember: React.FC<VerifyExistingMemberProps> = (props) => {
2931
useAnalyticsPath(...PageviewInfo.CONNECT_VERIFY_EXISTING_MEMBER)
3032
const { api } = useApi()
33+
const config = useSelector(selectConfig)
3134
const dispatch = useDispatch()
3235
const { members, onAddNew } = props
33-
const iavMembers = members.filter(
34-
(member) => member.verification_is_enabled && member.is_managed_by_user, // Only show user-managed members that support verification
35-
)
36-
const [selectedMember, setSelectedMember] = useState<MemberResponseType | null>(null)
37-
const [{ isLoadingInstitution, institutionError }, setInstitution] = useState({
38-
isLoadingInstitution: false,
39-
institutionError: null,
40-
})
36+
37+
const iavMembers = useMemo(() => {
38+
return members.filter(
39+
(member) => member.verification_is_enabled && member.is_managed_by_user, // Only show user-managed members that support verification
40+
)
41+
}, [members])
42+
43+
const [institutions, setInstitutions] = useState<Map<string, InstitutionResponseType>>(new Map())
44+
const [loading, setLoading] = useState(true)
45+
const [error, setError] = useState<Error | null>(null)
4146

4247
const tokens = useTokens()
4348

4449
const styles = getStyles(tokens)
4550

46-
const handleMemberClick = (selectedMember: MemberResponseType) => {
47-
setSelectedMember(selectedMember)
48-
setInstitution((state) => ({ ...state, isLoadingInstitution: true }))
49-
}
51+
const handleMemberClick = useCallback(
52+
(selectedMember: MemberResponseType) => {
53+
const institution = institutions.get(selectedMember.institution_guid)
54+
if (institution) {
55+
if (selectedMember.is_oauth) {
56+
dispatch(startOauth(selectedMember, institution))
57+
} else {
58+
dispatch(verifyExistingConnection(selectedMember, institution))
59+
}
60+
}
61+
},
62+
[dispatch, institutions],
63+
)
5064

5165
useEffect(() => {
5266
focusElement(document.getElementById('connect-select-institution'))
5367
}, [])
5468

5569
useEffect(() => {
56-
if (!isLoadingInstitution || !selectedMember) return
57-
58-
api
59-
.loadInstitutionByGuid(selectedMember.institution_guid)
60-
.then((institution) => {
61-
if (selectedMember.is_oauth) {
62-
dispatch(startOauth(selectedMember, institution))
63-
} else {
64-
dispatch(verifyExistingConnection(selectedMember, institution))
70+
const fetchInstitutionsProgressively = async () => {
71+
setLoading(true)
72+
setError(null)
73+
74+
const institutionMap = new Map<string, InstitutionResponseType>()
75+
76+
for (const member of iavMembers) {
77+
try {
78+
const institution = await api.loadInstitutionByGuid(member.institution_guid)
79+
if (institution) {
80+
institutionMap.set(member.institution_guid, institution)
81+
}
82+
} catch (err) {
83+
setError(err)
6584
}
66-
})
67-
.catch((error) => {
68-
setInstitution((state) => ({
69-
...state,
70-
isLoadingInstitution: false,
71-
institutionError: error,
72-
}))
73-
})
74-
}, [isLoadingInstitution, selectedMember])
75-
76-
if (isLoadingInstitution) {
85+
}
86+
87+
setInstitutions(new Map(institutionMap))
88+
setLoading(false)
89+
}
90+
91+
if (iavMembers.length > 0) {
92+
fetchInstitutionsProgressively()
93+
} else {
94+
setLoading(false)
95+
}
96+
}, [api, iavMembers])
97+
98+
const productSupportingMembers = useMemo(() => {
99+
return iavMembers.filter((member) => {
100+
const institution = institutions.get(member.institution_guid)
101+
if (institution) {
102+
return instutionSupportRequestedProducts(config, institution)
103+
}
104+
return false
105+
})
106+
}, [config, institutions, iavMembers])
107+
108+
if (loading) {
77109
return <LoadingSpinner showText={true} />
78110
}
79111

80-
if (institutionError) {
112+
if (error) {
81113
return (
82114
<GenericError
83115
onAnalyticPageview={() => {}}
@@ -119,11 +151,11 @@ const VerifyExistingMember: React.FC<VerifyExistingMemberProps> = (props) => {
119151
{_n(
120152
'%1 Connected institution',
121153
'%1 Connected institutions',
122-
iavMembers.length,
123-
iavMembers.length,
154+
productSupportingMembers.length,
155+
productSupportingMembers.length,
124156
)}
125157
</Text>
126-
{iavMembers.map((member) => {
158+
{productSupportingMembers.map((member) => {
127159
return (
128160
<UtilityRow
129161
borderType="none"
Lines changed: 124 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,21 @@
1-
import { render, screen } from 'src/utilities/testingLibrary'
1+
import { render, screen, waitFor } from 'src/utilities/testingLibrary'
22
import React from 'react'
33

44
import VerifyExistingMember from 'src/views/verification/VerifyExistingMember'
55
import { ReadableStatuses } from 'src/const/Statuses'
6-
7-
describe('VerifyExistingMember Test', () => {
8-
const onAddNewMock = vi.fn()
9-
beforeEach(() => {
10-
render(<VerifyExistingMember members={mockMembers} onAddNew={onAddNewMock} />)
11-
})
12-
afterEach(() => {
13-
vi.clearAllMocks()
14-
})
15-
16-
it('should render a list of members, only those that support IAV and are managed by the user', () => {
17-
expect(screen.getByText('Member 1')).toBeInTheDocument()
18-
expect(screen.queryByText('Member 2')).not.toBeInTheDocument()
19-
expect(screen.queryByText('Member 3')).not.toBeInTheDocument()
20-
})
21-
22-
it('should render a title and paragraph', () => {
23-
expect(screen.getByText('Select your institution')).toBeInTheDocument()
24-
expect(
25-
screen.getByText(
26-
'Choose an institution that’s already connected and select accounts to share, or search for a different one.',
27-
),
28-
).toBeInTheDocument()
29-
})
30-
31-
it('should render a count of connected institutions', () => {
32-
expect(screen.getByText('1 Connected institution')).toBeInTheDocument()
33-
})
34-
35-
it('should render a button to search for more institutions', () => {
36-
const button = screen.getByText('Search more institutions')
37-
expect(button).toBeInTheDocument()
38-
39-
button.click()
40-
expect(onAddNewMock).toHaveBeenCalled()
41-
})
42-
})
6+
import { ApiProvider } from 'src/context/ApiContext'
7+
import { initialState } from 'src/services/mockedData'
8+
import { COMBO_JOB_DATA_TYPES } from 'src/const/comboJobDataTypes'
439

4410
const mockMembers = [
4511
{
4612
guid: 'MBR-123',
4713
name: 'Member 1',
14+
institution_guid: 'INS-123',
4815
verification_is_enabled: true,
4916
is_managed_by_user: true,
5017
aggregation_status: 1,
5118
connection_status: ReadableStatuses.CONNECTED,
52-
institution_guid: 'INS-123',
5319
institution_url: 'https://example.com',
5420
is_being_aggregated: false,
5521
is_manual: false,
@@ -59,11 +25,11 @@ const mockMembers = [
5925
{
6026
guid: 'MBR-456',
6127
name: 'Member 2',
28+
institution_guid: 'INS-456',
6229
verification_is_enabled: true,
63-
is_managed_by_user: false,
30+
is_managed_by_user: true,
6431
aggregation_status: 6,
6532
connection_status: ReadableStatuses.CONNECTED,
66-
institution_guid: 'INS-456',
6733
institution_url: 'https://example.com',
6834
is_being_aggregated: false,
6935
is_manual: false,
@@ -73,15 +39,131 @@ const mockMembers = [
7339
{
7440
guid: 'MBR-789',
7541
name: 'Member 3',
42+
institution_guid: 'INS-789',
7643
verification_is_enabled: false,
7744
is_managed_by_user: true,
7845
aggregation_status: 6,
7946
connection_status: ReadableStatuses.CONNECTED,
80-
institution_guid: 'INS-789',
8147
institution_url: 'https://example.com',
8248
is_being_aggregated: false,
8349
is_manual: false,
8450
is_oauth: true,
8551
user_guid: 'USR-789',
8652
},
8753
]
54+
55+
const mockInstitutions = new Map([
56+
[
57+
'INS-123',
58+
{
59+
guid: 'INS-123',
60+
name: 'Institution 1',
61+
account_verification_is_enabled: true,
62+
account_identification_is_enabled: false,
63+
},
64+
],
65+
[
66+
'INS-456',
67+
{
68+
guid: 'INS-456',
69+
name: 'Institution 2',
70+
account_verification_is_enabled: true,
71+
account_identification_is_enabled: true,
72+
},
73+
],
74+
[
75+
'INS-789',
76+
{
77+
guid: 'INS-789',
78+
name: 'Institution 3',
79+
account_identification_is_enabled: true,
80+
account_verification_is_enabled: false,
81+
},
82+
],
83+
])
84+
85+
describe('VerifyExistingMember Component', () => {
86+
beforeEach(() => {
87+
vi.resetAllMocks() // Clear mocks before each test
88+
})
89+
90+
const loadInstitutionByGuidMock = (guid) => {
91+
return Promise.resolve(mockInstitutions.get(guid))
92+
}
93+
94+
const onAddNewMock = vi.fn()
95+
96+
// Helper function to render the component with consistent setup
97+
const renderComponent = (config = {}) => {
98+
return render(
99+
<ApiProvider apiValue={{ loadInstitutionByGuid: loadInstitutionByGuidMock }}>
100+
<VerifyExistingMember members={mockMembers} onAddNew={onAddNewMock} />
101+
</ApiProvider>,
102+
{
103+
preloadedState: {
104+
config: {
105+
...initialState.config,
106+
...config,
107+
},
108+
},
109+
},
110+
)
111+
}
112+
113+
it('should render a list of members, only those that support IAV and are managed by the user', async () => {
114+
renderComponent({
115+
data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_NUMBER] },
116+
})
117+
118+
await waitFor(() => screen.getByText('Member 1'))
119+
120+
expect(screen.getByText('Member 1')).toBeInTheDocument()
121+
expect(screen.queryByText('Member 2')).toBeInTheDocument()
122+
expect(screen.queryByText('Member 3')).not.toBeInTheDocument()
123+
})
124+
125+
it('should render a list of members, only those that support ACCOUNT_OWNER and are managed by the user', async () => {
126+
renderComponent({
127+
data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_OWNER] },
128+
})
129+
130+
await waitFor(() => screen.getByText('Member 2'))
131+
132+
expect(screen.getByText('Member 2')).toBeInTheDocument()
133+
expect(screen.queryByText('Member 1')).not.toBeInTheDocument()
134+
expect(screen.queryByText('Member 3')).not.toBeInTheDocument()
135+
})
136+
137+
it('should render a title and paragraph', async () => {
138+
renderComponent()
139+
140+
await waitFor(() => {
141+
expect(screen.getByRole('heading', { name: /Select your institution/i })).toBeInTheDocument()
142+
expect(
143+
screen.getByText(
144+
'Choose an institution that’s already connected and select accounts to share, or search for a different one.',
145+
),
146+
).toBeInTheDocument()
147+
})
148+
})
149+
150+
it('should render a count of connected institutions', async () => {
151+
renderComponent({
152+
data_request: { products: [COMBO_JOB_DATA_TYPES.ACCOUNT_OWNER] },
153+
})
154+
155+
await waitFor(() => screen.getByText('1 Connected institution'))
156+
expect(screen.getByText('1 Connected institution')).toBeInTheDocument()
157+
})
158+
159+
it('should call onAddNew when the search more institutions button is clicked', async () => {
160+
const { user } = renderComponent()
161+
162+
await waitFor(async () => {
163+
const button = screen.getByRole('button', { name: /Search more institutions/i })
164+
await user.click(button)
165+
166+
expect(onAddNewMock).toHaveBeenCalledTimes(1)
167+
})
168+
})
169+
})

0 commit comments

Comments
 (0)