WIP use new store actions

This commit is contained in:
nflnkr 2023-11-15 21:38:02 +03:00
parent dd46a3833f
commit 2103fe8977
29 changed files with 1516 additions and 1547 deletions

@ -28,6 +28,7 @@
"html-to-image": "^1.11.11",
"immer": "^10.0.3",
"jszip": "^3.10.1",
"nanoid": "^5.0.3",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",

@ -38,10 +38,10 @@ function editQuestion(body: EditQuestionRequest, signal?: AbortSignal) {
});
}
function copyQuestion(copyQuestionBody: CopyQuestionRequest) {
function copyQuestion(questionId: number, quizId: number) {
return makeRequest<CopyQuestionRequest, CopyQuestionResponse>({
url: `${baseUrl}/question/copy`,
body: copyQuestionBody,
body: { id: questionId, quiz_id: quizId },
method: "POST",
});
}

@ -1,4 +1,5 @@
import { QuestionType } from "@model/question/question";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { QUIZ_QUESTION_DATE } from "./date";
import { QUIZ_QUESTION_EMOJI } from "./emoji";
import { QUIZ_QUESTION_FILE } from "./file";
@ -10,19 +11,18 @@ 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,
export const defaultQuestionByType: Record<QuestionType, Omit<AnyQuizQuestion, "id">> = {
"date": QUIZ_QUESTION_DATE,
"emoji": QUIZ_QUESTION_EMOJI,
"file": QUIZ_QUESTION_FILE,
"images": QUIZ_QUESTION_IMAGES,
"number": QUIZ_QUESTION_NUMBER,
"page": QUIZ_QUESTION_PAGE,
"rating": QUIZ_QUESTION_RATING,
"select": QUIZ_QUESTION_SELECT,
"text": QUIZ_QUESTION_TEXT,
"variant": QUIZ_QUESTION_VARIANT,
"varimg": QUIZ_QUESTION_VARIMG,
} as const;

@ -1,11 +1,12 @@
import { AnyQuizQuestion, DefiniteQuestionType } from "@model/questionTypes/shared";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { QuestionType } from "./question";
export interface EditQuestionRequest {
id: number;
title?: string;
desc?: string;
type?: DefiniteQuestionType;
type?: QuestionType;
required?: boolean;
page?: number;
}

@ -1,5 +1,5 @@
import { AnyQuizQuestion } from "@model/questionTypes/shared";
import { defaultQuestionContentByType } from "../../constants/default";
import { defaultQuestionByType } from "../../constants/default";
export type QuestionType =
@ -44,7 +44,7 @@ export interface RawQuestion {
}
export function rawQuestionToQuestion(rawQuestion: RawQuestion): AnyQuizQuestion {
let content = defaultQuestionContentByType[rawQuestion.type];
let content = defaultQuestionByType[rawQuestion.type].content;
try {
content = JSON.parse(rawQuestion.content);

@ -10,6 +10,7 @@ import type { QuizQuestionSelect } from "./select";
import type { QuizQuestionText } from "./text";
import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg";
import { nanoid } from "nanoid";
export interface QuestionBranchingRule {
@ -32,6 +33,7 @@ export interface QuestionHint {
}
export type QuestionVariant = {
id: string;
/** Текст */
answer: string;
/** Текст подсказки */
@ -84,8 +86,14 @@ export type AnyQuizQuestion =
| QuizQuestionRating;
// | QuizQuestionInitial;
export type QuizQuestionType = AnyQuizQuestion["type"];
export const createQuestionVariant: () => QuestionVariant = () => ({
id: nanoid(),
answer: "",
extendedText: "",
hints: "",
});
export type AnyQuestionContent = AnyQuizQuestion["content"];
export type DefiniteQuestionType = Exclude<QuizQuestionType, "nonselected">;
export const createQuestionImageVariant: () => ImageQuestionVariant = () => ({
...createQuestionVariant(),
originalImageUrl: "",
});

@ -1,68 +1,51 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Draggable } from "react-beautiful-dnd";
import {
Box,
TextField,
FormControl,
InputAdornment,
IconButton,
Popover,
useTheme,
useMediaQuery,
} from "@mui/material";
import { useDebouncedCallback } from "use-debounce";
import { questionStore, updateQuestionsList } from "@root/questions";
import { MessageIcon } from "@icons/messagIcon";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { MessageIcon } from "@icons/messagIcon";
import TextareaAutosize from "@mui/base/TextareaAutosize";
import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
import type { DroppableProvided } from "react-beautiful-dnd";
import {
Box,
FormControl,
IconButton,
InputAdornment,
Popover,
TextField,
useMediaQuery,
useTheme,
} from "@mui/material";
import { addQuestionVariant, deleteQuestionVariant, setQuestionVariantField } from "@root/questions/actions";
import type { KeyboardEvent, ReactNode } from "react";
import { useState } from "react";
import { Draggable } from "react-beautiful-dnd";
import { useDebouncedCallback } from "use-debounce";
import type { ImageQuestionVariant, QuestionVariant } from "../../../model/questionTypes/shared";
type AnswerItemProps = {
index: number;
totalIndex: number;
variants: (QuestionVariant | ImageQuestionVariant)[];
questionId: number;
variant: QuestionVariant | ImageQuestionVariant;
provided: DroppableProvided;
largeCheck: boolean;
additionalContent?: ReactNode;
additionalMobile?: ReactNode;
};
export const AnswerItem = ({
index,
totalIndex,
variants,
variant,
provided,
questionId,
largeCheck,
additionalContent,
additionalMobile,
}: AnswerItemProps) => {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const question = listQuestions[quizId][totalIndex];
const isTablet = useMediaQuery(theme.breakpoints.down(790));
const debounced = useDebouncedCallback((value) => {
const answerNew = variants.slice();
answerNew[index].answer = value;
updateQuestionsList(quizId, totalIndex, {
content: {
...question.content,
variants: answerNew,
},
});
}, 1000);
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const setQuestionVariantAnswer = useDebouncedCallback((value) => {
setQuestionVariantField(questionId, variant.id,"answer", value);
}, 1000);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setIsOpen(true);
@ -72,41 +55,6 @@ export const AnswerItem = ({
setIsOpen(false);
};
const addNewAnswer = () => {
const answerNew = variants.slice();
if (["images", "varimg"].includes(question.type)) {
answerNew.push({ answer: "", hints: "", extendedText: "", originalImageUrl: "" });
} else {
answerNew.push({ answer: "", extendedText: "", hints: "" });
}
updateQuestionsList(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
const deleteAnswer = () => {
const answerNew = variants.slice();
answerNew.splice(index, 1);
updateQuestionsList(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
const changeAnswerHint = (event: ChangeEvent<HTMLTextAreaElement>) => {
const answerNew = variants.slice();
answerNew[index].hints = event.target.value;
updateQuestionsList(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
const largeCheck = ("largeCheck" in question.content) ? question.content.largeCheck : false
return (
<Draggable draggableId={String(index)} index={index}>
{(provided) => (
@ -128,10 +76,10 @@ export const AnswerItem = ({
focused={false}
placeholder={"Добавьте ответ"}
multiline={largeCheck}
onChange={({ target }) => debounced(target.value)}
onChange={({ target }) => setQuestionVariantAnswer(target.value)}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.code === "Enter" && !largeCheck) {
addNewAnswer();
addQuestionVariant(questionId);
}
}}
InputProps={{
@ -174,13 +122,16 @@ export const AnswerItem = ({
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
value={variant.hints}
onChange={changeAnswerHint}
onChange={e => setQuestionVariantField(questionId, variant.id, "hints", e.target.value)}
onKeyDown={(
event: KeyboardEvent<HTMLTextAreaElement>
) => event.stopPropagation()}
/>
</Popover>
<IconButton sx={{ padding: "0" }} onClick={deleteAnswer}>
<IconButton
sx={{ padding: "0" }}
onClick={() => deleteQuestionVariant(questionId, variant.id)}
>
<DeleteIcon
style={{
color: theme.palette.grey2.main,

@ -1,35 +1,28 @@
import { useParams } from "react-router-dom";
import { Box } from "@mui/material";
import { DragDropContext, Droppable } from "react-beautiful-dnd";
import { AnswerItem } from "./AnswerItem";
import { reorderVariants } from "@root/questions";
import { type ReactNode } from "react";
import type { DropResult } from "react-beautiful-dnd";
import type { ImageQuestionVariant, QuestionVariant } from "../../../model/questionTypes/shared";
import type { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant } from "../../../model/questionTypes/shared";
import { reorderQuestionVariants } from "@root/questions/actions";
type AnswerDraggableListProps = {
variants: QuestionVariant[];
totalIndex: number;
question: AnyQuizQuestion;
additionalContent?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
additionalMobile?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
};
export const AnswerDraggableList = ({
variants,
totalIndex,
question,
additionalContent,
additionalMobile,
}: AnswerDraggableListProps) => {
const quizId = Number(useParams().quizId);
const onDragEnd = ({ destination, source }: DropResult) => {
if (destination) {
reorderVariants(quizId, totalIndex, source.index, destination.index);
reorderQuestionVariants(question.id, source.index, destination.index);
}
};
@ -40,12 +33,11 @@ export const AnswerDraggableList = ({
<Box ref={provided.innerRef} {...provided.droppableProps}>
{variants.map((variant, index) => (
<AnswerItem
key={index}
key={variant.id}
index={index}
totalIndex={totalIndex}
variants={variants}
questionId={question.id}
largeCheck={("largeCheck" in question.content) ? question.content.largeCheck : false}
variant={variant}
provided={provided}
additionalContent={additionalContent?.(variant, index)}
additionalMobile={additionalMobile?.(variant, index)}
/>

@ -1,347 +1,332 @@
import { useState, useEffect } from "react";
import MiniButtonSetting from "@ui_kit/MiniButtonSetting";
import SettingIcon from "../../assets/icons/questionsPage/settingIcon";
import Clue from "../../assets/icons/questionsPage/clue";
import Branching from "../../assets/icons/questionsPage/branching";
import {
Box,
IconButton,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { HideIcon } from "../../assets/icons/questionsPage/hideIcon";
import { CopyIcon } from "../../assets/icons/questionsPage/CopyIcon";
import { DeleteIcon } from "../../assets/icons/questionsPage/deleteIcon";
import ImgIcon from "../../assets/icons/questionsPage/imgIcon";
import { useParams } from "react-router-dom";
import { quizStore } from "@root/quizes";
import {
questionStore,
copyQuestion,
removeQuestion,
removeQuestionForce,
updateQuestionsList,
} from "@root/questions";
import { DoubleArrowRight } from "@icons/questionsPage/DoubleArrowRight";
import { DoubleTick } from "@icons/questionsPage/DoubleTick";
import { VectorQuestions } from "@icons/questionsPage/VectorQuestions";
import {
Box,
IconButton,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { copyQuestion, deleteQuestion, updateQuestionWithFnOptimistic } from "@root/questions/actions";
import MiniButtonSetting from "@ui_kit/MiniButtonSetting";
import { ReallyChangingModal } from "@ui_kit/Modal/ReallyChangingModal/ReallyChangingModal";
import { useEffect, useState } from "react";
import { CopyIcon } from "../../assets/icons/questionsPage/CopyIcon";
import Branching from "../../assets/icons/questionsPage/branching";
import Clue from "../../assets/icons/questionsPage/clue";
import { DeleteIcon } from "../../assets/icons/questionsPage/deleteIcon";
import { HideIcon } from "../../assets/icons/questionsPage/hideIcon";
import ImgIcon from "../../assets/icons/questionsPage/imgIcon";
import SettingIcon from "../../assets/icons/questionsPage/settingIcon";
import type { AnyQuizQuestion } from "../../model/questionTypes/shared";
import type { QuizQuestionBase } from "../../model/questionTypes/shared";
interface Props {
switchState: string;
SSHC: (data: string) => void;
totalIndex: number;
switchState: string;
SSHC: (data: string) => void;
question: AnyQuizQuestion;
}
export default function ButtonsOptionsAndPict({
SSHC,
switchState,
totalIndex,
SSHC,
switchState,
question,
}: Props) {
const [buttonHover, setButtonHover] = useState<string>("");
const quizId = Number(useParams().quizId);
const { listQuizes } = quizStore();
const { listQuestions } = questionStore();
const [openedReallyChangingModal, setOpenedReallyChangingModal] =
useState<boolean>(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const isIconMobile = useMediaQuery(theme.breakpoints.down(1050));
const quize = listQuizes[quizId];
const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const [buttonHover, setButtonHover] = useState<string>("");
const [openedReallyChangingModal, setOpenedReallyChangingModal] =
useState<boolean>(false);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const isIconMobile = useMediaQuery(theme.breakpoints.down(1050));
useEffect(() => {
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
}, [listQuestions]);
useEffect(() => {
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
}, [question]);
const openedModal = () => {
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
openedModalSettings: true,
});
};
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
background: "#f2f3f7",
height: isMobile ? "92px" : "70px",
}}
>
<Box
sx={{
padding: isMobile ? " 3px 12px 11px" : "20px",
display: "flex",
flexWrap: isMobile ? "wrap" : "nowrap",
gap: "6px",
}}
>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("setting")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("setting");
}}
sx={{
maxWidth: "104px",
minWidth: isIconMobile ? "30px" : "64px",
height: "30px",
backgroundColor:
switchState === "setting"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "setting" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color:
switchState === "setting" ? theme.palette.grey3.main : null,
},
}}
>
<SettingIcon
color={
buttonHover === "setting"
? theme.palette.grey3.main
: switchState === "setting"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Настройки"}
</MiniButtonSetting>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("help")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("help");
}}
sx={{
minWidth: isIconMobile ? "30px" : "64px",
maxWidth: "102px",
height: "30px",
backgroundColor:
switchState === "help"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "help" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color: switchState === "help" ? theme.palette.grey3.main : null,
},
}}
>
<Clue
color={
buttonHover === "help"
? theme.palette.grey3.main
: switchState === "help"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Подсказка"}
</MiniButtonSetting>
<>
<Tooltip
arrow
placement="right"
componentsProps={{
tooltip: {
sx: {
background: "#fff",
borderRadius: "6px",
color: "#9A9AAF",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
"& .MuiTooltip-arrow": {
color: "#FFF",
},
},
},
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
background: "#f2f3f7",
height: isMobile ? "92px" : "70px",
}}
title={
<Box>
<Typography
sx={{
color: "#4D4D4D",
fontWeight: "bold",
fontSize: "14px",
marginBottom: "10px",
}}
>
Будет показан при условии
</Typography>
<Typography sx={{ fontWeight: "bold", fontSize: "12px" }}>
Название
</Typography>
<Typography
sx={{
fontWeight: "bold",
fontSize: "12px",
marginBottom: "10px",
}}
>
Условие 1, Условие 2
</Typography>
<Typography sx={{ color: "#7E2AEA", fontSize: "12px" }}>
Все условия обязательны
</Typography>
</Box>
}
>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("branching")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("branching");
openedModal();
}}
sx={{
height: "30px",
maxWidth: "103px",
minWidth: isIconMobile ? "30px" : "64px",
backgroundColor:
switchState === "branching"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "branching"
? "#ffffff"
: theme.palette.grey3.main,
"&:hover": {
color:
switchState === "branching"
? theme.palette.grey3.main
: null,
},
}}
>
<Box
sx={{
padding: isMobile ? " 3px 12px 11px" : "20px",
display: "flex",
flexWrap: isMobile ? "wrap" : "nowrap",
gap: "6px",
}}
>
<Branching
color={
buttonHover === "branching"
? theme.palette.grey3.main
: switchState === "branching"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Ветвление"}
</MiniButtonSetting>
</Tooltip>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("image")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("image");
}}
sx={{
height: "30px",
maxWidth: "123px",
minWidth: isIconMobile ? "30px" : "64px",
backgroundColor:
switchState === "image"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "image" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color:
switchState === "image" ? theme.palette.grey3.main : null,
},
}}
>
<ImgIcon
color={
buttonHover === "image"
? theme.palette.grey3.main
: switchState === "image"
? "#ffffff"
: theme.palette.grey3.main
}
<MiniButtonSetting
onMouseEnter={() => setButtonHover("setting")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("setting");
}}
sx={{
maxWidth: "104px",
minWidth: isIconMobile ? "30px" : "64px",
height: "30px",
backgroundColor:
switchState === "setting"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "setting" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color:
switchState === "setting" ? theme.palette.grey3.main : null,
},
}}
>
<SettingIcon
color={
buttonHover === "setting"
? theme.palette.grey3.main
: switchState === "setting"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Настройки"}
</MiniButtonSetting>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("help")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("help");
}}
sx={{
minWidth: isIconMobile ? "30px" : "64px",
maxWidth: "102px",
height: "30px",
backgroundColor:
switchState === "help"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "help" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color: switchState === "help" ? theme.palette.grey3.main : null,
},
}}
>
<Clue
color={
buttonHover === "help"
? theme.palette.grey3.main
: switchState === "help"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Подсказка"}
</MiniButtonSetting>
<>
<Tooltip
arrow
placement="right"
componentsProps={{
tooltip: {
sx: {
background: "#fff",
borderRadius: "6px",
color: "#9A9AAF",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
"& .MuiTooltip-arrow": {
color: "#FFF",
},
},
},
}}
title={
<Box>
<Typography
sx={{
color: "#4D4D4D",
fontWeight: "bold",
fontSize: "14px",
marginBottom: "10px",
}}
>
Будет показан при условии
</Typography>
<Typography sx={{ fontWeight: "bold", fontSize: "12px" }}>
Название
</Typography>
<Typography
sx={{
fontWeight: "bold",
fontSize: "12px",
marginBottom: "10px",
}}
>
Условие 1, Условие 2
</Typography>
<Typography sx={{ color: "#7E2AEA", fontSize: "12px" }}>
Все условия обязательны
</Typography>
</Box>
}
>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("branching")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("branching");
updateQuestionWithFnOptimistic(question.id, question => {
question.openedModalSettings = true;
});
}}
sx={{
height: "30px",
maxWidth: "103px",
minWidth: isIconMobile ? "30px" : "64px",
backgroundColor:
switchState === "branching"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "branching"
? "#ffffff"
: theme.palette.grey3.main,
"&:hover": {
color:
switchState === "branching"
? theme.palette.grey3.main
: null,
},
}}
>
<Branching
color={
buttonHover === "branching"
? theme.palette.grey3.main
: switchState === "branching"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Ветвление"}
</MiniButtonSetting>
</Tooltip>
<MiniButtonSetting
onMouseEnter={() => setButtonHover("image")}
onMouseLeave={() => setButtonHover("")}
onClick={() => {
SSHC("image");
}}
sx={{
height: "30px",
maxWidth: "123px",
minWidth: isIconMobile ? "30px" : "64px",
backgroundColor:
switchState === "image"
? theme.palette.brightPurple.main
: "transparent",
color:
switchState === "image" ? "#ffffff" : theme.palette.grey3.main,
"&:hover": {
color:
switchState === "image" ? theme.palette.grey3.main : null,
},
}}
>
<ImgIcon
color={
buttonHover === "image"
? theme.palette.grey3.main
: switchState === "image"
? "#ffffff"
: theme.palette.grey3.main
}
/>
{isIconMobile ? null : "Изображение"}
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<DoubleTick style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<DoubleArrowRight style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<VectorQuestions style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
</>
</Box>
<Box
sx={{
padding: "20px",
}}
>
<IconButton sx={{ borderRadius: "6px", padding: "0px 2px" }}>
<HideIcon style={{ color: "#4D4D4D" }} />
</IconButton>
<IconButton
sx={{ borderRadius: "6px" }}
onClick={() => copyQuestion(question.id, question.quizId)}
>
<CopyIcon style={{ color: "#4D4D4D" }} />
</IconButton>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => { // TODO
// const removedId = question.id;
// if (question.deleteTimeoutId) {
// clearTimeout(question.deleteTimeoutId);
// }
// removeQuestion(quizId, totalIndex);
// const newTimeoutId = window.setTimeout(() => {
// removeQuestionForce(quizId, removedId);
// }, 5000);
// updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
// ...question,
// deleteTimeoutId: newTimeoutId,
// });
deleteQuestion(question.id);
}}
>
<DeleteIcon style={{ color: "#4D4D4D" }} />
</IconButton>
</Box>
<ReallyChangingModal
opened={openedReallyChangingModal}
onClose={() => setOpenedReallyChangingModal(false)}
/>
{isIconMobile ? null : "Изображение"}
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<DoubleTick style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<DoubleArrowRight style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
<MiniButtonSetting
onClick={() => setOpenedReallyChangingModal(true)}
sx={{
minWidth: "30px",
height: "30px",
backgroundColor: "#FEDFD0",
}}
>
<VectorQuestions style={{ color: "#FC712F", fontSize: "9px" }} />
</MiniButtonSetting>
</>
</Box>
<Box
sx={{
padding: "20px",
}}
>
<IconButton sx={{ borderRadius: "6px", padding: "0px 2px" }}>
<HideIcon style={{ color: "#4D4D4D" }} />
</IconButton>
<IconButton
sx={{ borderRadius: "6px" }}
onClick={() => copyQuestion(quizId, totalIndex)}
>
<CopyIcon style={{ color: "#4D4D4D" }} />
</IconButton>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const removedId = question.id;
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
removeQuestion(quizId, totalIndex);
const newTimeoutId = window.setTimeout(() => {
removeQuestionForce(quizId, removedId);
}, 5000);
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
...question,
deleteTimeoutId: newTimeoutId,
});
}}
>
<DeleteIcon style={{ color: "#4D4D4D" }} />
</IconButton>
</Box>
<ReallyChangingModal
opened={openedReallyChangingModal}
onClose={() => setOpenedReallyChangingModal(false)}
/>
</Box>
);
</Box>
);
}

@ -11,21 +11,7 @@ import {
useTheme,
} from "@mui/material";
import { useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useDebouncedCallback } from "use-debounce";
import SwitchQuestionsPage from "../SwitchQuestionsPage";
import TypeQuestions from "../TypeQuestions";
import { ChooseAnswerModal } from "./ChooseAnswerModal";
import {
copyQuestion,
createQuestion,
removeQuestion,
removeQuestionForce,
updateQuestionsList
} from "@root/questions";
import { CrossedEyeIcon } from "@icons/CrossedEyeIcon";
import { ArrowDownIcon } from "@icons/questionsPage/ArrowDownIcon";
import { CopyIcon } from "@icons/questionsPage/CopyIcon";
@ -45,9 +31,12 @@ import Page from "@icons/questionsPage/page";
import RatingIcon from "@icons/questionsPage/rating";
import Slider from "@icons/questionsPage/slider";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import { copyQuestion, deleteQuestion, toggleExpandQuestion } from "@root/questions/actions";
import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
import { ReactComponent as PlusIcon } from "../../../assets/icons/plus.svg";
import type { AnyQuizQuestion } from "../../../model/questionTypes/shared";
import SwitchQuestionsPage from "../SwitchQuestionsPage";
import { ChooseAnswerModal } from "./ChooseAnswerModal";
interface Props {
@ -59,7 +48,6 @@ interface Props {
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));
@ -166,11 +154,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
sx={{ padding: "0", margin: "5px" }}
disableRipple
data-cy="expand-question"
onClick={() =>
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, {
expanded: !question.expanded,
})
}
onClick={() => toggleExpandQuestion(question.id)}
>
{question.expanded ? (
<ArrowDownIcon
@ -231,7 +215,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
/>
<IconButton
sx={{ padding: "0" }}
onClick={() => copyQuestion(quizId, totalIndex)}
onClick={() => copyQuestion(question.id, question.quizId)}
>
<CopyIcon
style={{ color: theme.palette.brightPurple.main }}
@ -244,22 +228,24 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
const removedId = question.id;
if (question.deleteTimeoutId) {
clearTimeout(question.deleteTimeoutId);
}
onClick={() => { // TODO
// const removedId = question.id;
// if (question.deleteTimeoutId) {
// clearTimeout(question.deleteTimeoutId);
// }
removeQuestion(quizId, totalIndex);
// removeQuestion(quizId, totalIndex);
const newTimeoutId = window.setTimeout(() => {
removeQuestionForce(quizId, removedId);
}, 5000);
// const newTimeoutId = window.setTimeout(() => {
// removeQuestionForce(quizId, removedId);
// }, 5000);
updateQuestionsList<AnyQuizQuestion>(quizId, totalIndex, {
...question,
deleteTimeoutId: newTimeoutId,
});
// updateQuestionsList<AnyQuizQuestion>(quizId, totalIndex, {
// ...question,
// deleteTimeoutId: newTimeoutId,
// });
deleteQuestion(question.id);
}}
>
<DeleteIcon
@ -301,11 +287,11 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
borderRadius: "12px",
}}
>
{question.type === "nonselected" ? (
{/* {question.type === "nonselected" ? (
<TypeQuestions question={question} />
) : (
<SwitchQuestionsPage question={question} />
)}
) : ( */}
<SwitchQuestionsPage question={question} />
{/* )} */}
</Box>
)}
</Paper>

@ -58,7 +58,7 @@ export default function DropDown({ totalIndex }: Props) {
) : (
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
question={totalIndex}
/>
)}
<Box

@ -48,7 +48,7 @@ export default function Emoji({ totalIndex }: Props) {
<Box sx={{ padding: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
question={totalIndex}
additionalContent={(variant, index) => (
<>
{!isTablet && (

@ -24,9 +24,9 @@ import { BUTTON_TYPE_QUESTIONS } from "../../TypeQuestions";
import type { RefObject } from "react";
import type {
QuizQuestionType,
QuizQuestionBase,
} from "../../../../model/questionTypes/shared";
import { QuestionType } from "@model/question/question";
type ChooseAnswerModalProps = {
open: boolean;
@ -44,7 +44,7 @@ export const ChooseAnswerModal = ({
switchState,
}: ChooseAnswerModalProps) => {
const [openModal, setOpenModal] = useState<boolean>(false);
const [selectedValue, setSelectedValue] = useState<QuizQuestionType>("text");
const [selectedValue, setSelectedValue] = useState<QuestionType>("text");
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();

@ -158,11 +158,11 @@ export default function QuestionsPageCard({
),
}}
/>
{question.type === "nonselected" ? (
{/* {question.type === "" ? (
<FormTypeQuestions totalIndex={totalIndex} />
) : (
) : ( */}
<SwitchQuestionsPage totalIndex={totalIndex} />
)}
{/* )} */}
</Box>
</Paper>
</>

@ -22,9 +22,9 @@ import {
} from "@root/questions";
import type {
QuizQuestionType,
QuizQuestionBase,
} from "../../../model/questionTypes/shared";
import { QuestionType } from "@model/question/question";
interface Props {
totalIndex: number;
@ -33,7 +33,7 @@ interface Props {
type ButtonTypeQuestion = {
icon: JSX.Element;
title: string;
value: QuizQuestionType;
value: QuestionType;
};
const BUTTON_TYPE_SHORT_QUESTIONS: ButtonTypeQuestion[] = [

@ -71,7 +71,7 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
<Box sx={{ pl: "20px", pr: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
question={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (

@ -1,44 +1,39 @@
import {
Box,
Link,
Typography,
Button,
useTheme,
useMediaQuery,
Box,
Link,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import { openCropModal } from "@root/cropModal";
import { setVariantImageUrl, setVariantOriginalImageUrl, updateQuestionsList } from "@root/questions";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { questionStore, setVariantImageUrl, updateQuestionsList, setVariantOriginalImageUrl } from "@root/questions";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../ButtonsOptions";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import SwitchAnswerOptionsPict from "./switchOptionsPict";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
interface Props {
totalIndex: number;
question: QuizQuestionImages;
}
export default function OptionsPicture({ totalIndex }: Props) {
export default function OptionsPicture({ question }: Props) {
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState<number>(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const quizId = Number(useParams().quizId);
const [switchState, setSwitchState] = useState("setting");
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionImages;
const SSHC = (data: string) => {
setSwitchState(data);
};
const SSHC = (data: string) => {
setSwitchState(data);
};
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
@ -56,117 +51,117 @@ export default function OptionsPicture({ totalIndex }: Props) {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", hints: "", extendedText: "", originalImageUrl: "" });
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
function handleCropModalSaveClick(url: string) {
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
}
return (
<>
<Box sx={{ padding: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
<>
<Box sx={{ padding: "20px" }}>
<AnswerDraggableList
variants={question.content.variants}
question={totalIndex}
additionalContent={(variant, index) => (
<>
{!isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ mx: "10px" }}
/>
)}
</>
)}
additionalMobile={(variant, index) => (
<>
{isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ mx: "10px" }}
/>
)}
</>
)}
additionalMobile={(variant, index) => (
<>
{isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setCurrentIndex(index);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={addNewAnswer}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
<SwitchAnswerOptionsPict switchState={switchState} totalIndex={totalIndex} />
</>
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={addNewAnswer}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
<SwitchAnswerOptionsPict switchState={switchState} totalIndex={totalIndex} />
</>
);
);
}

@ -1,63 +1,58 @@
import * as React from "react";
import AnswerOptions from "./answerOptions/AnswerOptions";
import OptionsPicture from "./OptionsPicture/OptionsPicture";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
import DataOptions from "./DataOptions/DataOptions";
import SliderOptions from "./SliderOptions/SliderOptions";
import DropDown from "./DropDown/DropDown";
import Emoji from "./Emoji/Emoji";
import OptionsAndPicture from "./OptionsAndPicture/OptionsAndPicture";
import OptionsPicture from "./OptionsPicture/OptionsPicture";
import OwnTextField from "./OwnTextField/OwnTextField";
import PageOptions from "./PageOptions/PageOptions";
import OptionsAndPicture from "./OptionsAndPicture/OptionsAndPicture";
import RatingOptions from "./RatingOptions/RatingOptions";
import Emoji from "./Emoji/Emoji";
import DropDown from "./DropDown/DropDown";
import SliderOptions from "./SliderOptions/SliderOptions";
import UploadFile from "./UploadFile/UploadFile";
import { useParams } from "react-router-dom";
import { questionStore } from "@root/questions";
import AnswerOptions from "./answerOptions/AnswerOptions";
interface Props {
totalIndex: number;
question: AnyQuizQuestion;
}
export default function SwitchQuestionsPage({ totalIndex }: Props) {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
export default function SwitchQuestionsPage({ question }: Props) {
const switchState = listQuestions[quizId][totalIndex].type;
switch (switchState) {
case "variant":
return <AnswerOptions totalIndex={totalIndex} />;
switch (question.type) {
case "variant":
return <AnswerOptions question={question} />;
case "images":
return <OptionsPicture totalIndex={totalIndex} />;
case "images":
return <OptionsPicture question={question} />;
case "varimg":
return <OptionsAndPicture totalIndex={totalIndex} />;
case "varimg":
return <OptionsAndPicture question={question} />;
case "emoji":
return <Emoji totalIndex={totalIndex} />;
case "emoji":
return <Emoji question={question} />;
case "text":
return <OwnTextField totalIndex={totalIndex} />;
case "text":
return <OwnTextField question={question} />;
case "select":
return <DropDown totalIndex={totalIndex} />;
case "select":
return <DropDown question={question} />;
case "date":
return <DataOptions totalIndex={totalIndex} />;
case "date":
return <DataOptions question={question} />;
case "number":
return <SliderOptions totalIndex={totalIndex} />;
case "number":
return <SliderOptions question={question} />;
case "file":
return <UploadFile totalIndex={totalIndex} />;
case "file":
return <UploadFile question={question} />;
case "page":
return <PageOptions totalIndex={totalIndex} />;
case "page":
return <PageOptions question={question} />;
case "rating":
return <RatingOptions totalIndex={totalIndex} />;
case "rating":
return <RatingOptions question={question} />;
default:
return <></>;
}
default:
return <></>;
}
}

@ -11,100 +11,102 @@ import OptionsPict from "../../assets/icons/questionsPage/options_pict";
import Page from "../../assets/icons/questionsPage/page";
import RatingIcon from "../../assets/icons/questionsPage/rating";
import Slider from "../../assets/icons/questionsPage/slider";
import { setQuestionFieldOptimistic } from "@root/questions/actions";
import { updateQuestionWithFnOptimistic } from "@root/questions/actions";
import type {
AnyQuizQuestion,
QuizQuestionType
} from "../../model/questionTypes/shared";
import { QuestionType } from "@model/question/question";
interface Props {
question: AnyQuizQuestion;
question: AnyQuizQuestion;
}
type ButtonTypeQuestion = {
icon: JSX.Element;
title: string;
value: QuizQuestionType;
icon: JSX.Element;
title: string;
value: QuestionType;
};
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>
);
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={() => updateQuestionWithFnOptimistic(question.id, question => {
question.type = value;
})}
icon={icon}
text={title}
/>
))}
</Box>
);
}
export const BUTTON_TYPE_QUESTIONS: ButtonTypeQuestion[] = [
{
icon: <Answer color="#9A9AAF" />,
title: "Варианты ответов",
value: "variant",
},
{
icon: <OptionsPict color="#9A9AAF" />,
title: "Варианты с картинками",
value: "images",
},
{
icon: <OptionsAndPict color="#9A9AAF" />,
title: "Варианты и картинка",
value: "varimg",
},
{
icon: <Emoji color="#9A9AAF" />,
title: "Эмоджи",
value: "emoji",
},
{
icon: <Input color="#9A9AAF" />,
title: "Своё поле для ввода",
value: "text",
},
{
icon: <DropDown color="#9A9AAF" />,
title: "Выпадающий список",
value: "select",
},
{
icon: <Date color="#9A9AAF" />,
title: "Дата",
value: "date",
},
{
icon: <Slider color="#9A9AAF" />,
title: "Ползунок",
value: "number",
},
{
icon: <Download color="#9A9AAF" />,
title: "Загрузка файла",
value: "file",
},
{
icon: <Page color="#9A9AAF" />,
title: "Страница",
value: "page",
},
{
icon: <RatingIcon color="#9A9AAF" />,
title: "Рейтинг",
value: "rating",
},
{
icon: <Answer color="#9A9AAF" />,
title: "Варианты ответов",
value: "variant",
},
{
icon: <OptionsPict color="#9A9AAF" />,
title: "Варианты с картинками",
value: "images",
},
{
icon: <OptionsAndPict color="#9A9AAF" />,
title: "Варианты и картинка",
value: "varimg",
},
{
icon: <Emoji color="#9A9AAF" />,
title: "Эмоджи",
value: "emoji",
},
{
icon: <Input color="#9A9AAF" />,
title: "Своё поле для ввода",
value: "text",
},
{
icon: <DropDown color="#9A9AAF" />,
title: "Выпадающий список",
value: "select",
},
{
icon: <Date color="#9A9AAF" />,
title: "Дата",
value: "date",
},
{
icon: <Slider color="#9A9AAF" />,
title: "Ползунок",
value: "number",
},
{
icon: <Download color="#9A9AAF" />,
title: "Загрузка файла",
value: "file",
},
{
icon: <Page color="#9A9AAF" />,
title: "Страница",
value: "page",
},
{
icon: <RatingIcon color="#9A9AAF" />,
title: "Рейтинг",
value: "rating",
},
];

@ -1,26 +1,21 @@
import { QuizQuestionVariant } from "@model/questionTypes/variant";
import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
import { questionStore, setQuestionBackgroundImage, setQuestionOriginalBackgroundImage } from "@root/questions";
import { openCropModal } from "@root/cropModal";
import { setQuestionBackgroundImage, setQuestionOriginalBackgroundImage } from "@root/questions/actions";
import { CropModal } from "@ui_kit/Modal/CropModal";
import UploadBox from "@ui_kit/UploadBox";
import * as React from "react";
import { useParams } from "react-router-dom";
import { useState, type DragEvent } from "react";
import UploadIcon from "../../../assets/icons/UploadIcon";
import { UploadImageModal } from "./UploadImageModal";
import { openCropModal } from "@root/cropModal";
import { QuizQuestionBase } from "model/questionTypes/shared";
import type { DragEvent } from "react";
type UploadImageProps = {
totalIndex: number;
question: QuizQuestionVariant;
};
export default function UploadImage({ totalIndex }: UploadImageProps) {
const quizId = Number(useParams().quizId);
export default function UploadImage({ question }: UploadImageProps) {
const theme = useTheme();
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = React.useState(false);
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
@ -29,8 +24,8 @@ export default function UploadImage({ totalIndex }: UploadImageProps) {
const url = URL.createObjectURL(file);
setQuestionBackgroundImage(quizId, totalIndex, url);
setQuestionOriginalBackgroundImage(quizId, totalIndex, url);
setQuestionBackgroundImage(question.id, url);
setQuestionOriginalBackgroundImage(question.id, url);
setIsUploadImageModalOpen(false);
openCropModal(url, url);
};
@ -43,7 +38,7 @@ export default function UploadImage({ totalIndex }: UploadImageProps) {
};
function handleCropModalSaveClick(url: string) {
setQuestionBackgroundImage(quizId, totalIndex, url);
setQuestionBackgroundImage(question.id, url);
}
return (

@ -1,111 +1,98 @@
import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Box, Typography, Link, useTheme, useMediaQuery } from "@mui/material";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import SwitchAnswerOptions from "./switchAnswerOptions";
import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptionsAndPict from "../ButtonsOptionsAndPict";
import { questionStore, updateQuestionsList } from "@root/questions";
import SwitchAnswerOptions from "./switchAnswerOptions";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
import { addQuestionVariant } from "@root/questions/actions";
interface Props {
totalIndex: number;
question: QuizQuestionVariant;
}
export default function AnswerOptions({ totalIndex }: Props) {
const [switchState, setSwitchState] = useState("setting");
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionVariant;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
export default function AnswerOptions({ question }: Props) {
const [switchState, setSwitchState] = useState("setting");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const SSHC = (data: string) => {
setSwitchState(data);
};
const SSHC = (data: string) => {
setSwitchState(data);
};
const addNewAnswer = () => {
const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "", hints: "" });
return (
<>
<Box sx={{ padding: "0 20px 20px 20px" }}>
{question.content.variants.length === 0 ? (
<Typography
sx={{
padding: "0 0 33px 80px",
fontWeight: 400,
fontSize: "18px",
lineHeight: "21.33px",
color: theme.palette.grey2.main,
}}
>
Добавьте ответ
</Typography>
) : (
<AnswerDraggableList
variants={question.content.variants}
question={question}
/>
)}
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
return (
<>
<Box sx={{ padding: "0 20px 20px 20px" }}>
{question.content.variants.length === 0 ? (
<Typography
sx={{
padding: "0 0 33px 80px",
fontWeight: 400,
fontSize: "18px",
lineHeight: "21.33px",
color: theme.palette.grey2.main,
}}
>
Добавьте ответ
</Typography>
) : (
<AnswerDraggableList
variants={question.content.variants}
totalIndex={totalIndex}
/>
)}
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={addNewAnswer}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptionsAndPict
switchState={switchState}
SSHC={SSHC}
totalIndex={totalIndex}
/>
<SwitchAnswerOptions switchState={switchState} totalIndex={totalIndex} />
</>
);
<Box
sx={{
display: "flex",
alignItems: "center",
marginBottom: "17px",
}}
>
<Link
component="button"
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
fontWeight: "400",
fontSize: "16px",
mr: "4px",
height: "19px",
}}
onClick={() => addQuestionVariant(question.id)}
>
Добавьте ответ
</Link>
{isMobile ? null : (
<>
<Typography
sx={{
fontWeight: 400,
lineHeight: "21.33px",
color: theme.palette.grey2.main,
fontSize: "16px",
}}
>
или нажмите Enter
</Typography>
<EnterIcon
style={{
color: "#7E2AEA",
fontSize: "24px",
marginLeft: "6px",
}}
/>
</>
)}
</Box>
</Box>
<ButtonsOptionsAndPict
switchState={switchState}
SSHC={SSHC}
question={question}
/>
<SwitchAnswerOptions switchState={switchState} question={question} />
</>
);
}

@ -1,181 +1,178 @@
import { useParams } from "react-router-dom";
import {
Box,
Typography,
Tooltip,
useMediaQuery,
useTheme,
Box,
Tooltip,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useDebouncedCallback } from "use-debounce";
import { updateQuestionWithFnOptimistic } from "@root/questions/actions";
import CustomCheckbox from "@ui_kit/CustomCheckbox";
import InfoIcon from "../../../assets/icons/InfoIcon";
import CustomTextField from "@ui_kit/CustomTextField";
import { questionStore, updateQuestionsList } from "@root/questions";
import { useDebouncedCallback } from "use-debounce";
import InfoIcon from "../../../assets/icons/InfoIcon";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
interface Props {
totalIndex: number;
question: QuizQuestionVariant;
}
export default function ResponseSettings({ totalIndex }: Props) {
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(900));
export default function ResponseSettings({ question }: Props) {
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(900));
const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const question = listQuestions[quizId][totalIndex] as QuizQuestionVariant;
const debounced = useDebouncedCallback((value) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, innerName: value },
});
}, 1000);
const isFigmaTablte = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexDirection: isTablet ? "column" : "none",
marginRight: isFigmaTablte ? (isMobile ? "0" : "0px") : "30px",
}}
>
<Box
sx={{
pt: isMobile ? "25px" : "20px",
pb: isMobile ? "25px" : "20px",
pl: "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<Typography
sx={{
height: isMobile ? "18px" : "auto",
fontWeight: "500",
fontSize: "18px",
color: " #4D4D4D",
}}
>
Настройки ответов
</Typography>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Длинный текстовый ответ"}
checked={question.content.largeCheck}
handleChange={({ target }) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: {
...question.content,
largeCheck: target.checked,
},
});
}}
/>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Можно несколько"}
checked={question.content.multi}
dataCy="multiple-answers-checkbox"
handleChange={({ target }) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, multi: target.checked },
});
}}
/>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={'Вариант "свой ответ"'}
checked={question.content.own}
handleChange={({ target }) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, own: target.checked },
});
}}
/>
</Box>
<Box
sx={{
boxSizing: "border-box",
pt: isMobile ? "0px" : "20px",
pb: "20px",
pl: isFigmaTablte ? (isTablet ? "20px" : "34px") : "28px",
pr: isFigmaTablte ? "19px" : "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<Typography
sx={{
height: isMobile ? "18px" : "auto",
fontWeight: "500",
fontSize: "18px",
color: " #4D4D4D",
}}
>
Настройки вопросов
</Typography>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Необязательный вопрос"}
checked={!question.required}
handleChange={({ target }) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
required: !target.checked,
});
}}
/>
const updateQuestionInnerName = useDebouncedCallback((value) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.innerName = value;
});
}, 1000);
return (
<Box
sx={{
width: isMobile ? "90%" : "auto",
display: "flex",
alignItems: "center",
}}
>
<CustomCheckbox
sx={{
mr: isMobile ? "0px" : "9px",
height: isMobile ? "42px" : "26px",
alignItems: "start",
display: "flex",
justifyContent: "space-between",
flexDirection: isTablet ? "column" : "none",
marginRight: isFigmaTablte ? (isMobile ? "0" : "0px") : "30px",
}}
label={"Внутреннее название вопроса"}
checked={question.content.innerNameCheck}
handleChange={({ target }) => {
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: {
...question.content,
innerNameCheck: target.checked,
innerName: target.checked ? question.content.innerName : "",
},
});
}}
/>
{isMobile && (
<Tooltip
title="Будет отображаться как заголовок вопроса в приходящих заявках."
placement="top"
>
<Box
sx={{
pt: isMobile ? "25px" : "20px",
pb: isMobile ? "25px" : "20px",
pl: "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<Box>
<InfoIcon />
</Box>
</Tooltip>
)}
<Typography
sx={{
height: isMobile ? "18px" : "auto",
fontWeight: "500",
fontSize: "18px",
color: " #4D4D4D",
}}
>
Настройки ответов
</Typography>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Длинный текстовый ответ"}
checked={question.content.largeCheck}
handleChange={({ target }) => {
updateQuestionWithFnOptimistic(question.id, question => {
if (!("largeCheck" in question.content)) return;
question.content.largeCheck = target.checked;
});
}}
/>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Можно несколько"}
checked={question.content.multi}
dataCy="multiple-answers-checkbox"
handleChange={({ target }) => {
updateQuestionWithFnOptimistic(question.id, question => {
if (!("multi" in question.content)) return;
question.content.multi = target.checked;
});
}}
/>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={'Вариант "свой ответ"'}
checked={question.content.own}
handleChange={({ target }) => {
updateQuestionWithFnOptimistic(question.id, question => {
if (!("own" in question.content)) return;
question.content.own = target.checked;
});
}}
/>
</Box>
<Box
sx={{
boxSizing: "border-box",
pt: isMobile ? "0px" : "20px",
pb: "20px",
pl: isFigmaTablte ? (isTablet ? "20px" : "34px") : "28px",
pr: isFigmaTablte ? "19px" : "20px",
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<Typography
sx={{
height: isMobile ? "18px" : "auto",
fontWeight: "500",
fontSize: "18px",
color: " #4D4D4D",
}}
>
Настройки вопросов
</Typography>
<CustomCheckbox
sx={{ mr: isMobile ? "0px" : "16px" }}
label={"Необязательный вопрос"}
checked={!question.required}
handleChange={({ target }) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.required = !target.checked;
});
}}
/>
<Box
sx={{
width: isMobile ? "90%" : "auto",
display: "flex",
alignItems: "center",
}}
>
<CustomCheckbox
sx={{
mr: isMobile ? "0px" : "9px",
height: isMobile ? "42px" : "26px",
alignItems: "start",
}}
label={"Внутреннее название вопроса"}
checked={question.content.innerNameCheck}
handleChange={({ target }) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.innerNameCheck = target.checked;
question.content.innerName = target.checked ? question.content.innerName : "";
});
}}
/>
{isMobile && (
<Tooltip
title="Будет отображаться как заголовок вопроса в приходящих заявках."
placement="top"
>
<Box>
<InfoIcon />
</Box>
</Tooltip>
)}
</Box>
{question.content.innerNameCheck && (
<CustomTextField
sx={{ mr: isMobile ? "0px" : "16px" }}
placeholder={"Развёрнутое описание вопроса"}
text={question.content.innerName}
onChange={({ target }) => updateQuestionInnerName(target.value)}
/>
)}
</Box>
</Box>
{question.content.innerNameCheck && (
<CustomTextField
sx={{ mr: isMobile ? "0px" : "16px" }}
placeholder={"Развёрнутое описание вопроса"}
text={question.content.innerName}
onChange={({ target }) => debounced(target.value)}
/>
)}
</Box>
</Box>
);
);
}

