From 8a2bc0e75121f80a0126c1047a94b0c6ad2f1c43 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 13 May 2026 18:12:04 -0600 Subject: [PATCH 1/5] feat(version): add an optional version number string that can be revealed to users --- src/Connect.tsx | 2 + src/__tests__/ConnectWidget-test.tsx | 11 ++++++ src/redux/actions/App.js | 7 ++++ src/redux/actions/__tests__/app-test.js | 14 ++++++- src/redux/reducers/App.js | 5 +++ src/redux/reducers/__tests__/app-test.js | 10 ++++- src/redux/selectors/app.js | 2 + src/views/search/Search.js | 31 +++++++++++++++ src/views/search/__tests__/Search-test.js | 46 ++++++++++++++++++++++- typings/connectProps.d.ts | 1 + 10 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/Connect.tsx b/src/Connect.tsx index 47a648698d..f44c31faad 100644 --- a/src/Connect.tsx +++ b/src/Connect.tsx @@ -10,6 +10,7 @@ import { TokenContext } from '@kyper/tokenprovider' import { usePrevious } from '@kyper/hooks' import * as connectActions from 'src/redux/actions/Connect' +import { setWidgetVersion } from 'src/redux/actions/App' import { addAnalyticPath, removeAnalyticPath } from 'src/redux/reducers/analyticsSlice' import { isConsentEnabled, loadUserFeatures } from 'src/redux/reducers/userFeaturesSlice' @@ -144,6 +145,7 @@ export const Connect: React.FC = ({ dispatch(loadProfiles(props.profiles)) dispatch(loadUserFeatures(props.userFeatures)) dispatch(loadExperimentalFeatures(props?.experimentalFeatures || {})) + dispatch(setWidgetVersion(props?.version || null)) // Also important to note that this is a race condition between connect // mounting and the master data loading the client data. It just so happens diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx index 9c36b31e2f..619bf81fa0 100644 --- a/src/__tests__/ConnectWidget-test.tsx +++ b/src/__tests__/ConnectWidget-test.tsx @@ -168,4 +168,15 @@ describe('ConnectWidget', () => { { timeout: 15000 }, ) }, 35000) + + it('stores version metadata in app state from the top-level version prop', async () => { + render(, { + apiValue: apiValueMock, + store: activeStore, + }) + + await waitFor(() => { + expect(activeStore.getState().app.version).toBe('abcdef1234567') + }) + }) }) diff --git a/src/redux/actions/App.js b/src/redux/actions/App.js index 0079aa496f..55a95433e7 100644 --- a/src/redux/actions/App.js +++ b/src/redux/actions/App.js @@ -1,9 +1,16 @@ export const ActionTypes = { SESSION_IS_TIMED_OUT: 'app/session_is_timed_out', HUMAN_EVENT_HAPPENED: 'app/human_event_happened', + SET_WIDGET_VERSION: 'app/set_widget_version', } +export const setWidgetVersion = (version) => ({ + type: ActionTypes.SET_WIDGET_VERSION, + payload: version, +}) + export const dispatcher = (dispatch) => ({ markSessionTimedOut: () => dispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }), handleHumanEvent: () => dispatch({ type: ActionTypes.HUMAN_EVENT_HAPPENED }), + setWidgetVersion: (version) => dispatch(setWidgetVersion(version)), }) diff --git a/src/redux/actions/__tests__/app-test.js b/src/redux/actions/__tests__/app-test.js index 44896bb4bd..471b413403 100644 --- a/src/redux/actions/__tests__/app-test.js +++ b/src/redux/actions/__tests__/app-test.js @@ -1,4 +1,4 @@ -import { dispatcher as appDispatcher, ActionTypes } from 'src/redux/actions/App' +import { dispatcher as appDispatcher, ActionTypes, setWidgetVersion } from 'src/redux/actions/App' import { createReduxActionUtils } from 'src/utilities/Test' const { actions, expectDispatch, resetDispatch } = createReduxActionUtils(appDispatcher) @@ -12,4 +12,16 @@ describe('app Dispatcher', () => { actions.markSessionTimedOut() expectDispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }) }) + + it('should dispatch SET_WIDGET_VERSION', () => { + actions.setWidgetVersion('abc1234') + expectDispatch({ type: ActionTypes.SET_WIDGET_VERSION, payload: 'abc1234' }) + }) + + it('should create SET_WIDGET_VERSION action', () => { + expect(setWidgetVersion('abc1234')).toEqual({ + type: ActionTypes.SET_WIDGET_VERSION, + payload: 'abc1234', + }) + }) }) diff --git a/src/redux/reducers/App.js b/src/redux/reducers/App.js index fa8fe807f2..1191d2a7ad 100644 --- a/src/redux/reducers/App.js +++ b/src/redux/reducers/App.js @@ -6,18 +6,23 @@ export const defaultState = { // credential stuffing. See https://gitlab.mx.com/mx/connect/issues/279 // wether or not we consider a human has used the app. humanEvent: false, + version: null, } const markSessionTimedOut = (state) => ({ ...state, sessionIsTimedOut: true }) const handleHumanEvent = (state) => ({ ...state, humanEvent: true }) +const setWidgetVersion = (state, action) => ({ ...state, version: action.payload || null }) + export const app = (state = defaultState, action) => { switch (action.type) { case ActionTypes.SESSION_IS_TIMED_OUT: return markSessionTimedOut(state) case ActionTypes.HUMAN_EVENT_HAPPENED: return handleHumanEvent(state) + case ActionTypes.SET_WIDGET_VERSION: + return setWidgetVersion(state, action) default: return state } diff --git a/src/redux/reducers/__tests__/app-test.js b/src/redux/reducers/__tests__/app-test.js index 03a1b737e8..a6fd21f910 100644 --- a/src/redux/reducers/__tests__/app-test.js +++ b/src/redux/reducers/__tests__/app-test.js @@ -1,7 +1,7 @@ import { ActionTypes } from 'src/redux/actions/App' import { app as reducer, defaultState } from 'src/redux/reducers/App' -const { SESSION_IS_TIMED_OUT } = ActionTypes +const { SESSION_IS_TIMED_OUT, SET_WIDGET_VERSION } = ActionTypes describe('app reducers', () => { it('should return the initial state', () => { @@ -15,4 +15,12 @@ describe('app reducers', () => { expect(reducer(undefined, action).sessionIsTimedOut).toBe(true) }) }) + + describe('SET_WIDGET_VERSION', () => { + it('should store the widget version', () => { + const action = { type: SET_WIDGET_VERSION, payload: 'abc1234' } + + expect(reducer(undefined, action).version).toBe('abc1234') + }) + }) }) diff --git a/src/redux/selectors/app.js b/src/redux/selectors/app.js index 1e579385c2..2a67343ba5 100644 --- a/src/redux/selectors/app.js +++ b/src/redux/selectors/app.js @@ -1 +1,3 @@ export const getSessionIsTimedOut = (state) => state.app.sessionIsTimedOut + +export const getWidgetVersion = (state) => state.app.version diff --git a/src/views/search/Search.js b/src/views/search/Search.js index eead808678..2805b6ab6b 100644 --- a/src/views/search/Search.js +++ b/src/views/search/Search.js @@ -23,6 +23,7 @@ import { IconButton } from '@mui/material' import { __ } from 'src/utilities/Intl' import * as connectActions from 'src/redux/actions/Connect' import { selectConnectConfig } from 'src/redux/reducers/configSlice' +import { getWidgetVersion } from 'src/redux/selectors/app' import { getMembers } from 'src/redux/selectors/Connect' import { AnalyticEvents, PageviewInfo } from 'src/const/Analytics' @@ -61,6 +62,20 @@ export const initialState = { const MAX_SUGGESTED_LIST_SIZE = 25 +const getVersionLabel = (version) => { + // Check for SHA pattern + if (typeof version === 'string' && /^[0-9a-f]{7,40}$/i.test(version)) { + return `v.${version.slice(0, 7)}` + } + + // Trim a string that isn't a SHA pattern + if (typeof version === 'string' && version.trim() !== '') { + return `v.${version.trim()}` + } + + return '' +} + const reducer = (state, action) => { switch (action.type) { case SEARCH_ACTIONS.LOAD_SUCCESS: @@ -131,6 +146,7 @@ export const Search = React.forwardRef((_, navigationRef) => { useAnalyticsPath(...PageviewInfo.CONNECT_SEARCH, {}, false) const [state, dispatch] = useReducer(reducer, initialState) const [ariaLiveRegionMessage, setAriaLiveRegionMessage] = useState('') + const [headerClicks, setHeaderClicks] = useState(0) const searchInput = useRef('') const sendAnalyticsEvent = useAnalyticsEvent() const postMessageFunctions = useContext(PostMessageContext) @@ -140,6 +156,7 @@ export const Search = React.forwardRef((_, navigationRef) => { // Redux const reduxDispatch = useDispatch() const connectConfig = useSelector(selectConnectConfig) + const widgetVersion = useSelector(getWidgetVersion) const connectedMembers = useSelector(getMembers) const usePopularOnly = useSelector((state) => { const clientProfile = state.profiles.clientProfile || {} @@ -155,6 +172,7 @@ export const Search = React.forwardRef((_, navigationRef) => { const MINIMUM_SEARCH_LENGTH = 2 const isFirstTimeUser = connectedMembers.length === 0 + const versionLabel = getVersionLabel(widgetVersion) useImperativeHandle(navigationRef, () => { return { @@ -331,6 +349,7 @@ export const Search = React.forwardRef((_, navigationRef) => { component={'h2'} data-test="search-header" id="connect-search-header" + onClick={() => setHeaderClicks((prev) => prev + 1)} style={inlineStyles.headerText} tabIndex={-1} truncate={false} @@ -338,6 +357,12 @@ export const Search = React.forwardRef((_, navigationRef) => { > {__('Select your institution')} + {/* This version is a hidden feature unless a user is told how to find it */} + {headerClicks >= 5 && versionLabel && ( + + {versionLabel} + + )} { spinner: { marginTop: '24px', }, + version: { + fontWeight: tokens.FontWeight.Semibold, + fontSize: tokens.FontSize.Small, + marginTop: '-16px', + marginBottom: '16px', + }, } } diff --git a/src/views/search/__tests__/Search-test.js b/src/views/search/__tests__/Search-test.js index b3f0edce6c..7611bb822b 100644 --- a/src/views/search/__tests__/Search-test.js +++ b/src/views/search/__tests__/Search-test.js @@ -1,5 +1,11 @@ import React from 'react' -import { render, screen, waitFor, fireEvent } from 'src/utilities/testingLibrary' +import { + render, + screen, + waitFor, + fireEvent, + createTestReduxStore, +} from 'src/utilities/testingLibrary' import { FAVORITE_INSTITUTIONS, SEARCHED_INSTITUTIONS } from 'src/services/mockedData' import { Search, buildSearchQuery, getSuggestedInstitutions } from 'src/views/search/Search' import { VERIFY_MODE, TAX_MODE, AGG_MODE } from 'src/const/Connect' @@ -8,6 +14,7 @@ import { __ } from 'src/utilities/Intl' import { ApiProvider } from 'src/context/ApiContext' import { apiValue } from 'src/const/apiProviderMock' import { InstitutionStatusField } from 'src/utilities/institutionStatus' +import { setWidgetVersion } from 'src/redux/actions/App' describe('Search View', () => { describe('Search component', () => { @@ -77,6 +84,43 @@ describe('Search View', () => { expect(screen.getByText(__('No results found for ”%1”', searchTerm))).toBeInTheDocument() }) }) + + it('shows version after clicking the header five times', async () => { + const ref = React.createRef() + const store = createTestReduxStore() + store.dispatch(setWidgetVersion('abcdef1234567')) + + render(, { store }) + + const header = await screen.findByText('Select your institution') + expect(screen.queryByText('v.abcdef1')).not.toBeInTheDocument() + + for (let i = 0; i < 5; i++) { + fireEvent.click(header) + } + + expect(await screen.findByText('v.abcdef1')).toBeInTheDocument() + }) + + it('does not show version after five clicks when version is not provided', async () => { + const ref = React.createRef() + const store = createTestReduxStore() + + const { container } = render(, { store }) + + const header = await screen.findByText('Select your institution') + expect(container.querySelector('[data-test="search-version-label"]')).not.toBeInTheDocument() + + for (let i = 0; i < 5; i++) { + fireEvent.click(header) + } + + await waitFor(() => { + expect( + container.querySelector('[data-test="search-version-label"]'), + ).not.toBeInTheDocument() + }) + }) }) describe('buildSearchQuery function', () => { diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts index 86356e91f9..7d1f1f7387 100644 --- a/typings/connectProps.d.ts +++ b/typings/connectProps.d.ts @@ -44,6 +44,7 @@ interface ConnectProps { memberPollingMilliseconds?: number useWebSockets?: boolean } + version?: string } interface ClientConfigType { _initialValues: string From e9bfa8c945ac2a00dd9b3b089adcf95ca142ca47 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Thu, 14 May 2026 08:07:56 -0600 Subject: [PATCH 2/5] refactor(version): show version in a snackbar --- src/views/search/Search.js | 28 ++++++----------------- src/views/search/__tests__/Search-test.js | 14 +++++------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/src/views/search/Search.js b/src/views/search/Search.js index 2805b6ab6b..d1476ddba1 100644 --- a/src/views/search/Search.js +++ b/src/views/search/Search.js @@ -18,7 +18,7 @@ import { CloseOutline } from '@kyper/icon/CloseOutline' import { Search as SearchIcon } from '@kyper/icon/Search' import InputAdornment from '@mui/material/InputAdornment' import { TextField } from 'src/privacy/input' -import { IconButton } from '@mui/material' +import { IconButton, Snackbar } from '@mui/material' import { __ } from 'src/utilities/Intl' import * as connectActions from 'src/redux/actions/Connect' @@ -62,20 +62,6 @@ export const initialState = { const MAX_SUGGESTED_LIST_SIZE = 25 -const getVersionLabel = (version) => { - // Check for SHA pattern - if (typeof version === 'string' && /^[0-9a-f]{7,40}$/i.test(version)) { - return `v.${version.slice(0, 7)}` - } - - // Trim a string that isn't a SHA pattern - if (typeof version === 'string' && version.trim() !== '') { - return `v.${version.trim()}` - } - - return '' -} - const reducer = (state, action) => { switch (action.type) { case SEARCH_ACTIONS.LOAD_SUCCESS: @@ -172,7 +158,6 @@ export const Search = React.forwardRef((_, navigationRef) => { const MINIMUM_SEARCH_LENGTH = 2 const isFirstTimeUser = connectedMembers.length === 0 - const versionLabel = getVersionLabel(widgetVersion) useImperativeHandle(navigationRef, () => { return { @@ -358,11 +343,12 @@ export const Search = React.forwardRef((_, navigationRef) => { {__('Select your institution')} {/* This version is a hidden feature unless a user is told how to find it */} - {headerClicks >= 5 && versionLabel && ( - - {versionLabel} - - )} + setHeaderClicks(0)} + open={headerClicks >= 5 && Boolean(widgetVersion)} + /> { render(, { store }) const header = await screen.findByText('Select your institution') - expect(screen.queryByText('v.abcdef1')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() for (let i = 0; i < 5; i++) { fireEvent.click(header) } - expect(await screen.findByText('v.abcdef1')).toBeInTheDocument() + expect(await screen.findByRole('alert')).toHaveTextContent('abcdef1234567') }) - it('does not show version after five clicks when version is not provided', async () => { + it('does not show version snackbar after five clicks when version is not provided', async () => { const ref = React.createRef() const store = createTestReduxStore() - const { container } = render(, { store }) + render(, { store }) const header = await screen.findByText('Select your institution') - expect(container.querySelector('[data-test="search-version-label"]')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() for (let i = 0; i < 5; i++) { fireEvent.click(header) } await waitFor(() => { - expect( - container.querySelector('[data-test="search-version-label"]'), - ).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) }) }) From 54d34382f69ca22c31a2d11def264fec109a0ea6 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Thu, 14 May 2026 08:13:29 -0600 Subject: [PATCH 3/5] remove unused styles from previous implementation --- src/views/search/Search.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/views/search/Search.js b/src/views/search/Search.js index d1476ddba1..5ba3571360 100644 --- a/src/views/search/Search.js +++ b/src/views/search/Search.js @@ -461,12 +461,6 @@ const getStyles = (tokens) => { spinner: { marginTop: '24px', }, - version: { - fontWeight: tokens.FontWeight.Semibold, - fontSize: tokens.FontSize.Small, - marginTop: '-16px', - marginBottom: '16px', - }, } } From 03109a97fa6c3dd428147c353a0da15773fe0058 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Thu, 14 May 2026 08:49:18 -0600 Subject: [PATCH 4/5] test(confidence): modify tests to give more confidence --- src/views/search/__tests__/Search-test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/views/search/__tests__/Search-test.js b/src/views/search/__tests__/Search-test.js index 6cff16dc15..a09375d14e 100644 --- a/src/views/search/__tests__/Search-test.js +++ b/src/views/search/__tests__/Search-test.js @@ -99,7 +99,7 @@ describe('Search View', () => { fireEvent.click(header) } - expect(await screen.findByRole('alert')).toHaveTextContent('abcdef1234567') + expect(screen.getByRole('alert')).toHaveTextContent('abcdef1234567') }) it('does not show version snackbar after five clicks when version is not provided', async () => { @@ -115,9 +115,7 @@ describe('Search View', () => { fireEvent.click(header) } - await waitFor(() => { - expect(screen.queryByRole('alert')).not.toBeInTheDocument() - }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) }) From 0a28e4f58b4246a3ca94a8bc9d50f9d24d02ddee Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Thu, 14 May 2026 09:05:39 -0600 Subject: [PATCH 5/5] fix(review): implement review suggestions --- src/redux/actions/App.js | 1 - src/redux/actions/__tests__/app-test.js | 5 ----- src/redux/reducers/App.js | 2 +- src/redux/reducers/__tests__/app-test.js | 8 +++----- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/redux/actions/App.js b/src/redux/actions/App.js index 55a95433e7..39cdfee98b 100644 --- a/src/redux/actions/App.js +++ b/src/redux/actions/App.js @@ -12,5 +12,4 @@ export const setWidgetVersion = (version) => ({ export const dispatcher = (dispatch) => ({ markSessionTimedOut: () => dispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }), handleHumanEvent: () => dispatch({ type: ActionTypes.HUMAN_EVENT_HAPPENED }), - setWidgetVersion: (version) => dispatch(setWidgetVersion(version)), }) diff --git a/src/redux/actions/__tests__/app-test.js b/src/redux/actions/__tests__/app-test.js index 471b413403..f8d09972f9 100644 --- a/src/redux/actions/__tests__/app-test.js +++ b/src/redux/actions/__tests__/app-test.js @@ -13,11 +13,6 @@ describe('app Dispatcher', () => { expectDispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }) }) - it('should dispatch SET_WIDGET_VERSION', () => { - actions.setWidgetVersion('abc1234') - expectDispatch({ type: ActionTypes.SET_WIDGET_VERSION, payload: 'abc1234' }) - }) - it('should create SET_WIDGET_VERSION action', () => { expect(setWidgetVersion('abc1234')).toEqual({ type: ActionTypes.SET_WIDGET_VERSION, diff --git a/src/redux/reducers/App.js b/src/redux/reducers/App.js index 1191d2a7ad..0d0cde7e18 100644 --- a/src/redux/reducers/App.js +++ b/src/redux/reducers/App.js @@ -13,7 +13,7 @@ const markSessionTimedOut = (state) => ({ ...state, sessionIsTimedOut: true }) const handleHumanEvent = (state) => ({ ...state, humanEvent: true }) -const setWidgetVersion = (state, action) => ({ ...state, version: action.payload || null }) +const setWidgetVersion = (state, action) => ({ ...state, version: action.payload }) export const app = (state = defaultState, action) => { switch (action.type) { diff --git a/src/redux/reducers/__tests__/app-test.js b/src/redux/reducers/__tests__/app-test.js index a6fd21f910..26ec1a69d1 100644 --- a/src/redux/reducers/__tests__/app-test.js +++ b/src/redux/reducers/__tests__/app-test.js @@ -1,7 +1,7 @@ -import { ActionTypes } from 'src/redux/actions/App' +import { ActionTypes, setWidgetVersion } from 'src/redux/actions/App' import { app as reducer, defaultState } from 'src/redux/reducers/App' -const { SESSION_IS_TIMED_OUT, SET_WIDGET_VERSION } = ActionTypes +const { SESSION_IS_TIMED_OUT } = ActionTypes describe('app reducers', () => { it('should return the initial state', () => { @@ -18,9 +18,7 @@ describe('app reducers', () => { describe('SET_WIDGET_VERSION', () => { it('should store the widget version', () => { - const action = { type: SET_WIDGET_VERSION, payload: 'abc1234' } - - expect(reducer(undefined, action).version).toBe('abc1234') + expect(reducer(undefined, setWidgetVersion('abc1234')).version).toBe('abc1234') }) }) })