aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/models/answerSlice.ts118
-rw-r--r--src/pages/Questions.tsx162
2 files changed, 158 insertions, 122 deletions
diff --git a/src/models/answerSlice.ts b/src/models/answerSlice.ts
index f271288..e0f9931 100644
--- a/src/models/answerSlice.ts
+++ b/src/models/answerSlice.ts
@@ -4,7 +4,7 @@ import {createSelector, createSlice, PayloadAction} from '@reduxjs/toolkit'
import {RootState} from '@/store'; // your root state type
import {PatternValidation, UpkQuestion} from "@/api";
-interface Answer {
+export interface Answer {
values: string[];
error_msg: string;
@@ -23,8 +23,17 @@ const answerSlice = createSlice({
name: 'answers',
initialState,
reducers: {
-
addAnswer(state, action: PayloadAction<{ question: UpkQuestion, val: string }>) {
+ /* If the question is MC, validate:
+ - validate selector SA vs MA (1 selected vs >1 selected)
+ - the answers match actual codes in the choices
+ - validate configuration.max_select
+ - validate choices.exclusive
+
+ If the question is TE, validate that:
+ - configuration.max_length
+ - validation.patterns
+ */
let question: UpkQuestion = action.payload.question;
let val: string = action.payload.val.trim();
let answer: Answer = state[question.question_id] ?? {
@@ -34,17 +43,7 @@ const answerSlice = createSlice({
processing: false
} as Answer;
- /*
- If the question is MC, validate:
- - validate selector SA vs MA (1 selected vs >1 selected)
- - the answers match actual codes in the choices
- - validate configuration.max_select
- - validate choices.exclusive
-
- If the question is TE, validate that:
- - configuration.max_length
- - validation.patterns
- */
+ answer.error_msg = "" // Reset any error messages
switch (question.question_type) {
case "TE":
@@ -78,26 +77,22 @@ const answerSlice = createSlice({
}
})
- answer.error_msg = ""
break;
case "MC":
- switch (question.selector) {
- case "SA": // Single Answer
+ if (answer.values.includes(val)) {
+ // The item has already been selected
+ answer.values = answer.values.filter(value => value !== val);
+
+ } else {
+ // It's a new selection
+ if (question.selector == "SA") {
answer.values = [val]
- break
- case "MA": /// Multi Answer
- if (answer.values.includes(val)) {
- // The item has already been selected
- answer.values = answer.values.filter(value => value !== val);
- } else {
- // It's a new selection
- answer.values.push(val);
- }
- break
+ } else if (question.selector == "MA") {
+ answer.values.push(val);
+ }
}
-
if (answer.values.length == 0) {
answer.error_msg = "MC question with no selected answers"
}
@@ -145,62 +140,29 @@ const answerSlice = createSlice({
state[question.question_id] = answer
},
- // removeAnswer(val: string): null {
- // switch (this.getType()) {
- // // You can only remove a value from a MultiChoice
- // case "MC":
- // // TODO: implement this
- // // let current_values: string[] = this._answer?.values
- // // current_values.push(val)
- // // this._answer = new ProfilingAnswer(this.questionId, current_values);
- // break
- // default:
- // throw new Error("Incorrect Question Type provided");
- // }
- // this.validate()
- // }
-
- // save() {
- // let question: ProfilingQuestion = this;
- // // @ts-ignore
- // let answer: ProfilingAnswer = question._answer;
- //
- // if (this._complete || this._processing) {
- // return
- // }
- // this._processing = true
- //
- // let res = JSON.stringify({
- // "answers": [{
- // "question_id": answer.get('question_id'),
- // "answer": map(answer.get("values"), "value")
- // }]
- // });
- //
- // $.ajax({
- // url: ["https://fsb.generalresearch.com", questions.BPID, "profiling-questions", ""].join("/") + "?" + stringify({"bpuid": questions.BPUID}),
- // xhrFields: {withCredentials: false},
- // processData: false,
- // type: "POST",
- // contentType: "application/json; charset=utf-8",
- // data: res,
- // success: function (data) {
- // channel.trigger("ProfilingQuestions:start");
- // },
- // error: function (data) {
- // channel.trigger("ProfilingQuestions:start");
- // Sentry.captureMessage("Profiling Question submission failed.");
- // }
- // });
- // }
-
-
+ saveAnswer(state, action: PayloadAction<{ question: UpkQuestion }>) {
+ let question: UpkQuestion = action.payload.question;
+ let answer: Answer = state[question.question_id]
+
+ state[question.question_id] = {
+ 'values': answer.values,
+ 'error_msg': "",
+ 'processing': false,
+ 'complete': false
+ } as Answer
+ }
}
})
-export const {addAnswer, setAnswer, setQuestions, questionAdded, questionUpdated} = answerSlice.actions;
+export const {addAnswer, saveAnswer} = answerSlice.actions;
export default answerSlice.reducer
+export const answerForQuestion = (state: RootState, question: UpkQuestion) => state.answers[question.question_id] ?? {
+ values: [],
+ error_msg: "",
+ complete: false,
+ processing: false
+} as Answer;
export const makeSelectChoicesByQuestion = (question: UpkQuestion) =>
createSelector(
diff --git a/src/pages/Questions.tsx b/src/pages/Questions.tsx
index a6f0d7b..e9abe31 100644
--- a/src/pages/Questions.tsx
+++ b/src/pages/Questions.tsx
@@ -1,8 +1,14 @@
import React, {useMemo, useState} from 'react'
-import {UpkQuestion, UpkQuestionChoice} from "@/api";
-import {Card, CardContent, CardTitle, CardFooter, CardHeader} from "@/components/ui/card.tsx";
+import {
+ BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost,
+ ProfilingQuestionsApiFactory,
+ UpkQuestion,
+ UpkQuestionChoice,
+ UserQuestionAnswerIn
+} from "@/api";
+import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {useAppDispatch, useAppSelector} from "@/hooks.ts";
-import {addAnswer, makeSelectChoicesByQuestion} from "@/models/answerSlice.ts";
+import answerSlice, {addAnswer, Answer,answerForQuestion, makeSelectChoicesByQuestion, saveAnswer} from "@/models/answerSlice.ts";
import {useSelector} from "react-redux";
import {Button} from "@/components/ui/button"
import {
@@ -12,41 +18,49 @@ import {
PaginationLink,
PaginationNext,
} from "@/components/ui/pagination"
-import { Input } from "@/components/ui/input"
+import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
const TextEntry: React.FC<{ question: UpkQuestion }> = ({question}) => {
const dispatch = useAppDispatch()
const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
- const answer = useSelector(selectAnswer);
+ const answer: Answer = useSelector(selectAnswer);
+ const error: Boolean = answer.error_msg.length > 0
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
dispatch(addAnswer({question: question, val: event.target.value}))
};
- // console.log("TextEntry.answer", answer)
-
return (
- <Input type="text"
- id="text-entry-input"
- aria-describedby=""
- placeholder=""
- onKeyDown={handleInputChange}
- />
- // <small id="text-entry-msg">{answer.error_msg}</small>
+ <>
+ <Input type="text"
+ id="text-entry-input"
+ aria-describedby=""
+ defaultValue={answer.values.length ? answer.values[0] : ""}
+ onKeyUp={handleInputChange}
+ 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>
+ }
+ </>
+
)
}
const MultiChoiceItem: React.FC<{ question: UpkQuestion, choice: UpkQuestionChoice }> = ({question, choice}) => {
const dispatch = useAppDispatch()
const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
- const answer = useSelector(selectAnswer);
+ const answer: Answer = useSelector(selectAnswer);
const selected: Boolean = answer.values.includes(choice.choice_id)
return (
<li key={choice.choice_id} style={{marginBottom: '0.5rem'}}>
<Button
onClick={() => dispatch(addAnswer({question: question, val: choice.choice_id}))}
+ className="cursor-pointer"
variant={selected ? "default" : "secondary"}
>
{choice.choice_text}
@@ -56,27 +70,40 @@ const MultiChoiceItem: React.FC<{ question: UpkQuestion, choice: UpkQuestionChoi
}
const MultipleChoice: React.FC<{ question: UpkQuestion }> = ({question}) => {
- // const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
- // const answer = useSelector(selectAnswer);
+ const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
+ const answer: Answer = useSelector(selectAnswer);
+ const error: Boolean = answer.error_msg.length > 0
return (
- // <small id="text-entry-msg">{answer.error_msg}</small>
- <ol style={{listStyle: 'none', padding: 0, margin: 0}}>
+ <>
{
- question.choices.map(c => {
- return <MultiChoiceItem
- key={`${question.question_id}-${c.choice_id}`}
- question={question}
- choice={c}/>
- })
+ error && <p className="text-sm text-red-500 mt-1">{answer.error_msg}</p>
}
- </ol>
+
+ <ol style={{listStyle: 'none', padding: 0, margin: 0}}>
+ {
+ question.choices.map(c => {
+ return <MultiChoiceItem
+ key={`${question.question_id}-${c.choice_id}`}
+ question={question}
+ choice={c}/>
+ })
+ }
+ </ol>
+ </>
)
}
const ProfileQuestionFull: React.FC<UpkQuestion> = ({question}) => {
+ const dispatch = useAppDispatch()
+ const selectAnswer = useMemo(() => makeSelectChoicesByQuestion(question), [question]);
+ const answer: Answer = useSelector(selectAnswer);
+ 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 renderContent = () => {
switch (question.question_type) {
@@ -87,10 +114,38 @@ const ProfileQuestionFull: React.FC<UpkQuestion> = ({question}) => {
}
};
+ const submitAnswer = () => {
+ if (!can_submit) {
+ return;
+ }
+
+ if (answer.complete || answer.processing) {
+ return
+ }
+
+ let body: BodySubmitProfilingQuestionsProductIdProfilingQuestionsPost = {
+ 'answers': [{
+ "question_id": question.question_id,
+ "answer": answer.values
+ } as UserQuestionAnswerIn
+ ]
+ }
+ console.log("submitAnswers", body)
+ new ProfilingQuestionsApiFactory().submitProfilingQuestionsProductIdProfilingQuestionsPost(app.bpid, app.bpuid, body)
+ .then(res => {
+ if (res.status == 200) {
+ dispatch(saveAnswer({question: question}))
+ } else {
+ // let error_msg = res.data.msg
+ }
+ })
+ .catch(err => console.log(err));
+ }
+
return (
<Card className="@container/card relative">
<Badge
- className="absolute top-2 right-2 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums"
+ className="absolute top-2 right-2 h-5 min-w-5 rounded-full px-1 font-mono tabular-nums cursor-pointer"
variant="outline"
title={`Currently ${question.task_count.toLocaleString()} surveys use this profiling question`}
>
@@ -103,21 +158,51 @@ const ProfileQuestionFull: React.FC<UpkQuestion> = ({question}) => {
<CardContent>
{renderContent()}
</CardContent>
- <CardFooter className="flex-col gap-2">
- <Button type="submit" className="w-full">
+ <CardFooter className="flex justify-end">
+ <Button
+ type="submit"
+ className="w-1/3 cursor-pointer"
+ disabled={!can_submit}
+ onClick={submitAnswer}
+ >
Submit
</Button>
</CardFooter>
</Card>
)
}
+// type Props = {
+// onSetQuestionID: (name: string) => void
+// }
+
+const PaginationIcon: React.FC<{
+ question: UpkQuestion, activeQuestionID: string, idx: number, onSetQuestionID: () => void
+}> = ({question, activeQuestionID, idx, onSetQuestionID}) => {
+
+ const answers = useAppSelector(state => state.answers)
+ const answer = answers[question.question_id]
+
+ return (
+ <PaginationItem>
+ <PaginationLink
+ href="#"
+ title={question.question_text}
+ isActive={question.question_id === activeQuestionID}
+ aria-disabled={answer?.complete}
+
+ onClick={(e) => answer?.complete ? e.preventDefault() : onSetQuestionID(question.question_id)}
+ className={answer?.complete ? "pointer-events-none opacity-50 cursor-not-allowed" : ""}>
+ {idx + 1}
+ </PaginationLink>
+ </PaginationItem>
+ )
+}
const QuestionsPage = () => {
const questions = useAppSelector(state => state.questions)
- const [activeQuestionID, setQuestionID] = useState(() => questions[0].question_id);
+ const [activeQuestionID, setQuestionID] = useState(() => questions[0].question_id);
const question = questions.find(q => q.question_id === activeQuestionID);
- console.log("activeQuestionID:", activeQuestionID, question)
return (
<div>
@@ -129,18 +214,8 @@ const QuestionsPage = () => {
<PaginationContent>
{
questions.slice(0, 5).map((q, i) => {
- return (
- <PaginationItem>
- <PaginationLink
- href="#"
- title={q.question_text}
- onClick={() => setQuestionID(q.question_id)}
- isActive={q.question_id === activeQuestionID}
- >
- {i + 1}
- </PaginationLink>
- </PaginationItem>
- )
+ return <PaginationIcon key={q.question_id} question={q} idx={i}
+ onSetQuestionID={setQuestionID}/>
})
}
@@ -152,7 +227,6 @@ const QuestionsPage = () => {
</PaginationContent>
</Pagination>
-
<ProfileQuestionFull key={question.question_id} question={question} className="mt-4 mb-4"/>
</div>
)