Skip to content

Commit f91e342

Browse files
authored
feat: add experimental features, including handling unavailable institutions (#249)
* feat: add experimental institution messaging for the UNAVAILABLE status * test(institutionstatus): add tests
1 parent 5c43eae commit f91e342

File tree

14 files changed

+425
-15
lines changed

14 files changed

+425
-15
lines changed

src/Connect.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type { RootState } from 'reduxify/Store'
3737
import useLoadConnect from 'src/hooks/useLoadConnect'
3838
import { useNavigationPostMessage } from 'src/hooks/useNavigationPostMessage'
3939
import { PostMessageContext } from 'src/ConnectWidget'
40+
import { loadExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice'
4041

4142
type ConnectState = {
4243
memberToDelete: object | null
@@ -142,6 +143,7 @@ export const Connect: React.FC<ConnectProps> = ({
142143
loadConnect(props.clientConfig)
143144
dispatch(loadProfiles(props.profiles))
144145
dispatch(loadUserFeatures(props.userFeatures))
146+
dispatch(loadExperimentalFeatures(props?.experimentalFeatures || {}))
145147

146148
// Also important to note that this is a race condition between connect
147149
// mounting and the master data loading the client data. It just so happens

src/components/InstitutionTile.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import { ChevronRight } from '@kyper/icon/ChevronRight'
1010
import { InstitutionLogo } from '@mxenabled/mxui'
1111

1212
import { formatUrl } from 'src/utilities/FormatUrl'
13+
import { InstitutionStatus, useInstitutionStatus } from 'src/utilities/institutionStatus'
1314

1415
export const InstitutionTile = (props) => {
1516
const { institution, selectInstitution, size } = props
1617

18+
const status = useInstitutionStatus(institution)
1719
const tokens = useTokens()
1820
const styles = getStyles(tokens)
1921

@@ -74,6 +76,9 @@ export const InstitutionTile = (props) => {
7476
{institution.is_disabled_by_client && (
7577
<Chip color="secondary" label={__('DISABLED')} size="small" sx={styles.chip} />
7678
)}
79+
{!institution.is_disabled_by_client && status === InstitutionStatus.UNAVAILABLE && (
80+
<Chip color="secondary" label={__('UNAVAILABLE')} size="small" sx={styles.chip} />
81+
)}
7782
</Button>
7883
)
7984
}
@@ -136,6 +141,7 @@ const getStyles = (tokens) => {
136141
background: '#ECECEC',
137142
color: '#494949',
138143
height: tokens.Spacing.Medium,
144+
fontSize: '9px',
139145
},
140146
}
141147
}

src/hooks/useSelectInstitution.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { selectConnectConfig } from 'src/redux/reducers/configSlice'
99
import { isConsentEnabled } from 'src/redux/reducers/userFeaturesSlice'
1010
import { RootState } from 'src/redux/Store'
1111
import { institutionIsBlockedForCostReasons } from 'src/utilities/institutionBlocks'
12+
import { useInstitutionStatus } from 'src/utilities/institutionStatus'
1213

1314
const useSelectInstitution = () => {
1415
const { api } = useApi()
1516
const [institution, setInstitution] = useState<InstitutionResponseType | null>(null)
17+
const institutionStatus = useInstitutionStatus(institution)
1618
const dispatch = useDispatch()
1719
const consentIsEnabled = useSelector((state: RootState) => isConsentEnabled(state))
1820
const connectConfig = useSelector(selectConnectConfig)
@@ -37,6 +39,7 @@ const useSelectInstitution = () => {
3739
...insWithCreds,
3840
is_disabled_by_client: institutionIsBlockedForCostReasons(institution), // Temporary workaround till backend/core is fixed
3941
},
42+
institutionStatus,
4043
consentIsEnabled: consentIsEnabled || false,
4144
additionalProductOption: connectConfig?.additional_product_option || null,
4245
},

src/redux/Store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import userFeaturesSlice from 'src/redux/reducers/userFeaturesSlice'
66
import { app } from 'src/redux/reducers/App'
77
import browser from 'src/redux/reducers/Browser'
88
import analyticsSlice from 'src/redux/reducers/analyticsSlice'
9-
import localizedContentSlice from './reducers/localizedContentSlice'
9+
import localizedContentSlice from 'src/redux/reducers/localizedContentSlice'
10+
import experimentalFeaturesSlice from 'src/redux/reducers/experimentalFeaturesSlice'
1011

1112
const rootReducer = combineReducers({
1213
analytics: analyticsSlice,
1314
app,
1415
browser,
1516
config: configSlice,
1617
connect,
18+
experimentalFeatures: experimentalFeaturesSlice,
1719
localizedContent: localizedContentSlice,
1820
profiles: profilesSlice,
1921
userFeatures: userFeaturesSlice,

src/redux/__mocks__/Store.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ const getState = () => ({
5757
],
5858
},
5959
connectionsMembers: [],
60+
experimentalFeatures: {
61+
unavailableInstitutions: [{ guid: 'INST-unavailable', name: 'Unavailable Bank' }],
62+
},
6063
user: {
6164
details: {
6265
birthday: '793508760',

src/redux/__tests__/Store-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ describe('Store', () => {
1616
'browser',
1717
'config',
1818
'connect',
19+
'experimentalFeatures',
1920
'localizedContent',
2021
'profiles',
2122
'userFeatures',

src/redux/reducers/Connect.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
institutionIsBlockedForCostReasons,
2121
memberIsBlockedForCostReasons,
2222
} from 'src/utilities/institutionBlocks'
23+
import { InstitutionStatus } from 'src/utilities/institutionStatus'
2324

2425
export const defaultState = {
2526
error: null, // The most recent job request error, if any
@@ -278,7 +279,8 @@ const selectInstitutionSuccess = (state, action) => {
278279

279280
if (
280281
action.payload.institution &&
281-
institutionIsBlockedForCostReasons(action.payload.institution)
282+
(institutionIsBlockedForCostReasons(action.payload.institution) ||
283+
action.payload.institutionStatus === InstitutionStatus.UNAVAILABLE)
282284
) {
283285
nextStep = STEPS.INSTITUTION_STATUS_DETAILS
284286
} else if (canOfferVerification || canOfferAggregation) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createSlice } from '@reduxjs/toolkit'
2+
import { RootState } from 'src/redux/Store'
3+
4+
type ExperimentalFeaturesSlice = {
5+
unavailableInstitutions?: { guid: string; name: string }[]
6+
}
7+
8+
export const initialState: ExperimentalFeaturesSlice = {
9+
unavailableInstitutions: [],
10+
}
11+
12+
const experimentalFeaturesSlice = createSlice({
13+
name: 'experimentalFeatures',
14+
initialState,
15+
reducers: {
16+
loadExperimentalFeatures(state, action) {
17+
state.unavailableInstitutions = action.payload?.unavailableInstitutions || []
18+
},
19+
},
20+
})
21+
22+
// Selectors
23+
24+
export const getExperimentalFeatures = (state: RootState) => state.experimentalFeatures
25+
26+
export const { loadExperimentalFeatures } = experimentalFeaturesSlice.actions
27+
28+
export default experimentalFeaturesSlice.reducer
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import React from 'react'
3+
import { describe, it, expect, vi, beforeEach } from 'vitest'
4+
import { renderHook } from '@testing-library/react'
5+
import { configureStore } from '@reduxjs/toolkit'
6+
import {
7+
InstitutionStatus,
8+
useInstitutionStatusMessage,
9+
useInstitutionStatus,
10+
getInstitutionStatus,
11+
} from '../institutionStatus'
12+
import * as institutionBlocks from '../institutionBlocks'
13+
import { Provider } from 'react-redux'
14+
15+
// Mock dependencies
16+
vi.mock('../institutionBlocks', () => ({
17+
institutionIsBlockedForCostReasons: vi.fn(),
18+
}))
19+
20+
vi.mock('src/utilities/Intl', () => ({
21+
__: vi.fn((key: string, ...args: any[]) => {
22+
if (args.length > 0) {
23+
return key.replace(
24+
/%(\d+)/g,
25+
(match: string, num: string) => args[parseInt(num) - 1] || match,
26+
)
27+
}
28+
return key
29+
}),
30+
}))
31+
// Mock store setup
32+
// Mock store setup
33+
const createMockStore = (unavailableInstitutions: any = []) => {
34+
return configureStore({
35+
reducer: {
36+
experimentalFeatures: (state = { unavailableInstitutions }) => state,
37+
},
38+
})
39+
}
40+
41+
const wrapper = ({ children, store }: { children: React.ReactNode; store: any }) => (
42+
<Provider store={store}>{children}</Provider>
43+
)
44+
45+
describe('institutionStatus', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
})
49+
50+
describe('getInstitutionStatus', () => {
51+
it('returns OPERATIONAL when institution is null', () => {
52+
const result = getInstitutionStatus(null, [])
53+
expect(result).toBe(InstitutionStatus.OPERATIONAL)
54+
})
55+
56+
it('returns OPERATIONAL when unavailableInstitutions is not an array', () => {
57+
const institution = { guid: 'test-guid', name: 'Test Bank' }
58+
const result = getInstitutionStatus(institution, null as any)
59+
expect(result).toBe(InstitutionStatus.OPERATIONAL)
60+
})
61+
62+
it('returns CLIENT_BLOCKED_FOR_FEES when institution is blocked for cost reasons', () => {
63+
const institution = { guid: 'test-guid', name: 'Test Bank' }
64+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(true)
65+
66+
const result = getInstitutionStatus(institution, [])
67+
expect(result).toBe(InstitutionStatus.CLIENT_BLOCKED_FOR_FEES)
68+
})
69+
70+
it('returns UNAVAILABLE when institution is in unavailableInstitutions by guid', () => {
71+
const institution = { guid: 'test-guid', name: 'Test Bank' }
72+
const unavailableInstitutions = [{ guid: 'test-guid', name: 'Other Bank' }]
73+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(false)
74+
75+
const result = getInstitutionStatus(institution, unavailableInstitutions)
76+
expect(result).toBe(InstitutionStatus.UNAVAILABLE)
77+
})
78+
79+
it('returns UNAVAILABLE when institution is in unavailableInstitutions by name', () => {
80+
const institution = { guid: 'test-guid', name: 'Test Bank' }
81+
const unavailableInstitutions = [{ guid: 'other-guid', name: 'Test Bank' }]
82+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(false)
83+
84+
const result = getInstitutionStatus(institution, unavailableInstitutions)
85+
expect(result).toBe(InstitutionStatus.UNAVAILABLE)
86+
})
87+
88+
it('returns OPERATIONAL when institution is not blocked or unavailable', () => {
89+
const institution = { guid: 'test-guid', name: 'Test Bank' }
90+
const unavailableInstitutions = [{ guid: 'other-guid', name: 'Other Bank' }]
91+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(false)
92+
93+
const result = getInstitutionStatus(institution, unavailableInstitutions)
94+
expect(result).toBe(InstitutionStatus.OPERATIONAL)
95+
})
96+
})
97+
98+
describe('useInstitutionStatus', () => {
99+
it('returns institution status using redux state', () => {
100+
const institution = { guid: 'test-guid', name: 'Test Bank' }
101+
const unavailableInstitutions = [{ guid: 'test-guid', name: 'Test Bank' }]
102+
const store = createMockStore(unavailableInstitutions)
103+
104+
const { result } = renderHook(() => useInstitutionStatus(institution), {
105+
wrapper: ({ children }) => wrapper({ children, store }),
106+
})
107+
108+
expect(result.current).toBe(InstitutionStatus.UNAVAILABLE)
109+
})
110+
111+
it('handles null institution', () => {
112+
const store = createMockStore([])
113+
114+
const { result } = renderHook(() => useInstitutionStatus(null), {
115+
wrapper: ({ children }) => wrapper({ children, store }),
116+
})
117+
118+
expect(result.current).toBe(InstitutionStatus.OPERATIONAL)
119+
})
120+
})
121+
122+
describe('useInstitutionStatusMessage', () => {
123+
it('throws error when institution is missing required fields', () => {
124+
const store = createMockStore([])
125+
const institution = { guid: '', name: '' }
126+
127+
expect(() => {
128+
renderHook(() => useInstitutionStatusMessage(institution), {
129+
wrapper: ({ children }) => wrapper({ children, store }),
130+
})
131+
}).toThrow('Selected institution is not defined or missing name and guid')
132+
})
133+
134+
it('throws error when unavailableInstitutions is not an array', () => {
135+
const store = createMockStore(null)
136+
const institution = { guid: 'test-guid', name: 'Test Bank' }
137+
138+
expect(() => {
139+
renderHook(() => useInstitutionStatusMessage(institution), {
140+
wrapper: ({ children }) => wrapper({ children, store }),
141+
})
142+
}).toThrow('Experimental feature unavailableInstitutions is not defined or not an array')
143+
})
144+
145+
it('returns fee-related message for CLIENT_BLOCKED_FOR_FEES status', () => {
146+
const institution = { guid: 'test-guid', name: 'Test Bank' }
147+
const store = createMockStore([])
148+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(true)
149+
150+
const { result } = renderHook(() => useInstitutionStatusMessage(institution), {
151+
wrapper: ({ children }) => wrapper({ children, store }),
152+
})
153+
154+
expect(result.current).toEqual({
155+
title: 'Free Test Bank Connections Are No Longer Available',
156+
body: 'Test Bank now charges a fee for us to access your account data. To avoid passing that cost on to you, we no longer support Test Bank connections.',
157+
})
158+
})
159+
160+
it('returns unavailable message for UNAVAILABLE status', () => {
161+
const institution = { guid: 'test-guid', name: 'Test Bank' }
162+
const unavailableInstitutions = [{ guid: 'test-guid', name: 'Test Bank' }]
163+
const store = createMockStore(unavailableInstitutions)
164+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(false)
165+
166+
const { result } = renderHook(() => useInstitutionStatusMessage(institution), {
167+
wrapper: ({ children }) => wrapper({ children, store }),
168+
})
169+
170+
expect(result.current).toEqual({
171+
title: 'Connection not supported by Test Bank',
172+
body: "Test Bank currently limits how your data can be shared. We'll enable this connection once Test Bank opens access.",
173+
})
174+
})
175+
176+
it('returns empty message for OPERATIONAL status', () => {
177+
const institution = { guid: 'test-guid', name: 'Test Bank' }
178+
const store = createMockStore([])
179+
vi.mocked(institutionBlocks.institutionIsBlockedForCostReasons).mockReturnValue(false)
180+
181+
const { result } = renderHook(() => useInstitutionStatusMessage(institution), {
182+
wrapper: ({ children }) => wrapper({ children, store }),
183+
})
184+
185+
expect(result.current).toEqual({
186+
title: '',
187+
body: '',
188+
})
189+
})
190+
})
191+
})

0 commit comments

Comments
 (0)