aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Widget.tsx2
-rw-r--r--src/components/nav-main.tsx13
-rw-r--r--src/models/answerSlice.ts21
-rw-r--r--src/models/questionSlice.ts58
-rw-r--r--src/pages/Questions.tsx121
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>
+ </>
)
}