Merge branch 'dev' into 'main'

Dev

See merge request frontend/squiz!83
This commit is contained in:
Nastya 2023-12-21 12:00:05 +00:00
commit 77c68856a4
49 changed files with 3523 additions and 3063 deletions

File diff suppressed because one or more lines are too long

@ -12,6 +12,7 @@ export const QUIZ_QUESTION_RESULT: Omit<QuizQuestionResult, "id" | "backendId">
innerName: "", innerName: "",
text: "", text: "",
price: [0], price: [0],
useImage: true useImage: true,
usage: true
}, },
}; };

@ -1,8 +1,4 @@
import type { import type { QuizQuestionBase, QuestionHint, PreviewRule } from "./shared";
QuizQuestionBase,
QuestionHint,
PreviewRule,
} from "./shared";
export interface QuizQuestionPage extends QuizQuestionBase { export interface QuizQuestionPage extends QuizQuestionBase {
type: "page"; type: "page";
@ -15,6 +11,7 @@ export interface QuizQuestionPage extends QuizQuestionBase {
text: string; text: string;
picture: string; picture: string;
originalPicture: string; originalPicture: string;
useImage: boolean;
video: string; video: string;
hint: QuestionHint; hint: QuestionHint;
rule: PreviewRule; rule: PreviewRule;

@ -18,5 +18,6 @@ export interface QuizQuestionResult extends QuizQuestionBase {
rule: QuestionBranchingRule, rule: QuestionBranchingRule,
hint: QuestionHint; hint: QuestionHint;
autofill: boolean; autofill: boolean;
usage: boolean
}; };
} }

@ -15,13 +15,9 @@ export default function Component() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600)); const isMobile = useMediaQuery(theme.breakpoints.down(600));
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const [select, setSelect] = React.useState(0);
const userId = useUserStore((state) => state.userId); const userId = useUserStore((state) => state.userId);
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const onClick = () => (userId ? navigate("/list") : navigate("/signin"));
return ( return (
<SectionStyled <SectionStyled
tag={"header"} tag={"header"}
@ -69,8 +65,10 @@ export default function Component() {
{/* ))}*/} {/* ))}*/}
{/*</Box>*/} {/*</Box>*/}
<Button <Button
component={Link}
to={"/signin"}
state={{ backgroundLocation: location }}
variant="outlined" variant="outlined"
onClick={onClick}
sx={{ sx={{
color: "black", color: "black",
border: "1px solid black", border: "1px solid black",

@ -1,16 +1,16 @@
import { MessageIcon } from "@icons/messagIcon"; import { MessageIcon } from "@icons/messagIcon";
import { PointsIcon } from "@icons/questionsPage/PointsIcon"; import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon"; import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import {TextareaAutosize} from "@mui/base/TextareaAutosize"; import { TextareaAutosize } from "@mui/base/TextareaAutosize";
import { import {
Box, Box,
FormControl, FormControl,
IconButton, IconButton,
InputAdornment, InputAdornment,
Popover, Popover,
TextField, TextField,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { addQuestionVariant, deleteQuestionVariant, setQuestionVariantField } from "@root/questions/actions"; import { addQuestionVariant, deleteQuestionVariant, setQuestionVariantField } from "@root/questions/actions";
import type { KeyboardEvent, ReactNode } from "react"; import type { KeyboardEvent, ReactNode } from "react";
@ -18,158 +18,147 @@ import { useState } from "react";
import { Draggable } from "react-beautiful-dnd"; import { Draggable } from "react-beautiful-dnd";
import type { QuestionVariant } from "../../../model/questionTypes/shared"; import type { QuestionVariant } from "../../../model/questionTypes/shared";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { enqueueSnackbar } from "notistack";
type AnswerItemProps = { type AnswerItemProps = {
index: number; index: number;
questionId: string; questionId: string;
variant: QuestionVariant; variant: QuestionVariant;
largeCheck: boolean; largeCheck: boolean;
additionalContent?: ReactNode; disableKeyDown?: boolean;
additionalMobile?: ReactNode; additionalContent?: ReactNode;
additionalMobile?: ReactNode;
}; };
export const AnswerItem = ({ export const AnswerItem = ({
index, index,
variant, variant,
questionId, questionId,
largeCheck, largeCheck,
additionalContent, additionalContent,
additionalMobile, additionalMobile,
disableKeyDown,
}: AnswerItemProps) => { }: AnswerItemProps) => {
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(790)); const isTablet = useMediaQuery(theme.breakpoints.down(790));
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const setQuestionVariantAnswer = useDebouncedCallback((value) => { const setQuestionVariantAnswer = useDebouncedCallback((value) => {
setQuestionVariantField(questionId, variant.id, "answer", value); setQuestionVariantField(questionId, variant.id, "answer", value);
}, 200); }, 200);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget); setAnchorEl(event.currentTarget);
setIsOpen(true); setIsOpen(true);
}; };
const handleClose = () => { const handleClose = () => {
setIsOpen(false); setIsOpen(false);
}; };
return ( return (
<Draggable draggableId={String(index)} index={index}> <Draggable draggableId={String(index)} index={index}>
{(provided) => ( {(provided) => (
<Box ref={provided.innerRef} {...provided.draggableProps}> <Box ref={provided.innerRef} {...provided.draggableProps}>
<FormControl <FormControl
key={index} key={index}
fullWidth fullWidth
variant="standard" variant="standard"
sx={{ sx={{
margin: isTablet ? " 15px 0 20px 0" : "0 0 15px 0", margin: isTablet ? " 15px 0 20px 0" : "0 0 15px 0",
borderRadius: "10px", borderRadius: "10px",
border: "1px solid rgba(0, 0, 0, 0.23)", border: "1px solid rgba(0, 0, 0, 0.23)",
background: "white", background: "white",
}}
>
<TextField
defaultValue={variant.answer}
fullWidth
focused={false}
placeholder={"Добавьте ответ"}
multiline={largeCheck}
onChange={({ target }) => {
setQuestionVariantAnswer(target.value || " ");
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (disableKeyDown) {
enqueueSnackbar("100 максимальное количество вопросов");
} else if (event.code === "Enter" && !largeCheck) {
addQuestionVariant(questionId);
}
}}
InputProps={{
startAdornment: (
<>
<InputAdornment {...provided.dragHandleProps} position="start">
<PointsIcon style={{ color: "#9A9AAF", fontSize: "30px" }} />
</InputAdornment>
{additionalContent}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton sx={{ padding: "0" }} aria-describedby="my-popover-id" onClick={handleClick}>
<MessageIcon
style={{
color: "#9A9AAF",
fontSize: "30px",
marginRight: "6.5px",
}} }}
/>
</IconButton>
<Popover
id="my-popover-id"
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
> >
<TextField <TextareaAutosize
defaultValue={variant.answer} style={{ margin: "10px" }}
fullWidth placeholder="Подсказка для этого ответа"
focused={false} value={variant.hints}
placeholder={"Добавьте ответ"} onChange={(e) => setQuestionVariantAnswer(e.target.value || " ")}
multiline={largeCheck} onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => event.stopPropagation()}
onChange={({ target }) => { />
setQuestionVariantAnswer(target.value || " "); </Popover>
}} <IconButton sx={{ padding: "0" }} onClick={() => deleteQuestionVariant(questionId, variant.id)}>
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { <DeleteIcon
if (event.code === "Enter" && !largeCheck) { style={{
addQuestionVariant(questionId); color: theme.palette.grey2.main,
} marginRight: "-1px",
}} }}
InputProps={{ />
startAdornment: ( </IconButton>
<> </InputAdornment>
<InputAdornment ),
{...provided.dragHandleProps} }}
position="start" sx={{
> "& .MuiInputBase-root": {
<PointsIcon padding: additionalContent ? "5px 13px" : "13px",
style={{ color: "#9A9AAF", fontSize: "30px" }} borderRadius: "10px",
/> background: "#ffffff",
</InputAdornment> "& input.MuiInputBase-input": {
{additionalContent} height: "22px",
</> },
), "& textarea.MuiInputBase-input": {
endAdornment: ( marginTop: "1px",
<InputAdornment position="end"> },
<IconButton "& .MuiOutlinedInput-notchedOutline": {
sx={{ padding: "0" }} border: "none",
aria-describedby="my-popover-id" },
onClick={handleClick} },
> }}
<MessageIcon inputProps={{
style={{ sx: { fontSize: "18px", lineHeight: "21px", py: 0, ml: "13px" },
color: "#9A9AAF", "data-cy": "quiz-variant-question-answer",
fontSize: "30px", }}
marginRight: "6.5px", />
}} {additionalMobile}
/> </FormControl>
</IconButton> </Box>
<Popover )}
id="my-popover-id" </Draggable>
open={isOpen} );
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<TextareaAutosize
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
value={variant.hints}
onChange={e => setQuestionVariantAnswer(e.target.value || " ")}
onKeyDown={(
event: KeyboardEvent<HTMLTextAreaElement>
) => event.stopPropagation()}
/>
</Popover>
<IconButton
sx={{ padding: "0" }}
onClick={() => deleteQuestionVariant(questionId, variant.id)}
>
<DeleteIcon
style={{
color: theme.palette.grey2.main,
marginRight: "-1px",
}}
/>
</IconButton>
</InputAdornment>
),
}}
sx={{
"& .MuiInputBase-root": {
padding: additionalContent ? "5px 13px" : "13px",
borderRadius: "10px",
background: "#ffffff",
"& input.MuiInputBase-input": {
height: "22px",
},
"& textarea.MuiInputBase-input": {
marginTop: "1px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
},
}}
inputProps={{
sx: { fontSize: "18px", lineHeight: "21px", py: 0, ml: "13px" },
"data-cy": "quiz-variant-question-answer",
}}
/>
{additionalMobile}
</FormControl>
</Box>
)}
</Draggable>
);
}; };

@ -6,44 +6,40 @@ import { DragDropContext, Droppable } from "react-beautiful-dnd";
import type { QuestionVariant, QuizQuestionsWithVariants } from "../../../model/questionTypes/shared"; import type { QuestionVariant, QuizQuestionsWithVariants } from "../../../model/questionTypes/shared";
import { AnswerItem } from "./AnswerItem"; import { AnswerItem } from "./AnswerItem";
type AnswerDraggableListProps = { type AnswerDraggableListProps = {
question: QuizQuestionsWithVariants; question: QuizQuestionsWithVariants;
additionalContent?: (variant: QuestionVariant, index: number) => ReactNode; additionalContent?: (variant: QuestionVariant, index: number) => ReactNode;
additionalMobile?: (variant: QuestionVariant, index: number) => ReactNode; additionalMobile?: (variant: QuestionVariant, index: number) => ReactNode;
}; };
export const AnswerDraggableList = ({ export const AnswerDraggableList = ({ question, additionalContent, additionalMobile }: AnswerDraggableListProps) => {
question, const onDragEnd = ({ destination, source }: DropResult) => {
additionalContent, if (destination) {
additionalMobile, reorderQuestionVariants(question.id, source.index, destination.index);
}: AnswerDraggableListProps) => { }
const onDragEnd = ({ destination, source }: DropResult) => { };
if (destination) {
reorderQuestionVariants(question.id, source.index, destination.index);
}
};
return ( return (
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable-answer-list"> <Droppable droppableId="droppable-answer-list">
{(provided) => ( {(provided) => (
<Box ref={provided.innerRef} {...provided.droppableProps}> <Box ref={provided.innerRef} {...provided.droppableProps}>
{question.content.variants.map((variant, index) => ( {question.content.variants.map((variant, index) => (
<AnswerItem <AnswerItem
key={variant.id} key={variant.id}
index={index} index={index}
questionId={question.id} disableKeyDown={question.content.variants.length >= 100}
largeCheck={("largeCheck" in question.content) ? question.content.largeCheck : false} questionId={question.id}
variant={variant} largeCheck={"largeCheck" in question.content ? question.content.largeCheck : false}
additionalContent={additionalContent?.(variant, index)} variant={variant}
additionalMobile={additionalMobile?.(variant, index)} additionalContent={additionalContent?.(variant, index)}
/> additionalMobile={additionalMobile?.(variant, index)}
))} />
{provided.placeholder} ))}
</Box> {provided.placeholder}
)} </Box>
</Droppable> )}
</DragDropContext> </Droppable>
); </DragDropContext>
);
}; };

@ -1,113 +1,45 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useState } from "react";
import Cytoscape from "cytoscape"; import Cytoscape from "cytoscape";
import { Button, Box } from "@mui/material";
import CytoscapeComponent from "react-cytoscapejs"; import CytoscapeComponent from "react-cytoscapejs";
import popper from "cytoscape-popper"; import popper from "cytoscape-popper";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { Button, Box } from "@mui/material";
import { updateRootContentId } from "@root/quizes/actions"
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared"
import { useQuestionsStore } from "@root/questions/store";
import { deleteQuestion, updateQuestion, getQuestionByContentId, clearRuleForAll, createFrontResult } from "@root/questions/actions";
import { updateCanCreatePublic, updateModalInfoWhyCantCreate, updateOpenedModalSettingsId, } from "@root/uiTools/actions";
import { cleardragQuestionContentId } from "@root/uiTools/actions";
import { withErrorBoundary } from "react-error-boundary"; import { withErrorBoundary } from "react-error-boundary";
import { enqueueSnackbar } from "notistack";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootContentId } from "@root/quizes/actions";
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { useQuestionsStore } from "@root/questions/store";
import { useUiTools } from "@root/uiTools/store";
import {
deleteQuestion,
updateQuestion,
getQuestionByContentId,
clearRuleForAll,
createResult,
} from "@root/questions/actions";
import {
updateModalInfoWhyCantCreate,
updateOpenedModalSettingsId
} from "@root/uiTools/actions";
import { cleardragQuestionContentId } from "@root/uiTools/actions";
import { updateDeleteId } from "@root/uiTools/actions";
import { DeleteNodeModal } from "../DeleteNodeModal";
import { ProblemIcon } from "@ui_kit/ProblemIcon"; import { ProblemIcon } from "@ui_kit/ProblemIcon";
import { useRemoveNode } from "./hooks/useRemoveNode";
import { usePopper } from "./hooks/usePopper";
import { storeToNodes } from "./helper"; import { storeToNodes } from "./helper";
import { stylesheet } from "./style/stylesheet";
import "./style/styles.css";
import "./styles.css"; import type { Core } from "cytoscape";
import type {
Stylesheet,
Core,
NodeSingular,
AbstractEventObject,
ElementDefinition,
} from "cytoscape";
import { enqueueSnackbar } from "notistack";
import { useUiTools } from "@root/uiTools/store";
type PopperItem = {
id: () => string;
};
type Modifier = {
name: string;
options: unknown;
};
type PopperConfig = {
popper: {
placement: string;
modifiers?: Modifier[];
};
content: (items: PopperItem[]) => void;
};
type Popper = {
update: () => Promise<void>;
setOptions: (modifiers: { modifiers?: Modifier[] }) => void;
};
type NodeSingularWithPopper = NodeSingular & {
popper: (config: PopperConfig) => Popper;
};
const stylesheet: Stylesheet[] = [
{
selector: "node",
style: {
shape: "round-rectangle",
width: 130,
height: 130,
backgroundColor: "#FFFFFF",
label: "data(label)",
"font-size": "16",
color: "#4D4D4D",
"text-halign": "center",
"text-valign": "center",
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: "[?eroticeyeblink]",
style: {
"border-width": "4px",
"border-style": "solid",
"border-color": "#7e2aea",
},
},
{
selector: ".multiline-auto",
style: {
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: "edge",
style: {
width: 30,
"line-color": "#DEDFE7",
"curve-style": "taxi",
"taxi-direction": "horizontal",
"taxi-turn": 60,
},
},
{
selector: ":selected",
style: {
"border-style": "solid",
"border-width": 1.5,
"border-color": "#9A9AAF",
},
},
];
Cytoscape.use(popper); Cytoscape.use(popper);
interface Props { interface CsComponentProps {
modalQuestionParentContentId: string; modalQuestionParentContentId: string;
modalQuestionTargetContentId: string; modalQuestionTargetContentId: string;
setOpenedModalQuestions: (open: boolean) => void; setOpenedModalQuestions: (open: boolean) => void;
@ -115,15 +47,13 @@ interface Props {
setModalQuestionTargetContentId: (id: string) => void; setModalQuestionTargetContentId: (id: string) => void;
} }
function CsComponent({ function CsComponent({
modalQuestionParentContentId, modalQuestionParentContentId,
modalQuestionTargetContentId, modalQuestionTargetContentId,
setOpenedModalQuestions, setOpenedModalQuestions,
setModalQuestionParentContentId, setModalQuestionParentContentId,
setModalQuestionTargetContentId setModalQuestionTargetContentId
}: Props) { }: CsComponentProps) {
const quiz = useCurrentQuiz(); const quiz = useCurrentQuiz();
const { dragQuestionContentId, desireToOpenABranchingModal, canCreatePublic } = useUiTools() const { dragQuestionContentId, desireToOpenABranchingModal, canCreatePublic } = useUiTools()
@ -138,6 +68,25 @@ function CsComponent({
const crossesContainer = useRef<HTMLDivElement | null>(null); const crossesContainer = useRef<HTMLDivElement | null>(null);
const gearsContainer = useRef<HTMLDivElement | null>(null); const gearsContainer = useRef<HTMLDivElement | null>(null);
const { layoutOptions } = usePopper({
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
setModalQuestionParentContentId,
setOpenedModalQuestions,
setStartCreate,
setStartRemove,
});
const { removeNode } = useRemoveNode({
cyRef,
layoutOptions,
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
});
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -181,7 +130,7 @@ function CsComponent({
if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) { if (Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren }) clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren })
cy?.data('changed', true) cy?.data('changed', true)
createFrontResult(quiz.backendId, targetQuestion.content.id) createResult(quiz?.backendId, targetQuestion.content.id)
const es = cy?.add([ const es = cy?.add([
{ {
data: { data: {
@ -196,7 +145,7 @@ function CsComponent({
} }
} }
]) ])
cy?.layout(lyopts).run() cy?.layout(layoutOptions).run()
cy?.center(es) cy?.center(es)
} else { } else {
enqueueSnackbar("Добавляемый вопрос не найден") enqueueSnackbar("Добавляемый вопрос не найден")
@ -208,10 +157,10 @@ function CsComponent({
const parentQuestion = { ...getQuestionByContentId(parentNodeContentId) } as AnyTypedQuizQuestion const parentQuestion = { ...getQuestionByContentId(parentNodeContentId) } as AnyTypedQuizQuestion
//смотрим не добавлен ли родителю result. Если да - убираем его. Веточкам result не нужен //смотрим не добавлен ли родителю result. Если да - делаем его неактивным. Веточкам result не нужен
trashQuestions.forEach((targetQuestion) => { trashQuestions.forEach((targetQuestion) => {
if (targetQuestion.type === "result" && targetQuestion.content.rule.parentId === parentQuestion.content.id) { if (targetQuestion.type === "result" && targetQuestion.content.rule.parentId === parentQuestion.content.id) {
deleteQuestion(targetQuestion.id); updateQuestion(targetQuestion.id, (q) => q.content.usage = false);
} }
}) })
@ -234,262 +183,44 @@ function CsComponent({
} }
const removeNode = ({ targetNodeContentId }: { targetNodeContentId: string }) => {
const deleteNodes = [] as string[]
const deleteEdges: any = []
const cy = cyRef?.current
const findChildrenToDelete = (node) => {
//Узнаём грани, идущие от этой ноды
cy?.$('edge[source = "' + node.id() + '"]')?.toArray().forEach((edge) => {
const edgeData = edge.data()
//записываем id грани для дальнейшего удаления
deleteEdges.push(edge)
//ищем ноду на конце грани, записываем её ID для дальнейшего удаления
const targetNode = cy?.$("#" + edgeData.target)
deleteNodes.push(targetNode.data().id)
//вызываем функцию для анализа потомков уже у этой ноды
findChildrenToDelete(targetNode)
})
}
findChildrenToDelete(cy?.getElementById(targetNodeContentId))
const targetQuestion = getQuestionByContentId(targetNodeContentId)
if (targetQuestion.content.rule.parentId === "root" && quiz) {
updateRootContentId(quiz?.id, "")
updateQuestion(targetNodeContentId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.children = []
question.content.rule.default = ""
})
trashQuestions.forEach(q => {
if (q.type === "result") {
deleteQuestion(q.id);
}
});
clearRuleForAll()
} else {
const parentQuestionContentId = cy?.$('edge[target = "' + targetNodeContentId + '"]')?.toArray()?.[0]?.data()?.source
if (targetNodeContentId && parentQuestionContentId) {
if (cy?.edges(`[source="${parentQuestionContentId}"]`).length === 0)
createFrontResult(quiz.backendId, parentQuestionContentId)
clearDataAfterRemoveNode({ targetQuestionContentId: targetNodeContentId, parentQuestionContentId })
cy?.remove(cy?.$('#' + targetNodeContentId)).layout(lyopts).run()
}
}
//После всех манипуляций удаляем грани и ноды из CS Чистим rule потомков на беке
deleteNodes.forEach((nodeId) => {//Ноды
cy?.remove(cy?.$("#" + nodeId))
removeButtons(nodeId)
updateQuestion(nodeId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
question.content.rule.children = []
})
})
deleteEdges.forEach((edge: any) => {//Грани
cy?.remove(edge)
})
removeButtons(targetNodeContentId)
cy?.data('changed', true)
cy?.layout(lyopts).run()
//удаляем result всех потомков
trashQuestions.forEach((qr) => {
if (qr.type === "result") {
if (deleteNodes.includes(qr.content.rule.parentId) || qr.content.rule.parentId === targetQuestion.content.id) {
deleteQuestion(qr.id);
}
}
})
}
const clearDataAfterRemoveNode = ({ targetQuestionContentId, parentQuestionContentId }: { targetQuestionContentId: string, parentQuestionContentId: string }) => {
updateQuestion(targetQuestionContentId, question => {
question.content.rule.parentId = ""
question.content.rule.children = []
question.content.rule.main = []
question.content.rule.default = ""
})
//чистим rule родителя
const parentQuestion = getQuestionByContentId(parentQuestionContentId)
const newRule = {}
const newChildren = [...parentQuestion.content.rule.children]
newChildren.splice(parentQuestion.content.rule.children.indexOf(targetQuestionContentId), 1);
newRule.main = parentQuestion.content.rule.main.filter((data) => data.next !== targetQuestionContentId) //удаляем условия перехода от родителя к этому вопросу
newRule.parentId = parentQuestion.content.rule.parentId
newRule.default = parentQuestion.content.rule.default === targetQuestionContentId ? "" : parentQuestion.content.rule.default
newRule.children = newChildren
updateQuestion(parentQuestionContentId, (PQ) => {
PQ.content.rule = newRule
})
}
useEffect(() => { useEffect(() => {
if (startCreate) { if (startCreate) {
addNode({ parentNodeContentId: startCreate }); addNode({ parentNodeContentId: startCreate });
cleardragQuestionContentId() cleardragQuestionContentId();
setStartCreate(""); setStartCreate("");
} }
}, [startCreate]); }, [startCreate]);
useEffect(() => { useEffect(() => {
if (startRemove) { if (startRemove) {
removeNode({ targetNodeContentId: startRemove }); updateDeleteId(startRemove);
setStartRemove(""); setStartRemove("");
} }
}, [startRemove]); }, [startRemove]);
const readyLO = (e) => {
if (e.cy.data('firstNode') === 'nonroot') {
e.cy.data('firstNode', 'root')
e.cy.nodes().sort((a, b) => (a.data('root') ? 1 : -1)).layout(lyopts).run()
} else {
e.cy.data('changed', false)
e.cy.removeData('firstNode')
}
//удаляем иконки
e.cy.nodes().forEach((ele: any) => {
const data = ele.data()
data.id && removeButtons(data.id);
})
initialPopperIcons(e)
}
const lyopts = {
name: 'preset',
positions: (e) => {
if (!e.cy().data('changed')) {
return e.data('oldPos')
}
const id = e.id()
const incomming = e.cy().edges(`[target="${id}"]`)
const layer = 0
e.removeData('lastChild')
if (incomming.length === 0) {
if (e.cy().data('firstNode') === undefined)
e.cy().data('firstNode', 'root')
e.data('root', true)
const children = e.cy().edges(`[source="${id}"]`).targets()
e.data('layer', layer)
e.data('children', children.length)
const queue = []
children.forEach(n => {
queue.push({ task: n, layer: layer + 1 })
})
while (queue.length) {
const task = queue.pop()
task.task.data('layer', task.layer)
task.task.removeData('subtreeWidth')
const children = e.cy().edges(`[source="${task.task.id()}"]`).targets()
task.task.data('children', children.length)
if (children.length !== 0) {
children.forEach(n => queue.push({ task: n, layer: task.layer + 1 }))
}
}
queue.push({ parent: e, children: children })
while (queue.length) {
const task = queue.pop()
if (task.children.length === 0) {
task.parent.data('subtreeWidth', task.parent.height() + 50)
continue
}
const unprocessed = task?.children.filter(e => {
return (e.data('subtreeWidth') === undefined)
})
if (unprocessed.length !== 0) {
queue.push(task)
unprocessed.forEach(t => {
queue.push({ parent: t, children: t.cy().edges(`[source="${t.id()}"]`).targets() })
})
continue
}
task?.parent.data('subtreeWidth', task.children.reduce((p, n) => p + n.data('subtreeWidth'), 0))
}
const pos = { x: 0, y: 0 }
e.data('oldPos', pos)
queue.push({ task: children, parent: e })
while (queue.length) {
const task = queue.pop()
const oldPos = task.parent.data('oldPos')
let yoffset = oldPos.y - task.parent.data('subtreeWidth') / 2
task.task.forEach(n => {
const width = n.data('subtreeWidth')
n.data('oldPos', { x: 250 * n.data('layer'), y: yoffset + width / 2 })
yoffset += width
queue.push({ task: n.cy().edges(`[source="${n.id()}"]`).targets(), parent: n })
})
}
e.cy().data('changed', false)
return pos
} else {
const opos = e.data('oldPos')
if (opos) {
return opos
}
}
}, // map of (node id) => (position obj); or function(node){ return somPos; }
zoom: undefined, // the zoom level to set (prob want fit = false if set)
pan: true, // the pan level to set (prob want fit = false if set)
fit: false, // whether to fit to viewport
padding: 30, // padding on fit
animate: false, // whether to transition the node positions
animationDuration: 500, // duration of animation in ms if enabled
animationEasing: undefined, // easing of animation if enabled
animateFilter: function (node, i) { return false; }, // a function that determines whether the node should be animated. All nodes animated by default on animate enabled. Non-animated nodes are positioned immediately when the layout starts
ready: readyLO, // callback on layoutready
transform: function (node, position) { return position; } // transform a given node position. Useful for changing flow direction in discrete layouts
}
useEffect(() => { useEffect(() => {
document.querySelector("#root")?.addEventListener("mouseup", cleardragQuestionContentId); document
.querySelector("#root")
?.addEventListener("mouseup", cleardragQuestionContentId);
const cy = cyRef.current; const cy = cyRef.current;
const eles = cy?.add(storeToNodes(questions.filter((question: AnyTypedQuizQuestion) => (question.type !== "result" && question.type !== null)))) const eles = cy?.add(
cy.data('changed', true) storeToNodes(
questions.filter(
(question) => question.type && question.type !== "result"
) as AnyTypedQuizQuestion[]
)
);
cy?.data("changed", true);
// cy.data('changed', true) // cy.data('changed', true)
const elecs = eles.layout(lyopts).run() const elecs = eles?.layout(layoutOptions).run();
cy?.on('add', () => cy.data('changed', true)) cy?.on("add", () => cy.data("changed", true));
cy?.fit() cy?.fit();
//cy?.layout().run() //cy?.layout().run()
return () => { return () => {
document.querySelector("#root")?.removeEventListener("mouseup", cleardragQuestionContentId); document
.querySelector("#root")
?.removeEventListener("mouseup", cleardragQuestionContentId);
layoutsContainer.current?.remove(); layoutsContainer.current?.remove();
plusesContainer.current?.remove(); plusesContainer.current?.remove();
crossesContainer.current?.remove(); crossesContainer.current?.remove();
@ -498,297 +229,6 @@ function CsComponent({
}, []); }, []);
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
const initialPopperIcons = (e) => {
const cy = e.cy
const container =
(document.body.querySelector(
".__________cytoscape_container"
) as HTMLDivElement) || null;
if (!container) {
return;
}
container.style.overflow = "hidden";
if (!plusesContainer.current) {
plusesContainer.current = document.createElement("div");
plusesContainer.current.setAttribute("id", "popper-pluses");
container.append(plusesContainer.current);
}
if (!crossesContainer.current) {
crossesContainer.current = document.createElement("div");
crossesContainer.current.setAttribute("id", "popper-crosses");
container.append(crossesContainer.current);
}
if (!gearsContainer.current) {
gearsContainer.current = document.createElement("div");
gearsContainer.current.setAttribute("id", "popper-gears");
container.append(gearsContainer.current);
}
if (!layoutsContainer.current) {
layoutsContainer.current = document.createElement("div");
layoutsContainer.current.setAttribute("id", "popper-layouts");
container.append(layoutsContainer.current);
}
const ext = cy.extent()
const nodesInView = cy.nodes().filter(n => {
const bb = n.boundingBox()
return bb.x2 > ext.x1 && bb.x1 < ext.x2 && bb.y2 > ext.y1 && bb.y1 < ext.y2
})
nodesInView
.toArray()
?.forEach((item) => {
const node = item as NodeSingularWithPopper;
const layoutsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = layoutsContainer.current?.querySelector(
`.popper-layout[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const layoutElement = document.createElement("div");
layoutElement.style.zIndex = "0"
layoutElement.classList.add("popper-layout");
layoutElement.setAttribute("data-id", item.id());
layoutElement.addEventListener("mouseup", () => {
//Узнаём грани, идущие от этой ноды
setModalQuestionParentContentId(item.id())
setOpenedModalQuestions(true)
});
layoutsContainer.current?.appendChild(layoutElement);
return layoutElement;
},
});
const plusesPopper = node.popper({
popper: {
placement: "right",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = plusesContainer.current?.querySelector(
`.popper-plus[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const plusElement = document.createElement("div");
plusElement.classList.add("popper-plus");
plusElement.setAttribute("data-id", item.id());
plusElement.style.zIndex = "1"
plusElement.addEventListener("mouseup", () => {
setStartCreate(node.id());
});
plusesContainer.current?.appendChild(plusElement);
return plusElement;
},
});
const crossesPopper = node.popper({
popper: {
placement: "top-end",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = crossesContainer.current?.querySelector(
`.popper-cross[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const crossElement = document.createElement("div");
crossElement.classList.add("popper-cross");
crossElement.setAttribute("data-id", item.id());
crossElement.style.zIndex = "2"
crossesContainer.current?.appendChild(crossElement);
crossElement.addEventListener("mouseup", () => {
setStartRemove(node.id())
}
);
return crossElement;
},
});
let gearsPopper = null
if (node.data().root !== true) {
gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = gearsContainer.current?.querySelector(
`.popper-gear[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const gearElement = document.createElement("div");
gearElement.classList.add("popper-gear");
gearElement.setAttribute("data-id", item.id());
gearElement.style.zIndex = "1"
gearsContainer.current?.appendChild(gearElement);
gearElement.addEventListener("mouseup", (e) => {
updateOpenedModalSettingsId(item.id())
});
return gearElement;
},
});
}
const update = async () => {
await plusesPopper.update();
await crossesPopper.update();
await gearsPopper?.update();
await layoutsPopper.update();
};
const onZoom = (event: AbstractEventObject) => {
const zoom = event.cy.zoom();
//update();
crossesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [-5 * zoom, -30 * zoom] } },
],
});
layoutsPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, -130 * zoom] } },
],
});
plusesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0 * zoom] } },
],
});
gearsPopper?.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0] } },
],
});
layoutsContainer.current
?.querySelectorAll("#popper-layouts > .popper-layout")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${130 * zoom}px`;
element.style.height = `${130 * zoom}px`;
});
plusesContainer.current
?.querySelectorAll("#popper-pluses > .popper-plus")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${40 * zoom}px`;
element.style.height = `${40 * zoom}px`;
element.style.fontSize = `${40 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
crossesContainer.current
?.querySelectorAll("#popper-crosses > .popper-cross")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${24 * zoom}px`;
element.style.height = `${24 * zoom}px`;
element.style.fontSize = `${24 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
gearsContainer?.current
?.querySelectorAll("#popper-gears > .popper-gear")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${60 * zoom}px`;
element.style.height = `${40 * zoom}px`;
});
};
//node?.on("position", update);
let pressed = false
let hide = false
cy?.on('mousedown', () => { pressed = true })
cy?.on('mouseup', () => {
pressed = false
hide = false
const gc = gearsContainer.current
if (gc) gc.style.display = 'block'
const pc = plusesContainer.current
const xc = crossesContainer.current
const lc = layoutsContainer.current
if (pc) pc.style.display = 'block'
if (xc) xc.style.display = 'block'
if (lc) lc.style.display = 'block'
update()
})
cy?.on('mousemove', () => {
if (pressed && !hide) {
hide = true
const gc = gearsContainer.current
if (gc) gc.style.display = 'none'
const pc = plusesContainer.current
const xc = crossesContainer.current
const lc = layoutsContainer.current
if (pc) pc.style.display = 'none'
if (xc) xc.style.display = 'none'
if (lc) lc.style.display = 'block'
}
});
cy?.on("zoom render", onZoom);
});
};
return ( return (
<> <>
<Box <Box
@ -818,19 +258,20 @@ function CsComponent({
// elements={createGraphElements(tree, quiz)} // elements={createGraphElements(tree, quiz)}
style={{ height: "480px", background: "#F2F3F7" }} style={{ height: "480px", background: "#F2F3F7" }}
stylesheet={stylesheet} stylesheet={stylesheet}
layout={(lyopts)} layout={(layoutOptions)}
cy={(cy) => { cy={(cy) => {
cyRef.current = cy; cyRef.current = cy;
}} }}
autoungrabify={true} autoungrabify={true}
/> />
<DeleteNodeModal />
</> </>
); );
}; };
function Clear() { function Clear() {
const quiz = useCurrentQuiz(); const quiz = useCurrentQuiz();
updateRootContentId(quiz.id, "") updateRootContentId(quiz?.id, "")
clearRuleForAll() clearRuleForAll()
return <></> return <></>
} }

