Merge branch 'branching' into style2

This commit is contained in:
Nastya 2023-12-02 18:57:32 +03:00
commit 91bd0ffe14
73 changed files with 21474 additions and 556 deletions

3
.vscode/settings.json vendored Normal file

@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

18910
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

@ -13,6 +13,7 @@
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^13.2.1",
"@types/cytoscape": "^3.19.16",
"@types/file-saver": "^2.0.5",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
@ -21,6 +22,7 @@
"@types/react-dom": "^18.0.0",
"axios": "^1.5.1",
"cytoscape": "^3.26.0",
"cytoscape-popper": "^2.0.0",
"dayjs": "^1.11.10",
"emoji-mart": "^5.5.2",
"file-saver": "^2.0.5",
@ -44,7 +46,7 @@
"react-router-dom": "^6.6.2",
"react-scripts": "5.0.1",
"swr": "^2.2.4",
"typescript": "^4.4.2",
"typescript": "^5.2.2",
"use-debounce": "^9.0.4",
"web-vitals": "^2.1.0",
"yup": "^1.3.2",
@ -57,12 +59,6 @@
"eject": "craco eject",
"cypress:open": "cypress open"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
@ -78,6 +74,7 @@
"devDependencies": {
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@types/cytoscape-popper": "^2.0.4",
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-cytoscapejs": "^1.2.4",
"@types/react-datepicker": "^4.19.3",

@ -30,6 +30,7 @@ async function getQuestionList(body?: Partial<GetQuestionListRequest>) {
}
function editQuestion(body: EditQuestionRequest, signal?: AbortSignal) {
console.log("`${baseUrl}/question/edit` start")
return makeRequest<EditQuestionRequest, EditQuestionResponse>({
url: `${baseUrl}/question/edit`,
body,

@ -69,7 +69,7 @@ function addQuizImages(quizId: number, image: Blob) {
formData.append("quiz", quizId.toString());
formData.append("image", image);
return makeRequest<FormData, never>({
return makeRequest<FormData, { [key: string]: string; }>({
url: `${imagesUrl}/quiz/putImages`,
body: formData,
method: "PUT",
@ -93,7 +93,7 @@ const defaultCreateQuizBody: CreateQuizRequest = {
"note_prevented": true,
"mail_notifications": false,
"unique_answers": true,
"name": "Название квиза",
"name": "",
"description": "",
"config": JSON.stringify(defaultQuizConfig),
"status": "stop",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 KiB

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 -2.62268e-07C21.3137 -1.17422e-07 24 2.68629 24 6L24 18C24 21.3137 21.3137 24 18 24L6 24C2.68629 24 -9.31652e-07 21.3137 -7.86805e-07 18L-5.24537e-07 12L-2.62268e-07 6C-1.17422e-07 2.68629 2.68629 -9.31652e-07 6 -7.86805e-07L18 -2.62268e-07Z" fill="#9A9AAF" fill-opacity="0.7"/>
<path d="M7 11.5L11.2857 15.5L17 8" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 495 B

@ -1,4 +1,4 @@
import type { QuizQuestionBase } from "../model/questionTypes/shared";
import type { QuizQuestionBase, QuestionBranchingRuleMain } from "../model/questionTypes/shared";
export const QUIZ_QUESTION_BASE: Omit<QuizQuestionBase, "id" | "backendId"> = {
@ -12,14 +12,16 @@ export const QUIZ_QUESTION_BASE: Omit<QuizQuestionBase, "id" | "backendId"> = {
deleted: false,
deleteTimeoutId: 0,
content: {
id: "",
hint: {
text: "",
video: "",
},
rule: {
default: "",
main: [],
},
main: [] as QuestionBranchingRuleMain[],
parentId: "",
default: ""
},
back: "",
originalBack: "",
autofill: false,

@ -46,16 +46,18 @@ export interface RawQuestion {
export function rawQuestionToQuestion(rawQuestion: RawQuestion): AnyTypedQuizQuestion {
let content = defaultQuestionByType[rawQuestion.type].content;
const frontId = nanoid()
try {
content = JSON.parse(rawQuestion.content);
if (content.id.length === 0 || content.id.length === undefined) content.id = frontId
} catch (error) {
console.warn("Cannot parse question content from string, using default content", error);
}
return {
backendId: rawQuestion.id,
id: nanoid(),
id: frontId,
description: rawQuestion.description,
page: rawQuestion.page,
quizId: rawQuestion.quiz_id,

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionDate extends QuizQuestionBase {
type: "date";
content: {
id: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Чекбокс "Внутреннее название вопроса" */

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionEmoji extends QuizQuestionBase {
type: "emoji";
content: {
id: string;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Чекбокс "Вариант "свой ответ"" */

@ -17,6 +17,7 @@ export type UploadFileType = keyof typeof UPLOAD_FILE_TYPES_MAP;
export interface QuizQuestionFile extends QuizQuestionBase {
type: "file";
content: {
id: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Чекбокс "Внутреннее название вопроса" */

@ -6,31 +6,32 @@ import type {
} from "./shared";
export interface QuizQuestionImages extends QuizQuestionBase {
type: "images";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Пропорции */
xy: "1:1" | "1:2" | "2:1";
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Большие картинки" */
large: boolean;
/** Форма */
format: "carousel" | "masonry";
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Варианты (картинки) */
variants: QuestionVariant[];
hint: QuestionHint;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
};
type: "images";
content: {
id: string;
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Пропорции */
xy: "1:1" | "1:2" | "2:1";
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Большие картинки" */
large: boolean;
/** Форма */
format: "carousel" | "masonry";
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Варианты (картинки) */
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
};
}

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionNumber extends QuizQuestionBase {
type: "number";
content: {
id: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Чекбокс "Внутреннее название вопроса" */

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionPage extends QuizQuestionBase {
type: "page";
content: {
id: string;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionRating extends QuizQuestionBase {
type: "rating";
content: {
id: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
/** Чекбокс "Внутреннее название вопроса" */

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionSelect extends QuizQuestionBase {
type: "select";
content: {
id: string;
/** Чекбокс "Можно несколько" */
multi: boolean;
/** Чекбокс "Необязательный вопрос" */

@ -12,104 +12,107 @@ import type { QuizQuestionVariant } from "./variant";
import type { QuizQuestionVarImg } from "./varimg";
import { nanoid } from "nanoid";
export type Rule = {
/* question id */
question: string;
/* Ответы на вопросы. Для вариантов выбора - конкретные айдишники ответов, для полей ввода текста - текст по полному совпадению, для ввода файла - просто факт того что файл ввели, т.е. boolean */
answers: (number | string | boolean)[];
};
export interface QuestionBranchingRuleMain {
next: string;
or: boolean;
rules: {
question: string; //id родителя (пока что)
answers: string[]
}[]
}
export interface QuestionBranchingRule {
export type PreviewRuleInfo = {
/* Id следующего вопроса */
next: string;
/* Радиокнопка "Все условия обязательны" */
or: boolean;
rules: Rule[];
};
export interface PreviewRule {
default: string;
main: PreviewRuleInfo[];
//список условий
main: QuestionBranchingRuleMain[];
parentId: string | null | "root";
default: string;
}
export interface QuestionHint {
/** Текст подсказки */
text: string;
/** URL видео подсказки */
video: string;
/** Текст подсказки */
text: string;
/** URL видео подсказки */
video: string;
}
export type QuestionVariant = {
id: string;
/** Текст */
answer: string;
/** Текст подсказки */
hints: string;
/** Дополнительное поле для текста, emoji, ссылки на картинку */
extendedText: string;
/** Оригинал изображения (до кропа) */
originalImageUrl: string;
id: string;
/** Текст */
answer: string;
/** Текст подсказки */
hints: string;
/** Дополнительное поле для текста, emoji, ссылки на картинку */
extendedText: string;
/** Оригинал изображения (до кропа) */
originalImageUrl: string;
};
export interface QuizQuestionBase {
backendId: number;
/** Stable id, generated on client */
id: string;
quizId: number;
title: string;
description: string;
page: number;
type?: QuestionType | null;
expanded: boolean;
openedModalSettings: boolean;
required: boolean;
deleted: boolean;
deleteTimeoutId: number;
content: {
hint: QuestionHint;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;
};
backendId: number;
/** Stable id, generated on client */
id: string;
quizId: number;
title: string;
description: string;
page: number;
type?: QuestionType | null;
expanded: boolean;
openedModalSettings: boolean;
required: boolean;
deleted: boolean;
deleteTimeoutId: number;
content: {
id: string;
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
};
}
export interface UntypedQuizQuestion {
type: null;
id: string;
quizId: number;
title: string;
description: string;
expanded: boolean;
deleted: boolean;
type: null;
id: string;
quizId: number;
title: string;
description: string;
expanded: boolean;
deleted: boolean;
}
export type AnyTypedQuizQuestion =
| QuizQuestionVariant
| QuizQuestionImages
| QuizQuestionVarImg
| QuizQuestionEmoji
| QuizQuestionText
| QuizQuestionSelect
| QuizQuestionDate
| QuizQuestionNumber
| QuizQuestionFile
| QuizQuestionPage
| QuizQuestionRating;
| QuizQuestionVariant
| QuizQuestionImages
| QuizQuestionVarImg
| QuizQuestionEmoji
| QuizQuestionText
| QuizQuestionSelect
| QuizQuestionDate
| QuizQuestionNumber
| QuizQuestionFile
| QuizQuestionPage
| QuizQuestionRating;
type FilterQuestionsWithVariants<T> = T extends {
content: { variants: QuestionVariant[] };
}
? T
: never;
content: { variants: QuestionVariant[]; };
} ? T : never;
export type QuizQuestionsWithVariants =
FilterQuestionsWithVariants<AnyTypedQuizQuestion>;
export type QuizQuestionsWithVariants = FilterQuestionsWithVariants<AnyTypedQuizQuestion>;
export const createBranchingRuleMain: (targetId:string, parentId:string) => QuestionBranchingRuleMain = (targetId, parentId) => ({
next: targetId,
or: false,
rules: [{
question: parentId,
answers: [] as string[],
}]
})
export const createQuestionVariant: () => QuestionVariant = () => ({
id: nanoid(),
answer: "",
extendedText: "",
hints: "",
originalImageUrl: "",
});
id: nanoid(),
answer: "",
extendedText: "",
hints: "",
originalImageUrl: "",
});

@ -7,6 +7,7 @@ import type {
export interface QuizQuestionText extends QuizQuestionBase {
type: "text";
content: {
id: string;
placeholder: string;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;

@ -8,6 +8,7 @@ import type {
export interface QuizQuestionVariant extends QuizQuestionBase {
type: "variant";
content: {
id: string;
/** Чекбокс "Длинный текстовый ответ" */
largeCheck: boolean;
/** Чекбокс "Можно несколько" */

@ -6,23 +6,24 @@ import type {
} from "./shared";
export interface QuizQuestionVarImg extends QuizQuestionBase {
type: "varimg";
content: {
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
variants: QuestionVariant[];
hint: QuestionHint;
rule: PreviewRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
replText: string;
};
type: "varimg";
content: {
id: string;
/** Чекбокс "Вариант "свой ответ"" */
own: boolean;
/** Чекбокс "Внутреннее название вопроса" */
innerNameCheck: boolean;
/** Поле "Внутреннее название вопроса" */
innerName: string;
/** Чекбокс "Необязательный вопрос" */
required: boolean;
variants: QuestionVariant[];
hint: QuestionHint;
rule: QuestionBranchingRule;
back: string;
originalBack: string;
autofill: boolean;
largeCheck: boolean;
replText: string;
};
}

@ -29,19 +29,20 @@ export type QuizResultsType = true | null;
export interface QuizConfig {
type: QuizType;
logo: string;
logo: string | null;
noStartPage: boolean;
startpageType: QuizStartpageType;
results: QuizResultsType;
haveRoot: boolean;
startpage: {
description: string;
button: string;
position: QuizStartpageAlignType;
background: {
type: null | "image" | "video";
desktop: string;
mobile: string;
video: string;
desktop: string | null;
mobile: string | null;
video: string | null;
cycle: boolean;
};
};
@ -57,19 +58,20 @@ export interface QuizConfig {
export const defaultQuizConfig: QuizConfig = {
type: null,
logo: "",
logo: null,
noStartPage: false,
startpageType: null,
results: null,
haveRoot: false,
startpage: {
description: "",
button: "",
position: "left",
background: {
type: null,
desktop: "https://happypik.ru/wp-content/uploads/2019/09/njashnye-kotiki8.jpg",
mobile: "https://krot.info/uploads/posts/2022-03/1646156155_3-krot-info-p-smeshnie-tolstie-koti-smeshnie-foto-3.png",
video: "https://youtu.be/dbaPkCiLPKQ",
desktop: null,
mobile: null,
video: null,
cycle: false,
},
},

@ -0,0 +1,611 @@
import { useEffect, useRef, useState } from "react";
import Cytoscape from "cytoscape";
import CytoscapeComponent from "react-cytoscapejs";
import popper from "cytoscape-popper";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { updateRootInfo } from "@root/quizes/actions"
import { AnyQuizQuestion } from "@model/questionTypes/shared"
import { useQuestionsStore } from "@root/questions/store";
import { cleardragQuestionContentId, getQuestionById, updateQuestion, updateOpenedModalSettingsId, getQuestionByContentId } from "@root/questions/actions";
import { storeToNodes } from "./helper";
import "./styles.css";
import type {
Stylesheet,
Core,
NodeSingular,
AbstractEventObject,
ElementDefinition,
} from "cytoscape";
import { QuestionsList } from "../BranchingPanel/QuestionsList";
import { enqueueSnackbar } from "notistack";
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: ".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);
interface Props {
modalQuestionParentContentId: string;
modalQuestionTargetContentId: string;
setOpenedModalQuestions: (open: boolean) => void;
setModalQuestionParentContentId: (id: string) => void;
setModalQuestionTargetContentId: (id: string) => void;
}
export const CsComponent = ({
modalQuestionParentContentId,
modalQuestionTargetContentId,
setOpenedModalQuestions,
setModalQuestionParentContentId,
setModalQuestionTargetContentId
}: Props) => {
const quiz = useCurrentQuiz();
const { dragQuestionContentId, questions } = useQuestionsStore()
const [startCreate, setStartCreate] = useState("");
const [startRemove, setStartRemove] = useState("");
const cyRef = useRef<Core | null>(null);
const layoutsContainer = useRef<HTMLDivElement | null>(null);
const plusesContainer = useRef<HTMLDivElement | null>(null);
const crossesContainer = useRef<HTMLDivElement | null>(null);
const gearsContainer = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (modalQuestionTargetContentId.length !== 0 && modalQuestionParentContentId.length !== 0) {
console.log("был выбран вопрос " + modalQuestionTargetContentId)
}
}, [modalQuestionTargetContentId])
const addNode = ({ parentNodeContentId }: { parentNodeContentId: string }) => {
console.log("dragQuestionContentId " + dragQuestionContentId)
const cy = cyRef?.current
const parentNodeChildren = cy?.$('edge[source = "' + parentNodeContentId + '"]')?.length
const targetQuestion = { ...getQuestionByContentId(dragQuestionContentId) } as AnyQuizQuestion
if (Object.keys(targetQuestion).length !== 0 && Object.keys(targetQuestion).length !== 0 && parentNodeContentId && parentNodeChildren !== undefined) {
clearDataAfterAddNode({ parentNodeContentId, targetQuestion, parentNodeChildren })
cy?.add([
{
data: {
id: targetQuestion.content.id,
label: targetQuestion.title || "noname"
}
},
{
data: {
source: parentNodeContentId,
target: targetQuestion.content.id
}
}
]).layout(lyopts).run()
} else {
enqueueSnackbar("Добавляемый вопрос не найден")
}
}
const clearDataAfterAddNode = ({ parentNodeContentId, targetQuestion, parentNodeChildren }: { parentNodeContentId: string, targetQuestion: AnyQuizQuestion, parentNodeChildren: number }) => {
console.log("записываю на бек ид родителя")
console.log({ parentNodeContentId, targetQuestion, parentNodeChildren })
//предупреждаем добавленный вопрос о том, кто его родитель
updateQuestion(targetQuestion.content.id, question => {
question.content.rule.parentId = parentNodeContentId
question.content.rule.main = []
})
//Если детей больше 1 - предупреждаем стор вопросов об открытии модалки ветвления
if (parentNodeChildren > 1) {
updateOpenedModalSettingsId(parentNodeContentId)
} else {
//Если ребёнок первый - добавляем его родителю как дефолтный
updateQuestion(parentNodeContentId, question => question.content.rule.default = targetQuestion.content.id)
}
}
const removeNode = ({ targetNodeContentId }: { targetNodeContentId: string }) => {
console.log("remove")
const cy = cyRef?.current
const a = getQuestionByContentId(targetNodeContentId)
console.log(a)
console.log(a.content)
console.log(a.content.rule.parentId)
console.log(a.content.rule.parentId === "root")
console.log(targetNodeContentId)
if (a.content.rule.parentId === "root" && quiz) {
console.log("click ROOT")
updateQuestion(targetNodeContentId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
updateRootInfo(quiz?.id, false)
} else {
console.log("click not ROOT")
const parentQuestionContentId = cy?.$('edge[target = "' + targetNodeContentId + '"]')?.toArray()?.[0]?.data()?.source
if (targetNodeContentId && parentQuestionContentId) {
clearDataAfterRemoveNode({ targetQuestionContentId: targetNodeContentId, parentQuestionContentId })
cy?.remove(cy?.$('#' + targetNodeContentId)).layout(lyopts).run()
}
}
}
const clearDataAfterRemoveNode = ({ targetQuestionContentId, parentQuestionContentId }: { targetQuestionContentId: string, parentQuestionContentId: string }) => {
console.log({ targetQuestionContentId, parentQuestionContentId })
updateQuestion(targetQuestionContentId, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
updateQuestion(parentQuestionContentId, question => {
if (question.content.rule.parentId === parentQuestionContentId) question.content.rule.parentId = ""
})
}
useEffect(() => {
if (startCreate) {
addNode({ parentNodeContentId: startCreate });
cleardragQuestionContentId()
setStartCreate("");
}
}, [startCreate]);
useEffect(() => {
if (startRemove) {
removeNode({ targetNodeContentId: startRemove });
setStartRemove("");
}
}, [startRemove]);
const readyLO = (e) => {
//удаляем иконки
e.cy.nodes().forEach((ele: any) => {
const data = ele.data()
data.id && removeButtons(data.id);
})
initialPopperIcons(e)
}
const lyopts = {
name: 'preset',
positions: (e) => {
console.log('POSITIIIIIIIONS')
const id = e.id()
const incomming = e.cy().edges(`[target="${id}"]`)
const layer = 0
e.removeData('lastChild')
if (incomming.length === 0) {
const children = e.cy().edges(`[source="${id}"]`)
e.data('layer', layer)
e.data('children', children.targets().length)
const queue = []
children.forEach(n => {
queue.push({ task: n.target(), layer: layer + 1 })
})
while (queue.length) {
const task = queue.pop()
task.task.data('layer', task.layer)
const children = e.cy().edges(`[source="${task.task.id()}"]`)
task.task.data('children', children.targets().length)
if (children.length !== 0) {
children.forEach(n => queue.push({ task: n.target(), layer: task.layer + 1 }))
}
}
queue.push({ parent: e, children: children.targets() })
while (queue.length) {
const task = queue.pop()
if (task.children.length === 0) {
task.parent.data('subtreeWidth', 0)
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))
}
return { x: 200 * e.data('layer'), y: 0 }
} else {
const parent = e.cy().edges(`[target="${e.id()}"]`)[0].source()
const wing = parent.data('subtreeWidth') / 2
const lastOffset = parent.data('lastChild')
const step = wing * 2 / (parent.data('children') - 1)
//e.removeData('subtreeWidth')
if (lastOffset !== undefined) {
parent.data('lastChild', lastOffset + step)
return { x: 200 * e.data('layer'), y: (lastOffset + step) }
} else {
parent.data('lastChild', parent.position().y - wing)
return { x: 200 * e.data('layer'), y: (parent.position().y - wing) }
}
}
}, // 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 true; }, // 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(() => {
document.querySelector("#root")?.addEventListener("mouseup", cleardragQuestionContentId);
const cy = cyRef.current;
const eles = cy?.add(storeToNodes(questions))
console.log('PETTY', storeToNodes(questions), eles.length)
const elecs = eles.layout(lyopts).run()
cy?.fit()
//cy?.layout().run()
return () => {
document.querySelector("#root")?.removeEventListener("mouseup", cleardragQuestionContentId);
layoutsContainer.current?.remove();
plusesContainer.current?.remove();
crossesContainer.current?.remove();
gearsContainer.current?.remove();
};
}, []);
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.x1 > ext.x1 && bb.x2 < ext.x2 && bb.y1 > ext.y1 && bb.y2 < 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", () => {
alert("layout")
}
// setStartCreate(node.id())
);
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;
},
});
const gearsPopper = node.popper({
popper: {
placement: "left",
modifiers: [{ name: "flip", options: { boundary: node } }],
},
content: ([item]) => {
const itemId = item.id();
if (item.data().lastChild === NaN || item.data().lastChild === undefined) {
return;
}
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) => {
console.log("шестерня")
// setOpenedModalSettings(
// findQuestionById(quizId, node.id().split(" ").pop() || "").index
// );
});
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] } },
],
});
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);
cy?.on("pan zoom resize render", onZoom);
});
};
return (
<CytoscapeComponent
wheelSensitivity={0.1}
elements={[]}
// elements={createGraphElements(tree, quiz)}
style={{ height: "480px", background: "#F2F3F7" }}
stylesheet={stylesheet}
layout={(lyopts)}
cy={(cy) => {
cyRef.current = cy;
}}
/>
);
};

@ -0,0 +1,73 @@
import { Box } from "@mui/material"
import { useEffect, useRef, useState } from "react";
import { updateDragQuestionContentId, updateQuestion } from "@root/questions/actions"
import { updateRootInfo } from "@root/quizes/actions"
import { useQuestionsStore } from "@root/questions/store"
import { useCurrentQuiz } from "@root/quizes/hooks";
import { enqueueSnackbar } from "notistack";
interface Props {
setOpenedModalQuestions: (open: boolean) => void;
modalQuestionTargetContentId: string;
}
export const FirstNodeField = ({ setOpenedModalQuestions, modalQuestionTargetContentId }: Props) => {
const { dragQuestionContentId } = useQuestionsStore()
const Container = useRef<HTMLDivElement | null>(null);
const quiz = useCurrentQuiz();
console.log(dragQuestionContentId)
const modalOpen = () => setOpenedModalQuestions(true)
const newRootNode = () => {
if (quiz) {
console.log(dragQuestionContentId)
if (dragQuestionContentId) {
updateRootInfo(quiz?.id, true)
updateQuestion(dragQuestionContentId, (question) => question.content.rule.parentId = "root")
}
} else {
enqueueSnackbar("Нет информации о взятом опроснике")
}
}
useEffect(() => {
Container.current?.addEventListener("mouseup", newRootNode)
Container.current?.addEventListener("click", modalOpen)
return () => {
Container.current?.removeEventListener("mouseup", newRootNode)
Container.current?.removeEventListener("click", modalOpen)
}
}, [dragQuestionContentId])
useEffect(() => {
if (quiz) {
if (modalQuestionTargetContentId) {
updateRootInfo(quiz?.id, true)
updateQuestion(modalQuestionTargetContentId, (question) => question.content.rule.parentId = "root")
}
} else {
enqueueSnackbar("Нет информации о взятом опроснике")
}
}, [modalQuestionTargetContentId])
return (
<Box
ref={Container}
sx={{
height: "100%",
width: "100%",
backgroundColor: "#f2f3f7",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#4d4d4d",
fontSize: "50px"
}}
>
+
</Box>
)
}

@ -0,0 +1,43 @@
import { AnyQuizQuestion } from "@model/questionTypes/shared"
interface Nodes {
data: {
id: string;
label: string;
parent?: string;
}
}
interface Edges {
data: {
source: string;
target: string;
}
}
export const storeToNodes = (questions: AnyQuizQuestion[]) => {
console.log(questions)
const nodes: Nodes[] = []
const edges: Edges[] = []
questions.forEach((question) => {
console.log(question)
if (question.content.rule.parentId) {
nodes.push({data: {
id: question.content.id,
label: question.title ? question.title : "noname"
}})
// nodes.push({
// data: {
// id: "delete" + question.content.id,
// label: "X",
// parent: question.content.id,
// }
// },)
if (question.content.rule.parentId !== "root") edges.push({data: {
source: question.content.rule.parentId,
target: question.content.id
}})
}
})
console.log([...nodes, ...edges])
return [...nodes, ...edges];
}

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

@ -0,0 +1,45 @@
#popper-pluses > .popper-plus {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: #eeeff4;
border: 1.5px dashed rgba(154, 154, 175, 0.5);
font-size: 0px;
}
#popper-pluses > .popper-plus::before {
content: "+";
color: rgba(154, 154, 175, 0.5);
font-size: inherit;
}
#popper-crosses > .popper-cross {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background: rgba(154, 154, 175, 0.7);
font-size: 0px;
}
#popper-crosses > .popper-cross::before {
content: "+";
transform: rotate(45deg);
color: #fff;
font-size: inherit;
}
#popper-gears > .popper-gear {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
background-image: url("../../../assets/icons/ArrowGear.svg");
font-size: 0px;
background-repeat: no-repeat;
background-size: contain;
}

@ -0,0 +1,178 @@
import { useState, useRef, useEffect } from "react";
import {
Box,
Button,
FormControl,
FormControlLabel,
Link,
Modal,
Radio,
RadioGroup,
Tooltip,
Typography,
useTheme,
Checkbox,
} from "@mui/material";
import { AnyQuizQuestion, 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 { TypeSwitch, BlockRule } from "./Settings";
import { getQuestionById, updateOpenedModalSettingsId, updateQuestion } from "@root/questions/actions";
import { useQuestionsStore } from "@root/questions/store";
import { enqueueSnackbar } from "notistack";
export default function BranchingQuestions() {
const theme = useTheme();
const { openedModalSettingsId } = useQuestionsStore();
const [targetQuestion, setTargetQuestion] = useState<AnyQuizQuestion | null>(getQuestionById(openedModalSettingsId))
const [parentQuestion, setParentQuestion] = useState<AnyQuizQuestion | null>(getQuestionById(openedModalSettingsId))
if (targetQuestion === null || parentQuestion === null) {
enqueueSnackbar("Невозможно найти данные ветвления для этого вопроса")
return <></>
}
const saveData = () => {
}
const handleClose = () => {
updateOpenedModalSettingsId()
};
return (
<>
<Modal open={openedModalSettingsId !== null} 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={{
height: "400px",
overflow: "auto"
}}
>
{
parentQuestion.content.rule.main.length ?
parentQuestion.content.rule.main.map((e: any, i: number) => {
if (e.next === targetQuestion.id) {
return <TypeSwitch key={i} targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={i} />
} else {
<></>
}
})
:
<TypeSwitch targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={0} />
}
</Box>
<Box
sx={{
margin: "20px 0 0 20px",
display: "flex",
flexDirection: "column"
}}
>
<Link
variant="body2"
sx={{
color: theme.palette.brightPurple.main,
marginBottom: "10px",
cursor: "pointer"
}}
onClick={() => {
const mutate = parentQuestion
mutate.content.rule.main.push(createBranchingRuleMain(targetQuestion.id, parentQuestion.id))
setParentQuestion(mutate)
}}
>
Добавить условие
</Link>
<FormControlLabel control={<Checkbox
sx={{
margin: 0
}}
checked={parentQuestion.content.rule.default === targetQuestion.id}
onClick={() => {
const mutate = parentQuestion
mutate.content.rule.default = targetQuestion.id
setParentQuestion(mutate)
}}
/>} label="Следующий вопрос по-умолчанию" />
</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={handleClose}
>
Готово
</Button>
</Box>
</>
</Box>
</Modal>
</>
);
}

@ -0,0 +1,516 @@
import { Box, MenuItem, FormControl, Checkbox, FormControlLabel, Radio, RadioGroup, Typography, useTheme, Select, Chip, IconButton, TextField } from "@mui/material"
import RadioCheck from "@ui_kit/RadioCheck"
import RadioIcon from "@ui_kit/RadioIcon"
import { QuizQuestionBase } from "model/questionTypes/shared"
import { useState, useRef, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useQuestionsStore } from "@root/questions/store";
import { updateQuestion, getQuestionById } from "@root/questions/actions";
import { AnyQuizQuestion } from "../../../model/questionTypes/shared"
import { SelectChangeEvent } from '@mui/material/Select';
import InfoIcon from "@icons/Info";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
const CONDITIONS = [
"Все условия обязательны",
"Обязательно хотя бы одно условие",
];
interface Props {
parentQuestion: AnyQuizQuestion;
targetQuestion: AnyQuizQuestion;
ruleIndex: number;
}
//Этот компонент вызывается 1 раз на каждое условие родителя для перехода к этому вопросу. Поэтому для изменения стора мы знаем индекс
export const TypeSwitch = ({ parentQuestion, targetQuestion, ruleIndex }: Props) => {
switch (parentQuestion.type) {
// case 'nonselected':
// return <BlockRule text={"Не выбран тип родительского вопроса"} />
// break;
case "variant":
case "images":
case "varimg":
case "emoji":
case "select":
return (parentQuestion.content.variants === undefined ? <BlockRule text={"У родителя нет вариантов"} /> :
<SelectorType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
//Реализован
)
break;
case "date":
// return <DateInputsType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
return <></>
break;
case "number":
return <NumberInputsType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
//Реализован
break;
case "page":
case "date":
return <BlockRule text={"У такого родителя может быть только один потомок"} />
break;
case "text":
return <TextInputsType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
//Реализован
break;
case "file":
return <FileInputsType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
//Реализован
break;
case "rating":
return <RatingInputsType targetQuestion={targetQuestion} parentQuestion={parentQuestion} ruleIndex={ruleIndex} />
//Реализован
break;
default:
return <BlockRule text={"Не распознан тип родительского вопроса"} />
break;
}
}
export const BlockRule = ({ text }: { text: string }) => {
return (
<Typography
sx={{
margin: "100px 0",
textAlign: "center"
}}
>{text}</Typography>
)
}
const SelectorType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
const theme = useTheme();
const quizId = Number(useParams().quizId);
return (
<Box
sx={{
padding: "20px",
margin: "20px",
borderRadius: "8px",
bgcolor: "#F2F3F7",
height: "280px",
overflow: "auto"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Новое условие
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main.splice(ruleIndex, 1)
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Дан ответ
</Typography>
<Typography sx={{ color: "#7E2AEA", pl: "10px" }}>
(Укажите один или несколько вариантов)
</Typography>
</Box>
<Select
multiple
value={parentQuestion.content.rule.main[ruleIndex].rules[0].answers}
onChange={(event: SelectChangeEvent) => {
const newParentQuestion = { ...parentQuestion }
parentQuestion.content.rule.main[ruleIndex].rules[0].answers = (event.target as HTMLSelectElement).value
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
sx={{
width: "100%",
height: "48px",
borderRadius: "8px",
"& .MuiOutlinedInput-notchedOutline": {
border: `1px solid ${theme.palette.brightPurple.main} !important`,
height: "48px",
borderRadius: "10px",
},
}}
>
{parentQuestion.content.variants.map((e: any) => {
return <MenuItem value={e.id}>
{e.answer}
</MenuItem>
})}
</Select>
<FormControl>
<RadioGroup
aria-labelledby="demo-controlled-radio-buttons-group"
value={parentQuestion.content.rule.main[ruleIndex].or}
onChange={(_, value) => {
const newParentQuestion = { ...parentQuestion }
parentQuestion.content.rule.main[ruleIndex].or = value
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
{CONDITIONS.map((condition, totalIndex) => (
<FormControlLabel
key={totalIndex}
sx={{ color: theme.palette.grey2.main }}
value={Boolean(Number(totalIndex))}
control={
<Radio
checkedIcon={<RadioCheck />}
icon={<RadioIcon />}
/>
}
label={condition}
/>
))}
</RadioGroup>
</FormControl>
</Box >
)
}
const DateInputsType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
return <></>
}
const NumberInputsType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
const theme = useTheme();
const quizId = Number(useParams().quizId);
return (
<Box
sx={{
padding: "20px",
margin: "20px",
borderRadius: "8px",
bgcolor: "#F2F3F7",
height: "280px",
overflow: "auto"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Новое условие
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main.splice(ruleIndex, 1)
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Дан ответ
</Typography>
<Typography sx={{ color: "#7E2AEA", pl: "10px" }}>
(Укажите один или несколько вариантов)
</Typography>
</Box>
{/* <TextField
sx={{
marginTop: "20px",
width: "100%"
}}
value={parentQuestion.content.rule.main[ruleIndex].rules[0].answers[0]}
onChange={(event: React.FormEvent<HTMLInputElement>) => {
const newParentQuestion = { ...parentQuestion }
parentQuestion.content.rule.main[ruleIndex].rules[0].answers = [(event.target as HTMLInputElement).value.replace(/[^0-9,\s]/g, "")]
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
/> */}
</Box >
)
}
const TextInputsType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
const theme = useTheme();
const quizId = Number(useParams().quizId);
return (
<Box
sx={{
padding: "20px",
margin: "20px",
borderRadius: "8px",
bgcolor: "#F2F3F7",
height: "280px",
overflow: "auto"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Новое условие
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main.splice(ruleIndex, 1)
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Box
sx={{
display: "inline",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Дан ответ
</Typography>
<Typography sx={{ color: "#7E2AEA", pl: "10px", fontSize: "12px" }}>
(Укажите текст, при совпадении с которым пользователь попадёт на этот вопрос)
</Typography>
</Box>
{/* <TextField
sx={{
marginTop: "20px",
width: "100%"
}}
value={parentQuestion.content.rule.main[ruleIndex].rules[0].answers[0]}
onChange={(event: React.FormEvent<HTMLInputElement>) => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main[ruleIndex].rules[0].answers = [(event.target as HTMLInputElement).value]
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
/> */}
</Box >
)
}
const FileInputsType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
const theme = useTheme();
const quizId = Number(useParams().quizId);
return (
<Box
sx={{
padding: "20px",
margin: "20px",
borderRadius: "8px",
bgcolor: "#F2F3F7",
height: "280px",
overflow: "auto"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Новое условие
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main.splice(ruleIndex, 1)
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Box
sx={{
display: "inline",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Перевести на этот вопрос если пользователь загрузил файл
</Typography>
</Box>
<FormControlLabel control={<Checkbox
sx={{
margin: 0
}}
checked={parentQuestion.content.rule.main[ruleIndex].rules[0].answers[0]}
onChange={(event: React.FormEvent<HTMLInputElement>) => {
const newParentQuestion = { ...parentQuestion }
parentQuestion.content.rule.main[ruleIndex].rules[0].answers = [(event.target as HTMLInputElement).checked]
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
/>} label="да" />
</Box >
)
}
const RatingInputsType = ({ parentQuestion, targetQuestion, ruleIndex }: { parentQuestion: any, targetQuestion: any, ruleIndex: number }) => {
const theme = useTheme();
const quizId = Number(useParams().quizId);
return (
<Box
sx={{
padding: "20px",
margin: "20px",
borderRadius: "8px",
bgcolor: "#F2F3F7",
height: "280px",
overflow: "auto"
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
pb: "5px",
}}
>
<Typography sx={{ color: "#4D4D4D", fontWeight: "500" }}>
Новое условие
</Typography>
<IconButton
sx={{ borderRadius: "6px", padding: "2px" }}
onClick={() => {
const newParentQuestion = { ...parentQuestion }
newParentQuestion.content.rule.main.splice(ruleIndex, 1)
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
>
<DeleteIcon color={"#4D4D4D"} />
</IconButton>
</Box>
<Box
sx={{
display: "inline",
alignItems: "center",
pb: "10px",
}}
>
<Typography sx={{ color: "#7E2AEA", pl: "10px", fontSize: "12px" }}>
(Ожидаемое количество ячеек)
</Typography>
</Box>
{/* <TextField
sx={{
marginTop: "20px",
width: "100%"
}}
value={parentQuestion.content.rule.main[ruleIndex].rules[0].answers[0]}
onChange={(event: React.FormEvent<HTMLInputElement>) => {
const newParentQuestion = { ...parentQuestion }
parentQuestion.content.rule.main[ruleIndex].rules[0].answers = [(event.target as HTMLInputElement).value.replace(/[^0-9,\s]/g, "")]
//updateQuestionsList(quizId, parentQuestion.index, parentQuestion);
}}
/> */}
</Box >
)
}
// {
// content: {
// rule: {
// main: [
// {
// next: "id of next question", // 2 string
// or: true // 3 boolean
// rules: [
// {
// question: "question id", string
// answers: [] // ответы на вопросы. для вариантов выбора - конкретные айдишники, для полей ввода текста - текст по полному совпадению, для ввода файла ии ещё какой подобной дичи - просто факт того что файл ввели, т.е. boolean
// }
// ]
// }
// ],
// default: ID string
// }
// }
// }

@ -0,0 +1,89 @@
import { useParams } from "react-router-dom";
import { Box, Button, Typography } from "@mui/material";
import { ReactComponent as CheckedIcon } from "@icons/checked.svg";
import { useQuestionsStore } from "@root/questions/store";
import { updateDragQuestionContentId } from "@root/questions/actions";
import { useEffect } from "react";
import { AnyTypedQuizQuestion, UntypedQuizQuestion } from "@model/questionTypes/shared";
const getItemStyle = (isDragging:any, draggableStyle:any) => ({
// some basic styles to make the items look a bit nicer
userSelect: "none",
padding: 5 * 2,
margin: `0 0 5px 0`,
// change background colour if dragging
background: isDragging ? "lightgreen" : "grey",
// styles we need to apply on draggables
...draggableStyle
});
type AnyQuestion = UntypedQuizQuestion | AnyTypedQuizQuestion
export const QuestionsList = () => {
const { questions } = useQuestionsStore()
console.log(questions)
return (
<Box sx={{ padding: "15px" }}>
<Typography
sx={{ fontSize: "12px", color: "#9A9AAF", marginBottom: "5px" }}
>
Перетащите вопросы в карту ветвления
</Typography>
<Box
sx={{
maxHeight: "400px",
overflowY: "scroll",
paddingRight: "12px",
"&::-webkit-scrollbar": { width: "8px" },
"&::-webkit-scrollbar-track": {
background: "#D4D4DF",
borderRadius: "4px",
},
"&::-webkit-scrollbar-thumb": {
boxSizing: "border-box",
background: "#9A9AAF",
borderRadius: "4px",
border: "1.5px solid transparent",
backgroundClip: "padding-box",
},
}}
>
{/* тут нужно будет фильтровать с проверкой, что вопрос имеет тип*/}
{questions.filter((q:AnyQuestion) => q.type).map(({ title, id, content }, index) => (
<Button
onMouseDown={() => {//Разрешаем добавить этот вопрос если у него нет родителя (не добавляли ещё в дерево)
if (!content.rule.parentId) updateDragQuestionContentId(content.id)
}}
key={index}
sx={{
width: "100%",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
background: "#FFFFFF",
borderRadius: "8px",
marginBottom: "20px",
boxShadow: "0px 10px 30px #e7e7e7",
backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='rgb(154, 154, 175)' stroke-width='2' stroke-dasharray='8 8' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
border-radius: 8px;`,
"&:last-child": { marginBottom: 0 },
}}
>
<Typography sx={{ width: "100%", color: content.rule.parentId ? "#9A9AAF" : "#000" }}>
{title || "нет заголовка"}
</Typography>
{content.rule.parentId && <CheckedIcon />}
</Button>
))}
</Box>
</Box>
);
}

@ -0,0 +1,85 @@
import { Box, Typography, Switch, useTheme } from "@mui/material";
import { QuestionsList } from "./QuestionsList";
type BranchingPanelProps = {
active: boolean;
setActive: (active: boolean) => void;
};
export const BranchingPanel = ({ active, setActive }: BranchingPanelProps) => {
const theme = useTheme();
return (
<Box sx={{ userSelect: "none", maxWidth: "350px", width: "100%" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "15px",
padding: "18px",
background: "#fff",
borderRadius: "12px",
boxShadow: "0px 10px 30px #e7e7e7",
}}
>
<Switch
value={active}
onChange={(_, value) => setTimeout(() => setActive(value), 10)}
sx={{
width: 50,
height: 30,
padding: 0,
"& .MuiSwitch-switchBase": {
padding: 0,
margin: "2px",
transitionDuration: "300ms",
"&.Mui-checked": {
transform: "translateX(20px)",
color: theme.palette.brightPurple.main,
"& + .MuiSwitch-track": {
backgroundColor: "#E8DCF9",
opacity: 1,
border: 0,
},
"&.Mui-disabled + .MuiSwitch-track": { opacity: 0.5 },
},
"&.Mui-disabled .MuiSwitch-thumb": {
color:
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[600],
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: theme.palette.mode === "light" ? 0.7 : 0.3,
},
},
"& .MuiSwitch-thumb": {
boxSizing: "border-box",
width: 25,
height: 25,
},
"& .MuiSwitch-track": {
borderRadius: 13,
backgroundColor:
theme.palette.mode === "light" ? "#E9E9EA" : "#39393D",
opacity: 1,
transition: theme.transitions.create(["background-color"], {
duration: 500,
}),
},
}}
/>
<Box>
<Typography sx={{ fontWeight: "bold", color: "#4D4D4D" }}>
Логика ветвления
</Typography>
<Typography sx={{ color: "#4D4D4D", fontSize: "12px" }}>
Настройте связи между вопросами
</Typography>
</Box>
</Box>
{ active && <QuestionsList /> }
</Box>
);
};

@ -0,0 +1,77 @@
import { useParams } from "react-router-dom";
import { Box, Modal, Button, Typography } from "@mui/material";
import { useQuestionsStore } from "@root/questions/store";
import { AnyTypedQuizQuestion, UntypedQuizQuestion } from "@model/questionTypes/shared";
type AnyQuestion = UntypedQuizQuestion | AnyTypedQuizQuestion
interface Props {
openedModalQuestions: boolean;
setModalQuestionTargetContentId: (contentId:string) => void;
setOpenedModalQuestions: (open:boolean) => void;
}
export const BranchingQuestionsModal = ({ openedModalQuestions, setOpenedModalQuestions, setModalQuestionTargetContentId}:Props) => {
const quizId = Number(useParams().quizId);
const { questions } = useQuestionsStore();
const handleClose = () => {
setOpenedModalQuestions(false);
};
return (
<Modal open={openedModalQuestions} 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,
padding: "30px 0",
}}
>
<Box sx={{ margin: "0 auto", maxWidth: "350px" }}>
{questions.filter((q:AnyQuestion) => (q.type && !q.content.rule.parentId)).map((question: AnyTypedQuizQuestion, index:number) => (
<Button
key={question.content.id}
onClick={() => {
setModalQuestionTargetContentId(question.content.id)
handleClose();
}}
sx={{
width: "100%",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
background: "#FFFFFF",
borderRadius: "8px",
marginBottom: "20px",
boxShadow: "0px 10px 30px #e7e7e7",
backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='rgb(154, 154, 175)' stroke-width='2' stroke-dasharray='8 8' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e");
border-radius: 8px;`,
"&:last-child": { marginBottom: 0 },
}}
>
<Typography
sx={{
width: "100%",
color: "#000",
}}
>
{question.title || "нет заголовка"}
</Typography>
</Button>
))}
</Box>
</Box>
</Modal>
);
};

@ -1,5 +1,4 @@
import { QuizQuestionDate } from "@model/questionTypes/date";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingData from "./settingData";
@ -18,8 +17,6 @@ export default function SwitchData({
return <SettingData question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -66,6 +66,7 @@ function DraggableListItem({ question, isDragging, index }: Props) {
question={question}
draggableProps={provided.dragHandleProps}
isDragging={isDragging}
index={index}
/>
</Box>
)}

@ -45,9 +45,10 @@ interface Props {
question: AnyTypedQuizQuestion | UntypedQuizQuestion;
draggableProps: DraggableProvidedDragHandleProps | null | undefined;
isDragging: boolean;
index: number;
}
export default function QuestionsPageCard({ question, draggableProps, isDragging }: Props) {
export default function QuestionsPageCard({ question, draggableProps, isDragging, index }: Props) {
const [plusVisible, setPlusVisible] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const theme = useTheme();
@ -96,7 +97,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
<TextField
defaultValue={question.title}
placeholder={"Заголовок вопроса"}
onChange={({ target }) => setTitle(target.value)}
onChange={({ target }: { target: HTMLInputElement }) => setTitle(target.value)}
InputProps={{
startAdornment: (
<Box>
@ -262,17 +263,26 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
</IconButton>
</Box>
)}
<OneIcon
<Box
style={{
fontSize: "30px",
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "30px",
width: "30px",
marginLeft: "3px",
color: !question.expanded ? "#FFF" : "",
fill: question.expanded
borderRadius: "50%",
fontSize: "16px",
color: question.expanded
? theme.palette.brightPurple.main
: "#FFF",
background: question.expanded
? "#EEE4FC"
: theme.palette.brightPurple.main,
}}
/>
>
{index + 1}
</Box>
<IconButton
disableRipple
sx={{

@ -1,5 +1,4 @@
import { QuizQuestionSelect } from "@model/questionTypes/select";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingDropDown from "./settingDropDown";
@ -18,8 +17,6 @@ export default function SwitchDropDown({
return <SettingDropDown question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -1,5 +1,4 @@
import { QuizQuestionEmoji } from "@model/questionTypes/emoji";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingEmoji from "./settingEmoji";
@ -18,8 +17,6 @@ export default function SwitchEmoji({
return <SettingEmoji question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -72,7 +72,7 @@ export default function FormTypeQuestions({ question }: Props) {
margin: "20px",
}}
>
{(true /* TODO какое-то непонятное условие */
{(true /* TODO только первый вопрос */
? BUTTON_TYPE_QUESTIONS
: BUTTON_TYPE_SHORT_QUESTIONS
).map(({ icon, title, value: questionType }) => (

@ -14,12 +14,13 @@ import {
useMediaQuery,
useTheme
} from "@mui/material";
import { openCropModal } from "@root/cropModal";
import { closeImageUploadModal, openImageUploadModal } from "@root/imageUploadModal";
import { addQuestionVariant, setVariantImageUrl, setVariantOriginalImageUrl } from "@root/questions/actions";
import { setCropModal } from "@root/cropModal";
import { addQuestionVariant, uploadQuestionImage } from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { useState } from "react";
import { useDisclosure } from "../../../utils/useDisclosure";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg";
import { AnswerDraggableList } from "../AnswerDraggableList";
@ -38,27 +39,44 @@ export default function OptionsAndPicture({ question }: Props) {
const theme = useTheme();
const isTablet = useMediaQuery(theme.breakpoints.down(1000));
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const quizQid = useCurrentQuiz()?.qid;
const [isCropModalOpen, openCropModal, closeCropModal] = useDisclosure();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const SSHC = (data: string) => {
setSwitchState(data);
};
const handleImageUpload = (files: FileList | null) => {
const handleImageUpload = async (files: FileList | null) => {
if (!files?.length || !selectedVariantId) return;
const [file] = Array.from(files);
const url = URL.createObjectURL(file);
setVariantImageUrl(question.id, selectedVariantId, url);
setVariantOriginalImageUrl(question.id, selectedVariantId, url);
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId);
if (!variant) return;
variant.extendedText = url;
variant.originalImageUrl = url;
});
closeImageUploadModal();
openCropModal(url, url);
setCropModal(file, url);
openCropModal();
};
function handleCropModalSaveClick(url: string) {
function handleCropModalSaveClick(imageBlob: Blob) {
if (!selectedVariantId) return;
setVariantImageUrl(question.id, selectedVariantId, url);
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId);
if (!variant) return;
variant.extendedText = url;
});
}
return (
@ -72,13 +90,16 @@ export default function OptionsAndPicture({ question }: Props) {
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setSelectedVariantId(variant.id);
if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
if (variant.extendedText) {
openCropModal();
setCropModal(
variant.extendedText,
variant.originalImageUrl
);
return;
}
openImageUploadModal();
}}
@ -97,13 +118,16 @@ export default function OptionsAndPicture({ question }: Props) {
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setSelectedVariantId(variant.id);
if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
if (variant.extendedText) {
openCropModal();
setCropModal(
variant.extendedText,
variant.originalImageUrl
);
return;
}
openImageUploadModal();
}}
@ -117,8 +141,8 @@ export default function OptionsAndPicture({ question }: Props) {
</>
)}
/>
<UploadImageModal imgHC={handleImageUpload} />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<UploadImageModal isOpen={isImageUploadOpen} onClose={closeImageUploadModal} imgHC={handleImageUpload} />
<CropModal isOpen={isCropModalOpen} onClose={closeCropModal} onSaveImageClick={handleCropModalSaveClick} />
<Box
sx={{
width: "100%",
@ -312,7 +336,7 @@ export default function OptionsAndPicture({ question }: Props) {
height: "19px",
}}
onClick={() => {
addQuestionVariant(question.id)
addQuestionVariant(question.id);
}}
>
Добавьте ответ

@ -1,6 +1,5 @@
import { QuizQuestionVarImg } from "@model/questionTypes/varimg";
import UploadImage from "../UploadImage";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingOptionsAndPict from "./SettingOptionsAndPict";
@ -19,8 +18,6 @@ export default function SwitchOptionsAndPict({
return <SettingOptionsAndPict question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
case "image":
return <UploadImage question={question} />;
default:

@ -5,9 +5,8 @@ import {
useMediaQuery,
useTheme
} from "@mui/material";
import { openCropModal } from "@root/cropModal";
import { closeImageUploadModal, openImageUploadModal } from "@root/imageUploadModal";
import { addQuestionVariant, setVariantImageUrl, setVariantOriginalImageUrl } from "@root/questions/actions";
import { setCropModal } from "@root/cropModal";
import { addQuestionVariant, uploadQuestionImage } from "@root/questions/actions";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { useState } from "react";
@ -17,6 +16,8 @@ import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../ButtonsOptions";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import SwitchAnswerOptionsPict from "./switchOptionsPict";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useDisclosure } from "../../../utils/useDisclosure";
interface Props {
@ -25,30 +26,47 @@ interface Props {
export default function OptionsPicture({ question }: Props) {
const theme = useTheme();
const quizQid = useCurrentQuiz()?.qid;
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
const [switchState, setSwitchState] = useState("setting");
const isMobile = useMediaQuery(theme.breakpoints.down(790));
const [isCropModalOpen, openCropModal, closeCropModal] = useDisclosure();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const SSHC = (data: string) => {
setSwitchState(data);
};
const handleImageUpload = (files: FileList | null) => {
const handleImageUpload = async (files: FileList | null) => {
if (!files?.length || !selectedVariantId) return;
const [file] = Array.from(files);
const url = URL.createObjectURL(file);
setVariantImageUrl(question.id, selectedVariantId, url);
setVariantOriginalImageUrl(question.id, selectedVariantId, url);
const url = await uploadQuestionImage(question.id, quizQid, file, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId);
if (!variant) return;
variant.extendedText = url;
variant.originalImageUrl = url;
});
closeImageUploadModal();
openCropModal(url, url);
setCropModal(file, url);
openCropModal();
};
function handleCropModalSaveClick(url: string) {
function handleCropModalSaveClick(imageBlob: Blob) {
if (!selectedVariantId) return;
setVariantImageUrl(question.id, selectedVariantId, url);
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(variant => variant.id === selectedVariantId);
if (!variant) return;
variant.extendedText = url;
});
}
return (
@ -62,14 +80,15 @@ export default function OptionsPicture({ question }: Props) {
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setSelectedVariantId(variant.id);
if (variant.extendedText) {
return openCropModal(
openCropModal();
setCropModal(
variant.extendedText,
variant.originalImageUrl
);
return;
}
openImageUploadModal();
@ -89,14 +108,15 @@ export default function OptionsPicture({ question }: Props) {
<AddOrEditImageButton
imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setSelectedVariantId(variant.id);
if (variant.extendedText) {
return openCropModal(
openCropModal();
setCropModal(
variant.extendedText,
variant.originalImageUrl
);
return;
}
openImageUploadModal();
@ -111,8 +131,8 @@ export default function OptionsPicture({ question }: Props) {
</>
)}
/>
<UploadImageModal imgHC={handleImageUpload} />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<UploadImageModal isOpen={isImageUploadOpen} onClose={closeImageUploadModal} imgHC={handleImageUpload} />
<CropModal isOpen={isCropModalOpen} onClose={closeCropModal} onSaveImageClick={handleCropModalSaveClick} />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link
component="button"

@ -1,5 +1,4 @@
import * as React from "react";
import BranchingQuestions from "../branchingQuestions";
import SettingOpytionsPict from "./settingOpytionsPict";
import HelpQuestions from "../helpQuestions";
import { QuizQuestionImages } from "@model/questionTypes/images";
@ -18,8 +17,6 @@ export default function SwitchAnswerOptionsPict({
return <SettingOpytionsPict question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -1,5 +1,4 @@
import { QuizQuestionText } from "@model/questionTypes/text";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingTextField from "./settingTextField";
@ -18,8 +17,6 @@ export default function SwitchTextField({
return <SettingTextField question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -1,8 +1,8 @@
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { openCropModal } from "@root/cropModal";
import { closeImageUploadModal, openImageUploadModal } from "@root/imageUploadModal";
import { setPageQuestionOriginalPicture, setPageQuestionPicture, updateQuestion } from "@root/questions/actions";
import { setCropModal } from "@root/cropModal";
import { updateQuestion, uploadQuestionImage } from "@root/questions/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import CustomTextField from "@ui_kit/CustomTextField";
import { CropModal } from "@ui_kit/Modal/CropModal";
@ -13,6 +13,7 @@ import ButtonsOptions from "../ButtonsOptions";
import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { UploadVideoModal } from "../UploadVideoModal";
import SwitchPageOptions from "./switchPageOptions";
import { useDisclosure } from "../../../utils/useDisclosure";
type Props = {
@ -27,6 +28,9 @@ export default function PageOptions({ disableInput, question }: Props) {
const isTablet = useMediaQuery(theme.breakpoints.down(980));
const isFigmaTablet = useMediaQuery(theme.breakpoints.down(990));
const isMobile = useMediaQuery(theme.breakpoints.down(780));
const quizQid = useCurrentQuiz()?.qid;
const [isCropModalOpen, openCropModal, closeCropModal] = useDisclosure();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const setText = useDebouncedCallback((value) => {
updateQuestion(question.id, question => {
@ -40,19 +44,26 @@ export default function PageOptions({ disableInput, question }: Props) {
setSwitchState(data);
};
function handleImageUpload(fileList: FileList | null) {
async function handleImageUpload(fileList: FileList | null) {
if (!fileList?.length) return;
const url = URL.createObjectURL(fileList[0]);
const url = await uploadQuestionImage(question.id, quizQid, fileList[0], (question, url) => {
if (question.type !== "page") return;
setPageQuestionPicture(question.id, url);
setPageQuestionOriginalPicture(question.id, url);
question.content.picture = url;
question.content.originalPicture = url;
});
closeImageUploadModal();
openCropModal(url, url);
setCropModal(fileList[0], url);
openCropModal();
}
function handleCropModalSaveClick(url: string) {
setPageQuestionPicture(question.id, url);
function handleCropModalSaveClick(imageBlob: Blob) {
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
if (question.type !== "page") return;
question.content.picture = url;
});
}
return (
@ -97,7 +108,7 @@ export default function PageOptions({ disableInput, question }: Props) {
imageSrc={question.content.picture}
onImageClick={() => {
if (question.content.picture) {
return openCropModal(
return setCropModal(
question.content.picture,
question.content.originalPicture
);
@ -122,8 +133,8 @@ export default function PageOptions({ disableInput, question }: Props) {
Изображение
</Typography>
</Box>
<UploadImageModal imgHC={handleImageUpload} />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<UploadImageModal isOpen={isImageUploadOpen} onClose={closeImageUploadModal} imgHC={handleImageUpload} />
<CropModal isOpen={isCropModalOpen} onClose={closeCropModal} onSaveImageClick={handleCropModalSaveClick} />
<Typography> или</Typography>
<Box
sx={{

@ -1,5 +1,4 @@
import { QuizQuestionPage } from "@model/questionTypes/page";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingPageOptions from "./SettingPageOptions";
@ -18,8 +17,6 @@ export default function SwitchPageOptions({
return <SettingPageOptions question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -0,0 +1,27 @@
import {
Box,
} from "@mui/material";
import { DraggableList } from "./DraggableList";
import { BranchingPanel } from "./BranchingPanel";
import { BranchingMap } from "./BranchingMap";
interface Props {
settingBranching: boolean;
setSettingBranching: (active: boolean) => void
}
export const QuestionSwitchWindowTool = ({settingBranching, setSettingBranching}:Props) => {
return (
<Box sx={{ display: "flex", gap: "20px", flexWrap: "wrap" }}>
<Box sx={{ flexBasis: "796px" }}>
{settingBranching ? <BranchingMap /> : <DraggableList />}
</Box>
<BranchingPanel
active={settingBranching}
setActive={setSettingBranching}
/>
</Box>
)
}

@ -1,3 +1,4 @@
import { useState } from "react"
import {
Box,
Button,
@ -13,14 +14,18 @@ import QuizPreview from "@ui_kit/QuizPreview/QuizPreview";
import { createPortal } from "react-dom";
import AddPlus from "../../assets/icons/questionsPage/addPlus";
import ArrowLeft from "../../assets/icons/questionsPage/arrowLeft";
import { DraggableList } from "./DraggableList";
import BranchingQuestions from "./BranchingModal/BranchingQuestionsModal"
import { QuestionSwitchWindowTool } from "./QuestionSwitchWindowTool";
import { useQuestionsStore } from "@root/questions/store";
export default function QuestionsPage() {
const theme = useTheme();
const { openedModalSettingsId } = useQuestionsStore();
const isMobile = useMediaQuery(theme.breakpoints.down(660));
const quiz = useCurrentQuiz();
const [settingBranching, setSettingBranching] = useState<boolean>(false);
if (!quiz) return null;
return (
@ -37,6 +42,7 @@ export default function QuestionsPage() {
<Typography variant={"h5"}>Заголовок квиза</Typography>
<Button
sx={{
display: settingBranching ? "none" : "flex",
fontSize: "16px",
lineHeight: "19px",
padding: 0,
@ -49,7 +55,7 @@ export default function QuestionsPage() {
Свернуть всё
</Button>
</Box>
<DraggableList />
<QuestionSwitchWindowTool settingBranching={settingBranching} setSettingBranching={setSettingBranching} />
<Box
sx={{
display: "flex",
@ -95,6 +101,7 @@ export default function QuestionsPage() {
</Box>
</Box>
{createPortal(<QuizPreview />, document.body)}
{/* {openedModalSettingsId !== null && <BranchingQuestions/>} */}
</>
);
}

@ -172,13 +172,13 @@ export default function RatingOptions({ question }: Props) {
defaultValue={question.content.ratingNegativeDescription}
value={negativeText}
placeholder="Негативно"
onChange={({ target }) => {
onChange={({ target }: {target: HTMLInputElement}) => {
if (target.value.length <= 15) {
setNegativeText(target.value);
debounceNegativeDescription(target.value);
}
}}
onBlur={({ target }) => debounceNegativeDescription(target.value)}
onBlur={({ target }: {target: HTMLInputElement}) => debounceNegativeDescription(target.value)}
sx={{
width: negativeTextWidth + 10 + "px",
maxWidth: isMobile ? "140px" : "230px",
@ -222,13 +222,13 @@ export default function RatingOptions({ question }: Props) {
<TextField
value={positiveText}
placeholder="Позитивно"
onChange={({ target }) => {
onChange={({ target }: {target: HTMLInputElement}) => {
if (target.value.length <= 15) {
setPositiveText(target.value);
debouncePositiveDescription(target.value);
}
}}
onBlur={({ target }) => debouncePositiveDescription(target.value)}
onBlur={({ target }: {target: HTMLInputElement}) => debouncePositiveDescription(target.value)}
sx={{
width: positiveTextWidth + 10 + "px",
maxWidth: isMobile ? "140px" : "230px",

@ -1,5 +1,4 @@
import { QuizQuestionRating } from "@model/questionTypes/rating";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingRating from "./settingRating";
@ -18,8 +17,6 @@ export default function SwitchRating({
return <SettingRating question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -1,5 +1,4 @@
import { QuizQuestionNumber } from "@model/questionTypes/number";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingSlider from "./settingSlider";
@ -18,8 +17,6 @@ export default function SwitchSlider({
return <SettingSlider question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -1,5 +1,4 @@
import { QuizQuestionFile } from "@model/questionTypes/file";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import SettingsUpload from "./settingUpload";
@ -18,8 +17,6 @@ export default function SwitchUpload({
return <SettingsUpload question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
default:
return <></>;
}

@ -14,17 +14,19 @@ import * as React from "react";
import UnsplashIcon from "../../../assets/icons/Unsplash.svg";
import type { DragEvent } from "react";
import { closeImageUploadModal, useImageUploadModalStore } from "@root/imageUploadModal";
interface ModalkaProps {
isOpen: boolean;
onClose: () => void;
imgHC: (imgInp: FileList | null) => void;
}
export const UploadImageModal: React.FC<ModalkaProps> = ({
imgHC,
isOpen,
onClose,
}) => {
const theme = useTheme();
const isOpen = useImageUploadModalStore(state => state.isOpen)
const dropZone = React.useRef<HTMLDivElement>(null);
const [ready, setReady] = React.useState(false);
@ -44,7 +46,7 @@ export const UploadImageModal: React.FC<ModalkaProps> = ({
return (
<Modal
open={isOpen}
onClose={closeImageUploadModal}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>

@ -1,13 +1,14 @@
import { AnyTypedQuizQuestion } from "@model/questionTypes/shared";
import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
import { openCropModal } from "@root/cropModal";
import { closeImageUploadModal, openImageUploadModal } from "@root/imageUploadModal";
import { setQuestionBackgroundImage, setQuestionOriginalBackgroundImage } from "@root/questions/actions";
import { uploadQuestionImage } from "@root/questions/actions";
import { CropModal } from "@ui_kit/Modal/CropModal";
import UploadBox from "@ui_kit/UploadBox";
import { type DragEvent } from "react";
import UploadIcon from "../../../assets/icons/UploadIcon";
import { UploadImageModal } from "./UploadImageModal";
import { setCropModal } from "@root/cropModal";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { useDisclosure } from "../../../utils/useDisclosure";
type UploadImageProps = {
@ -16,18 +17,20 @@ type UploadImageProps = {
export default function UploadImage({ question }: UploadImageProps) {
const theme = useTheme();
const quizQid = useCurrentQuiz()?.qid;
const [isCropModalOpen, openCropModal, closeCropModal] = useDisclosure();
const [isImageUploadOpen, openImageUploadModal, closeImageUploadModal] = useDisclosure();
const handleImageUpload = (files: FileList | null) => {
const handleImageUpload = async (files: FileList | null) => {
if (!files?.length) return;
const [file] = Array.from(files);
const url = URL.createObjectURL(file);
setQuestionBackgroundImage(question.id, url);
setQuestionOriginalBackgroundImage(question.id, url);
const url = await uploadQuestionImage(question.id, quizQid, files[0], (question, url) => {
question.content.back = url;
question.content.originalBack = url;
});
closeImageUploadModal();
openCropModal(url, url);
setCropModal(files[0], url);
openCropModal();
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
@ -37,8 +40,10 @@ export default function UploadImage({ question }: UploadImageProps) {
handleImageUpload(event.dataTransfer.files);
};
function handleCropModalSaveClick(url: string) {
setQuestionBackgroundImage(question.id, url);
function handleCropModalSaveClick(imageBlob: Blob) {
uploadQuestionImage(question.id, quizQid, imageBlob, (question, url) => {
question.content.back = url;
});
}
return (
@ -81,8 +86,8 @@ export default function UploadImage({ question }: UploadImageProps) {
/>
}
</ButtonBase>
<UploadImageModal imgHC={handleImageUpload} />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<UploadImageModal isOpen={isImageUploadOpen} onClose={closeImageUploadModal} imgHC={handleImageUpload} />
<CropModal isOpen={isCropModalOpen} onClose={closeCropModal} onSaveImageClick={handleCropModalSaveClick} />
</Box>
);
}

@ -1,6 +1,5 @@
import { QuizQuestionVariant } from "@model/questionTypes/variant";
import UploadImage from "../UploadImage";
import BranchingQuestions from "../branchingQuestions";
import HelpQuestions from "../helpQuestions";
import ResponseSettings from "./responseSettings";
@ -19,8 +18,6 @@ export default function SwitchAnswerOptions({
return <ResponseSettings question={question} />;
case "help":
return <HelpQuestions question={question} />;
case "branching":
return <BranchingQuestions question={question} />;
case "image":
return <UploadImage question={question} />;
default:

@ -1,9 +1,7 @@
import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import AddImage from "@icons/questionsPage/addImage";
import AddVideofile from "@icons/questionsPage/addVideofile";
import { CropModal } from "@ui_kit/Modal/CropModal";
import { openCropModal } from "@root/cropModal";
export default function ImageAndVideoButtons() {
const theme = useTheme();
@ -11,8 +9,7 @@ export default function ImageAndVideoButtons() {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: "12px", mt: "20px", mb: "20px" }}>
<AddImage onClick={() => openCropModal("", "")} />
<CropModal />
<AddImage onClick={undefined/* TODO () => openCropModal("", "") */} />
<Typography
sx={{
fontWeight: 400,

@ -1,6 +1,5 @@
import Info from "@icons/Info";
import { Box, TextField } from "@mui/material";
import BranchingQuestions from "../../Questions/branchingQuestions";
import PointsQuestions from "./PointsQuestions";
interface Props {
@ -45,10 +44,6 @@ export default function SwitchResult({
case "setting":
return <ResponseSettings />;
break;
case "branching":
// return <BranchingQuestions question={question} />;
return null
break;
case "points":
return <PointsQuestions />;
break;

@ -1,3 +1,4 @@
import { useState, useRef } from "react";
import ChartIcon from "@icons/ChartIcon";
import LinkIcon from "@icons/LinkIcon";
import PencilIcon from "@icons/PencilIcon";
@ -10,6 +11,8 @@ import {
Typography,
useMediaQuery,
useTheme,
Popover,
} from "@mui/material";
import { deleteQuiz, setEditQuizId } from "@root/quizes/actions";
import { useNavigate } from "react-router-dom";
@ -31,6 +34,8 @@ export default function QuizCard({
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(600));
const navigate = useNavigate();
const [subMenuOpen, setSubMenuOpen] = useState<boolean>(false);
const subMenuRef = useRef<HTMLButtonElement | null>(null);
function handleEditClick() {
setEditQuizId(quiz.backendId);
@ -134,15 +139,45 @@ export default function QuizCard({
}}
/>
<IconButton
ref={subMenuRef}
sx={{
color: theme.palette.brightPurple.main,
ml: "auto",
}}
onClick={() => deleteQuiz(quiz.id)}
onClick={() => setSubMenuOpen(true)}
data-cy="delete-quiz"
>
<MoreHorizIcon sx={{ transform: "scale(1.75)" }} />
</IconButton>
<Popover
open={subMenuOpen}
anchorEl={subMenuRef.current}
onClose={() => setSubMenuOpen(false)}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
>
<Box onClick={() => setSubMenuOpen(false)}>
<Button
sx={{
display: "block",
width: "100%",
padding: "15px 30px",
}}
onClick={()=>{}}
>
Копировать
</Button>
<Button
sx={{
display: "block",
width: "100%",
padding: "15px 30px",
}}
onClick={() => deleteQuiz(quiz.id)}
>
Удалить
</Button>
</Box>
</Popover>
</Box>
</Box>
);

@ -82,7 +82,7 @@ export default function StartPageSettings() {
const MobileVersionHC = (bool: boolean) => {
setMobileVersion(bool);
};
if (!quiz) return null; // TODO throw and catch with error boundary
return (
@ -306,6 +306,10 @@ export default function StartPageSettings() {
text={"5 MB максимум"}
heightImg={"110px"}
sx={{ maxWidth: "300px" }}
imageUrl={quiz.config.startpage.background.desktop}
onImageUpload={(quiz, url) => {
quiz.config.startpage.background.desktop = url;
}}
/>
</Box>
@ -353,7 +357,14 @@ export default function StartPageSettings() {
>
Изображение для мобильной версии
</Typography>
<DropZone text={"5 MB максимум"} heightImg={"110px"} />
<DropZone
text={"5 MB максимум"}
heightImg={"110px"}
imageUrl={quiz.config.startpage.background.mobile}
onImageUpload={(quiz, url) => {
quiz.config.startpage.background.mobile = url;
}}
/>
</Box>
) : (
<></>
@ -435,7 +446,14 @@ export default function StartPageSettings() {
>
Изображение для мобильной версии
</Typography>
<DropZone text={"5 MB максимум"} heightImg={"110px"} />
<DropZone
text={"5 MB максимум"}
heightImg={"110px"}
imageUrl={quiz.config.startpage.background.mobile}
onImageUpload={(quiz, url) => {
quiz.config.startpage.background.mobile = url;
}}
/>
</Box>
<Typography
@ -499,6 +517,10 @@ export default function StartPageSettings() {
text={"5 MB максимум"}
heightImg={"110px"}
sx={{ maxWidth: "300px" }}
imageUrl={quiz.config.logo}
onImageUpload={(quiz, url) => {
quiz.config.logo = url;
}}
/>
</Box>
@ -569,6 +591,10 @@ export default function StartPageSettings() {
text={"5 MB максимум"}
heightImg={"110px"}
sx={{ maxWidth: "300px" }}
imageUrl={quiz.config.logo}
onImageUpload={(quiz, url) => {
quiz.config.logo = url;
}}
/>
</Box>
@ -620,7 +646,7 @@ export default function StartPageSettings() {
Заголовок
</Typography>
<CustomTextField
placeholder=""
placeholder="Имя заголовка об опроснике для подбора табуретки"
text={quiz.name}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.name = e.target.value;
@ -637,7 +663,7 @@ export default function StartPageSettings() {
Текст
</Typography>
<CustomTextField
placeholder=""
placeholder="Внимательно заполняйте поля ответов"
text={quiz.config.startpage.description}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.startpage.description = e.target.value;
@ -654,7 +680,7 @@ export default function StartPageSettings() {
Текст кнопки
</Typography>
<CustomTextField
placeholder=""
placeholder="Начать опрос"
text={quiz.config.startpage.button}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.startpage.button = e.target.value;
@ -671,7 +697,7 @@ export default function StartPageSettings() {
Телефон
</Typography>
<CustomTextField
placeholder=""
placeholder="8-800-000-00-00"
text={quiz.config.info.phonenumber}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.info.phonenumber = e.target.value;
@ -696,7 +722,7 @@ export default function StartPageSettings() {
Название или слоган компании
</Typography>
<CustomTextField
placeholder=""
placeholder="Только лучшее"
text={quiz.config.info.orgname}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.info.orgname = e.target.value;
@ -713,7 +739,7 @@ export default function StartPageSettings() {
Сайт
</Typography>
<CustomTextField
placeholder=""
placeholder="https://mysite.com"
text={quiz.config.info.site}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.info.site = e.target.value;
@ -730,7 +756,7 @@ export default function StartPageSettings() {
Юридическая информация
</Typography>
<CustomTextField
placeholder=""
placeholder="Данные наших документов"
text={quiz.config.info.law}
onChange={e => updateQuiz(quiz.id, quiz => {
quiz.config.info.law = e.target.value;

@ -1,4 +1,5 @@
import UploadIcon from "@icons/UploadIcon";
import { Quiz } from "@model/quiz/quiz";
import {
Box,
ButtonBase,
@ -7,7 +8,7 @@ import {
Typography,
useTheme,
} from "@mui/material";
import { updateQuiz } from "@root/quizes/actions";
import { uploadQuizImage } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
import { enqueueSnackbar } from "notistack";
import { useState } from "react";
@ -18,27 +19,27 @@ interface Props {
sx?: SxProps<Theme>;
heightImg: string;
widthImg?: string;
onImageUpload: (quiz: Quiz, url: string) => void;
imageUrl: string | null;
}
//Научи функцию принимать данные для валидации
export const DropZone = ({ text, sx, heightImg, widthImg }: Props) => {
export const DropZone = ({ text, sx, heightImg, widthImg, onImageUpload, imageUrl }: Props) => {
const theme = useTheme();
const quiz = useCurrentQuiz();
const [ready, setReady] = useState(false);
if (!quiz) return null; // TODO throw and catch with error boundary
const imgHC = (imgInp: HTMLInputElement) => {
const imgHC = async (imgInp: HTMLInputElement) => {
if (!quiz) return;
const file = imgInp.files?.[0];
if (file) {
if (file.size < 5242880) {
updateQuiz(quiz.id, quiz => {
quiz.config.startpage.background.desktop = URL.createObjectURL(file);
});
} else {
enqueueSnackbar("Размер картинки слишком велик");
}
}
if (!file) return;
if (file.size > 5 * 2 ** 20) return enqueueSnackbar("Размер картинки слишком велик");
uploadQuizImage(quiz.id, file, onImageUpload);
};
const dragenterHC = () => {
@ -54,13 +55,9 @@ export const DropZone = ({ text, sx, heightImg, widthImg }: Props) => {
setReady(false);
const file = event.dataTransfer.files[0];
if (file.size < 5242880) {
updateQuiz(quiz.id, quiz => {
quiz.config.startpage.background.desktop = URL.createObjectURL(file);
});
} else {
enqueueSnackbar("Размер картинки слишком велик");
}
if (file.size < 5 * 2 ** 20) return enqueueSnackbar("Размер картинки слишком велик");
uploadQuizImage(quiz.id, file, onImageUpload);
};
const dragOverHC = (event: React.DragEvent<HTMLDivElement>) => {
@ -91,7 +88,7 @@ export const DropZone = ({ text, sx, heightImg, widthImg }: Props) => {
backgroundColor: theme.palette.background.default,
border: `1px solid ${ready ? "red" : theme.palette.grey2.main}`,
borderRadius: "8px",
opacity: quiz.config.startpage.background.desktop ? "0.5" : 1,
opacity: imageUrl ? "0.5" : 1,
...sx,
}}
>
@ -109,11 +106,11 @@ export const DropZone = ({ text, sx, heightImg, widthImg }: Props) => {
>
{text}
</Typography>
{quiz.config.startpage.background.desktop && (
{imageUrl && (
<img
height={heightImg}
width={widthImg}
src={quiz.config.startpage.background.desktop}
src={imageUrl}
style={{
position: "absolute",
zIndex: "-1",

@ -3,14 +3,12 @@ import { devtools } from "zustand/middleware";
type CropModalStore = {
isCropModalOpen: boolean;
imageUrl: string | null;
imageBlob: Blob | null;
originalImageUrl: string | null;
};
const initialState: CropModalStore = {
isCropModalOpen: false,
imageUrl: null,
export const initialState: CropModalStore = {
imageBlob: null,
originalImageUrl: null,
};
@ -25,19 +23,24 @@ export const useCropModalStore = create<CropModalStore>()(
),
);
export const openCropModal = (imageUrl: string, originalImageUrl: string) => useCropModalStore.setState(
{
isCropModalOpen: true,
imageUrl,
originalImageUrl,
},
false,
{
type: "openCropModal",
imageUrl,
originalImageUrl,
export const setCropModal = async (
imageBlob: Blob | string | null,
originalImageUrl: string | null | undefined,
) => {
if (typeof imageBlob === "string") {
const response = await fetch(imageBlob);
imageBlob = await response.blob();
}
);
useCropModalStore.setState({
imageBlob,
originalImageUrl: originalImageUrl ?? null,
}, false, {
type: "setCropModal",
imageBlob,
originalImageUrl,
});
};
export const closeCropModal = () => useCropModalStore.setState(
initialState,
@ -45,22 +48,22 @@ export const closeCropModal = () => useCropModalStore.setState(
"closeCropModal"
);
export const setCropModalImageUrl = (imageUrl: string | null) => useCropModalStore.setState(
{ imageUrl },
false,
{
type: "setCropModalImageUrl",
imageUrl,
export const setCropModalImageBlob = async (image: Blob | string | null) => {
if (typeof image === "string") {
const response = await fetch(image);
image = await response.blob();
}
);
export const resetToOriginalImage = (): boolean => {
if (!useCropModalStore.getState().originalImageUrl) return false;
useCropModalStore.setState(
state => ({ imageUrl: state.originalImageUrl }),
false,
"resetToOriginalImage"
);
return true;
useCropModalStore.setState({
imageBlob: image,
}, false, {
type: "setCropModalImageBlob",
image,
});
};
export const setCropModalOriginalImageUrl = (originalImageUrl: string | null | undefined) => useCropModalStore.setState(
{ originalImageUrl: originalImageUrl ?? null },
false,
"setCropModalOriginalImageUrl"
);

@ -1,29 +0,0 @@
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type ImageUploadModalStore = {
isOpen: boolean;
};
const initialState: ImageUploadModalStore = {
isOpen: false,
};
export const useImageUploadModalStore = create<ImageUploadModalStore>()(
persist(
devtools(
() => initialState,
{
name: "ImageUploadModalStore",
}
),
{
name: "ImageUploadModalStore",
}
)
);
export const openImageUploadModal = () => useImageUploadModalStore.setState({ isOpen: true });
export const closeImageUploadModal = () => useImageUploadModalStore.setState({ isOpen: false });

@ -126,6 +126,7 @@ export const updateQuestion = <T extends AnyTypedQuizQuestion>(
questionIndex: number,
recipe: (question: T) => void,
) => setProducedState(state => {
console.log("начинаю отправку fire квиза " )
const question = state.listQuestions[quizId][questionIndex] as T;
recipe(question);
@ -152,7 +153,6 @@ export const setVariantImageUrl = (
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.extendedText === url) return;
@ -177,7 +177,6 @@ export const setVariantOriginalImageUrl = (
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.originalImageUrl === url) return;

@ -1,12 +1,13 @@
import { questionApi } from "@api/question";
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import { questionToEditQuestionRequest } from "@model/question/edit";
import { QuestionType, RawQuestion, rawQuestionToQuestion } from "@model/question/question";
import { AnyTypedQuizQuestion, QuestionVariant, UntypedQuizQuestion, createQuestionVariant } from "@model/questionTypes/shared";
import { defaultQuestionByType } from "../../constants/default";
import { produce } from "immer";
import { nanoid } from "nanoid";
import { enqueueSnackbar } from "notistack";
import { defaultQuestionByType } from "../../constants/default";
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { RequestQueue } from "../../utils/requestQueue";
import { QuestionsStore, useQuestionsStore } from "./store";
@ -122,10 +123,9 @@ export const updateQuestion = (
updateFn: (question: AnyTypedQuizQuestion) => void,
) => {
setProducedState(state => {
const question = state.questions.find(q => q.id === questionId);
const question = state.questions.find(q => q.id === questionId) || state.questions.find(q => q.content.id === questionId);
if (!question) return;
if (question.type === null) throw new Error("Cannot update untyped question, use 'updateUntypedQuestion' instead");
updateFn(question);
}, {
type: "updateQuestion",
@ -133,12 +133,14 @@ export const updateQuestion = (
updateFn: updateFn.toString(),
});
clearTimeout(requestTimeoutId);
requestTimeoutId = setTimeout(() => {
// clearTimeout(requestTimeoutId);
// requestTimeoutId = setTimeout(() => {
requestQueue.enqueue(async () => {
const q = useQuestionsStore.getState().questions.find(q => q.id === questionId);
const q = useQuestionsStore.getState().questions.find(q => q.id === questionId) || useQuestionsStore.getState().questions.find(q => q.content.id === questionId);
console.log("мы пытаемся найти вопрос ")
if (!q) return;
if (q.type === null) throw new Error("Cannot send update request for untyped question");
console.log(q.title)
const response = await questionApi.edit(questionToEditQuestionRequest(q));
@ -149,7 +151,7 @@ export const updateQuestion = (
devlog("Error editing question", { error, questionId });
enqueueSnackbar("Не удалось сохранить вопрос");
});
}, REQUEST_DEBOUNCE);
// }, REQUEST_DEBOUNCE);
};
export const addQuestionVariant = (questionId: string) => {
@ -211,98 +213,37 @@ export const reorderQuestionVariants = (
});
};
export const setQuestionBackgroundImage = (
export const uploadQuestionImage = async (
questionId: string,
url: string,
quizQid: string | undefined,
blob: Blob,
updateFn: (question: AnyTypedQuizQuestion, imageId: string) => void,
) => {
updateQuestion(questionId, question => {
if (question.content.back === url) return;
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
if (!question || !quizQid) return;
if (
question.content.back !== question.content.originalBack
) URL.revokeObjectURL(question.content.back);
question.content.back = url;
});
};
try {
const response = await quizApi.addImages(question.quizId, blob);
export const setQuestionOriginalBackgroundImage = (
questionId: string,
url: string,
) => {
updateQuestion(questionId, question => {
if (question.content.originalBack === url) return;
const values = Object.values(response);
if (values.length !== 1) {
console.warn("Error uploading image");
return;
}
URL.revokeObjectURL(question.content.originalBack);
question.content.originalBack = url;
});
};
const imageId = values[0];
const imageUrl = `https://squiz.pena.digital/squizimages/${quizQid}/${imageId}`;
export const setVariantImageUrl = (
questionId: string,
variantId: string,
url: string,
) => {
updateQuestion(questionId, question => {
if (!("variants" in question.content)) return;
updateQuestion(questionId, question => {
updateFn(question, imageUrl);
});
const variant = question.content.variants.find(variant => variant.id === variantId);
if (!variant) return;
return imageUrl;
} catch (error) {
devlog("Error uploading question image", error);
if (variant.extendedText === url) return;
if (variant.extendedText !== variant.originalImageUrl) URL.revokeObjectURL(variant.extendedText);
variant.extendedText = url;
});
};
export const setVariantOriginalImageUrl = (
questionId: string,
variantId: string,
url: string,
) => {
updateQuestion(questionId, question => {
if (!("variants" in question.content)) return;
const variant = question.content.variants.find(
variant => variant.id === variantId
) as QuestionVariant | undefined;
if (!variant) return;
if (variant.originalImageUrl === url) return;
URL.revokeObjectURL(variant.originalImageUrl);
variant.originalImageUrl = url;
});
};
export const setPageQuestionPicture = (
questionId: string,
url: string,
) => {
updateQuestion(questionId, question => {
if (question.type !== "page") return;
if (question.content.picture === url) return;
if (
question.content.picture !== question.content.originalPicture
) URL.revokeObjectURL(question.content.picture);
question.content.picture = url;
});
};
export const setPageQuestionOriginalPicture = (
questionId: string,
url: string,
) => {
updateQuestion(questionId, question => {
if (question.type !== "page") return;
if (question.content.originalPicture === url) return;
URL.revokeObjectURL(question.content.originalPicture);
question.content.originalPicture = url;
});
enqueueSnackbar("Не удалось загрузить изображение");
}
};
export const setQuestionInnerName = (
@ -331,7 +272,7 @@ export const createTypedQuestion = async (
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
if (!question) return;
if (question.type !== null) throw new Error("Cannot upgrade already typed question");
try {
const createdQuestion = await questionApi.create({
quiz_id: question.quizId,
@ -423,4 +364,28 @@ function setProducedState<A extends string | { type: unknown; }>(
action?: A,
) {
useQuestionsStore.setState(state => produce(state, recipe), false, action);
};
export const cleardragQuestionContentId = () => {
useQuestionsStore.setState({dragQuestionContentId: null});
};
export const getQuestionById = (questionId: string | null) => {
if (questionId === null) return null;
return useQuestionsStore.getState().questions.find(q => q.id === questionId) || null;
};
export const getQuestionByContentId = (questionContentId: string | null) => {
console.log("questionContentId " + questionContentId)
if (questionContentId === null) return null;
return useQuestionsStore.getState().questions.find(q => {
console.log(q.content.id)
console.log(q.content.id === questionContentId)
return ( q.content.id === questionContentId)}) || null;
};
export const updateOpenedModalSettingsId = (id?: string) => useQuestionsStore.setState({openedModalSettingsId: id ? id : null});
export const updateDragQuestionContentId = (contentId?: string) => {
console.log("contentId " + contentId)
useQuestionsStore.setState({dragQuestionContentId: contentId ? contentId : null});
}

@ -4,11 +4,15 @@ import { devtools } from "zustand/middleware";
export type QuestionsStore = {
questions: (AnyTypedQuizQuestion | UntypedQuizQuestion)[];
questions: (AnyTypedQuizQuestion | UntypedQuizQuestion);
openedModalSettingsId: string | null;
dragQuestionContentId: string | null;
};
const initialState: QuestionsStore = {
questions: [],
openedModalSettingsId: null as null,
dragQuestionContentId: null,
};
export const useQuestionsStore = create<QuestionsStore>()(

@ -28,6 +28,10 @@ export const toggleQuizPreview = () => useQuizPreviewStore.setState(
state => ({ isPreviewShown: !state.isPreviewShown })
);
export const setCurrentQuestionIndex = (step: number) => useQuizPreviewStore.setState(
state => ({ currentQuestionIndex:state.currentQuestionIndex = step })
);
export const incrementCurrentQuestionIndex = (maxStep: number) => useQuizPreviewStore.setState(
state => ({ currentQuestionIndex: Math.min(state.currentQuestionIndex + 1, maxStep) })
);

@ -175,9 +175,44 @@ export const deleteQuiz = async (quizId: string) => requestQueue.enqueue(async (
enqueueSnackbar(`Не удалось удалить квиз. ${message}`);
}
});
export const updateRootInfo = (quizId: string, have:boolean) => updateQuiz(
quizId,
quiz => {
quiz.config.haveRoot = have;
},
);
// TODO copy quiz
export const uploadQuizImage = async (
quizId: string,
blob: Blob,
updateFn: (quiz: Quiz, imageId: string) => void,
) => {
const quiz = useQuizStore.getState().quizes.find(q => q.id === quizId);
if (!quiz) return;
try {
const response = await quizApi.addImages(quiz.backendId, blob);
const values = Object.values(response);
if (values.length !== 1) {
console.warn("Error uploading image");
return;
}
const imageId = values[0];
updateQuiz(quizId, quiz => {
updateFn(quiz, `https://squiz.pena.digital/squizimages/${quiz.qid}/${imageId}`);
});
} catch (error) {
devlog("Error uploading quiz image", error);
enqueueSnackbar("Не удалось загрузить изображение");
}
};
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuizStore) => void,
action?: A,

@ -86,7 +86,7 @@ export default () => {
>E-mail</Typography>
<TextField
value={mail}
onChange={(e) => setContactFormMailField(e.target.value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setContactFormMailField(e.target.value)}
placeholder="username@penahaub.com"
name="name"
fullWidth

@ -12,12 +12,11 @@ import {
useMediaQuery,
useTheme,
} from "@mui/material";
import { closeCropModal, resetToOriginalImage, setCropModalImageUrl, useCropModalStore } from "@root/cropModal";
import { FC, useRef, useState } from "react";
import { FC, useMemo, useRef, useState } from "react";
import ReactCrop, { Crop, PixelCrop } from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";
import { canvasPreview } from "./utils/canvasPreview";
import { enqueueSnackbar } from "notistack";
import { setCropModalImageBlob, useCropModalStore } from "@root/cropModal";
const styleSlider: SxProps<Theme> = {
@ -45,21 +44,25 @@ const styleSlider: SxProps<Theme> = {
};
interface Props {
onSaveImageClick?: (imageUrl: string) => void;
isOpen: boolean;
onClose: () => void;
onSaveImageClick?: (imageBlob: Blob) => void;
}
export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
export const CropModal: FC<Props> = ({isOpen, onSaveImageClick, onClose }) => {
const theme = useTheme();
const isCropModalOpen = useCropModalStore(state => state.isCropModalOpen);
const imageUrl = useCropModalStore(state => state.imageUrl);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const imageBlob = useCropModalStore(state => state.imageBlob);
const originalImageUrl = useCropModalStore(state => state.originalImageUrl);
const [darken, setDarken] = useState(0);
const [rotate, setRotate] = useState(0);
const [width, setWidth] = useState<number>(0);
const cropImageElementRef = useRef<HTMLImageElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down(786));
const imageUrl = useMemo(() => imageBlob && URL.createObjectURL(imageBlob), [imageBlob]);
const handleCropClick = async () => {
if (!completedCrop) throw new Error("No completed crop");
if (!cropImageElementRef.current) throw new Error("No image");
@ -75,27 +78,31 @@ export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
await canvasPreview(cropImageElementRef.current, canvasCopy, completedCrop, rotate);
canvasCopy.toBlob((blob) => {
if (!blob) {
throw new Error("Failed to create blob");
}
const newImageUrl = URL.createObjectURL(blob);
if (!blob) throw new Error("Failed to create blob");
setCropModalImageUrl(newImageUrl);
setCropModalImageBlob(blob);
setCrop(undefined);
setCompletedCrop(undefined);
});
};
function handleSaveClick() {
if (imageUrl) onSaveImageClick?.(imageUrl);
if (imageBlob) onSaveImageClick?.(imageBlob);
setCrop(undefined);
setCompletedCrop(undefined);
closeCropModal();
onClose();
}
function handleLoadOriginalImage() {
const isSuccess = resetToOriginalImage();
if (!isSuccess) enqueueSnackbar("Не удалось восстановить оригинал. Приносим глубочайшие извинения");
async function handleLoadOriginalImage() {
if (!originalImageUrl) return;
const response = await fetch(originalImageUrl);
const blob = await response.blob();
onSaveImageClick?.(blob);
setCropModalImageBlob(blob);
setCrop(undefined);
setCompletedCrop(undefined);
}
const getImageSize = () => {
@ -119,8 +126,8 @@ export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
return (
<Modal
open={isCropModalOpen}
onClose={closeCropModal}
open={ isOpen}
onClose={onClose}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
@ -257,6 +264,7 @@ export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
<Button
onClick={handleLoadOriginalImage}
disableRipple
disabled={!originalImageUrl}
sx={{
width: "215px",
height: "48px",
@ -288,4 +296,4 @@ export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
</Box>
</Modal>
);
};
};

@ -1,14 +1,11 @@
import { Box, Button } from "@mui/material";
import { FC } from "react";
import { CropModal } from "./CropModal";
import { openCropModal } from "@root/cropModal";
const ImageCrop: FC = () => {
return (
<Box>
<Button onClick={() => openCropModal("", "")}>Открыть модалку</Button>
<CropModal />
<Button onClick={undefined}>Открыть модалку</Button>
</Box>
);
};

@ -1,9 +1,10 @@
import { Box, Button, LinearProgress, Paper, Typography } from "@mui/material";
import { Box, Button, LinearProgress, Paper, Typography, FormControl, Select as MuiSelect, MenuItem, useTheme } from "@mui/material";
import { useQuestionsStore } from "@root/questions/store";
import {
decrementCurrentQuestionIndex,
incrementCurrentQuestionIndex,
useQuizPreviewStore,
setCurrentQuestionIndex
} from "@root/quizPreview";
import { AnyTypedQuizQuestion, UntypedQuizQuestion } from "model/questionTypes/shared";
import { useEffect } from "react";
@ -20,9 +21,10 @@ import Text from "./QuizPreviewQuestionTypes/Text";
import Variant from "./QuizPreviewQuestionTypes/Variant";
import Varimg from "./QuizPreviewQuestionTypes/Varimg";
import { notReachable } from "../../utils/notReachable";
import ArrowDownIcon from "@icons/ArrowDownIcon";
export default function QuizPreviewLayout() {
const theme = useTheme();
const questions = useQuestionsStore(state => state.questions);
const currentQuizStep = useQuizPreviewStore(
(state) => state.currentQuestionIndex
@ -67,7 +69,9 @@ export default function QuizPreviewLayout() {
whiteSpace: "break-spaces",
overflowY: "auto",
flexGrow: 1,
"&::-webkit-scrollbar": { width: 0 },
"&::-webkit-scrollbar": { width: 0, display: "none" },
msOverflowStyle: "none",
scrollbarWidth: "none",
}}
>
<QuestionPreviewComponent question={currentQuestion} />
@ -76,62 +80,138 @@ export default function QuizPreviewLayout() {
sx={{
mt: "auto",
p: "16px",
display: "flex",
borderTop: "1px solid #E3E3E3",
alignItems: "center",
}}
>
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Typography>
{nonDeletedQuizQuestions.length > 0
? `Вопрос ${currentQuizStep + 1} из ${nonDeletedQuizQuestions.length
}`
: "Нет вопросов"}
</Typography>
{nonDeletedQuizQuestions.length > 0 && (
<LinearProgress
variant="determinate"
value={currentProgress}
<Box sx={{ marginBottom: "10px" }}>
<FormControl
fullWidth
size="small"
sx={{ width: "100%", minWidth: "200px", height: "48px" }}
>
<MuiSelect
id="category-select"
variant="outlined"
value={currentQuizStep}
placeholder="Заголовок вопроса"
onChange={({ target }) =>
setCurrentQuestionIndex(window.Number(target.value))
}
sx={{
"&.MuiLinearProgress-colorPrimary": {
backgroundColor: "fadePurple.main",
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: "brightPurple.main",
height: "48px",
borderRadius: "8px",
"& .MuiOutlinedInput-notchedOutline": {
border: `1px solid ${theme.palette.brightPurple.main} !important`,
},
}}
/>
)}
</Box>
<Box
sx={{
ml: 2,
display: "flex",
gap: 1,
}}
>
<Button
variant="outlined"
onClick={decrementCurrentQuestionIndex}
disabled={currentQuizStep === 0}
sx={{ px: 1, minWidth: 0 }}
MenuProps={{
PaperProps: {
sx: {
mt: "8px",
p: "4px",
borderRadius: "8px",
border: "1px solid #EEE4FC",
boxShadow: "0px 8px 24px rgba(210, 208, 225, 0.4)",
},
},
MenuListProps: {
sx: {
py: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
"& .Mui-selected": {
backgroundColor: theme.palette.background.default,
color: theme.palette.brightPurple.main,
},
},
},
}}
inputProps={{
sx: {
color: theme.palette.brightPurple.main,
display: "flex",
alignItems: "center",
px: "9px",
gap: "20px",
},
}}
IconComponent={(props) => <ArrowDownIcon {...props} />}
>
{Object.values(questions).map(
({ id, title }, index) => (
<MenuItem
key={id}
value={index}
sx={{
display: "flex",
alignItems: "center",
gap: "20px",
p: "4px",
borderRadius: "5px",
color: theme.palette.grey2.main,
}}
>
{`${index + 1}. ${title}`}
</MenuItem>
)
)}
</MuiSelect>
</FormControl>
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
flexGrow: 1,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<ArrowLeft />
</Button>
<Button
variant="contained"
onClick={() => incrementCurrentQuestionIndex(maxCurrentQuizStep)}
disabled={currentQuizStep >= maxCurrentQuizStep}
<Typography>
{nonDeletedQuizQuestions.length > 0
? `Вопрос ${currentQuizStep + 1} из ${nonDeletedQuizQuestions.length
}`
: "Нет вопросов"}
</Typography>
{nonDeletedQuizQuestions.length > 0 && (
<LinearProgress
variant="determinate"
value={currentProgress}
sx={{
"&.MuiLinearProgress-colorPrimary": {
backgroundColor: "fadePurple.main",
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: "brightPurple.main",
},
}}
/>
)}
</Box>
<Box
sx={{
ml: 2,
display: "flex",
gap: 1,
}}
>
Далее
</Button>
<Button
variant="outlined"
onClick={decrementCurrentQuestionIndex}
disabled={currentQuizStep === 0}
sx={{ px: 1, minWidth: 0 }}
>
<ArrowLeft />
</Button>
<Button
variant="contained"
onClick={() => incrementCurrentQuestionIndex(maxCurrentQuizStep)}
disabled={currentQuizStep >= maxCurrentQuizStep}
>
Далее
</Button>
</Box>
</Box>
</Box>
</Paper>

@ -8,11 +8,7 @@ export class RequestQueue<T = unknown> {
enqueue(action: () => Promise<T>) {
return new Promise((resolve, reject) => {
if (this.items.length === 2) {
this.items[1] = { action, resolve, reject };
} else {
this.items.push({ action, resolve, reject });
}
this.items.push({ action, resolve, reject });
this.dequeue();
});
}

@ -0,0 +1,13 @@
import { useState, useCallback } from "react";
export function useDisclosure(initialState = false) {
const [opened, setOpened] = useState(initialState);
return [
opened,
useCallback(() => setOpened(true), []),
useCallback(() => setOpened(false), []),
useCallback(() => setOpened(prev => !prev), []),
] as const;
}

@ -1890,7 +1890,7 @@
schema-utils "^3.0.0"
source-map "^0.7.3"
"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
"@popperjs/core@^2.0.0", "@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
version "2.11.8"
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
@ -2232,11 +2232,24 @@
dependencies:
"@types/node" "*"
"@types/cytoscape-popper@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/cytoscape-popper/-/cytoscape-popper-2.0.4.tgz#cd1e81d28b202e2bfc5608e0e60ae53c908bf0e3"
integrity sha512-vGRiAMXeEIoY5ziPO0NrS8xmJyVkT8j8ARzvyD/x6CXciAO1+80Q2/Triyd9/+5I4PeatGo1Pch5YBwDMB1D6A==
dependencies:
"@popperjs/core" "^2.0.0"
"@types/cytoscape" "*"
"@types/cytoscape@*":
version "3.19.13"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.19.13.tgz#2e4c5df1e85d4ad77b7bf1375f0f01c8f26457b1"
integrity sha512-FZY6Zyh8kGqEwZ6jizCJa5rbKriOWqJaEoGR8AG2LNQyo5sy1q7hFNo8ojbRpHNBluBmCJa+/w//OWaehxolgA==
"@types/cytoscape@^3.19.16":
version "3.19.16"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.19.16.tgz#c54ad4ff5c53a0046f42d1b08a11e730ad83dcc1"
integrity sha512-A3zkjaZ6cOGyqEvrVuC1YUgiRSJhDZOj8Qhd1ALH2/+YxH2za1BOmR4RWQsKYHsc+aMP/IWoqg1COuUbZ39t/g==
"@types/eslint-scope@^3.7.3":
version "3.7.4"
resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz"
@ -4192,6 +4205,13 @@ cypress@^13.4.0:
untildify "^4.0.0"
yauzl "^2.10.0"
cytoscape-popper@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/cytoscape-popper/-/cytoscape-popper-2.0.0.tgz#d93917695a9b8af3dbda1d8ee433618ac4d4e359"
integrity sha512-b7WSOn8qXHWtdIXFNmrgc8qkaOs16tMY0EwtRXlxzvn8X+al6TAFrUwZoYATkYSlotfd/36ZMoeKMEoUck6feA==
dependencies:
"@popperjs/core" "^2.0.0"
cytoscape@^3.26.0:
version "3.26.0"
resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.26.0.tgz#b4c6961445fd51e1fd3cca83c3ffe924d9a8abc9"
@ -10063,10 +10083,10 @@ typedarray-to-buffer@^3.1.5:
dependencies:
is-typedarray "^1.0.0"
typescript@^4.4.2:
version "4.9.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz"
integrity sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==
typescript@^5.2.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.2.tgz#00d1c7c1c46928c5845c1ee8d0cc2791031d4c43"
integrity sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==
unbox-primitive@^1.0.2:
version "1.0.2"