Skip to content

Commit

Permalink
feat: quiz create form now completed
Browse files Browse the repository at this point in the history
  • Loading branch information
MarcoEscaleira committed Feb 26, 2024
1 parent dc15d41 commit e8164f8
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 31 deletions.
6 changes: 4 additions & 2 deletions src/components/DifficultyChip/DifficultyChip.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { Chip } from "@material-tailwind/react";
import { size } from "@material-tailwind/react/types/components/chip";
import { colors } from "@material-tailwind/react/types/generic";
import { Difficulty } from "@generated/graphql.ts";

interface DifficultyChipProps {
// Difficulty of the quiz, if not provided then its Unknown
difficulty?: Difficulty | null;
size?: size;
}

export function DifficultyChip({ difficulty }: DifficultyChipProps) {
export function DifficultyChip({ difficulty, size = "md" }: DifficultyChipProps) {
const color = {
[Difficulty.Easy]: "green",
[Difficulty.Medium]: "yellow",
[Difficulty.Hard]: "red",
[Difficulty.Unknown]: "gray",
}[difficulty || Difficulty.Unknown];

return <Chip color={color as colors} value={difficulty} />;
return <Chip color={color as colors} value={difficulty} size={size} />;
}
16 changes: 16 additions & 0 deletions src/components/QuestionTypeChip/QuestionTypeChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Chip } from "@material-tailwind/react";
import { colors } from "@material-tailwind/react/types/generic";
import { QuestionType } from "@generated/graphql.ts";

interface QuestionTypeChipProps {
questionType: QuestionType;
}

export function QuestionTypeChip({ questionType }: QuestionTypeChipProps) {
const color = {
[QuestionType.Single]: "green",
[QuestionType.Multi]: "blue",
}[questionType || QuestionType.Single];

return <Chip color={color as colors} value={questionType} size='sm' />;
}
16 changes: 12 additions & 4 deletions src/components/Quiz/CreateQuizDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { Roles } from "@generated/graphql.ts";
import { useUserStore } from "@state/userStore.ts";
import { QuizForm } from "./QuizForm.tsx";

interface CreateQuizDialogProps {}
interface CreateQuizDialogProps {
simpleButton?: boolean;
}

