Skip to content

Commit

Permalink
feat: allow quiz answer value to have string type (#365)
Browse files Browse the repository at this point in the history
  • Loading branch information
huyenltnguyen authored Oct 10, 2024
1 parent f23cd28 commit 4433d94
Show file tree
Hide file tree
Showing 10 changed files with 238 additions and 52 deletions.
7 changes: 4 additions & 3 deletions src/quiz-question/answer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";

import { QuizQuestionValidation, type QuizQuestionAnswer } from "./types";

interface AnswerProps extends QuizQuestionAnswer {
interface AnswerProps<AnswerT extends number | string>
extends QuizQuestionAnswer<AnswerT> {
checked?: boolean;
disabled?: boolean;
validation?: QuizQuestionValidation;
Expand Down Expand Up @@ -101,13 +102,13 @@ const ValidationMessage = ({ state, message }: QuizQuestionValidation) => {
);
};

export const Answer = ({
export const Answer = <AnswerT extends number | string>({
value,
label,
disabled,
checked,
validation,
}: AnswerProps) => {
}: AnswerProps<AnswerT>) => {
const getRadioWrapperCls = () => {
const cls = [...radioWrapperDefaultClasses];

Expand Down
6 changes: 3 additions & 3 deletions src/quiz-question/quiz-question.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ const story = {

type Story = StoryObj<typeof QuizQuestion>;

const QuizQuestionComp = ({
const QuizQuestionComp = <AnswerT extends number | string>({
question,
answers = [],
disabled,
validation,
position,
selectedAnswer,
}: Partial<QuizQuestionProps>) => {
}: Partial<QuizQuestionProps<AnswerT>>) => {
const [answer, setAnswer] =
useState<QuizQuestionProps["selectedAnswer"]>(selectedAnswer);
useState<QuizQuestionProps<AnswerT>["selectedAnswer"]>(selectedAnswer);

return (
<QuizQuestion
Expand Down
61 changes: 61 additions & 0 deletions src/quiz-question/quiz-question.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,64 @@ describe("<QuizQuestion />", () => {
).toBeInTheDocument();
});
});

// ------------------------------
// Type tests
// ------------------------------
// QuizQuestion without explicit type
<QuizQuestion
question="Lorem ipsum"
answers={[
{ label: "Option 1", value: "1" },
// @ts-expect-error - values must have the same type
{ label: "Option 2", value: 2 },
// @ts-expect-error - values must have the same type
{ label: "Option 3", value: 3 },
]}
/>;

// QuizQuestion with `number` type
<QuizQuestion<number>
question="Lorem ipsum"
answers={[
// @ts-expect-error - `value` type must be in accordance with the specified type
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
]}
/>;

// QuizQuestion with `string` type
<QuizQuestion<string>
question="Lorem ipsum"
answers={[
// @ts-expect-error - `value` type must be in accordance with the specified type
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
]}
/>;

// QuizQuestion with `value` as number and `selectedAnswer` as string
<QuizQuestion<number>
question="Lorem ipsum"
answers={[
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
]}
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
selectedAnswer="1"
/>;

// QuizQuestion with `value` as string and `selectedAnswer` as number
<QuizQuestion<string>
question="Lorem ipsum"
answers={[
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
]}
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
selectedAnswer={1}
/>;
8 changes: 5 additions & 3 deletions src/quiz-question/quiz-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const QuestionText = ({
* but instead, it provides a `selectedAnswer` and an `onChange` props,
* giving the parent component full control over the selection handling logic.
*/
export const QuizQuestion = ({
export const QuizQuestion = <AnswerT extends number | string>({
question,
answers,
required,
Expand All @@ -41,8 +41,10 @@ export const QuizQuestion = ({
selectedAnswer,
onChange,
position,
}: QuizQuestionProps) => {
const handleChange = (selectedOption: QuizQuestionAnswer["value"]) => {
}: QuizQuestionProps<AnswerT>) => {
const handleChange = (
selectedOption: QuizQuestionAnswer<AnswerT>["value"],
) => {
if (!onChange) {
return;
}
Expand Down
12 changes: 6 additions & 6 deletions src/quiz-question/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { ReactNode } from "react";

export interface QuizQuestionAnswer {
export interface QuizQuestionAnswer<T extends number | string> {
label: ReactNode;
value: number;
value: T;
}

export interface QuizQuestionValidation {
state: "correct" | "incorrect";
message: string;
}

export interface QuizQuestionProps {
export interface QuizQuestionProps<AnswerT extends number | string> {
/**
* Question text, can be plain text or contain code.
* If the question text contains code, use the PrismFormatted component to ensure the code is rendered correctly.
Expand All @@ -20,7 +20,7 @@ export interface QuizQuestionProps {
/**
* Answer options
*/
answers: QuizQuestionAnswer[];
answers: QuizQuestionAnswer<AnswerT>[];

/**
* Position of the question amongst its siblings
Expand All @@ -45,10 +45,10 @@ export interface QuizQuestionProps {
/**
* Value of the selected answer
*/
selectedAnswer?: QuizQuestionAnswer["value"];
selectedAnswer?: AnswerT;

/**
* Change event handler, called when an answer is selected
*/
onChange?: (selectedAnswer: QuizQuestionAnswer["value"]) => void;
onChange?: (selectedAnswer: AnswerT) => void;
}
4 changes: 2 additions & 2 deletions src/quiz/quiz.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const story = {
type Story = StoryObj<typeof Quiz>;

const QuizDefault = () => {
const initialQuestions: Question[] = [
const initialQuestions: Question<number>[] = [
{
question: "Lorem ipsum dolor sit amet",
answers: [
Expand Down Expand Up @@ -57,7 +57,7 @@ const QuizDefault = () => {
};

const QuizWithValidation = () => {
const initialQuestions: Question[] = [
const initialQuestions: Question<number>[] = [
{
question: "Lorem ipsum dolor sit amet",
answers: [
Expand Down
119 changes: 117 additions & 2 deletions src/quiz/quiz.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import React from "react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { Quiz, QuizProps } from "./quiz";
import { Quiz } from "./quiz";
import { type QuizProps } from "./types";
import { useQuiz } from "./use-quiz";

const ControlledQuiz = ({ disabled, required }: Partial<QuizProps>) => {
const ControlledQuiz = ({ disabled, required }: Partial<QuizProps<number>>) => {
const { questions } = useQuiz({
initialQuestions: [
{
Expand Down Expand Up @@ -122,3 +123,117 @@ describe("<Quiz />", () => {
});
});
});

// ------------------------------
// Type tests
// ------------------------------
// Quiz without explicit type
<Quiz
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
{ label: "Option 1", value: "1" },
// @ts-expect-error - values must have the same type
{ label: "Option 2", value: 2 },
// @ts-expect-error - values must have the same type
{ label: "Option 3", value: 3 },
],
},
]}
/>;

// Quiz with `number` type
<Quiz<number>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
// @ts-expect-error - `value` type must be in accordance with the specified type
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
],
correctAnswer: 1,
},
]}
/>;

// Quiz with `string` type
<Quiz<string>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
// @ts-expect-error - `value` type must be in accordance with the specified type
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
],
},
]}
/>;

// Quiz with `value` as number and `selectedAnswer` as string
<Quiz<number>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
],
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
selectedAnswer: "1",
},
]}
/>;

// Quiz with `value` as string and `selectedAnswer` as number
<Quiz<string>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
],
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
selectedAnswer: 1,
},
]}
/>;

// Quiz with `value` as number and `correctAnswer` as string
<Quiz<number>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
{ label: "Option 1", value: 1 },
{ label: "Option 2", value: 2 },
{ label: "Option 3", value: 3 },
],
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
correctAnswer: "1",
},
]}
/>;

// Quiz with `value` as string and `correctAnswer` as number
<Quiz<string>
questions={[
{
question: "Lorem ipsum dolor sit amet",
answers: [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
],
// @ts-expect-error - `value` and `selectedAnswer` must have the same type
correctAnswer: 1,
},
]}
/>;
14 changes: 6 additions & 8 deletions src/quiz/quiz.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import React from "react";

import { QuizQuestion } from "../quiz-question";
import { type Question } from "./types";
import { type QuizProps } from "./types";

export interface QuizProps {
questions: Question[];
disabled?: boolean;
required?: boolean;
}

export const Quiz = ({ questions, disabled, required }: QuizProps) => {
export const Quiz = <AnswerT extends number | string>({
questions,
disabled,
required,
}: QuizProps<AnswerT>) => {
return (
<ul className="flex flex-col gap-y-[24px]">
{questions.map((question, index) => (
Expand Down
18 changes: 12 additions & 6 deletions src/quiz/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,38 @@ import type {
QuizQuestionProps,
} from "../quiz-question";

export interface QuizProps<AnswerT extends number | string> {
questions: Question<AnswerT>[];
disabled?: boolean;
required?: boolean;
}

// This interface is a subset of QuizQuestionProps.
// The props are limited to ensure that
// their configurations don't collide with Quiz'.
// For example: Quiz should be able to apply `disabled` to all questions
// without being overriden by the `disabled` prop of the individual question.
export interface Question {
export interface Question<AnswerT extends number | string> {
/**
* Question text, can be plain text or contain code.
* If the question text contains code, use the PrismFormatted component to ensure the code is rendered correctly.
*/
question: QuizQuestionProps["question"];
question: QuizQuestionProps<AnswerT>["question"];

/**
* Answer options
*/
answers: QuizQuestionAnswer[];
answers: QuizQuestionAnswer<AnswerT>[];

/**
* Value of the correct answer
*/
correctAnswer: QuizQuestionAnswer["value"];
correctAnswer: AnswerT;

/**
* Change event handler, called when an answer is selected
*/
onChange?: (selectedOption: number) => void;
onChange?: (selectedAnswer: AnswerT) => void;

/**
* Information needed to render the validation status
Expand All @@ -39,5 +45,5 @@ export interface Question {
/**
* Value of the selected answer
*/
selectedAnswer?: number;
selectedAnswer?: AnswerT;
}
Loading

0 comments on commit 4433d94

Please sign in to comment.