Skip to content

Commit 8a2bc0e

Browse files
committed
feat(version): add an optional version number string that can be revealed to users
1 parent 4c786c0 commit 8a2bc0e

10 files changed

Lines changed: 126 additions & 3 deletions

File tree

src/Connect.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TokenContext } from '@kyper/tokenprovider'
1010
import { usePrevious } from '@kyper/hooks'
1111

1212
import * as connectActions from 'src/redux/actions/Connect'
13+
import { setWidgetVersion } from 'src/redux/actions/App'
1314
import { addAnalyticPath, removeAnalyticPath } from 'src/redux/reducers/analyticsSlice'
1415

1516
import { isConsentEnabled, loadUserFeatures } from 'src/redux/reducers/userFeaturesSlice'
@@ -144,6 +145,7 @@ export const Connect: React.FC<ConnectProps> = ({
144145
dispatch(loadProfiles(props.profiles))
145146
dispatch(loadUserFeatures(props.userFeatures))
146147
dispatch(loadExperimentalFeatures(props?.experimentalFeatures || {}))
148+
dispatch(setWidgetVersion(props?.version || null))
147149

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

src/__tests__/ConnectWidget-test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,15 @@ describe('ConnectWidget', () => {
168168
{ timeout: 15000 },
169169
)
170170
}, 35000)
171+
172+
it('stores version metadata in app state from the top-level version prop', async () => {
173+
render(<ConnectWidgetWithoutReduxProvider {...defaultProps} version="abcdef1234567" />, {
174+
apiValue: apiValueMock,
175+
store: activeStore,
176+
})
177+
178+
await waitFor(() => {
179+
expect(activeStore.getState().app.version).toBe('abcdef1234567')
180+
})
181+
})
171182
})

src/redux/actions/App.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
export const ActionTypes = {
22
SESSION_IS_TIMED_OUT: 'app/session_is_timed_out',
33
HUMAN_EVENT_HAPPENED: 'app/human_event_happened',
4+
SET_WIDGET_VERSION: 'app/set_widget_version',
45
}
56

7+
export const setWidgetVersion = (version) => ({
8+
type: ActionTypes.SET_WIDGET_VERSION,
9+
payload: version,
10+
})
11+
612
export const dispatcher = (dispatch) => ({
713
markSessionTimedOut: () => dispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }),
814
handleHumanEvent: () => dispatch({ type: ActionTypes.HUMAN_EVENT_HAPPENED }),
15+
setWidgetVersion: (version) => dispatch(setWidgetVersion(version)),
916
})

src/redux/actions/__tests__/app-test.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { dispatcher as appDispatcher, ActionTypes } from 'src/redux/actions/App'
1+
import { dispatcher as appDispatcher, ActionTypes, setWidgetVersion } from 'src/redux/actions/App'
22
import { createReduxActionUtils } from 'src/utilities/Test'
33

44
const { actions, expectDispatch, resetDispatch } = createReduxActionUtils(appDispatcher)
@@ -12,4 +12,16 @@ describe('app Dispatcher', () => {
1212
actions.markSessionTimedOut()
1313
expectDispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT })
1414
})
15+
16+
it('should dispatch SET_WIDGET_VERSION', () => {
17+
actions.setWidgetVersion('abc1234')
18+
expectDispatch({ type: ActionTypes.SET_WIDGET_VERSION, payload: 'abc1234' })
19+
})
20+
21+
it('should create SET_WIDGET_VERSION action', () => {
22+
expect(setWidgetVersion('abc1234')).toEqual({
23+
type: ActionTypes.SET_WIDGET_VERSION,
24+
payload: 'abc1234',
25+
})
26+
})
1527
})

src/redux/reducers/App.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,23 @@ export const defaultState = {
66
// credential stuffing. See https://gitlab.mx.com/mx/connect/issues/279
77
// wether or not we consider a human has used the app.
88
humanEvent: false,
9+
version: null,
910
}
1011

1112
const markSessionTimedOut = (state) => ({ ...state, sessionIsTimedOut: true })
1213

1314
const handleHumanEvent = (state) => ({ ...state, humanEvent: true })
1415

