merge graph && quiz-preview-e2e-tests

This commit is contained in:
Nastya 2023-10-29 14:21:20 +03:00
commit aefc939965
60 changed files with 2544 additions and 32933 deletions

2
.gitignore vendored

@ -21,3 +21,5 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.idea

8
.idea/.gitignore vendored

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/squiz.iml" filepath="$PROJECT_DIR$/.idea/squiz.iml" />
</modules>
</component>
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

10
cypress.config.ts Normal file

@ -0,0 +1,10 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1440,
viewportHeight: 900,
supportFile: false,
},
});

@ -0,0 +1,52 @@
describe("Quiz preview", () => {
beforeEach(() => {
cy.visit("/list");
cy.get("[data-cy=create-quiz]").click();
cy.get("[data-cy=create-quiz-card]").click();
cy.get("[data-cy=select-quiz-layout-standard]").click();
cy.get("[data-cy=setup-questions]").click();
});
it("container and layout elements should exist", () => {
cy.get("[data-cy=quiz-preview-container]").should("exist");
cy.get("[data-cy=quiz-preview-layout]").should("exist");
});
describe("Variant question", () => {
beforeEach(function fillTitleAndOptions() {
cy.get("[data-cy=expand-question]").click();
cy.get("[data-cy=select-questiontype-variant]").click();
cy.get("[data-cy=quiz-question-card]").eq(0).within(() => {
cy.get("[data-cy=quiz-question-title]").type("Question Title");
cy.get("[data-cy=quiz-variant-question-answer]").eq(0).type("Answer 1{enter}");
cy.get("[data-cy=quiz-variant-question-answer]").eq(1).type("Answer 2{enter}");
cy.get("[data-cy=quiz-variant-question-answer]").eq(2).type("Answer 3");
});
});
it("should contain title and options, and be selected properly", () => {
cy.get("[data-cy=quiz-preview-layout]").within(() => {
cy.get("[data-cy=variant-title]").should("have.text", "Question Title");
cy.get("[data-cy=variant-answer]").eq(0).should("have.text", "Answer 1");
cy.get("[data-cy=variant-answer]").eq(1).should("have.text", "Answer 2");
cy.get("[data-cy=variant-answer]").eq(2).should("have.text", "Answer 3");
cy.get("[data-cy=variant-answer]").eq(0).click();
cy.get("[data-cy=variant-radio]").eq(0).should("be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("not.be.checked");
cy.get("[data-cy=variant-answer]").eq(1).click();
cy.get("[data-cy=variant-radio]").eq(0).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("not.be.checked");
cy.get("[data-cy=variant-answer]").eq(2).click();
cy.get("[data-cy=variant-radio]").eq(0).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(1).should("not.be.checked");
cy.get("[data-cy=variant-radio]").eq(2).should("be.checked");
});
});
});
});

12
cypress/tsconfig.json Normal file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"noEmit": true,
"types": [
"cypress"
]
},
"include": [
"../node_modules/cypress",
"./**/*.ts"
]
}

31358
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -49,23 +49,14 @@
"start": "craco start", "start": "craco start",
"build": "craco build", "build": "craco build",
"test": "craco test", "test": "craco test",
"eject": "craco eject" "eject": "craco eject",
"cypress:open": "cypress open"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
"react-app", "react-app",
"react-app/jest" "react-app/jest"
],
"rules": {
"quotes": [
"warn",
"double",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
] ]
}
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

@ -24,6 +24,7 @@ export const QUIZ_QUESTION_BASE: Omit<QuizQuestionInitial, "id"> = {
], ],
}, },
back: "", back: "",
originalBack: "",
autofill: false, autofill: false,
}, },
}; };

@ -16,6 +16,7 @@ export const QUIZ_QUESTION_EMOJI: Omit<QuizQuestionEmoji, "id"> = {
{ {
answer: "", answer: "",
extendedText: "", extendedText: "",
hints: ""
}, },
], ],
}, },

@ -19,6 +19,8 @@ export const QUIZ_QUESTION_IMAGES: Omit<QuizQuestionImages, "id"> = {
{ {
answer: "", answer: "",
extendedText: "", extendedText: "",
originalImageUrl: "",
hints: ""
}, },
], ],
largeCheck: false, largeCheck: false,

@ -11,6 +11,7 @@ export const QUIZ_QUESTION_PAGE: Omit<QuizQuestionPage, "id"> = {
innerName: "", innerName: "",
text: "", text: "",
picture: "", picture: "",
originalPicture: "",
video: "", video: "",
}, },
}; };

@ -12,6 +12,6 @@ export const QUIZ_QUESTION_SELECT: Omit<QuizQuestionSelect, "id"> = {
innerNameCheck: false, innerNameCheck: false,
innerName: "", innerName: "",
default: "", default: "",
variants: [{ answer: "", extendedText: "" }], variants: [{ answer: "", extendedText: "", hints: "" }],
}, },
}; };

@ -13,6 +13,6 @@ export const QUIZ_QUESTION_VARIANT: Omit<QuizQuestionVariant, "id"> = {
innerNameCheck: false, innerNameCheck: false,
required: false, required: false,
innerName: "", innerName: "",
variants: [{ answer: "", extendedText: "" }], variants: [{ answer: "", extendedText: "", hints: "" }],
}, },
}; };

@ -11,7 +11,7 @@ export const QUIZ_QUESTION_VARIMG: Omit<QuizQuestionVarImg, "id"> = {
innerNameCheck: false, innerNameCheck: false,
innerName: "", innerName: "",
required: false, required: false,
variants: [{ answer: "", extendedText: "" }], variants: [{ answer: "", hints: "", extendedText: "", originalImageUrl: "" }],
largeCheck: false, largeCheck: false,
replText: "", replText: "",
}, },

@ -5,7 +5,7 @@ import { HTML5Backend } from "react-dnd-html5-backend";
import "./index.css"; import "./index.css";
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import lightTheme from "./utils/themes/light"; import lightTheme from "./utils/themes/light";
import { ThemeProvider } from "@mui/material"; import { CssBaseline, ThemeProvider } from "@mui/material";
import StartPage from "./pages/startPage/StartPage"; import StartPage from "./pages/startPage/StartPage";
import Main from "./pages/main"; import Main from "./pages/main";
import QuestionsPage from "./pages/Questions/QuestionsPage"; import QuestionsPage from "./pages/Questions/QuestionsPage";
@ -50,6 +50,7 @@ root.render(
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru" localeText={localeText}>
<ThemeProvider theme={lightTheme}> <ThemeProvider theme={lightTheme}>
<SnackbarProvider> <SnackbarProvider>
<CssBaseline />
<ContactFormModal /> <ContactFormModal />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>

@ -18,6 +18,7 @@ export interface QuizQuestionDate extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -22,6 +22,7 @@ export interface QuizQuestionEmoji extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -29,5 +29,6 @@ export interface QuizQuestionFile extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
}; };
} }

@ -1,8 +1,8 @@
import type { import type {
QuizQuestionBase, ImageQuestionVariant,
QuestionVariant,
QuestionHint,
QuestionBranchingRule, QuestionBranchingRule,
QuestionHint,
QuizQuestionBase
} from "./shared"; } from "./shared";
export interface QuizQuestionImages extends QuizQuestionBase { export interface QuizQuestionImages extends QuizQuestionBase {
@ -25,10 +25,11 @@ export interface QuizQuestionImages extends QuizQuestionBase {
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
/** Варианты (картинки) */ /** Варианты (картинки) */
variants: QuestionVariant[]; variants: ImageQuestionVariant[];
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
largeCheck: boolean; largeCheck: boolean;
}; };

@ -27,6 +27,7 @@ export interface QuizQuestionNumber extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
form: "star" | "trophie" | "flag" | "heart" | "like" | "bubble" | "hashtag"; form: "star" | "trophie" | "flag" | "heart" | "like" | "bubble" | "hashtag";
}; };

@ -13,10 +13,12 @@ export interface QuizQuestionPage extends QuizQuestionBase {
innerName: string; innerName: string;
text: string; text: string;
picture: string; picture: string;
originalPicture: string;
video: string; video: string;
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -20,6 +20,7 @@ export interface QuizQuestionRating extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
/** Позитивное описание рейтинга */ /** Позитивное описание рейтинга */
ratingPositiveDescription: string; ratingPositiveDescription: string;

@ -22,6 +22,7 @@ export interface QuizQuestionSelect extends QuizQuestionBase {
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
hint: QuestionHint; hint: QuestionHint;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -32,10 +32,17 @@ export interface QuestionHint {
export type QuestionVariant = { export type QuestionVariant = {
/** Текст */ /** Текст */
answer: string; answer: string;
/** Текст подсказки */
hints: string;
/** Дополнительное поле для текста, emoji, ссылки на картинку */ /** Дополнительное поле для текста, emoji, ссылки на картинку */
extendedText: string; extendedText: string;
}; };
export interface ImageQuestionVariant extends QuestionVariant {
/** Оригинал изображения (до кропа) */
originalImageUrl: string;
}
export interface QuizQuestionBase { export interface QuizQuestionBase {
id: number; id: number;
title: string; title: string;
@ -48,6 +55,7 @@ export interface QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -20,5 +20,6 @@ export interface QuizQuestionText extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
}; };
} }

@ -25,6 +25,7 @@ export interface QuizQuestionVariant extends QuizQuestionBase {
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
}; };
} }

