diff --git a/apps/backend/src/libs/modules/controller/libs/types/controller-route-parameters.type.ts b/apps/backend/src/libs/modules/controller/libs/types/controller-route-parameters.type.ts index db20668b0..e543f8d03 100644 --- a/apps/backend/src/libs/modules/controller/libs/types/controller-route-parameters.type.ts +++ b/apps/backend/src/libs/modules/controller/libs/types/controller-route-parameters.type.ts @@ -11,6 +11,7 @@ type ControllerRouteParameters = { preHandlers?: APIPreHandler[]; validation?: { body?: ValidationSchema; + query?: ValidationSchema; }; }; diff --git a/apps/backend/src/libs/modules/server-application/base-server-application.ts b/apps/backend/src/libs/modules/server-application/base-server-application.ts index fc06e6fef..21b2b8aaa 100644 --- a/apps/backend/src/libs/modules/server-application/base-server-application.ts +++ b/apps/backend/src/libs/modules/server-application/base-server-application.ts @@ -140,6 +140,7 @@ class BaseServerApplication implements ServerApplication { preHandler: preHandlers, schema: { body: validation?.body, + query: validation?.query, }, url: path, }); diff --git a/apps/backend/src/libs/modules/server-application/libs/types/server-application-route-parameters.type.ts b/apps/backend/src/libs/modules/server-application/libs/types/server-application-route-parameters.type.ts index 051db1600..e71420be3 100644 --- a/apps/backend/src/libs/modules/server-application/libs/types/server-application-route-parameters.type.ts +++ b/apps/backend/src/libs/modules/server-application/libs/types/server-application-route-parameters.type.ts @@ -14,6 +14,7 @@ type ServerApplicationRouteParameters = { preHandlers: APIPreHandler[]; validation?: { body?: ValidationSchema; + query?: ValidationSchema; }; }; diff --git a/apps/backend/src/modules/categories/category.repository.ts b/apps/backend/src/modules/categories/category.repository.ts index 2896c5ddb..a3fa7f0f8 100644 --- a/apps/backend/src/modules/categories/category.repository.ts +++ b/apps/backend/src/modules/categories/category.repository.ts @@ -73,6 +73,17 @@ class CategoryRepository implements Repository { .where({ userId }) .delete(); } + public async deleteUserScoresByCategoryIds( + userId: number, + categoryIds: number[], + ): Promise { + return await this.categoryModel + .query() + .from(DatabaseTableName.QUIZ_SCORES) + .whereIn("categoryId", categoryIds) + .andWhere({ userId }) + .delete(); + } public async find(id: number): Promise { const category = await this.categoryModel @@ -95,22 +106,12 @@ class CategoryRepository implements Repository { const categories = await this.categoryModel.query().select("*"); return await Promise.all( - categories.map(async (category) => { - const scoresModel = await this.categoryModel - .query() - .from(DatabaseTableName.QUIZ_SCORES) - .where({ categoryId: category.id }) - .castTo(); - - const scoreEntities = scoresModel.map((score) => { - return CategoryEntity.initializeNew(score); - }); - + categories.map((category) => { return CategoryEntity.initialize({ createdAt: category.createdAt, id: category.id, name: category.name, - scores: scoreEntities, + scores: [], updatedAt: category.updatedAt, }); }), diff --git a/apps/backend/src/modules/categories/category.service.ts b/apps/backend/src/modules/categories/category.service.ts index be33c3cca..8131ac606 100644 --- a/apps/backend/src/modules/categories/category.service.ts +++ b/apps/backend/src/modules/categories/category.service.ts @@ -74,6 +74,16 @@ class CategoryService implements Service { return this.categoryRepository.deleteUserScores(userId); } + public deleteUserScoresByCategoryIds( + userId: number, + categoryIds: number[], + ): Promise { + return this.categoryRepository.deleteUserScoresByCategoryIds( + userId, + categoryIds, + ); + } + public async find(id: number): Promise { const categoryEntity = await this.categoryRepository.find(id); diff --git a/apps/backend/src/modules/quiz-answers/quiz-answer.repository.ts b/apps/backend/src/modules/quiz-answers/quiz-answer.repository.ts index 5b064022f..39e0ff6d0 100644 --- a/apps/backend/src/modules/quiz-answers/quiz-answer.repository.ts +++ b/apps/backend/src/modules/quiz-answers/quiz-answer.repository.ts @@ -72,6 +72,18 @@ class QuizAnswerRepository implements Repository { .delete(); } + public async deleteUserAnswersByAnswerIds( + userId: number, + answerIds: number[], + ): Promise { + return await this.quizAnswerModel + .query() + .from(DatabaseTableName.QUIZ_ANSWERS_TO_USERS) + .whereIn("answerId", answerIds) + .andWhere({ userId }) + .delete(); + } + public async find(id: number): Promise { const answer = await this.quizAnswerModel .query() diff --git a/apps/backend/src/modules/quiz-answers/quiz-answer.service.ts b/apps/backend/src/modules/quiz-answers/quiz-answer.service.ts index 61608e65a..7d429c554 100644 --- a/apps/backend/src/modules/quiz-answers/quiz-answer.service.ts +++ b/apps/backend/src/modules/quiz-answers/quiz-answer.service.ts @@ -38,7 +38,7 @@ class QuizAnswerService implements Service { this.quizQuestionService = quizQuestionService; } - public convertAnswerEntityToDto( + private convertAnswerEntityToDto( answerEntity: QuizAnswerEntity, ): QuizAnswerDto { const answer = answerEntity.toObject(); @@ -54,6 +54,20 @@ class QuizAnswerService implements Service { }; } + private extractIdsFromAnswerEntities( + answerEntities: QuizAnswerEntity[], + ): number[] { + const answerIds: number[] = []; + + for (const answerEntity of answerEntities) { + const answer = answerEntity.toObject(); + + answerIds.push(answer.id); + } + + return answerIds; + } + public async create(payload: QuizAnswerRequestDto): Promise { const answerEntity = await this.quizAnswerRepository.create( QuizAnswerEntity.initializeNew(payload), @@ -62,6 +76,52 @@ class QuizAnswerService implements Service { return this.convertAnswerEntityToDto(answerEntity); } + public async createAllUserAnswers({ + answerIds, + userId, + }: UserAnswersRequestDto): Promise { + const existingAnswers = + await this.quizAnswerRepository.findByIds(answerIds); + + if (existingAnswers.length !== answerIds.length) { + throw new QuizError({ + message: ErrorMessage.REQUESTED_ENTITY_NOT_FOUND, + status: HTTPCode.NOT_FOUND, + }); + } + + const questionsCount = await this.quizQuestionService.countAll(); + + if (questionsCount !== existingAnswers.length) { + throw new QuizError({ + message: ErrorMessage.INSUFFICIENT_ANSWERS, + status: HTTPCode.BAD_REQUEST, + }); + } + + const answerEntities = existingAnswers.map((answer) => answer.toObject()); + const questionIds = answerEntities.map((answer) => answer.questionId); + const uniqueQuestionIds = new Set(questionIds); + + if (uniqueQuestionIds.size !== questionIds.length) { + throw new QuizError({ + message: ErrorMessage.DUPLICATE_QUESTION_ANSWER, + status: HTTPCode.BAD_REQUEST, + }); + } + + await this.quizAnswerRepository.deleteUserAnswers(userId); + const userAnswers = await this.quizAnswerRepository.createUserAnswers( + userId, + answerIds, + ); + + await this.categoryService.deleteUserScores(userId); + const { items } = await this.createScores({ answerIds, userId }); + + return { scores: items, userAnswers }; + } + public async createScores({ answerIds, userId, @@ -104,6 +164,23 @@ class QuizAnswerService implements Service { public async createUserAnswers({ answerIds, + categoryIds, + userId, + }: UserAnswersRequestDto): Promise { + if (!categoryIds) { + return await this.createAllUserAnswers({ answerIds, userId }); + } + + return await this.createUserAnswersByCategories({ + answerIds, + categoryIds, + userId, + }); + } + + public async createUserAnswersByCategories({ + answerIds, + categoryIds, userId, }: UserAnswersRequestDto): Promise { const existingAnswers = @@ -116,7 +193,9 @@ class QuizAnswerService implements Service { }); } - const questionsCount = await this.quizQuestionService.countAll(); + const questionsCount = await this.quizQuestionService.countByCategoryIds( + categoryIds as number[], + ); if (questionsCount !== existingAnswers.length) { throw new QuizError({ @@ -136,13 +215,23 @@ class QuizAnswerService implements Service { }); } - await this.quizAnswerRepository.deleteUserAnswers(userId); + const existingAnswersIds = + this.extractIdsFromAnswerEntities(existingAnswers); + + await this.quizAnswerRepository.deleteUserAnswersByAnswerIds( + userId, + existingAnswersIds, + ); + const userAnswers = await this.quizAnswerRepository.createUserAnswers( userId, answerIds, ); - await this.categoryService.deleteUserScores(userId); + await this.categoryService.deleteUserScoresByCategoryIds( + userId, + categoryIds as number[], + ); const { items } = await this.createScores({ answerIds, userId }); return { scores: items, userAnswers }; diff --git a/apps/backend/src/modules/quiz-questions/libs/types/types.ts b/apps/backend/src/modules/quiz-questions/libs/types/types.ts index e17048eeb..b1bc5172a 100644 --- a/apps/backend/src/modules/quiz-questions/libs/types/types.ts +++ b/apps/backend/src/modules/quiz-questions/libs/types/types.ts @@ -1 +1,5 @@ -export { type QuizQuestionDto, type QuizQuestionRequestDto } from "shared"; +export { + type CategoriesGetRequestQueryDto, + type QuizQuestionDto, + type QuizQuestionRequestDto, +} from "shared"; diff --git a/apps/backend/src/modules/quiz-questions/quiz-question.repository.ts b/apps/backend/src/modules/quiz-questions/quiz-question.repository.ts index cc844a7df..9d1a9782c 100644 --- a/apps/backend/src/modules/quiz-questions/quiz-question.repository.ts +++ b/apps/backend/src/modules/quiz-questions/quiz-question.repository.ts @@ -23,6 +23,15 @@ class QuizQuestionRepository implements Repository { return Number(questionModelCount[FIRST_ELEMENT_INDEX].count); } + public async countByCategoryIds(categoryIds: number[]): Promise { + const questionModelCount = await this.quizQuestionModel + .query() + .whereIn("categoryId", categoryIds) + .count() + .castTo<[{ count: string }]>(); + + return Number(questionModelCount[FIRST_ELEMENT_INDEX].count); + } public async create(entity: QuizQuestionEntity): Promise { const { categoryId, label } = entity.toNewObject(); @@ -103,6 +112,38 @@ class QuizQuestionRepository implements Repository { ); } + public async findByCategoryIds( + categoryIds: number[], + ): Promise { + const questions = await this.quizQuestionModel + .query() + .whereIn("categoryId", categoryIds) + .select("*"); + + return await Promise.all( + questions.map(async (question) => { + const answersModel = await this.quizQuestionModel + .relatedQuery(RelationName.QUIZ_ANSWERS) + .for(question.id) + .select("*") + .castTo(); + + const answerEntities = answersModel.map((answer) => { + return QuizAnswerEntity.initialize(answer); + }); + + return QuizQuestionEntity.initialize({ + answers: answerEntities, + categoryId: question.categoryId, + createdAt: question.createdAt, + id: question.id, + label: question.label, + updatedAt: question.updatedAt, + }); + }), + ); + } + public async update( id: number, payload: Partial, diff --git a/apps/backend/src/modules/quiz-questions/quiz-question.service.ts b/apps/backend/src/modules/quiz-questions/quiz-question.service.ts index f6ad2fc5b..c711ed66d 100644 --- a/apps/backend/src/modules/quiz-questions/quiz-question.service.ts +++ b/apps/backend/src/modules/quiz-questions/quiz-question.service.ts @@ -5,6 +5,7 @@ import { type QuizAnswerEntity, } from "../quiz-answers/quiz-answers.js"; import { + type CategoriesGetRequestQueryDto, type QuizQuestionDto, type QuizQuestionRequestDto, } from "./libs/types/types.js"; @@ -43,6 +44,10 @@ class QuizQuestionService implements Service { return await this.quizQuestionRepository.countAll(); } + public async countByCategoryIds(categoryIds: number[]): Promise { + return await this.quizQuestionRepository.countByCategoryIds(categoryIds); + } + public async create( payload: QuizQuestionRequestDto, ): Promise { @@ -89,6 +94,47 @@ class QuizQuestionService implements Service { return { items: result }; } + public async findQuestions( + query: CategoriesGetRequestQueryDto, + ): Promise<{ items: QuizQuestionDto[][] }> { + const { categoryIds } = query; + + if (!categoryIds) { + return await this.findAll(); + } + + return await this.findQuestionsByCategoryIds( + JSON.parse(query.categoryIds) as number[], + ); + } + + public async findQuestionsByCategoryIds( + categoryIds: number[], + ): Promise<{ items: QuizQuestionDto[][] }> { + const questions = + await this.quizQuestionRepository.findByCategoryIds(categoryIds); + + const items = questions.map((questionEntity) => { + return this.convertQuestionEntityToDto(questionEntity); + }); + + const groupedByCategory: Record = {}; + + for (const question of items) { + const { categoryId } = question; + + if (!groupedByCategory[categoryId]) { + groupedByCategory[categoryId] = []; + } + + groupedByCategory[categoryId].push(question); + } + + const result = Object.values(groupedByCategory); + + return { items: result }; + } + public async update( id: number, payload: Partial, diff --git a/apps/backend/src/modules/quiz/libs/types/types.ts b/apps/backend/src/modules/quiz/libs/types/types.ts new file mode 100644 index 000000000..f3c421d06 --- /dev/null +++ b/apps/backend/src/modules/quiz/libs/types/types.ts @@ -0,0 +1 @@ +export { type CategoriesGetRequestQueryDto } from "shared"; diff --git a/apps/backend/src/modules/quiz/libs/validation-schemas/validation-schemas.ts b/apps/backend/src/modules/quiz/libs/validation-schemas/validation-schemas.ts index 9bb5c5e6d..204d64891 100644 --- a/apps/backend/src/modules/quiz/libs/validation-schemas/validation-schemas.ts +++ b/apps/backend/src/modules/quiz/libs/validation-schemas/validation-schemas.ts @@ -1 +1,4 @@ -export { quizUserAnswersValidationSchema } from "shared"; +export { + categoryIdsValidationSchema, + quizUserAnswersValidationSchema, +} from "shared"; diff --git a/apps/backend/src/modules/quiz/quiz.controller.ts b/apps/backend/src/modules/quiz/quiz.controller.ts index a9f583b2e..de01ecf3a 100644 --- a/apps/backend/src/modules/quiz/quiz.controller.ts +++ b/apps/backend/src/modules/quiz/quiz.controller.ts @@ -19,7 +19,11 @@ import { import { type QuizQuestionService } from "../quiz-questions/quiz-questions.js"; import { type UserDto } from "../users/users.js"; import { QuizApiPath } from "./libs/enums/enums.js"; -import { quizUserAnswersValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; +import { type CategoriesGetRequestQueryDto } from "./libs/types/types.js"; +import { + categoryIdsValidationSchema, + quizUserAnswersValidationSchema, +} from "./libs/validation-schemas/validation-schemas.js"; type Constructor = { categoryService: CategoryService; @@ -169,9 +173,17 @@ class QuizController extends BaseController { }); this.addRoute({ - handler: () => this.findAll(), + handler: (options) => + this.findQuestions( + options as APIHandlerOptions<{ + query: CategoriesGetRequestQueryDto; + }>, + ), method: "GET", path: QuizApiPath.QUESTIONS, + validation: { + query: categoryIdsValidationSchema, + }, }); this.addRoute({ @@ -257,10 +269,12 @@ class QuizController extends BaseController { }>, ): Promise { const { answerIds } = options.body; + const categoryIds = options.body.categoryIds as number[]; return { payload: await this.quizAnswerService.createUserAnswers({ answerIds, + categoryIds, userId: options.user.id, }), status: HTTPCode.CREATED, @@ -271,10 +285,19 @@ class QuizController extends BaseController { * @swagger * /quiz/questions: * get: - * tags: [quiz] - * summary: Get all quiz questions + * summary: Get quiz questions * security: * - bearerAuth: [] + * parameters: + * - in: query + * name: categoryIds + * schema: + * type: string + * items: + * type: string + * description: Array of category IDs to filter the quiz questions (optional) + * required: false # Optional parameter + * example: [3, 6] * responses: * 200: * description: Successful operation @@ -294,9 +317,12 @@ class QuizController extends BaseController { * schema: * $ref: "#/components/schemas/CommonErrorResponse" */ - private async findAll(): Promise { + + private async findQuestions( + options: APIHandlerOptions<{ query: CategoriesGetRequestQueryDto }>, + ): Promise { return { - payload: await this.quizQuestionService.findAll(), + payload: await this.quizQuestionService.findQuestions(options.query), status: HTTPCode.OK, }; } diff --git a/apps/frontend/src/assets/css/variables.css b/apps/frontend/src/assets/css/variables.css index 685d2d0e0..ff27074be 100644 --- a/apps/frontend/src/assets/css/variables.css +++ b/apps/frontend/src/assets/css/variables.css @@ -16,9 +16,11 @@ --light-brand: #00f0ff; --text-background: #63a0e7; --text-secondary: #7c7c7c; + --medium-gray: #7c7c7c; --light-gray: #e0e0e0; --white: #ffffff; --disable: #e0e0e0; + --switch-disable: #7c7c7c; --complete: #c2eacd; --completed: #f0fff4; --physical-start: #ffe307; diff --git a/apps/frontend/src/libs/components/button/button.tsx b/apps/frontend/src/libs/components/button/button.tsx index e2549538d..9e1df8501 100644 --- a/apps/frontend/src/libs/components/button/button.tsx +++ b/apps/frontend/src/libs/components/button/button.tsx @@ -9,10 +9,11 @@ type Properties = { iconName?: IconName; iconPosition?: "center" | "left" | "right"; isDisabled?: boolean; + isSelected?: boolean; label: string; onClick?: (() => void) | undefined; type?: "button" | "submit"; - variant?: "icon" | "primary" | "secondary"; + variant?: "icon" | "primary" | "secondary" | "switch"; }; const Button: React.FC = ({ @@ -20,6 +21,7 @@ const Button: React.FC = ({ iconName, iconPosition = "right", isDisabled = false, + isSelected = false, label, onClick, type = "button", @@ -30,6 +32,7 @@ const Button: React.FC = ({ styles["btn"], variant === "icon" && styles[`position-${iconPosition}`], styles[`${variant}-button`], + isSelected && styles[`${variant}-button-selected`], )} disabled={isDisabled} onClick={onClick} diff --git a/apps/frontend/src/libs/components/button/styles.module.css b/apps/frontend/src/libs/components/button/styles.module.css index 1d5133645..8de144eb7 100644 --- a/apps/frontend/src/libs/components/button/styles.module.css +++ b/apps/frontend/src/libs/components/button/styles.module.css @@ -78,6 +78,27 @@ transform: translateY(-50%); } +.switch-button { + padding: 0 10px; + font-size: 10px; + color: var(--medium-gray); + background: none; + border-radius: 20px; + transition: all 200ms ease-in; +} + +.switch-button:hover, +.switch-button-selected { + color: var(--black); + background-color: var(--white); + transition: all 200ms ease-in; +} + +.switch-button-selected:disabled { + cursor: not-allowed; + opacity: 1; +} + .position-right { right: 20px; } diff --git a/apps/frontend/src/libs/components/checkbox/styles.module.css b/apps/frontend/src/libs/components/checkbox/styles.module.css index 3258600ff..6447f8546 100644 --- a/apps/frontend/src/libs/components/checkbox/styles.module.css +++ b/apps/frontend/src/libs/components/checkbox/styles.module.css @@ -58,7 +58,7 @@ .rounded-checkbox { border-radius: 40px; - box-shadow: 0 0 8px var(--light-blue); + box-shadow: 0 0 8px -3px var(--light-blue); } .checkbox-gradient-border > .rounded-checkbox { diff --git a/apps/frontend/src/libs/components/components.ts b/apps/frontend/src/libs/components/components.ts index ca852bd56..dd81cd466 100644 --- a/apps/frontend/src/libs/components/components.ts +++ b/apps/frontend/src/libs/components/components.ts @@ -14,8 +14,8 @@ export { ProtectedRoute } from "./protected-route/protected-route.js"; export { QuizCategoriesForm } from "./quiz-categories-form/quiz-categories-form.js"; export { QuizQuestion } from "./quiz-question/quiz-question.js"; export { RouterProvider } from "./router-provider/router-provider.js"; -export { ScoresEditModal } from "./scores-edit-modal/scores-edit-modal.js"; export { Sidebar } from "./sidebar/sidebar.js"; export { Slider } from "./slider/slider.js"; +export { Switch } from "./switch/switch.js"; export { Provider as StoreProvider } from "react-redux"; export { Navigate, Outlet as RouterOutlet } from "react-router-dom"; diff --git a/apps/frontend/src/libs/components/quiz-categories-form/quiz-categories-form.tsx b/apps/frontend/src/libs/components/quiz-categories-form/quiz-categories-form.tsx index f6794d398..c3500f2c6 100644 --- a/apps/frontend/src/libs/components/quiz-categories-form/quiz-categories-form.tsx +++ b/apps/frontend/src/libs/components/quiz-categories-form/quiz-categories-form.tsx @@ -8,6 +8,7 @@ import { } from "~/libs/hooks/hooks.js"; import { type InputOption } from "~/libs/types/types.js"; import { actions as categoriesActions } from "~/modules/categories/categories.js"; +import { type CategoriesGetRequestQueryDto } from "~/modules/categories/categories.js"; import { Button, Checkbox, Loader } from "../components.js"; import { QUIZ_CATEGORIES_FORM_DEFAULT_VALUES } from "./libs/constants/constants.js"; @@ -16,11 +17,13 @@ import styles from "./styles.module.css"; type Properties = { buttonLabel: string; - onSubmit: (payload: { categoryIds: number[] }) => void; + header?: string; + onSubmit: (payload: CategoriesGetRequestQueryDto) => void; }; const QuizCategoriesForm: React.FC = ({ buttonLabel, + header, onSubmit, }: Properties) => { const { control, getValues, handleSubmit, setValue } = @@ -73,7 +76,8 @@ const QuizCategoriesForm: React.FC = ({ const handleFormSubmit = useCallback( (event: React.BaseSyntheticEvent): void => { void handleSubmit(({ categoryIds }) => { - onSubmit({ categoryIds }); + const categoryIdsStringified = categoryIds.toString(); + onSubmit({ categoryIds: categoryIdsStringified }); })(event); }, [onSubmit, handleSubmit], @@ -84,7 +88,8 @@ const QuizCategoriesForm: React.FC = ({ } return ( -
+
+ {header && {header}}
= ({ options={categoryInputOptions} />
-
); diff --git a/apps/frontend/src/libs/components/quiz-categories-form/styles.module.css b/apps/frontend/src/libs/components/quiz-categories-form/styles.module.css index c58b5d870..55eedcc1d 100644 --- a/apps/frontend/src/libs/components/quiz-categories-form/styles.module.css +++ b/apps/frontend/src/libs/components/quiz-categories-form/styles.module.css @@ -1,3 +1,15 @@ +.container { + display: flex; + flex-direction: column; +} + +.header { + margin-bottom: 20px; + font-size: 16px; + font-weight: bold; + text-align: center; +} + .checkbox-divider { height: 10px; } diff --git a/apps/frontend/src/libs/components/switch/styles.module.css b/apps/frontend/src/libs/components/switch/styles.module.css new file mode 100644 index 000000000..286ddd931 --- /dev/null +++ b/apps/frontend/src/libs/components/switch/styles.module.css @@ -0,0 +1,11 @@ +.container { + position: relative; + left: 25%; + display: flex; + gap: 5px; + min-width: 250px; + height: 40px; + padding: 5px; + background-color: var(--background-gray); + border-radius: 20px; +} diff --git a/apps/frontend/src/libs/components/switch/switch.tsx b/apps/frontend/src/libs/components/switch/switch.tsx new file mode 100644 index 000000000..6a67ba9d5 --- /dev/null +++ b/apps/frontend/src/libs/components/switch/switch.tsx @@ -0,0 +1,43 @@ +import { Button } from "~/libs/components/components.js"; + +import styles from "./styles.module.css"; + +type Properties = { + currentMode: T; + leftButtonProperties: SwitchButtonProperties; + onToggleMode: () => void; + rightButtonProperties: SwitchButtonProperties; +}; + +type SwitchButtonProperties = { + label: string; + mode: T; +}; + +const Switch = ({ + currentMode, + leftButtonProperties, + onToggleMode, + rightButtonProperties, +}: Properties): React.ReactNode => { + return ( +
+
+ ); +}; + +export { Switch }; diff --git a/apps/frontend/src/libs/constants/constants.ts b/apps/frontend/src/libs/constants/constants.ts index e0f169036..99badd6c2 100644 --- a/apps/frontend/src/libs/constants/constants.ts +++ b/apps/frontend/src/libs/constants/constants.ts @@ -2,4 +2,4 @@ export { NOTIFICATION_FREQUENCY_OPTIONS } from "./notification-frequency-options export { SIDEBAR_ITEMS } from "./sidebar-items.constant.js"; export { TASK_DAYS_OPTIONS } from "./task-days-options.constant.js"; export { HALF_PI, TAU } from "chart.js/helpers"; -export { PREVIOUS_INDEX_OFFSET, ZERO_INDEX } from "shared"; +export { FIRST_ITEM_INDEX, PREVIOUS_INDEX_OFFSET, ZERO_INDEX } from "shared"; diff --git a/apps/frontend/src/modules/categories/categories.ts b/apps/frontend/src/modules/categories/categories.ts index cd422f15b..8edcb2734 100644 --- a/apps/frontend/src/modules/categories/categories.ts +++ b/apps/frontend/src/modules/categories/categories.ts @@ -10,5 +10,6 @@ const categoriesApi = new CategoriesApi({ storage, }); +export { type CategoriesGetRequestQueryDto } from "./libs/types/types.js"; export { actions, reducer } from "./slices/categories.js"; export { categoriesApi }; diff --git a/apps/frontend/src/modules/categories/libs/types/types.ts b/apps/frontend/src/modules/categories/libs/types/types.ts index ba17dee8e..181d5040c 100644 --- a/apps/frontend/src/modules/categories/libs/types/types.ts +++ b/apps/frontend/src/modules/categories/libs/types/types.ts @@ -1 +1,5 @@ -export { type CategoriesGetAllResponseDto, type CategoryDto } from "shared"; +export { + type CategoriesGetAllResponseDto, + type CategoriesGetRequestQueryDto, + type CategoryDto, +} from "shared"; diff --git a/apps/frontend/src/modules/onboarding/slices/onboarding.slice.ts b/apps/frontend/src/modules/onboarding/slices/onboarding.slice.ts index e02524b51..855dcec83 100644 --- a/apps/frontend/src/modules/onboarding/slices/onboarding.slice.ts +++ b/apps/frontend/src/modules/onboarding/slices/onboarding.slice.ts @@ -69,6 +69,10 @@ const { actions, name, reducer } = createSlice({ state.questions[state.currentQuestionIndex] || null; } }, + resetState(state) { + state.dataStatus = DataStatus.IDLE; + state.currentQuestionIndex = ZERO_INDEX; + }, }, }); diff --git a/apps/frontend/src/modules/quiz/libs/types/types.ts b/apps/frontend/src/modules/quiz/libs/types/types.ts index b9f571493..551f78a3b 100644 --- a/apps/frontend/src/modules/quiz/libs/types/types.ts +++ b/apps/frontend/src/modules/quiz/libs/types/types.ts @@ -1,4 +1,5 @@ export { + type CategoriesGetRequestQueryDto, type QuizAnswerDto, type QuizAnswersRequestDto, type QuizQuestionDto, diff --git a/apps/frontend/src/modules/quiz/quiz-api.ts b/apps/frontend/src/modules/quiz/quiz-api.ts index da112c12b..c05d187b6 100644 --- a/apps/frontend/src/modules/quiz/quiz-api.ts +++ b/apps/frontend/src/modules/quiz/quiz-api.ts @@ -53,6 +53,24 @@ class QuizApi extends BaseHTTPApi { return await response.json<{ items: QuizQuestionDto[][] }>(); } + public async getQuestionsByCategoryIds(categoryIds: string): Promise<{ + items: QuizQuestionDto[][]; + }> { + const response = await this.load( + this.getFullEndpoint( + `${QuizApiPath.QUESTIONS}?categoryIds=[${categoryIds}]`, + {}, + ), + { + contentType: ContentType.JSON, + hasAuth: true, + method: "GET", + }, + ); + + return await response.json<{ items: QuizQuestionDto[][] }>(); + } + public async getScores(): Promise { const response = await this.load( this.getFullEndpoint(QuizApiPath.SCORE, {}), diff --git a/apps/frontend/src/modules/quiz/quiz.ts b/apps/frontend/src/modules/quiz/quiz.ts index 7ef24b0b4..c5c709fed 100644 --- a/apps/frontend/src/modules/quiz/quiz.ts +++ b/apps/frontend/src/modules/quiz/quiz.ts @@ -12,6 +12,7 @@ const quizApi = new QuizApi({ export { quizApi }; export { + type CategoriesGetRequestQueryDto, type QuizAnswerDto, type QuizAnswersRequestDto, type QuizQuestionDto, diff --git a/apps/frontend/src/modules/quiz/slices/actions.ts b/apps/frontend/src/modules/quiz/slices/actions.ts index 551bb64e7..8613f4d69 100644 --- a/apps/frontend/src/modules/quiz/slices/actions.ts +++ b/apps/frontend/src/modules/quiz/slices/actions.ts @@ -2,6 +2,7 @@ import { createAsyncThunk } from "@reduxjs/toolkit"; import { type AsyncThunkConfig } from "~/libs/types/types.js"; import { + type CategoriesGetRequestQueryDto, type QuizAnswersRequestDto, type QuizQuestionDto, type QuizScoresGetAllResponseDto, @@ -32,6 +33,16 @@ const getAllQuestions = createAsyncThunk< return await quizApi.getAllQuestions(); }); +const getQuestionsByCategoryIds = createAsyncThunk< + { items: QuizQuestionDto[][] }, + CategoriesGetRequestQueryDto, + AsyncThunkConfig +>(`${sliceName}/get-questions-by-category-ids`, async (payload, { extra }) => { + const { quizApi } = extra; + + return await quizApi.getQuestionsByCategoryIds(payload.categoryIds); +}); + const saveAnswers = createAsyncThunk< QuizUserAnswerDto[], QuizAnswersRequestDto, @@ -52,4 +63,10 @@ const getScores = createAsyncThunk< return await quizApi.getScores(); }); -export { editScores, getAllQuestions, getScores, saveAnswers }; +export { + editScores, + getAllQuestions, + getQuestionsByCategoryIds, + getScores, + saveAnswers, +}; diff --git a/apps/frontend/src/modules/quiz/slices/quiz.slice.ts b/apps/frontend/src/modules/quiz/slices/quiz.slice.ts index 9b00f1405..df164f790 100644 --- a/apps/frontend/src/modules/quiz/slices/quiz.slice.ts +++ b/apps/frontend/src/modules/quiz/slices/quiz.slice.ts @@ -1,4 +1,4 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; import { PREVIOUS_INDEX_OFFSET, @@ -11,29 +11,35 @@ import { type QuizScoresGetAllItemResponseDto, type QuizUserAnswerDto, } from "~/modules/quiz/quiz.js"; +import { Step } from "~/pages/quiz/libs/enums/step.js"; import { editScores, getAllQuestions, + getQuestionsByCategoryIds, getScores, saveAnswers, } from "./actions.js"; type State = { - currentCategory: null | QuizQuestionDto[]; currentCategoryIndex: number; + currentCategoryQuestions: null | QuizQuestionDto[]; dataStatus: ValueOf; - questions: QuizQuestionDto[][]; + isRetakingQuiz: boolean; + questionsByCategories: QuizQuestionDto[][]; scores: QuizScoresGetAllItemResponseDto[]; + step: ValueOf; userAnswers: QuizUserAnswerDto[]; }; const initialState: State = { - currentCategory: null, currentCategoryIndex: ZERO_INDEX, + currentCategoryQuestions: null, dataStatus: DataStatus.IDLE, - questions: [], + isRetakingQuiz: false, + questionsByCategories: [], scores: [], + step: Step.MOTIVATION, userAnswers: [], }; @@ -44,13 +50,29 @@ const { actions, name, reducer } = createSlice({ }); builder.addCase(getAllQuestions.fulfilled, (state, action) => { state.dataStatus = DataStatus.FULFILLED; - state.questions = action.payload.items; - state.currentCategory = - state.questions[state.currentCategoryIndex] || null; + state.questionsByCategories = action.payload.items; + state.currentCategoryQuestions = + state.questionsByCategories[state.currentCategoryIndex] || null; }); builder.addCase(getAllQuestions.rejected, (state) => { state.dataStatus = DataStatus.REJECTED; }); + + builder.addCase(getQuestionsByCategoryIds.pending, (state) => { + state.dataStatus = DataStatus.PENDING; + state.isRetakingQuiz = true; + }); + builder.addCase(getQuestionsByCategoryIds.fulfilled, (state, action) => { + state.dataStatus = DataStatus.FULFILLED; + state.questionsByCategories = action.payload.items; + state.currentCategoryQuestions = + state.questionsByCategories[state.currentCategoryIndex] || null; + }); + builder.addCase(getQuestionsByCategoryIds.rejected, (state) => { + state.dataStatus = DataStatus.REJECTED; + state.isRetakingQuiz = false; + }); + builder.addCase(getScores.pending, (state) => { state.dataStatus = DataStatus.PENDING; }); @@ -61,16 +83,21 @@ const { actions, name, reducer } = createSlice({ builder.addCase(getScores.rejected, (state) => { state.dataStatus = DataStatus.REJECTED; }); + builder.addCase(saveAnswers.rejected, (state) => { state.dataStatus = DataStatus.REJECTED; + state.isRetakingQuiz = false; }); builder.addCase(saveAnswers.fulfilled, (state, action) => { state.dataStatus = DataStatus.FULFILLED; state.userAnswers = action.payload; + state.currentCategoryIndex = ZERO_INDEX; + state.isRetakingQuiz = false; }); builder.addCase(saveAnswers.pending, (state) => { state.dataStatus = DataStatus.PENDING; }); + builder.addCase(editScores.fulfilled, (state, action) => { const updatedScores = new Map( action.payload.items.map((score) => [score.id, score]), @@ -102,16 +129,23 @@ const { actions, name, reducer } = createSlice({ reducers: { nextQuestion(state) { state.currentCategoryIndex += PREVIOUS_INDEX_OFFSET; - state.currentCategory = - state.questions[state.currentCategoryIndex] || null; + + state.currentCategoryQuestions = + state.questionsByCategories[state.currentCategoryIndex] ?? null; + }, + nextStep(state) { + state.step++; }, previousQuestion(state) { if (state.currentCategoryIndex > initialState.currentCategoryIndex) { state.currentCategoryIndex -= PREVIOUS_INDEX_OFFSET; - state.currentCategory = - state.questions[state.currentCategoryIndex] || null; + state.currentCategoryQuestions = + state.questionsByCategories[state.currentCategoryIndex] ?? null; } }, + setStep(state, action: PayloadAction>) { + state.step = action.payload; + }, }, }); diff --git a/apps/frontend/src/modules/quiz/slices/quiz.ts b/apps/frontend/src/modules/quiz/slices/quiz.ts index adc4813a0..5d62e1e64 100644 --- a/apps/frontend/src/modules/quiz/slices/quiz.ts +++ b/apps/frontend/src/modules/quiz/slices/quiz.ts @@ -1,6 +1,7 @@ import { editScores, getAllQuestions, + getQuestionsByCategoryIds, getScores, saveAnswers, } from "./actions.js"; @@ -10,6 +11,7 @@ const allActions = { ...actions, editScores, getAllQuestions, + getQuestionsByCategoryIds, getScores, saveAnswers, }; diff --git a/apps/frontend/src/pages/quiz/libs/components/balance-wheel/balance-wheel.tsx b/apps/frontend/src/pages/quiz/libs/components/balance-wheel/balance-wheel.tsx index 75ba902df..7207b9992 100644 --- a/apps/frontend/src/pages/quiz/libs/components/balance-wheel/balance-wheel.tsx +++ b/apps/frontend/src/pages/quiz/libs/components/balance-wheel/balance-wheel.tsx @@ -1,20 +1,23 @@ -import { BalanceWheelChart, Navigate } from "~/libs/components/components.js"; +import { BalanceWheelChart } from "~/libs/components/components.js"; import { AppRoute } from "~/libs/enums/enums.js"; import { getValidClassNames } from "~/libs/helpers/helpers.js"; import { + useAppDispatch, useAppSelector, useCallback, useEffect, - useNavigate, useState, } from "~/libs/hooks/hooks.js"; +import { actions as appActions } from "~/modules/app/app.js"; +import { actions as quizActions } from "~/modules/quiz/quiz.js"; +import { Step } from "../../enums/step.js"; import { BALANCE_WHEEL_ANIMATED_INITIAL_DATA } from "./libs/constants/constants.js"; import { PercentageConfig } from "./libs/enums/enums.js"; import styles from "./styles.module.css"; const BalanceWheel: React.FC = () => { - const navigate = useNavigate(); + const dispatch = useAppDispatch(); const [percentage, setPercentage] = useState( PercentageConfig.DEFAULT_VALUE, ); @@ -67,10 +70,11 @@ const BalanceWheel: React.FC = () => { return (): void => { clearInterval(intervalId); }; - }, [handleUpdatePercentage, navigate]); + }, [handleUpdatePercentage]); if (shouldNavigate) { - return ; + dispatch(appActions.changeLink(AppRoute.ROOT)); + dispatch(quizActions.setStep(Step.MOTIVATION)); } const roundedPercentage = Math.ceil(percentage); diff --git a/apps/frontend/src/pages/quiz/libs/components/onboarding/onboarding.tsx b/apps/frontend/src/pages/quiz/libs/components/onboarding/onboarding.tsx index f80dc0dbe..917651f00 100644 --- a/apps/frontend/src/pages/quiz/libs/components/onboarding/onboarding.tsx +++ b/apps/frontend/src/pages/quiz/libs/components/onboarding/onboarding.tsx @@ -80,6 +80,7 @@ const OnboardingForm: React.FC = ({ onNext }: Properties) => { if (isLastQuestion) { const answerIds = getAnswerIds(data); void dispatch(onboardingActions.saveAnswers({ answerIds })); + void dispatch(onboardingActions.resetState()); onNext(); } diff --git a/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/extract-category-ids-from-questions.helper.ts b/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/extract-category-ids-from-questions.helper.ts new file mode 100644 index 000000000..f94491a00 --- /dev/null +++ b/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/extract-category-ids-from-questions.helper.ts @@ -0,0 +1,18 @@ +import { FIRST_ITEM_INDEX } from "~/libs/constants/constants.js"; +import { type QuizQuestionDto } from "~/modules/quiz/quiz.js"; + +function extractCategoryIdsFromQuestions( + questions: QuizQuestionDto[][], +): number[] { + const categoryIds = new Set(); + + for (const questionsInsideCategory of questions) { + categoryIds.add( + (questionsInsideCategory[FIRST_ITEM_INDEX] as QuizQuestionDto).categoryId, + ); + } + + return [...categoryIds]; +} + +export { extractCategoryIdsFromQuestions }; diff --git a/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/helpers.ts b/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/helpers.ts new file mode 100644 index 000000000..a2aed5481 --- /dev/null +++ b/apps/frontend/src/pages/quiz/libs/components/quiz-form/libs/helpers/helpers.ts @@ -0,0 +1 @@ +export { extractCategoryIdsFromQuestions } from "./extract-category-ids-from-questions.helper.js"; diff --git a/apps/frontend/src/pages/quiz/libs/components/quiz-form/quiz-form.tsx b/apps/frontend/src/pages/quiz/libs/components/quiz-form/quiz-form.tsx index b84296d0a..c9a904ce2 100644 --- a/apps/frontend/src/pages/quiz/libs/components/quiz-form/quiz-form.tsx +++ b/apps/frontend/src/pages/quiz/libs/components/quiz-form/quiz-form.tsx @@ -22,8 +22,10 @@ import { PREVIOUS_INDEX_OFFSET, ZERO_INDEX, } from "../../constants/constants.js"; +import { Step } from "../../enums/enums.js"; import { getQuizDefaultValues } from "../../helpers/helpers.js"; import { type QuizFormValues } from "../../types/types.js"; +import { extractCategoryIdsFromQuestions } from "./libs/helpers/helpers.js"; import styles from "./styles.module.css"; type Properties = { @@ -36,29 +38,41 @@ const QuizForm: React.FC = ({ onNext }: Properties) => { const [isDisabled, setIsDisabled] = useState(true); const [categoryDone, setCategoryDone] = useState([]); - const { category, currentCategoryIndex, dataStatus, questions } = - useAppSelector(({ quiz }) => ({ - category: quiz.currentCategory, - currentCategoryIndex: quiz.currentCategoryIndex, - dataStatus: quiz.dataStatus, - questions: quiz.questions, - })); - - const defaultValues = getQuizDefaultValues(questions); + const { + categoryQuestions, + currentCategoryIndex, + dataStatus, + isRetakingQuiz, + questionsByCategories, + } = useAppSelector(({ quiz }) => ({ + categoryQuestions: quiz.currentCategoryQuestions, + currentCategoryIndex: quiz.currentCategoryIndex, + dataStatus: quiz.dataStatus, + isRetakingQuiz: quiz.isRetakingQuiz, + questionsByCategories: quiz.questionsByCategories, + })); + + const defaultValues = getQuizDefaultValues(questionsByCategories) as Record< + string, + string + >; const { control, getValues, handleSubmit } = useAppForm({ defaultValues, }); useEffect(() => { - void dispatch(quizActions.getAllQuestions()); - }, [dispatch]); + if (!isRetakingQuiz) { + void dispatch(quizActions.getAllQuestions()); + } + }, [dispatch, isRetakingQuiz]); useEffect(() => { setIsLast( - currentCategoryIndex === questions.length - PREVIOUS_INDEX_OFFSET, + currentCategoryIndex === + questionsByCategories.length - PREVIOUS_INDEX_OFFSET, ); - }, [currentCategoryIndex, questions]); + }, [currentCategoryIndex, questionsByCategories]); const handlePreviousStep = useCallback(() => { void dispatch(quizActions.previousQuestion()); @@ -71,11 +85,11 @@ const QuizForm: React.FC = ({ onNext }: Properties) => { }, [categoryDone, currentCategoryIndex]); const handleOnChange = useCallback(() => { - if (!category) { + if (!categoryQuestions) { return; } - const questionLabels = category.map( + const questionLabels = categoryQuestions.map( (categoryItem) => `question${categoryItem.id.toString()}`, ); const formValues = getValues(); @@ -90,7 +104,7 @@ const QuizForm: React.FC = ({ onNext }: Properties) => { setIsDisabled(false); } - }, [category, categoryDone, currentCategoryIndex, getValues]); + }, [categoryQuestions, categoryDone, currentCategoryIndex, getValues]); const getAnswerIds = useCallback((formData: QuizFormValues) => { return Object.values(formData).map(Number); @@ -100,15 +114,32 @@ const QuizForm: React.FC = ({ onNext }: Properties) => { (data: QuizFormValues) => { if (isLast) { const answerIds = getAnswerIds(data); - void dispatch(quizActions.saveAnswers({ answerIds })); - void dispatch(authActions.updateQuizAnsweredState()); - onNext(); + + if (isRetakingQuiz) { + const categoryIds = extractCategoryIdsFromQuestions( + questionsByCategories, + ); + + void dispatch(quizActions.saveAnswers({ answerIds, categoryIds })); + void dispatch(quizActions.setStep(Step.BALANCE_WHEEL)); + } else { + void dispatch(quizActions.saveAnswers({ answerIds })); + void dispatch(authActions.updateQuizAnsweredState()); + onNext(); + } } setIsDisabled(true); void dispatch(quizActions.nextQuestion()); }, - [dispatch, getAnswerIds, isLast, onNext], + [ + dispatch, + getAnswerIds, + isLast, + isRetakingQuiz, + questionsByCategories, + onNext, + ], ); const handleFormSubmit = useCallback( @@ -130,7 +161,7 @@ const QuizForm: React.FC = ({ onNext }: Properties) => {

Wheel Quiz questions

@@ -140,7 +171,7 @@ const QuizForm: React.FC = ({ onNext }: Properties) => { ) : ( - category?.map((question) => { + categoryQuestions?.map((question) => { const answerOptions = question.answers.map(({ id, label }) => ({ label, value: id.toString(), diff --git a/apps/frontend/src/pages/quiz/quiz.tsx b/apps/frontend/src/pages/quiz/quiz.tsx index 16f07b404..1800ace1a 100644 --- a/apps/frontend/src/pages/quiz/quiz.tsx +++ b/apps/frontend/src/pages/quiz/quiz.tsx @@ -3,8 +3,8 @@ import { useAppSelector, useCallback, useEffect, - useState, } from "~/libs/hooks/hooks.js"; +import { actions as quizActions } from "~/modules/quiz/quiz.js"; import { type NotificationAnswersPayloadDto, actions as userActions, @@ -19,12 +19,15 @@ import { OnboardingForm, QuizForm, } from "./libs/components/components.js"; -import { PREVIOUS_INDEX_OFFSET } from "./libs/constants/constants.js"; import { Step } from "./libs/enums/enums.js"; const Quiz: React.FC = () => { - const [step, setStep] = useState(Step.MOTIVATION); const dispatch = useAppDispatch(); + const { isRetakingQuiz, step } = useAppSelector(({ quiz }) => quiz); + + const handleNextStep = useCallback((): void => { + dispatch(quizActions.nextStep()); + }, [dispatch]); const { hasAnsweredOnboardingQuestions } = useAppSelector(({ auth }) => ({ hasAnsweredOnboardingQuestions: auth.user?.hasAnsweredOnboardingQuestions, @@ -32,13 +35,9 @@ const Quiz: React.FC = () => { useEffect(() => { if (hasAnsweredOnboardingQuestions) { - setStep(Step.INTRODUCTION); + dispatch(quizActions.setStep(Step.INTRODUCTION)); } - }, [hasAnsweredOnboardingQuestions]); - - const handleNextStep = useCallback((): void => { - setStep((previousStep) => previousStep + PREVIOUS_INDEX_OFFSET); - }, []); + }, [hasAnsweredOnboardingQuestions, dispatch]); const handleNotificationQuestionsSubmit = useCallback( (payload: NotificationAnswersPayloadDto): void => { @@ -48,6 +47,12 @@ const Quiz: React.FC = () => { [dispatch, handleNextStep], ); + useEffect(() => { + if (hasAnsweredOnboardingQuestions && isRetakingQuiz) { + dispatch(quizActions.setStep(Step.INTRODUCTION)); + } + }, [dispatch, hasAnsweredOnboardingQuestions, isRetakingQuiz]); + const getScreen = (step: number): React.ReactNode => { switch (step) { case Step.MOTIVATION: { diff --git a/apps/frontend/src/pages/root/components/user-wheel/libs/components/components.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/components/components.ts new file mode 100644 index 000000000..c4bf2ca6b --- /dev/null +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/components/components.ts @@ -0,0 +1,2 @@ +export { RetakeQuizModal } from "./retake-quiz-modal/retake-quiz-modal.js"; +export { ScoresEditModal } from "./scores-edit-modal/scores-edit-modal.js"; diff --git a/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/retake-quiz-modal.tsx b/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/retake-quiz-modal.tsx new file mode 100644 index 000000000..ea77f61f2 --- /dev/null +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/retake-quiz-modal.tsx @@ -0,0 +1,36 @@ +import { QuizCategoriesForm } from "~/libs/components/components.js"; +import { AppRoute } from "~/libs/enums/enums.js"; +import { useAppDispatch, useCallback } from "~/libs/hooks/hooks.js"; +import { actions as appActions } from "~/modules/app/app.js"; +import { + type CategoriesGetRequestQueryDto, + actions as quizActions, +} from "~/modules/quiz/quiz.js"; + +import styles from "./styles.module.css"; + +const RetakeQuizModal: React.FC = () => { + const dispatch = useAppDispatch(); + + const handleSubmit = useCallback( + (payload: CategoriesGetRequestQueryDto): void => { + void dispatch(quizActions.getQuestionsByCategoryIds(payload)); + void dispatch(appActions.changeLink(AppRoute.QUIZ)); + }, + [dispatch], + ); + + return ( +
+
+ +
+
+ ); +}; + +export { RetakeQuizModal }; diff --git a/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/styles.module.css b/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/styles.module.css new file mode 100644 index 000000000..55d2e5615 --- /dev/null +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/components/retake-quiz-modal/styles.module.css @@ -0,0 +1,17 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + width: 391px; + padding: 15px 40px; + border: 1px solid var(--light-gray); + border-radius: 16px; +} + +.categories-container { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; +} diff --git a/apps/frontend/src/libs/components/scores-edit-modal/libs/constants/constants.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/constants/constants.ts similarity index 100% rename from apps/frontend/src/libs/components/scores-edit-modal/libs/constants/constants.ts rename to apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/constants/constants.ts diff --git a/apps/frontend/src/libs/components/scores-edit-modal/libs/types/modal-data.type.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/types/modal-data.type.ts similarity index 100% rename from apps/frontend/src/libs/components/scores-edit-modal/libs/types/modal-data.type.ts rename to apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/types/modal-data.type.ts diff --git a/apps/frontend/src/libs/components/scores-edit-modal/libs/types/types.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/types/types.ts similarity index 100% rename from apps/frontend/src/libs/components/scores-edit-modal/libs/types/types.ts rename to apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/libs/types/types.ts diff --git a/apps/frontend/src/libs/components/scores-edit-modal/scores-edit-modal.tsx b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/scores-edit-modal.tsx similarity index 96% rename from apps/frontend/src/libs/components/scores-edit-modal/scores-edit-modal.tsx rename to apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/scores-edit-modal.tsx index b5eac0e7b..ce064216a 100644 --- a/apps/frontend/src/libs/components/scores-edit-modal/scores-edit-modal.tsx +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/scores-edit-modal.tsx @@ -1,7 +1,7 @@ +import { Button, Slider } from "~/libs/components/components.js"; import { useAppDispatch, useCallback, useState } from "~/libs/hooks/hooks.js"; import { actions as quizActions } from "~/modules/quiz/quiz.js"; -import { Button, Slider } from "../components.js"; import { NO_SCORES_COUNT } from "./libs/constants/constants.js"; import { type ModalData } from "./libs/types/types.js"; import styles from "./styles.module.css"; diff --git a/apps/frontend/src/libs/components/scores-edit-modal/styles.module.css b/apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/styles.module.css similarity index 100% rename from apps/frontend/src/libs/components/scores-edit-modal/styles.module.css rename to apps/frontend/src/pages/root/components/user-wheel/libs/components/scores-edit-modal/styles.module.css diff --git a/apps/frontend/src/pages/root/components/user-wheel/libs/types/types.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/types/types.ts new file mode 100644 index 000000000..ea8fe8856 --- /dev/null +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/types/types.ts @@ -0,0 +1 @@ +export { type WheelEditMode } from "./wheel-edit-mode.type.js"; diff --git a/apps/frontend/src/pages/root/components/user-wheel/libs/types/wheel-edit-mode.type.ts b/apps/frontend/src/pages/root/components/user-wheel/libs/types/wheel-edit-mode.type.ts new file mode 100644 index 000000000..078b12560 --- /dev/null +++ b/apps/frontend/src/pages/root/components/user-wheel/libs/types/wheel-edit-mode.type.ts @@ -0,0 +1,3 @@ +type WheelEditMode = "manual" | "retake_quiz"; + +export { type WheelEditMode }; diff --git a/apps/frontend/src/pages/root/components/user-wheel/styles.module.css b/apps/frontend/src/pages/root/components/user-wheel/styles.module.css index c1b3b9b84..aa1e64ab3 100644 --- a/apps/frontend/src/pages/root/components/user-wheel/styles.module.css +++ b/apps/frontend/src/pages/root/components/user-wheel/styles.module.css @@ -7,6 +7,7 @@ .header { display: flex; + align-items: center; width: 100%; height: 69px; padding: 13px 33px; diff --git a/apps/frontend/src/pages/root/components/user-wheel/user-wheel.tsx b/apps/frontend/src/pages/root/components/user-wheel/user-wheel.tsx index d1cee8c64..c99df1447 100644 --- a/apps/frontend/src/pages/root/components/user-wheel/user-wheel.tsx +++ b/apps/frontend/src/pages/root/components/user-wheel/user-wheel.tsx @@ -2,7 +2,7 @@ import { BalanceWheelChart, Button, Loader, - ScoresEditModal, + Switch, } from "~/libs/components/components.js"; import { useAppDispatch, @@ -13,15 +13,20 @@ import { } from "~/libs/hooks/hooks.js"; import { actions as quizActions } from "~/modules/quiz/quiz.js"; +import { + RetakeQuizModal, + ScoresEditModal, +} from "./libs/components/components.js"; +import { type WheelEditMode } from "./libs/types/types.js"; import styles from "./styles.module.css"; -const UserWheel: React.FC = () => { - const NO_SCORES_COUNT = 0; +const NO_SCORES_COUNT = 0; +const UserWheel: React.FC = () => { const dispatch = useAppDispatch(); const { dataStatus, scores } = useAppSelector((state) => state.quiz); const [isEditingModalOpen, setIsEditingModalOpen] = useState(false); - + const [editMode, setEditMode] = useState("manual"); const isLoading = dataStatus === "pending"; const chartData = scores.map((score) => { @@ -43,22 +48,55 @@ const UserWheel: React.FC = () => { setIsEditingModalOpen(false); }, []); + const handleModeToggle = useCallback(() => { + setEditMode((previousState) => { + return previousState === "manual" ? "retake_quiz" : "manual"; + }); + }, []); + useEffect(() => { void dispatch(quizActions.getScores()); }, [dispatch]); + const handleGetModal = (mode: WheelEditMode): React.ReactNode => { + switch (mode) { + case "manual": { + return ( + + ); + } + + case "retake_quiz": { + return ; + } + + default: { + return null; + } + } + }; + return (

{headerText}

+ {isEditingModalOpen && ( + + )}
{scores.length > NO_SCORES_COUNT && ( )} - {isEditingModalOpen && ( - - )} + {isEditingModalOpen && handleGetModal(editMode)}
{!isEditingModalOpen && (
diff --git a/apps/frontend/src/pages/tasks/libs/components/task-card/styles.module.css b/apps/frontend/src/pages/tasks/libs/components/task-card/styles.module.css index cce4bdf4f..e4868b364 100644 --- a/apps/frontend/src/pages/tasks/libs/components/task-card/styles.module.css +++ b/apps/frontend/src/pages/tasks/libs/components/task-card/styles.module.css @@ -32,5 +32,5 @@ font-size: 14px; font-weight: 400; line-height: 19px; - color: var(--text-secondary); + color: var(--medium-gray); } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dcc84648a..6aec069ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export { + FIRST_ITEM_INDEX, PREVIOUS_INDEX_OFFSET, ZERO_INDEX, } from "./libs/constants/constant.js"; @@ -48,10 +49,12 @@ export { export { CategoriesApiPath, type CategoriesGetAllResponseDto, + type CategoriesGetRequestQueryDto, type CategoriesSelectedRequestDto, categoriesSelectedValidationSchema, type CategoryCreateRequestDto, type CategoryDto, + categoryIdsValidationSchema, type CategoryUpdateRequestDto, type CategoryWithScoresDto, } from "./modules/categories/categories.js"; diff --git a/packages/shared/src/libs/constants/constant.ts b/packages/shared/src/libs/constants/constant.ts index e65287eae..5f986b1e0 100644 --- a/packages/shared/src/libs/constants/constant.ts +++ b/packages/shared/src/libs/constants/constant.ts @@ -1,2 +1,3 @@ +export { FIRST_ITEM_INDEX } from "./first-item-index.constant.js"; export { PREVIOUS_INDEX_OFFSET } from "./previous-index-offset.constant.js"; export { ZERO_INDEX } from "./zero-index.constant.js"; diff --git a/packages/shared/src/libs/constants/first-item-index.constant.ts b/packages/shared/src/libs/constants/first-item-index.constant.ts new file mode 100644 index 000000000..492cdaa81 --- /dev/null +++ b/packages/shared/src/libs/constants/first-item-index.constant.ts @@ -0,0 +1,3 @@ +const FIRST_ITEM_INDEX = 0; + +export { FIRST_ITEM_INDEX }; diff --git a/packages/shared/src/modules/categories/categories.ts b/packages/shared/src/modules/categories/categories.ts index 94a6d33fc..dbd70de56 100644 --- a/packages/shared/src/modules/categories/categories.ts +++ b/packages/shared/src/modules/categories/categories.ts @@ -1,10 +1,12 @@ export { CategoriesApiPath } from "./libs/enums/enums.js"; export { type CategoriesGetAllResponseDto, + type CategoriesGetRequestQueryDto, type CategoriesSelectedRequestDto, type CategoryCreateRequestDto, type CategoryDto, type CategoryUpdateRequestDto, type CategoryWithScoresDto, } from "./libs/types/types.js"; +export { categoryIds as categoryIdsValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; export { categoriesSelected as categoriesSelectedValidationSchema } from "./libs/validation-schemas/validation-schemas.js"; diff --git a/packages/shared/src/modules/categories/libs/enums/categories-validation-message.enum.ts b/packages/shared/src/modules/categories/libs/enums/categories-validation-message.enum.ts index 1717c9c36..21e668328 100644 --- a/packages/shared/src/modules/categories/libs/enums/categories-validation-message.enum.ts +++ b/packages/shared/src/modules/categories/libs/enums/categories-validation-message.enum.ts @@ -1,4 +1,6 @@ const CategoriesValidationMessage = { + NOT_INTEGER_ARRAY: "Query's value should be a stringified array of integers.", ONE_CATEGORY_SELECTED: "At least one category should be selected", } as const; + export { CategoriesValidationMessage }; diff --git a/packages/shared/src/modules/categories/libs/enums/categories-validation-regex-rule.enum.ts b/packages/shared/src/modules/categories/libs/enums/categories-validation-regex-rule.enum.ts new file mode 100644 index 000000000..120c5ba36 --- /dev/null +++ b/packages/shared/src/modules/categories/libs/enums/categories-validation-regex-rule.enum.ts @@ -0,0 +1,7 @@ +const CategoriesValidationRegexRule = { + QUERY_IS_STRINGIFIED_INTEGERS_ARRAY: new RegExp( + /^\[\s*(\d+\s*,\s*)*\d+\s*]$/, + ), +}; + +export { CategoriesValidationRegexRule }; diff --git a/packages/shared/src/modules/categories/libs/enums/enums.ts b/packages/shared/src/modules/categories/libs/enums/enums.ts index 235dab76c..867f1a604 100644 --- a/packages/shared/src/modules/categories/libs/enums/enums.ts +++ b/packages/shared/src/modules/categories/libs/enums/enums.ts @@ -1,3 +1,4 @@ export { CategoriesApiPath } from "./categories-api-path.enum.js"; export { CategoriesValidationMessage } from "./categories-validation-message.enum.js"; +export { CategoriesValidationRegexRule } from "./categories-validation-regex-rule.enum.js"; export { CategoriesValidationRule } from "./categories-validation-rule.enum.js"; diff --git a/packages/shared/src/modules/categories/libs/types/categories-get-request-query-dto.type.ts b/packages/shared/src/modules/categories/libs/types/categories-get-request-query-dto.type.ts new file mode 100644 index 000000000..a5cdfdefd --- /dev/null +++ b/packages/shared/src/modules/categories/libs/types/categories-get-request-query-dto.type.ts @@ -0,0 +1,5 @@ +type CategoriesGetRequestQueryDto = { + categoryIds: string; +}; + +export { type CategoriesGetRequestQueryDto }; diff --git a/packages/shared/src/modules/categories/libs/types/types.ts b/packages/shared/src/modules/categories/libs/types/types.ts index b3b66ba9f..725176a85 100644 --- a/packages/shared/src/modules/categories/libs/types/types.ts +++ b/packages/shared/src/modules/categories/libs/types/types.ts @@ -1,4 +1,5 @@ export { type CategoriesGetAllResponseDto } from "./categories-get-all-response-dto.type.js"; +export { type CategoriesGetRequestQueryDto } from "./categories-get-request-query-dto.type.js"; export { type CategoriesSelectedRequestDto } from "./categories-selected-request-dto.type.js"; export { type CategoryCreateRequestDto } from "./category-create-request-dto.type.js"; export { type CategoryDto } from "./category-dto.type.js"; diff --git a/packages/shared/src/modules/categories/libs/validation-schemas/category-ids.validation-schema.ts b/packages/shared/src/modules/categories/libs/validation-schemas/category-ids.validation-schema.ts new file mode 100644 index 000000000..d2ef3e920 --- /dev/null +++ b/packages/shared/src/modules/categories/libs/validation-schemas/category-ids.validation-schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { + CategoriesValidationMessage, + CategoriesValidationRegexRule, +} from "../enums/enums.js"; + +const categoryIds = z.object({ + categoryIds: z + .string() + .trim() + .regex( + CategoriesValidationRegexRule.QUERY_IS_STRINGIFIED_INTEGERS_ARRAY, + CategoriesValidationMessage.NOT_INTEGER_ARRAY, + ) + .optional(), +}); + +export { categoryIds }; diff --git a/packages/shared/src/modules/categories/libs/validation-schemas/validation-schemas.ts b/packages/shared/src/modules/categories/libs/validation-schemas/validation-schemas.ts index c0ba87f20..80cd51a29 100644 --- a/packages/shared/src/modules/categories/libs/validation-schemas/validation-schemas.ts +++ b/packages/shared/src/modules/categories/libs/validation-schemas/validation-schemas.ts @@ -1 +1,2 @@ export { categoriesSelected } from "./categories-selected.validation-schema.js"; +export { categoryIds } from "./category-ids.validation-schema.js"; diff --git a/packages/shared/src/modules/quiz/libs/types/quiz-answers-request-dto.type.ts b/packages/shared/src/modules/quiz/libs/types/quiz-answers-request-dto.type.ts index 6e1acd383..2b601183e 100644 --- a/packages/shared/src/modules/quiz/libs/types/quiz-answers-request-dto.type.ts +++ b/packages/shared/src/modules/quiz/libs/types/quiz-answers-request-dto.type.ts @@ -1,5 +1,6 @@ type QuizAnswersRequestDto = { answerIds: number[]; + categoryIds?: number[]; }; export { type QuizAnswersRequestDto }; diff --git a/packages/shared/src/modules/quiz/libs/validation-schemas/quiz-user-answers.validation-schema.ts b/packages/shared/src/modules/quiz/libs/validation-schemas/quiz-user-answers.validation-schema.ts index 0fa54009a..3c87942e7 100644 --- a/packages/shared/src/modules/quiz/libs/validation-schemas/quiz-user-answers.validation-schema.ts +++ b/packages/shared/src/modules/quiz/libs/validation-schemas/quiz-user-answers.validation-schema.ts @@ -4,6 +4,7 @@ import { QuizValidationMessage } from "../enums/enums.js"; type QuizUserAnswersValidationSchema = { answerIds: z.ZodEffects, number[], number[]>; + categoryIds: z.ZodOptional>; }; const quizUserAnswers = z.object({ @@ -12,6 +13,9 @@ const quizUserAnswers = z.object({ .refine((ids) => new Set(ids).size === ids.length, { message: QuizValidationMessage.UNIQUE_ANSWERS, }), + categoryIds: z + .array(z.number({ message: QuizValidationMessage.INVALID_REQUEST })) + .optional(), }); export { quizUserAnswers };