This commit is contained in:
nflnkr 2023-11-14 23:15:52 +03:00
parent a57de5fe4d
commit dd46a3833f
16 changed files with 968 additions and 939 deletions

@ -1,6 +1,6 @@
import { makeRequest } from "@frontend/kitui"; import { makeRequest } from "@frontend/kitui";
import { CreateQuestionRequest } from "model/question/create"; import { CreateQuestionRequest } from "model/question/create";
import { Question } from "model/question/question"; import { RawQuestion } from "model/question/question";
import { GetQuestionListRequest, GetQuestionListResponse } from "model/question/getList"; import { GetQuestionListRequest, GetQuestionListResponse } from "model/question/getList";
import { EditQuestionRequest, EditQuestionResponse } from "model/question/edit"; import { EditQuestionRequest, EditQuestionResponse } from "model/question/edit";
import { DeleteQuestionRequest, DeleteQuestionResponse } from "model/question/delete"; import { DeleteQuestionRequest, DeleteQuestionResponse } from "model/question/delete";
@ -9,32 +9,27 @@ import { CopyQuestionRequest, CopyQuestionResponse } from "model/question/copy";
const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz"; const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz";
export function createQuestion(body: CreateQuestionRequest = defaultCreateQuestionBody) { function createQuestion(body?: Partial<CreateQuestionRequest>) {
return makeRequest<CreateQuestionRequest, Question>({ return makeRequest<CreateQuestionRequest, RawQuestion>({
url: `${baseUrl}/question/create`, url: `${baseUrl}/question/create`,
body, body: { ...defaultCreateQuestionBody, ...body },
method: "POST", method: "POST",
}); });
} }
export function getQuestionList(body: GetQuestionListRequest = defaultGetQuestionListBody) { async function getQuestionList(body?: Partial<GetQuestionListRequest>) {
return makeRequest<GetQuestionListRequest, GetQuestionListResponse>({ if (!body?.quiz_id) return null;
const response = await makeRequest<GetQuestionListRequest, GetQuestionListResponse>({
url: `${baseUrl}/question/getList`, url: `${baseUrl}/question/getList`,
body, body: { ...defaultGetQuestionListBody, ...body },
method: "GET", method: "POST",
}); });
return response.items;
} }
export function editQuestion(updatedQuestion: Question, signal?: AbortSignal) { function editQuestion(body: EditQuestionRequest, signal?: AbortSignal) {
const body: EditQuestionRequest = {
id: updatedQuestion.id,
title: updatedQuestion.title,
desc: updatedQuestion.description,
type: updatedQuestion.type,
required: updatedQuestion.required,
page: updatedQuestion.page,
};
return makeRequest<EditQuestionRequest, EditQuestionResponse>({ return makeRequest<EditQuestionRequest, EditQuestionResponse>({
url: `${baseUrl}/question/edit`, url: `${baseUrl}/question/edit`,
body, body,
@ -43,7 +38,7 @@ export function editQuestion(updatedQuestion: Question, signal?: AbortSignal) {
}); });
} }
export function copyQuestion(copyQuestionBody: CopyQuestionRequest) { function copyQuestion(copyQuestionBody: CopyQuestionRequest) {
return makeRequest<CopyQuestionRequest, CopyQuestionResponse>({ return makeRequest<CopyQuestionRequest, CopyQuestionResponse>({
url: `${baseUrl}/question/copy`, url: `${baseUrl}/question/copy`,
body: copyQuestionBody, body: copyQuestionBody,
@ -51,7 +46,7 @@ export function copyQuestion(copyQuestionBody: CopyQuestionRequest) {
}); });
} }
export function deleteQuestion(id: number) { function deleteQuestion(id: number) {
return makeRequest<DeleteQuestionRequest, DeleteQuestionResponse>({ return makeRequest<DeleteQuestionRequest, DeleteQuestionResponse>({
url: `${baseUrl}/question/delete`, url: `${baseUrl}/question/delete`,
body: { id }, body: { id },
@ -72,7 +67,7 @@ const defaultCreateQuestionBody: CreateQuestionRequest = {
"quiz_id": 0, "quiz_id": 0,
"title": "string", "title": "string",
"description": "string", "description": "string",
"type": "string", "type": "variant",
"required": true, "required": true,
"page": 0, "page": 0,
"content": "string", "content": "string",
@ -87,14 +82,4 @@ const defaultGetQuestionListBody: GetQuestionListRequest = {
"type": "string", "type": "string",
"deleted": true, "deleted": true,
"required": true, "required": true,
"quiz_id": 0
};
const defaultEditQuestionBody: EditQuestionRequest = {
"id": 0,
"title": "string",
"desc": "string",
"type": "",
"required": true,
"page": 0
}; };

@ -38,7 +38,6 @@ function getQuiz(body?: Partial<GetQuizRequest>) {
} }
async function editQuiz(body: EditQuizRequest, signal?: AbortSignal) { async function editQuiz(body: EditQuizRequest, signal?: AbortSignal) {
// await new Promise((resolve) => setTimeout(resolve, 1000));
return makeRequest<EditQuizRequest, EditQuizResponse>({ return makeRequest<EditQuizRequest, EditQuizResponse>({
url: `${baseUrl}/quiz/edit`, url: `${baseUrl}/quiz/edit`,
body, body,
@ -106,26 +105,6 @@ const defaultCreateQuizBody: CreateQuizRequest = {
"group_id": 0, "group_id": 0,
}; };
const defaultEditQuizBody: EditQuizRequest = {
"id": 0,
"fp": true,
"rep": true,
"note_prevented": true,
"mailing": true,
"uniq": true,
"name": "string",
"desc": "string",
"conf": "string",
"status": "string",
"limit": 0,
"due_to": 0,
"time_of_passing": 0,
"pausable": true,
"question_cnt": 0,
"super": true,
"group_id": 0,
};
const defaultGetQuizBody: GetQuizRequest = { const defaultGetQuizBody: GetQuizRequest = {
"quiz_id": "string", "quiz_id": "string",
"limit": 0, "limit": 0,

@ -1,31 +1,34 @@
import type { QuizQuestionInitial } from "../model/questionTypes/shared"; import type { QuizQuestionBase } from "../model/questionTypes/shared";
export const QUIZ_QUESTION_BASE: Omit<QuizQuestionInitial, "id"> = {
title: "", export const QUIZ_QUESTION_BASE: Omit<QuizQuestionBase, "id"> = {
type: "nonselected", quizId: 0,
expanded: true, description: "",
openedModalSettings: false, page: 0,
required: false, title: "",
deleted: false, expanded: true,
deleteTimeoutId: 0, openedModalSettings: false,
content: { required: false,
hint: { deleted: false,
text: "", deleteTimeoutId: 0,
video: "", content: {
}, hint: {
rule: { text: "",
or: true, video: "",
show: true,
title: "",
reqs: [
{
id: "",
vars: [],
}, },
], rule: {
or: true,
show: true,
title: "",
reqs: [
{
id: "",
vars: [],
},
],
},
back: "",
originalBack: "",
autofill: false,
}, },
back: "",
originalBack: "",
autofill: false,
},
}; };

28
src/constants/default.ts Normal file

@ -0,0 +1,28 @@
import { QuestionType } from "@model/question/question";
import { QUIZ_QUESTION_DATE } from "./date";
import { QUIZ_QUESTION_EMOJI } from "./emoji";
import { QUIZ_QUESTION_FILE } from "./file";
import { QUIZ_QUESTION_IMAGES } from "./images";
import { QUIZ_QUESTION_NUMBER } from "./number";
import { QUIZ_QUESTION_PAGE } from "./page";
import { QUIZ_QUESTION_RATING } from "./rating";
import { QUIZ_QUESTION_SELECT } from "./select";
import { QUIZ_QUESTION_TEXT } from "./text";
import { QUIZ_QUESTION_VARIANT } from "./variant";
import { QUIZ_QUESTION_VARIMG } from "./varimg";
import { AnyQuestionContent } from "@model/questionTypes/shared";
export const defaultQuestionContentByType: Record<QuestionType, AnyQuestionContent> = {
"date": QUIZ_QUESTION_DATE.content,
"emoji": QUIZ_QUESTION_EMOJI.content,
"file": QUIZ_QUESTION_FILE.content,
"images": QUIZ_QUESTION_IMAGES.content,
"number": QUIZ_QUESTION_NUMBER.content,
"page": QUIZ_QUESTION_PAGE.content,
"rating": QUIZ_QUESTION_RATING.content,
"select": QUIZ_QUESTION_SELECT.content,
"text": QUIZ_QUESTION_TEXT.content,
"variant": QUIZ_QUESTION_VARIANT.content,
"varimg": QUIZ_QUESTION_VARIMG.content,
} as const;

@ -1,12 +1,26 @@
import { AnyQuizQuestion, DefiniteQuestionType } from "@model/questionTypes/shared";
export interface EditQuestionRequest { export interface EditQuestionRequest {
id: number; id: number;
title: string; title?: string;
desc: string; desc?: string;
type: "test" | "button" | "file" | "checkbox" | "select" | "none" | ""; type?: DefiniteQuestionType;
required: boolean; required?: boolean;
page: number; page?: number;
} }
export interface EditQuestionResponse { export interface EditQuestionResponse {
updated: number; updated: number;
} }
export function questionToEditQuestionRequest(question: AnyQuizQuestion): EditQuestionRequest {
return {
id: question.id,
title: question.title,
desc: question.description,
type: question.type,
required: question.required,
page: question.page,
};
}

@ -1,4 +1,4 @@
import { Question } from "./question"; import { RawQuestion } from "./question";
export interface GetQuestionListRequest { export interface GetQuestionListRequest {
@ -24,5 +24,5 @@ export interface GetQuestionListRequest {
export interface GetQuestionListResponse { export interface GetQuestionListResponse {
count: number; count: number;
items: Question[]; items: RawQuestion[];
} }

@ -1,15 +1,69 @@
export interface Question { import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { defaultQuestionContentByType } from "../../constants/default";
export type QuestionType =
| "variant"
| "images"
| "varimg"
| "emoji"
| "text"
| "select"
| "date"
| "number"
| "file"
| "page"
| "rating";
/** Type that comes from server */
export interface RawQuestion {
/** Id of created question */
id: number; id: number;
/** relation to quiz */
quiz_id: number; quiz_id: number;
/** title of question. max 512 length */
title: string; title: string;
/** description of question */
description: string; description: string;
type: "test" | "button" | "file" | "checkbox" | "select" | "none" | ""; /** status of question. allow only text, select, file, variant, images, varimg, emoji, date, number, page, rating */
type: QuestionType;
/** user must pass this question */
required: boolean; required: boolean;
/** true if question is deleted */
deleted: boolean; deleted: boolean;
/** page if question */
page: number; page: number;
/** serialized json of created question */
content: string; content: string;
/** version of quiz */
version: number; version: number;
/** array of previous versions of quiz */
parent_ids: number[]; parent_ids: number[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export function rawQuestionToQuestion(rawQuestion: RawQuestion): AnyQuizQuestion {
let content = defaultQuestionContentByType[rawQuestion.type];
try {
content = JSON.parse(rawQuestion.content);
} catch (error) {
console.warn("Cannot parse question content from string, using default content", error);
}
return {
id: rawQuestion.id,
description: rawQuestion.description,
page: rawQuestion.page,
quizId: rawQuestion.quiz_id,
required: rawQuestion.required,
title: rawQuestion.title,
type: rawQuestion.type,
expanded: true,
openedModalSettings: false,
deleted: false,
deleteTimeoutId: 0,
content,
} as AnyQuizQuestion;
}

@ -1,3 +1,4 @@
import { QuestionType } from "@model/question/question";
import type { QuizQuestionDate } from "./date"; import type { QuizQuestionDate } from "./date";
import type { QuizQuestionEmoji } from "./emoji"; import type { QuizQuestionEmoji } from "./emoji";
import type { QuizQuestionFile } from "./file"; import type { QuizQuestionFile } from "./file";
@ -10,6 +11,7 @@ import type { QuizQuestionText } from "./text";
import type { QuizQuestionVariant } from "./variant"; import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg"; import type { QuizQuestionVarImg } from "./varimg";
export interface QuestionBranchingRule { export interface QuestionBranchingRule {
/** Радиокнопка "Все условия обязательны" */ /** Радиокнопка "Все условия обязательны" */
or: boolean; or: boolean;
@ -45,8 +47,11 @@ export interface ImageQuestionVariant extends QuestionVariant {
export interface QuizQuestionBase { export interface QuizQuestionBase {
id: number; id: number;
quizId: number;
title: string; title: string;
type: string; description: string;
page: number;
type?: QuestionType;
expanded: boolean; expanded: boolean;
openedModalSettings: boolean; openedModalSettings: boolean;
required: boolean; required: boolean;
@ -61,9 +66,9 @@ export interface QuizQuestionBase {
}; };
} }
export interface QuizQuestionInitial extends QuizQuestionBase { // export interface QuizQuestionInitial extends QuizQuestionBase {
type: "nonselected"; // type: "nonselected";
} // }
export type AnyQuizQuestion = export type AnyQuizQuestion =
| QuizQuestionVariant | QuizQuestionVariant
@ -76,9 +81,11 @@ export type AnyQuizQuestion =
| QuizQuestionNumber | QuizQuestionNumber
| QuizQuestionFile | QuizQuestionFile
| QuizQuestionPage | QuizQuestionPage
| QuizQuestionRating | QuizQuestionRating;
| QuizQuestionInitial; // | QuizQuestionInitial;
export type QuizQuestionType = AnyQuizQuestion["type"]; export type QuizQuestionType = AnyQuizQuestion["type"];
export type AnyQuestionContent = AnyQuizQuestion["content"];
export type DefiniteQuestionType = Exclude<QuizQuestionType, "nonselected">; export type DefiniteQuestionType = Exclude<QuizQuestionType, "nonselected">;

@ -1,149 +1,146 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom";
import { import {
Box, Box,
Typography, Typography,
Popper, Popper,
Grow, Grow,
Paper, Paper,
MenuList, MenuList,
MenuItem, MenuItem,
ClickAwayListener, ClickAwayListener,
Modal, Modal,
Button, Button,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { import {
questionStore, updateQuestionsList,
updateQuestionsList, removeQuestionForce,
removeQuestionForce, createQuestion,
createQuestion,
} from "@root/questions"; } from "@root/questions";
import { BUTTON_TYPE_QUESTIONS } from "../TypeQuestions"; import { BUTTON_TYPE_QUESTIONS } from "../TypeQuestions";
import type { RefObject } from "react"; import type { RefObject } from "react";
import type { import type {
QuizQuestionType, QuizQuestionType,
QuizQuestionBase, QuizQuestionBase,
AnyQuizQuestion,
} from "../../../model/questionTypes/shared"; } from "../../../model/questionTypes/shared";
type ChooseAnswerModalProps = { type ChooseAnswerModalProps = {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
anchorRef: RefObject<HTMLDivElement>; anchorRef: RefObject<HTMLDivElement>;
totalIndex: number; question: AnyQuizQuestion;
switchState: string; switchState: string;
}; };
export const ChooseAnswerModal = ({ export const ChooseAnswerModal = ({
open, open,
onClose, onClose,
anchorRef, anchorRef,
totalIndex, question,
switchState, switchState,
}: ChooseAnswerModalProps) => { }: ChooseAnswerModalProps) => {
const [openModal, setOpenModal] = useState<boolean>(false); const [openModal, setOpenModal] = useState<boolean>(false);
const [selectedValue, setSelectedValue] = useState<QuizQuestionType>("text"); const [selectedValue, setSelectedValue] = useState<QuizQuestionType>("text");
const quizId = Number(useParams().quizId); const theme = useTheme();
const { listQuestions } = questionStore();
const theme = useTheme();
return ( return (
<> <>
<Popper <Popper
placement="right-start" placement="right-start"
open={open} open={open}
anchorEl={anchorRef.current} anchorEl={anchorRef.current}
transition transition
> >
{({ TransitionProps }) => ( {({ TransitionProps }) => (
<Grow {...TransitionProps}> <Grow {...TransitionProps}>
<Paper> <Paper>
<ClickAwayListener onClickAway={onClose}> <ClickAwayListener onClickAway={onClose}>
<MenuList autoFocusItem={open}> <MenuList autoFocusItem={open}>
{BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => ( {BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => (
<MenuItem <MenuItem
key={value} key={value}
sx={{ display: "flex", gap: "10px" }} sx={{ display: "flex", gap: "10px" }}
{...(value !== switchState && { {...(value !== switchState && {
onClick: () => { onClick: () => {
onClose(); onClose();
setOpenModal(true); setOpenModal(true);
setSelectedValue(value); setSelectedValue(value);
}, },
})} })}
> >
<Box>{icon}</Box> <Box>{icon}</Box>
<Typography <Typography
sx={{
color:
value === switchState
? theme.palette.brightPurple.main
: theme.palette.grey2.main,
}}
>
{title}
</Typography>
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
<Modal open={openModal} onClose={() => setOpenModal(false)}>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: "30px",
borderRadius: "10px",
background: "#FFFFFF",
}}
>
<Typography variant="h6">
Все настройки, кроме заголовка вопроса будут сброшены
</Typography>
<Box
sx={{ sx={{
color: marginTop: "30px",
value === switchState display: "flex",
? theme.palette.brightPurple.main justifyContent: "center",
: theme.palette.grey2.main, gap: "15px",
}} }}
> >
{title} <Button
</Typography> variant="contained"
</MenuItem> sx={{ minWidth: "150px" }}
))} onClick={() => setOpenModal(false)}
</MenuList> >
</ClickAwayListener> Отмена
</Paper> </Button>
</Grow> <Button
)} variant="contained"
</Popper> sx={{ minWidth: "150px" }}
<Modal open={openModal} onClose={() => setOpenModal(false)}> onClick={() => { // TODO
<Box // setOpenModal(false);
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: "30px",
borderRadius: "10px",
background: "#FFFFFF",
}}
>
<Typography variant="h6">
Все настройки, кроме заголовка вопроса будут сброшены
</Typography>
<Box
sx={{
marginTop: "30px",
display: "flex",
justifyContent: "center",
gap: "15px",
}}
>
<Button
variant="contained"
sx={{ minWidth: "150px" }}
onClick={() => setOpenModal(false)}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ minWidth: "150px" }}
onClick={() => {
setOpenModal(false);
const question = { ...listQuestions[quizId][totalIndex] }; // const question = { ...listQuestions[quizId][totalIndex] };
removeQuestionForce(quizId, question.id); // removeQuestionForce(quizId, question.id);
createQuestion(quizId, selectedValue, totalIndex); // createQuestion(quizId, selectedValue, totalIndex);
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, { // updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
title: question.title, // title: question.title,
expanded: question.expanded, // expanded: question.expanded,
}); // });
}} }}
> >
Подтвердить Подтвердить
</Button> </Button>
</Box> </Box>
</Box> </Box>
</Modal> </Modal>
</> </>
); );
}; };

@ -1,84 +1,80 @@
import { memo } from "react";
import { useParams } from "react-router-dom";
import { Draggable } from "react-beautiful-dnd";
import { Box, ListItem, Typography, useTheme } from "@mui/material"; import { Box, ListItem, Typography, useTheme } from "@mui/material";
import { memo } from "react";
import { Draggable } from "react-beautiful-dnd";
import QuestionsPageCard from "./QuestionPageCard"; import QuestionsPageCard from "./QuestionPageCard";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { updateQuestionsList } from "@root/questions";
import { QuizQuestionBase } from "../../../model/questionTypes/shared"; type Props = {
question: AnyQuizQuestion;
type DraggableListItemProps = { isDragging: boolean;
index: number; index: number;
isDragging: boolean;
questionData: QuizQuestionBase;
}; };
export default memo( function DraggableListItem({ question, isDragging, index }: Props) {
({ index, isDragging, questionData }: DraggableListItemProps) => {
const quizId = Number(useParams().quizId);
const theme = useTheme(); const theme = useTheme();
return ( return (
<Draggable draggableId={String(index)} index={index}> <Draggable draggableId={question.id.toString()} index={index}>
{(provided) => ( {(provided) => (
<ListItem <ListItem
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
sx={{ userSelect: "none", padding: 0 }} sx={{ userSelect: "none", padding: 0 }}
>
{questionData.deleted ? (
<Box
{...provided.dragHandleProps}
sx={{
width: "100%",
maxWidth: "800px",
display: "flex",
justifyContent: "center",
marginBottom: "40px",
gap: "5px",
}}
>
<Typography
sx={{
fontSize: "16px",
color: theme.palette.grey2.main,
}}
> >
Вопрос удалён. {/* questionData.deleted TODO */ true ? (
</Typography> <Box
<Typography {...provided.dragHandleProps}
onClick={() => { sx={{
updateQuestionsList<QuizQuestionBase>(quizId, index, { width: "100%",
...questionData, maxWidth: "800px",
deleted: false, display: "flex",
}); justifyContent: "center",
}} marginBottom: "40px",
sx={{ gap: "5px",
cursor: "pointer", }}
fontSize: "16px", >
textDecoration: "underline", <Typography
color: theme.palette.brightPurple.main, sx={{
textDecorationColor: theme.palette.brightPurple.main, fontSize: "16px",
}} color: theme.palette.grey2.main,
> }}
Восстановить? >
</Typography> Вопрос удалён.
</Box> </Typography>
) : ( <Typography
<Box sx={{ width: "100%", position: "relative" }}> onClick={() => { // TODO
<QuestionsPageCard // updateQuestionsList<QuizQuestionBase>(quizId, index, {
key={index} // ...questionData,
totalIndex={index} // deleted: false,
draggableProps={provided.dragHandleProps} // });
isDragging={isDragging} }}
/> sx={{
</Box> cursor: "pointer",
fontSize: "16px",
textDecoration: "underline",
color: theme.palette.brightPurple.main,
textDecorationColor: theme.palette.brightPurple.main,
}}
>
Восстановить?
</Typography>
</Box>
) : (
<Box sx={{ width: "100%", position: "relative" }}>
<QuestionsPageCard
question={question}
draggableProps={provided.dragHandleProps}
isDragging={isDragging}
/>
</Box>
)}
</ListItem>
)} )}
</ListItem> </Draggable>
)}
</Draggable>
); );
} }
);
const DraggableListItemMemo = memo(DraggableListItem);
export default DraggableListItemMemo;

@ -1,449 +1,433 @@
import { useState, useRef, useEffect } from "react";
import { useParams } from "react-router-dom";
import { import {
Box, Box,
Checkbox, Checkbox,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
IconButton, IconButton,
InputAdornment, InputAdornment,
Paper, Paper,
TextField, TextField,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { ChooseAnswerModal } from "./ChooseAnswerModal";
import TypeQuestions from "../TypeQuestions";
import SwitchQuestionsPage from "../SwitchQuestionsPage"; import SwitchQuestionsPage from "../SwitchQuestionsPage";
import TypeQuestions from "../TypeQuestions";
import { ChooseAnswerModal } from "./ChooseAnswerModal";
import { import {
questionStore, copyQuestion,
updateQuestionsList, createQuestion,
createQuestion, removeQuestion,
copyQuestion, removeQuestionForce,
removeQuestion, updateQuestionsList
removeQuestionForce,
} from "@root/questions"; } from "@root/questions";
import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import { CrossedEyeIcon } from "@icons/CrossedEyeIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon"; import { ArrowDownIcon } from "@icons/questionsPage/ArrowDownIcon";
import { CopyIcon } from "@icons/questionsPage/CopyIcon";
import { OneIcon } from "@icons/questionsPage/OneIcon"; import { OneIcon } from "@icons/questionsPage/OneIcon";
import { PointsIcon } from "@icons/questionsPage/PointsIcon"; import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { CopyIcon } from "@icons/questionsPage/CopyIcon";
import { CrossedEyeIcon } from "@icons/CrossedEyeIcon";
import { HideIcon } from "@icons/questionsPage/hideIcon";
import Answer from "@icons/questionsPage/answer"; import Answer from "@icons/questionsPage/answer";
import OptionsPict from "@icons/questionsPage/options_pict";
import OptionsAndPict from "@icons/questionsPage/options_and_pict";
import Emoji from "@icons/questionsPage/emoji";
import Input from "@icons/questionsPage/input";
import DropDown from "@icons/questionsPage/drop_down";
import Date from "@icons/questionsPage/date"; import Date from "@icons/questionsPage/date";
import Slider from "@icons/questionsPage/slider"; import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import Download from "@icons/questionsPage/download"; import Download from "@icons/questionsPage/download";
import DropDown from "@icons/questionsPage/drop_down";
import Emoji from "@icons/questionsPage/emoji";
import { HideIcon } from "@icons/questionsPage/hideIcon";
import Input from "@icons/questionsPage/input";
import OptionsAndPict from "@icons/questionsPage/options_and_pict";
import OptionsPict from "@icons/questionsPage/options_pict";
import Page from "@icons/questionsPage/page"; import Page from "@icons/questionsPage/page";
import RatingIcon from "@icons/questionsPage/rating"; import RatingIcon from "@icons/questionsPage/rating";
import { ArrowDownIcon } from "@icons/questionsPage/ArrowDownIcon"; import Slider from "@icons/questionsPage/slider";
import { ReactComponent as PlusIcon } from "../../../assets/icons/plus.svg"; import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
import type { import { ReactComponent as PlusIcon } from "../../../assets/icons/plus.svg";
AnyQuizQuestion, import type { AnyQuizQuestion } from "../../../model/questionTypes/shared";
QuizQuestionInitial,
} from "../../../model/questionTypes/shared";
interface Props { interface Props {
totalIndex: number; question: AnyQuizQuestion;
draggableProps: DraggableProvidedDragHandleProps | null | undefined; draggableProps: DraggableProvidedDragHandleProps | null | undefined;
isDragging: boolean; isDragging: boolean;
}
export default function QuestionsPageCard({ question, draggableProps, isDragging }: Props) {
const [plusVisible, setPlusVisible] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const quizId = Number(useParams().quizId);
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const anchorRef = useRef(null);
const debounced = useDebouncedCallback((title) => { // TODO update title
// updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, { title });
}, 200);
return (
<>
<Paper
data-cy="quiz-question-card"
sx={{
maxWidth: "796px",
width: "100%",
borderRadius: "12px",
backgroundColor: question.expanded ? "white" : "#EEE4FC",
border: question.expanded ? "none" : "1px solid #9A9AAF",
boxShadow: "0px 10px 30px #e7e7e7",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
padding: isMobile ? "10px" : "20px 10px 20px 20px",
flexDirection: isMobile ? "column" : null,
}}
>
<FormControl
variant="standard"
sx={{
p: 0,
maxWidth: isTablet ? "549px" : "640px",
width: "100%",
marginRight: isMobile ? "0px" : "16.1px",
}}
>
<TextField
defaultValue={question.title}
placeholder={"Заголовок вопроса"}
onChange={({ target }) => debounced(target.value)}
InputProps={{
startAdornment: (
<Box>
<InputAdornment
ref={anchorRef}
position="start"
sx={{ cursor: "pointer" }}
onClick={() => setOpen((isOpened) => !isOpened)}
>
{IconAndrom(question.expanded, question.type)}
</InputAdornment>
<ChooseAnswerModal
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
question={question}
switchState={question.type}
/>
</Box>
),
}}
sx={{
margin: isMobile ? "10px 0" : 0,
"& .MuiInputBase-root": {
color: "#000000",
backgroundColor: question.expanded
? theme.palette.background.default
: "transparent",
height: "48px",
borderRadius: "10px",
".MuiOutlinedInput-notchedOutline": {
borderWidth: "1px !important",
border: !question.expanded ? "none" : null,
},
"& .MuiInputBase-input::placeholder": {
color: "#4D4D4D",
opacity: 0.8,
},
},
}}
inputProps={{
sx: {
fontSize: "18px",
lineHeight: "21px",
py: 0,
paddingLeft: question.type.length === 0 ? 0 : "18px",
},
"data-cy": "quiz-question-title",
}}
/>
</FormControl>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
width: isMobile ? "100%" : "auto",
position: "relative",
}}
>
<IconButton
sx={{ padding: "0", margin: "5px" }}
disableRipple
data-cy="expand-question"
onClick={() =>
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, {
expanded: !question.expanded,
})
}
>
{question.expanded ? (
<ArrowDownIcon
style={{
width: "18px",
color: "#4D4D4D",
}}
/>
) : (
<ExpandLessIcon
sx={{
boxSizing: "border-box",
fill: theme.palette.brightPurple.main,
background: "#FFF",
borderRadius: "6px",
height: "30px",
width: "30px",
}}
/>
)}
</IconButton>
{question.expanded ? (
<></>
) : (
<Box
sx={{
display: "flex",
height: "30px",
borderRight: "solid 1px #4D4D4D",
}}
>
<FormControlLabel
control={
<Checkbox
icon={
<HideIcon
style={{
boxSizing: "border-box",
color: "#7E2AEA",
background: "#FFF",
borderRadius: "6px",
height: "30px",
width: "30px",
padding: "3px",
}}
/>
}
checkedIcon={<CrossedEyeIcon />}
/>
}
label={""}
sx={{
color: theme.palette.grey2.main,
ml: "-9px",
mr: 0,
userSelect: "none",
}}
/>
<IconButton
sx={{ padding: "0" }}
onClick={() => copyQuestion(quizId, totalIndex)}
>
<CopyIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
<IconButton
sx={{
cursor: "pointer",
borderRadius: "6px",
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
const removedId = question.id;
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
removeQuestion(quizId, totalIndex);
const newTimeoutId = window.setTimeout(() => {
removeQuestionForce(quizId, removedId);
}, 5000);
updateQuestionsList<AnyQuizQuestion>(quizId, totalIndex, {
...question,
deleteTimeoutId: newTimeoutId,
});
}}
>
<DeleteIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
</Box>
)}
<OneIcon
style={{
fontSize: "30px",
marginLeft: "3px",
color: !question.expanded ? "#FFF" : "",
fill: question.expanded
? "#EEE4FC"
: theme.palette.brightPurple.main,
}}
/>
<IconButton
disableRipple
sx={{
padding: isMobile ? "0" : "0 5px",
right: isMobile ? "0" : null,
bottom: isMobile ? "0" : null,
}}
{...draggableProps}
>
<PointsIcon style={{ color: "#4D4D4D", fontSize: "30px" }} />
</IconButton>
</Box>
</Box>
{question.expanded && (
<Box
sx={{
display: "flex",
flexDirection: "column",
padding: 0,
borderRadius: "12px",
}}
>
{question.type === "nonselected" ? (
<TypeQuestions question={question} />
) : (
<SwitchQuestionsPage question={question} />
)}
</Box>
)}
</Paper>
<Box
onMouseEnter={() => setPlusVisible(true)}
onMouseLeave={() => setPlusVisible(false)}
sx={{
maxWidth: "825px",
display: "flex",
alignItems: "center",
height: "40px",
cursor: "pointer",
}}
>
<Box
onClick={() => createQuestion(quizId, "nonselected", totalIndex + 1)}
sx={{
display: plusVisible && !isDragging ? "flex" : "none",
width: "100%",
alignItems: "center",
columnGap: "10px",
}}
>
<Box
sx={{
boxSizing: "border-box",
width: "100%",
height: "1px",
backgroundPosition: "bottom",
backgroundRepeat: "repeat-x",
backgroundSize: "20px 1px",
backgroundImage:
"radial-gradient(circle, #7E2AEA 6px, #F2F3F7 1px)",
}}
/>
<PlusIcon />
</Box>
</Box>
</>
);
} }
const IconAndrom = (isExpanded: boolean, switchState: string) => { const IconAndrom = (isExpanded: boolean, switchState: string) => {
switch (switchState) { switch (switchState) {
case "variant": case "variant":
return ( return (
<Answer <Answer
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "images": case "images":
return ( return (
<OptionsPict <OptionsPict
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "varimg": case "varimg":
return ( return (
<OptionsAndPict <OptionsAndPict
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "emoji": case "emoji":
return ( return (
<Emoji <Emoji
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "text": case "text":
return ( return (
<Input <Input
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "select": case "select":
return ( return (
<DropDown <DropDown
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "date": case "date":
return ( return (
<Date <Date
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "number": case "number":
return ( return (
<Slider <Slider
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "file": case "file":
return ( return (
<Download <Download
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "page": case "page":
return ( return (
<Page <Page
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
case "rating": case "rating":
return ( return (
<RatingIcon <RatingIcon
color={isExpanded ? "#9A9AAF" : "#7E2AEA"} color={isExpanded ? "#9A9AAF" : "#7E2AEA"}
sx={{ height: "22px", width: "20px" }} sx={{ height: "22px", width: "20px" }}
/> />
); );
default: default:
return <></>; return <></>;
}
};
export default function QuestionsPageCard({
totalIndex,
draggableProps,
isDragging,
}: Props) {
const [plusVisible, setPlusVisible] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const quizId = Number(useParams().quizId);
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex];
const anchorRef = useRef(null);
const debounced = useDebouncedCallback((title) => {
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, { title });
}, 200);
useEffect(() => {
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
} }
}, [question]); };
return (
<>
<Paper
id={String(totalIndex)}
data-cy="quiz-question-card"
sx={{
maxWidth: "796px",
width: "100%",
borderRadius: "12px",
backgroundColor: question.expanded ? "white" : "#EEE4FC",
border: question.expanded ? "none" : "1px solid #9A9AAF",
boxShadow: "0px 10px 30px #e7e7e7",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
padding: isMobile ? "10px" : "20px 10px 20px 20px",
flexDirection: isMobile ? "column" : null,
}}
>
<FormControl
variant="standard"
sx={{
p: 0,
maxWidth: isTablet ? "549px" : "640px",
width: "100%",
marginRight: isMobile ? "0px" : "16.1px",
}}
>
<TextField
defaultValue={question.title}
placeholder={"Заголовок вопроса"}
onChange={({ target }) => debounced(target.value)}
InputProps={{
startAdornment: (
<Box>
<InputAdornment
ref={anchorRef}
position="start"
sx={{ cursor: "pointer" }}
onClick={() => setOpen((isOpened) => !isOpened)}
>
{IconAndrom(question.expanded, question.type)}
</InputAdornment>
<ChooseAnswerModal
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
totalIndex={totalIndex}
switchState={question.type}
/>
</Box>
),
}}
sx={{
margin: isMobile ? "10px 0" : 0,
"& .MuiInputBase-root": {
color: "#000000",
backgroundColor: question.expanded
? theme.palette.background.default
: "transparent",
height: "48px",
borderRadius: "10px",
".MuiOutlinedInput-notchedOutline": {
borderWidth: "1px !important",
border: !question.expanded ? "none" : null,
},
"& .MuiInputBase-input::placeholder": {
color: "#4D4D4D",
opacity: 0.8,
},
},
}}
inputProps={{
sx: {
fontSize: "18px",
lineHeight: "21px",
py: 0,
paddingLeft: question.type.length === 0 ? 0 : "18px",
},
"data-cy": "quiz-question-title",
}}
/>
</FormControl>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
width: isMobile ? "100%" : "auto",
position: "relative",
}}
>
<IconButton
sx={{ padding: "0", margin: "5px" }}
disableRipple
data-cy="expand-question"
onClick={() =>
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, {
expanded: !question.expanded,
})
}
>
{question.expanded ? (
<ArrowDownIcon
style={{
width: "18px",
color: "#4D4D4D",
}}
/>
) : (
<ExpandLessIcon
sx={{
boxSizing: "border-box",
fill: theme.palette.brightPurple.main,
background: "#FFF",
borderRadius: "6px",
height: "30px",
width: "30px",
}}
/>
)}
</IconButton>
{question.expanded ? (
<></>
) : (
<Box
sx={{
display: "flex",
height: "30px",
borderRight: "solid 1px #4D4D4D",
}}
>
<FormControlLabel
control={
<Checkbox
icon={
<HideIcon
style={{
boxSizing: "border-box",
color: "#7E2AEA",
background: "#FFF",
borderRadius: "6px",
height: "30px",
width: "30px",
padding: "3px",
}}
/>
}
checkedIcon={<CrossedEyeIcon />}
/>
}
label={""}
sx={{
color: theme.palette.grey2.main,
ml: "-9px",
mr: 0,
userSelect: "none",
}}
/>
<IconButton
sx={{ padding: "0" }}
onClick={() => copyQuestion(quizId, totalIndex)}
>
<CopyIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
<IconButton
sx={{
cursor: "pointer",
borderRadius: "6px",
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
const removedId = question.id;
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
removeQuestion(quizId, totalIndex);
const newTimeoutId = window.setTimeout(() => {
removeQuestionForce(quizId, removedId);
}, 5000);
updateQuestionsList<AnyQuizQuestion>(quizId, totalIndex, {
...question,
deleteTimeoutId: newTimeoutId,
});
}}
>
<DeleteIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
</Box>
)}
<OneIcon
style={{
fontSize: "30px",
marginLeft: "3px",
color: !question.expanded ? "#FFF" : "",
fill: question.expanded
? "#EEE4FC"
: theme.palette.brightPurple.main,
}}
/>
<IconButton
disableRipple
sx={{
padding: isMobile ? "0" : "0 5px",
right: isMobile ? "0" : null,
bottom: isMobile ? "0" : null,
}}
{...draggableProps}
>
<PointsIcon style={{ color: "#4D4D4D", fontSize: "30px" }} />
</IconButton>
</Box>
</Box>
{question.expanded && (
<Box
sx={{
display: "flex",
flexDirection: "column",
padding: 0,
borderRadius: "12px",
}}
>
{question.type === "nonselected" ? (
<TypeQuestions totalIndex={totalIndex} />
) : (
<SwitchQuestionsPage totalIndex={totalIndex} />
)}
</Box>
)}
</Paper>
<Box
onMouseEnter={() => setPlusVisible(true)}
onMouseLeave={() => setPlusVisible(false)}
sx={{
maxWidth: "825px",
display: "flex",
alignItems: "center",
height: "40px",
cursor: "pointer",
}}
>
<Box
onClick={() => createQuestion(quizId, "nonselected", totalIndex + 1)}
sx={{
display: plusVisible && !isDragging ? "flex" : "none",
width: "100%",
alignItems: "center",
columnGap: "10px",
}}
>
<Box
sx={{
boxSizing: "border-box",
width: "100%",
height: "1px",
backgroundPosition: "bottom",
backgroundRepeat: "repeat-x",
backgroundSize: "20px 1px",
backgroundImage:
"radial-gradient(circle, #7E2AEA 6px, #F2F3F7 1px)",
}}
/>
<PlusIcon />
</Box>
</Box>
</>
);
}

@ -1,48 +1,59 @@
import { useParams } from "react-router-dom";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { DragDropContext, Droppable } from "react-beautiful-dnd"; import { DragDropContext, Droppable } from "react-beautiful-dnd";
import DraggableListItem from "./DraggableListItem"; import DraggableListItem from "./DraggableListItem";
import { questionStore, updateQuestionsListDragAndDrop } from "@root/questions";
import { reorder } from "./helper";
import type { DropResult } from "react-beautiful-dnd"; import type { DropResult } from "react-beautiful-dnd";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useQuestionArray } from "@root/questions/hooks";
import useSWR from "swr";
import { questionApi } from "@api/question";
import { setQuestions } from "@root/questions/actions";
import { isAxiosError } from "axios";
import { devlog } from "@frontend/kitui";
import { enqueueSnackbar } from "notistack";
export const DraggableList = () => { export const DraggableList = () => {
const quizId = Number(useParams().quizId); const { quiz } = useCurrentQuiz();
const { listQuestions } = questionStore(); useSWR(["questions", quiz?.id], ([, id]) => questionApi.getList({ quiz_id: id }), {
onSuccess: setQuestions,
onError: error => {
const message = isAxiosError<string>(error) ? (error.response?.data ?? "") : "";
const onDragEnd = ({ destination, source }: DropResult) => { devlog("Error getting question list", error);
if (destination) { enqueueSnackbar(`Не удалось получить вопросы. ${message}`);
const newItems = reorder( }
listQuestions[quizId], });
source.index, const questions = useQuestionArray();
destination.index
);
updateQuestionsListDragAndDrop(quizId, newItems); const onDragEnd = ({ destination, source }: DropResult) => { // TODO
} // if (destination) {
}; // const newItems = reorder(
// listQuestions[quizId],
// source.index,
// destination.index
// );
return ( // updateQuestionsListDragAndDrop(quizId, newItems);
<DragDropContext onDragEnd={onDragEnd}> // }
<Droppable droppableId="droppable-list"> };
{(provided, snapshot) => (
<Box ref={provided.innerRef} {...provided.droppableProps}> return (
{listQuestions[quizId]?.map((_, index) => ( <DragDropContext onDragEnd={onDragEnd}>
<DraggableListItem <Droppable droppableId="droppable-list">
key={index} {(provided, snapshot) => (
index={index} <Box ref={provided.innerRef} {...provided.droppableProps}>
isDragging={snapshot.isDraggingOver} {questions.map((question, index) => (
questionData={_} <DraggableListItem
/> key={question.id}
))} question={question}
{provided.placeholder} isDragging={snapshot.isDraggingOver}
</Box> index={index}
)} />
</Droppable> ))}
</DragDropContext> {provided.placeholder}
); </Box>
)}
</Droppable>
</DragDropContext>
);
}; };

@ -1,120 +1,106 @@
import { import {
Box, Box,
Button, Button,
IconButton, IconButton,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import AddPlus from "../../assets/icons/questionsPage/addPlus"; import { createQuestion } from "@root/questions/actions";
import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft"; import { incrementCurrentStep } from "@root/quizes/actions";
import { quizStore } from "@root/quizes"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { useParams } from "react-router-dom";
import {
questionStore,
createQuestion,
updateQuestionsList,
} from "@root/questions";
import { DraggableList } from "./DraggableList";
import type { AnyQuizQuestion } from "../../model/questionTypes/shared";
import QuizPreview from "@ui_kit/QuizPreview/QuizPreview"; import QuizPreview from "@ui_kit/QuizPreview/QuizPreview";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import AddPlus from "../../assets/icons/questionsPage/addPlus";
import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft";
import { DraggableList } from "./DraggableList";
export default function QuestionsPage() { export default function QuestionsPage() {
const { listQuizes, updateQuizesList } = quizStore(); const theme = useTheme();
const quizId = Number(useParams().quizId); const isMobile = useMediaQuery(theme.breakpoints.down(660));
const { listQuestions } = questionStore(); const { quiz } = useCurrentQuiz();
const handleNext = () => {
updateQuizesList(quizId, { step: listQuizes[quizId].step + 1 });
};
const handleBack = () => { const collapseEverything = () => { // TODO
let result = listQuizes[quizId].step - 1; // listQuestions[quizId].forEach((item, index) => {
updateQuizesList(quizId, { step: result ? result : 1 }); // updateQuestionsList<AnyQuizQuestion>(quizId, index, {
}; // ...item,
// expanded: false,
// });
// });
};
const collapseEverything = () => { if (!quiz) return null;
listQuestions[quizId].forEach((item, index) => {
updateQuestionsList<AnyQuizQuestion>(quizId, index, {
...item,
expanded: false,
});
});
};
const theme = useTheme(); return (
const isMobile = useMediaQuery(theme.breakpoints.down(660)); <>
<Box
return ( sx={{
<> maxWidth: "796px",
<Box width: "100%",
sx={{ display: "flex",
maxWidth: "796px", justifyContent: "space-between",
width: "100%", margin: "60px 0 40px 0",
display: "flex", }}
justifyContent: "space-between", >
margin: "60px 0 40px 0", <Typography variant={"h5"}>Заголовок квиза</Typography>
}} <Button
> sx={{
<Typography variant={"h5"}>Заголовок квиза</Typography> fontSize: "16px",
<Button lineHeight: "19px",
sx={{ padding: 0,
fontSize: "16px", textDecoration: "underline",
lineHeight: "19px", color: theme.palette.brightPurple.main,
padding: 0, textDecorationColor: theme.palette.brightPurple.main,
textDecoration: "underline", }}
color: theme.palette.brightPurple.main, onClick={collapseEverything}
textDecorationColor: theme.palette.brightPurple.main, >
}} Свернуть всё
onClick={collapseEverything} </Button>
> </Box>
Свернуть всё <DraggableList />
</Button> <Box
</Box> sx={{
<DraggableList /> display: "flex",
<Box justifyContent: "space-between",
sx={{ maxWidth: "796px",
display: "flex", }}
justifyContent: "space-between", >
maxWidth: "796px", <IconButton
}} onClick={() => {
> createQuestion(quiz.id);
<IconButton }}
onClick={() => { sx={{
createQuestion(quizId); position: "fixed",
}} left: isMobile ? "20px" : "250px",
sx={{ bottom: "20px",
position: "fixed", }}
left: isMobile ? "20px" : "250px", >
bottom: "20px", <AddPlus />
}} </IconButton>
> <Box sx={{ display: "flex", gap: "8px", marginLeft: "auto" }}>
<AddPlus /> <Button
</IconButton> variant="outlined"
<Box sx={{ display: "flex", gap: "8px", marginLeft: "auto" }}> sx={{ padding: "10px 20px", borderRadius: "8px", height: "44px" }}
<Button >
variant="outlined" <ArrowLeft />
sx={{ padding: "10px 20px", borderRadius: "8px", height: "44px" }} </Button>
> <Button
<ArrowLeft /> variant="contained"
</Button> sx={{
<Button height: "44px",
variant="contained" padding: "10px 20px",
sx={{ borderRadius: "8px",
height: "44px", background: theme.palette.brightPurple.main,
padding: "10px 20px", fontSize: "18px",
borderRadius: "8px", }}
background: theme.palette.brightPurple.main, onClick={incrementCurrentStep}
fontSize: "18px", >
}} Следующий шаг
onClick={handleNext} </Button>
> </Box>
Следующий шаг </Box>
</Button> {createPortal(<QuizPreview />, document.body)}
</Box> </>
</Box> );
{createPortal(<QuizPreview />, document.body)}
</>
);
} }

@ -1,33 +1,24 @@
import { Box } from "@mui/material";
import QuestionsMiniButton from "@ui_kit/QuestionsMiniButton"; import QuestionsMiniButton from "@ui_kit/QuestionsMiniButton";
import Answer from "../../assets/icons/questionsPage/answer"; import Answer from "../../assets/icons/questionsPage/answer";
import OptionsPict from "../../assets/icons/questionsPage/options_pict"; import Date from "../../assets/icons/questionsPage/date";
import OptionsAndPict from "../../assets/icons/questionsPage/options_and_pict"; import Download from "../../assets/icons/questionsPage/download";
import DropDown from "../../assets/icons/questionsPage/drop_down";
import Emoji from "../../assets/icons/questionsPage/emoji"; import Emoji from "../../assets/icons/questionsPage/emoji";
import Input from "../../assets/icons/questionsPage/input"; import Input from "../../assets/icons/questionsPage/input";
import DropDown from "../../assets/icons/questionsPage/drop_down"; import OptionsAndPict from "../../assets/icons/questionsPage/options_and_pict";
import Date from "../../assets/icons/questionsPage/date"; import OptionsPict from "../../assets/icons/questionsPage/options_pict";
import Slider from "../../assets/icons/questionsPage/slider";
import Download from "../../assets/icons/questionsPage/download";
import Page from "../../assets/icons/questionsPage/page"; import Page from "../../assets/icons/questionsPage/page";
import RatingIcon from "../../assets/icons/questionsPage/rating"; import RatingIcon from "../../assets/icons/questionsPage/rating";
import { Box } from "@mui/material"; import Slider from "../../assets/icons/questionsPage/slider";
import React from "react"; import { setQuestionFieldOptimistic } from "@root/questions/actions";
import { useParams } from "react-router-dom";
import {
questionStore,
updateQuestionsList,
createQuestion,
removeQuestionForce,
} from "@root/questions";
import type { import type {
QuizQuestionType, AnyQuizQuestion,
QuizQuestionBase, QuizQuestionType
} from "../../model/questionTypes/shared"; } from "../../model/questionTypes/shared";
interface Props { interface Props {
totalIndex: number; question: AnyQuizQuestion;
} }
type ButtonTypeQuestion = { type ButtonTypeQuestion = {
@ -36,6 +27,30 @@ type ButtonTypeQuestion = {
value: QuizQuestionType; value: QuizQuestionType;
}; };
export default function TypeQuestions({ question }: Props) {
return (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: "20px",
padding: "8px 20px 20px",
}}
>
{BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => (
<QuestionsMiniButton
key={title}
dataCy={`select-questiontype-${value}`}
onClick={() => setQuestionFieldOptimistic(question.id, "type", value)}
icon={icon}
text={title}
/>
))}
</Box>
);
}
export const BUTTON_TYPE_QUESTIONS: ButtonTypeQuestion[] = [ export const BUTTON_TYPE_QUESTIONS: ButtonTypeQuestion[] = [
{ {
icon: <Answer color="#9A9AAF" />, icon: <Answer color="#9A9AAF" />,
@ -93,38 +108,3 @@ export const BUTTON_TYPE_QUESTIONS: ButtonTypeQuestion[] = [
value: "rating", value: "rating",
}, },
]; ];
export default function TypeQuestions({ totalIndex }: Props) {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
return (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: "20px",
padding: "8px 20px 20px",
}}
>
{BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => (
<QuestionsMiniButton
key={title}
dataCy={`select-questiontype-${value}`}
onClick={() => {
const question = { ...listQuestions[quizId][totalIndex] };
removeQuestionForce(quizId, question.id);
createQuestion(quizId, value, totalIndex);
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
expanded: question.expanded,
type: value,
});
}}
icon={icon}
text={title}
/>
))}
</Box>
);
}

@ -1,33 +1,35 @@
import { questionApi } from "@api/question"; import { questionApi } from "@api/question";
import { devlog } from "@frontend/kitui"; import { devlog } from "@frontend/kitui";
import { Question } from "@model/question/question"; import { RawQuestion, rawQuestionToQuestion } from "@model/question/question";
import { produce } from "immer"; import { produce } from "immer";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { QuestionsStore, useQuestionsStore } from "./store"; import { QuestionsStore, useQuestionsStore } from "./store";
import { questionToEditQuestionRequest } from "@model/question/edit";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
export const setQuestions = (quizes: Question[] | null) => setProducedState(state => { export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
state.questionsById = {}; state.questionsById = {};
if (quizes === null) return; if (questions === null) return;
quizes.forEach(question => state.questionsById[question.id] = question); questions.forEach(question => state.questionsById[question.id] = rawQuestionToQuestion(question));
}, { }, {
type: "setQuizes", type: "setQuestions",
quizes, questions,
}); });
export const setQuestion = (question: Question) => setProducedState(state => { export const setQuestion = (question: AnyQuizQuestion) => setProducedState(state => {
state.questionsById[question.id] = question; state.questionsById[question.id] = question;
}, { }, {
type: "setQuestion", type: "setQuestion",
question, question,
}); });
export const setQuestionField = <T extends keyof Question>( export const setQuestionField = <T extends keyof AnyQuizQuestion>(
questionId: number, questionId: number,
field: T, field: T,
value: Question[T], value: AnyQuizQuestion[T],
) => setProducedState(state => { ) => setProducedState(state => {
const question = state.questionsById[questionId]; const question = state.questionsById[questionId];
if (!question) return; if (!question) return;
@ -40,13 +42,13 @@ export const setQuestionField = <T extends keyof Question>(
value, value,
}); });
let savedOriginalQuestion: Question | null = null; let savedOriginalQuestion: AnyQuizQuestion | null = null;
let controller: AbortController | null = null; let controller: AbortController | null = null;
export const setQuestionFieldOptimistic = async <T extends keyof Question>( export const setQuestionFieldOptimistic = async <T extends keyof AnyQuizQuestion>(
questionId: number, questionId: number,
field: T, field: T,
value: Question[T], value: AnyQuizQuestion[T],
) => { ) => {
const question = useQuestionsStore.getState().questionsById[questionId] ?? null; const question = useQuestionsStore.getState().questionsById[questionId] ?? null;
if (!question) return; if (!question) return;
@ -60,9 +62,12 @@ export const setQuestionFieldOptimistic = async <T extends keyof Question>(
setQuestion(currentUpdatedQuestion); setQuestion(currentUpdatedQuestion);
try { try {
const { updated } = await questionApi.edit(currentUpdatedQuestion, controller.signal); const { updated } = await questionApi.edit(
questionToEditQuestionRequest(currentUpdatedQuestion),
controller.signal,
);
setQuestionField(question.id, "version", updated); setQuestionField(question.id, "id", updated);
controller = null; controller = null;
savedOriginalQuestion = null; savedOriginalQuestion = null;
} catch (error) { } catch (error) {
@ -83,7 +88,7 @@ export const setQuestionFieldOptimistic = async <T extends keyof Question>(
export const updateQuestionWithFn = ( export const updateQuestionWithFn = (
questionId: number, questionId: number,
updateFn: (question: Question) => void, updateFn: (question: AnyQuizQuestion) => void,
) => setProducedState(state => { ) => setProducedState(state => {
const question = state.questionsById[questionId]; const question = state.questionsById[questionId];
if (!question) return; if (!question) return;
@ -101,7 +106,7 @@ export const createQuestion = async (quizId: number) => {
quiz_id: quizId, quiz_id: quizId,
}); });
setQuestion(question); setQuestion(rawQuestionToQuestion(question));
} catch (error) { } catch (error) {
devlog("Error creating question", error); devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос"); enqueueSnackbar("Не удалось создать вопрос");

@ -1,10 +1,10 @@
import { Question } from "@model/question/question"; import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { create } from "zustand"; import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
export type QuestionsStore = { export type QuestionsStore = {
questionsById: Record<number, Question | undefined>; questionsById: Record<number, AnyQuizQuestion | undefined>;
}; };
const initialState: QuestionsStore = { const initialState: QuestionsStore = {