@ -1,32 +1,29 @@
import * as React from "react";
import { QuizQuestionVariant } from "@model/questionTypes/variant";
import UploadImage from "../UploadImage";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import ResponseSettings from "./responseSettings";
import BranchingQuestions from "../branchingQuestions";
interface Props {
switchState: string;
totalIndex: number;
switchState: string;
question: QuizQuestionVariant;
}
export default function SwitchAnswerOptions({
switchState = "setting",
totalIndex,
switchState = "setting",
question,
}: Props) {
switch (switchState) {
case "setting":
return <ResponseSettings totalIndex={totalIndex} />;
break;
case "help":
return <HelpQuestions totalIndex={totalIndex} />;
break;
case "branching":
return <BranchingQuestions totalIndex={totalIndex} />;
break;
case "image":
return <UploadImage totalIndex={totalIndex} />;
break;
default:
return <></>;
}
switch (switchState) {
case "setting":
return <ResponseSettings question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
case "image":
return <UploadImage question={question} />;
default:
return <></>;
}
}

@ -1,394 +1,338 @@
import { useState, useRef, useEffect } from "react";
import { useParams } from "react-router-dom";
import {
Box,
Button,
Chip,
FormControl,
FormControlLabel,
IconButton,
Link,
Modal,
Radio,
RadioGroup,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import { questionStore, updateQuestionsList } from "@root/questions";
import { Select } from "./Select";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import InfoIcon from "@icons/Info";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { QuizQuestionVariant } from "@model/questionTypes/variant";
import {
Box,
Button,
Chip,
FormControl,
FormControlLabel,
IconButton,
Link,
Modal,
Radio,
RadioGroup,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import { updateQuestionWithFnOptimistic } from "@root/questions/actions";
import RadioCheck from "@ui_kit/RadioCheck";
import RadioIcon from "@ui_kit/RadioIcon";
import { useEffect, useRef, useState } from "react";
import { Select } from "./Select";
import type { QuizQuestionBase } from "../../model/questionTypes/shared";
type BranchingQuestionsProps = {
totalIndex: number;
question: QuizQuestionVariant;
};
const ACTIONS = ["Показать", "Скрыть"];
const STIPULATIONS = ["Условие 1", "Условие 2", "Условие 3"];
const ANSWERS = ["Ответ 1", "Ответ 2", "Ответ 3"];
const CONDITIONS = [
"Все условия обязательны",
"Обязательно хотя бы одно условие",
"Все условия обязательны",
"Обязательно хотя бы одно условие",
];
export default function BranchingQuestions({
totalIndex,
question,
}: BranchingQuestionsProps) {
const theme = useTheme();
const [title, setTitle] = useState<string>("");
const [titleInputWidth, setTitleInputWidth] = useState<number>(0);
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const titleRef = useRef<HTMLDivElement>(null);
const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const theme = useTheme();
const [title, setTitle] = useState<string>("");
const [titleInputWidth, setTitleInputWidth] = useState<number>(0);
const titleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setTitleInputWidth(titleRef.current?.offsetWidth || 0);
}, [title]);
useEffect(() => {
setTitleInputWidth(titleRef.current?.offsetWidth || 0);
}, [title]);
const handleClose = () => {
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
openedModalSettings: false,
});
};
const handleClose = () => {
updateQuestionWithFnOptimistic(question.id, question => {
question.openedModalSettings = false;
});
};
return (
<>
<Modal open={question.openedModalSettings} onClose={handleClose}>
<Box
sx={{
position: "absolute",
overflow: "hidden",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "620px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
p: 0,
}}
>
<Box
sx={{
boxSizing: "border-box",
background: "#F2F3F7",
height: "70px",
padding: "0 25px",
display: "flex",
alignItems: "center",
}}
>
<Box sx={{ color: "#9A9AAF" }}>
<Typography component="span">(</Typography>
<Box sx={{ display: "inline" }}>
<Typography
ref={titleRef}
sx={{
position: "absolute",
opacity: 0,
zIndex: "-100",
whiteSpace: "pre",
}}
>
{title}
</Typography>
<input
type="text"
value={title}
placeholder="Заголовок вопроса"
onChange={({ target }) => setTitle(target.value)}
style={{
width: titleInputWidth ? titleInputWidth : 170,
outline: "none",
background: "transparent",
border: "none",
fontSize: "18px",
minWidth: "50px",
maxWidth: "500px",
fontFamily: "Rubik",
transition: ".2s",
}}
/>
</Box>
<Typography component="span">)</Typography>
</Box>
<Tooltip
title="Настройте условия, при которых данный вопрос будет отображаться в квизе."
placement="top"
>
<Box>
<InfoIcon />
</Box>
</Tooltip>
</Box>
<Box
sx={{
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "30px",
}}
>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<Select
items={ACTIONS}
activeItemIndex={question.content.rule.show ? 0 : 1}
sx={{ maxWidth: "140px" }}
onChange={(action) => {
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: {
...question.content,
rule: {
...question.content.rule,
show: action === ACTIONS[0],
},
},
});
}}
/>
<Typography sx={{ color: theme.palette.grey2.main }}>
если в ответе на вопрос
</Typography>
</Box>
{question.content.rule.reqs.map((request, index) => (
<Box
key={index}
sx={{
padding: "20px",
borderRadius: "8px",
height: "100%",
bgcolor: "#F2F3F7",
}}
>
return (
<>
<Modal open={question.openedModalSettings} onClose={handleClose}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Условие 1
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const clonedContent = { ...question.content };
clonedContent.rule.reqs.splice(index, 1);
updateQuestionsList<QuizQuestionBase>(
quizId,
totalIndex,
{
content: clonedContent,
}
);
sx={{
position: "absolute",
overflow: "hidden",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
maxWidth: "620px",
width: "100%",
bgcolor: "background.paper",
borderRadius: "12px",
boxShadow: 24,
p: 0,
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Select
empty
activeItemIndex={request.id ? Number(request.id) : -1}
items={STIPULATIONS}
onChange={(stipulation) => {
const clonedContent = { ...question.content };
clonedContent.rule.reqs[index] = {
id: String(
STIPULATIONS.findIndex((item) =>
item.includes(stipulation)
)
),
vars: request.vars,
};
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: clonedContent,
});
}}
sx={{ marginBottom: "15px" }}
/>
{request.id && (
<>
<Box
sx={{
display: "flex",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Дан ответ
</Typography>
<Typography sx={{ color: "#7E2AEA", pl: "10px" }}>
(Укажите один или несколько вариантов)
</Typography>
</Box>
<Select
empty
activeItemIndex={-1}
items={ANSWERS}
onChange={(answer) => {
const clonedContent = { ...question.content };
const answerItemIndex = ANSWERS.findIndex(
(answerItem) => answerItem === answer
);
if (
!clonedContent.rule.reqs[index].vars.includes(
answerItemIndex
)
) {
question.content.rule.reqs[index].vars.push(
answerItemIndex
);
}
updateQuestionsList<QuizQuestionBase>(
quizId,
totalIndex,
{
content: clonedContent,
}
);
}}
sx={{
marginBottom: "10px",
".MuiSelect-select.MuiInputBase-input": {
color: "transparent",
},
}}
/>
<Box
sx={{
display: "flex",
gap: "10px",
}}
>
{question.content.rule.reqs[index].vars.map(
(item, varIndex) => (
<Chip
key={varIndex}
label={ANSWERS[item]}
variant="outlined"
onDelete={() => {
const clonedContent = { ...question.content };
const removedItemIndex = clonedContent.rule.reqs[
index
].vars.findIndex((varItem) => varItem === item);
clonedContent.rule.reqs[index].vars.splice(
removedItemIndex,
1
);
updateQuestionsList<QuizQuestionBase>(
quizId,
totalIndex,
{
content: clonedContent,
}
);
}}
/>
)
)}
</Box>
</>
)}
</Box>
))}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "baseline",
}}
>
<Link
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
marginBottom: "10px",
}}
onClick={() => {
const clonedContent = { ...question.content };
clonedContent.rule.reqs.push({ id: "", vars: [] });
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: clonedContent,
});
}}
>
Добавить условие
</Link>
<FormControl>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
value={question.content.rule.or ? 1 : 0}
onChange={(_, value) => {
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: {
...question.content,
rule: {
...question.content.rule,
or: Boolean(Number(value)),
},
},
});
}}
>
{CONDITIONS.map((condition, index) => (
<FormControlLabel
key={index}
sx={{ color: theme.palette.grey2.main }}
value={index}
control={
<Radio
checkedIcon={<RadioCheck />}
icon={<RadioIcon />}
/>
}
label={condition}
/>
))}
</RadioGroup>
</FormControl>
</Box>
<Box sx={{ display: "flex", justifyContent: "end", gap: "10px" }}>
<Button
variant="outlined"
onClick={handleClose}
sx={{ width: "100%", maxWidth: "130px" }}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ width: "100%", maxWidth: "130px" }}
onClick={handleClose}
>
Готово
</Button>
</Box>
</Box>
</Box>
</Modal>
</>
);
<Box
sx={{
boxSizing: "border-box",
background: "#F2F3F7",
height: "70px",
padding: "0 25px",
display: "flex",
alignItems: "center",
}}
>
<Box sx={{ color: "#9A9AAF" }}>
<Typography component="span">(</Typography>
<Box sx={{ display: "inline" }}>
<Typography
ref={titleRef}
sx={{
position: "absolute",
opacity: 0,
zIndex: "-100",
whiteSpace: "pre",
}}
>
{title}
</Typography>
<input
type="text"
value={title}
placeholder="Заголовок вопроса"
onChange={({ target }) => setTitle(target.value)}
style={{
width: titleInputWidth ? titleInputWidth : 170,
outline: "none",
background: "transparent",
border: "none",
fontSize: "18px",
minWidth: "50px",
maxWidth: "500px",
fontFamily: "Rubik",
transition: ".2s",
}}
/>
</Box>
<Typography component="span">)</Typography>
</Box>
<Tooltip
title="Настройте условия, при которых данный вопрос будет отображаться в квизе."
placement="top"
>
<Box>
<InfoIcon />
</Box>
</Tooltip>
</Box>
<Box
sx={{
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "30px",
}}
>
<Box
sx={{
display: "flex",
gap: "20px",
alignItems: "center",
}}
>
<Select
items={ACTIONS}
activeItemIndex={question.content.rule.show ? 0 : 1}
sx={{ maxWidth: "140px" }}
onChange={(action) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.rule.show = action === ACTIONS[0];
});
}}
/>
<Typography sx={{ color: theme.palette.grey2.main }}>
если в ответе на вопрос
</Typography>
</Box>
{question.content.rule.reqs.map((request, index) => (
<Box
key={index}
sx={{
padding: "20px",
borderRadius: "8px",
height: "100%",
bgcolor: "#F2F3F7",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Условие 1
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.rule.reqs.splice(index, 1);
});
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Select
empty
activeItemIndex={request.id ? Number(request.id) : -1}
items={STIPULATIONS}
onChange={(stipulation) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.rule.reqs[index].id = String(
STIPULATIONS.findIndex((item) => item.includes(stipulation))
);
});
}}
sx={{ marginBottom: "15px" }}
/>
{request.id && (
<>
<Box
sx={{
display: "flex",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Дан ответ
</Typography>
<Typography sx={{ color: "#7E2AEA", pl: "10px" }}>
(Укажите один или несколько вариантов)
</Typography>
</Box>
<Select
empty
activeItemIndex={-1}
items={ANSWERS}
onChange={(answer) => {
const answerItemIndex = ANSWERS.findIndex(
(answerItem) => answerItem === answer
);
updateQuestionWithFnOptimistic(question.id, question => {
const vars = question.content.rule.reqs[index].vars;
if (vars.includes(answerItemIndex)) {
vars.push(answerItemIndex);
}
});
}}
sx={{
marginBottom: "10px",
".MuiSelect-select.MuiInputBase-input": {
color: "transparent",
},
}}
/>
<Box
sx={{
display: "flex",
gap: "10px",
}}
>
{question.content.rule.reqs[index].vars.map(
(item, varIndex) => (
<Chip
key={varIndex}
label={ANSWERS[item]}
variant="outlined"
onDelete={() => {
updateQuestionWithFnOptimistic(question.id, question => {
const vars = question.content.rule.reqs[index].vars;
const removedItemIndex = vars.findIndex((varItem) => varItem === item);
if (removedItemIndex === -1) return;
vars.splice(removedItemIndex, 1);
});
}}
/>
)
)}
</Box>
</>
)}
</Box>
))}
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "baseline",
}}
>
<Link
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
marginBottom: "10px",
}}
onClick={() => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.rule.reqs.push({ id: "", vars: [] });
});
}}
>
Добавить условие
</Link>
<FormControl>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
value={question.content.rule.or ? 1 : 0}
onChange={(_, value) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.rule.or = Boolean(Number(value));
});
}}
>
{CONDITIONS.map((condition, index) => (
<FormControlLabel
key={index}
sx={{ color: theme.palette.grey2.main }}
value={index}
control={
<Radio
checkedIcon={<RadioCheck />}
icon={<RadioIcon />}
/>
}
label={condition}
/>
))}
</RadioGroup>
</FormControl>
</Box>
<Box sx={{ display: "flex", justifyContent: "end", gap: "10px" }}>
<Button
variant="outlined"
onClick={handleClose}
sx={{ width: "100%", maxWidth: "130px" }}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ width: "100%", maxWidth: "130px" }}
onClick={handleClose}
>
Готово
</Button>
</Box>
</Box>
</Box>
</Modal>
</>
);
}