16+
const setWidgetVersion = (state, action) => ({ ...state, version: action.payload || null })
17+
1518
export const app = (state = defaultState, action) => {
1619
switch (action.type) {
1720
case ActionTypes.SESSION_IS_TIMED_OUT:
1821
return markSessionTimedOut(state)
1922
case ActionTypes.HUMAN_EVENT_HAPPENED:
2023
return handleHumanEvent(state)
24+
case ActionTypes.SET_WIDGET_VERSION:
25+
return setWidgetVersion(state, action)
2126
default:
2227
return state
2328
}

src/redux/reducers/__tests__/app-test.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ActionTypes } from 'src/redux/actions/App'
22
import { app as reducer, defaultState } from 'src/redux/reducers/App'
33

4-
const { SESSION_IS_TIMED_OUT } = ActionTypes
4+
const { SESSION_IS_TIMED_OUT, SET_WIDGET_VERSION } = ActionTypes
55

66
describe('app reducers', () => {
77
it('should return the initial state', () => {
@@ -15,4 +15,12 @@ describe('app reducers', () => {
1515
expect(reducer(undefined, action).sessionIsTimedOut).toBe(true)
1616
})
1717
})
18+
19+
describe('SET_WIDGET_VERSION', () => {
20+
it('should store the widget version', () => {
21+
const action = { type: SET_WIDGET_VERSION, payload: 'abc1234' }
22+
23+
expect(reducer(undefined, action).version).toBe('abc1234')
24+
})
25+
})
1826
})

src/redux/selectors/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export const getSessionIsTimedOut = (state) => state.app.sessionIsTimedOut
2+
3+
export const getWidgetVersion = (state) => state.app.version

src/views/search/Search.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { IconButton } from '@mui/material'
2323
import { __ } from 'src/utilities/Intl'
2424
import * as connectActions from 'src/redux/actions/Connect'
2525
import { selectConnectConfig } from 'src/redux/reducers/configSlice'
26+
import { getWidgetVersion } from 'src/redux/selectors/app'
2627
import { getMembers } from 'src/redux/selectors/Connect'
2728