export function CreateQuizDialog({}: CreateQuizDialogProps) {
export function CreateQuizDialog({ simpleButton }: CreateQuizDialogProps) {
const {
user: { role },
} = useUserStore();
Expand All @@ -19,7 +21,13 @@ export function CreateQuizDialog({}: CreateQuizDialogProps) {

return (
<>
<Button variant="outlined" size="md" onClick={toggleDialog} className="my-10">
<Button
variant={simpleButton ? "text" : "gradient"}
color={simpleButton ? "blue-gray" : "green"}
size="md"
onClick={toggleDialog}
className={simpleButton ? "px-1" : "my-10"}
>
{title}
</Button>

Expand All @@ -30,7 +38,7 @@ export function CreateQuizDialog({}: CreateQuizDialogProps) {
<X className="size-5" />
</IconButton>
</DialogHeader>
<DialogBody>
<DialogBody className="h-[42rem] overflow-scroll">
<QuizForm />
</DialogBody>
</Dialog>
Expand Down
57 changes: 57 additions & 0 deletions src/components/Quiz/OptionsFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Checkbox, IconButton, Input, Typography } from "@material-tailwind/react";
import { Plus, X } from "lucide-react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { z } from "zod";
import { quizFormSchema } from "./quizFormSchema";

interface OptionsFieldsProps {
questionIndex: number;
}

export function OptionsFields({ questionIndex }: OptionsFieldsProps) {
const {
control,
register,
formState: { errors },
} = useFormContext<z.infer<typeof quizFormSchema>>();
const { fields, append, remove } = useFieldArray({
control,
name: `questions.${questionIndex}.options`,
});

return (
<div className="flex flex-col gap-3 px-2 py-2 md:px-4">
<div className="flex items-center justify-between">
<Typography>Options: {fields.length}</Typography>
<IconButton onClick={() => append({ text: "", correct: false })} color="green" size="sm">
<Plus className="size-5" />
</IconButton>
</div>

{fields.map((field, optionIndex) => (
<div key={field.id} className="mb-4">
<div className="mb-2 flex items-center justify-between">
<Typography>Option #{optionIndex + 1}</Typography>
<IconButton onClick={() => remove(optionIndex)} variant="text" size="sm">
<X className="size-4 stroke-red-500" />
</IconButton>
</div>
<div className="flex items-center gap-3">
<Input
{...register(`questions.${questionIndex}.options.${optionIndex}.text`)}
size="md"
label="Text"
placeholder=""
error={!!errors.questions?.[questionIndex]?.options?.[optionIndex]?.text}
/>
<Checkbox
{...register(`questions.${questionIndex}.options.${optionIndex}.correct`)}
color="green"
label="Correct"
/>
</div>
</div>
))}
</div>
);
}
81 changes: 81 additions & 0 deletions src/components/Quiz/QuestionsFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { cloneElement } from "react";
import { IconButton, Input, Option, Select, Typography } from "@material-tailwind/react";
import { Plus, X } from "lucide-react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { z } from "zod";
import { QuestionTypeChip } from "@components/QuestionTypeChip/QuestionTypeChip.tsx";
import { QuestionType } from "@generated/graphql.ts";
import { OptionsFields } from "./OptionsFields";
import { quizFormSchema } from "./quizFormSchema";

export function QuestionFields() {
const {
control,
register,
getValues,
setValue,
formState: { errors },
} = useFormContext<z.infer<typeof quizFormSchema>>();
const { fields, append, remove } = useFieldArray({
control,
name: "questions",
});

return (
<>
<div className="flex items-center justify-between">
<Typography>Questions: {fields.length}</Typography>
<IconButton
onClick={() => append({ question: "", type: QuestionType.Single, options: [{ text: "", correct: false }] })}
color="green"
size="sm"
>
<Plus className="size-5" />
</IconButton>
</div>

{fields.map((field, questionIndex) => (
<div key={field.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Typography>Question #{questionIndex + 1}</Typography>
<IconButton onClick={() => remove(questionIndex)} variant="text" size="sm">
<X className="size-4 stroke-red-500" />
</IconButton>
</div>

<Input
{...register(`questions.${questionIndex}.question`)}
size="md"
label="Title"
placeholder=""
error={!!errors.questions?.[questionIndex]?.question}
/>
<Select
label="Type"
value={getValues(`questions.${questionIndex}.type`)}
onChange={value =>
setValue(`questions.${questionIndex}.type`, (value as QuestionType) || QuestionType.Single)
}
selected={element =>
element &&
cloneElement(element, {
disabled: true,
className: "flex items-center opacity-100 px-0 gap-2 pointer-events-none",
})
}
>
{Object.keys(QuestionType).map(type => (
<Option key={`questions-${questionIndex}-${type}`} value={type} className="flex items-center gap-2">
<QuestionTypeChip questionType={QuestionType[type]} />
</Option>
))}
</Select>

<OptionsFields questionIndex={questionIndex} />

<hr className="border-secondaryShades.5 mt-2 w-full" />
</div>
))}
</>
);
}
51 changes: 30 additions & 21 deletions src/components/Quiz/QuizForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,12 @@ import { toast } from "react-toastify";
import { useCountries } from "use-react-countries";
import { z } from "zod";
import { DifficultyChip } from "@components/DifficultyChip/DifficultyChip.tsx";
import { Difficulty, Roles } from "@generated/graphql.ts";
import { QuestionFields } from "@components/Quiz/QuestionsFields.tsx";
import { quizFormSchema } from "@components/Quiz/quizFormSchema.ts";
import { Difficulty, QuestionType, Roles } from "@generated/graphql.ts";
import { useUserStore } from "@state/userStore.ts";
import { CREATE_QUIZ } from "@utils/queries/CreateQuiz.ts";

const formSchema = z.object({
title: z.string().min(5, { message: "Enter a title." }),
description: z.string().min(50, { message: "Enter a description." }),
country: z.string().min(1, { message: "Enter a country." }),
image: z.union([z.literal(""), z.string().trim().url()]),
// questions: z.array()
difficulty: z.nativeEnum(Difficulty),
timeLimit: z.number().nonnegative().optional(),
// tags: z.array()
});
export function QuizForm() {
const navigate = useNavigate();
const { countries } = useCountries();
Expand All @@ -30,22 +22,24 @@ export function QuizForm() {
user: { role },
} = useUserStore();

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof quizFormSchema>>({
resolver: zodResolver(quizFormSchema),
defaultValues: {
title: "",
description: "",
country: "",
image: "",
// questions: [],
questions: [{ question: "", type: QuestionType.Single, options: [{ text: "", correct: false }] }],
difficulty: Difficulty.Unknown,
timeLimit: 0,
tags: [],
},
});
const {
register,
handleSubmit,
reset,
getValues,
formState: { errors },
} = form;

Expand All @@ -63,15 +57,13 @@ export function QuizForm() {
},
});

const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = async (values, event) => {
const onSubmit: SubmitHandler<z.infer<typeof quizFormSchema>> = async (values, event) => {
event?.preventDefault();
try {
await createQuizMutation({
variables: {
quiz: {
...values,
questions: [],
tags: [],
},
},
});
Expand All @@ -82,7 +74,7 @@ export function QuizForm() {

return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4 overflow-y-auto">
<Input {...register("title")} size="lg" label="Title" placeholder="A great quiz title" error={!!errors.title} />

<Textarea
Expand Down Expand Up @@ -116,11 +108,14 @@ export function QuizForm() {

<Input {...register("image")} size="lg" label="Image URL" placeholder="" error={!!errors.image} />

<QuestionFields />

<div className="flex flex-col items-center gap-6 md:flex-row md:gap-3">
<Select
size="lg"
label="Difficulty"
onChange={value => form.setValue("difficulty", value || Difficulty.Unknown)}
value={getValues("difficulty")}
onChange={value => form.setValue("difficulty", (value as Difficulty) || Difficulty.Unknown)}
selected={element =>
element &&
cloneElement(element, {
Expand All @@ -132,7 +127,7 @@ export function QuizForm() {
>
{Object.keys(Difficulty).map(difficulty => (
<Option key={difficulty} value={difficulty} className="flex items-center gap-2">
<DifficultyChip difficulty={Difficulty[difficulty]} />
<DifficultyChip difficulty={Difficulty[difficulty]} size="sm" />
</Option>
))}
</Select>
Expand All @@ -152,7 +147,21 @@ export function QuizForm() {
</Typography>
)}

<Button type="submit" fullWidth disabled={isLoadingQuizCreation} loading={isLoadingQuizCreation}>
{/* @ts-expect-error: not sure how to grab this type from Zod superRefine */}
{errors?.correctOption && (
<Typography variant="small" color="red">
{/* @ts-expect-error: not sure how to grab this type from Zod superRefine */}
{errors.correctOption.message}
</Typography>
)}

<Button
type="submit"
fullWidth
disabled={isLoadingQuizCreation}
loading={isLoadingQuizCreation}
className="mt-4"
>
Create Quiz
</Button>
</form>
Expand Down
38 changes: 38 additions & 0 deletions src/components/Quiz/quizFormSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from "zod";
import { Difficulty, QuestionType } from "@generated/graphql.ts";

export const quizFormSchema = z
.object({
title: z.string().min(5, { message: "Enter a title." }),
description: z.string().min(50, { message: "Enter a description." }),
country: z.string().min(1, { message: "Enter a country." }),
image: z.union([z.literal(""), z.string().trim().url()]),
questions: z.array(
z.object({
question: z.string().min(15, { message: "Enter a question." }),
type: z.nativeEnum(QuestionType),
options: z.array(
z.object({
text: z.string().min(1, { message: "Enter an option." }),
correct: z.boolean(),
})
),
})
),
difficulty: z.nativeEnum(Difficulty),
timeLimit: z.number().nonnegative().optional(),
tags: z.string().array().optional(),
})
.superRefine(({ questions }, ctx) => {
const hasAllQuestionsHaveAtLeastOneCorrectOption = questions.every(question => {
return question.options.some(option => option.correct);
});

if (!hasAllQuestionsHaveAtLeastOneCorrectOption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Each question must at least have one correct option.",
path: ["correctOption"],
});
}
});
6 changes: 2 additions & 4 deletions src/pages/Game/CountryQuizList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,8 @@ export const CountryQuizList = ({ quizList, isLoadingCountryQuizList }: CountryQ
<>
{headerContent} <Typography className="mt-6">No quizzes found for this country 🥹</Typography>
<div className="mt-4 flex items-center">
<Typography>Please try again later or&nbsp;</Typography>
<Button variant="text" className="px-1">
create your quiz
</Button>
<Typography className="pl-1">Please try again later or&nbsp;</Typography>
<CreateQuizDialog simpleButton />
</div>
</>
);
Expand Down

0 comments on commit e8164f8

Please sign in to comment.