diff options
| author | Max Nanis | 2025-06-06 22:40:41 +0700 |
|---|---|---|
| committer | Max Nanis | 2025-06-06 22:40:41 +0700 |
| commit | 257bc2f85b71a8564e95a8e6ba39ab0b00e022df (patch) | |
| tree | c44ced82b5f5f99e7e7bb9cffcd444a41b8a648c /src | |
| parent | 696dee6a6a9506fcf771d0ec4911dcc82a279fda (diff) | |
| download | panel-ui-257bc2f85b71a8564e95a8e6ba39ab0b00e022df.tar.gz panel-ui-257bc2f85b71a8564e95a8e6ba39ab0b00e022df.zip | |
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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/Widget.tsx | 2 | ||||
| -rw-r--r-- | src/components/nav-main.tsx | 13 | ||||
| -rw-r--r-- | src/models/answerSlice.ts | 21 | ||||
| -rw-r--r-- | src/models/questionSlice.ts | 58 | ||||
| -rw-r--r-- | 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 ( <SidebarGroup> <SidebarGroupContent className="flex flex-col gap-2"> @@ -34,7 +39,13 @@ export function NavMain() { > <SidebarMenuButton tooltip="Questions"> <ListIcon/> - <span>Questions</span> + <span> + Questions <Badge + className="absolute top-2 right-2 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer" + variant="outline" + title={`${questions.length.toLocaleString()} profiling question available`} + >{questions.length.toLocaleString()}</Badge> + </span> </SidebarMenuButton> </SidebarMenuItem> 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<RootState, Answer | null> => 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<RootState, ProfileQuestion | null> => { + /* 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<RootState, ProfileQuestion | null> => { + /* 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<HTMLInputElement>) => { dispatch(addAnswer({question: question, val: event.target.value})) @@ -44,14 +47,14 @@ const TextEntry: React.FC<{ question: ProfileQuestion }> = ({question}) => { <Input type="text" id="text-entry-input" aria-describedby="" - defaultValue={answer.values.length ? answer.values[0] : ""} + defaultValue={answer?.values.length ? answer?.values[0] : ""} onKeyUp={handleInputChange} - title={error ? answer.error_msg : ""} + title={error ? answer?.error_msg : ""} className={error ? "border-red-500 focus-visible:ring-red-500" : ""} /> { - error && <p className="text-sm text-red-500 mt-1">{answer.error_msg}</p> + error && <p className="text-sm text-red-500 mt-1">{answer?.error_msg}</p> } </> @@ -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 ( <li key={choice.choice_id} style={{marginBottom: '0.5rem'}}> @@ -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 && <p className="text-sm text-red-500 mt-1">{answer.error_msg}</p> + error && <p className="text-sm text-red-500 mt-1">{answer?.error_msg}</p> } <ol style={{listStyle: 'none', padding: 0, margin: 0}}> @@ -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 ( - <Card className="@container/card relative"> + <Card className="@container/card relative overflow-hidden"> + {answer && answer.processing && ( + <motion.div + className="absolute top-0 left-0 h-0.5 bg-gray-300" + initial={{width: "0%"}} + animate={{width: "100%"}} + transition={{duration: 1, ease: "easeInOut"}} + // onAnimationComplete={() => setLoading(false)} + /> + )} + <Badge className="absolute top-2 right-2 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer" variant="outline" @@ -219,35 +237,64 @@ const PaginationIcon: React.FC<{ ) } + const QuestionsPage = () => { 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 ( - <div> - <p> - A total of {questions.length} questions are available. - </p> - + <> <Pagination className="mt-4 mb-4"> <PaginationContent> { - questions.slice(0, 5).map((q, i) => { + questionsWindow(questions, q_idx).map(q => { return <PaginationIcon key={q.question_id} question={q} - idx={i} + idx={questions.findIndex(qq => qq.question_id === q.question_id)} /> }) } @@ -265,7 +312,7 @@ const QuestionsPage = () => { key={question.question_id} question={question} className="mt-4 mb-4"/> - </div> + </> ) } |
