From 3eaa56f0306ead818f64c3d99fc6d230d9b970a4 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Wed, 18 Feb 2026 20:42:03 -0500 Subject: HERE WE GO, HERE WE GO, HERE WE GO --- jb-ui/src/models/profilingQuestionsSlice.ts | 436 ++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 jb-ui/src/models/profilingQuestionsSlice.ts (limited to 'jb-ui/src/models/profilingQuestionsSlice.ts') diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts new file mode 100644 index 0000000..cc36f17 --- /dev/null +++ b/jb-ui/src/models/profilingQuestionsSlice.ts @@ -0,0 +1,436 @@ +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 + -- cgit v1.2.3