@ -1,8 +1,8 @@
import type { import type {
QuizQuestionBase, ImageQuestionVariant,
QuestionVariant,
QuestionHint,
QuestionBranchingRule, QuestionBranchingRule,
QuestionHint,
QuizQuestionBase
} from "./shared"; } from "./shared";
export interface QuizQuestionVarImg extends QuizQuestionBase { export interface QuizQuestionVarImg extends QuizQuestionBase {
@ -16,10 +16,11 @@ export interface QuizQuestionVarImg extends QuizQuestionBase {
innerName: string; innerName: string;
/** Чекбокс "Необязательный вопрос" */ /** Чекбокс "Необязательный вопрос" */
required: boolean; required: boolean;
variants: QuestionVariant[]; variants: ImageQuestionVariant[];
hint: QuestionHint; hint: QuestionHint;
rule: QuestionBranchingRule; rule: QuestionBranchingRule;
back: string; back: string;
originalBack: string;
autofill: boolean; autofill: boolean;
largeCheck: boolean; largeCheck: boolean;
replText: string; replText: string;

7
src/mui.d.ts vendored

@ -41,3 +41,10 @@ declare module "@mui/material/Typography" {
p1: true; p1: true;
} }
} }
type DataAttributeKey = `data-${string}`;
declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
[dataAttribute: DataAttributeKey]: unknown;
}
}

