From ed201936813d9a720b51e6cb40a05267b94e7314 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Thu, 10 Sep 2026 17:46:41 -0700 Subject: Typescript prettier Committing seperate so it's easier to see actual changes. --- jb-ui/src/models/profilingQuestionsSlice.ts | 672 +++++++++++++++------------- 1 file changed, 349 insertions(+), 323 deletions(-) (limited to 'jb-ui/src/models/profilingQuestionsSlice.ts') diff --git a/jb-ui/src/models/profilingQuestionsSlice.ts b/jb-ui/src/models/profilingQuestionsSlice.ts index cc36f17..577566f 100644 --- a/jb-ui/src/models/profilingQuestionsSlice.ts +++ b/jb-ui/src/models/profilingQuestionsSlice.ts @@ -1,313 +1,327 @@ import { - BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, - ProfilingQuestionsApi, - StatusResponse, - UpkQuestion, UserQuestionAnswerIn + 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 type { RootState } from "@/store"; import { - createAsyncThunk, - createEntityAdapter, createSlice, - PayloadAction, - WritableDraft -} from '@reduxjs/toolkit'; + 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 + // 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; + // Only one Question can be active at a time, which is the one + // being shown to the Respondent + isActive: boolean; - answers: string[]; + answers: string[]; - // If it's actively being submitted to the server - isProcessing: boolean; + // If it's actively being submitted to the server + isProcessing: boolean; - // If we recieved a successful response from the server after submitting - isSaved: 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[]; -}; + // 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'; -}; + path: string; // e.g., "user.email" + message: string; + severity: "error" | "warning"; +} export interface ProfileQuestion extends UpkQuestion { - _metadata: UpkQuestionMetadata; - _validation: ValidationResult; + _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); - }, + 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, - }; + return { + isActive: false, + answers: [], + isProcessing: false, + isSaved: false, + }; } function getDefaultValidation(): ValidationResult { - - return { - isValid: undefined, - isComplete: undefined, - errors: [], - }; + 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 }; - + "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)); } -) -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; - } + return { questionId, response }; + }, ); -type ProfilingQuestionsState = ReturnType; +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< + typeof profilingQuestionSlice.getInitialState +>; // 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++; + 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(), - }; - }); + state: WritableDraft, + questions: ProfileQuestion[], +) { + const hasActiveQuestion = Object.values(state.entities).some( + (entity) => entity?._metadata.isActive, + ); - questionsAdapter.setAll(state, entities); -} + 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:") + 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. + // 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); + const allQuestions = questionsAdapter.getSelectors().selectAll(state); + const currentIndex = allQuestions.findIndex((q) => q._metadata.isActive); - if (currentIndex === -1) false; + if (currentIndex === -1) false; - const totalQuestions = allQuestions.length; - let searchIndex = (currentIndex + 1) % totalQuestions; - let iterations = 0; + const totalQuestions = allQuestions.length; + let searchIndex = (currentIndex + 1) % totalQuestions; + let iterations = 0; - while (iterations < totalQuestions) { - if (!allQuestions[searchIndex]._metadata.isSaved) { + while (iterations < totalQuestions) { + if (!allQuestions[searchIndex]._metadata.isSaved) { + const current = isGR(allQuestions[currentIndex].ext_question_id!); + const next = isGR(allQuestions[searchIndex].ext_question_id!); - 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++; + // 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; } - return false; + searchIndex = (searchIndex + 1) % totalQuestions; + iterations++; + } + + return false; } const profilingQuestionSlice = createSlice({ - name: 'profilingQuestions', - initialState: questionsAdapter.getInitialState(), - reducers: { + name: "profilingQuestions", + initialState: questionsAdapter.getInitialState(), + reducers: { + setProfilingQuestions: ( + state, + action: PayloadAction, + ) => { + setProfilingQuestionsLogic(state, action.payload); + }, - 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 }; + } + }, - 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 }; + } + }, - 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; - // 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; - } - }, + // Deactivate all questions + Object.values(state.entities).forEach((entity) => { + if (entity) { + entity._metadata.isActive = false; + } + }); - // Clear active state from all questions - clearActiveQuestion: (state) => { - 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; + } + }, - goToNextQuestion: (state) => { - goToNextQuestionLogic(state); - }, + // 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: + 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 @@ -325,112 +339,124 @@ const profilingQuestionSlice = createSlice({ 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 }; + // 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; + if (entity) { + entity._metadata = newMetadata; - entity = questionUtils.validate(entity) - entity = questionUtils.assignValidationState(entity) + entity = questionUtils.validate(entity); + entity = questionUtils.assignValidationState(entity); - state.entities[action.payload.questionId] = 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[]); - }); - ; - } - -}) + }, + + 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, + setProfilingQuestions, + metadataUpdated, + setActiveQuestion, + goToNextQuestion, + addAnswers, } = profilingQuestionSlice.actions; // Selectors export const questionsSelectors = questionsAdapter.getSelectors( - (state: RootState) => state.profilingQuestions + (state: RootState) => state.profilingQuestions, ); // Custom selector to get the active question export const selectAllQuestions = (state: RootState): ProfileQuestion[] => { - return questionsSelectors.selectAll(state) ?? []; + 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; +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; + 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 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 const selectActiveQuestionValidation = ( + state: RootState, +): ValidationResult | null => { + const activeQuestion = selectActiveQuestion(state); + return activeQuestion?._validation || null; }; - -export default profilingQuestionSlice.reducer - +export default profilingQuestionSlice.reducer; -- cgit v1.2.3