@ -1,118 +1,103 @@
import { useState } from "react";
import { QuizQuestionVariant } from "@model/questionTypes/variant";
import { Box, ButtonBase, Typography } from "@mui/material";
import { useParams } from "react-router-dom";
import SelectableButton from "@ui_kit/SelectableButton";
import { updateQuestionWithFnOptimistic } from "@root/questions/actions";
import CustomTextField from "@ui_kit/CustomTextField";
import SelectableButton from "@ui_kit/SelectableButton";
import UploadBox from "@ui_kit/UploadBox";
import { useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import UploadIcon from "../../assets/icons/UploadIcon";
import UploadBox from "@ui_kit/UploadBox";
import { questionStore, updateQuestionsList } from "@root/questions";
import { UploadVideoModal } from "./UploadVideoModal";
import type { QuizQuestionBase } from "../../model/questionTypes/shared";
type BackgroundType = "text" | "video";
type HelpQuestionsProps = {
totalIndex: number;
question: QuizQuestionVariant;
};
export default function HelpQuestions({ totalIndex }: HelpQuestionsProps) {
const [open, setOpen] = useState(false);
const [backgroundType, setBackgroundType] = useState<BackgroundType>("text");
const quizId = Number(useParams().quizId);
const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const debounced = useDebouncedCallback((value) => {
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: {
...question.content,
hint: { text: value, video: question.content.hint.video },
},
});
}, 1000);
export default function HelpQuestions({ question }: HelpQuestionsProps) {
const [open, setOpen] = useState(false);
const [backgroundType, setBackgroundType] = useState<BackgroundType>("text");
const videoHC = (url: string) => {
const clonedContent = { ...question.content };
clonedContent.hint.video = url;
updateQuestionsList<QuizQuestionBase>(quizId, totalIndex, {
content: {
...question.content,
hint: { video: url, text: question.content.hint.text },
},
});
};
const updateQuestionHint = useDebouncedCallback((value) => {
updateQuestionWithFnOptimistic(question.id, question => {
question.content.hint.text = value;
});
}, 1000);
return (
<Box
sx={{
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "20px",
}}
>
<Typography sx={{ fontWeight: "500" }}>Подсказка консультанта</Typography>
<Box
sx={{
display: "flex",
gap: "10px",
}}
>
<SelectableButton
isSelected={backgroundType === "text"}
onClick={() => setBackgroundType("text")}
sx={{ maxWidth: "130px" }}
return (
<Box
sx={{
padding: "20px",
display: "flex",
flexDirection: "column",
gap: "20px",
}}
>
Текст
</SelectableButton>
<SelectableButton
isSelected={backgroundType === "video"}
onClick={() => setBackgroundType("video")}
sx={{ maxWidth: "130px" }}
>
Видео
</SelectableButton>
</Box>
{backgroundType === "text" ? (
<>
<CustomTextField
placeholder={"Текст консультанта"}
text={question.content.hint.text}
onChange={({ target }) => debounced(target.value)}
/>
</>
) : (
<Box>
<Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}>
Загрузите видео
</Typography>
<ButtonBase
onClick={() => setOpen(true)}
sx={{ justifyContent: "flex-start" }}
>
{question.content.hint.video ? (
<video src={question.content.hint.video} width="400" controls />
<Typography sx={{ fontWeight: "500" }}>Подсказка консультанта</Typography>
<Box
sx={{
display: "flex",
gap: "10px",
}}
>
<SelectableButton
isSelected={backgroundType === "text"}
onClick={() => setBackgroundType("text")}
sx={{ maxWidth: "130px" }}
>
Текст
</SelectableButton>
<SelectableButton
isSelected={backgroundType === "video"}
onClick={() => setBackgroundType("video")}
sx={{ maxWidth: "130px" }}
>
Видео
</SelectableButton>
</Box>
{backgroundType === "text" ? (
<>
<CustomTextField
placeholder={"Текст консультанта"}
text={question.content.hint.text}
onChange={({ target }) => updateQuestionHint(target.value)}
/>
</>
) : (
<>
<UploadBox
icon={<UploadIcon />}
sx={{
height: "48px",
width: "48px",
}}
/>
</>
<Box>
<Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}>
Загрузите видео
</Typography>
<ButtonBase
onClick={() => setOpen(true)}
sx={{ justifyContent: "flex-start" }}
>
{question.content.hint.video ? (
<video src={question.content.hint.video} width="400" controls />
) : (
<>
<UploadBox
icon={<UploadIcon />}
sx={{
height: "48px",
width: "48px",
}}
/>
</>
)}
</ButtonBase>
<UploadVideoModal
open={open}
onClose={() => setOpen(false)}
video={question.content.hint.video}
onUpload={url => updateQuestionWithFnOptimistic(question.id, question => {
question.content.hint.video = url;
})}
/>
</Box>
)}
</ButtonBase>
<UploadVideoModal
open={open}
onClose={() => setOpen(false)}
video={question.content.hint.video}
onUpload={videoHC}
/>
</Box>
)}
</Box>
);
);
}

