diff --git a/spec/components/CioQuiz/reducerTestCases.ts b/spec/components/CioQuiz/reducerTestCases.ts index c0477e35..4a36f6da 100644 --- a/spec/components/CioQuiz/reducerTestCases.ts +++ b/spec/components/CioQuiz/reducerTestCases.ts @@ -97,6 +97,47 @@ export const apiReducerCases = [ matchedOptions: [], }, }, + { + initialState: { + ...apiInitialState, + quizRequestState: 'SUCCESS', + quizResults: { + ...results, + quiz_selected_options: [ + { + attribute: null, + has_attribute: false, + id: 1, + is_matched: true, + value: 'VALUE', + }, + { + attribute: null, + has_attribute: true, + id: 4, + is_matched: false, + value: 'VALUE', + }, + ], + }, + quizCurrentQuestion: undefined, + selectedOptionsWithAttributes: ['VALUE'], + matchedOptions: [], + }, + action: { + type: QuizAPIActionTypes.JUMP_TO_QUESTION, + payload: { + questionId: 2, + }, + }, + expected: { + ...apiInitialState, + quizRequestState: 'SUCCESS', + quizResults: apiInitialState.quizResults, + selectedOptionsWithAttributes: apiInitialState.selectedOptionsWithAttributes, + matchedOptions: undefined, + }, + }, { initialState: apiInitialState, action: { type: 'unknown' }, @@ -330,6 +371,62 @@ export const localReducerCases = [ }, }, }, + { + initialState: { + ...localInitialState, + answerInputs: { + '1': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + '2': { + type: QuestionTypes.OpenText, + value: 'true', + }, + '3': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + }, + prevAnswerInputs: { + '1': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + '2': { + type: QuestionTypes.OpenText, + value: 'true', + }, + '3': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + }, + answers: [[1], ['true'], [1]], + isQuizCompleted: true, + }, + action: { + type: QuestionTypes.JumpToQuestion, + payload: { questionId: 2 }, + }, + expected: { + ...localInitialState, + isQuizCompleted: false, + answers: [[1]], + answerInputs: { + '1': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + }, + prevAnswerInputs: { + '1': { + type: QuestionTypes.SingleSelect, + value: [{ id: 1 }], + }, + }, + }, + }, { initialState: { ...localInitialState, diff --git a/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.server.test.ts b/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.server.test.ts new file mode 100644 index 00000000..5fd5916c --- /dev/null +++ b/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.server.test.ts @@ -0,0 +1,37 @@ +import { renderHookServerSide } from '../../../__tests__/utils.server'; +import useJumpToQuestionButtonProps from '../../../../src/hooks/usePropsGetters/useJumpToQuestionButtonProps'; +import { QuizAPIReducerState } from '../../../../src/components/CioQuiz/quizApiReducer'; +import { QuizLocalReducerState } from '../../../../src/components/CioQuiz/quizLocalReducer'; + +describe('Testing Hook (server): useJumpToQuestionButtonProps', () => { + it('initializes without errors and returns a function that provides button props', () => { + const jumpToQuestionMock = jest.fn(); + const quizApiStateMock = { + quizCurrentQuestion: { + next_question: { id: '2' }, + isCoverQuestion: false, + }, + } as unknown as QuizAPIReducerState; + + const quizLocalStateMock = { + prevAnswerInputs: { + 1: { type: 'open', value: '' }, + }, + } as unknown as QuizLocalReducerState; + + const { result } = renderHookServerSide( + () => useJumpToQuestionButtonProps(jumpToQuestionMock, quizApiStateMock, quizLocalStateMock), + { + initialProps: {}, + } + ); + + expect(typeof result).toBe('function'); + + const buttonProps = result(); + expect(buttonProps).toHaveProperty('className'); + expect(buttonProps).toHaveProperty('type'); + expect(buttonProps).toHaveProperty('onClick'); + expect(typeof buttonProps.onClick).toBe('function'); + }); +}); diff --git a/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.test.ts b/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.test.ts new file mode 100644 index 00000000..e24a58df --- /dev/null +++ b/spec/hooks/usePropsGetters/useJumpToQuestionButtonProps/useJumpToQuestionButtonProps.test.ts @@ -0,0 +1,101 @@ +import { renderHook, act } from '@testing-library/react'; +import useJumpToQuestionButtonProps from '../../../../src/hooks/usePropsGetters/useJumpToQuestionButtonProps'; +import { QuizAPIReducerState } from '../../../../src/components/CioQuiz/quizApiReducer'; +import { QuizLocalReducerState } from '../../../../src/components/CioQuiz/quizLocalReducer'; + +describe('Testing Hook (client): useJumpToQuestionButtonProps', () => { + const mockEvent = { preventDefault: jest.fn() } as unknown as React.MouseEvent; + const jumpToQuestionMock = jest.fn(); + + it('returns button props with disabled class if no answer is provided', () => { + const quizApiState = { + quizCurrentQuestion: { + next_question: { id: '2' }, + isCoverQuestion: false, + }, + } as unknown as QuizAPIReducerState; + + const quizLocalStateMock = { + prevAnswerInputs: { + 1: { type: 'open', value: '' }, + }, + } as unknown as QuizLocalReducerState; + + const { result } = renderHook(() => + useJumpToQuestionButtonProps(jumpToQuestionMock, quizApiState, quizLocalStateMock) + ); + + expect(result.current(2).className).toContain('disabled'); + }); + + it('returns button props with disabled class if an invalid question ID is provided', () => { + const quizApiState = { + quizCurrentQuestion: { + next_question: { id: '2' }, + isCoverQuestion: false, + }, + } as unknown as QuizAPIReducerState; + + const quizLocalStateMock = { + prevAnswerInputs: { + 1: { type: 'open', value: '' }, + }, + } as unknown as QuizLocalReducerState; + + const { result } = renderHook(() => + useJumpToQuestionButtonProps(jumpToQuestionMock, quizApiState, quizLocalStateMock) + ); + + expect(result.current(-1).className).toContain('disabled'); + }); + + it('returns button props without disabled class if an answer is provided', () => { + const quizApiState = { + quizCurrentQuestion: { + next_question: { id: '3' }, + isCoverQuestion: false, + }, + } as unknown as QuizAPIReducerState; + + const quizLocalStateMock = { + prevAnswerInputs: { + 1: { type: 'open', value: '' }, + 2: { type: 'open', value: '' }, + }, + } as unknown as QuizLocalReducerState; + + const { result } = renderHook(() => + useJumpToQuestionButtonProps(jumpToQuestionMock, quizApiState, quizLocalStateMock) + ); + + expect(result.current(1).className).not.toContain('disabled'); + }); + + it('calls jumpToQuestion function when button is clicked', () => { + const quizApiState = { + quizCurrentQuestion: { + next_question: { id: '3' }, + isCoverQuestion: false, + }, + } as unknown as QuizAPIReducerState; + + const quizLocalStateMock = { + prevAnswerInputs: { + 1: { type: 'open', value: '' }, + 2: { type: 'open', value: '' }, + }, + } as unknown as QuizLocalReducerState; + + const { result } = renderHook(() => + useJumpToQuestionButtonProps(jumpToQuestionMock, quizApiState, quizLocalStateMock) + ); + + expect(typeof result.current(1).onClick).toBe('function'); + + act(() => { + result.current(1).onClick(mockEvent); + }); + + expect(jumpToQuestionMock).toHaveBeenCalled(); + }); +}); diff --git a/spec/hooks/usePropsGetters/useSelectInputProps/useSelectInputProps.test.tsx b/spec/hooks/usePropsGetters/useSelectInputProps/useSelectInputProps.test.tsx index 50ffe441..d0209875 100644 --- a/spec/hooks/usePropsGetters/useSelectInputProps/useSelectInputProps.test.tsx +++ b/spec/hooks/usePropsGetters/useSelectInputProps/useSelectInputProps.test.tsx @@ -24,7 +24,9 @@ describe('Testing Hook (client): useSelectInputProps', () => { }); const setupHook = (questionData) => - renderHook(() => useSelectInputProps(quizAnswerChangedMock, nextQuestionMock, questionData)); + renderHook(() => + useSelectInputProps(quizAnswerChangedMock, nextQuestionMock, questionData, answerInputs) + ); it('correctly toggles selected class on click', () => { const { result } = setupHook(currentQuestionData); @@ -80,6 +82,25 @@ describe('Testing Hook (client): useSelectInputProps', () => { expect(nextQuestionMock).not.toHaveBeenCalled(); }); + it('does not advance when configured not to', () => { + currentQuestionData.type = 'single'; + const { result } = renderHook(() => + useSelectInputProps( + quizAnswerChangedMock, + nextQuestionMock, + currentQuestionData, + answerInputs, + false + ) + ); + + act(() => { + result.current(currentQuestionData.options[0]).onClick(mockEvent); + }); + expect(result.current(currentQuestionData.options[0]).className).toContain('selected'); + expect(nextQuestionMock).not.toHaveBeenCalled(); + }); + it('allows toggling options off in a multiple select question', () => { currentQuestionData.type = 'multiple'; const { result } = setupHook(currentQuestionData); diff --git a/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.server.test.ts b/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.server.test.ts new file mode 100644 index 00000000..58621d13 --- /dev/null +++ b/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.server.test.ts @@ -0,0 +1,34 @@ +import { renderHookServerSide } from '../../../__tests__/utils.server'; +import useJumpToQuestion from '../../../../src/hooks/useQuizEvents/useJumpToQuestion'; +import { QuizAPIReducerState } from '../../../../src/components/CioQuiz/quizApiReducer'; + +describe('Testing Hook (server): useJumpToQuestion', () => { + it('initializes without throwing errors', async () => { + const dispatchLocalStateMock = jest.fn(); + const dispatchApiStateMock = jest.fn(); + const quizApiStateMock: QuizAPIReducerState = { + quizCurrentQuestion: { + id: '1', + next_question: { + id: '2', + type: 'singleChoice', + }, + }, + } as unknown as QuizAPIReducerState; + + const executeHook = () => + renderHookServerSide( + () => + useJumpToQuestion({ + quizApiState: quizApiStateMock, + dispatchLocalState: dispatchLocalStateMock, + dispatchApiState: dispatchApiStateMock, + }), + { + initialProps: {}, + } + ); + + expect(executeHook).not.toThrow(); + }); +}); diff --git a/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.test.ts b/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.test.ts new file mode 100644 index 00000000..0de58f97 --- /dev/null +++ b/spec/hooks/useQuizEvents/useJumpToQuestion/useJumpToQuestion.test.ts @@ -0,0 +1,64 @@ +import { renderHook, act } from '@testing-library/react'; + +import useJumpToQuestion from '../../../../src/hooks/useQuizEvents/useJumpToQuestion'; +import { QuizAPIReducerState } from '../../../../src/components/CioQuiz/quizApiReducer'; +import { QuestionTypes, QuizAPIActionTypes } from '../../../../src/components/CioQuiz/actions'; + +describe('Testing Hook (client): useJumpToQuestion', () => { + const dispatchLocalStateMock = jest.fn(); + const dispatchApiStateMock = jest.fn(); + const quizApiStateMock = { + quizCurrentQuestion: { + id: 2, + next_question: { + id: 3, + type: 'singleChoice', + }, + }, + } as unknown as QuizAPIReducerState; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not call dispatchLocalState and dispatchApiState', () => { + const { result } = renderHook(() => + useJumpToQuestion({ + quizApiState: quizApiStateMock, + dispatchLocalState: dispatchLocalStateMock, + dispatchApiState: dispatchApiStateMock, + }) + ); + + act(() => { + result.current(2); + }); + + expect(dispatchLocalStateMock).not.toHaveBeenCalled(); + expect(dispatchApiStateMock).not.toHaveBeenCalled(); + }); + + it('calls dispatchLocalState and dispatchApiState correctly', () => { + const { result } = renderHook(() => + useJumpToQuestion({ + quizApiState: quizApiStateMock, + dispatchLocalState: dispatchLocalStateMock, + dispatchApiState: dispatchApiStateMock, + }) + ); + + act(() => { + result.current(1); + }); + + expect(dispatchLocalStateMock).toHaveBeenCalledWith({ + type: QuestionTypes.JumpToQuestion, + payload: { questionId: 1 }, + }); + + expect(dispatchApiStateMock).toHaveBeenCalledWith({ + type: QuizAPIActionTypes.JUMP_TO_QUESTION, + payload: { questionId: 1 }, + }); + }); +}); diff --git a/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.server.test.ts b/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.server.test.ts new file mode 100644 index 00000000..61e75334 --- /dev/null +++ b/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.server.test.ts @@ -0,0 +1,25 @@ +import { renderHookServerSide } from '../../../__tests__/utils.server'; +import useQuizResetClick from '../../../../src/hooks/useQuizEvents/useQuizResetClick'; +import * as factories from '../../../__tests__/factories'; + +describe('Testing Hook (server): useQuizResetClick', () => { + it('initializes without throwing errors', async () => { + const resetQuizSessionStorageStateMock = jest.fn(); + const dispatchLocalStateMock = jest.fn(); + const dispatchApiStateMock = jest.fn(); + + const props: Parameters[0] = { + resetQuizSessionStorageState: resetQuizSessionStorageStateMock, + dispatchLocalState: dispatchLocalStateMock, + dispatchApiState: dispatchApiStateMock, + quizResults: factories.quizResults.build(), + }; + + const executeHook = () => + renderHookServerSide(() => useQuizResetClick(props), { + initialProps: {}, + }); + + expect(executeHook).not.toThrow(); + }); +}); diff --git a/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.test.ts b/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.test.ts new file mode 100644 index 00000000..adc58b17 --- /dev/null +++ b/spec/hooks/useQuizEvents/useQuizResetClick/useQuizResetClick.test.ts @@ -0,0 +1,51 @@ +import { act, renderHook } from '@testing-library/react'; + +import * as factories from '../../../__tests__/factories'; + +import useQuizResetClick from '../../../../src/hooks/useQuizEvents/useQuizResetClick'; +import * as useQueryParams from '../../../../src/hooks/useQueryParams'; +import { QuestionTypes, QuizAPIActionTypes } from '../../../../src/components/CioQuiz/actions'; + +describe('Testing Hook (client): useQuizResetClick', () => { + const resetQuizSessionStorageStateMock = jest.fn(); + const dispatchLocalStateMock = jest.fn(); + const dispatchApiStateMock = jest.fn(); + const removeSharedResultsQueryParamsMock = jest.fn(); + const useQueryParamsReturnValue = { + removeSharedResultsQueryParams: removeSharedResultsQueryParamsMock, + isSharedResultsQuery: true, + answers: [], + quizVersionId: '', + queryItems: [], + queryAttributes: [], + }; + const props: Parameters[0] = { + resetQuizSessionStorageState: resetQuizSessionStorageStateMock, + dispatchLocalState: dispatchLocalStateMock, + dispatchApiState: dispatchApiStateMock, + quizResults: factories.quizResults.build(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(useQueryParams, 'default').mockReturnValue(useQueryParamsReturnValue); + }); + + it('should reset the quiz when called', () => { + const { result } = renderHook(() => useQuizResetClick(props)); + + act(() => { + result.current(); + }); + + expect(dispatchLocalStateMock).toHaveBeenCalledWith({ + type: QuestionTypes.Reset, + }); + + expect(dispatchApiStateMock).toHaveBeenCalledWith({ + type: QuizAPIActionTypes.RESET_QUIZ, + }); + expect(resetQuizSessionStorageStateMock).toHaveBeenCalled(); + expect(removeSharedResultsQueryParamsMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/CioQuiz/actions.ts b/src/components/CioQuiz/actions.ts index a5d5ee90..1b3b90f7 100644 --- a/src/components/CioQuiz/actions.ts +++ b/src/components/CioQuiz/actions.ts @@ -23,6 +23,7 @@ export enum QuestionTypes { Reset = 'reset', Hydrate = 'hydrate', Complete = 'complete', + JumpToQuestion = 'jump_to_question', } export interface QuestionAnswer { @@ -54,6 +55,7 @@ export type ActionAnswerQuestion = | Action | Action | Action + | Action | Action>; // API actions @@ -63,6 +65,7 @@ export enum QuizAPIActionTypes { SET_QUIZ_RESULTS, SET_CURRENT_QUESTION, RESET_QUIZ, + JUMP_TO_QUESTION, SET_QUIZ_SHARED_RESULTS, SET_QUIZ_RESULTS_CONFIG, SET_QUIZ_RESULTS_CONFIG_ERROR, @@ -85,7 +88,10 @@ export type ActionSetCurrentQuestion = Action< QuizAPIActionTypes.SET_CURRENT_QUESTION, { quizCurrentQuestion: NextQuestionResponse; quizSessionId?: string; quizVersionId?: string } >; - +export type ActionJumpToQuestion = Action< + QuizAPIActionTypes.JUMP_TO_QUESTION, + { questionId: number } +>; export type ActionResetQuiz = Action; export type ActionSetQuizResultsConfig = | Action< @@ -100,4 +106,5 @@ export type ActionQuizAPI = | ActionSetCurrentQuestion | ActionResetQuiz | ActionSetQuizSharedResults - | ActionSetQuizResultsConfig; + | ActionSetQuizResultsConfig + | ActionJumpToQuestion; diff --git a/src/components/CioQuiz/context.ts b/src/components/CioQuiz/context.ts index b924fcf2..b9458f45 100644 --- a/src/components/CioQuiz/context.ts +++ b/src/components/CioQuiz/context.ts @@ -17,6 +17,7 @@ import { PrimaryColorStyles, QuizReturnState, GetShareResultsButtonProps, + GetJumpToQuestionButtonProps, } from '../../types'; export interface QuizContextValue { @@ -36,6 +37,7 @@ export interface QuizContextValue { getAddToFavoritesButtonProps: GetAddToFavoritesButtonProps; getQuizResultButtonProps: GetQuizResultButtonProps; getQuizResultLinkProps: GetQuizResultLinkProps; + getJumpToQuestionButtonProps: GetJumpToQuestionButtonProps; primaryColorStyles: PrimaryColorStyles; customClickItemCallback: boolean; customAddToFavoritesCallback: boolean; diff --git a/src/components/CioQuiz/index.tsx b/src/components/CioQuiz/index.tsx index 3216509c..0cc365b8 100644 --- a/src/components/CioQuiz/index.tsx +++ b/src/components/CioQuiz/index.tsx @@ -24,6 +24,7 @@ export default function CioQuiz(props: IQuizProps) { getHydrateQuizButtonProps, getNextQuestionButtonProps, getSkipQuestionButtonProps, + getJumpToQuestionButtonProps, getOpenTextInputProps, getPreviousQuestionButtonProps, getQuizImageProps, @@ -69,6 +70,7 @@ export default function CioQuiz(props: IQuizProps) { getHydrateQuizButtonProps, getNextQuestionButtonProps, getSkipQuestionButtonProps, + getJumpToQuestionButtonProps, getOpenTextInputProps, getPreviousQuestionButtonProps, getQuizImageProps, diff --git a/src/components/CioQuiz/quizApiReducer.ts b/src/components/CioQuiz/quizApiReducer.ts index 9952d2bd..24d923fa 100644 --- a/src/components/CioQuiz/quizApiReducer.ts +++ b/src/components/CioQuiz/quizApiReducer.ts @@ -106,6 +106,14 @@ export default function apiReducer( selectedOptionsWithAttributes: action.payload?.quizResults.attributes, }; } + case QuizAPIActionTypes.JUMP_TO_QUESTION: { + return { + ...state, + quizResults: undefined, + selectedOptionsWithAttributes: undefined, + matchedOptions: undefined, + }; + } case QuizAPIActionTypes.SET_QUIZ_RESULTS_CONFIG: { return { ...state, diff --git a/src/components/CioQuiz/quizLocalReducer.ts b/src/components/CioQuiz/quizLocalReducer.ts index 944f3e43..e9cc9043 100644 --- a/src/components/CioQuiz/quizLocalReducer.ts +++ b/src/components/CioQuiz/quizLocalReducer.ts @@ -18,16 +18,6 @@ export const initialState: QuizLocalReducerState = { isQuizCompleted: false, }; -function answerInputReducer(state: AnswerInputState, action: ActionAnswerInputQuestion) { - return { - ...state, - [String(action.payload!.questionId)]: { - type: action.type, - value: action.payload!.input, - }, - }; -} - function handleNextQuestion(state: QuizLocalReducerState) { const { answers, answerInputs } = state; const newAnswers = [...answers]; @@ -63,47 +53,31 @@ function handleNextQuestion(state: QuizLocalReducerState) { }; } +const handleAnswerInput = (state: QuizLocalReducerState, action: ActionAnswerInputQuestion) => ({ + ...state, + answerInputs: { + ...state.answerInputs, + [String(action.payload!.questionId)]: { + type: action.type, + value: action.payload!.input, + }, + }, + isQuizCompleted: false, +}); + +// eslint-disable-next-line complexity export default function quizLocalReducer( state: QuizLocalReducerState, action: ActionAnswerQuestion ): QuizLocalReducerState { switch (action.type) { case QuestionTypes.OpenText: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; case QuestionTypes.Cover: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; case QuestionTypes.SingleSelect: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; case QuestionTypes.MultipleSelect: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; case QuestionTypes.SingleFilterValue: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; case QuestionTypes.MultipleFilterValues: - return { - ...state, - answerInputs: answerInputReducer(state.answerInputs, action), - isQuizCompleted: false, - }; + return handleAnswerInput(state, action); case QuestionTypes.Next: { return handleNextQuestion(state); } @@ -145,6 +119,30 @@ export default function quizLocalReducer( }; } + case QuestionTypes.JumpToQuestion: { + const questionId = action.payload?.questionId; + if (questionId === undefined) return state; + const prevAnswerInputs = { ...state.prevAnswerInputs }; + + // Remove all keys greater than questionId from answerInputs + const filteredAnswerInputs: AnswerInputState = {}; + Object.keys(prevAnswerInputs).forEach((key) => { + if (parseInt(key, 10) >= questionId) return; + filteredAnswerInputs[key] = prevAnswerInputs[key]; + }); + + // Calculate the number of questions to keep (questions <= questionId) + const questionsToKeep = Object.keys(filteredAnswerInputs).length; + + return { + ...state, + answerInputs: filteredAnswerInputs, + prevAnswerInputs: filteredAnswerInputs, + answers: state.answers.slice(0, questionsToKeep), + isQuizCompleted: false, + }; + } + case QuestionTypes.Reset: return { ...initialState, diff --git a/src/hooks/usePropsGetters/index.ts b/src/hooks/usePropsGetters/index.ts index ba40515f..259c5be7 100644 --- a/src/hooks/usePropsGetters/index.ts +++ b/src/hooks/usePropsGetters/index.ts @@ -17,6 +17,8 @@ import { GetAddToFavoritesButtonProps, GetSkipQuestionButtonProps, GetShareResultsButtonProps, + GetJumpToQuestionButtonProps, + QuestionsPageOptions, } from '../../types'; import { QuizAPIReducerState } from '../../components/CioQuiz/quizApiReducer'; import { QuizLocalReducerState } from '../../components/CioQuiz/quizLocalReducer'; @@ -27,13 +29,21 @@ import useNextQuestionButtonProps from './useNextQuestionButtonProps'; import usePreviousQuestionButtonProps from './usePreviousQuestionButtonProps'; import useAddToFavoritesButtonProps from './useAddToFavoritesButtonProps'; import useSkipQuestionButtonProps from './useSkipQuestionButtonProps'; - -const usePropsGetters = ( - quizEvents: QuizEventsReturn, - quizApiState: QuizAPIReducerState, - quizLocalState: QuizLocalReducerState, - favoriteItems?: string[] -) => { +import useJumpToQuestionButtonProps from './useJumpToQuestionButtonProps'; + +const usePropsGetters = ({ + questionsPageOptions, + favoriteItems, + quizEvents, + quizApiState, + quizLocalState, +}: { + quizEvents: QuizEventsReturn; + quizApiState: QuizAPIReducerState; + quizLocalState: QuizLocalReducerState; + favoriteItems?: string[]; + questionsPageOptions?: QuestionsPageOptions; +}) => { const { quizAnswerChanged, nextQuestion, @@ -44,6 +54,7 @@ const usePropsGetters = ( addToCart, addToFavorites, resultClick, + jumpToQuestion, } = quizEvents; const getOpenTextInputProps: GetOpenTextInputProps = useOpenTextInputProps( @@ -62,7 +73,8 @@ const usePropsGetters = ( quizAnswerChanged, nextQuestion, quizApiState.quizCurrentQuestion?.next_question, - quizLocalState.answerInputs + quizLocalState.answerInputs, + questionsPageOptions?.nextQuestionOnSingleSelect ); const getNextQuestionButtonProps: GetNextQuestionButtonProps = useNextQuestionButtonProps( @@ -76,6 +88,12 @@ const usePropsGetters = ( quizApiState ); + const getJumpToQuestionButtonProps: GetJumpToQuestionButtonProps = useJumpToQuestionButtonProps( + jumpToQuestion, + quizApiState, + quizLocalState + ); + const getPreviousQuestionButtonProps: GetPreviousQuestionButtonProps = usePreviousQuestionButtonProps(quizApiState, previousQuestion); @@ -182,6 +200,7 @@ const usePropsGetters = ( getQuizResultButtonProps, getQuizResultLinkProps, getSkipQuestionButtonProps, + getJumpToQuestionButtonProps, }; }; diff --git a/src/hooks/usePropsGetters/useJumpToQuestionButtonProps.ts b/src/hooks/usePropsGetters/useJumpToQuestionButtonProps.ts new file mode 100644 index 00000000..027aaea2 --- /dev/null +++ b/src/hooks/usePropsGetters/useJumpToQuestionButtonProps.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; +import { QuizAPIReducerState } from '../../components/CioQuiz/quizApiReducer'; +import { GetJumpToQuestionButtonProps, QuizEventsReturn } from '../../types'; +import { QuizLocalReducerState } from '../../components/CioQuiz/quizLocalReducer'; + +export default function useJumpToQuestionButtonProps( + jumpToQuestion: QuizEventsReturn.JumpToQuestion, + quizApiState: QuizAPIReducerState, + quizLocalState: QuizLocalReducerState +): GetJumpToQuestionButtonProps { + const getJumpToQuestionButtonProps: GetJumpToQuestionButtonProps = useCallback( + (id: number) => { + let buttonDisabled; + + const currentQuestionId = quizApiState.quizCurrentQuestion?.next_question?.id; + + const answerIds = Object.keys(quizLocalState.prevAnswerInputs); + const isInvalidQuestionId = answerIds && !answerIds.includes(String(id)); + const isCurrentOrFutureQuestionId = currentQuestionId && id >= currentQuestionId; + + if (isInvalidQuestionId || isCurrentOrFutureQuestionId) { + buttonDisabled = true; + } + + return { + className: buttonDisabled ? 'cio-question-cta-button disabled' : 'cio-question-cta-button', + tabIndex: buttonDisabled ? -1 : 0, + 'aria-disabled': buttonDisabled ? 'true' : 'false', + 'aria-describedby': buttonDisabled ? 'jump-to-button-help' : '', + disabled: !!buttonDisabled, + type: 'button', + onClick: () => { + jumpToQuestion(id); + }, + }; + }, + [ + quizLocalState.prevAnswerInputs, + quizApiState.quizCurrentQuestion?.next_question?.id, + jumpToQuestion, + ] + ); + + return getJumpToQuestionButtonProps; +} diff --git a/src/hooks/usePropsGetters/useSelectInputProps.ts b/src/hooks/usePropsGetters/useSelectInputProps.ts index 39e0868a..fc629db1 100644 --- a/src/hooks/usePropsGetters/useSelectInputProps.ts +++ b/src/hooks/usePropsGetters/useSelectInputProps.ts @@ -15,7 +15,8 @@ export default function useSelectInputProps( quizAnswerChanged: QuizEventsReturn.QuizAnswerChanged, nextQuestion: QuizEventsReturn.NextQuestion, currentQuestionData?: Nullable, - answerInputs?: AnswerInputState + answerInputs?: AnswerInputState, + nextQuestionOnSingleSelect = true ): GetSelectInputProps { const type: `${QuestionTypes}` | undefined = currentQuestionData?.type; const hasImages = currentQuestionData?.options?.some((option: QuestionOption) => option.images); @@ -112,7 +113,8 @@ export default function useSelectInputProps( if ( (currentQuestionData?.type === QuestionTypes.SingleSelect || currentQuestionData?.type === QuestionTypes.SingleFilterValue) && - singleSelectClicked.current + singleSelectClicked.current && + nextQuestionOnSingleSelect ) { nextQuestion(); } diff --git a/src/hooks/useQuiz.ts b/src/hooks/useQuiz.ts index a5311d1e..db11efab 100644 --- a/src/hooks/useQuiz.ts +++ b/src/hooks/useQuiz.ts @@ -23,12 +23,13 @@ const useQuiz: UseQuiz = (quizOptions) => { // Props getters const { quizApiState, quizLocalState, quizSessionStorageState } = quizState; - const propGetters = usePropsGetters( + const propGetters = usePropsGetters({ quizEvents, quizApiState, quizLocalState, - resultsPageOptions?.favoriteItems - ); + favoriteItems: resultsPageOptions?.favoriteItems, + questionsPageOptions: quizOptions.questionsPageOptions, + }); const primaryColorStyles = usePrimaryColorStyles(primaryColor); diff --git a/src/hooks/useQuizEvents/index.ts b/src/hooks/useQuizEvents/index.ts index 3cda8c89..6a1e1f38 100644 --- a/src/hooks/useQuizEvents/index.ts +++ b/src/hooks/useQuizEvents/index.ts @@ -12,6 +12,7 @@ import useQuizState from '../useQuizState'; import { resetQuizSessionStorageState } from '../../utils'; import useQuizAddToFavorites from './useQuizAddToFavorites'; import useQuizSkipClick from './useQuizSkipClick'; +import useJumpToQuestion from './useJumpToQuestion'; type UseQuizEvents = ( quizOptions: IQuizProps, @@ -82,6 +83,12 @@ const useQuizEvents: UseQuizEvents = (quizOptions, cioClient, quizState) => { quizResults: quizApiState.quizResults, }); + const jumpToQuestion = useJumpToQuestion({ + dispatchLocalState, + dispatchApiState, + quizApiState, + }); + // Quiz rehydrate const hydrateQuizLocalState = useHydrateQuizLocalState( quizOptions.quizId, @@ -98,6 +105,7 @@ const useQuizEvents: UseQuizEvents = (quizOptions, cioClient, quizState) => { nextQuestion, skipQuestion, resetQuiz, + jumpToQuestion, hydrateQuiz: hydrateQuizLocalState, resetSessionStorageState, }; diff --git a/src/hooks/useQuizEvents/useJumpToQuestion.ts b/src/hooks/useQuizEvents/useJumpToQuestion.ts new file mode 100644 index 00000000..814928b3 --- /dev/null +++ b/src/hooks/useQuizEvents/useJumpToQuestion.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { + ActionAnswerQuestion, + ActionQuizAPI, + QuestionTypes, + QuizAPIActionTypes, +} from '../../components/CioQuiz/actions'; +import { QuizEventsReturn } from '../../types'; +import { QuizAPIReducerState } from '../../components/CioQuiz/quizApiReducer'; + +type IUseJumpToQuestionProps = { + dispatchLocalState: React.Dispatch; + dispatchApiState: React.Dispatch; + quizApiState: QuizAPIReducerState; +}; + +const useJumpToQuestion = (props: IUseJumpToQuestionProps): QuizEventsReturn.JumpToQuestion => { + const { dispatchLocalState, dispatchApiState, quizApiState } = props; + const quizJumpToQuestionClickHandler = useCallback( + (questionId: number) => { + const currentQuestionId = quizApiState.quizCurrentQuestion?.id; + + if (questionId >= currentQuestionId) { + return; + } + dispatchLocalState({ + type: QuestionTypes.JumpToQuestion, + payload: { questionId }, + }); + dispatchApiState({ + type: QuizAPIActionTypes.JUMP_TO_QUESTION, + payload: { questionId }, + }); + }, + [quizApiState.quizCurrentQuestion?.id, dispatchLocalState, dispatchApiState] + ); + + return quizJumpToQuestionClickHandler; +}; + +export default useJumpToQuestion; diff --git a/src/hooks/useQuizEvents/useQuizResetClick.ts b/src/hooks/useQuizEvents/useQuizResetClick.ts index be8cf321..c57e2362 100644 --- a/src/hooks/useQuizEvents/useQuizResetClick.ts +++ b/src/hooks/useQuizEvents/useQuizResetClick.ts @@ -15,7 +15,7 @@ type IUseQuizResetClickProps = { quizResults?: QuizResultsResponse | QuizSharedResultsData; }; -const useQuizResetClick = (props: IUseQuizResetClickProps): QuizEventsReturn.NextQuestion => { +const useQuizResetClick = (props: IUseQuizResetClickProps): QuizEventsReturn.ResetQuiz => { const { resetQuizSessionStorageState, dispatchLocalState, dispatchApiState, quizResults } = props; const { removeSharedResultsQueryParams, isSharedResultsQuery } = useQueryParams(); const quizResetClickHandler = useCallback(() => { diff --git a/src/hooks/useQuizState/useQuizApiState.ts b/src/hooks/useQuizState/useQuizApiState.ts index d5ce5d7b..0a2ff1ea 100644 --- a/src/hooks/useQuizState/useQuizApiState.ts +++ b/src/hooks/useQuizState/useQuizApiState.ts @@ -125,7 +125,7 @@ const useQuizApiState: UseQuizApiState = ( return; } - const { onQuizResultsConfigLoaded } = callbacks; + const { onQuizResultsConfigLoaded } = callbacks || {}; if (onQuizResultsConfigLoaded && isFunction(onQuizResultsConfigLoaded)) { onQuizResultsConfigLoaded(quizApiState.resultsConfig, quizApiState.metadata); } diff --git a/src/hooks/useQuizState/useSessionStorageState.ts b/src/hooks/useQuizState/useSessionStorageState.ts index 16ef99e3..77ed2209 100644 --- a/src/hooks/useQuizState/useSessionStorageState.ts +++ b/src/hooks/useQuizState/useSessionStorageState.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { getStateFromSessionStorage } from '../../utils'; import { SessionStateOptions } from '../../types'; import { QuizLocalReducerState } from '../../components/CioQuiz/quizLocalReducer'; @@ -11,6 +11,7 @@ const useSessionStorageState = ( enableHydration?: boolean ) => { const quizSessionStorageStateKey = sessionStateOptions?.sessionStateKey || quizSessionStateKey; + const [quizData, setQuizData] = useState(getStateFromSessionStorage(quizSessionStorageStateKey)); // Save state to session storage useEffect(() => { @@ -22,11 +23,10 @@ const useSessionStorageState = ( [quizId]: quizLocalState, }; window?.sessionStorage?.setItem(quizSessionStorageStateKey, JSON.stringify(dataToSave)); + setQuizData(data); } }, [quizLocalState, quizSessionStorageStateKey, enableHydration, quizId]); - const quizData = getStateFromSessionStorage(quizSessionStorageStateKey); - const skipToResults = !!enableHydration && !!quizData?.[quizId]?.isQuizCompleted && diff --git a/src/stories/Quiz/Examples/RetrievingAnswers.tsx b/src/stories/Quiz/Examples/RetrievingAnswers.tsx index 2aa3d3a1..9bf5a532 100644 --- a/src/stories/Quiz/Examples/RetrievingAnswers.tsx +++ b/src/stories/Quiz/Examples/RetrievingAnswers.tsx @@ -18,6 +18,9 @@ export default function RetrievingAnswersStory() { apiKey, quizId, resultsPageOptions, + callbacks: { + onAddToCartClick: () => {}, + }, }; const quizHook = useQuiz(quizProps); diff --git a/src/stories/Quiz/Hooks/Docs/markdown/EventsDocs.md b/src/stories/Quiz/Hooks/Docs/markdown/EventsDocs.md index 3134008d..5023f1b6 100644 --- a/src/stories/Quiz/Hooks/Docs/markdown/EventsDocs.md +++ b/src/stories/Quiz/Hooks/Docs/markdown/EventsDocs.md @@ -15,4 +15,4 @@ | addToCart | `function(e: React.MouseEvent, item, price) => void` | Action event to trigger add to cart click events | | addToFavorites | `function(e: React.MouseEvent, item, price) => void` | Action event trigger add to favorites click events | | hydrateQuiz | `function() => void` | Action event to hydrate the quiz with saved state in session storage on reload | - | quizAnswerChanged | `function(payload: string \| string[] ) => void` | Action event to trigger add to cart click events | + | quizAnswerChanged | `function(payload: string \| options[] ) => void` | Action event to change an answer to a question | diff --git a/src/stories/Quiz/Hooks/Docs/markdown/QuizQuestionsPropGettersDocs.md b/src/stories/Quiz/Hooks/Docs/markdown/QuizQuestionsPropGettersDocs.md index 52b0b9bf..bf8e0b52 100644 --- a/src/stories/Quiz/Hooks/Docs/markdown/QuizQuestionsPropGettersDocs.md +++ b/src/stories/Quiz/Hooks/Docs/markdown/QuizQuestionsPropGettersDocs.md @@ -14,9 +14,9 @@ ); ``` - + - ##### `getSelectInputProps` - + This method should be applied to an element of type `
  • ` or `` on quiz questions of type SelectQuestion. This handles selection state, events and styles. @@ -71,7 +71,7 @@ ); ``` - + - ##### `getSkipQuestionButtonProps` This method should be applied to an element of type ` + ); + ``` + - ##### `getQuizImageProps` This method should be applied to an element of type `` or `
    ` on quiz images of any question type. diff --git a/src/types.ts b/src/types.ts index cf6687b3..229dddd6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -87,6 +87,7 @@ export interface ResultsPageOptions { export interface QuestionsPageOptions { skipQuestionButtonText?: string; + nextQuestionOnSingleSelect?: boolean; } export interface SessionStateOptions { @@ -187,6 +188,7 @@ export namespace QuizEventsReturn { export type SkipQuestion = () => void; export type PreviousQuestion = () => void; export type ResetQuiz = () => void; + export type JumpToQuestion = (questionId: number) => void; export type ResultClick = (result: QuizResultDataPartial, position: number) => void; export type AddToCart = ( e: React.MouseEvent, @@ -206,6 +208,7 @@ export namespace QuizEventsReturn { export interface QuizEventsReturn { nextQuestion: QuizEventsReturn.NextQuestion; skipQuestion: QuizEventsReturn.SkipQuestion; + jumpToQuestion: QuizEventsReturn.JumpToQuestion; quizAnswerChanged: QuizEventsReturn.QuizAnswerChanged; previousQuestion: QuizEventsReturn.PreviousQuestion; resetQuiz: QuizEventsReturn.ResetQuiz; @@ -313,12 +316,19 @@ export interface SelectInputProps { key: number | string; } +export interface JumpToQuestionButtonProps { + className: string; + onClick: React.MouseEventHandler; + style?: Record; +} + export type GetOpenTextInputProps = () => OpenTextInputProps; export type GetCoverQuestionProps = () => CoverQuestionProps; export type GetSelectInputProps = (option: QuestionOption) => SelectInputProps; export type GetNextQuestionButtonProps = () => NextQuestionButtonProps; export type GetSkipQuestionButtonProps = () => SkipQuestionButtonProps; export type GetPreviousQuestionButtonProps = () => PreviousQuestionButtonProps; +export type GetJumpToQuestionButtonProps = (id: number) => JumpToQuestionButtonProps; export type GetResetQuizButtonProps = ( stylesType?: 'primary' | 'secondary' ) => ResetQuizButtonProps; @@ -371,6 +381,7 @@ export interface UseQuizReturn { getAddToFavoritesButtonProps: GetAddToFavoritesButtonProps; getQuizResultButtonProps: GetQuizResultButtonProps; getQuizResultLinkProps: GetQuizResultLinkProps; + getJumpToQuestionButtonProps: GetJumpToQuestionButtonProps; primaryColorStyles: PrimaryColorStyles; }