summaryrefslogtreecommitdiff
path: root/jb-ui/src/models/profilingQuestionsSlice.ts
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src/models/profilingQuestionsSlice.ts')
-rw-r--r--jb-ui/src/models/profilingQuestionsSlice.ts436
1 files changed, 436 insertions, 0 deletions
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<typeof profilingQuestionSlice.getInitialState>;
+
+
+// Helper function that contains the logic (outside the slice)
+function goToNextQuestionLogic(state: WritableDraft<ProfilingQuestionsState>) {
+ 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<ProfilingQuestionsState>,
+ 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<ProfilingQuestionsState>): 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<ProfileQuestion[]>) => {
+ setProfilingQuestionsLogic(state, action.payload);
+ },
+
+ metadataUpdated: (
+ state,
+ action: PayloadAction<{ question_id: string; metadata: Partial<UpkQuestionMetadata> }>
+ ) => {
+ 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<ValidationResult> }>
+ ) => {
+ 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<string>) => {
+ 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
+