@ -2,10 +2,10 @@ import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import type {
AnyQuizQuestion,
QuizQuestionType
AnyQuizQuestion
} from "../model/questionTypes/shared";
import { QuestionType } from "@model/question/question";
import { produce, setAutoFreeze } from "immer";
import { QUIZ_QUESTION_BASE } from "../constants/base";
import { QUIZ_QUESTION_DATE } from "../constants/date";
@ -276,7 +276,7 @@ export const reorderVariants = (
export const createQuestion = (
quizId: number,
questionType: QuizQuestionType = "nonselected",
questionType: QuestionType = "variant",
placeIndex = -1
) => {
const id = getRandom();

@ -1,12 +1,13 @@
import { questionApi } from "@api/question";
import { devlog } from "@frontend/kitui";
import { questionToEditQuestionRequest } from "@model/question/edit";
import { RawQuestion, rawQuestionToQuestion } from "@model/question/question";
import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared";
import { produce } from "immer";
import { enqueueSnackbar } from "notistack";
import { notReachable } from "utils/notReachable";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { QuestionsStore, useQuestionsStore } from "./store";
import { questionToEditQuestionRequest } from "@model/question/edit";
import { AnyQuizQuestion } from "@model/questionTypes/shared";
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
@ -26,6 +27,13 @@ export const setQuestion = (question: AnyQuizQuestion) => setProducedState(state
question,
});
export const removeQuestion = (questionId: number) => setProducedState(state => {
delete state.questionsById[questionId];
}, {
type: "removeQuestion",
questionId,
});
export const setQuestionField = <T extends keyof AnyQuizQuestion>(
questionId: number,
field: T,
@ -34,7 +42,13 @@ export const setQuestionField = <T extends keyof AnyQuizQuestion>(
const question = state.questionsById[questionId];
if (!question) return;
const oldId = question.id;
question[field] = value;
if (field === "id") {
delete state.questionsById[oldId];
state.questionsById[value as number] = question;
}
}, {
type: "setQuestionField",
questionId,
@ -42,32 +56,159 @@ export const setQuestionField = <T extends keyof AnyQuizQuestion>(
value,
});
export const toggleExpandQuestion = (questionId: number) => setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
question.expanded = !question.expanded;
});
export const toggleOpenQuestionModal = (questionId: number) => setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
question.openedModalSettings = !question.openedModalSettings;
});
export const addQuestionVariant = (questionId: number) => {
updateQuestionWithFnOptimistic(questionId, question => {
switch (question.type) {
case "variant":
case "emoji":
case "select":
question.content.variants.push(createQuestionVariant());
break;
case "images":
case "varimg":
question.content.variants.push(createQuestionImageVariant());
break;
case "text":
case "date":
case "number":
case "file":
case "page":
case "rating":
throw new Error(`Cannot add variant to question of type "${question.type}"`);
default: notReachable(question);
}
});
};
export const deleteQuestionVariant = (questionId: number, variantId: string) => {
updateQuestionWithFnOptimistic(questionId, question => {
if (!("variants" in question.content)) return;
const variantIndex = question.content.variants.findIndex(variant => variant.id === variantId);
if (variantIndex === -1) return;
question.content.variants.splice(variantIndex, 1);
});
};
export const setQuestionVariantField = (
questionId: number,
variantId: string,
field: keyof QuestionVariant,
value: QuestionVariant[keyof QuestionVariant],
) => {
updateQuestionWithFnOptimistic(questionId, question => {
if (!("variants" in question.content)) return;
const variantIndex = question.content.variants.findIndex(variant => variant.id === variantId);
if (variantIndex === -1) return;
const variant = question.content.variants[variantIndex];
variant[field] = value;
});
};
export const setQuestionImageVariantField = (
questionId: number,
variantId: string,
field: keyof ImageQuestionVariant,
value: ImageQuestionVariant[keyof ImageQuestionVariant],
) => {
updateQuestionWithFnOptimistic(questionId, question => {
if (!("variants" in question.content)) return;
const variantIndex = question.content.variants.findIndex(variant => variant.id === variantId);
if (variantIndex === -1) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
variant[field] = value;
});
};
export const reorderQuestionVariants = (
questionId: number,
sourceIndex: number,
destinationIndex: number,
) => {
if (sourceIndex === destinationIndex) return;
updateQuestionWithFnOptimistic(questionId, question => {
if (!("variants" in question.content)) return;
const [removed] = question.content.variants.splice(sourceIndex, 1);
question.content.variants.splice(destinationIndex, 0, removed);
});
};
export const setQuestionBackgroundImage = (
questionId: number,
url: string,
) => {
updateQuestionWithFnOptimistic(questionId, question => {
if (question.content.back === url) return;
if (
question.content.back !== question.content.originalBack
) URL.revokeObjectURL(question.content.back);
question.content.back = url;
});
};
export const setQuestionOriginalBackgroundImage = (
questionId: number,
url: string,
) => {
updateQuestionWithFnOptimistic(questionId, question => {
if (question.content.originalBack === url) return;
URL.revokeObjectURL(question.content.originalBack);
question.content.originalBack = url;
});
};
let savedOriginalQuestion: AnyQuizQuestion | null = null;
let controller: AbortController | null = null;
export const setQuestionFieldOptimistic = async <T extends keyof AnyQuizQuestion>(
export const updateQuestionWithFnOptimistic = async (
questionId: number,
field: T,
value: AnyQuizQuestion[T],
updateFn: (question: AnyQuizQuestion) => void,
) => {
const question = useQuestionsStore.getState().questionsById[questionId] ?? null;
if (!question) return;
const currentUpdatedQuestion = produce(question, draft => {
draft[field] = value;
});
const currentUpdatedQuestion = produce(question, updateFn);
controller?.abort();
controller = new AbortController();
savedOriginalQuestion ??= question;
setQuestion(currentUpdatedQuestion);
try {
const { updated } = await questionApi.edit(
const { updated: newId } = await questionApi.edit(
questionToEditQuestionRequest(currentUpdatedQuestion),
controller.signal,
);
setQuestionField(question.id, "id", updated);
setQuestionField(question.id, "id", newId);
controller = null;
savedOriginalQuestion = null;
} catch (error) {
@ -75,6 +216,7 @@ export const setQuestionFieldOptimistic = async <T extends keyof AnyQuizQuestion
devlog("Error editing question", { error, question, currentUpdatedQuestion });
enqueueSnackbar("Не удалось сохранить вопрос");
if (!savedOriginalQuestion) {
devlog("Cannot rollback question");
throw new Error("Cannot rollback question");
@ -86,20 +228,6 @@ export const setQuestionFieldOptimistic = async <T extends keyof AnyQuizQuestion
}
};
export const updateQuestionWithFn = (
questionId: number,
updateFn: (question: AnyQuizQuestion) => void,
) => setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
updateFn(question);
}, {
type: "updateQuestion",
questionId,
updateFn: updateFn.toString(),
});
export const createQuestion = async (quizId: number) => {
try {
const question = await questionApi.create({
@ -124,12 +252,25 @@ export const deleteQuestion = async (questionId: number) => {
}
};
export const removeQuestion = (questionId: number) => setProducedState(state => {
delete state.questionsById[questionId];
}, {
type: "removeQuestion",
questionId,
});
export const copyQuestion = async (questionId: number, quizId: number) => {
try {
const { updated: newQuestionId } = await questionApi.copy(questionId, quizId);
setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
state.questionsById[newQuestionId] = question;
}, {
type: "copyQuestion",
questionId,
quizId,
});
} catch (error) {
devlog("Error copying question", error);
enqueueSnackbar("Не удалось скопировать вопрос");
}
};
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuestionsStore) => void,

@ -17,6 +17,8 @@ export const useQuestionsStore = create<QuestionsStore>()(
{
name: "QuestionsStore",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
actionsBlacklist: "ignored",
}
)
);

@ -7305,6 +7305,11 @@ nanoid@^3.3.4:
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.3.tgz#6c97f53d793a7a1de6a38ebb46f50f95bf9793c7"
integrity sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==
natural-compare-lite@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz"