import { BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, ProfilingQuestionsApi, StatusResponse, UpkQuestion, UserQuestionAnswerIn } from "@/api_fsb"; import { assert, bpid } from "@/lib/utils"; import { questionUtils } from "@/models/profilingUtils"; import type { RootState } from '@/store'; import { createAsyncThunk, createEntityAdapter, createSlice, PayloadAction, WritableDraft } from '@reduxjs/toolkit'; interface UpkQuestionMetadata { // The data we need to conduct profiling questions and manager user // submission along with the User Interfaces // Only one Question can be active at a time, which is the one // being shown to the Respondent isActive: boolean; answers: string[]; // If it's actively being submitted to the server isProcessing: boolean; // If we recieved a successful response from the server after submitting isSaved: boolean; } interface ValidationResult { // If isValid or isComplete are undefined, that means the question // hasn't been validated yet. This is different from false, which means // it has been validated and is not valid. isValid?: boolean; isComplete?: boolean; errors: ValidationError[]; }; export interface ValidationError { path: string; // e.g., "user.email" message: string; severity: 'error' | 'warning'; }; export interface ProfileQuestion extends UpkQuestion { _metadata: UpkQuestionMetadata; _validation: ValidationResult; } // Entity adapter for normalized storage const questionsAdapter = createEntityAdapter({ selectId: (model: ProfileQuestion) => model.question_id!, sortComparer: (a, b) => { return (b.importance?.task_score ?? 0) - (a.importance?.task_score ?? 0); }, }); function getDefaultMetadata(): UpkQuestionMetadata { return { isActive: false, answers: [], isProcessing: false, isSaved: false, }; } function getDefaultValidation(): ValidationResult { return { isValid: undefined, isComplete: undefined, errors: [], }; } // Create the async thunk (outside your slice) export const saveAnswer = createAsyncThunk( 'profilingQuestions/saveAnswer', async ({ questionId, bpuid }: { questionId: string; bpuid: string }, { getState, dispatch }) => { const state = getState() as RootState; const question = state.profilingQuestions.entities[questionId]; // Validations const answers = { 'question_id': question.question_id!, 'answer': question._metadata.answers } as UserQuestionAnswerIn; const answers_body = { 'answers': [answers] } as BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost; // Make API call const res = await new ProfilingQuestionsApi() .submitProfilingQuestionsProductIdProfilingQuestionsPost( bpid, bpuid, answers_body, undefined, true ); const response = res.data as StatusResponse; // Check if we need to fetch new questions BEFORE returning if (response.status === "success" && isFirstNonGR(state.profilingQuestions)) { // NOW dispatch it dispatch(fetchNewQuestions(bpuid)); } return { questionId, response }; } ) export const fetchNewQuestions = createAsyncThunk( 'profilingQuestions/fetchNewQuestions', async (bpuid: string) => { console.log("profilingQuestions/fetchNewQuestions") const res = await new ProfilingQuestionsApi() .getProfilingQuestionsProductIdProfilingQuestionsGet( bpid, bpuid, undefined, // "104.9.125.144", // ip undefined, // countryIso undefined, // languageIso 2_500 ); return res.data.questions; } ); type ProfilingQuestionsState = ReturnType; // Helper function that contains the logic (outside the slice) function goToNextQuestionLogic(state: WritableDraft) { const allQuestions = questionsAdapter.getSelectors().selectAll(state); const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); if (currentIndex === -1) return; const totalQuestions = allQuestions.length; let searchIndex = (currentIndex + 1) % totalQuestions; let iterations = 0; while (iterations < totalQuestions) { if (!allQuestions[searchIndex]._metadata.isSaved) { questionsAdapter.updateMany(state, [ { id: allQuestions[currentIndex].question_id!, changes: { _metadata: { ...allQuestions[currentIndex]._metadata, isActive: false } } }, { id: allQuestions[searchIndex].question_id!, changes: { _metadata: { ...allQuestions[searchIndex]._metadata, isActive: true } } } ]); return; } searchIndex = (searchIndex + 1) % totalQuestions; iterations++; } } function setProfilingQuestionsLogic( state: WritableDraft, questions: ProfileQuestion[]) { const hasActiveQuestion = Object.values(state.entities).some( entity => entity?._metadata.isActive ); const entities = questions.map((serverQuestion, index) => { const existing = state.entities[serverQuestion.question_id!]; const shouldActivate = !hasActiveQuestion && index === 0; return { ...serverQuestion, _metadata: existing?._metadata || { ...getDefaultMetadata(), isActive: shouldActivate, }, _validation: existing?._validation || getDefaultValidation(), }; }); questionsAdapter.setAll(state, entities); } function isGR(ext_question_id: string): boolean { return ext_question_id.startsWith("gr:") } function isFirstNonGR(state: WritableDraft): boolean { // We want to identify if the next question is the first non-GR question // because will want to trigger a full profile question refresh. const allQuestions = questionsAdapter.getSelectors().selectAll(state); const currentIndex = allQuestions.findIndex(q => q._metadata.isActive); if (currentIndex === -1) false; const totalQuestions = allQuestions.length; let searchIndex = (currentIndex + 1) % totalQuestions; let iterations = 0; while (iterations < totalQuestions) { if (!allQuestions[searchIndex]._metadata.isSaved) { const current = isGR(allQuestions[currentIndex].ext_question_id!) const next = isGR(allQuestions[searchIndex].ext_question_id!) // We want to identify transitions from GR to non-GR questions, // as we use GR questions to pre-calculate some non-GR questions, // we should force a refresh. return current && !next; } searchIndex = (searchIndex + 1) % totalQuestions; iterations++; } return false; } const profilingQuestionSlice = createSlice({ name: 'profilingQuestions', initialState: questionsAdapter.getInitialState(), reducers: { setProfilingQuestions: (state, action: PayloadAction) => { setProfilingQuestionsLogic(state, action.payload); }, metadataUpdated: ( state, action: PayloadAction<{ question_id: string; metadata: Partial }> ) => { const { question_id, metadata } = action.payload; const entity = state.entities[question_id]; if (entity) { entity._metadata = { ...entity._metadata, ...metadata }; } }, validationUpdated: ( state, action: PayloadAction<{ question_id: string; validation: Partial }> ) => { const { question_id, validation } = action.payload; const entity = state.entities[question_id]; if (entity) { entity._validation = { ...entity._validation, ...validation }; } }, // Set one question as active, deactivate all others setActiveQuestion: (state, action: PayloadAction) => { const questionId = action.payload; // Deactivate all questions Object.values(state.entities).forEach(entity => { if (entity) { entity._metadata.isActive = false; } }); // Activate the selected one const entity = state.entities[questionId]; if (entity) { entity._metadata.isActive = true; } }, // Clear active state from all questions clearActiveQuestion: (state) => { Object.values(state.entities).forEach(entity => { if (entity) { entity._metadata.isActive = false; } }); }, goToNextQuestion: (state) => { goToNextQuestionLogic(state); }, // ----------------------- addAnswers(state, action: PayloadAction<{ questionId: string, answers: string[] }>) { /* When changing the answers in anyway, we want to: 1. Add the new answers to the question metadata This does not perform validation, it simply reassigns the new answers to the question's _metadata.answers field based on the question type and selector. This "looses" the ability to track choice selection sequence and timing which may be useful for MC questions and security validation in the future. Validation is handled separately in the isValid function. 2. Re-run validation to generate new ValidationError list 3. Re-calculate the isComplete and isValid state */ // let new_question = questionUtils.addAnswer(action.payload.question, action.payload.answers) let entity = state.entities[action.payload.questionId]; const newMetadata = { ...entity._metadata, answers: action.payload.answers }; if (entity) { entity._metadata = newMetadata; entity = questionUtils.validate(entity) entity = questionUtils.assignValidationState(entity) state.entities[action.payload.questionId] = entity } }, }, extraReducers: (builder) => { builder .addCase(saveAnswer.pending, (state, action) => { const questionId = action.meta.arg.questionId; const bpuid = action.meta.arg.bpuid; assert(bpuid, "Worker must be defined"); const question = state.entities[questionId]; if (question) { assert(question._validation.isComplete, "Must submit Completed Questions"); assert(!question._metadata.isProcessing, "Can't submit processing Answer"); assert(!question._metadata.isSaved, "Can't submit completed Answer"); question._metadata.isProcessing = true; } }) .addCase(saveAnswer.fulfilled, (state, action) => { const { questionId, response } = action.payload; const question = state.entities[questionId]; if (question) { question._metadata.isProcessing = false; if (response.status === "success") { question._metadata.isSaved = true; // fetchNewQuestions is already being dispatched from // the THUNK goToNextQuestionLogic(state); } else { question._metadata.isSaved = false; } } }) .addCase(saveAnswer.rejected, (state, action) => { const questionId = action.meta.arg.questionId; const question = state.entities[questionId]; if (question) { question._metadata.isProcessing = false; question._metadata.isSaved = false; } }) .addCase(fetchNewQuestions.fulfilled, (state, action) => { console.log(".addCase(fetchNewQuestions.fulfilled, (state, action) => {") setProfilingQuestionsLogic(state, action.payload as ProfileQuestion[]); }); ; } }) export const { setProfilingQuestions, metadataUpdated, setActiveQuestion, goToNextQuestion, addAnswers, } = profilingQuestionSlice.actions; // Selectors export const questionsSelectors = questionsAdapter.getSelectors( (state: RootState) => state.profilingQuestions ); // Custom selector to get the active question export const selectAllQuestions = (state: RootState): ProfileQuestion[] => { return questionsSelectors.selectAll(state) ?? []; }; // Custom selector to get the active question export const selectActiveQuestion = (state: RootState): ProfileQuestion | null => { const allQuestions = questionsSelectors.selectAll(state); return allQuestions.find(q => q._metadata.isActive) || null; }; // Custom selector to get active question ID export const selectActiveQuestionId = (state: RootState): string | null => { const activeQuestion = selectActiveQuestion(state); return activeQuestion?.question_id || null; }; export const selectActiveQuestionMetadata = (state: RootState): UpkQuestionMetadata | null => { const activeQuestion = selectActiveQuestion(state); return activeQuestion?._metadata || null; }; export const selectActiveQuestionValidation = (state: RootState): ValidationResult | null => { const activeQuestion = selectActiveQuestion(state); return activeQuestion?._validation || null; }; export default profilingQuestionSlice.reducer