2829
import { AnalyticEvents, PageviewInfo } from 'src/const/Analytics'
@@ -61,6 +62,20 @@ export const initialState = {
6162

6263
const MAX_SUGGESTED_LIST_SIZE = 25
6364

65+
const getVersionLabel = (version) => {
66+
// Check for SHA pattern
67+
if (typeof version === 'string' && /^[0-9a-f]{7,40}$/i.test(version)) {
68+
return `v.${version.slice(0, 7)}`
69+
}
70+
71+
// Trim a string that isn't a SHA pattern
72+
if (typeof version === 'string' && version.trim() !== '') {
73+
return `v.${version.trim()}`
74+
}
75+
76+
return ''
77+
}
78+
6479
const reducer = (state, action) => {
6580
switch (action.type) {
6681
case SEARCH_ACTIONS.LOAD_SUCCESS:
@@ -131,6 +146,7 @@ export const Search = React.forwardRef((_, navigationRef) => {
131146
useAnalyticsPath(...PageviewInfo.CONNECT_SEARCH, {}, false)
132147
const [state, dispatch] = useReducer(reducer, initialState)
133148
const [ariaLiveRegionMessage, setAriaLiveRegionMessage] = useState('')
149+
const [headerClicks, setHeaderClicks] = useState(0)
134150
const searchInput = useRef('')
135151
const sendAnalyticsEvent = useAnalyticsEvent()
136152
const postMessageFunctions = useContext(PostMessageContext)
@@ -140,6 +156,7 @@ export const Search = React.forwardRef((_, navigationRef) => {
140156
// Redux
141157
const reduxDispatch = useDispatch()
142158
const connectConfig = useSelector(selectConnectConfig)
159+
const widgetVersion = useSelector(getWidgetVersion)
143160
const connectedMembers = useSelector(getMembers)
144161
const usePopularOnly = useSelector((state) => {
145162
const clientProfile = state.profiles.clientProfile || {}
@@ -155,6 +172,7 @@ export const Search = React.forwardRef((_, navigationRef) => {
155172

156173
const MINIMUM_SEARCH_LENGTH = 2
157174
const isFirstTimeUser = connectedMembers.length === 0
175+
const versionLabel = getVersionLabel(widgetVersion)
158176

159177
useImperativeHandle(navigationRef, () => {
160178
return {
@@ -331,13 +349,20 @@ export const Search = React.forwardRef((_, navigationRef) => {
331349
component={'h2'}
332350
data-test="search-header"
333351
id="connect-search-header"
352+
onClick={() => setHeaderClicks((prev) => prev + 1)}
334353
style={inlineStyles.headerText}
335354
tabIndex={-1}
336355
truncate={false}
337356
variant="H2"
338357
>
339358
{__('Select your institution')}
340359
</Text>
360+
{/* This version is a hidden feature unless a user is told how to find it */}
361+
{headerClicks >= 5 && versionLabel && (
362+
<Text data-test="search-version-label" style={inlineStyles.version}>
363+
{versionLabel}
364+
</Text>
365+
)}
341366
<TextField
342367
InputProps={{
343368
startAdornment: (
@@ -450,6 +475,12 @@ const getStyles = (tokens) => {
450475
spinner: {
451476
marginTop: '24px',
452477
},
478+
version: {
479+
fontWeight: tokens.FontWeight.Semibold,
480+
fontSize: tokens.FontSize.Small,
481+
marginTop: '-16px',
482+
marginBottom: '16px',
483+
},
453484
}
454485
}
455486

src/views/search/__tests__/Search-test.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import React from 'react'
2-
import { render, screen, waitFor, fireEvent } from 'src/utilities/testingLibrary'
2+
import {
3+
render,
4+
screen,
5+
waitFor,
6+
fireEvent,
7+
createTestReduxStore,
8+
} from 'src/utilities/testingLibrary'
39
import { FAVORITE_INSTITUTIONS, SEARCHED_INSTITUTIONS } from 'src/services/mockedData'
410
import { Search, buildSearchQuery, getSuggestedInstitutions } from 'src/views/search/Search'
511
import { VERIFY_MODE, TAX_MODE, AGG_MODE } from 'src/const/Connect'
@@ -8,6 +14,7 @@ import { __ } from 'src/utilities/Intl'
814
import { ApiProvider } from 'src/context/ApiContext'
915
import { apiValue } from 'src/const/apiProviderMock'
1016
import { InstitutionStatusField } from 'src/utilities/institutionStatus'
17+
import { setWidgetVersion } from 'src/redux/actions/App'
1118

1219
describe('Search View', () => {
1320
describe('Search component', () => {
@@ -77,6 +84,43 @@ describe('Search View', () => {
7784
expect(screen.getByText(__('No results found for ”%1”', searchTerm))).toBeInTheDocument()
7885
})
7986
})
87+
88+
it('shows version after clicking the header five times', async () => {
89+
const ref = React.createRef()
90+
const store = createTestReduxStore()
91+
store.dispatch(setWidgetVersion('abcdef1234567'))
92+
93+
render(<Search {...defaultProps} ref={ref} />, { store })
94+
95+
const header = await screen.findByText('Select your institution')
96+
expect(screen.queryByText('v.abcdef1')).not.toBeInTheDocument()
97+
98+
for (let i = 0; i < 5; i++) {
99+
fireEvent.click(header)
100+
}
101+
102+
expect(await screen.findByText('v.abcdef1')).toBeInTheDocument()
103+
})
104+
105+
it('does not show version after five clicks when version is not provided', async () => {
106+
const ref = React.createRef()
107+
const store = createTestReduxStore()
108+
109+
const { container } = render(<Search {...defaultProps} ref={ref} />, { store })
110+
111+
const header = await screen.findByText('Select your institution')
112+
expect(container.querySelector('[data-test="search-version-label"]')).not.toBeInTheDocument()
113+
114+
for (let i = 0; i < 5; i++) {
115+
fireEvent.click(header)
116+
}
117+
118+
await waitFor(() => {
119+
expect(
120+
container.querySelector('[data-test="search-version-label"]'),
121+
).not.toBeInTheDocument()
122+
})
123+
})
80124
})
81125

82126
describe('buildSearchQuery function', () => {

typings/connectProps.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ interface ConnectProps {
4444
memberPollingMilliseconds?: number
4545
useWebSockets?: boolean
4646
}
47+
version?: string
4748
}
4849
interface ClientConfigType {
4950
_initialValues: string

0 commit comments

Comments
 (0)