aboutsummaryrefslogtreecommitdiff
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.ts672
1 files changed, 349 insertions, 323 deletions
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<typeof profilingQuestionSlice.getInitialState>;
+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++;
+ 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(),
- };
- });
+ state: WritableDraft<ProfilingQuestionsState>,
+ 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<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.
+ // 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<ProfileQuestion[]>,
+ ) => {
+ setProfilingQuestionsLogic(state, action.payload);
+ },
- 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 };
+ }
+ },
- 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 };
+ }
+ },
- 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;
- // 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;
- }
- },
+ // 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;