diff options
| -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> + </> ) } |