@ -1,6 +1,6 @@
import { Box } from "@mui/material" import { Box } from "@mui/material"
import { useEffect, useRef, useLayoutEffect } from "react"; import { useEffect, useRef, useLayoutEffect } from "react";
import { deleteQuestion, clearRuleForAll, updateQuestion } from "@root/questions/actions" import { deleteQuestion, clearRuleForAll, updateQuestion, createResult } from "@root/questions/actions"
import { updateOpenedModalSettingsId } from "@root/uiTools/actions" import { updateOpenedModalSettingsId } from "@root/uiTools/actions"
import { updateRootContentId } from "@root/quizes/actions" import { updateRootContentId } from "@root/quizes/actions"
import { useCurrentQuiz } from "@root/quizes/hooks" import { useCurrentQuiz } from "@root/quizes/hooks"
@ -34,11 +34,7 @@ export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetCon
if (dragQuestionContentId) { if (dragQuestionContentId) {
updateRootContentId(quiz?.id, dragQuestionContentId) updateRootContentId(quiz?.id, dragQuestionContentId)
updateQuestion(dragQuestionContentId, (question) => question.content.rule.parentId = "root") updateQuestion(dragQuestionContentId, (question) => question.content.rule.parentId = "root")
//если были результаты - удалить createResult(quiz?.backendId, dragQuestionContentId)
questions.forEach((q) => {
if (q.type === 'result') deleteQuestion(q.id)
})
} }
} else { } else {
enqueueSnackbar("Нет информации о взятом опроснике") enqueueSnackbar("Нет информации о взятом опроснике")
@ -61,10 +57,7 @@ export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetCon
if (modalQuestionTargetContentId) { if (modalQuestionTargetContentId) {
updateRootContentId(quiz?.id, modalQuestionTargetContentId) updateRootContentId(quiz?.id, modalQuestionTargetContentId)
updateQuestion(modalQuestionTargetContentId, (question) => question.content.rule.parentId = "root") updateQuestion(modalQuestionTargetContentId, (question) => question.content.rule.parentId = "root")
//если были результаты - удалить createResult(quiz?.backendId, modalQuestionTargetContentId)
questions.forEach((q) => {
if (q.type === 'result') deleteQuestion(q.id)
})
} }
} else { } else {
enqueueSnackbar("Нет информации о взятом опроснике") enqueueSnackbar("Нет информации о взятом опроснике")
@ -90,4 +83,4 @@ export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetCon
+ +
</Box> </Box>
) )
} }