@ -7,6 +7,7 @@ import {
FormControl, FormControl,
InputAdornment, InputAdornment,
IconButton, IconButton,
Popover,
useTheme, useTheme,
useMediaQuery, useMediaQuery,
} from "@mui/material"; } from "@mui/material";
@ -16,8 +17,11 @@ import { questionStore, updateQuestionsList } from "@root/questions";
import { PointsIcon } from "@icons/questionsPage/PointsIcon"; import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon"; import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { MessageIcon } from "@icons/messagIcon";
import TextareaAutosize from "@mui/base/TextareaAutosize";
import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import type { DroppableProvided } from "react-beautiful-dnd"; import type { DroppableProvided } from "react-beautiful-dnd";
import type { QuizQuestionVariant } from "../../../model/questionTypes/variant"; import type { QuizQuestionVariant } from "../../../model/questionTypes/variant";
import type { QuestionVariant } from "../../../model/questionTypes/shared"; import type { QuestionVariant } from "../../../model/questionTypes/shared";
@ -49,7 +53,6 @@ export const AnswerItem = ({
const debounced = useDebouncedCallback((value) => { const debounced = useDebouncedCallback((value) => {
const answerNew = variants.slice(); const answerNew = variants.slice();
answerNew[index].answer = value; answerNew[index].answer = value;
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, { updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { content: {
...question.content, ...question.content,
@ -58,9 +61,23 @@ export const AnswerItem = ({
}); });
}, 1000); }, 1000);
console.log(provided)
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
};
const addNewAnswer = () => { const addNewAnswer = () => {
const answerNew = variants.slice(); const answerNew = variants.slice();
answerNew.push({ answer: "", extendedText: "" }); answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, { updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew }, content: { ...question.content, variants: answerNew },
@ -76,8 +93,20 @@ export const AnswerItem = ({
}); });
}; };
const changeAnswerHint = (event: ChangeEvent<HTMLTextAreaElement>) => {
const answerNew = variants.slice();
answerNew[index].hints = event.target.value;
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew },
});
};
return ( return (
<Box> <Draggable draggableId={String(index)} index={index}>
{(provided) => (
<Box ref={provided.innerRef} {...provided.draggableProps}>
<FormControl <FormControl
key={index} key={index}
fullWidth fullWidth
@ -104,14 +133,49 @@ export const AnswerItem = ({
InputProps={{ InputProps={{
startAdornment: ( startAdornment: (
<> <>
<InputAdornment {...provided.droppableProps} position="start"> <InputAdornment
<PointsIcon style={{ color: "#9A9AAF", fontSize: "30px" }} /> {...provided.dragHandleProps}
position="start"
>
<PointsIcon
style={{ color: "#9A9AAF", fontSize: "30px" }}
/>
</InputAdornment> </InputAdornment>
{additionalContent} {additionalContent}
</> </>
), ),
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton
sx={{ padding: "0" }}
aria-describedby="my-popover-id"
onClick={handleClick}
>
<MessageIcon
style={{
color: "#9A9AAF",
fontSize: "30px",
marginRight: "6.5px",
}}
/>
</IconButton>
<Popover
id="my-popover-id"
open={isOpen}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
>
<TextareaAutosize
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
value={variant.hints}
onChange={changeAnswerHint}
onKeyDown={(
event: KeyboardEvent<HTMLTextAreaElement>
) => event.stopPropagation()}
/>
</Popover>
<IconButton sx={{ padding: "0" }} onClick={deleteAnswer}> <IconButton sx={{ padding: "0" }} onClick={deleteAnswer}>
<DeleteIcon <DeleteIcon
style={{ style={{
@ -125,11 +189,7 @@ export const AnswerItem = ({
}} }}
sx={{ sx={{
"& .MuiInputBase-root": { "& .MuiInputBase-root": {
padding: additionalContent padding: additionalContent ? "5px 13px" : "13px",
? isTablet
? "13px"
: "5px 13px"
: "13px",
borderRadius: "10px", borderRadius: "10px",
background: "#ffffff", background: "#ffffff",
"& input.MuiInputBase-input": { "& input.MuiInputBase-input": {
@ -150,5 +210,8 @@ export const AnswerItem = ({
{additionalMobile} {additionalMobile}
</FormControl> </FormControl>
</Box> </Box>
)}
</Draggable>
); );
}; };

@ -4,21 +4,21 @@ import { DragDropContext, Droppable } from "react-beautiful-dnd";
import { AnswerItem } from "./AnswerItem"; import { AnswerItem } from "./AnswerItem";
import { updateVariants } from "@root/questions"; import { reorderVariants } from "@root/questions";
import { reorder } from "./helper"; import { type ReactNode } from "react";
import type { ReactNode } from "react";
import type { DropResult } from "react-beautiful-dnd"; import type { DropResult } from "react-beautiful-dnd";
import type { QuestionVariant } from "../../../model/questionTypes/shared"; import type { ImageQuestionVariant, QuestionVariant } from "../../../model/questionTypes/shared";
type AnswerDraggableListProps = { type AnswerDraggableListProps = {
variants: QuestionVariant[]; variants: QuestionVariant[];
totalIndex: number; totalIndex: number;
additionalContent?: (variant: QuestionVariant, index: number) => ReactNode; additionalContent?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
additionalMobile?: (variant: QuestionVariant, index: number) => ReactNode; additionalMobile?: (variant: QuestionVariant | ImageQuestionVariant, index: number) => ReactNode;
}; };
export const AnswerDraggableList = ({ export const AnswerDraggableList = ({
variants, variants,
totalIndex, totalIndex,
@ -29,9 +29,7 @@ export const AnswerDraggableList = ({
const onDragEnd = ({ destination, source }: DropResult) => { const onDragEnd = ({ destination, source }: DropResult) => {
if (destination) { if (destination) {
const newItems = reorder(variants, source.index, destination.index); reorderVariants(quizId, totalIndex, source.index, destination.index);
updateVariants(quizId, totalIndex, newItems);
} }
}; };

@ -171,6 +171,7 @@ export default function QuestionsPageCard({
<> <>
<Paper <Paper
id={String(totalIndex)} id={String(totalIndex)}
data-cy="quiz-question-card"
sx={{ sx={{
maxWidth: "796px", maxWidth: "796px",
width: "100%", width: "100%",
@ -248,6 +249,7 @@ export default function QuestionsPageCard({
py: 0, py: 0,
paddingLeft: question.type.length === 0 ? 0 : "18px", paddingLeft: question.type.length === 0 ? 0 : "18px",
}, },
"data-cy": "quiz-question-title",
}} }}
/> />
</FormControl> </FormControl>
@ -263,6 +265,7 @@ export default function QuestionsPageCard({
<IconButton <IconButton
sx={{ padding: "0", margin: "5px" }} sx={{ padding: "0", margin: "5px" }}
disableRipple disableRipple
data-cy="expand-question"
onClick={() => onClick={() =>
updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, { updateQuestionsList<QuizQuestionInitial>(quizId, totalIndex, {
expanded: !question.expanded, expanded: !question.expanded,

@ -29,7 +29,7 @@ export default function DropDown({ totalIndex }: Props) {
const addNewAnswer = () => { const addNewAnswer = () => {
const answerNew = question.content.variants.slice(); const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" }); answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionSelect>(quizId, totalIndex, { updateQuestionsList<QuizQuestionSelect>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew }, content: { ...question.content, variants: answerNew },

@ -213,7 +213,7 @@ export default function Emoji({ totalIndex }: Props) {
sx={{ color: theme.palette.brightPurple.main }} sx={{ color: theme.palette.brightPurple.main }}
onClick={() => { onClick={() => {
const answerNew = question.content.variants.slice(); const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" }); answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionEmoji>(quizId, totalIndex, { updateQuestionsList<QuizQuestionEmoji>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew }, content: { ...question.content, variants: answerNew },

@ -1,4 +1,3 @@
import { useState } from "react";
import { import {
Box, Box,
Link, Link,
@ -12,35 +11,32 @@ import {
TextareaAutosize, TextareaAutosize,
TextField, TextField,
} from "@mui/material"; } from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { CropModal } from "@ui_kit/Modal/CropModal"; import { CropModal } from "@ui_kit/Modal/CropModal";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { UploadImageModal } from "../UploadImage/UploadImageModal"; import { UploadImageModal } from "../UploadImage/UploadImageModal";
import AddImage from "@icons/questionsPage/addImage"; import { ImageAddIcons } from "@icons/ImageAddIcons";
import Image from "@icons/questionsPage/image"; import { MessageIcon } from "@icons/messagIcon";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import { questionStore, setVariantImageUrl, setVariantOriginalImageUrl, updateQuestionsList } from "@root/questions";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import ButtonsOptionsAndPict from "../ButtonsOptionsAndPict"; import ButtonsOptionsAndPict from "../ButtonsOptionsAndPict";
import SwitchOptionsAndPict from "./switchOptionsAndPict"; import SwitchOptionsAndPict from "./switchOptionsAndPict";
import React from "react";
import { questionStore, updateQuestionsList } from "@root/questions";
import { ImageAddIcons } from "@icons/ImageAddIcons";
import { PointsIcon } from "@icons/questionsPage/PointsIcon";
import { MessageIcon } from "@icons/messagIcon";
import { DeleteIcon } from "@icons/questionsPage/deleteIcon";
import PlusImage from "@icons/questionsPage/plus";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg"; import type { QuizQuestionVarImg } from "../../../model/questionTypes/varimg";
import { produce } from "immer";
interface Props { interface Props {
totalIndex: number; totalIndex: number;
} }
export default function OptionsAndPicture({ totalIndex }: Props) { export default function OptionsAndPicture({ totalIndex }: Props) {
const [open, setOpen] = useState(false); const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const [opened, setOpened] = useState<boolean>(false);
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const [currentIndex, setCurrentIndex] = useState<number>(0); const [currentIndex, setCurrentIndex] = useState<number>(0);
const quizId = Number(useParams().quizId); const quizId = Number(useParams().quizId);
@ -54,22 +50,22 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
setSwitchState(data); setSwitchState(data);
}; };
const uploadImage = (files: FileList | null) => { const handleImageUpload = (files: FileList | null) => {
if (files?.length) { if (!files?.length) return;
const [file] = Array.from(files); const [file] = Array.from(files);
const url = URL.createObjectURL(file);
const clonedContent = { ...question.content }; setVariantImageUrl(quizId, totalIndex, currentIndex, url);
clonedContent.variants[currentIndex].extendedText = setVariantOriginalImageUrl(quizId, totalIndex, currentIndex, url);
URL.createObjectURL(file); setIsUploadImageModalOpen(false);
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, { openCropModal(url, url);
content: clonedContent,
});
setOpen(false);
setOpened(true);
}
}; };
function handleCropModalSaveClick(url: string) {
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
}
return ( return (
<> <>
<Box sx={{ pl: "20px", pr: "20px" }}> <Box sx={{ pl: "20px", pr: "20px" }}>
@ -79,107 +75,217 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
additionalContent={(variant, index) => ( additionalContent={(variant, index) => (
<> <>
{!isMobile && ( {!isMobile && (
<Box <AddOrEditImageButton
sx={{ cursor: "pointer" }} imageSrc={variant.extendedText}
onClick={() => { onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index); setCurrentIndex(index);
setOpen(true); if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
setIsUploadImageModalOpen(true);
}} }}
> onPlusClick={() => {
{variant.extendedText ? ( setCurrentIndex(index);
<Box setIsUploadImageModalOpen(true);
sx={{
overflow: "hidden",
width: "60px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
borderRadius: "3px",
margin: "0 10px",
height: "40px",
}} }}
> sx={{ mx: "10px" }}
<Box sx={{ display: "flex", width: "40px" }}>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/> />
</Box>
<PlusImage />
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<AddImage
sx={{
height: "40px",
width: "60px",
margin: "0 10px",
}}
/>
</Button>
)}
</Box>
)} )}
</> </>
)} )}
additionalMobile={(variant, index) => ( additionalMobile={(variant, index) => (
<> <>
{isMobile && ( {isMobile && (
<Box <AddOrEditImageButton
onClick={() => { imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index); setCurrentIndex(index);
setOpen(true); if (variant.extendedText) return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
setIsUploadImageModalOpen(true);
}}
onPlusClick={() => {
setCurrentIndex(index);
setIsUploadImageModalOpen(true);
}}
sx={{ m: "8px", width: "auto" }}
/>
)}
</>
)}
/>
<UploadImageModal
open={isUploadImageModalOpen}
onClose={() => setIsUploadImageModalOpen(false)}
imgHC={handleImageUpload}
/>
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box
sx={{
width: "100%",
border: "1px solid #9A9AAF",
borderRadius: "8px",
display: isTablet ? "block" : "none",
}}
>
<TextField
fullWidth
focused={false}
placeholder={"Добавьте ответ"}
multiline={question.content.largeCheck}
InputProps={{
startAdornment: (
<>
<InputAdornment position="start">
<PointsIcon
style={{ color: "#9A9AAF", fontSize: "30px" }}
/>
</InputAdornment>
{!isMobile && (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
marginRight: "20px",
marginLeft: "12px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
}}
>
<ImageAddIcons fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
</>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
sx={{ padding: "0" }}
aria-describedby="my-popover-id"
>
<MessageIcon
style={{
color: "#9A9AAF",
fontSize: "30px",
marginRight: "6.5px",
}}
/>
</IconButton>
<Popover
id="my-popover-id"
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
open={false}
>
<TextareaAutosize
style={{ margin: "10px" }}
placeholder="Подсказка для этого ответа"
/>
</Popover>
<IconButton sx={{ padding: "0" }}>
<DeleteIcon
style={{
color: theme.palette.grey2.main,
marginRight: "-1px",
}}
/>
</IconButton>
</InputAdornment>
),
}} }}
sx={{ sx={{
overflow: "hidden", "& .MuiInputBase-root": {
padding: "13.5px",
borderRadius: "10px",
background: "#ffffff",
height: "48px",
},
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
},
}}
inputProps={{
sx: { fontSize: "18px", lineHeight: "21px", py: 0 },
}}
/>
{isMobile && (
<Box
sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
m: "8px", m: "8px",
position: "relative", position: "relative",
borderRadius: "3px",
}} }}
> >
<Box
sx={{ width: "100%", background: "#EEE4FC", height: "40px" }}
/>
<ImageAddIcons
style={{
position: "absolute",
color: "#7E2AEA",
fontSize: "20px",
left: "45%",
right: "55%",
}}
/>
<Box <Box
sx={{ sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex", display: "flex",
alignItems: "center",
justifyContent: "center", justifyContent: "center",
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "40px",
display: "flex",
alignItems: "center", alignItems: "center",
width: "20px",
background: "#EEE4FC", background: "#EEE4FC",
height: "30px", height: "40px",
borderRadius: "3px", color: "white",
backgroundColor: "#7E2AEA",
}} }}
> >
<img <Box
src={variant.extendedText} sx={{ width: "100%", background: "#EEE4FC", height: "40px" }}
alt=""
style={{ width: "100%" }}
/> />
</Box> <ImageAddIcons
) : ( style={{
<Button component="label" sx={{ padding: "0px" }}> position: "absolute",
<Image color: "#7E2AEA",
sx={{ fontSize: "20px",
height: "40px", left: "45%",
width: "60px", right: "55%",
margin: "0 10px",
}} }}
/> />
</Button>
)}
</Box>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -195,28 +301,9 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
+ +
</Box> </Box>
</Box> </Box>
</Box>
)} )}
</> </Box>
)}
/>
<UploadImageModal
open={open}
onClose={() => setOpen(false)}
imgHC={uploadImage}
/>
<CropModal
opened={opened}
onClose={() => setOpened(false)}
picture={question.content.variants[currentIndex]?.extendedText}
onCropPress={(url) => {
const content = produce(question.content, (draft) => {
draft.variants[currentIndex].extendedText = url;
});
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content,
});
}}
/>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@ -238,7 +325,9 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
const clonedContent = { ...question.content }; const clonedContent = { ...question.content };
clonedContent.variants.push({ clonedContent.variants.push({
answer: "", answer: "",
hints: "",
extendedText: "", extendedText: "",
originalImageUrl: "",
}); });
updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, { updateQuestionsList<QuizQuestionVarImg>(quizId, totalIndex, {
content: clonedContent, content: clonedContent,
@ -277,5 +366,6 @@ export default function OptionsAndPicture({ totalIndex }: Props) {
/> />
<SwitchOptionsAndPict switchState={switchState} totalIndex={totalIndex} /> <SwitchOptionsAndPict switchState={switchState} totalIndex={totalIndex} />
</> </>
); );
} }

@ -1,5 +1,3 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { import {
Box, Box,
Link, Link,
@ -8,33 +6,31 @@ import {
useTheme, useTheme,
useMediaQuery, useMediaQuery,
} from "@mui/material"; } from "@mui/material";
import { useState } from "react";
import { useParams } from "react-router-dom";
import ButtonsOptions from "../ButtonsOptions"; import { questionStore, setVariantImageUrl, updateQuestionsList, setVariantOriginalImageUrl } from "@root/questions";
import { AnswerDraggableList } from "../AnswerDraggableList";
import { CropModal } from "@ui_kit/Modal/CropModal"; import { CropModal } from "@ui_kit/Modal/CropModal";
import { AnswerDraggableList } from "../AnswerDraggableList";
import ButtonsOptions from "../ButtonsOptions";
import { UploadImageModal } from "../UploadImage/UploadImageModal"; import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { questionStore, updateQuestionsList } from "@root/questions";
import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon"; import { EnterIcon } from "../../../assets/icons/questionsPage/enterIcon";
import AddImage from "../../../assets/icons/questionsPage/addImage";
import Image from "../../../assets/icons/questionsPage/image";
import SwitchAnswerOptionsPict from "./switchOptionsPict"; import SwitchAnswerOptionsPict from "./switchOptionsPict";
import PlusImage from "@icons/questionsPage/plus";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import type { QuizQuestionImages } from "../../../model/questionTypes/images"; import type { QuizQuestionImages } from "../../../model/questionTypes/images";
import { produce } from "immer";
interface Props { interface Props {
totalIndex: number; totalIndex: number;
} }
export default function OptionsPicture({ totalIndex }: Props) { export default function OptionsPicture({ totalIndex }: Props) {
const [open, setOpen] = useState(false); const [isUploadImageModalOpen, setIsUploadImageModalOpen] = useState(false);
const [opened, setOpened] = useState<boolean>(false);
const [currentIndex, setCurrentIndex] = useState<number>(0); const [currentIndex, setCurrentIndex] = useState<number>(0);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(790)); const isMobile = useMediaQuery(theme.breakpoints.down(790));
const isTablet = useMediaQuery(theme.breakpoints.down(790));
const quizId = Number(useParams().quizId); const quizId = Number(useParams().quizId);
const [switchState, setSwitchState] = useState("setting"); const [switchState, setSwitchState] = useState("setting");
const { listQuestions } = questionStore(); const { listQuestions } = questionStore();
@ -44,31 +40,31 @@ export default function OptionsPicture({ totalIndex }: Props) {
setSwitchState(data); setSwitchState(data);
}; };
const uploadImage = (files: FileList | null) => { const handleImageUpload = (files: FileList | null) => {
if (files?.length) { if (!files?.length) return;
const [file] = Array.from(files); const [file] = Array.from(files);
const url = URL.createObjectURL(file);
const clonedContent = { ...question.content }; setVariantImageUrl(quizId, totalIndex, currentIndex, url);
clonedContent.variants[currentIndex].extendedText = setVariantOriginalImageUrl(quizId, totalIndex, currentIndex, url);
URL.createObjectURL(file); setIsUploadImageModalOpen(false);
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, { openCropModal(url, url);
content: clonedContent,
});
setOpen(false);
setOpened(true);
}
}; };
const addNewAnswer = () => { const addNewAnswer = () => {
const answerNew = question.content.variants.slice(); const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" }); answerNew.push({ answer: "", hints: "", extendedText: "", originalImageUrl: "" });
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, { updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew }, content: { ...question.content, variants: answerNew },
}); });
}; };
function handleCropModalSaveClick(url: string) {
setVariantImageUrl(quizId, totalIndex, currentIndex, url);
}
return ( return (
<> <>
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
@ -78,140 +74,64 @@ export default function OptionsPicture({ totalIndex }: Props) {
additionalContent={(variant, index) => ( additionalContent={(variant, index) => (
<> <>
{!isMobile && ( {!isMobile && (
<Box <AddOrEditImageButton
sx={{ cursor: "pointer" }} imageSrc={variant.extendedText}
onClick={() => { onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index); setCurrentIndex(index);
setOpen(true); if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}} }}
> onPlusClick={() => {
{variant.extendedText ? ( setCurrentIndex(index);
<Box setIsUploadImageModalOpen(true);
sx={{
overflow: "hidden",
width: "60px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
borderRadius: "3px",
margin: "0 10px",
height: "40px",
}} }}
> sx={{ mx: "10px" }}
<Box sx={{ display: "flex", width: "40px" }}>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/> />
</Box>
<PlusImage />
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<AddImage
sx={{ height: "40px", width: "60px", margin: "0 10px" }}
/>
</Button>
)}
</Box>
)} )}
</> </>
)} )}
additionalMobile={(variant, index) => ( additionalMobile={(variant, index) => (
<> <>
{isMobile && ( {isMobile && (
<Box <AddOrEditImageButton
onClick={() => { imageSrc={variant.extendedText}
onImageClick={() => {
if (!("originalImageUrl" in variant)) return;
setCurrentIndex(index); setCurrentIndex(index);
setOpen(true); if (variant.extendedText) {
return openCropModal(
variant.extendedText,
variant.originalImageUrl
);
}
setIsUploadImageModalOpen(true);
}} }}
sx={{ onPlusClick={() => {
overflow: "hidden", setCurrentIndex(index);
display: "flex", setIsUploadImageModalOpen(true);
alignItems: "center",
m: "8px",
position: "relative",
borderRadius: "3px",
}} }}
> sx={{ m: "8px", width: "auto" }}
<Box
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{variant.extendedText ? (
<Box
sx={{
overflow: "hidden",
width: "40px",
display: "flex",
alignItems: "center",
background: "#EEE4FC",
height: "30px",
borderRadius: "3px",
}}
>
<img
src={variant.extendedText}
alt=""
style={{ width: "100%" }}
/> />
</Box>
) : (
<Button component="label" sx={{ padding: "0px" }}>
<Image
sx={{
height: "40px",
width: "60px",
margin: "0 10px",
}}
/>
</Button>
)}
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
}}
>
+
</Box>
</Box>
)} )}
</> </>
)} )}
/> />
<UploadImageModal <UploadImageModal
open={open} open={isUploadImageModalOpen}
onClose={() => setOpen(false)} onClose={() => setIsUploadImageModalOpen(false)}
imgHC={uploadImage} imgHC={handleImageUpload}
/>
<CropModal
opened={opened}
onClose={() => setOpened(false)}
picture={question.content.variants[currentIndex]?.extendedText}
onCropPress={(url) => {
const content = produce(question.content, (draft) => {
draft.variants[currentIndex].extendedText = url;
});
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, {
content,
});
}}
/> />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}> <Box sx={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Link <Link
component="button" component="button"
@ -244,15 +164,9 @@ export default function OptionsPicture({ totalIndex }: Props) {
)} )}
</Box> </Box>
</Box> </Box>
<ButtonsOptions <ButtonsOptions switchState={switchState} SSHC={SSHC} totalIndex={totalIndex} />
switchState={switchState} <SwitchAnswerOptionsPict switchState={switchState} totalIndex={totalIndex} />
SSHC={SSHC}
totalIndex={totalIndex}
/>
<SwitchAnswerOptionsPict
switchState={switchState}
totalIndex={totalIndex}
/>
</> </>
); );
} }

@ -1,20 +1,18 @@
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { questionStore, setPageQuestionOriginalPicture, setPageQuestionPicture, updateQuestionsList } from "@root/questions";
import CustomTextField from "@ui_kit/CustomTextField";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Box, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import ButtonsOptions from "../ButtonsOptions"; import ButtonsOptions from "../ButtonsOptions";
import CustomTextField from "@ui_kit/CustomTextField";
import AddImage from "../../../assets/icons/questionsPage/addImage";
import AddVideofile from "../../../assets/icons/questionsPage/addVideofile";
import SwitchPageOptions from "./switchPageOptions";
import { questionStore, updateQuestionsList } from "@root/questions";
import { UploadImageModal } from "../UploadImage/UploadImageModal"; import { UploadImageModal } from "../UploadImage/UploadImageModal";
import { UploadVideoModal } from "../UploadVideoModal"; import { UploadVideoModal } from "../UploadVideoModal";
import { AddPlusImage } from "@icons/questionsPage/addPlusImage"; import SwitchPageOptions from "./switchPageOptions";
import { AddPlusVideo } from "@icons/questionsPage/addPlusVideo";
import { ImageAddIcons } from "@icons/ImageAddIcons";
import { VideofileIcon } from "@icons/questionsPage/VideofileIcon";
import { openCropModal } from "@root/cropModal";
import AddOrEditImageButton from "@ui_kit/AddOrEditImageButton";
import { CropModal } from "@ui_kit/Modal/CropModal";
import type { QuizQuestionPage } from "../../../model/questionTypes/page"; import type { QuizQuestionPage } from "../../../model/questionTypes/page";
type Props = { type Props = {
@ -43,6 +41,21 @@ export default function PageOptions({ disableInput, totalIndex }: Props) {
setSwitchState(data); setSwitchState(data);
}; };
function handleImageUpload(fileList: FileList | null) {
if (!fileList?.length) return;
const url = URL.createObjectURL(fileList[0]);
setPageQuestionPicture(quizId, totalIndex, url);
setPageQuestionOriginalPicture(quizId, totalIndex, url);
setOpenImageModal(false);
openCropModal(url, url);
}
function handleCropModalSaveClick(url: string) {
setPageQuestionPicture(quizId, totalIndex, url);
}
return ( return (
<> <>
<Box <Box
@ -74,7 +87,6 @@ export default function PageOptions({ disableInput, totalIndex }: Props) {
}} }}
> >
<Box <Box
onClick={() => setOpenImageModal(true)}
sx={{ sx={{
cursor: "pointer", cursor: "pointer",
display: "flex", display: "flex",
@ -82,80 +94,22 @@ export default function PageOptions({ disableInput, totalIndex }: Props) {
gap: "20px", gap: "20px",
}} }}
> >
{isMobile ? ( <AddOrEditImageButton
<Box imageSrc={question.content.picture}
sx={{ onImageClick={() => {
display: "flex", if (question.content.picture) {
alignItems: "center", return openCropModal(
width: "120px", question.content.picture,
position: "relative", question.content.originalPicture
);
}
setOpenImageModal(true);
}} }}
> onPlusClick={() => {
<Box setOpenImageModal(true);
sx={{
width: "100%",
background: "#EEE4FC",
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "4px",
borderBottomLeftRadius: "4px",
}}
>
<ImageAddIcons
style={{
color: "#7E2AEA",
fontSize: "20px",
}} }}
/> />
</Box>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "20px",
background: "#EEE4FC",
height: "40px",
color: "white",
backgroundColor: "#7E2AEA",
borderTopRightRadius: "4px",
borderBottomRightRadius: "4px",
}}
>
+
</Box>
</Box>
) : (
<Box
sx={{
width: "60px",
height: "40px",
background: "#EEE4FC",
display: "flex",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", width: "100%" }}>
<ImageAddIcons fontSize="22px" color="#7E2AEA" />
</Box>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#7E2AEA",
height: "100%",
width: "25px",
color: "white",
fontSize: "15px",
}}
>
+
</span>
</Box>
)}
<Typography <Typography
sx={{ sx={{
@ -172,18 +126,9 @@ export default function PageOptions({ disableInput, totalIndex }: Props) {
<UploadImageModal <UploadImageModal
open={openImageModal} open={openImageModal}
onClose={() => setOpenImageModal(false)} onClose={() => setOpenImageModal(false)}
imgHC={(fileList) => { imgHC={handleImageUpload}
if (fileList?.length) {
updateQuestionsList<QuizQuestionPage>(quizId, totalIndex, {
content: {
...question.content,
picture: URL.createObjectURL(fileList[0]),
},
});
}
}}
// onClick={() => setOpenVideoModal(true)}
/> />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
<Typography> или</Typography> <Typography> или</Typography>
<Box <Box
sx={{ sx={{

@ -110,6 +110,7 @@ export default function TypeQuestions({ totalIndex }: Props) {
{BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => ( {BUTTON_TYPE_QUESTIONS.map(({ icon, title, value }) => (
<QuestionsMiniButton <QuestionsMiniButton
key={title} key={title}
dataCy={`select-questiontype-${value}`}
onClick={() => { onClick={() => {
const question = { ...listQuestions[quizId][totalIndex] }; const question = { ...listQuestions[quizId][totalIndex] };

@ -1,15 +1,15 @@
import { useParams } from "react-router-dom"; import { Box, ButtonBase, Typography, useTheme } from "@mui/material";
import { useState } from "react"; import { questionStore, setQuestionBackgroundImage, setQuestionOriginalBackgroundImage } from "@root/questions";
import { Typography, Box, useTheme, ButtonBase } from "@mui/material";
import UploadBox from "@ui_kit/UploadBox";
import { CropModal } from "@ui_kit/Modal/CropModal"; import { CropModal } from "@ui_kit/Modal/CropModal";
import UploadIcon from "../../../assets/icons/UploadIcon"; import UploadBox from "@ui_kit/UploadBox";
import * as React from "react"; import * as React from "react";
import { questionStore, updateQuestionsList } from "@root/questions"; import { useParams } from "react-router-dom";
import UploadIcon from "../../../assets/icons/UploadIcon";
import { UploadImageModal } from "./UploadImageModal"; import { UploadImageModal } from "./UploadImageModal";
import { openCropModal } from "@root/cropModal";
import { QuizQuestionBase } from "model/questionTypes/shared";
import type { DragEvent } from "react"; import type { DragEvent } from "react";
import type { QuizQuestionImages } from "../../../model/questionTypes/images";
type UploadImageProps = { type UploadImageProps = {
totalIndex: number; totalIndex: number;
@ -18,36 +18,34 @@ type UploadImageProps = {
export default function UploadImage({ totalIndex }: UploadImageProps) { export default function UploadImage({ totalIndex }: UploadImageProps) {
const quizId = Number(useParams().quizId); const quizId = Number(useParams().quizId);
const theme = useTheme(); const theme = useTheme();
const [open, setOpen] = React.useState(false); const [isUploadImageModalOpen, setIsUploadImageModalOpen] = React.useState(false);
const { listQuestions } = questionStore(); const { listQuestions } = questionStore();
const question = listQuestions[quizId][totalIndex] as QuizQuestionImages; const question = listQuestions[quizId][totalIndex] as QuizQuestionBase;
const handleImageUpload = (files: FileList | null) => {
if (!files?.length) return;
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
const imgHC = (files: FileList | null) => {
if (files?.length) {
const [file] = Array.from(files); const [file] = Array.from(files);
updateQuestionsList<QuizQuestionImages>(quizId, totalIndex, { const url = URL.createObjectURL(file);
content: {
...question.content,
back: URL.createObjectURL(file),
},
});
handleClose(); setQuestionBackgroundImage(quizId, totalIndex, url);
setOpened(true); setQuestionOriginalBackgroundImage(quizId, totalIndex, url);
} setIsUploadImageModalOpen(false);
openCropModal(url, url);
}; };
const [opened, setOpened] = useState<boolean>(false);
const handleDrop = (event: DragEvent<HTMLDivElement>) => { const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
imgHC(event.dataTransfer.files); handleImageUpload(event.dataTransfer.files);
}; };
function handleCropModalSaveClick(url: string) {
setQuestionBackgroundImage(quizId, totalIndex, url);
}
return ( return (
<Box sx={{ padding: "20px" }}> <Box sx={{ padding: "20px" }}>
<Typography <Typography
@ -61,22 +59,39 @@ export default function UploadImage({ totalIndex }: UploadImageProps) {
Загрузить изображение Загрузить изображение
</Typography> </Typography>
<ButtonBase <ButtonBase
onClick={handleOpen} onClick={() => setIsUploadImageModalOpen(true)}
sx={{ width: "100%", maxWidth: "260px" }} sx={{
width: "100%",
maxWidth: "260px",
height: "120px",
}}
> >
{question.content.back ?
<img
src={question.content.back}
alt="question background"
style={{
width: "100%",
height: "100%",
objectFit: "scale-down",
display: "block",
}}
/>
:
<UploadBox <UploadBox
handleDrop={handleDrop} handleDrop={handleDrop}
sx={{ maxWidth: "260px" }} sx={{ maxWidth: "260px" }}
icon={<UploadIcon />} icon={<UploadIcon />}
text="5 MB максимум" text="5 MB максимум"
/> />
}
</ButtonBase> </ButtonBase>
<UploadImageModal open={open} onClose={handleClose} imgHC={imgHC} /> <UploadImageModal
<CropModal open={isUploadImageModalOpen}
opened={opened} onClose={() => setIsUploadImageModalOpen(false)}
onClose={() => setOpened(false)} imgHC={handleImageUpload}
picture={question.content.back}
/> />
<CropModal onSaveImageClick={handleCropModalSaveClick} />
</Box> </Box>
); );
} }

@ -27,7 +27,7 @@ export default function AnswerOptions({ totalIndex }: Props) {
const addNewAnswer = () => { const addNewAnswer = () => {
const answerNew = question.content.variants.slice(); const answerNew = question.content.variants.slice();
answerNew.push({ answer: "", extendedText: "" }); answerNew.push({ answer: "", extendedText: "", hints: "" });
updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, { updateQuestionsList<QuizQuestionVariant>(quizId, totalIndex, {
content: { ...question.content, variants: answerNew }, content: { ...question.content, variants: answerNew },

@ -2,27 +2,17 @@ import { Box, Typography, useTheme, useMediaQuery } from "@mui/material";
import AddImage from "@icons/questionsPage/addImage"; import AddImage from "@icons/questionsPage/addImage";
import AddVideofile from "@icons/questionsPage/addVideofile"; import AddVideofile from "@icons/questionsPage/addVideofile";
import { useState } from "react";
import { CropModal } from "@ui_kit/Modal/CropModal"; import { CropModal } from "@ui_kit/Modal/CropModal";
import { openCropModal } from "@root/cropModal";
export default function ImageAndVideoButtons() { export default function ImageAndVideoButtons() {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(400)); const isMobile = useMediaQuery(theme.breakpoints.down(400));
const [opened, setOpened] = useState<boolean>(false);
return ( return (
<Box <Box sx={{ display: "flex", alignItems: "center", gap: "12px", mt: "20px", mb: "20px" }}>
sx={{ <AddImage onClick={() => openCropModal("", "")} />
display: "flex", <CropModal />
alignItems: "center",
gap: "12px",
mt: "20px",
mb: "20px",
}}
>
<AddImage onClick={() => setOpened(true)} />
<CropModal opened={opened} onClose={() => setOpened(false)} />
<Typography <Typography
sx={{ sx={{
fontWeight: 400, fontWeight: 400,

@ -34,6 +34,7 @@ export default function FirstQuiz() {
</Typography> </Typography>
<Button <Button
variant="contained" variant="contained"
data-cy="create-quiz"
onClick={() => { onClick={() => {
navigate(`/setting/${createBlank()}`); navigate(`/setting/${createBlank()}`);
}} }}

@ -848,10 +848,9 @@ export default function StartPageSettings() {
justifyContent: "flex-end", justifyContent: "flex-end",
}} }}
/> />
<Box>
<Button <Button
variant="contained" variant="contained"
sx={{ display: "block", marginLeft: "auto" }} data-cy="setup-questions"
onClick={() => { onClick={() => {
let SPageClone = listQuizes[params].config; let SPageClone = listQuizes[params].config;
SPageClone.startpage.background.desktop = SPageClone.startpage.background.desktop =
@ -868,7 +867,6 @@ export default function StartPageSettings() {
Настроить вопросы Настроить вопросы
</Button> </Button>
</Box> </Box>
</Box>
</> </>
); );
} }

@ -28,6 +28,7 @@ export default function StepOne() {
> >
<Button <Button
variant="text" variant="text"
data-cy="create-quiz-card"
onClick={() => { onClick={() => {
let SPageClone = listQuizes[params].config; let SPageClone = listQuizes[params].config;
SPageClone.type = "quize"; SPageClone.type = "quize";

66
src/stores/cropModal.ts Normal file

@ -0,0 +1,66 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type CropModalStore = {
isCropModalOpen: boolean;
imageUrl: string | null;
originalImageUrl: string | null;
};
const initialState: CropModalStore = {
isCropModalOpen: false,
imageUrl: null,
originalImageUrl: null,
};
export const useCropModalStore = create<CropModalStore>()(
devtools(
() => initialState,
{
name: "CropModalStore",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
}
),
);
export const openCropModal = (imageUrl: string, originalImageUrl: string) => useCropModalStore.setState(
{
isCropModalOpen: true,
imageUrl,
originalImageUrl,
},
false,
{
type: "openCropModal",
imageUrl,
originalImageUrl,
}
);
export const closeCropModal = () => useCropModalStore.setState(
initialState,
false,
"closeCropModal"
);
export const setCropModalImageUrl = (imageUrl: string | null) => useCropModalStore.setState(
{ imageUrl },
false,
{
type: "setCropModalImageUrl",
imageUrl,
}
);
export const resetToOriginalImage = (): boolean => {
if (!useCropModalStore.getState().originalImageUrl) return false;
useCropModalStore.setState(
state => ({ imageUrl: state.originalImageUrl }),
false,
"resetToOriginalImage"
);
return true;
};

@ -1,17 +1,12 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { devtools, persist } from "zustand/middleware";
import { QuizQuestionEmoji } from "../model/questionTypes/emoji";
import { QuizQuestionImages } from "../model/questionTypes/images";
import { QuizQuestionVariant } from "../model/questionTypes/variant";
import { QuizQuestionVarImg } from "../model/questionTypes/varimg";
import type { import type {
AnyQuizQuestion, AnyQuizQuestion,
QuizQuestionType, QuizQuestionType
QuestionVariant,
} from "../model/questionTypes/shared"; } from "../model/questionTypes/shared";
import { produce, setAutoFreeze } from "immer";
import { QUIZ_QUESTION_BASE } from "../constants/base"; import { QUIZ_QUESTION_BASE } from "../constants/base";
import { QUIZ_QUESTION_DATE } from "../constants/date"; import { QUIZ_QUESTION_DATE } from "../constants/date";
import { QUIZ_QUESTION_EMOJI } from "../constants/emoji"; import { QUIZ_QUESTION_EMOJI } from "../constants/emoji";
@ -24,7 +19,6 @@ import { QUIZ_QUESTION_SELECT } from "../constants/select";
import { QUIZ_QUESTION_TEXT } from "../constants/text"; import { QUIZ_QUESTION_TEXT } from "../constants/text";
import { QUIZ_QUESTION_VARIANT } from "../constants/variant"; import { QUIZ_QUESTION_VARIANT } from "../constants/variant";
import { QUIZ_QUESTION_VARIMG } from "../constants/varimg"; import { QUIZ_QUESTION_VARIMG } from "../constants/varimg";
import { setAutoFreeze } from "immer";
setAutoFreeze(false); setAutoFreeze(false);
@ -36,11 +30,19 @@ interface QuestionStore {
let isFirstPartialize = true; let isFirstPartialize = true;
export const questionStore = create<QuestionStore>()( export const questionStore = create<QuestionStore>()(
persist<QuestionStore>( persist(
devtools(
() => ({ () => ({
listQuestions: {}, listQuestions: {},
openedModalSettings: "", openedModalSettings: "",
}), }),
{
name: "Question",
enabled: process.env.NODE_ENV === "development",
trace: process.env.NODE_ENV === "development",
actionsBlacklist: "ignored",
}
),
{ {
name: "question", name: "question",
partialize: (state: QuestionStore) => { partialize: (state: QuestionStore) => {
@ -66,26 +68,31 @@ export const questionStore = create<QuestionStore>()(
const state = persistedState as QuestionStore; const state = persistedState as QuestionStore;
// replace blob urls with "" // replace blob urls with ""
Object.values(state.listQuestions).forEach((questions) => { Object.values(state.listQuestions).forEach(questions => {
questions.forEach((question) => { questions.forEach(question => {
if ( if (question.type === "page") {
question.type === "page" && if (question.content.picture.startsWith("blob:")) {
question.content.picture.startsWith("blob:")
) {
question.content.picture = ""; question.content.picture = "";
} }
}
if (question.type === "images") { if (question.type === "images") {
question.content.variants.forEach((variant) => { question.content.variants.forEach(variant => {
if (variant.extendedText.startsWith("blob:")) { if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = ""; variant.extendedText = "";
} }
if (variant.originalImageUrl.startsWith("blob:")) {
variant.originalImageUrl = "";
}
}); });
} }
if (question.type === "varimg") { if (question.type === "varimg") {
question.content.variants.forEach((variant) => { question.content.variants.forEach(variant => {
if (variant.extendedText.startsWith("blob:")) { if (variant.extendedText.startsWith("blob:")) {
variant.extendedText = ""; variant.extendedText = "";
} }
if (variant.originalImageUrl.startsWith("blob:")) {
variant.originalImageUrl = "";
}
}); });
} }
}); });
@ -112,6 +119,131 @@ export const updateQuestionsList = <T = AnyQuizQuestion>(
questionStore.setState({ listQuestions: questionListClone }); questionStore.setState({ listQuestions: questionListClone });
}; };
export const updateQuestion = <T extends AnyQuizQuestion>(
quizId: number,
questionIndex: number,
recipe: (question: T) => void,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex] as T;
recipe(question);
}, {
type: "updateQuestion",
quizId,
questionIndex,
recipe,
});
export const removeQuestionsByQuizId = (quizId: number) => setProducedState(state => {
delete state.listQuestions[quizId];
}, "removeQuestionsByQuizId");
export const setVariantImageUrl = (
quizId: number,
questionIndex: number,
variantIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.extendedText === url) return;
if (variant.extendedText !== variant.originalImageUrl) URL.revokeObjectURL(variant.extendedText);
variant.extendedText = url;
}, {
type: "setVariantImageUrl",
quizId,
questionIndex,
variantIndex,
url,
});
export const setVariantOriginalImageUrl = (
quizId: number,
questionIndex: number,
variantIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (!("variants" in question.content)) return;
const variant = question.content.variants[variantIndex];
if (!("originalImageUrl" in variant)) return;
if (variant.originalImageUrl === url) return;
URL.revokeObjectURL(variant.originalImageUrl);
variant.originalImageUrl = url;
}, {
type: "setVariantOriginalImageUrl",
quizId,
questionIndex,
variantIndex,
url,
});
export const setPageQuestionPicture = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
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 = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.type !== "page") return;
if (question.content.originalPicture === url) return;
URL.revokeObjectURL(question.content.originalPicture);
question.content.originalPicture = url;
});
export const setQuestionBackgroundImage = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.content.back === url) return;
if (
question.content.back !== question.content.originalBack
) URL.revokeObjectURL(question.content.back);
question.content.back = url;
})
export const setQuestionOriginalBackgroundImage = (
quizId: number,
questionIndex: number,
url: string,
) => setProducedState(state => {
const question = state.listQuestions[quizId][questionIndex];
if (question.content.originalBack === url) return;
URL.revokeObjectURL(question.content.originalBack);
question.content.originalBack = url;
})
export const updateQuestionsListDragAndDrop = ( export const updateQuestionsListDragAndDrop = (
quizId: number, quizId: number,
updatedQuestions: AnyQuizQuestion[] updatedQuestions: AnyQuizQuestion[]
@ -122,35 +254,27 @@ export const updateQuestionsListDragAndDrop = (
}); });
}; };
export const updateVariants = (
export const reorderVariants = (
quizId: number, quizId: number,
index: number, questionIndex: number,
variants: QuestionVariant[] sourceIndex: number,
) => { destinationIndex: number,
const listQuestions = { ...questionStore.getState()["listQuestions"] }; ) => setProducedState(state => {
if (sourceIndex === destinationIndex) return;
switch (listQuestions[quizId][index].type) { const question = state.listQuestions[quizId][questionIndex];
case "emoji": if (!("variants" in question.content)) return;
const emojiState = listQuestions[quizId][index] as QuizQuestionEmoji;
emojiState.content.variants = variants;
return questionStore.setState({ listQuestions });
case "images": const [removed] = question.content.variants.splice(sourceIndex, 1);
const imagesState = listQuestions[quizId][index] as QuizQuestionImages; question.content.variants.splice(destinationIndex, 0, removed);
imagesState.content.variants = variants; }, {
return questionStore.setState({ listQuestions }); type: sourceIndex === destinationIndex ? "reorderVariants" : "ignored",
quizId,
case "variant": questionIndex,
const variantState = listQuestions[quizId][index] as QuizQuestionVariant; sourceIndex,
variantState.content.variants = variants; destinationIndex,
return questionStore.setState({ listQuestions }); });
case "varimg":
const varImgState = listQuestions[quizId][index] as QuizQuestionVarImg;
varImgState.content.variants = variants;
return questionStore.setState({ listQuestions });
}
};
export const createQuestion = ( export const createQuestion = (
quizId: number, quizId: number,
@ -183,7 +307,7 @@ export const createQuestion = (
newData[quizId].splice( newData[quizId].splice(
placeIndex < 0 ? newData[quizId].length : placeIndex, placeIndex < 0 ? newData[quizId].length : placeIndex,
0, 0,
{ ...defaultObject, id } { ...JSON.parse(JSON.stringify(defaultObject)), id }
); );
questionStore.setState({ listQuestions: newData }); questionStore.setState({ listQuestions: newData });
@ -230,6 +354,7 @@ export const findQuestionById = (quizId: number) => {
found = { quiz, index }; found = { quiz, index };
return true; return true;
} }
return false;
}); });
return found; return found;
}; };
@ -239,3 +364,10 @@ function getRandom() {
const max = Math.floor(10000000); const max = Math.floor(10000000);
return Math.floor(Math.random() * (max - min)) + min; return Math.floor(Math.random() * (max - min)) + min;
} }
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuestionStore) => void,
action?: A,
) {
questionStore.setState(state => produce(state, recipe), false, action);
}

@ -1,5 +1,6 @@
import {create} from "zustand"; import {create} from "zustand";
import {persist} from "zustand/middleware"; import {persist} from "zustand/middleware";
import { removeQuestionsByQuizId } from "./questions";
interface QuizStore { interface QuizStore {
listQuizes: { [key: number]: Quizes }; listQuizes: { [key: number]: Quizes };
@ -87,6 +88,8 @@ export const quizStore = create<QuizStore>()(
return accumulator; return accumulator;
}, {}); }, {});
set({listQuizes: newState}); set({listQuizes: newState});
removeQuestionsByQuizId(id);
}, },
createBlank: () => { createBlank: () => {
const id = getRandom(1000000, 10000000) const id = getRandom(1000000, 10000000)

@ -0,0 +1,63 @@
import Plus from "@icons/questionsPage/plus";
import { Box, Button, SxProps, Theme } from "@mui/material";
import Image from "../assets/icons/questionsPage/image";
interface Props {
sx?: SxProps<Theme>;
imageSrc?: string;
onImageClick?: () => void;
onPlusClick?: () => void;
}
export default function AddOrEditImageButton({ onImageClick, onPlusClick, sx, imageSrc }: Props) {
return (
<Box sx={{
display: "flex",
height: "40px",
minWidth: "60px",
width: "60px",
borderRadius: "3px",
overflow: "hidden",
...sx,
}}>
<Button
onClick={onImageClick}
sx={{
p: 0,
minWidth: "40px",
flexGrow: 1,
backgroundColor: "#EEE4FC",
}}>
{imageSrc ? (
<img
src={imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "scale-down",
display: "block",
}}
/>
) : (
<Image sx={{
height: "100%",
width: "100%",
}} />
)}
</Button>
<Button
onClick={onPlusClick}
sx={{
p: 0,
minWidth: "20px",
width: "20px",
}}
>
<Plus />
</Button>
</Box>
);
}

@ -1,66 +1,26 @@
import React, { useState, useRef, useEffect, FC } from "react"; import { CropIcon } from "@icons/CropIcon";
import ReactCrop, { Crop, PixelCrop } from "react-image-crop"; import { ResetIcon } from "@icons/ResetIcon";
import { import {
Box, Box,
Button, Button,
IconButton,
Modal, Modal,
Slider, Slider,
SxProps,
Theme,
Typography, Typography,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
import { closeCropModal, resetToOriginalImage, setCropModalImageUrl, useCropModalStore } from "@root/cropModal";
import { canvasPreview } from "./utils/canvasPreview"; import { FC, useRef, useState } from "react";
import { useDebounceEffect } from "./utils/useDebounceEffect"; import ReactCrop, { Crop, PixelCrop } from "react-image-crop";
import { ResetIcon } from "@icons/ResetIcon";
import "react-image-crop/dist/ReactCrop.css"; import "react-image-crop/dist/ReactCrop.css";
import { CropIcon } from "@icons/CropIcon"; import { canvasPreview } from "./utils/canvasPreview";
import { enqueueSnackbar } from "notistack";
interface Iprops {
opened: boolean;
onClose: React.Dispatch<React.SetStateAction<boolean>>;
picture?: string;
onCropPress?: (imageUrl: string) => void;
}
export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress }) => { const styleSlider: SxProps<Theme> = {
const [imgSrc, setImgSrc] = useState("");
const imgRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const hiddenAnchorRef = useRef<HTMLAnchorElement>(null);
const blobUrlRef = useRef("");
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
const [rotate, setRotate] = useState(0);
const [darken, setDarken] = useState(0);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down(786));
useEffect(() => {
if (picture) {
setImgSrc(picture);
}
}, [picture]);
const styleModal = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: isMobile ? "343px" : "620px",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
};
const styleSlider = {
width: isMobile ? "350px" : "250px",
color: "#7E2AEA", color: "#7E2AEA",
height: "12px", height: "12px",
"& .MuiSlider-track": { "& .MuiSlider-track": {
@ -84,80 +44,67 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
}, },
}; };
const rotateImage = () => { interface Props {
const newRotation = (rotate + 90) % 360; onSaveImageClick?: (imageUrl: string) => void;
setRotate(newRotation);
};
const onSelectFile = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setCrop(undefined);
const reader = new FileReader();
reader.addEventListener("load", () =>
setImgSrc(reader.result?.toString() || "")
);
reader.readAsDataURL(event.target.files[0]);
} }
};
const onDownloadCropClick = () => { export const CropModal: FC<Props> = ({ onSaveImageClick }) => {
if (!previewCanvasRef.current) { const theme = useTheme();
throw new Error("Crop canvas does not exist"); const isCropModalOpen = useCropModalStore(state => state.isCropModalOpen);
} const imageUrl = useCropModalStore(state => state.imageUrl);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop>();
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 handleCropClick = async () => {
if (!completedCrop) throw new Error("No completed crop");
if (!cropImageElementRef.current) throw new Error("No image");
const canvasCopy = document.createElement("canvas"); const canvasCopy = document.createElement("canvas");
const ctx = canvasCopy.getContext("2d"); const ctx = canvasCopy.getContext("2d");
canvasCopy.width = previewCanvasRef.current.width; if (!ctx) throw new Error("No 2d context");
canvasCopy.height = previewCanvasRef.current.height;
ctx!.filter = `brightness(${100 - darken}%)`; canvasCopy.width = completedCrop.width;
ctx!.drawImage(previewCanvasRef.current, 0, 0); canvasCopy.height = completedCrop.height;
ctx.filter = `brightness(${100 - darken}%)`;
await canvasPreview(cropImageElementRef.current, canvasCopy, completedCrop, rotate);
canvasCopy.toBlob((blob) => { canvasCopy.toBlob((blob) => {
if (!blob) { if (!blob) {
throw new Error("Failed to create blob"); throw new Error("Failed to create blob");
} }
if (blobUrlRef.current) { const newImageUrl = URL.createObjectURL(blob);
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
hiddenAnchorRef.current!.href = blobUrlRef.current;
hiddenAnchorRef.current!.click();
setImgSrc(blobUrlRef.current); setCropModalImageUrl(newImageUrl);
onCropPress?.(blobUrlRef.current); setCrop(undefined);
setCompletedCrop(undefined);
}); });
}; };
useDebounceEffect( function handleSaveClick() {
async () => { if (imageUrl) onSaveImageClick?.(imageUrl);
if ( setCrop(undefined);
completedCrop?.width && setCompletedCrop(undefined);
completedCrop?.height && closeCropModal();
imgRef.current &&
previewCanvasRef.current
) {
canvasPreview(
imgRef.current,
previewCanvasRef.current,
completedCrop,
rotate
);
} }
},
100,
[completedCrop, rotate]
);
const [width, setWidth] = useState<number>(0); function handleLoadOriginalImage() {
const isSuccess = resetToOriginalImage();
if (!isSuccess) enqueueSnackbar("Не удалось восстановить оригинал. Приносим глубочайшие извинения");
}
const getImageSize = () => { const getImageSize = () => {
if (imgRef.current) { if (cropImageElementRef.current) {
const imageWidth = imgRef.current.naturalWidth; const imageWidth = cropImageElementRef.current.naturalWidth;
const imageHeight = imgRef.current.naturalHeight; const imageHeight = cropImageElementRef.current.naturalHeight;
const aspect = imageWidth / imageHeight; const aspect = imageWidth / imageHeight;
if (aspect <= 1.333) { if (aspect <= 1.333) {
setWidth(240); setWidth(240);
} }
@ -171,27 +118,35 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
}; };
return ( return (
<>
<Modal <Modal
open={opened} open={isCropModalOpen}
onClose={onClose} onClose={closeCropModal}
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<Box sx={styleModal}> <Box sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
boxShadow: 24,
padding: "20px",
borderRadius: "8px",
width: isMobile ? "343px" : "620px",
}}>
<Box <Box
sx={{ sx={{
height: "320px", height: "320px",
padding: "10px", padding: "10px",
backgroundSize: "cover", backgroundSize: "cover",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}} }}
> >
{imgSrc && ( {imageUrl && (
<ReactCrop <ReactCrop
crop={crop} crop={crop}
onChange={(_, percentCrop) => setCrop(percentCrop)} onChange={(_, percentCrop) => setCrop(percentCrop)}
@ -203,9 +158,9 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
> >
<img <img
onLoad={getImageSize} onLoad={getImageSize}
ref={imgRef} ref={cropImageElementRef}
alt="Crop me" alt="Crop me"
src={imgSrc} src={imageUrl}
style={{ style={{
filter: `brightness(${100 - darken}%)`, filter: `brightness(${100 - darken}%)`,
transform: ` rotate(${rotate}deg)`, transform: ` rotate(${rotate}deg)`,
@ -243,16 +198,17 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<ResetIcon <IconButton onClick={() => setRotate(r => (r + 90) % 360)}>
onClick={rotateImage} <ResetIcon />
style={{ marginBottom: "10px", cursor: "pointer" }} </IconButton>
/>
<Box> <Box>
<Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}> <Typography sx={{ color: "#9A9AAF", fontSize: "16px" }}>
Размер Размер
</Typography> </Typography>
<Slider <Slider
sx={styleSlider} sx={[styleSlider, {
width: isMobile ? "350px" : "250px",
}]}
value={width} value={width}
min={50} min={50}
max={580} max={580}
@ -267,7 +223,9 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
Затемнение Затемнение
</Typography> </Typography>
<Slider <Slider
sx={styleSlider} sx={[styleSlider, {
width: isMobile ? "350px" : "250px",
}]}
value={darken} value={darken}
min={0} min={0}
max={100} max={100}
@ -281,18 +239,22 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
marginTop: "40px", marginTop: "40px",
width: "100%", width: "100%",
display: "flex", display: "flex",
justifyContent: "end",
}} }}
> >
<input
style={{ display: "none", zIndex: "-999" }}
ref={fileInputRef}
type="file"
accept="image/*"
onChange={onSelectFile}
/>
<Button <Button
onClick={() => fileInputRef.current?.click()} onClick={handleSaveClick}
disableRipple
sx={{
height: "48px",
color: "#7E2AEA",
borderRadius: "8px",
border: "1px solid #7E2AEA",
marginRight: "10px",
px: "20px",
}}
>Сохранить</Button>
<Button
onClick={handleLoadOriginalImage}
disableRipple disableRipple
sx={{ sx={{
width: "215px", width: "215px",
@ -301,14 +263,16 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
borderRadius: "8px", borderRadius: "8px",
border: "1px solid #7E2AEA", border: "1px solid #7E2AEA",
marginRight: "10px", marginRight: "10px",
ml: "auto",
}} }}
> >
Загрузить оригинал Загрузить оригинал
</Button> </Button>
<Button <Button
onClick={onDownloadCropClick} onClick={handleCropClick}
disableRipple disableRipple
variant="contained" variant="contained"
disabled={!completedCrop}
sx={{ sx={{
padding: "10px 20px", padding: "10px 20px",
borderRadius: "8px", borderRadius: "8px",
@ -322,33 +286,5 @@ export const CropModal: FC<Iprops> = ({ opened, onClose, picture, onCropPress })
</Box> </Box>
</Box> </Box>
</Modal> </Modal>
{completedCrop && (
<div>
<canvas
ref={previewCanvasRef}
style={{
display: "none",
zIndex: "-999",
border: "1px solid black",
objectFit: "contain",
width: completedCrop.width,
height: completedCrop.height,
}}
/>
</div>
)}
<div>
<a
href="#hidden"
ref={hiddenAnchorRef}
download
style={{
display: "none",
}}
>
Hidden download
</a>
</div>
</>
); );
}; };

@ -1,13 +1,14 @@
import { Box, Button } from "@mui/material"; import { Box, Button } from "@mui/material";
import { FC, useState } from "react"; import { FC } from "react";
import { CropModal } from "./CropModal"; import { CropModal } from "./CropModal";
import { openCropModal } from "@root/cropModal";
const ImageCrop: FC = () => { const ImageCrop: FC = () => {
const [opened, setOpened] = useState<boolean>(false);
return ( return (
<Box> <Box>
<Button onClick={() => setOpened(true)}>Открыть модалку</Button> <Button onClick={() => openCropModal("", "")}>Открыть модалку</Button>
<CropModal opened={opened} onClose={() => setOpened(false)} /> <CropModal />
</Box> </Box>
); );
}; };

@ -7,15 +7,17 @@ interface QuestionsMiniButtonProps {
icon: ReactNode; icon: ReactNode;
text: string; text: string;
onClick: () => void; onClick: () => void;
dataCy?: string;
} }
export default function QuestionsMiniButton({ icon, text, onClick }: QuestionsMiniButtonProps) { export default function QuestionsMiniButton({ icon, text, onClick, dataCy }: QuestionsMiniButtonProps) {
const theme = useTheme(); const theme = useTheme();
return ( return (
<> <>
<Button <Button
variant="outlined" variant="outlined"
data-cy={dataCy}
sx={{ sx={{
padding: "26px 15px 15px 15px", padding: "26px 15px 15px 15px",
display: "flex", display: "flex",

@ -1,10 +1,11 @@
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Box, IconButton } from "@mui/material"; import { Box, IconButton } from "@mui/material";
import { toggleQuizPreview, useQuizPreviewStore } from "@root/quizPreview"; import { toggleQuizPreview, useQuizPreviewStore } from "@root/quizPreview";
import { useLayoutEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { Rnd } from "react-rnd"; import { Rnd } from "react-rnd";
import { useWindowSize } from "../../utils/hooks/useWindowSize";
import QuizPreviewLayout from "./QuizPreviewLayout"; import QuizPreviewLayout from "./QuizPreviewLayout";
import ResizeIcon from "./ResizeIcon"; import ResizeIcon from "./ResizeIcon";
import VisibilityIcon from "@mui/icons-material/Visibility";
const DRAG_PARENT_MARGIN = 0; const DRAG_PARENT_MARGIN = 0;
const NAVBAR_HEIGHT = 0; const NAVBAR_HEIGHT = 0;

@ -160,4 +160,5 @@ export default function QuizPreviewLayout() {
</Box> </Box>
</Paper> </Paper>
); );
} }

@ -25,7 +25,10 @@ export default function Variant({ question }: Props) {
return ( return (
<FormControl fullWidth> <FormControl fullWidth>
<FormLabel id="quiz-question-radio-group">{question.title}</FormLabel> <FormLabel
id="quiz-question-radio-group"
data-cy="variant-title"
>{question.title}</FormLabel>
<RadioGroup <RadioGroup
aria-labelledby="quiz-question-radio-group" aria-labelledby="quiz-question-radio-group"
value={value} value={value}
@ -35,11 +38,17 @@ export default function Variant({ question }: Props) {
<FormControlLabel <FormControlLabel
key={index} key={index}
value={variant.answer} value={variant.answer}
control={<Radio />} control={<Radio
inputProps={{
"data-cy": "variant-radio",
}}
/>}
label={ label={
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Typography>{variant.answer}</Typography> <Typography
<Tooltip title="Подсказка" placement="right"> data-cy="variant-answer"
>{variant.answer}</Typography>
<Tooltip title={variant.hints} placement="right">
<Box> <Box>
<InfoIcon /> <InfoIcon />
</Box> </Box>

@ -0,0 +1,35 @@
import { useEffect, useLayoutEffect, useState } from "react";
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: 0,
height: 0,
});
const handleSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
useEffect(() => {
window.addEventListener("resize", handleSize);
return () => {
window.removeEventListener("resize", handleSize);
};
});
useLayoutEffect(() => {
handleSize();
}, []);
return windowSize;
}

@ -19,9 +19,14 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
}, },
"include": [ "include": [
"src" "src",
],
"exclude": [
"cypress.config.ts",
"cypress",
"node_modules",
] ]
} }

672
yarn.lock

File diff suppressed because it is too large Load Diff