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..39cdfee98b 100644 --- a/src/redux/actions/App.js +++ b/src/redux/actions/App.js @@ -1,8 +1,14 @@ 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 }), diff --git a/src/redux/actions/__tests__/app-test.js b/src/redux/actions/__tests__/app-test.js index 44896bb4bd..f8d09972f9 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,11 @@ describe('app Dispatcher', () => { actions.markSessionTimedOut() expectDispatch({ type: ActionTypes.SESSION_IS_TIMED_OUT }) }) + + 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..0d0cde7e18 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 }) + 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..26ec1a69d1 100644 --- a/src/redux/reducers/__tests__/app-test.js +++ b/src/redux/reducers/__tests__/app-test.js @@ -1,4 +1,4 @@ -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 } = ActionTypes @@ -15,4 +15,10 @@ describe('app reducers', () => { expect(reducer(undefined, action).sessionIsTimedOut).toBe(true) }) }) + + describe('SET_WIDGET_VERSION', () => { + it('should store the widget version', () => { + expect(reducer(undefined, setWidgetVersion('abc1234')).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..5ba3571360 100644 --- a/src/views/search/Search.js +++ b/src/views/search/Search.js @@ -18,11 +18,12 @@ 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' 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' @@ -131,6 +132,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 +142,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 || {} @@ -331,6 +334,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 +342,13 @@ export const Search = React.forwardRef((_, navigationRef) => { > {__('Select your institution')} + {/* This version is a hidden feature unless a user is told how to find it */} + setHeaderClicks(0)} + open={headerClicks >= 5 && Boolean(widgetVersion)} + /> { describe('Search component', () => { @@ -77,6 +84,39 @@ 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.queryByRole('alert')).not.toBeInTheDocument() + + for (let i = 0; i < 5; i++) { + fireEvent.click(header) + } + + expect(screen.getByRole('alert')).toHaveTextContent('abcdef1234567') + }) + + it('does not show version snackbar after five clicks when version is not provided', async () => { + const ref = React.createRef() + const store = createTestReduxStore() + + render(, { store }) + + const header = await screen.findByText('Select your institution') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + for (let i = 0; i < 5; i++) { + fireEvent.click(header) + } + + expect(screen.queryByRole('alert')).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