@ -0,0 +1,480 @@
import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import type { MutableRefObject } from "react";
import type {
PresetLayoutOptions,
LayoutEventObject,
NodeSingular,
AbstractEventObject,
} from "cytoscape";
type usePopperArgs = {
layoutsContainer: MutableRefObject<HTMLDivElement | null>;
plusesContainer: MutableRefObject<HTMLDivElement | null>;
crossesContainer: MutableRefObject<HTMLDivElement | null>;
gearsContainer: MutableRefObject<HTMLDivElement | null>;
setModalQuestionParentContentId: (id: string) => void;
setOpenedModalQuestions: (open: boolean) => void;
setStartCreate: (id: string) => void;
setStartRemove: (id: string) => void;
};
type PopperItem = {
id: () => string;
};
type Modifier = {
name: string;
options: unknown;
};
type PopperConfig = {
popper: {
placement: string;
modifiers?: Modifier[];
};
content: (items: PopperItem[]) => void;
};
type Popper = {
update: () => Promise<void>;
setOptions: (modifiers: { modifiers?: Modifier[] }) => void;
};
type NodeSingularWithPopper = NodeSingular & {
popper: (config: PopperConfig) => Popper;
};
export const usePopper = ({
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
setModalQuestionParentContentId,
setOpenedModalQuestions,
setStartCreate,
setStartRemove,
}: usePopperArgs) => {
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
const initialPopperIcons = ({ cy }: LayoutEventObject) => {
const container =
(document.body.querySelector(
".__________cytoscape_container"
) as HTMLDivElement) || null;
if (!container) {
return;
}
container.style.overflow = "hidden";
if (!plusesContainer.current) {
plusesContainer.current = document.createElement("div");
plusesContainer.current.setAttribute("id", "popper-pluses");
container.append(plusesContainer.current);
}
if (!crossesContainer.current) {
crossesContainer.current = document.createElement("div");
crossesContainer.current.setAttribute("id", "popper-crosses");
container.append(crossesContainer.current);
}
if (!gearsContainer.current) {
gearsContainer.current = document.createElement("div");
gearsContainer.current.setAttribute("id", "popper-gears");
container.append(gearsContainer.current);
}
if (!layoutsContainer.current) {
layoutsContainer.current = document.createElement("div");
layoutsContainer.current.setAttribute("id", "popper-layouts");
container.append(layoutsContainer.current);
}
const ext = cy.extent();
const nodesInView = cy.nodes().filter((n) => {
const bb = n.boundingBox();
return (
bb.x2 > ext.x1 && bb.x1 < ext.x2 && bb.y2 > ext.y1 && bb.y1 < ext.y2
);
});
nodesInView.toArray()?.forEach((item) => {
const node = item as NodeSingularWithPopper;
const layoutsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = layoutsContainer.current?.querySelector(
`.popper-layout[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const layoutElement = document.createElement("div");
layoutElement.style.zIndex = "0";
layoutElement.classList.add("popper-layout");
layoutElement.setAttribute("data-id", item.id());
layoutElement.addEventListener("mouseup", () => {
//Узнаём грани, идущие от этой ноды
setModalQuestionParentContentId(item.id());
setOpenedModalQuestions(true);
});
layoutsContainer.current?.appendChild(layoutElement);
return layoutElement;
},
});
const plusesPopper = node.popper({
popper: {
placement: "right",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = plusesContainer.current?.querySelector(
`.popper-plus[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const plusElement = document.createElement("div");
plusElement.classList.add("popper-plus");
plusElement.setAttribute("data-id", item.id());
plusElement.style.zIndex = "1";
plusElement.addEventListener("mouseup", () => {
setStartCreate(node.id());
});
plusesContainer.current?.appendChild(plusElement);
return plusElement;
},
});
const crossesPopper = node.popper({
popper: {
placement: "top-end",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = crossesContainer.current?.querySelector(
`.popper-cross[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const crossElement = document.createElement("div");
crossElement.classList.add("popper-cross");
crossElement.setAttribute("data-id", item.id());
crossElement.style.zIndex = "2";
crossesContainer.current?.appendChild(crossElement);
crossElement.addEventListener("mouseup", () => {
setStartRemove(node.id());
});
return crossElement;
},
});
let gearsPopper: Popper | null = null;
if (node.data().root !== true) {
gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
const itemElement = gearsContainer.current?.querySelector(
`.popper-gear[data-id='${itemId}']`
);
if (itemElement) {
return itemElement;
}
const gearElement = document.createElement("div");
gearElement.classList.add("popper-gear");
gearElement.setAttribute("data-id", item.id());
gearElement.style.zIndex = "1";
gearsContainer.current?.appendChild(gearElement);
gearElement.addEventListener("mouseup", () => {
console.log("up");
updateOpenedModalSettingsId(item.id());
});
return gearElement;
},
});
}
const update = async () => {
await plusesPopper.update();
await crossesPopper.update();
await gearsPopper?.update();
await layoutsPopper.update();
};
const onZoom = (event: AbstractEventObject) => {
const zoom = event.cy.zoom();
//update();
crossesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [-5 * zoom, -30 * zoom] } },
],
});
layoutsPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, -130 * zoom] } },
],
});
plusesPopper.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0 * zoom] } },
],
});
gearsPopper?.setOptions({
modifiers: [
{ name: "flip", options: { boundary: node } },
{ name: "offset", options: { offset: [0, 0] } },
],
});
layoutsContainer.current
?.querySelectorAll("#popper-layouts > .popper-layout")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${130 * zoom}px`;
element.style.height = `${130 * zoom}px`;
});
plusesContainer.current
?.querySelectorAll("#popper-pluses > .popper-plus")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${40 * zoom}px`;
element.style.height = `${40 * zoom}px`;
element.style.fontSize = `${40 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
crossesContainer.current
?.querySelectorAll("#popper-crosses > .popper-cross")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${24 * zoom}px`;
element.style.height = `${24 * zoom}px`;
element.style.fontSize = `${24 * zoom}px`;
element.style.borderRadius = `${6 * zoom}px`;
});
gearsContainer?.current
?.querySelectorAll("#popper-gears > .popper-gear")
.forEach((item) => {
const element = item as HTMLDivElement;
element.style.width = `${60 * zoom}px`;
element.style.height = `${40 * zoom}px`;
});
};
//node?.on("position", update);
let pressed = false;
let hide = false;
cy?.on("mousedown", () => {
pressed = true;
});
cy?.on("mouseup", () => {
pressed = false;
hide = false;
const gc = gearsContainer.current;
if (gc) gc.style.display = "block";
const pc = plusesContainer.current;
const xc = crossesContainer.current;
const lc = layoutsContainer.current;
if (pc) pc.style.display = "block";
if (xc) xc.style.display = "block";
if (lc) lc.style.display = "block";
update();
});
cy?.on("mousemove", () => {
if (pressed && !hide) {
hide = true;
const gc = gearsContainer.current;
if (gc) gc.style.display = "none";
const pc = plusesContainer.current;
const xc = crossesContainer.current;
const lc = layoutsContainer.current;
if (pc) pc.style.display = "none";
if (xc) xc.style.display = "none";
if (lc) lc.style.display = "block";
}
});
cy?.on("zoom render", onZoom);
});
};
const readyLO = (event: LayoutEventObject) => {
if (event.cy.data("firstNode") === "nonroot") {
event.cy.data("firstNode", "root");
event.cy
.nodes()
.sort((a, b) => (a.data("root") ? 1 : -1))
.layout(layoutOptions)
.run();
} else {
event.cy.data("changed", false);
event.cy.removeData("firstNode");
}
//удаляем иконки
event.cy.nodes().forEach((ele: any) => {
const data = ele.data();
data.id && removeButtons(data.id);
});
initialPopperIcons(event);
};
const layoutOptions: PresetLayoutOptions = {
name: "preset",
positions: (node) => {
if (!node.cy().data("changed")) {
return node.data("oldPos");
}
const id = node.id();
const incomming = node.cy().edges(`[target="${id}"]`);
const layer = 0;
node.removeData("lastChild");
if (incomming.length === 0) {
if (node.cy().data("firstNode") === undefined)
node.cy().data("firstNode", "root");
node.data("root", true);
const children = node.cy().edges(`[source="${id}"]`).targets();
node.data("layer", layer);
node.data("children", children.length);
const queue = [];
children.forEach((n) => {
queue.push({ task: n, layer: layer + 1 });
});
while (queue.length) {
const task = queue.pop();
task.task.data("layer", task.layer);
task.task.removeData("subtreeWidth");
const children = node
.cy()
.edges(`[source="${task.task.id()}"]`)
.targets();
task.task.data("children", children.length);
if (children.length !== 0) {
children.forEach((n) =>
queue.push({ task: n, layer: task.layer + 1 })
);
}
}
queue.push({ parent: node, children: children });
while (queue.length) {
const task = queue.pop();
if (task.children.length === 0) {
task.parent.data("subtreeWidth", task.parent.height() + 50);
continue;
}
const unprocessed = task?.children.filter((node) => {
return node.data("subtreeWidth") === undefined;
});
if (unprocessed.length !== 0) {
queue.push(task);
unprocessed.forEach((t) => {
queue.push({
parent: t,
children: t.cy().edges(`[source="${t.id()}"]`).targets(),
});
});
continue;
}
task?.parent.data(
"subtreeWidth",
task.children.reduce((p, n) => p + n.data("subtreeWidth"), 0)
);
}
const pos = { x: 0, y: 0 };
node.data("oldPos", pos);
queue.push({ task: children, parent: node });
while (queue.length) {
const task = queue.pop();
const oldPos = task.parent.data("oldPos");
let yoffset = oldPos.y - task.parent.data("subtreeWidth") / 2;
task.task.forEach((n) => {
const width = n.data("subtreeWidth");
n.data("oldPos", {
x: 250 * n.data("layer"),
y: yoffset + width / 2,
});
yoffset += width;
queue.push({
task: n.cy().edges(`[source="${n.id()}"]`).targets(),
parent: n,
});
});
}
node.cy().data("changed", false);
return pos;
} else {
const opos = node.data("oldPos");
if (opos) {
return opos;
}
}
}, // map of (node id) => (position obj); or function(node){ return somPos; }
zoom: undefined, // the zoom level to set (prob want fit = false if set)
pan: 1, // the pan level to set (prob want fit = false if set)
fit: false, // whether to fit to viewport
padding: 30, // padding on fit
animate: false, // whether to transition the node positions
animationDuration: 500, // duration of animation in ms if enabled
animationEasing: undefined, // easing of animation if enabled
animateFilter: function (node, i) {
return false;
}, // a function that determines whether the node should be animated. All nodes animated by default on animate enabled. Non-animated nodes are positioned immediately when the layout starts
ready: readyLO, // callback on layoutready
transform: function (node, position) {
return position;
}, // transform a given node position. Useful for changing flow direction in discrete layouts
};
return { layoutOptions };
};

@ -0,0 +1,205 @@
import {
deleteQuestion,
updateQuestion,
getQuestionByContentId,
clearRuleForAll,
} from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootContentId } from "@root/quizes/actions";
import type { MutableRefObject } from "react";
import type {
Core,
CollectionReturnValue,
PresetLayoutOptions,
} from "cytoscape";
import type {
QuestionBranchingRule,
QuestionBranchingRuleMain,
} from "../../../../model/questionTypes/shared";
type UseRemoveNodeArgs = {
cyRef: MutableRefObject<Core | null>;
layoutOptions: PresetLayoutOptions;
layoutsContainer: MutableRefObject<HTMLDivElement | null>;
plusesContainer: MutableRefObject<HTMLDivElement | null>;
crossesContainer: MutableRefObject<HTMLDivElement | null>;
gearsContainer: MutableRefObject<HTMLDivElement | null>;
};
export const useRemoveNode = ({
cyRef,
layoutOptions,
layoutsContainer,
plusesContainer,
crossesContainer,
gearsContainer,
}: UseRemoveNodeArgs) => {
const { questions: trashQuestions } = useQuestionsStore();
const quiz = useCurrentQuiz();
const removeButtons = (id: string) => {
layoutsContainer.current
?.querySelector(`.popper-layout[data-id='${id}']`)
?.remove();
plusesContainer.current
?.querySelector(`.popper-plus[data-id='${id}']`)
?.remove();
crossesContainer.current
?.querySelector(`.popper-cross[data-id='${id}']`)
?.remove();
gearsContainer.current
?.querySelector(`.popper-gear[data-id='${id}']`)
?.remove();
};
const clearDataAfterRemoveNode = ({
targetQuestionContentId,
parentQuestionContentId,
}: {
targetQuestionContentId: string;
parentQuestionContentId: string;
}) => {
updateQuestion(targetQuestionContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.children = [];
question.content.rule.main = [];
question.content.rule.default = "";
});
//чистим rule родителя
const parentQuestion = getQuestionByContentId(parentQuestionContentId);
if (!parentQuestion?.type) {
return;
}
const newChildren = [...parentQuestion.content.rule.children];
newChildren.splice(
parentQuestion.content.rule.children.indexOf(targetQuestionContentId),
1
);
const newRule: QuestionBranchingRule = {
children: newChildren,
default:
parentQuestion.content.rule.default === targetQuestionContentId
? ""
: parentQuestion.content.rule.default,
//удаляем условия перехода от родителя к этому вопросу,
main: parentQuestion.content.rule.main.filter(
(data: QuestionBranchingRuleMain) =>
data.next !== targetQuestionContentId
),
parentId: parentQuestion.content.rule.parentId,
};
updateQuestion(parentQuestionContentId, (PQ) => {
PQ.content.rule = newRule;
});
};
const removeNode = (targetNodeContentId: string) => {
const deleteNodes: string[] = [];
const deleteEdges: any = [];
const cy = cyRef?.current;
const findChildrenToDelete = (node: CollectionReturnValue) => {
//Узнаём грани, идущие от этой ноды
cy?.$('edge[source = "' + node.id() + '"]')
?.toArray()
.forEach((edge) => {
const edgeData = edge.data();
//записываем id грани для дальнейшего удаления
deleteEdges.push(edge);
//ищем ноду на конце грани, записываем её ID для дальнейшего удаления
const targetNode = cy?.$("#" + edgeData.target);
deleteNodes.push(targetNode.data().id);
//вызываем функцию для анализа потомков уже у этой ноды
findChildrenToDelete(targetNode);
});
};
const elementToDelete = cy?.getElementById(targetNodeContentId);
if (elementToDelete) {
findChildrenToDelete(elementToDelete);
}
const targetQuestion = getQuestionByContentId(targetNodeContentId);
if (
targetQuestion?.type &&
targetQuestion.content.rule.parentId === "root" &&
quiz
) {
updateRootContentId(quiz?.id, "");
updateQuestion(targetNodeContentId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.children = [];
question.content.rule.default = "";
});
clearRuleForAll();
} else {
const parentQuestionContentId = cy
?.$('edge[target = "' + targetNodeContentId + '"]')
?.toArray()?.[0]
?.data()?.source;
if (targetNodeContentId && parentQuestionContentId) {
if (
quiz &&
cy?.edges(`[source="${parentQuestionContentId}"]`).length === 0
) {
//createFrontResult(quiz.backendId, parentQuestionContentId);
}
clearDataAfterRemoveNode({
targetQuestionContentId: targetNodeContentId,
parentQuestionContentId,
});
cy?.remove(cy?.$("#" + targetNodeContentId))
.layout(layoutOptions)
.run();
}
}
//После всех манипуляций удаляем грани и ноды из CS Чистим rule потомков на беке
deleteNodes.forEach((nodeId) => {
//Ноды
cy?.remove(cy?.$("#" + nodeId));
removeButtons(nodeId);
updateQuestion(nodeId, (question) => {
question.content.rule.parentId = "";
question.content.rule.main = [];
question.content.rule.default = "";
question.content.rule.children = [];
});
});
deleteEdges.forEach((edge: any) => {
//Грани
cy?.remove(edge);
});
removeButtons(targetNodeContentId);
cy?.data("changed", true);
cy?.layout(layoutOptions).run();
//удаляем result всех потомков
trashQuestions.forEach((qr) => {
if (
qr.type === "result" &&
(deleteNodes.includes(qr.content.rule.parentId || "") ||
(targetQuestion?.type &&
qr.content.rule.parentId === targetQuestion.content.id))
) {
deleteQuestion(qr.id);
}
});
};
return { removeNode };
};

@ -2,42 +2,53 @@ import { Box } from "@mui/material";
import { FirstNodeField } from "./FirstNodeField"; import { FirstNodeField } from "./FirstNodeField";
import CsComponent from "./CsComponent"; import CsComponent from "./CsComponent";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { useState } from "react"; import { useEffect, useState } from "react";
import {BranchingQuestionsModal} from "../BranchingQuestionsModal" import { BranchingQuestionsModal } from "../BranchingQuestionsModal";
import { useUiTools } from "@root/uiTools/store"; import { useUiTools } from "@root/uiTools/store";
export const BranchingMap = () => { export const BranchingMap = () => {
const quiz = useCurrentQuiz(); const quiz = useCurrentQuiz();
const { dragQuestionContentId } = useUiTools() const { dragQuestionContentId } = useUiTools();
const [modalQuestionParentContentId, setModalQuestionParentContentId] = useState<string>("") const [modalQuestionParentContentId, setModalQuestionParentContentId] =
const [modalQuestionTargetContentId, setModalQuestionTargetContentId] = useState<string>("") useState<string>("");
const [openedModalQuestions, setOpenedModalQuestions] = useState<boolean>(false) const [modalQuestionTargetContentId, setModalQuestionTargetContentId] =
useState<string>("");
const [openedModalQuestions, setOpenedModalQuestions] =
useState<boolean>(false);
return ( return (
<Box <Box
id="cytoscape-container" id="cytoscape-container"
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
padding: "20px", padding: "20px",
background: "#FFFFFF", background: "#FFFFFF",
borderRadius: "12px", borderRadius: "12px",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)", boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
marginBottom: "40px", marginBottom: "40px",
height: "568px", height: "568px",
border: dragQuestionContentId === null ? "none" : "#7e2aea 2px dashed" border: dragQuestionContentId === null ? "none" : "#7e2aea 2px dashed",
}} }}
> >
{quiz?.config.haveRoot ? (
{ <CsComponent
quiz?.config.haveRoot ? modalQuestionParentContentId={modalQuestionParentContentId}
<CsComponent modalQuestionParentContentId={modalQuestionParentContentId} modalQuestionTargetContentId={modalQuestionTargetContentId} setOpenedModalQuestions={setOpenedModalQuestions} setModalQuestionParentContentId={setModalQuestionParentContentId} setModalQuestionTargetContentId={setModalQuestionTargetContentId}/> modalQuestionTargetContentId={modalQuestionTargetContentId}
: setOpenedModalQuestions={setOpenedModalQuestions}
<FirstNodeField setOpenedModalQuestions={setOpenedModalQuestions} modalQuestionTargetContentId={modalQuestionTargetContentId}/> setModalQuestionParentContentId={setModalQuestionParentContentId}
} setModalQuestionTargetContentId={setModalQuestionTargetContentId}
<BranchingQuestionsModal openedModalQuestions={openedModalQuestions} setOpenedModalQuestions={setOpenedModalQuestions} setModalQuestionTargetContentId={setModalQuestionTargetContentId} /> />
</Box> ) : (
<FirstNodeField
setOpenedModalQuestions={setOpenedModalQuestions}
modalQuestionTargetContentId={modalQuestionTargetContentId}
/>
)}
<BranchingQuestionsModal
openedModalQuestions={openedModalQuestions}
setOpenedModalQuestions={setOpenedModalQuestions}
setModalQuestionTargetContentId={setModalQuestionTargetContentId}
/>
</Box>
); );
}; };

@ -38,7 +38,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 6px; border-radius: 6px;
background-image: url("../../../assets/icons/ArrowGear.svg"); background-image: url("../../../../assets/icons/ArrowGear.svg");
font-size: 0px; font-size: 0px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: contain; background-size: contain;

@ -0,0 +1,53 @@
import type { Stylesheet } from "cytoscape";
export const stylesheet: Stylesheet[] = [
{
selector: "node",
style: {
shape: "round-rectangle",
width: 130,
height: 130,
backgroundColor: "#FFFFFF",
label: "data(label)",
"font-size": "16",
color: "#4D4D4D",
"text-halign": "center",
"text-valign": "center",
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: "[?eroticeyeblink]",
style: {
"border-width": "4px",
"border-style": "solid",
"border-color": "#7e2aea",
},
},
{
selector: ".multiline-auto",
style: {
"text-wrap": "wrap",
"text-max-width": "80",
},
},
{
selector: "edge",
style: {
width: 30,
"line-color": "#DEDFE7",
"curve-style": "taxi",
"taxi-direction": "horizontal",
"taxi-turn": 60,
},
},
{
selector: ":selected",
style: {
"border-style": "solid",
"border-width": 1.5,
"border-color": "#9A9AAF",
},
},
];

@ -13,7 +13,7 @@ import {
useTheme, useTheme,
Checkbox, Checkbox,
} from "@mui/material"; } from "@mui/material";
import { AnyTypedQuizQuestion, createBranchingRuleMain } from "../../../model/questionTypes/shared" import { AnyTypedQuizQuestion, createBranchingRuleMain } from "../../../model/questionTypes/shared";
import { Select } from "../Select"; import { Select } from "../Select";
import RadioCheck from "@ui_kit/RadioCheck"; import RadioCheck from "@ui_kit/RadioCheck";
@ -27,45 +27,51 @@ import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store"; import { useUiTools } from "@root/uiTools/store";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
export default function BranchingQuestions() { export default function BranchingQuestions() {
const theme = useTheme(); const theme = useTheme();
const { openedModalSettingsId } = useUiTools(); const { openedModalSettingsId } = useUiTools();
const [targetQuestion, setTargetQuestion] = useState<AnyTypedQuizQuestion | null>(getQuestionById(openedModalSettingsId) || getQuestionByContentId(openedModalSettingsId)) const [targetQuestion, setTargetQuestion] = useState<AnyTypedQuizQuestion | null>(
const [parentQuestion, setParentQuestion] = useState<AnyTypedQuizQuestion | null>(getQuestionByContentId(targetQuestion?.content.rule.parentId)) getQuestionById(openedModalSettingsId) || getQuestionByContentId(openedModalSettingsId)
);
const [parentQuestion, setParentQuestion] = useState<AnyTypedQuizQuestion | null>(
getQuestionByContentId(targetQuestion?.content.rule.parentId)
);
useLayoutEffect(() => { useLayoutEffect(() => {
if (parentQuestion === null) return if (parentQuestion === null) return;
if (parentQuestion.content.rule.main.length === 0) { if (parentQuestion.content.rule.main.length === 0) {
let mutate = JSON.parse(JSON.stringify(parentQuestion)) let mutate = JSON.parse(JSON.stringify(parentQuestion));
mutate.content.rule.main = [{ mutate.content.rule.main = [
next: targetQuestion.content.id, {
or: true, next: targetQuestion.content.id,
rules: [{ or: true,
question: parentQuestion.content.id, rules: [
answers: [] {
}] question: parentQuestion.content.id,
}] answers: [],
setParentQuestion(mutate) },
],
},
];
setParentQuestion(mutate);
} }
}) });
if (targetQuestion === null || parentQuestion === null) { if (targetQuestion === null || parentQuestion === null) {
enqueueSnackbar("Невозможно найти данные ветвления для этого вопроса") enqueueSnackbar("Невозможно найти данные ветвления для этого вопроса");
return <></> return <></>;
} }
const saveData = () => { const saveData = () => {
if (parentQuestion !== null) { if (parentQuestion !== null) {
updateQuestion(parentQuestion.content.id, question => question.content = parentQuestion.content) updateQuestion(parentQuestion.content.id, (question) => (question.content = parentQuestion.content));
} }
handleClose() handleClose();
};
}
const handleClose = () => { const handleClose = () => {
updateOpenedModalSettingsId() updateOpenedModalSettingsId();
}; };
return ( return (
@ -100,44 +106,50 @@ export default function BranchingQuestions() {
<Box sx={{ color: "#4d4d4d" }}> <Box sx={{ color: "#4d4d4d" }}>
<Typography component="span">{targetQuestion.title}</Typography> <Typography component="span">{targetQuestion.title}</Typography>
</Box> </Box>
<Tooltip <Tooltip title="Настройте условия, при которых данный вопрос будет отображаться в квизе." placement="top">
title="Настройте условия, при которых данный вопрос будет отображаться в квизе."
placement="top"
>
<Box> <Box>
<InfoIcon /> <InfoIcon />
</Box> </Box>
</Tooltip> </Tooltip>
</Box> </Box>
<Box <Box
sx={{ sx={{
height: "400px", height: "400px",
overflow: "auto" overflow: "auto",
}} }}
> >
{ {parentQuestion.content.rule.main.length ? (
parentQuestion.content.rule.main.length ? parentQuestion.content.rule.main.map((e: any, i: number) => {
parentQuestion.content.rule.main.map((e: any, i: number) => { if (e.next === targetQuestion.content.id) {
if (e.next === targetQuestion.content.id) { return (
return <TypeSwitch key={i} setParentQuestion={setParentQuestion} targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={i} /> <TypeSwitch
} else { key={i}
<></> setParentQuestion={setParentQuestion}
} targetQuestion={targetQuestion}
}) parentQuestion={parentQuestion}
: ruleIndex={i}
<TypeSwitch targetQuestion={targetQuestion} setParentQuestion={setParentQuestion} parentQuestion={parentQuestion} ruleIndex={0} /> />
} );
} else {
<></>;
}
})
) : (
<TypeSwitch
targetQuestion={targetQuestion}
setParentQuestion={setParentQuestion}
parentQuestion={parentQuestion}
ruleIndex={0}
/>
)}
</Box> </Box>
<Box <Box
sx={{ sx={{
margin: "20px 0 0 20px", margin: "20px 0 0 20px",
display: "flex", display: "flex",
flexDirection: "column" flexDirection: "column",
}} }}
> >
<Link <Link
@ -145,48 +157,45 @@ export default function BranchingQuestions() {
sx={{ sx={{
color: theme.palette.brightPurple.main, color: theme.palette.brightPurple.main,
marginBottom: "10px", marginBottom: "10px",
cursor: "pointer" cursor: "pointer",
}} }}
onClick={() => { onClick={() => {
const mutate = JSON.parse(JSON.stringify(parentQuestion)) const mutate = JSON.parse(JSON.stringify(parentQuestion));
mutate.content.rule.main.push(createBranchingRuleMain(targetQuestion.content.id, parentQuestion.content.id)) mutate.content.rule.main.push(
setParentQuestion(mutate) createBranchingRuleMain(targetQuestion.content.id, parentQuestion.content.id)
);
setParentQuestion(mutate);
}} }}
> >
Добавить условие Добавить условие
</Link> </Link>
<FormControlLabel
<FormControlLabel control={<Checkbox control={
<Checkbox
sx={{ sx={{
margin: 0 margin: 0,
}} }}
checked={parentQuestion.content.rule.default === targetQuestion.content.id} checked={parentQuestion.content.rule.default === targetQuestion.content.id}
onClick={() => {
onClick={() => { let mutate = JSON.parse(JSON.stringify(parentQuestion));
let mutate = JSON.parse(JSON.stringify(parentQuestion)) mutate.content.rule.default =
mutate.content.rule.default = parentQuestion.content.rule.default === targetQuestion.content.id ? "" : targetQuestion.content.id parentQuestion.content.rule.default === targetQuestion.content.id
setParentQuestion(mutate) ? ""
}} : targetQuestion.content.id;
/>} label="Следующий вопрос по-умолчанию" /> setParentQuestion(mutate);
}}
/>
}
label="Следующий вопрос по-умолчанию"
/>
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "end", gap: "10px", margin: "20px" }}> <Box sx={{ display: "flex", justifyContent: "end", gap: "10px", margin: "20px" }}>
<Button <Button variant="outlined" onClick={handleClose} sx={{ width: "100%", maxWidth: "130px" }}>
variant="outlined"
onClick={handleClose}
sx={{ width: "100%", maxWidth: "130px" }}
>
Отмена Отмена
</Button> </Button>
<Button <Button variant="contained" sx={{ width: "100%", maxWidth: "130px" }} onClick={saveData}>
variant="contained"
sx={{ width: "100%", maxWidth: "130px" }}
onClick={saveData}
>
Готово Готово
</Button> </Button>
</Box> </Box>

@ -6,14 +6,12 @@ interface Props {
openedModalQuestions: boolean; openedModalQuestions: boolean;
setModalQuestionTargetContentId: (contentId: string) => void; setModalQuestionTargetContentId: (contentId: string) => void;
setOpenedModalQuestions: (open: boolean) => void; setOpenedModalQuestions: (open: boolean) => void;
setModalQuestionParentContentId: (open: string) => void;
} }
export const BranchingQuestionsModal = ({ export const BranchingQuestionsModal = ({
openedModalQuestions, openedModalQuestions,
setOpenedModalQuestions, setOpenedModalQuestions,
setModalQuestionTargetContentId, setModalQuestionTargetContentId,
setModalQuestionParentContentId,
}: Props) => { }: Props) => {
const trashQuestions = useQuestionsStore().questions; const trashQuestions = useQuestionsStore().questions;
const questions = trashQuestions.filter( const questions = trashQuestions.filter(
@ -25,10 +23,14 @@ export const BranchingQuestionsModal = ({
}; };
const typedQuestions: AnyTypedQuizQuestion[] = questions.filter( const typedQuestions: AnyTypedQuizQuestion[] = questions.filter(
(question) => question.type && !question.content.rule.parentId && question.type !== "result" (question) =>
question.type &&
!question.content.rule.parentId &&
question.type !== "result"
) as AnyTypedQuizQuestion[]; ) as AnyTypedQuizQuestion[];
if (typedQuestions.length === 0) return <></> if (typedQuestions.length === 0) return <></>;
return ( return (
<Modal open={openedModalQuestions} onClose={handleClose}> <Modal open={openedModalQuestions} onClose={handleClose}>
<Box <Box
@ -44,7 +46,7 @@ export const BranchingQuestionsModal = ({
borderRadius: "12px", borderRadius: "12px",
boxShadow: 24, boxShadow: 24,
padding: "30px 0", padding: "30px 0",
height: "80vh" height: "80vh",
}} }}
> >
<Box sx={{ margin: "0 auto", maxWidth: "350px" }}> <Box sx={{ margin: "0 auto", maxWidth: "350px" }}>

@ -57,11 +57,6 @@ export default function ButtonsOptions({
if (question.content.rule.parentId === "root") { //удалить из стора root и очистить rule всем вопросам if (question.content.rule.parentId === "root") { //удалить из стора root и очистить rule всем вопросам
updateRootContentId(quiz.id, ""); updateRootContentId(quiz.id, "");
clearRuleForAll(); clearRuleForAll();
questions.forEach(q => {
if (q.type === "result") {
deleteQuestion(q.id);
}
});
deleteQuestion(question.id); deleteQuestion(question.id);
} else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков } else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков
const clearQuestions = [] as string[]; const clearQuestions = [] as string[];
@ -69,10 +64,8 @@ export default function ButtonsOptions({
//записываем потомков , а их результаты удаляем //записываем потомков , а их результаты удаляем
const getChildren = (parentQuestion: AnyTypedQuizQuestion) => { const getChildren = (parentQuestion: AnyTypedQuizQuestion) => {
questions.forEach((targetQuestion) => { questions.forEach((targetQuestion) => {
if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его if (targetQuestion.type !== null && targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его
if (targetQuestion.type === "result") { if (targetQuestion.type !== "result" && targetQuestion.type !== null) {
deleteQuestion(targetQuestion.id);
} else {
if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id); if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id);
getChildren(targetQuestion); //и ищем его потомков getChildren(targetQuestion); //и ищем его потомков
} }

@ -58,11 +58,6 @@ export default function ButtonsOptionsAndPict({
if (question.content.rule.parentId === "root") { //удалить из стора root и очистить rule всем вопросам if (question.content.rule.parentId === "root") { //удалить из стора root и очистить rule всем вопросам
updateRootContentId(quiz.id, ""); updateRootContentId(quiz.id, "");
clearRuleForAll(); clearRuleForAll();
questions.forEach(q => {
if (q.type === "result") {
deleteQuestion(q.id);
}
});
deleteQuestion(question.id); deleteQuestion(question.id);
} else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков } else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков
const clearQuestions = [] as string[]; const clearQuestions = [] as string[];
@ -71,9 +66,7 @@ export default function ButtonsOptionsAndPict({
const getChildren = (parentQuestion: AnyTypedQuizQuestion) => { const getChildren = (parentQuestion: AnyTypedQuizQuestion) => {
questions.forEach((targetQuestion) => { questions.forEach((targetQuestion) => {
if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его
if (targetQuestion.type === "result") { if (targetQuestion.type !== null && targetQuestion.type !== "result") {
deleteQuestion(targetQuestion.id);
} else {
if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id); if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id);
getChildren(targetQuestion); //и ищем его потомков getChildren(targetQuestion); //и ищем его потомков
} }

@ -0,0 +1,122 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import {
Box,
Button,
FormControl,
FormControlLabel,
Link,
Modal,
Radio,
RadioGroup,
Tooltip,
Typography,
useTheme,
Checkbox,
} from "@mui/material";
import { enqueueSnackbar } from "notistack";
import {
AnyTypedQuizQuestion,
createBranchingRuleMain,
} from "../../../model/questionTypes/shared";
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 {
getQuestionById,
getQuestionByContentId,
updateQuestion,
} from "@root/questions/actions";
import { updateOpenedModalSettingsId } from "@root/uiTools/actions";
import { useUiTools } from "@root/uiTools/store";
export const DeleteNodeModal = () => {
const { deleteNodeId } = useUiTools();
const targetQuestion = getQuestionById(deleteNodeId);
const saveData = () => {
// if (parentQuestion !== null) {
// updateQuestion(
// parentQuestion.content.id,
// (question) => (question.content = parentQuestion.content)
// );
// }
// handleClose();
};
const handleClose = () => {
updateOpenedModalSettingsId();
};
return (
<>
<Modal open={!!deleteNodeId} 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: "#4d4d4d" }}>
<Typography component="span">{targetQuestion?.title}</Typography>
</Box>
<Tooltip
title="Настройте условия, при которых данный вопрос будет отображаться в квизе."
placement="top"
>
<Box>
<InfoIcon />
</Box>
</Tooltip>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "end",
gap: "10px",
margin: "20px",
}}
>
<Button
variant="outlined"
onClick={handleClose}
sx={{ width: "100%", maxWidth: "130px" }}
>
Отмена
</Button>
<Button
variant="contained"
sx={{ width: "100%", maxWidth: "130px" }}
onClick={saveData}
>
Готово
</Button>
</Box>
</Box>
</Modal>
</>
);
};

@ -18,18 +18,31 @@ import RatingIcon from "@icons/questionsPage/rating";
import Slider from "@icons/questionsPage/slider"; import Slider from "@icons/questionsPage/slider";
import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import { import {
Box, Button, Box,
Checkbox, Button,
FormControl, Checkbox,
FormControlLabel, FormControl,
IconButton, FormControlLabel,
InputAdornment, Modal, IconButton,
Paper, InputAdornment,
TextField, Typography, Modal,
useMediaQuery, Paper,
useTheme, TextField,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material"; } from "@mui/material";
import { copyQuestion, createUntypedQuestion, deleteQuestion, clearRuleForAll, toggleExpandQuestion, updateQuestion, updateUntypedQuestion, getQuestionByContentId, deleteQuestionWithTimeout } from "@root/questions/actions"; import {
copyQuestion,
createUntypedQuestion,
deleteQuestion,
clearRuleForAll,
toggleExpandQuestion,
updateQuestion,
updateUntypedQuestion,
getQuestionByContentId,
deleteQuestionWithTimeout,
} from "@root/questions/actions";
import { updateRootContentId } from "@root/quizes/actions"; import { updateRootContentId } from "@root/quizes/actions";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
@ -51,9 +64,14 @@ interface Props {
} }
export default function QuestionsPageCard({ question, draggableProps, isDragging, index }: Props) { export default function QuestionsPageCard({ question, draggableProps, isDragging, index }: Props) {
const maxLengthTextField = 225;
const { questions } = useQuestionsStore(); const { questions } = useQuestionsStore();
const [plusVisible, setPlusVisible] = useState<boolean>(false); const [plusVisible, setPlusVisible] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [isTextFieldtActive, setIsTextFieldtActive] = useState(false);
const [openDelete, setOpenDelete] = useState<boolean>(false); const [openDelete, setOpenDelete] = useState<boolean>(false);
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
@ -75,21 +93,14 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
updateRootContentId(quiz.id, ""); updateRootContentId(quiz.id, "");
clearRuleForAll(); clearRuleForAll();
deleteQuestion(question.id); deleteQuestion(question.id);
questions.forEach(q => {
if (q.type === "result") {
deleteQuestion(q.id);
}
});
} else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков } else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков
const clearQuestions = [] as string[]; const clearQuestions = [] as string[];
//записываем потомков , а их результаты удаляем //записываем потомков , а их результаты удаляем
const getChildren = (parentQuestion: AnyTypedQuizQuestion) => { const getChildren = (parentQuestion: AnyTypedQuizQuestion) => {
questions.forEach((targetQuestion) => { questions.forEach((targetQuestion) => {
if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его if (targetQuestion.type !== null && targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его
if (targetQuestion.type === "result") { if (targetQuestion.type !== null && targetQuestion.type !== "result") {
deleteQuestion(targetQuestion.id);
} else {
if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id); if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id);
getChildren(targetQuestion); //и ищем его потомков getChildren(targetQuestion); //и ищем его потомков
} }
@ -127,328 +138,332 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
} }
}; };
return (
<>
<Paper
id={question.id}
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 }: { target: HTMLInputElement; }) => setTitle(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}
questionType={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 === null ? 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={() => toggleExpandQuestion(question.id)}
>
{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(question.id, question.quizId)}
>
<CopyIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
<IconButton
sx={{
cursor: "pointer",
borderRadius: "6px",
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
if (question.type === null) deleteQuestion(question.id)
if(question?.content?.rule.parentId.length !== 0) {
setOpenDelete(true)
} else {
deleteQuestionWithTimeout(question.id, deleteFn);
}
}} const handleInputFocus = () => {
data-cy="delete-question" setIsTextFieldtActive(true);
> };
<DeleteIcon
style={{ color: theme.palette.brightPurple.main }} const handleInputBlur = (event: React.FocusEvent<HTMLInputElement>) => {
/> setIsTextFieldtActive(false);
</IconButton> };
<Modal open={openDelete} onClose={() => setOpenDelete(false)}>
<Box
sx={{ return (
position: "absolute", <>
top: "50%", <Paper
left: "50%", id={question.id}
transform: "translate(-50%, -50%)", data-cy="quiz-question-card"
padding: "30px", sx={{
borderRadius: "10px", maxWidth: "796px",
background: "#FFFFFF", width: "100%",
}} borderRadius: "12px",
> backgroundColor: question.expanded ? "white" : "#EEE4FC",
<Typography variant="h6" sx={{textAlign: "center"}}> border: question.expanded ? "none" : "1px solid #9A9AAF",
Вы удаляете вопрос, участвующий в ветвлении. Все его потомки потеряют данные ветвления. Вы уверены, что хотите удалить вопрос? boxShadow: "0px 10px 30px #e7e7e7",
</Typography> }}
<Box >
sx={{ <Box
marginTop: "30px", sx={{
display: "flex", display: "flex",
justifyContent: "center", alignItems: "center",
gap: "15px", padding: isMobile ? "10px" : "20px 10px 20px 20px",
}} flexDirection: isMobile ? "column" : null,
> }}
<Button >
variant="contained" <FormControl
sx={{ minWidth: "150px" }} variant="standard"
onClick={() => setOpenDelete(false)} sx={{
> p: 0,
Отмена maxWidth: isTablet ? "549px" : "640px",
</Button> width: "100%",
<Button marginRight: isMobile ? "0px" : "16.1px",
variant="contained" }}
sx={{ minWidth: "150px" }} >
onClick={() => { <TextField
deleteQuestionWithTimeout(question.id, deleteFn); defaultValue={question.title}
}} placeholder={"Заголовок вопроса"}
> onChange={({ target }: { target: HTMLInputElement }) => setTitle(target.value || " ")}
Подтвердить onFocus={handleInputFocus}
</Button> onBlur={handleInputBlur}
</Box> inputProps={{
</Box> maxLength: maxLengthTextField,
</Modal> }}
</Box> InputProps={{
)} startAdornment: (
{question.type !== null && <Box>
<Box <InputAdornment
style={{ ref={anchorRef}
display: "flex", position="start"
alignItems: "center", sx={{ cursor: "pointer" }}
justifyContent: "center", onClick={() => setOpen((isOpened) => !isOpened)}
height: "30px",
width: "30px",
marginLeft: "3px",
borderRadius: "50%",
fontSize: "16px",
color: question.expanded
? theme.palette.brightPurple.main
: "#FFF",
background: question.expanded
? "#EEE4FC"
: theme.palette.brightPurple.main,
}}
>
{question.page + 1}
</Box>
}
<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 === null ? ( {IconAndrom(question.expanded, question.type)}
<TypeQuestions question={question} /> </InputAdornment>
) : ( <ChooseAnswerModal
<SwitchQuestionsPage question={question} /> open={open}
)} onClose={() => setOpen(false)}
</Box> anchorRef={anchorRef}
)} question={question}
</Paper> questionType={question.type}
<Box
onMouseEnter={() => setPlusVisible(true)}
onMouseLeave={() => setPlusVisible(false)}
sx={{
maxWidth: "825px",
display: "flex",
alignItems: "center",
height: "40px",
cursor: "pointer",
}}
>
<Box
onClick={() => createUntypedQuestion(question.quizId, question.id)}
sx={{
display: plusVisible && !isDragging ? "flex" : "none",
width: "100%",
alignItems: "center",
columnGap: "10px",
}}
data-cy="create-question"
>
<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> ),
</Box> endAdornment: isTextFieldtActive && question.title.length >= maxLengthTextField - 7 && (
</> <Box
); sx={{
display: "flex",
marginTop: "5px",
marginLeft: "auto",
position: "absolute",
bottom: "-28px",
right: "0",
}}
>
<Typography fontSize="14px">{question.title.length}</Typography>
<span>/</span>
<Typography fontSize="14px">{maxLengthTextField}</Typography>
</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,
},
},
}}
/>
</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={() => toggleExpandQuestion(question.id)}
>
{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(question.id, question.quizId)}>
<CopyIcon style={{ color: theme.palette.brightPurple.main }} />
</IconButton>
<IconButton
sx={{
cursor: "pointer",
borderRadius: "6px",
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
if (question.content.rule.parentId.length !== 0) {
setOpenDelete(true);
} else {
deleteQuestionWithTimeout(question.id, deleteFn);
}
}}
data-cy="delete-question"
>
<DeleteIcon style={{ color: theme.palette.brightPurple.main }} />
</IconButton>
<Modal open={openDelete} onClose={() => setOpenDelete(false)}>
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
padding: "30px",
borderRadius: "10px",
background: "#FFFFFF",
}}
>
<Typography variant="h6" sx={{ textAlign: "center" }}>
Вы удаляете вопрос, участвующий в ветвлении. Все его потомки потеряют данные ветвления. Вы
уверены, что хотите удалить вопрос?
</Typography>
<Box
sx={{
marginTop: "30px",
display: "flex",
justifyContent: "center",
gap: "15px",
}}
>
<Button variant="contained" sx={{ minWidth: "150px" }} onClick={() => setOpenDelete(false)}>
Отмена
</Button>
<Button
variant="contained"
sx={{ minWidth: "150px" }}
onClick={() => {
deleteQuestionWithTimeout(question.id, deleteFn);
}}
>
Подтвердить
</Button>
</Box>
</Box>
</Modal>
</Box>
)}
{question.type !== null && (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "30px",
width: "30px",
marginLeft: "3px",
borderRadius: "50%",
fontSize: "16px",
color: question.expanded ? theme.palette.brightPurple.main : "#FFF",
background: question.expanded ? "#EEE4FC" : theme.palette.brightPurple.main,
}}
>
{question.page + 1}
</Box>
)}
<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 === null ? (
<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={() => createUntypedQuestion(question.quizId, question.id)}
sx={{
display: plusVisible && !isDragging ? "flex" : "none",
width: "100%",
alignItems: "center",
columnGap: "10px",
}}
data-cy="create-question"
>
<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, questionType: QuestionType | null) => { const IconAndrom = (isExpanded: boolean, questionType: QuestionType | null) => {

@ -4,22 +4,23 @@ import { AnswerDraggableList } from "../AnswerDraggableList";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import SwitchDropDown from "./switchDropDown"; import SwitchDropDown from "./switchDropDown";
import ButtonsOptions from "../ButtonsOptions"; import ButtonsOptions from "../ButtonsOptions";
import type { QuizQuestionSelect } from "../../../model/questionTypes/select";
import { addQuestionVariant } from "@root/questions/actions"; import { addQuestionVariant } from "@root/questions/actions";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer";
import type { QuizQuestionSelect } from "../../../model/questionTypes/select";
interface Props { interface Props {
question: QuizQuestionSelect; question: QuizQuestionSelect;
} }
export default function DropDown({ question }: Props) { export default function DropDown({ question }: Props) {
const onClickAddAnAnswer = useAddAnswer();
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
}; };
return ( return (
<> <>
@ -60,7 +61,7 @@ export default function DropDown({ question }: Props) {
mr: "4px", mr: "4px",
height: "19px", height: "19px",
}} }}
onClick={() => addQuestionVariant(question.id)} onClick={() => onClickAddAnAnswer(question)}
> >
Добавьте ответ Добавьте ответ
</Link> </Link>
@ -87,11 +88,7 @@ export default function DropDown({ question }: Props) {
)} )}
</Box> </Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions switchState={switchState} SSHC={SSHC} question={question} />
switchState={switchState}
SSHC={SSHC}
question={question}
/>
<SwitchDropDown switchState={switchState} question={question} /> <SwitchDropDown switchState={switchState} question={question} />
</> </>
); );

@ -1,14 +1,7 @@
import { EmojiIcons } from "@icons/EmojiIocns"; import { EmojiIcons } from "@icons/EmojiIocns";
import AddEmoji from "@icons/questionsPage/addEmoji"; import AddEmoji from "@icons/questionsPage/addEmoji";
import PlusImage from "@icons/questionsPage/plus"; import PlusImage from "@icons/questionsPage/plus";
import { import { Box, Link, Popover, Typography, useMediaQuery, useTheme } from "@mui/material";
Box,
Link,
Popover,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
import { addQuestionVariant, updateQuestion } from "@root/questions/actions"; import { addQuestionVariant, updateQuestion } from "@root/questions/actions";
import { EmojiPicker } from "@ui_kit/EmojiPicker"; import { EmojiPicker } from "@ui_kit/EmojiPicker";
import { useState } from "react"; import { useState } from "react";
@ -17,225 +10,220 @@ import type { QuizQuestionEmoji } from "../../../model/questionTypes/emoji";
import { AnswerDraggableList } from "../AnswerDraggableList"; import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../ButtonsOptions"; import ButtonsOptions from "../ButtonsOptions";
import SwitchEmoji from "./switchEmoji"; import SwitchEmoji from "./switchEmoji";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer";
interface Props { interface Props {
question: QuizQuestionEmoji; question: QuizQuestionEmoji;
} }
export default function Emoji({ question }: Props) { export default function Emoji({ question }: Props) {
const [switchState, setSwitchState] = useState<string>("setting"); const [switchState, setSwitchState] = useState<string>("setting");
const [open, setOpen] = useState<boolean>(false); const onClickAddAnAnswer = useAddAnswer();
const [anchorElement, setAnchorElement] = useState<HTMLDivElement | null>( const [open, setOpen] = useState<boolean>(false);
null const [anchorElement, setAnchorElement] = useState<HTMLDivElement | null>(null);
); const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
const [selectedVariant, setSelectedVariant] = useState<string | null>(null); const theme = useTheme();
const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
}; };
return ( return (
<> <>
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
<AnswerDraggableList <AnswerDraggableList
question={question} question={question}
additionalContent={(variant) => ( additionalContent={(variant) => (
<> <>
{!isTablet && ( {!isTablet && (
<Box sx={{ cursor: "pointer" }}> <Box sx={{ cursor: "pointer" }}>
<Box <Box
data-cy="choose-emoji-button" data-cy="choose-emoji-button"
onClick={({ currentTarget }) => { onClick={({ currentTarget }) => {
setAnchorElement(currentTarget); setAnchorElement(currentTarget);
setSelectedVariant(variant.id); setSelectedVariant(variant.id);
setOpen(true); setOpen(true);
}}
>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "5px",
}}
>
{variant.extendedText ? (
<Box
sx={{
height: "40px",
width: "60px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#EEE4FC",
borderRadius: "3px",
}}
>
<Box
sx={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
{variant.extendedText}
</Box>
<Box>
<PlusImage />
</Box>
</Box>
) : (
<AddEmoji />
)}
</Box>
</Box>
</Box>
)}
</>
)}
additionalMobile={(variant) => (
<>
{isTablet && (
<Box
onClick={({ currentTarget }) => {
setAnchorElement(currentTarget);
setSelectedVariant(variant.id);
setOpen(true);
}}
sx={{
display: "flex",
alignItems: "center",
m: "8px",
position: "relative",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
}}
/>
{variant.extendedText ? (
<Box
sx={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
>
{variant.extendedText}
</Box>
) : (
<EmojiIcons
style={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
/>
)}
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
+
</Box>
</Box>
)}
</>
)}
/>
<Popover
open={open}
anchorEl={anchorElement}
onClick={(event) => event.stopPropagation()}
onClose={() => setOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}} }}
sx={{ >
".MuiPaper-root.MuiPaper-rounded": { <Box
borderRadius: "10px", sx={{
},
}}
>
<EmojiPicker
onEmojiSelect={({ native }) => {
setOpen(false);
updateQuestion(question.id, question => {
if (question.type !== "emoji") return;
const variant = question.content.variants.find(v => v.id === selectedVariant);
if (!variant) return;
variant.extendedText = native;
});
}}
/>
</Popover>
<Box
sx={{
display: "flex", display: "flex",
justifyContent: "center",
alignItems: "center", alignItems: "center",
gap: "10px", gap: "5px",
marginBottom: isMobile ? "17px" : "20px", }}
}}
>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => addQuestionVariant(question.id)}
> >
Добавьте ответ {variant.extendedText ? (
</Link> <Box
{!isTablet && ( sx={{
<> height: "40px",
<Typography width: "60px",
sx={{ display: "flex",
fontWeight: 400, alignItems: "center",
lineHeight: "21.33px", justifyContent: "space-between",
color: theme.palette.grey2.main, background: "#EEE4FC",
fontSize: "16px", borderRadius: "3px",
}} }}
> >
или нажмите Enter <Box
</Typography> sx={{
<EnterIcon width: "100%",
style={{ display: "flex",
color: "#7E2AEA", justifyContent: "center",
fontSize: "24px", }}
marginLeft: "6px", >
}} {variant.extendedText}
/> </Box>
</> <Box>
)} <PlusImage />
</Box>
</Box>
) : (
<AddEmoji />
)}
</Box>
</Box>
</Box> </Box>
</Box> )}
<ButtonsOptions </>
switchState={switchState} )}
SSHC={SSHC} additionalMobile={(variant) => (
question={question} <>
/> {isTablet && (
<SwitchEmoji switchState={switchState} question={question} /> <Box
</> onClick={({ currentTarget }) => {
); setAnchorElement(currentTarget);
setSelectedVariant(variant.id);
setOpen(true);
}}
sx={{
display: "flex",
alignItems: "center",
m: "8px",
position: "relative",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
}}
/>
{variant.extendedText ? (
<Box
sx={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
>
{variant.extendedText}
</Box>
) : (
<EmojiIcons
style={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
/>
)}
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
+
</Box>
</Box>
)}
</>
)}
/>
<Popover
open={open}
anchorEl={anchorElement}
onClick={(event) => event.stopPropagation()}
onClose={() => setOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
sx={{
".MuiPaper-root.MuiPaper-rounded": {
borderRadius: "10px",
},
}}
>
<EmojiPicker
onEmojiSelect={({ native }) => {
setOpen(false);
updateQuestion(question.id, (question) => {
if (question.type !== "emoji") return;
const variant = question.content.variants.find((v) => v.id === selectedVariant);
if (!variant) return;
variant.extendedText = native;
});
}}
/>
</Popover>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: isMobile ? "17px" : "20px",
}}
>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</Link>
{!isTablet && (
<>
<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} question={question} />
<SwitchEmoji switchState={switchState} question={question} />
</>
);
} }

@ -11,8 +11,23 @@ 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 Slider from "@icons/questionsPage/slider"; import Slider from "@icons/questionsPage/slider";
import { Box, FormControlLabel, IconButton, InputAdornment, Paper, useMediaQuery, useTheme } from "@mui/material"; import {
import { toggleExpandQuestion, updateQuestion, updateUntypedQuestion } from "@root/questions/actions"; Box, Checkbox,
FormControl,
FormControlLabel,
IconButton,
InputAdornment,
Paper, TextField,
useMediaQuery,
useTheme
} from "@mui/material";
import {
copyQuestion,
deleteQuestion, deleteQuestionWithTimeout,
toggleExpandQuestion,
updateQuestion,
updateUntypedQuestion
} from "@root/questions/actions";
import CustomTextField from "@ui_kit/CustomTextField"; import CustomTextField from "@ui_kit/CustomTextField";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd"; import type { DraggableProvidedDragHandleProps } from "react-beautiful-dnd";
@ -52,150 +67,246 @@ export default function QuestionsPageCard({ question, questionIndex, draggablePr
}, 200); }, 200);
console.log(question) console.log(question)
return ( return (
<> <>
<Paper <Paper
sx={{ sx={{
overflow: "hidden", overflow: "hidden",
maxWidth: "796px", maxWidth: "796px",
width: "100%", width: "100%",
backgroundColor: "white", backgroundColor: question.expanded ? "white" : "#EEE4FC",
border: "none", border: question.expanded ? "none" : "1px solid #9A9AAF",
boxShadow: "none", boxShadow: "none",
paddingBottom: "20px", paddingBottom: "20px",
borderRadius: "0", borderRadius: "0",
borderTopLeftRadius: "12px", borderTopLeftRadius: "12px",
borderTopRightRadius: "12px", borderTopRightRadius: "12px",
}} }}
>
<Box
sx={{
display: "flex",
p: 0,
flexDirection: "column",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
margin: "20px",
gap: "18px",
flexDirection: isMobile ? "column-reverse" : null,
}}
>
<CustomTextField
placeholder={`Заголовок ${questionIndex + 1} вопроса`}
value={question.title}
onChange={({ target }) => setTitle(target.value)}
sx={{ width: "100%" }}
InputProps={{
startAdornment: (
<Box>
<InputAdornment
ref={anchorRef}
position="start"
sx={{ cursor: "pointer" }}
onClick={() => setOpen((isOpened) => !isOpened)}
>
{IconAndrom(question.type)}
</InputAdornment>
<ChooseAnswerModal
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
question={question}
questionType={question.type}
/>
</Box>
),
}}
/>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: isMobile ? "100%" : "auto",
position: "relative",
}}
> >
<Box
sx={{
flexDirection: isMobile ? "row-reverse" : null,
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<IconButton
sx={{ padding: "0", margin: "5px" }}
disableRipple
data-cy="expand-question"
onClick={() => toggleExpandQuestion(question.id)}
>
{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>
<Box <Box
style={{ sx={{
display: "flex", display: "flex",
alignItems: "center", p: 0,
justifyContent: "center", flexDirection: "column",
height: "30px", }}
width: "30px",
marginLeft: "3px",
borderRadius: "50%",
fontSize: "16px",
color: question.expanded ? theme.palette.brightPurple.main : "#FFF",
background: question.expanded ? "#EEE4FC" : theme.palette.brightPurple.main,
}}
> >
{questionIndex + 1} <Box
sx={{
display: "flex",
alignItems: "center",
margin: "20px",
gap: "18px",
flexDirection: isMobile ? "column-reverse" : null,
}}
>
<FormControl
variant="standard"
sx={{
p: 0,
maxWidth: isTablet ? "549px" : "640px",
width: "100%",
marginRight: isMobile ? "0px" : "16.1px",
}}
>
<TextField
placeholder={`Заголовок ${questionIndex + 1} вопроса`}
value={question.title}
onChange={({target}) => setTitle(target.value)}
sx={{
width: "100%",
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={{
startAdornment: (
<Box>
<InputAdornment
ref={anchorRef}
position="start"
sx={{cursor: "pointer"}}
onClick={() => setOpen((isOpened) => !isOpened)}
>
{IconAndrom(question.type)}
</InputAdornment>
<ChooseAnswerModal
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
question={question}
questionType={question.type}
/>
</Box>
),
}}
/>
</FormControl>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: isMobile ? "100%" : "auto",
position: "relative",
}}
>
<Box
sx={{
flexDirection: isMobile ? "row-reverse" : null,
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
<IconButton
sx={{padding: "0", margin: "5px"}}
disableRipple
data-cy="expand-question"
onClick={() => toggleExpandQuestion(question.id)}
>
{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(question.id, question.quizId)}
>
<CopyIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
<IconButton
sx={{
cursor: "pointer",
borderRadius: "6px",
padding: "0",
margin: "0 5px 0 10px",
}}
onClick={() => {
deleteQuestionWithTimeout(question.id, deleteQuestion(question.id));
}}
>
<DeleteIcon
style={{ color: theme.palette.brightPurple.main }}
/>
</IconButton>
</Box>
)}
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "30px",
width: "30px",
marginLeft: "3px",
borderRadius: "50%",
fontSize: "16px",
color: question.expanded ? theme.palette.brightPurple.main : "#FFF",
background: question.expanded ? "#EEE4FC" : theme.palette.brightPurple.main,
}}
>
{questionIndex + 1}
</Box>
</Box>
<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 && (
<>
{question.type === null ? (
<FormTypeQuestions question={question}/>
) : (
<SwitchQuestionsPage question={question}/>
)}
</>
)}
</Box> </Box>
</Box> </Paper>
</>
<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.type === null ? (
<FormTypeQuestions question={question} />
) : (
<SwitchQuestionsPage question={question} />
)}
</Box>
</Paper>
</>
);
} }
const IconAndrom = (questionType: QuestionType | null) => { const IconAndrom = (questionType: QuestionType | null) => {
@ -225,4 +336,4 @@ const IconAndrom = (questionType: QuestionType | null) => {
default: default:
return <AnswerGroup color="#9A9AAF" sx={{ height: "22px", width: "20px" }} />; return <AnswerGroup color="#9A9AAF" sx={{ height: "22px", width: "20px" }} />;
} }
}; };

@ -1,10 +1,4 @@
import { import { Box, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
Box,
Link,
Typography,
useMediaQuery,
useTheme
} from "@mui/material";
import { addQuestionVariant, uploadQuestionImage } from "@root/questions/actions"; import { addQuestionVariant, uploadQuestionImage } from "@root/questions/actions";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton"; import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal"; import { CropModal, useCropModalState } from "@ui_kit/Modal/CropModal";
@ -17,165 +11,153 @@ import { UploadImageModal } from "../UploadImage/UploadImageModal";
import SwitchAnswerOptionsPict from "./switchOptionsPict"; import SwitchAnswerOptionsPict from "./switchOptionsPict";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { useDisclosure } from "../../../utils/useDisclosure"; import { useDisclosure } from "../../../utils/useDisclosure";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer";
interface Props { interface Props {
question: QuizQuestionImages; question: QuizQuestionImages;
} }
export default function OptionsPicture({ question }: Props) { export default function OptionsPicture({ question }: Props) {
const theme = useTheme(); const theme = useTheme();
const quizQid = useCurrentQuiz()?.qid; const onClickAddAnAnswer = useAddAnswer();
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null); const quizQid = useCurrentQuiz()?.qid;
const [switchState, setSwitchState] = useState("setting"); const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const [switchState, setSwitchState] = useState("setting");
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure(); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const { const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
isCropModalOpen, const { isCropModalOpen, openCropModal, closeCropModal, imageBlob, originalImageUrl, setCropModalImageBlob } =
openCropModal, useCropModalState();
closeCropModal,
imageBlob,
originalImageUrl,
setCropModalImageBlob,
} = useCropModalState();
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
}; };
const handleImageUpload = async (file: File) => { const handleImageUpload = async (file: File) => {
if (!selectedVariantId) return; if (!selectedVariantId) return;
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => { const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
if (!("variants" in question.content)) return; if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId); const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
if (!variant) return; if (!variant) return;
variant.extendedText = url; variant.extendedText = url;
variant.originalImageUrl = url; variant.originalImageUrl = url;
}); });
closeImageUploadModal(); closeImageUploadModal();
openCropModal(file, url); openCropModal(file, url);
}; };
function handleCropModalSaveClick(imageBlob: Blob) { function handleCropModalSaveClick(imageBlob: Blob) {
if (!selectedVariantId) return; if (!selectedVariantId) return;
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => { uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (!("variants" in question.content)) return; if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId); const variant = question.content.variants.find((variant) => variant.id === selectedVariantId);
if (!variant) return; if (!variant) return;
variant.extendedText = url; variant.extendedText = url;
}); });
} }
return ( return (
<> <>
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
<AnswerDraggableList <AnswerDraggableList
question={question} question={question}
additionalContent={(variant) => ( additionalContent={(variant) => (
<> <>
{!isMobile && ( {!isMobile && (
<AddOrEditImageButton <AddOrEditImageButton
imageSrc={variant.extendedText} imageSrc={variant.extendedText}
onImageClick={() => { onImageClick={() => {
setSelectedVariantId(variant.id); setSelectedVariantId(variant.id);
if (variant.extendedText) { if (variant.extendedText) {
return openCropModal( return openCropModal(variant.extendedText, variant.originalImageUrl);
variant.extendedText, }
variant.originalImageUrl
);
}
openImageUploadModal(); openImageUploadModal();
}} }}
onPlusClick={() => { onPlusClick={() => {
setSelectedVariantId(variant.id); setSelectedVariantId(variant.id);
openImageUploadModal(); openImageUploadModal();
}} }}
sx={{ mx: "10px" }} sx={{ mx: "10px" }}
/>
)}
</>
)}
additionalMobile={(variant) => (
<>
{isMobile && (
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
setSelectedVariantId(variant.id);
if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
openImageUploadModal();
}}
onPlusClick={() => {
setSelectedVariantId(variant.id);
openImageUploadModal();
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/> />
<UploadImageModal )}
isOpen={isImageUploadOpen} </>
onClose={closeImageUploadModal} )}
handleImageChange={handleImageUpload} additionalMobile={(variant) => (
/> <>
<CropModal {isMobile && (
isOpen={isCropModalOpen} <AddOrEditImageButton
imageBlob={imageBlob} imageSrc={variant.extendedText}
originalImageUrl={originalImageUrl} onImageClick={() => {
setCropModalImageBlob={setCropModalImageBlob} setSelectedVariantId(variant.id);
onClose={closeCropModal} if (variant.extendedText) {
onSaveImageClick={handleCropModalSaveClick} return openCropModal(variant.extendedText, variant.originalImageUrl);
/> }
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
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>
<ButtonsOptions switchState={switchState} SSHC={SSHC} question={question} />
<SwitchAnswerOptionsPict switchState={switchState} question={question} />
</>
); openImageUploadModal();
}}
onPlusClick={() => {
setSelectedVariantId(variant.id);
openImageUploadModal();
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/>
<UploadImageModal
isOpen={isImageUploadOpen}
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
/>
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"
variant="body2"
sx={{ color: theme.palette.brightPurple.main }}
onClick={() => onClickAddAnAnswer(question)}
>
Добавьте ответ
</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} question={question} />
<SwitchAnswerOptionsPict switchState={switchState} question={question} />
</>
);
} }

@ -1,5 +1,5 @@
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon"; import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Button, Typography, useMediaQuery, useTheme } from "@mui/material";
import { updateQuestion, uploadQuestionImage } from "@root/questions/actions"; import { updateQuestion, uploadQuestionImage } from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton"; import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
@ -14,253 +14,252 @@ import { UploadVideoModal } from "../UploadVideoModal";
import SwitchPageOptions from "./switchPageOptions"; import SwitchPageOptions from "./switchPageOptions";
import { useDisclosure } from "../../../utils/useDisclosure"; import { useDisclosure } from "../../../utils/useDisclosure";
type Props = { type Props = {
disableInput?: boolean; disableInput?: boolean;
question: QuizQuestionPage; question: QuizQuestionPage;
}; };
export default function PageOptions({ disableInput, question }: Props) { export default function PageOptions({ disableInput, question }: Props) {
const [openVideoModal, setOpenVideoModal] = useState<boolean>(false); const [openVideoModal, setOpenVideoModal] = useState<boolean>(false);
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const theme = useTheme(); const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(980)); const isTablet = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablet = useMediaQuery(theme.breakpoints.down(990)); const isFigmaTablet = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(780)); const isMobile = useMediaQuery(theme.breakpoints.down(780));
const quizQid = useCurrentQuiz()?.qid; const quizQid = useCurrentQuiz()?.qid;
const { const { isCropModalOpen, openCropModal, closeCropModal, imageBlob, originalImageUrl, setCropModalImageBlob } =
isCropModalOpen, useCropModalState();
openCropModal, const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
closeCropModal,
imageBlob,
originalImageUrl,
setCropModalImageBlob,
} = useCropModalState();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const setText = useDebouncedCallback((value) => { const setText = useDebouncedCallback((value) => {
updateQuestion(question.id, question => { updateQuestion(question.id, (question) => {
if (question.type !== "page") return; if (question.type !== "page") return;
question.content.text = value; question.content.text = value;
}); });
}, 200); }, 200);
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
}; };
async function handleImageUpload(file: File) { async function handleImageUpload(file: File) {
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => { const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
if (question.type !== "page") return; if (question.type !== "page") return;
question.content.picture = url; question.content.picture = url;
question.content.originalPicture = url; question.content.originalPicture = url;
}); });
closeImageUploadModal(); closeImageUploadModal();
openCropModal(file, url); openCropModal(file, url);
} }
function handleCropModalSaveClick(imageBlob: Blob) { function handleCropModalSaveClick(imageBlob: Blob) {
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => { uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (question.type !== "page") return; if (question.type !== "page") return;
question.content.picture = url; question.content.picture = url;
}); });
} }
return ( console.log(question.content.useImage);
<>
<Box return (
sx={{ <>
width: isTablet ? "auto" : "100%", <Box
maxWidth: isFigmaTablet ? "549px" : "640px", sx={{
display: "flex", width: isTablet ? "auto" : "100%",
px: "20px", maxWidth: isFigmaTablet ? "549px" : "640px",
flexDirection: "column", display: "flex",
gap: isMobile ? "25px" : "20px", px: "20px",
}} flexDirection: "column",
gap: isMobile ? "25px" : "20px",
}}
>
<Box sx={{ display: disableInput ? "none" : "", mt: isMobile ? "15px" : "0px" }}>
<CustomTextField
placeholder={"Можно добавить текст"}
text={question.content.text}
onChange={({ target }) => setText(target.value)}
/>
</Box>
<Box
sx={{
mb: "20px",
ml: isTablet ? "0px" : "60px",
display: "flex",
alignItems: "center",
gap: "28px",
justifyContent: isMobile ? "space-between" : null,
}}
>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "20px",
}}
>
<AddOrEditImageButton
imageSrc={question.content.picture}
onImageClick={() => {
if (question.content.picture) {
return openCropModal(question.content.picture, question.content.originalPicture);
}
openImageUploadModal();
}}
onPlusClick={() => {
openImageUploadModal();
}}
/>
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: question.content.useImage ? "#7E2AEA" : "#9A9AAF",
}}
onClick={() =>
updateQuestion(question.id, (question) => ((question as QuizQuestionPage).content.useImage = true))
}
> >
<Box sx={{ display: disableInput ? "none" : "", mt: isMobile ? "15px" : "0px" }}> Изображение
<CustomTextField </Typography>
placeholder={"Можно добавить текст"} </Box>
text={question.content.text} <UploadImageModal
onChange={({ target }) => setText(target.value)} isOpen={isImageUploadOpen}
/> onClose={closeImageUploadModal}
</Box> handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
/>
<Typography> или</Typography>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile ? (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "120px",
position: "relative",
}}
>
<Box <Box
sx={{ sx={{
mb: "20px", width: "100%",
ml: isTablet ? "0px" : "60px", background: "#EEE4FC",
display: "flex", height: "40px",
alignItems: "center", display: "flex",
gap: "28px", alignItems: "center",
justifyContent: isMobile ? "space-between" : null, justifyContent: "center",
}} borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
> >
<Box <VideofileIcon
sx={{ style={{
cursor: "pointer", color: "#7E2AEA",
display: "flex", fontSize: "20px",
alignItems: "center", }}
gap: "20px", />
}}
>
<AddOrEditImageButton
imageSrc={question.content.picture}
onImageClick={() => {
if (question.content.picture) {
return openCropModal(
question.content.picture,
question.content.originalPicture
);
}
openImageUploadModal();
}}
onPlusClick={() => {
openImageUploadModal();
}}
/>
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Изображение
</Typography>
</Box>
<UploadImageModal
isOpen={isImageUploadOpen}
onClose={closeImageUploadModal}
handleImageChange={handleImageUpload}
/>
<CropModal
isOpen={isCropModalOpen}
imageBlob={imageBlob}
originalImageUrl={originalImageUrl}
setCropModalImageBlob={setCropModalImageBlob}
onClose={closeCropModal}
onSaveImageClick={handleCropModalSaveClick}
/>
<Typography> или</Typography>
<Box
sx={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
{isMobile ? (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "120px",
position: "relative",
}}
>
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
>
<VideofileIcon
style={{
color: "#7E2AEA",
fontSize: "20px",
}}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<VideofileIcon fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: theme.palette.grey2.main,
}}
>
Видео
</Typography>
</Box>
<UploadVideoModal
open={openVideoModal}
onClose={() => setOpenVideoModal(false)}
video={question.content.video}
onUpload={(url) => {
updateQuestion(question.id, question => {
if (question.type !== "page") return;
question.content.video = url;
});
}}
/>
</Box> </Box>
</Box> <Box
<ButtonsOptions switchState={switchState} SSHC={SSHC} question={question} /> sx={{
<SwitchPageOptions switchState={switchState} question={question} /> display: "flex",
</> justifyContent: "center",
); alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<VideofileIcon fontSize="22px" color="#7E2AEA" />
</Box>
<span
onClick={() => setOpenVideoModal(true)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography
sx={{
display: isMobile ? "none" : "block",
fontWeight: 400,
fontSize: "16px",
lineHeight: "18.96px",
color: question.content.useImage ? "#9A9AAF" : "#7E2AEA",
}}
onClick={() =>
updateQuestion(question.id, (question) => ((question as QuizQuestionPage).content.useImage = false))
}
>
Видео
</Typography>
</Box>
<UploadVideoModal
open={openVideoModal}
onClose={() => setOpenVideoModal(false)}
video={question.content.video}
onUpload={(url) => {
updateQuestion(question.id, (question) => {
if (question.type !== "page") return;
question.content.video = url;
});
}}
/>
</Box>
</Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} question={question} />
<SwitchPageOptions switchState={switchState} question={question} />
</>
);
} }

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import ButtonsOptions from "../ButtonsOptions"; import ButtonsOptions from "../ButtonsOptions";
import CustomNumberField from "@ui_kit/CustomNumberField"; import CustomNumberField from "@ui_kit/CustomNumberField";
@ -16,6 +16,38 @@ export default function SliderOptions({ question }: Props) {
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const [stepError, setStepError] = useState(""); const [stepError, setStepError] = useState("");
const [startError, setStartError] = useState<boolean>(false);
const [minError, setMinError] = useState<boolean>(false);
const [maxError, setMaxError] = useState<boolean>(false);
useEffect(() => {
const min = Number(question.content.range.split("—")[0]);
const max = Number(question.content.range.split("—")[1]);
const start = Number(question.content.start);
if (start < min || start > max) {
setStartError(true);
}
if (start >= min && start <= max) {
setStartError(false);
}
}, [question.content.range, question.content.start]);
useEffect(() => {
const min = Number(question.content.range.split("—")[0]);
const max = Number(question.content.range.split("—")[1]);
const step = Number(question.content.step);
const range = max - min;
if (range % step) {
setStepError(
`Шаг должен делить без остатка диапазон ${max} - ${min} = ${max - min}`
);
} else {
setStepError("");
}
}, [question]);
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
@ -44,42 +76,43 @@ export default function SliderOptions({ question }: Props) {
marginRight: isMobile ? "10px" : "0px", marginRight: isMobile ? "10px" : "0px",
}} }}
> >
<Typography sx={{ fontWeight: "500", fontSize: "18px", color: "#4D4D4D" }}> <Typography
sx={{ fontWeight: "500", fontSize: "18px", color: "#4D4D4D" }}
>
Выбор значения из диапазона Выбор значения из диапазона
</Typography> </Typography>
<Box sx={{ width: "100%", display: "flex", alignItems: "center", gap: isMobile ? "9px" : "20px" }}> <Box
sx={{
width: "100%",
display: "flex",
alignItems: "center",
gap: isMobile ? "9px" : "20px",
}}
>
<CustomNumberField <CustomNumberField
sx={{ maxWidth: "310px", width: "100%" }} sx={{ maxWidth: "310px", width: "100%" }}
placeholder={"0"} placeholder={"0"}
min={0} min={0}
max={99999999999} max={99999999999}
value={question.content.range.split("—")[0]} value={question.content.range.split("—")[0]}
emptyError={minError}
onChange={({ target }) => { onChange={({ target }) => {
updateQuestion(question.id, (question) => {
if (question.type !== "number") return;
question.content.range = `${target.value}${question.content.range.split("—")[1]}`;
});
}}
onBlur={({ target }) => {
const start = question.content.start;
const min = Number(target.value); const min = Number(target.value);
const max = Number(question.content.range.split("—")[1]); const max = Number(question.content.range.split("—")[1]);
updateQuestion(question.id, (question) => {
if (question.type !== "number") return;
question.content.range = `${target.value}${
question.content.range.split("—")[1]
}`;
});
if (min >= max) { if (min >= max) {
updateQuestion(question.id, (question) => { setMinError(true);
if (question.type !== "number") return; } else {
setMinError(false);
question.content.range = `${max - 1 >= 0 ? max - 1 : 0}${question.content.range.split("—")[1]}`; setMaxError(false);
});
}
if (start < min) {
updateQuestion(question.id, (question) => {
if (question.type !== "number") return;
question.content.start = min;
});
} }
}} }}
/> />
@ -90,35 +123,30 @@ export default function SliderOptions({ question }: Props) {
min={0} min={0}
max={100000000000} max={100000000000}
value={question.content.range.split("—")[1]} value={question.content.range.split("—")[1]}
emptyError={maxError}
onChange={({ target }) => { onChange={({ target }) => {
const min = Number(question.content.range.split("—")[0]);
const max = Number(target.value);
updateQuestion(question.id, (question) => { updateQuestion(question.id, (question) => {
if (question.type !== "number") return; if (question.type !== "number") return;
question.content.range = `${question.content.range.split("—")[0]}${target.value}`; question.content.range = `${
question.content.range.split("—")[0]
}${target.value}`;
}); });
if (max <= min) {
setMaxError(true);
} else {
setMaxError(false);
setMinError(false);
}
}} }}
onBlur={({ target }) => { onBlur={({ target }) => {
const start = question.content.start;
const step = question.content.step; const step = question.content.step;
const min = Number(question.content.range.split("—")[0]); const min = Number(question.content.range.split("—")[0]);
const max = Number(target.value); const max = Number(target.value);
const range = max - min;
if (max <= min) {
updateQuestion(question.id, (question) => {
if (question.type !== "number") return;
question.content.range = `${min}${min + 1 >= 100 ? 100 : min + 1}`;
});
}
if (start > max) {
updateQuestion(question.id, (question) => {
if (question.type !== "number") return;
question.content.start = max;
});
}
if (step > max) { if (step > max) {
updateQuestion(question.id, (question) => { updateQuestion(question.id, (question) => {
@ -126,12 +154,6 @@ export default function SliderOptions({ question }: Props) {
question.content.step = min; question.content.step = min;
}); });
if (range % step) {
setStepError(`Шаг должен делить без остатка диапазон ${max} - ${min} = ${max - min}`);
} else {
setStepError("");
}
} }
}} }}
/> />
@ -147,15 +169,21 @@ export default function SliderOptions({ question }: Props) {
}} }}
> >
<Box sx={{ width: "100%" }}> <Box sx={{ width: "100%" }}>
<Typography sx={{ fontWeight: "500", fontSize: "18px", color: "#4D4D4D", mb: isMobile ? "10px" : "14px" }}> <Typography
sx={{
fontWeight: "500",
fontSize: "18px",
color: "#4D4D4D",
mb: isMobile ? "10px" : "14px",
}}
>
Начальное значение Начальное значение
</Typography> </Typography>
<CustomNumberField <CustomNumberField
sx={{ maxWidth: "310px", width: "100%" }} sx={{ maxWidth: "310px", width: "100%" }}
placeholder={"50"} placeholder={"50"}
min={Number(question.content.range.split("—")[0])}
max={Number(question.content.range.split("—")[1])}
value={String(question.content.start)} value={String(question.content.start)}
emptyError={startError}
onChange={({ target }) => { onChange={({ target }) => {
updateQuestion(question.id, (question) => { updateQuestion(question.id, (question) => {
if (question.type !== "number") return; if (question.type !== "number") return;
@ -178,8 +206,8 @@ export default function SliderOptions({ question }: Props) {
</Typography> </Typography>
<CustomNumberField <CustomNumberField
sx={{ maxWidth: "310px", width: "100%" }} sx={{ maxWidth: "310px", width: "100%" }}
min={0} min={Number(question.content.range.split("—")[0])}
max={100} max={Number(question.content.range.split("—")[1])}
placeholder={"1"} placeholder={"1"}
error={stepError} error={stepError}
value={String(question.content.step)} value={String(question.content.step)}
@ -191,9 +219,7 @@ export default function SliderOptions({ question }: Props) {
}); });
}} }}
onBlur={({ target }) => { onBlur={({ target }) => {
const min = Number(question.content.range.split("—")[0]);
const max = Number(question.content.range.split("—")[1]); const max = Number(question.content.range.split("—")[1]);
const range = max - min;
const step = Number(target.value); const step = Number(target.value);
if (step > max) { if (step > max) {
@ -203,18 +229,16 @@ export default function SliderOptions({ question }: Props) {
question.content.step = max; question.content.step = max;
}); });
} }
if (range % step) {
setStepError(`Шаг должен делить без остатка диапазон ${max} - ${min} = ${max - min}`);
} else {
setStepError("");
}
}} }}
/> />
</Box> </Box>
</Box> </Box>
</Box> </Box>
<ButtonsOptions switchState={switchState} SSHC={SSHC} question={question} /> <ButtonsOptions
switchState={switchState}
SSHC={SSHC}
question={question}
/>
<SwitchSlider switchState={switchState} question={question} /> <SwitchSlider switchState={switchState} question={question} />
</> </>
); );

@ -1,11 +1,4 @@
import { import { Box, Button, ButtonBase, Modal, Typography, useTheme } from "@mui/material";
Box,
Button,
ButtonBase,
Modal,
Typography,
useTheme,
} from "@mui/material";
import SelectableButton from "@ui_kit/SelectableButton"; import SelectableButton from "@ui_kit/SelectableButton";
import CustomTextField from "@ui_kit/CustomTextField"; import CustomTextField from "@ui_kit/CustomTextField";
import { useState } from "react"; import { useState } from "react";
@ -22,14 +15,8 @@ type HelpQuestionsProps = {
onUpload: (number: string) => void; onUpload: (number: string) => void;
}; };
export const UploadVideoModal = ({ export const UploadVideoModal = ({ open, onClose, video, onUpload }: HelpQuestionsProps) => {
open, const [backgroundTypeModal, setBackgroundTypeModal] = useState<BackgroundTypeModal>("linkVideo");
onClose,
video,
onUpload,
}: HelpQuestionsProps) => {
const [backgroundTypeModal, setBackgroundTypeModal] =
useState<BackgroundTypeModal>("linkVideo");
const theme = useTheme(); const theme = useTheme();
const handleDrop = (event: DragEvent<HTMLDivElement>) => { const handleDrop = (event: DragEvent<HTMLDivElement>) => {
@ -42,12 +29,7 @@ export const UploadVideoModal = ({
}; };
return ( return (
<Modal <Modal open={open} onClose={onClose} aria-labelledby="modal-modal-title" aria-describedby="modal-modal-description">
open={open}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box <Box
sx={{ sx={{
position: "absolute", position: "absolute",
@ -70,10 +52,11 @@ export const UploadVideoModal = ({
}} }}
> >
<Typography sx={{ color: "#9A9AAF" }}> <Typography sx={{ color: "#9A9AAF" }}>
Видео можно вставить с любого хостинга: YouTube, Vimeo или загрузить Видео можно вставить с любого хостинга: YouTube, Vimeo или загрузить собственное
собственное
</Typography> </Typography>
<Button variant="contained">Готово</Button> <Button onClick={onClose} variant="contained">
Готово
</Button>
</Box> </Box>
<Box sx={{ padding: "20px", gap: "10px", display: "flex" }}> <Box sx={{ padding: "20px", gap: "10px", display: "flex" }}>
<SelectableButton <SelectableButton
@ -93,9 +76,7 @@ export const UploadVideoModal = ({
</Box> </Box>
{backgroundTypeModal === "linkVideo" ? ( {backgroundTypeModal === "linkVideo" ? (
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
<Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}> <Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}>Ссылка на видео</Typography>
Ссылка на видео
</Typography>
<CustomTextField <CustomTextField
placeholder={"http://example.com"} placeholder={"http://example.com"}
text={video} text={video}
@ -104,13 +85,8 @@ export const UploadVideoModal = ({
</Box> </Box>
) : ( ) : (
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
<Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}> <Typography sx={{ paddingBottom: "15px", fontWeight: "500" }}>Загрузите видео</Typography>
Загрузите видео <ButtonBase component="label" sx={{ justifyContent: "flex-start", width: "100%" }}>
</Typography>
<ButtonBase
component="label"
sx={{ justifyContent: "flex-start", width: "100%" }}
>
<input <input
onChange={({ target }) => { onChange={({ target }) => {
if (target.files?.length) { if (target.files?.length) {
@ -123,9 +99,7 @@ export const UploadVideoModal = ({
type="file" type="file"
/> />
<Box <Box
onDragOver={(event: DragEvent<HTMLDivElement>) => onDragOver={(event: DragEvent<HTMLDivElement>) => event.preventDefault()}
event.preventDefault()
}
onDrop={handleDrop} onDrop={handleDrop}
sx={{ sx={{
width: "580px", width: "580px",
@ -140,12 +114,8 @@ export const UploadVideoModal = ({
> >
<UploadIcon /> <UploadIcon />
<Box sx={{ color: "#9A9AAF" }}> <Box sx={{ color: "#9A9AAF" }}>
<Typography sx={{ fontWeight: "500" }}> <Typography sx={{ fontWeight: "500" }}>Добавить видео</Typography>
Добавить видео <Typography sx={{ fontSize: "16px" }}>Принимает .mp4 и .mov формат максимум 100мб</Typography>
</Typography>
<Typography sx={{ fontSize: "16px" }}>
Принимает .mp4 и .mov формат максимум 100мб
</Typography>
</Box> </Box>
</Box> </Box>
</ButtonBase> </ButtonBase>

@ -6,90 +6,87 @@ import ButtonsOptionsAndPict from "../ButtonsOptionsAndPict";
import SwitchAnswerOptions from "./switchAnswerOptions"; import SwitchAnswerOptions from "./switchAnswerOptions";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant"; import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
import { addQuestionVariant } from "@root/questions/actions"; import { addQuestionVariant } from "@root/questions/actions";
import { useAddAnswer } from "../../../utils/hooks/useAddAnswer";
interface Props { interface Props {
question: QuizQuestionVariant; question: QuizQuestionVariant;
} }
export default function AnswerOptions({ question }: Props) { export default function AnswerOptions({ question }: Props) {
const [switchState, setSwitchState] = useState("setting"); const onClickAddAnAnswer = useAddAnswer();
const theme = useTheme(); const [switchState, setSwitchState] = useState("setting");
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const SSHC = (data: string) => { const SSHC = (data: string) => {
setSwitchState(data); setSwitchState(data);
}; };
return ( return (
<> <>
<Box sx={{ padding: "0 20px 20px 20px" }}> <Box sx={{ padding: "0 20px 20px 20px" }}>
{question.content.variants.length === 0 ? ( {question.content.variants.length === 0 ? (
<Typography <Typography
sx={{ sx={{
padding: "0 0 33px 80px", padding: "0 0 33px 80px",
fontWeight: 400, fontWeight: 400,
fontSize: "18px", fontSize: "18px",
lineHeight: "21.33px", lineHeight: "21.33px",
color: theme.palette.grey2.main, color: theme.palette.grey2.main,
}} }}
> >
Добавьте ответ Добавьте ответ
</Typography> </Typography>
) : ( ) : (
<AnswerDraggableList question={question} /> <AnswerDraggableList question={question} />
)} )}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
marginBottom: "17px", marginBottom: "17px",
}} }}
> >
<Link <Link
component="button" component="button"
variant="body2" variant="body2"
sx={{ sx={{
color: theme.palette.brightPurple.main, color: theme.palette.brightPurple.main,
fontWeight: "400", fontWeight: "400",
fontSize: "16px", fontSize: "16px",
mr: "4px", mr: "4px",
height: "19px", height: "19px",
}} }}
onClick={() => addQuestionVariant(question.id)} onClick={() => onClickAddAnAnswer(question)}
> >
Добавьте ответ Добавьте ответ
</Link> </Link>
{isMobile ? null : ( {isMobile ? null : (
<> <>
<Typography <Typography
sx={{ sx={{
fontWeight: 400, fontWeight: 400,
lineHeight: "21.33px", lineHeight: "21.33px",
color: theme.palette.grey2.main, color: theme.palette.grey2.main,
fontSize: "16px", fontSize: "16px",
}} }}
> >
или нажмите Enter или нажмите Enter
</Typography> </Typography>
<EnterIcon <EnterIcon
style={{ style={{
color: "#7E2AEA", color: "#7E2AEA",
fontSize: "24px", fontSize: "24px",
marginLeft: "6px", marginLeft: "6px",
}} }}
/> />
</> </>
)} )}
</Box> </Box>
</Box> </Box>
<ButtonsOptionsAndPict <ButtonsOptionsAndPict switchState={switchState} SSHC={SSHC} question={question} />
switchState={switchState} <SwitchAnswerOptions switchState={switchState} question={question} />
SSHC={SSHC} </>
question={question} );
/>
<SwitchAnswerOptions switchState={switchState} question={question} />
</>
);
} }

@ -1,38 +1,10 @@
import { ResultSettings } from "./ResultSettings";
import { createFrontResult } from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { Box, Typography, useTheme, useMediaQuery, Button } from "@mui/material"; import { Box, Typography, useTheme, useMediaQuery, Button } from "@mui/material";
import image from "../../assets/Rectangle 110.png"; import image from "../../assets/Rectangle 110.png";
import { enqueueSnackbar } from "notistack";
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft";
import { decrementCurrentStep } from "@root/quizes/actions";
export const FirstEntry = () => { export const FirstEntry = () => {
const theme = useTheme(); const theme = useTheme();
const quiz = useCurrentQuiz();
const { questions } = useQuestionsStore();
const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1250)); const isSmallMonitor = useMediaQuery(theme.breakpoints.down(1250));
const create = () => {
if (quiz?.config.haveRoot) {
questions
.filter((question: AnyTypedQuizQuestion) => {
return (
question.type !== null &&
question.content.rule.parentId.length !== 0 &&
question.content.rule.children.length === 0
);
})
.forEach((question) => {
createFrontResult(quiz.backendId, question.content.id);
});
} else {
createFrontResult(quiz.backendId, "line");
}
};
return ( return (
<> <>
<Box <Box
@ -99,35 +71,6 @@ export const FirstEntry = () => {
}} }}
/> />
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "flex-start", alignItems: "center", gap: "8px", mt: "30px" }}>
<Button
variant="outlined"
sx={{
padding: "10px 20px",
borderRadius: "8px",
height: "44px",
}}
onClick={decrementCurrentStep}
>
<ArrowLeft />
</Button>
<Button
onClick={create}
variant="contained"
sx={{
backgroundColor: "#7E2AEA",
fontSize: "18px",
lineHeight: "18px",
width: "216px",
height: "44px",
p: "10px 20px",
}}
>
Создать результаты
</Button>
</Box>
</> </>
); );
}; };

@ -1,15 +1,43 @@
import { useQuestionsStore } from "@root/questions/store";
import { FirstEntry } from "./FirstEntry" import { FirstEntry } from "./FirstEntry"
import { ResultSettings } from "./ResultSettings" import { ResultSettings } from "./ResultSettings"
import { decrementCurrentStep, incrementCurrentStep } from "@root/quizes/actions";
import { Box, Button } from "@mui/material";
import ArrowLeft from "@icons/questionsPage/arrowLeft"
export const ResultPage = () => { export const ResultPage = () => {
const { questions } = useQuestionsStore();
//ищём хотя бы один result
const haveResult = questions.some((question) => question.type === "result")
return ( return (
haveResult ? <>
<ResultSettings />
:
<FirstEntry /> <FirstEntry />
); <ResultSettings />
<Box sx={{ display: "flex", justifyContent: "flex-start", alignItems: "center", gap: "8px", mt: "30px" }}>
<Button
variant="outlined"
sx={{
padding: "10px 20px",
borderRadius: "8px",
height: "44px",
}}
onClick={decrementCurrentStep}
>
<ArrowLeft />
</Button>
<Button
onClick={incrementCurrentStep}
variant="contained"
sx={{
backgroundColor: "#7E2AEA",
fontSize: "18px",
lineHeight: "18px",
width: "216px",
height: "44px",
p: "10px 20px",
}}
>
Настроить форму
</Button>
</Box>
</>
)
} }

@ -12,45 +12,50 @@ import { useEffect, useRef, useState } from "react";
import { WhenCard } from "./cards/WhenCard"; import { WhenCard } from "./cards/WhenCard";
import { ResultCard, checkEmptyData } from "./cards/ResultCard"; import { ResultCard, checkEmptyData } from "./cards/ResultCard";
import { EmailSettingsCard } from "./cards/EmailSettingsCard"; import { EmailSettingsCard } from "./cards/EmailSettingsCard";
import { useCurrentQuiz } from "@root/quizes/hooks" import { useCurrentQuiz } from "@root/quizes/hooks";
import { useQuestionsStore } from "@root/questions/store"; import { useQuestionsStore } from "@root/questions/store";
import { createFrontResult, deleteQuestion } from "@root/questions/actions"; import { deleteQuestion } from "@root/questions/actions";
import { QuizQuestionResult } from "@model/questionTypes/result"; import { QuizQuestionResult } from "@model/questionTypes/result";
export const ResultSettings = () => { export const ResultSettings = () => {
const { questions } = useQuestionsStore() const { questions } = useQuestionsStore();
const quiz = useCurrentQuiz() const quiz = useCurrentQuiz();
const results = useQuestionsStore().questions.filter((q): q is QuizQuestionResult => q.type === "result") const results = useQuestionsStore().questions.filter((q): q is QuizQuestionResult => q.type === "result");
const [quizExpand, setQuizExpand] = useState(true) const [quizExpand, setQuizExpand] = useState(true);
const [resultContract, setResultContract] = useState(true) const [resultContract, setResultContract] = useState(true);
const isReadyToLeaveRef = useRef(true); const isReadyToLeaveRef = useRef(true);
useEffect(function calcIsReadyToLeave(){ console.log(quiz)
let isReadyToLeave = true; console.log(results)
results.forEach((result) => { useEffect(
if (checkEmptyData({ resultData: result })) { function calcIsReadyToLeave() {
isReadyToLeave = false; let isReadyToLeave = true;
} results.forEach((result) => {
}); if (checkEmptyData({ resultData: result })) {
isReadyToLeaveRef.current = isReadyToLeave; isReadyToLeave = false;
}, [results]) }
});
isReadyToLeaveRef.current = isReadyToLeave;
},
[results]
);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (isReadyToLeaveRef.current === false) alert("Пожалуйста, проверьте, что вы заполнили все результаты"); if (isReadyToLeaveRef.current === false) alert("Пожалуйста, проверьте, что вы заполнили все результаты");
}; };
}, []); }, []);
return ( return (
<Box sx={{ maxWidth: "796px" }}> <Box sx={{ maxWidth: "796px" }}>
<Box sx={{ <Box
display: "flex", sx={{
alignItems: "center", display: "flex",
margin: "60px 0 40px 0", alignItems: "center",
}}> margin: "60px 0 40px 0",
<Typography variant="h5"> }}
Настройки результатов >
</Typography> <Typography variant="h5">Настройки результатов</Typography>
<Info /> <Info />
<Button <Button
disableRipple disableRipple
@ -73,14 +78,10 @@ export const ResultSettings = () => {
</Button> </Button>
</Box> </Box>
<WhenCard quizExpand={quizExpand} /> <WhenCard quizExpand={quizExpand} />
{quiz.config.resultInfo.when === "email" && <EmailSettingsCard quizExpand={quizExpand} />} {quiz.config.resultInfo.when === "email" && <EmailSettingsCard quizExpand={quizExpand} />}
<Box sx={{ display: "flex", alignItems: "center", mb: "15px", mt: "15px" }}>
<Box
sx={{ display: "flex", alignItems: "center", mb: "15px", mt: "15px" }}
>
<Typography variant="p1" sx={{ color: "#4D4D4D", fontSize: "14px" }}> <Typography variant="p1" sx={{ color: "#4D4D4D", fontSize: "14px" }}>
Создайте результат Создайте результат
</Typography> </Typography>
@ -105,9 +106,9 @@ export const ResultSettings = () => {
</Button> </Button>
</Box> </Box>
{ {results.map((resultQuestion) => (
results.map((resultQuestion) => <ResultCard resultContract={resultContract} resultData={resultQuestion} key={resultQuestion.id} />) <ResultCard resultContract={resultContract} resultData={resultQuestion} key={resultQuestion.id} />
} ))}
<Modal <Modal
open={false} open={false}
// onClose={handleClose} // onClose={handleClose}

@ -1,14 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import { Box, Button, IconButton, SxProps, Theme, Typography, useTheme, useMediaQuery } from "@mui/material";
Box,
Button,
IconButton,
SxProps,
Theme,
Typography,
useTheme,
useMediaQuery,
} from "@mui/material";
import { SwitchSetting } from "./SwichResult"; import { SwitchSetting } from "./SwichResult";
import Info from "@icons/Info"; import Info from "@icons/Info";
@ -92,13 +83,7 @@ export const SettingForm = () => {
Показывать результат Показывать результат
</Typography> </Typography>
<IconButton> <IconButton>
<svg <svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="30" height="30" rx="6" fill="#EEE4FC" /> <rect width="30" height="30" rx="6" fill="#EEE4FC" />
<path <path
d="M22.5 11.25L15 18.75L7.5 11.25" d="M22.5 11.25L15 18.75L7.5 11.25"
@ -136,27 +121,16 @@ export const SettingForm = () => {
))} ))}
</Box> </Box>
{typeActive === "e-mail" ? ( {typeActive === "e-mail" ? (
<SwitchSetting <SwitchSetting icon={listChecks} text="Показывать несколько результатов" />
icon={listChecks}
text="Показывать несколько результатов"
/>
) : ( ) : (
<> <>
<SwitchSetting <SwitchSetting icon={listChecks} text="Показывать несколько результатов" />
icon={listChecks}
text="Показывать несколько результатов"
/>
<SwitchSetting icon={ShareNetwork} text="Поделиться результатами" /> <SwitchSetting icon={ShareNetwork} text="Поделиться результатами" />
<SwitchSetting <SwitchSetting icon={ArrowCounterClockWise} text="Кнопка `Пройти тест заново`" />
icon={ArrowCounterClockWise}
text="Кнопка `Пройти тест заново`"
/>
</> </>
)} )}
</Box> </Box>
<Box <Box sx={{ display: "flex", alignItems: "center", mb: "15px", mt: "15px" }}>
sx={{ display: "flex", alignItems: "center", mb: "15px", mt: "15px" }}
>
<Typography variant="p1" sx={{ color: "#4D4D4D", fontSize: "14px" }}> <Typography variant="p1" sx={{ color: "#4D4D4D", fontSize: "14px" }}>
Создайте результат Создайте результат
</Typography> </Typography>

@ -6,6 +6,7 @@ import TextIcon from "@icons/ContactFormIcon/TextIcon";
import AddressIcon from "@icons/ContactFormIcon/AddressIcon"; import AddressIcon from "@icons/ContactFormIcon/AddressIcon";
import { useCurrentQuiz } from "@root/quizes/hooks"; import { useCurrentQuiz } from "@root/quizes/hooks";
import { NameplateLogo } from "@icons/NameplateLogo";
import CustomCheckbox from "@ui_kit/CustomCheckbox"; import CustomCheckbox from "@ui_kit/CustomCheckbox";
import { useState } from "react"; import { useState } from "react";
import { useQuestionsStore } from "@root/questions/store"; import { useQuestionsStore } from "@root/questions/store";
@ -71,19 +72,19 @@ export const ContactForm = ({
}} }}
> >
{quiz?.config.formContact.title || "Заполните форму, чтобы получить результаты теста"} {quiz?.config.formContact.title || "Заполните форму, чтобы получить результаты теста"}
</Typography> </Typography>
{ {
quiz?.config.formContact.desc && quiz?.config.formContact.desc &&
<Typography <Typography
sx={{ sx={{
textAlign: "center", textAlign: "center",
m: "20px 0", m: "20px 0",
fontSize: "18px" fontSize: "18px"
}} }}
> >
{quiz?.config.formContact.desc} {quiz?.config.formContact.desc}
</Typography> </Typography>
} }
</Box> </Box>
@ -138,6 +139,16 @@ export const ContactForm = ({
</Typography> </Typography>
</Box> </Box>
<Box
sx={{
display: "flex",
alignItems: "center",
mt: "20px"
}}
>
<NameplateLogo style={{ fontSize: "34px" }} />
<Typography sx={{ fontSize: "20px", color: "#4D4D4D", whiteSpace: "nowrap" }}>Сделано на PenaQuiz</Typography>
</Box>
</Paper> </Paper>
</Box > </Box >
</Box > </Box >
@ -154,14 +165,14 @@ const Inputs = () => {
if (FC.used) someUsed.push(<CustomInput title={FC.innerText || data.defaultText} desc={FC.text || data.defaultTitle} Icon={data.icon} />) if (FC.used) someUsed.push(<CustomInput title={FC.innerText || data.defaultText} desc={FC.text || data.defaultTitle} Icon={data.icon} />)
return <CustomInput title={FC.innerText || data.defaultText} desc={FC.text || data.defaultTitle} Icon={data.icon} /> return <CustomInput title={FC.innerText || data.defaultText} desc={FC.text || data.defaultTitle} Icon={data.icon} />
}) })
if (someUsed.length) { if (someUsed.length) {
return <>{someUsed}</> return <>{someUsed}</>
} else { } else {
return <> return <>
{Icons[0]} {Icons[0]}
{Icons[1]} {Icons[1]}
{Icons[2]} {Icons[2]}
</> </>
} }
} }

@ -8,7 +8,7 @@ import { useQuestionsStore } from "@root/questions/store";
import type { AnyTypedQuizQuestion, QuizQuestionBase } from "../../model/questionTypes/shared"; import type { AnyTypedQuizQuestion, QuizQuestionBase } from "../../model/questionTypes/shared";
import { getQuestionByContentId } from "@root/questions/actions"; import { getQuestionByContentId } from "@root/questions/actions";
import { enqueueSnackbar } from "notistack"; import { enqueueSnackbar } from "notistack";
import { NameplateLogo } from "@icons/NameplateLogo"; import { NameplateLogoFQ } from "@icons/NameplateLogoFQ";
type FooterProps = { type FooterProps = {
setCurrentQuestion: (step: AnyTypedQuizQuestion) => void; setCurrentQuestion: (step: AnyTypedQuizQuestion) => void;
@ -189,22 +189,11 @@ export const Footer = ({ setCurrentQuestion, question, setShowContactForm, setSh
position: "relative", position: "relative",
padding: "15px 0", padding: "15px 0",
borderTop: `1px solid ${theme.palette.grey[400]}`, borderTop: `1px solid ${theme.palette.grey[400]}`,
height: '75px',
display: "flex"
}} }}
> >
<Box
sx={{
display: "flex",
alignItems: "center",
position: "absolute",
top: "-45px",
left: "50%",
transform: "translateX(-50%)",
gap: "8px",
}}
>
<NameplateLogo style={{ fontSize: "34px" }} />
<Typography sx={{ fontSize: "20px", color: "#4D4D4D", whiteSpace: "nowrap" }}>Сделано на PenaQuiz</Typography>
</Box>
<Box <Box
sx={{ sx={{
width: "100%", width: "100%",
@ -216,6 +205,7 @@ export const Footer = ({ setCurrentQuestion, question, setShowContactForm, setSh
gap: "10px", gap: "10px",
}} }}
> >
<NameplateLogoFQ style={{ fontSize: "34px", width:"200px", height:"auto" }} />
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

@ -66,11 +66,14 @@ export const Question = ({ questions }: QuestionProps) => {
QUESTIONS_MAP[currentQuestion.type as Exclude<QuestionType, "nonselected">]; QUESTIONS_MAP[currentQuestion.type as Exclude<QuestionType, "nonselected">];
return ( return (
<Box> <Box
height="100vh"
>
{!showContactForm && !showResultForm && ( {!showContactForm && !showResultForm && (
<Box <Box
sx={{ sx={{
minHeight: "calc(100vh - 75px)", height: "calc(100vh - 75px)",
width: "100%", width: "100%",
maxWidth: "1440px", maxWidth: "1440px",
padding: "40px 25px 20px", padding: "40px 25px 20px",

@ -6,6 +6,7 @@ import { useQuestionsStore } from "@root/questions/store";
import type { AnyTypedQuizQuestion } from "../../model/questionTypes/shared"; import type { AnyTypedQuizQuestion } from "../../model/questionTypes/shared";
import YoutubeEmbedIframe from "../../ui_kit/StartPagePreview/YoutubeEmbedIframe.tsx" import YoutubeEmbedIframe from "../../ui_kit/StartPagePreview/YoutubeEmbedIframe.tsx"
import { NameplateLogo } from "@icons/NameplateLogo";
type ResultFormProps = { type ResultFormProps = {
currentQuestion: AnyTypedQuizQuestion; currentQuestion: AnyTypedQuizQuestion;
@ -29,7 +30,7 @@ export const ResultForm = ({
(question.content.rule.parentId === "line" || currentQuestion.content.id) (question.content.rule.parentId === "line" || currentQuestion.content.id)
); );
const followNextForm = () => { const followNextForm = () => {
@ -100,31 +101,59 @@ export const ResultForm = ({
</Box> </Box>
{ <Box width="100%">
quiz?.config.resultInfo.when === "before" &&
<Box <Box
sx={{ sx={{
height: "100px",
boxShadow: "0 0 15px 0 rgba(0,0,0,.08)",
width: "100%",
display: "flex", display: "flex",
justifyContent: "center", width: "100%",
alignItems: "center" justifyContent: "end",
px: "20px"
}} }}
> >
<Button <Box
onClick={followNextForm}
variant="contained"
sx={{ sx={{
p: "10px 20px", display: "flex",
width: "210px", alignItems: "center",
height: "50px" mt: "15px"
}} }}
> >
{resultQuestion.content.hint.text || "Узнать подробнее"} <NameplateLogo style={{ fontSize: "34px" }} />
</Button> <Typography sx={{ fontSize: "20px", color: "#4D4D4D", whiteSpace: "nowrap" }}>Сделано на PenaQuiz</Typography>
</Box>
</Box> </Box>
}
{
quiz?.config.resultInfo.when === "before" &&
<>
<Box
sx={{
boxShadow: "0 0 15px 0 rgba(0,0,0,.08)",
width: "100%",
flexDirection: "column",
display: "flex",
justifyContent: "center",
alignItems: "center",
p: "20px"
}}
>
<Button
onClick={followNextForm}
variant="contained"
sx={{
p: "10px 20px",
width: "210px",
height: "50px"
}}
>
{resultQuestion.content.hint.text || "Узнать подробнее"}
</Button>
</Box>
</>
}
</Box>
</Box> </Box>
); );
}; };

@ -18,21 +18,29 @@ export const Number = ({ currentQuestion }: NumberProps) => {
const [maxRange, setMaxRange] = useState<string>("100000000000"); const [maxRange, setMaxRange] = useState<string>("100000000000");
const theme = useTheme(); const theme = useTheme();
const { answers } = useQuizViewStore(); const { answers } = useQuizViewStore();
const updateMinRangeDebounced = useDebouncedCallback((value, crowded = false) => { const updateMinRangeDebounced = useDebouncedCallback(
if (crowded) { (value, crowded = false) => {
setMinRange(maxRange); if (crowded) {
} setMinRange(maxRange);
}
updateAnswer(currentQuestion.content.id, value); updateAnswer(currentQuestion.content.id, value);
}, 1000); },
const updateMaxRangeDebounced = useDebouncedCallback((value, crowded = false) => { 1000
if (crowded) { );
setMaxRange(minRange); const updateMaxRangeDebounced = useDebouncedCallback(
} (value, crowded = false) => {
if (crowded) {
setMaxRange(minRange);
}
updateAnswer(currentQuestion.content.id, value); updateAnswer(currentQuestion.content.id, value);
}, 1000); },
const answer = answers.find(({ questionId }) => questionId === currentQuestion.content.id)?.answer as string; 1000
);
const answer = answers.find(
({ questionId }) => questionId === currentQuestion.content.id
)?.answer as string;
const min = window.Number(currentQuestion.content.range.split("—")[0]); const min = window.Number(currentQuestion.content.range.split("—")[0]);
const max = window.Number(currentQuestion.content.range.split("—")[1]); const max = window.Number(currentQuestion.content.range.split("—")[1]);

@ -12,171 +12,171 @@ import { Link as RouterLink, useNavigate, useLocation } from "react-router-dom";
import { object, string } from "yup"; import { object, string } from "yup";
interface Values { interface Values {
email: string; email: string;
password: string; password: string;
} }
const initialValues: Values = { const initialValues: Values = {
email: "", email: "",
password: "", password: "",
}; };
const validationSchema = object({ const validationSchema = object({
email: string().required("Поле обязательно").email("Введите корректный email"), email: string().required("Поле обязательно").email("Введите корректный email"),
password: string().required("Поле обязательно").min(8, "Минимум 8 символов"), password: string().required("Поле обязательно").min(8, "Минимум 8 символов"),
}); });
export default function SigninDialog() { export default function SigninDialog() {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const formik = useFormik<Values>({ const formik = useFormik<Values>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit: async (values, formikHelpers) => { onSubmit: async (values, formikHelpers) => {
const [loginResponse, loginError] = await login(values.email.trim(), values.password.trim()); const [loginResponse, loginError] = await login(values.email.trim(), values.password.trim());
formikHelpers.setSubmitting(false); formikHelpers.setSubmitting(false);
if (loginError) { if (loginError) {
return enqueueSnackbar(loginError); return enqueueSnackbar(loginError);
} }
if (loginResponse) { if (loginResponse) {
setUserId(loginResponse._id); setUserId(loginResponse._id);
} }
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/list", { replace: true });
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
}, },
}} });
slotProps={{
backdrop: { useEffect(
style: { function redirectIfSignedIn() {
backgroundColor: "rgb(0 0 0 / 0.7)", if (user) navigate("/list", { replace: true });
},
}, },
}} [navigate, user]
> );
<Box
component="form" function handleClose() {
onSubmit={formik.handleSubmit} setIsDialogOpen(false);
noValidate setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
sx={{ }
position: "relative",
backgroundColor: "white", return (
display: "flex", <Dialog
alignItems: "center", open={isDialogOpen}
flexDirection: "column", onClose={handleClose}
p: upMd ? "50px" : "18px", PaperProps={{
pb: upMd ? "40px" : "30px", sx: {
gap: "15px", width: "600px",
borderRadius: "12px", maxWidth: "600px",
boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)", },
"& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": { }}
position: "absolute", slotProps={{
top: "46px", backdrop: {
margin: "0", style: {
}, backgroundColor: "rgb(0 0 0 / 0.7)",
}} },
> },
<IconButton }}
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
> >
<CloseIcon sx={{ transform: "scale(1.5)" }} /> <Box
</IconButton> component="form"
<Box> onSubmit={formik.handleSubmit}
<Logotip width={upMd ? 233 : 196} /> noValidate
</Box> sx={{
<Typography position: "relative",
sx={{ backgroundColor: "white",
color: "#4D4D4D", display: "flex",
mt: "5px", alignItems: "center",
mb: upMd ? "10px" : "33px", flexDirection: "column",
}} p: upMd ? "50px" : "18px",
> pb: upMd ? "40px" : "30px",
Вход в личный кабинет gap: "15px",
</Typography> borderRadius: "12px",
<InputTextfield boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)",
TextfieldProps={{ "& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": {
value: formik.values.email, position: "absolute",
placeholder: "username", top: "46px",
onBlur: formik.handleBlur, margin: "0",
error: formik.touched.email && Boolean(formik.errors.email), },
helperText: formik.touched.email && formik.errors.email, }}
"data-cy": "username", >
}} <IconButton
onChange={formik.handleChange} onClick={handleClose}
color="#F2F3F7" sx={{
id="email" position: "absolute",
label="Email" right: "7px",
gap={upMd ? "10px" : "10px"} top: "7px",
/> }}
<PasswordInput >
TextfieldProps={{ <CloseIcon sx={{ transform: "scale(1.5)" }} />
value: formik.values.password, </IconButton>
placeholder: "Не менее 8 символов", <Box>
onBlur: formik.handleBlur, <Logotip width={upMd ? 233 : 196} />
error: formik.touched.password && Boolean(formik.errors.password), </Box>
helperText: formik.touched.password && formik.errors.password, <Typography
type: "password", sx={{
"data-cy": "password", color: "#4D4D4D",
}} mt: "5px",
onChange={formik.handleChange} mb: upMd ? "10px" : "33px",
color="#F2F3F7" }}
id="password" >
label="Пароль" Вход в личный кабинет
gap={upMd ? "10px" : "10px"} </Typography>
/> <InputTextfield
<Button TextfieldProps={{
variant="contained" value: formik.values.email,
fullWidth placeholder: "username",
type="submit" onBlur: formik.handleBlur,
disabled={formik.isSubmitting} error: formik.touched.email && Boolean(formik.errors.email),
sx={{ helperText: formik.touched.email && formik.errors.email,
py: "12px", "data-cy": "username",
"&:hover": { }}
backgroundColor: "#581CA7", onChange={formik.handleChange}
}, color="#F2F3F7"
"&:active": { id="email"
color: "white", label="Email"
backgroundColor: "black", gap={upMd ? "10px" : "10px"}
}, />
}} <PasswordInput
data-cy="signin" TextfieldProps={{
> value: formik.values.password,
Войти placeholder: "Не менее 8 символов",
</Button> onBlur: formik.handleBlur,
{/* <Link error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
type: "password",
"data-cy": "password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="contained"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: "#581CA7",
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
data-cy="signin"
>
Войти
</Button>
{/* <Link
component={RouterLink} component={RouterLink}
to="/" to="/"
href="#" href="#"
@ -188,26 +188,38 @@ export default function SigninDialog() {
> >
Забыли пароль? Забыли пароль?
</Link> */} </Link> */}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
gap: "10px", gap: "10px",
mt: "auto", mt: "auto",
}} }}
> >
<Typography sx={{ color: "#7E2AEA", textAlign: "center" }}>Вы еще не присоединились?</Typography> <Typography sx={{ color: "#7E2AEA", textAlign: "center" }}>Вы еще не присоединились?</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: "4px" }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "15px" }}>
<Link component={RouterLink} to="/signup" sx={{ color: "#7E2AEA" }}> <Link
Регистрация component={RouterLink}
</Link> to="/signup"
<Link component={RouterLink} to="/restore" sx={{ color: "#7E2AEA" }}> state={{ backgroundLocation: location.state.backgroundLocation }}
Забыли пароль sx={{
</Link> color: "#7E2AEA"
</Box> }}>
</Box> Регистрация
</Box> </Link>
</Dialog> <Link
); component={RouterLink}
to="/restore"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{
color: "#7E2AEA"
}}>
Забыли пароль
</Link>
</Box>
</Box>
</Box>
</Dialog>
);
} }

@ -12,210 +12,215 @@ import { Link as RouterLink, useLocation, useNavigate } from "react-router-dom";
import { object, ref, string } from "yup"; import { object, ref, string } from "yup";
interface Values { interface Values {
email: string; email: string;
password: string; password: string;
repeatPassword: string; repeatPassword: string;
} }
const initialValues: Values = { const initialValues: Values = {
email: "", email: "",
password: "", password: "",
repeatPassword: "", repeatPassword: "",
}; };
const validationSchema = object({ const validationSchema = object({
email: string().required("Поле обязательно").email("Введите корректный email"), email: string().required("Поле обязательно").email("Введите корректный email"),
password: string() password: string()
.min(8, "Минимум 8 символов") .min(8, "Минимум 8 символов")
.matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы") .matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы")
.required("Поле обязательно"), .required("Поле обязательно"),
repeatPassword: string() repeatPassword: string()
.oneOf([ref("password"), undefined], "Пароли не совпадают") .oneOf([ref("password"), undefined], "Пароли не совпадают")
.required("Повторите пароль"), .required("Повторите пароль"),
}); });
export default function SignupDialog() { export default function SignupDialog() {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const user = useUserStore((state) => state.user); const user = useUserStore((state) => state.user);
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const formik = useFormik<Values>({ const formik = useFormik<Values>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit: async (values, formikHelpers) => { onSubmit: async (values, formikHelpers) => {
const [registerResponse, registerError] = await register(values.email.trim(), values.password.trim(), "+7"); const [registerResponse, registerError] = await register(values.email.trim(), values.password.trim(), "+7");
formikHelpers.setSubmitting(false); formikHelpers.setSubmitting(false);
if (registerError) { if (registerError) {
return enqueueSnackbar(registerError); return enqueueSnackbar(registerError);
} }
if (registerResponse) { if (registerResponse) {
setUserId(registerResponse._id); setUserId(registerResponse._id);
} }
},
});
useEffect(
function redirectIfSignedIn() {
if (user) navigate("/list", { replace: true });
},
[navigate, user]
);
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
}, },
}} });
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
}}
>
<Box
component="form"
onSubmit={formik.handleSubmit}
noValidate
sx={{
position: "relative",
backgroundColor: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)",
"& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": {
position: "absolute",
top: "46px",
margin: "0",
},
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box sx={{ mt: upMd ? undefined : "62px" }}>
<Logotip width={upMd ? 233 : 196} />
</Box>
<Typography
sx={{
color: "#4D4D4D",
mt: "5px",
mb: upMd ? "35px" : "33px",
}}
>
Регистрация
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
"data-cy": "username",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
autoComplete: "new-password",
"data-cy": "password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.repeatPassword,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword),
helperText: formik.touched.repeatPassword && formik.errors.repeatPassword,
autoComplete: "new-password",
"data-cy": "repeat-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="repeatPassword"
label="Повторить пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="contained"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: "#581CA7",
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
data-cy="signup"
>
Зарегистрироваться
</Button>
<Link useEffect(
component={RouterLink} function redirectIfSignedIn() {
to="/signin" if (user) navigate("/list", { replace: true });
state={{ backgroundLocation: location.state.backgroundLocation }} },
sx={{ [navigate, user]
color: "#7E2AEA", );
mt: "auto",
}} function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
},
}}
slotProps={{
backdrop: {
style: {
backgroundColor: "rgb(0 0 0 / 0.7)",
},
},
}}
> >
Вход в личный кабинет <Box
</Link> component="form"
<Link component={RouterLink} to="/restore" sx={{ color: "#7E2AEA" }}> onSubmit={formik.handleSubmit}
Забыли пароль noValidate
</Link> sx={{
</Box> position: "relative",
</Dialog> backgroundColor: "white",
); display: "flex",
alignItems: "center",
flexDirection: "column",
p: upMd ? "50px" : "18px",
pb: upMd ? "40px" : "30px",
gap: "15px",
borderRadius: "12px",
boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)",
"& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": {
position: "absolute",
top: "46px",
margin: "0",
},
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
>
<CloseIcon sx={{ transform: "scale(1.5)" }} />
</IconButton>
<Box sx={{ mt: upMd ? undefined : "62px" }}>
<Logotip width={upMd ? 233 : 196} />
</Box>
<Typography
sx={{
color: "#4D4D4D",
mt: "5px",
mb: upMd ? "35px" : "33px",
}}
>
Регистрация
</Typography>
<InputTextfield
TextfieldProps={{
value: formik.values.email,
placeholder: "username",
onBlur: formik.handleBlur,
error: formik.touched.email && Boolean(formik.errors.email),
helperText: formik.touched.email && formik.errors.email,
"data-cy": "username",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="email"
label="Email"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.password,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.password && Boolean(formik.errors.password),
helperText: formik.touched.password && formik.errors.password,
autoComplete: "new-password",
"data-cy": "password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="password"
label="Пароль"
gap={upMd ? "10px" : "10px"}
/>
<PasswordInput
TextfieldProps={{
value: formik.values.repeatPassword,
placeholder: "Не менее 8 символов",
onBlur: formik.handleBlur,
error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword),
helperText: formik.touched.repeatPassword && formik.errors.repeatPassword,
autoComplete: "new-password",
"data-cy": "repeat-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="repeatPassword"
label="Повторить пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="contained"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: "#581CA7",
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
data-cy="signup"
>
Зарегистрироваться
</Button>
<Link
component={RouterLink}
to="/signin"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{
color: "#7E2AEA",
mt: "auto",
}}
>
Вход в личный кабинет
</Link>
<Link
component={RouterLink}
to="/restore"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{ color: "#7E2AEA" }}
>
Забыли пароль
</Link>
</Box>
</Dialog>
);
} }

@ -46,7 +46,12 @@ export default function QuizCard({ quiz, openCount = 0, applicationCount = 0, co
0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)`, 0px 2.76726px 8.55082px rgba(210, 208, 225, 0.0674749)`,
}} }}
> >
<Typography variant="h5">{quiz.name}</Typography> <Typography
sx={{ textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", widows: "100%" }}
variant="h5"
>
{quiz.name}
</Typography>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

@ -32,7 +32,7 @@ import { Link, useNavigate } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import { SidebarMobile } from "./Sidebar/SidebarMobile"; import { SidebarMobile } from "./Sidebar/SidebarMobile";
import { cleanQuestions, setQuestions } from "@root/questions/actions"; import { cleanQuestions, createResult, setQuestions } from "@root/questions/actions";
import { updateOpenBranchingPanel, updateCanCreatePublic, updateModalInfoWhyCantCreate } from "@root/uiTools/actions"; import { updateOpenBranchingPanel, updateCanCreatePublic, updateModalInfoWhyCantCreate } from "@root/uiTools/actions";
import { BranchingPanel } from "../Questions/BranchingPanel"; import { BranchingPanel } from "../Questions/BranchingPanel";
import { useQuestionsStore } from "@root/questions/store"; import { useQuestionsStore } from "@root/questions/store";
@ -59,10 +59,14 @@ export default function EditPage() {
const questions = await questionApi.getList({ quiz_id: editQuizId }); const questions = await questionApi.getList({ quiz_id: editQuizId });
setQuestions(questions); setQuestions(questions);
if (questions === null || !questions.find(q => q.type !== null && q.content?.rule.parentId === "line")) createResult(quiz?.backendId, "line")
}; };
getData(); getData();
}, []); }, []);
console.log(quiz)
console.log(questions)
const { openBranchingPanel, whyCantCreatePublic, canCreatePublic } = useUiTools(); const { openBranchingPanel, whyCantCreatePublic, canCreatePublic } = useUiTools();
const theme = useTheme(); const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
@ -78,10 +82,12 @@ export default function EditPage() {
}, [navigate, editQuizId]); }, [navigate, editQuizId]);
useEffect( useEffect(
() => () => { () => {
resetEditConfig(); return () => {
cleanQuestions(); resetEditConfig();
updateModalInfoWhyCantCreate(false) cleanQuestions();
updateModalInfoWhyCantCreate(false)
}
}, },
[] []
); );
@ -123,7 +129,7 @@ export default function EditPage() {
useEffect(() => { useEffect(() => {
updateQuestionHint(questions) updateQuestionHint(questions)
}, [questions]); }, [questions]);
async function handleLogoutClick() { async function handleLogoutClick() {
const [, logoutError] = await logout(); const [, logoutError] = await logout();
@ -376,11 +382,11 @@ export default function EditPage() {
height: "34px", height: "34px",
minWidth: "130px", minWidth: "130px",
}} }}
onClick={() => Object.keys(whyCantCreatePublic).length === 0 ? () => {} : updateModalInfoWhyCantCreate(true)} onClick={() => Object.keys(whyCantCreatePublic).length === 0 ? () => { } : updateModalInfoWhyCantCreate(true)}
> >
Тестовый просмотр Тестовый просмотр
</Button> </Button>
: :
<a href={`/view`} target="_blank" rel="noreferrer" style={{ textDecoration: "none" }}> <a href={`/view`} target="_blank" rel="noreferrer" style={{ textDecoration: "none" }}>
<Button <Button
variant="contained" variant="contained"
@ -411,22 +417,22 @@ export default function EditPage() {
() => updateQuiz(quiz?.id, (state) => { () => updateQuiz(quiz?.id, (state) => {
state.status = quiz?.status === "start" ? "stop" : "start"; state.status = quiz?.status === "start" ? "stop" : "start";
}) })
: :
() => updateModalInfoWhyCantCreate(true) () => updateModalInfoWhyCantCreate(true)
} }
> >
{quiz?.status === "start" ? "Стоп" : "Старт"} {quiz?.status === "start" ? "Стоп" : "Старт"}
</Button> </Button>
{quiz?.status === "start" && <Box {quiz?.status === "start" && <Box
component={Link} component={Link}
sx={{ sx={{
color: "#7e2aea", color: "#7e2aea",
fontSize: "14px" fontSize: "14px"
}} }}
target="_blank" to={"https://hbpn.link/" + quiz.qid}>https://hbpn.link/{quiz.qid} target="_blank" to={"https://hbpn.link/" + quiz.qid}>https://hbpn.link/{quiz.qid}
</Box>} </Box>}
</Box> </Box>
</Box > </Box >
<ModalInfoWhyCantCreate /> <ModalInfoWhyCantCreate />
</> </>
); );

@ -1,183 +1,195 @@
import { FC, useState } from "react"; import { FC, useState } from "react";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { Box, Button, Dialog, IconButton, Typography, useMediaQuery, useTheme } from "@mui/material"; import { Box, Button, Dialog, IconButton, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import InputTextfield from "@ui_kit/InputTextfield"; import InputTextfield from "@ui_kit/InputTextfield";
import PasswordInput from "@ui_kit/passwordInput"; import PasswordInput from "@ui_kit/passwordInput";
import { useFormik } from "formik"; import { useFormik } from "formik";
import { object, ref, string } from "yup"; import { object, ref, string } from "yup";
import Logotip from "../Landing/images/icons/QuizLogo"; import Logotip from "../Landing/images/icons/QuizLogo";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link as RouterLink, useLocation } from "react-router-dom";
interface Values { interface Values {
email: string; email: string;
password: string; password: string;
repeatPassword: string; repeatPassword: string;
} }
const initialValues: Values = { const initialValues: Values = {
email: "", email: "",
password: "", password: "",
repeatPassword: "", repeatPassword: "",
}; };
const validationSchema = object({ const validationSchema = object({
email: string().required("Поле обязательно").email("Введите корректный email"), email: string().required("Поле обязательно").email("Введите корректный email"),
password: string() password: string()
.min(8, "Минимум 8 символов") .min(8, "Минимум 8 символов")
.matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы") .matches(/^[.,:;-_+\d\w]+$/, "Некорректные символы")
.required("Поле обязательно"), .required("Поле обязательно"),
repeatPassword: string() repeatPassword: string()
.oneOf([ref("password"), undefined], "Пароли не совпадают") .oneOf([ref("password"), undefined], "Пароли не совпадают")
.required("Повторите пароль"), .required("Повторите пароль"),
}); });
export const Restore: FC = () => { export const Restore: FC = () => {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(true);
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const upMd = useMediaQuery(theme.breakpoints.up("md")); const upMd = useMediaQuery(theme.breakpoints.up("md"));
const location = useLocation();
const formik = useFormik<Values>({ const formik = useFormik<Values>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit: (values) => { onSubmit: (values) => {
},
});
function handleClose() {
setIsDialogOpen(false);
setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}
return (
<Dialog
open={isDialogOpen}
onClose={handleClose}
PaperProps={{
sx: {
width: "600px",
maxWidth: "600px",
}, },
}} });
slotProps={{
backdrop: { function handleClose() {
style: { setIsDialogOpen(false);
backgroundColor: "rgb(0 0 0 / 0.7)", setTimeout(() => navigate("/"), theme.transitions.duration.leavingScreen);
}, }
},
}} return (
> <Dialog
<Box open={isDialogOpen}
component="form" onClose={handleClose}
onSubmit={formik.handleSubmit} PaperProps={{
noValidate sx: {
sx={{ width: "600px",
position: "relative", maxWidth: "600px",
backgroundColor: "white", },
display: "flex", }}
alignItems: "center", slotProps={{
flexDirection: "column", backdrop: {
p: upMd ? "50px" : "18px", style: {
pb: upMd ? "40px" : "30px", backgroundColor: "rgb(0 0 0 / 0.7)",
gap: "15px", },
borderRadius: "12px", },
boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)", }}
"& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": {
position: "absolute",
top: "46px",
margin: "0",
},
}}
>
<IconButton
onClick={handleClose}
sx={{
position: "absolute",
right: "7px",
top: "7px",
}}
> >
<CloseIcon sx={{ transform: "scale(1.5)" }} /> <Box
</IconButton> component="form"
<Box sx={{ mt: upMd ? undefined : "62px" }}> onSubmit={formik.handleSubmit}
<Logotip width={upMd ? 233 : 196} /> noValidate
</Box> sx={{
<Typography position: "relative",
sx={{ backgroundColor: "white",
color: "#4D4D4D", display: "flex",
mt: "5px", alignItems: "center",
mb: upMd ? "30px" : "33px", flexDirection: "column",
}} p: upMd ? "50px" : "18px",
> pb: upMd ? "40px" : "30px",
Восстановление пароля gap: "15px",
</Typography> borderRadius: "12px",
<InputTextfield boxShadow: "0px 15px 80px rgb(210 208 225 / 70%)",
TextfieldProps={{ "& .MuiFormHelperText-root.Mui-error, & .MuiFormHelperText-root.Mui-error.MuiFormHelperText-filled": {
value: formik.values.email, position: "absolute",
placeholder: "username", top: "46px",
onBlur: formik.handleBlur, margin: "0",
error: formik.touched.email && Boolean(formik.errors.email), },
helperText: formik.touched.email && formik.errors.email, }}
"data-cy": "username", >
}} <IconButton
onChange={formik.handleChange} onClick={handleClose}
color="#F2F3F7" sx={{
id="email" position: "absolute",
label="Email" right: "7px",
gap={upMd ? "10px" : "10px"} top: "7px",
/> }}
<PasswordInput >
TextfieldProps={{ <CloseIcon sx={{ transform: "scale(1.5)" }} />
value: formik.values.password, </IconButton>
placeholder: "Не менее 8 символов", <Box sx={{ mt: upMd ? undefined : "62px" }}>
onBlur: formik.handleBlur, <Logotip width={upMd ? 233 : 196} />
error: formik.touched.password && Boolean(formik.errors.password), </Box>
helperText: formik.touched.password && formik.errors.password, <Typography
autoComplete: "new-password", sx={{
"data-cy": "password", color: "#4D4D4D",
}} mt: "5px",
onChange={formik.handleChange} mb: upMd ? "30px" : "33px",
color="#F2F3F7" }}
id="password" >
label="Пароль" Восстановление пароля
gap={upMd ? "10px" : "10px"} </Typography>
/> <InputTextfield
<PasswordInput TextfieldProps={{
TextfieldProps={{ value: formik.values.email,
value: formik.values.repeatPassword, placeholder: "username",
placeholder: "Не менее 8 символов", onBlur: formik.handleBlur,
onBlur: formik.handleBlur, error: formik.touched.email && Boolean(formik.errors.email),
error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword), helperText: formik.touched.email && formik.errors.email,
helperText: formik.touched.repeatPassword && formik.errors.repeatPassword, "data-cy": "username",
autoComplete: "new-password", }}
"data-cy": "repeat-password", onChange={formik.handleChange}
}} color="#F2F3F7"
onChange={formik.handleChange} id="email"
color="#F2F3F7" label="Email"
id="repeatPassword" gap={upMd ? "10px" : "10px"}
label="Повторить пароль" />
gap={upMd ? "10px" : "10px"} <PasswordInput
/> TextfieldProps={{
<Button value: formik.values.password,
variant="contained" placeholder: "Не менее 8 символов",
fullWidth onBlur: formik.handleBlur,
type="submit" error: formik.touched.password && Boolean(formik.errors.password),
disabled={formik.isSubmitting} helperText: formik.touched.password && formik.errors.password,
sx={{ autoComplete: "new-password",
py: "12px", "data-cy": "password",
"&:hover": { }}
backgroundColor: "#581CA7", onChange={formik.handleChange}
}, color="#F2F3F7"
"&:active": { id="password"
color: "white", label="Пароль"
backgroundColor: "black", gap={upMd ? "10px" : "10px"}
}, />
}} <PasswordInput
data-cy="signup" TextfieldProps={{
> value: formik.values.repeatPassword,
Восстановить placeholder: "Не менее 8 символов",
</Button> onBlur: formik.handleBlur,
</Box> error: formik.touched.repeatPassword && Boolean(formik.errors.repeatPassword),
</Dialog> helperText: formik.touched.repeatPassword && formik.errors.repeatPassword,
); autoComplete: "new-password",
"data-cy": "repeat-password",
}}
onChange={formik.handleChange}
color="#F2F3F7"
id="repeatPassword"
label="Повторить пароль"
gap={upMd ? "10px" : "10px"}
/>
<Button
variant="contained"
fullWidth
type="submit"
disabled={formik.isSubmitting}
sx={{
py: "12px",
"&:hover": {
backgroundColor: "#581CA7",
},
"&:active": {
color: "white",
backgroundColor: "black",
},
}}
data-cy="signup"
>
Восстановить
</Button>
<Link
component={RouterLink}
to="/signin"
state={{ backgroundLocation: location.state.backgroundLocation }}
sx={{
color: "#7E2AEA",
mt: "auto",
}}
>
У меня уже есть аккаунт
</Link>
</Box>
</Dialog>
);
}; };

@ -15,6 +15,7 @@ import { useCurrentQuiz } from "@root/quizes/hooks";
import { QuestionsStore, useQuestionsStore } from "./store"; import { QuestionsStore, useQuestionsStore } from "./store";
import { useUiTools } from "../uiTools/store"; import { useUiTools } from "../uiTools/store";
import { withErrorBoundary } from "react-error-boundary"; import { withErrorBoundary } from "react-error-boundary";
import { QuizQuestionResult } from "@model/questionTypes/result";
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => { export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
@ -481,7 +482,12 @@ export const getQuestionByContentId = (questionContentId: string | null) => {
export const clearRuleForAll = () => { export const clearRuleForAll = () => {
const { questions } = useQuestionsStore.getState(); const { questions } = useQuestionsStore.getState();
questions.forEach(question => { questions.forEach(question => {
if (question.type !== null && (question.content.rule.main.length > 0 || question.content.rule.default.length > 0 || question.content.rule.parentId.length > 0)) { if (question.type !== null &&
(question.content.rule.main.length > 0
|| question.content.rule.default.length > 0
|| question.content.rule.parentId.length > 0)
&& question.type !== "result") {
updateQuestion(question.content.id, question => { updateQuestion(question.content.id, question => {
question.content.rule.parentId = ""; question.content.rule.parentId = "";
question.content.rule.main = []; question.content.rule.main = [];
@ -491,63 +497,48 @@ export const clearRuleForAll = () => {
}); });
}; };
export const createResult = async (
export const createFrontResult = (quizId: number, parentContentId?: string) => setProducedState(state => { quizId: number,
const frontId = nanoid(); parentContentId?: string
const content = JSON.parse(JSON.stringify(defaultQuestionByType["result"].content));
content.id = frontId;
if (parentContentId) content.rule.parentId = parentContentId;
state.questions.push({
id: frontId,
quizId,
type: "result",
title: "",
description: "",
deleted: false,
expanded: true,
page: 101,
required: true,
content
});
}, {
type: "createFrontResult",
quizId,
});
export const createBackResult = async (
questionId: string,
type: QuestionType,
) => requestQueue.enqueue(async () => { ) => requestQueue.enqueue(async () => {
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId); if (!quizId || !parentContentId) {
if (!question) return; console.error("Нет данных для создания результата. quizId: ", quizId, ", quizId: ", parentContentId)
if (question.type !== "result") throw new Error("Cannot upgrade already typed question");
try {
const createdQuestion = await questionApi.create({
quiz_id: question.quizId,
type,
title: question.title,
description: question.description,
page: 0,
required: true,
content: JSON.stringify(defaultQuestionByType[type].content),
});
setProducedState(state => {
const questionIndex = state.questions.findIndex(q => q.id === questionId);
if (questionIndex !== -1) state.questions.splice(
questionIndex,
1,
rawQuestionToQuestion(createdQuestion)
);
}, {
type: "createBackResult",
question,
});
} catch (error) {
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
} }
//Мы получили запрос на создание резулта. Анализируем существует ли такой. Если да - просто делаем его активным
const question = useQuestionsStore.getState().questions.find(q=> q.type !== null && q?.content.rule.parentContentId === parentContentId)
if (question) {//существует, делаем активным
updateQuestion(question.id, (q) => {
q.content.usage = true
})
} else {//не существует, создаём
const content = JSON.parse(JSON.stringify(defaultQuestionByType["result"].content));
content.rule.parentId = parentContentId;
try {
const createdQuestion:RawQuestion = await questionApi.create({
quiz_id: quizId,
type: "result",
title: "",
description: "",
page: 101,
required: true,
content: JSON.stringify(content),
});
setProducedState(state => {
state.questions.push(rawQuestionToQuestion(createdQuestion))
}, {
type: "createBackResult",
createdQuestion,
});
} catch (error) {
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
}
}
}); });

@ -9,8 +9,9 @@ import { NavigateFunction } from "react-router-dom";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { RequestQueue } from "../../utils/requestQueue"; import { RequestQueue } from "../../utils/requestQueue";
import { QuizStore, useQuizStore } from "./store"; import { QuizStore, useQuizStore } from "./store";
import { createUntypedQuestion } from "@root/questions/actions"; import { createUntypedQuestion, updateQuestion } from "@root/questions/actions";
import { useCurrentQuiz } from "./hooks" import { useCurrentQuiz } from "./hooks"
import { useQuestionsStore } from "@root/questions/store";
export const setEditQuizId = (quizId: number | null) => setProducedState(state => { export const setEditQuizId = (quizId: number | null) => setProducedState(state => {
@ -176,12 +177,40 @@ export const deleteQuiz = async (quizId: string) => requestQueue.enqueue(async (
enqueueSnackbar(`Не удалось удалить квиз. ${message}`); enqueueSnackbar(`Не удалось удалить квиз. ${message}`);
} }
}); });
export const updateRootContentId = (quizId: string, id:string) => updateQuiz( export const updateRootContentId = (quizId: string, id: string) => {
quizId,
quiz => { if (id.length === 0) {//дерева больше не существует, все результаты неактивны кроме результата линейности
quiz.config.haveRoot = id useQuestionsStore.getState().questions.forEach((q) => {
}, if (q.type !== null && q.type === "result") {
); if (q.content.rule.parentId === "line" && q.content.usage === false) {
updateQuestion(q.id, (q) => {
q.content.usage === true
})
} else {
updateQuestion(q.id, (q) => {
q.content.usage === false
})
}
}
})
} else { //было создано дерево, результат линейности неактивен
useQuestionsStore.getState().questions.forEach((q) => {
if (q.type !== null && q.content.rule.parentId === "line") {
updateQuestion(q.id, (q) => {
q.content.usage === false
})
}
})
}
updateQuiz(
quizId,
quiz => {
quiz.config.haveRoot = id
},
);
}
// TODO copy quiz // TODO copy quiz

@ -36,4 +36,5 @@ export const updateOpenedModalSettingsId = (id?: string) => useUiTools.setState(
export const updateCanCreatePublic = (can: boolean) => useUiTools.setState({ canCreatePublic: can }); export const updateCanCreatePublic = (can: boolean) => useUiTools.setState({ canCreatePublic: can });
export const updateModalInfoWhyCantCreate = (can: boolean) => useUiTools.setState({ openModalInfoWhyCantCreate: can }); export const updateModalInfoWhyCantCreate = (can: boolean) => useUiTools.setState({ openModalInfoWhyCantCreate: can });
export const updateDeleteId = (deleteNodeId: string | null = null) => useUiTools.setState({ deleteNodeId });

@ -1,7 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { devtools } from "zustand/middleware"; import { devtools } from "zustand/middleware";
export type UiTools = { export type UiTools = {
openedModalSettingsId: string | null; openedModalSettingsId: string | null;
dragQuestionContentId: string | null; dragQuestionContentId: string | null;
@ -11,6 +10,8 @@ export type UiTools = {
canCreatePublic: boolean; canCreatePublic: boolean;
whyCantCreatePublic: Record<string, WhyCantCreatePublic>//ид вопроса и список претензий к нему whyCantCreatePublic: Record<string, WhyCantCreatePublic>//ид вопроса и список претензий к нему
openModalInfoWhyCantCreate: boolean; openModalInfoWhyCantCreate: boolean;
lastDeletionNodeTime: number | null;
deleteNodeId: string | null;
}; };
export type WhyCantCreatePublic = { export type WhyCantCreatePublic = {
name: string; name: string;
@ -26,16 +27,15 @@ const initialState: UiTools = {
editSomeQuestion: null as null, editSomeQuestion: null as null,
canCreatePublic: false, canCreatePublic: false,
whyCantCreatePublic: {}, whyCantCreatePublic: {},
openModalInfoWhyCantCreate: false openModalInfoWhyCantCreate: false,
lastDeletionNodeTime: null,
deleteNodeId: null,
}; };
export const useUiTools = create<UiTools>()( export const useUiTools = create<UiTools>()(
devtools( devtools(() => initialState, {
() => initialState, name: "UiTools",
{ enabled: process.env.NODE_ENV === "development",
name: "UiTools", trace: process.env.NODE_ENV === "development",
enabled: process.env.NODE_ENV === "development", })
trace: process.env.NODE_ENV === "development",
}
)
); );

@ -9,6 +9,7 @@ interface CustomNumberFieldProps {
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void; onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
error?: string; error?: string;
emptyError?: boolean;
value: string; value: string;
sx?: SxProps<Theme>; sx?: SxProps<Theme>;
min?: number; min?: number;
@ -20,6 +21,7 @@ export default function CustomNumberField({
value, value,
sx, sx,
error, error,
emptyError,
onChange, onChange,
onKeyDown, onKeyDown,
onBlur, onBlur,
@ -57,6 +59,7 @@ export default function CustomNumberField({
placeholder={placeholder} placeholder={placeholder}
sx={sx} sx={sx}
error={error} error={error}
emptyError={emptyError}
onChange={onInputChange} onChange={onInputChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onBlur={onInputBlur} onBlur={onInputBlur}

@ -7,6 +7,7 @@ interface CustomTextFieldProps {
placeholder: string; placeholder: string;
value?: string; value?: string;
error?: string; error?: string;
emptyError?: boolean;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void; onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void; onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onBlur?: (event: FocusEvent<HTMLInputElement>) => void; onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
@ -25,6 +26,7 @@ export default function CustomTextField({
text, text,
sx, sx,
error, error,
emptyError,
InputProps, InputProps,
maxLength = 200, maxLength = 200,
}: CustomTextFieldProps) { }: CustomTextFieldProps) {
@ -62,7 +64,7 @@ export default function CustomTextField({
value={inputValue} value={inputValue}
placeholder={placeholder} placeholder={placeholder}
onChange={handleInputChange} onChange={handleInputChange}
error={!!error} error={!!error || emptyError}
label={error} label={error}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onBlur={handleInputBlur} onBlur={handleInputBlur}

@ -0,0 +1,17 @@
import { useSnackbar } from "notistack";
import { addQuestionVariant } from "@root/questions/actions";
import { QuizQuestionsWithVariants } from "@model/questionTypes/shared";
export const useAddAnswer = () => {
const { enqueueSnackbar } = useSnackbar();
const onClickAddAnAnswer = (question: QuizQuestionsWithVariants) => {
if (question.content.variants.length >= 10) {
enqueueSnackbar("100 максимальное количество вопросов");
} else {
addQuestionVariant(question.id);
}
};
return onClickAddAnAnswer;
};