Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
108 changes: 70 additions & 38 deletions src/views/verification/VerifyExistingMember.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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[]
Expand All @@ -28,56 +30,86 @@ interface VerifyExistingMemberProps {
const VerifyExistingMember: React.FC<VerifyExistingMemberProps> = (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<MemberResponseType | null>(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<Map<string, InstitutionResponseType>>(new Map())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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<string, InstitutionResponseType>()

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 <LoadingSpinner showText={true} />
}

if (institutionError) {
if (error) {
return (
<GenericError
onAnalyticPageview={() => {}}
Expand Down Expand Up @@ -119,11 +151,11 @@ const VerifyExistingMember: React.FC<VerifyExistingMemberProps> = (props) => {
{_n(
'%1 Connected institution',
'%1 Connected institutions',
iavMembers.length,
iavMembers.length,
productSupportingMembers.length,
productSupportingMembers.length,
)}
</Text>
{iavMembers.map((member) => {
{productSupportingMembers.map((member) => {
return (
<UtilityRow
borderType="none"
Expand Down
166 changes: 124 additions & 42 deletions src/views/verification/__tests__/VerifyExistingMember-test.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,21 @@
import { render, screen } from 'src/utilities/testingLibrary'
import { render, screen, waitFor } from 'src/utilities/testingLibrary'
import React from 'react'

import VerifyExistingMember from 'src/views/verification/VerifyExistingMember'
import { ReadableStatuses } from 'src/const/Statuses'

describe('VerifyExistingMember Test', () => {
const onAddNewMock = vi.fn()
beforeEach(() => {
render(<VerifyExistingMember members={mockMembers} onAddNew={onAddNewMock} />)
})
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,
Expand All @@ -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,
Expand All @@ -73,15 +39,131 @@ 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,
is_oauth: true,
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(
<ApiProvider apiValue={{ loadInstitutionByGuid: loadInstitutionByGuidMock }}>
<VerifyExistingMember members={mockMembers} onAddNew={onAddNewMock} />
</ApiProvider>,
{
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)
})
})
})