From 257bc2f85b71a8564e95a8e6ba39ab0b00e022df Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Fri, 6 Jun 2025 22:40:41 +0700 Subject: Question.active state (clear naming on getInitialQuestion and getNextQuestion). Explicit use of return null as an option for answerSlice. Saving motion. Questions rolling window. Question count badge to sidebar. --- src/Widget.tsx | 2 +- src/components/nav-main.tsx | 13 ++++- src/models/answerSlice.ts | 21 +++----- src/models/questionSlice.ts | 58 ++++++++++++++++----- src/pages/Questions.tsx | 121 ++++++++++++++++++++++++++++++-------------- 5 files changed, 147 insertions(+), 68 deletions(-) diff --git a/src/Widget.tsx b/src/Widget.tsx index 3c7f613..c28b55d 100644 --- a/src/Widget.tsx +++ b/src/Widget.tsx @@ -27,7 +27,7 @@ const Widget = () => { }) .catch(err => console.log(err)); - new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet(app.bpid, app.bpuid, "104.9.125.144", undefined, undefined, 1000 ) + new ProfilingQuestionsApi().getProfilingQuestionsProductIdProfilingQuestionsGet(app.bpid, app.bpuid, "104.9.125.144", undefined, undefined, 2500 ) .then(res => { dispatch(setQuestions(res.data.questions)) }) diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 12a60aa..34fa5a8 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -10,10 +10,15 @@ import { } from "@/components/ui/sidebar" import {setPage} from "@/models/appSlice.ts"; import {useAppDispatch} from "@/hooks.ts"; +import {useSelector} from "react-redux"; +import {selectQuestions} from "@/models/questionSlice.ts"; +import {Badge} from "@/components/ui/badge" export function NavMain() { const dispatch = useAppDispatch() + const questions = useSelector(selectQuestions) + return ( @@ -34,7 +39,13 @@ export function NavMain() { > - Questions + + Questions {questions.length.toLocaleString()} + diff --git a/src/models/answerSlice.ts b/src/models/answerSlice.ts index aada48c..cd20dbc 100644 --- a/src/models/answerSlice.ts +++ b/src/models/answerSlice.ts @@ -1,4 +1,6 @@ import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit' +import {Selector} from 'react-redux' + // import {Answer} from "@/models/answer.ts"; // import {stringify} from "querystring"; import {RootState} from '@/store'; // your root state type @@ -183,24 +185,13 @@ export const { } = answerSlice.actions; export default answerSlice.reducer -export const answerForQuestion = (state: RootState, question: ProfileQuestion) => state.answers[question.question_id] ?? { - values: [], - error_msg: "", - complete: false, - processing: false -} as Answer; -export const makeSelectChoicesByQuestion = (question: ProfileQuestion) => +export const selectAnswerForQuestion = ( + question: ProfileQuestion +): Selector => createSelector( (state: RootState) => state.answers, (answers) => { - // const question = questions.find(q => q.id === questionId); - // return question?.choices ?? []; - return answers[question.question_id] ?? { - values: [], - error_msg: "", - complete: false, - processing: false - } as Answer; + return answers[question.question_id] || null } ); \ No newline at end of file diff --git a/src/models/questionSlice.ts b/src/models/questionSlice.ts index ec9f84a..9543088 100644 --- a/src/models/questionSlice.ts +++ b/src/models/questionSlice.ts @@ -2,6 +2,8 @@ import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit' import type {RootState} from '@/store' import {UpkQuestion} from "@/api"; import {Answer} from "@/models/answerSlice.ts"; +import {assert} from "@/lib/utils.ts" +import {Selector} from "react-redux"; export interface ProfileQuestion extends UpkQuestion { active: false @@ -59,11 +61,18 @@ export const selectQuestions = (state: RootState) => state.questions export const selectActiveQuestion = (state: RootState) => state.questions.find(i => i.active) export const selectAnswers = (state: RootState) => state.answers -export const selectNextAvailableQuestion = createSelector( +export const selectFirstAvailableQuestion = createSelector( [selectQuestions, selectAnswers], - (questions, answers) => { + (questions, answers): Selector => { + /* This is used when the app loads up the Questions page and we + need to find the first Question that we'll present to + the Respondent. + + If there are any questions marked as active, show that + first. However, if there are not.. go ahead and search for + the next Question without an Answer or an Answer that isn't complete + */ - // -- Check if there are any questions marked as active const active = questions.find(q => q.active) if (active) { return active @@ -71,23 +80,44 @@ export const selectNextAvailableQuestion = createSelector( let res = questions.filter(q => { const a: Answer | undefined = answers[q.question_id] - return !a || a.complete === false + return !a || !a.complete }) - // return res.reduce((min, q) => - // !min || q.order < min.order ? q : min, - // null as typeof res[0] | null - // ) return res[0] || null } ) +export const selectNextAvailableQuestion = createSelector( + [selectQuestions, selectAnswers], + (questions, answers): Selector => { + /* This takes the current active position and finds the next available + question to answer. -export const selectQuestionById = (questionId: string) => - createSelector( - (state: RootState) => state.questions, - (questions) => { - return questions.find(q => q.question_id === questionId); + Check if there are any questions marked as active. If there are not, + the Questions page didn't load yet and/or we don't know what the Respondent + is currently looking at... so we can't determine what is next. Immediately fail. + */ + const active = questions.find(q => q.active) + assert(active, "Must have an active Question") + const active_index = questions.findIndex(q => q.question_id === active.question_id) + + // Find any Questions without Answers, or Answers that are not complete + // that are positioned after the currently active Question + let found = questions.find((q, q_idx) => { + const a: Answer | undefined = answers[q.question_id] + return q_idx > active_index && (!a || !a.complete) + }) + + if (!found) { + // No eligible questions were found after the current active position, so + // go back and look for any before the current active position. + found = questions.find((q, q_idx) => { + const a: Answer | undefined = answers[q.question_id] + return q_idx < active_index && (!a || !a.complete) + }) } - ); \ No newline at end of file + + return found || null + } +) diff --git a/src/pages/Questions.tsx b/src/pages/Questions.tsx index 1056144..3cd6f71 100644 --- a/src/pages/Questions.tsx +++ b/src/pages/Questions.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost, ProfilingQuestionsApiFactory, @@ -7,7 +7,7 @@ import { } from "@/api"; import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {useAppDispatch, useAppSelector} from "@/hooks.ts"; -import {addAnswer, Answer, makeSelectChoicesByQuestion, saveAnswer, submitAnswer} from "@/models/answerSlice.ts"; +import {addAnswer, Answer, saveAnswer, selectAnswerForQuestion, submitAnswer} from "@/models/answerSlice.ts"; import {useSelector} from "react-redux"; import {Button} from "@/components/ui/button" import { @@ -22,18 +22,21 @@ import {Badge} from "@/components/ui/badge" import clsx from "clsx" import { ProfileQuestion, + selectFirstAvailableQuestion, selectNextAvailableQuestion, selectQuestions, setNextQuestion, setQuestionActive } from "@/models/questionSlice.ts"; import {assert} from "@/lib/utils.ts"; +import {motion} from "framer-motion" const TextEntry: React.FC<{ question: ProfileQuestion }> = ({question}) => { const dispatch = useAppDispatch() - const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); - const answer: Answer = useSelector(selectAnswer); - const error: Boolean = answer.error_msg.length > 0 + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const selectAnswer = useSelector(selectAnswerForQuestion(question)); + const answer: Answer | undefined = useSelector(selectAnswerForQuestion(question)); + const error: Boolean = answer?.error_msg.length > 0 const handleInputChange = (event: React.ChangeEvent) => { dispatch(addAnswer({question: question, val: event.target.value})) @@ -44,14 +47,14 @@ const TextEntry: React.FC<{ question: ProfileQuestion }> = ({question}) => { { - error &&

{answer.error_msg}

+ error &&

{answer?.error_msg}

} @@ -60,9 +63,11 @@ const TextEntry: React.FC<{ question: ProfileQuestion }> = ({question}) => { const MultiChoiceItem: React.FC<{ question: ProfileQuestion, choice: UpkQuestionChoice }> = ({question, choice}) => { const dispatch = useAppDispatch() - const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); - const answer: Answer = useSelector(selectAnswer); - const selected: Boolean = answer.values.includes(choice.choice_id) + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const answer: Answer = useSelector(selectAnswer); + const answer: Answer | undefined = useSelector(selectAnswerForQuestion(question)); + + const selected: Boolean = (answer?.values || []).includes(choice.choice_id) return (
  • @@ -78,14 +83,16 @@ const MultiChoiceItem: React.FC<{ question: ProfileQuestion, choice: UpkQuestion } const MultipleChoice: React.FC<{ question: ProfileQuestion }> = ({question}) => { - const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); - const answer: Answer = useSelector(selectAnswer); - const error: Boolean = answer.error_msg.length > 0 + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const answer: Answer = useSelector(selectAnswer); + + const answer: Answer | undefined = useSelector(selectAnswerForQuestion(question)); + const error: Boolean = answer?.error_msg.length > 0 return ( <> { - error &&

    {answer.error_msg}

    + error &&

    {answer?.error_msg}

    }
      @@ -109,13 +116,14 @@ const ProfileQuestionFull: React.FC<{ const dispatch = useAppDispatch() - const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]); - const answer: Answer = useSelector(selectAnswer); + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const answer: Answer = useSelector(selectAnswer); + const answer: Answer | undefined = useSelector(selectAnswerForQuestion(question)); const app = useAppSelector(state => state.app) - const provided_answer = answer.values.length > 0 - const error: Boolean = answer.error_msg.length > 0 - const can_submit = provided_answer && !error && !answer.complete + const provided_answer = answer?.values.length > 0 + const error: Boolean = answer?.error_msg.length > 0 + const can_submit = provided_answer && !error && !answer?.complete const renderContent = () => { switch (question.question_type) { @@ -129,9 +137,9 @@ const ProfileQuestionFull: React.FC<{ const submitAnswerEvt = () => { dispatch(submitAnswer({question: question})) - assert(!answer.complete, "Can't submit completed Answer") - assert(!answer.processing, "Can't submit processing Answer") - assert(answer.error_msg.length == 0, "Can't submit Answer with error message") + assert(!answer?.complete, "Can't submit completed Answer") + assert(!answer?.processing, "Can't submit processing Answer") + assert(answer?.error_msg.length == 0, "Can't submit Answer with error message") let body: BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost = { 'answers': [{ @@ -153,7 +161,17 @@ const ProfileQuestionFull: React.FC<{ } return ( - + + {answer && answer.processing && ( + setLoading(false)} + /> + )} + { const dispatch = useAppDispatch() const questions = useSelector(selectQuestions) - const question = useSelector(selectNextAvailableQuestion) - dispatch(setQuestionActive(question)) + const question = useSelector(selectFirstAvailableQuestion) + dispatch(setQuestionActive(question as ProfileQuestion)) + + // This is saved now, so that if they click next it's ready. It + // cannot be done within the click handler. + const nextQuestion = useSelector(selectNextAvailableQuestion) const clickNext = () => { // TODO: if nextQuestion was already submitted, skip it! + if (nextQuestion) { + // TS is not smart enough to know that the if statement above + // prevents this from ever being null + dispatch(setQuestionActive(nextQuestion as ProfileQuestion)) + } else { + // What do we do now... no more questions left to do. + } + } - const index = questions.findIndex(q => q.question_id === question.question_id) - const nextQuestion = index !== -1 ? questions[index + 1] ?? null : null - dispatch(setQuestionActive(nextQuestion)) + // All the variables needed for a sliding window + const q_idx = questions.findIndex(q => q.question_id === question.question_id) + const questionsWindow = ( + items: ProfileQuestion[], currentIndex: number, windowSize: number = 7 + ): ProfileQuestion[] => { + const half: number = Math.floor(windowSize / 2) + const total: number = items.length + let start: number = currentIndex - half + let end: number = currentIndex + half + 1 + + if (start < 0) { + end += Math.abs(start) + start = 0 + } + + // Adjust if window goes past the end + if (end > total) { + const overflow: number = end - total + start = Math.max(0, start - overflow) + end = total + } + + return items.slice(start, end) } return ( -
      -

      - A total of {questions.length} questions are available. -

      - + <> { - questions.slice(0, 5).map((q, i) => { + questionsWindow(questions, q_idx).map(q => { return qq.question_id === q.question_id)} /> }) } @@ -265,7 +312,7 @@ const QuestionsPage = () => { key={question.question_id} question={question} className="mt-4 mb-4"/> -
      + ) } -- cgit v1.2.3