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 { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; import { RequestQueue } from "../../utils/requestQueue"; import { updateRootContentId } from "@root/quizes/actions"; import { useCurrentQuiz } from "@root/quizes/hooks"; import { QuestionsStore, useQuestionsStore } from "./store"; import { useUiTools } from "../uiTools/store"; import { withErrorBoundary } from "react-error-boundary"; import { QuizQuestionResult } from "@model/questionTypes/result"; import { replaceEmptyLinesToSpace } from "../../utils/replaceEmptyLinesToSpace"; export const setQuestions = (questions: RawQuestion[] | null) => setProducedState( (state) => { const untypedResultQuestions = state.questions.filter( (q) => q.type === null, ); state.questions = questions?.map(rawQuestionToQuestion) ?? []; state.questions.push(...untypedResultQuestions); }, { type: "setQuestions", questions, }, ); export const createUntypedQuestion = ( quizId: number, insertAfterQuestionId?: string, ) => setProducedState( (state) => { const newUntypedQuestion = { id: nanoid(), quizId, type: null, title: "", description: "", deleted: false, expanded: true, }; if (insertAfterQuestionId) { const index = state.questions.findIndex( (q) => q.id === insertAfterQuestionId, ); if (index === -1) return; state.questions.splice(index + 1, 0, newUntypedQuestion); return; } state.questions.push(newUntypedQuestion); }, { type: "createUntypedQuestion", quizId, }, ); const removeQuestion = (questionId: string) => setProducedState( (state) => { const index = state.questions.findIndex((q) => q.id === questionId); if (index === -1) return; state.questions.splice(index, 1); }, { type: "removeQuestion", questionId, }, ); export const updateUntypedQuestion = ( questionId: string, updateFn: (question: UntypedQuizQuestion) => void, ) => { setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question) return; if (question.type !== null) throw new Error( "Cannot update typed question, use 'updateQuestion' instead", ); updateFn(question); }, { type: "updateUntypedQuestion", questionId, updateFn: updateFn.toString(), }, ); }; export const cleanQuestions = () => setProducedState( (state) => { state.questions = []; }, { type: "cleanQuestions", }, ); const setQuestionBackendId = (questionId: string, backendId: number) => setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question) return; if (question.type === null) throw new Error("Cannot set backend id for untyped question"); question.backendId = backendId; }, { type: "setQuestionBackendId", questionId: questionId, backendId, }, ); const updateQuestionOrders = () => { const questions = useQuestionsStore .getState() .questions.filter( (question): question is AnyTypedQuizQuestion => question.type !== null && question.type !== "result", ); questions.forEach((question, index) => { updateQuestion( question.id, (question) => { question.page = index; }, true, ); }); }; export const reorderQuestions = ( sourceIndex: number, destinationIndex: number, ) => { if (sourceIndex === destinationIndex) return; setProducedState( (state) => { const [removed] = state.questions.splice(sourceIndex, 1); state.questions.splice(destinationIndex, 0, removed); }, { type: "reorderQuestions", sourceIndex, destinationIndex, }, ); updateQuestionOrders(); }; export const toggleExpandQuestion = (questionId: string) => setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question) return; question.expanded = !question.expanded; }, { type: "toggleExpandQuestion", questionId, }, ); export const collapseAllQuestions = () => setProducedState((state) => { state.questions.forEach((question) => (question.expanded = false)); }, "collapseAllQuestions"); const DELETE_TIMEOUT = 5000; export const deleteQuestionWithTimeout = ( questionId: string, deleteFn: (questionId: string) => void, ) => setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question) return; if (question.type === null || question.type === "result") { queueMicrotask(() => deleteFn(questionId)); return; } question.deleted = true; clearTimeout(question.deleteTimeoutId); question.deleteTimeoutId = window.setTimeout(() => { deleteFn(questionId); }, DELETE_TIMEOUT); }, { type: "deleteQuestionWithTimeout", questionId, }, ); export const cancelQuestionDeletion = (questionId: string) => setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question || question.type === null || question.type === "result") return; question.deleted = false; clearTimeout(question.deleteTimeoutId); }, { type: "cancelQuestionDeletion", questionId, }, ); const REQUEST_DEBOUNCE = 200; const requestQueue = new RequestQueue(); let requestTimeoutId: ReturnType; export const updateQuestion = async ( questionId: string, updateFn: (question: T) => void, skipQueue = false, ) => { setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId) || state.questions.find( (q) => q.type !== null && q.content.id === questionId, ); if (!question) return; if (question.type === null) throw new Error( "Cannot update untyped question, use 'updateUntypedQuestion' instead", ); updateFn(question as T); }, { type: "updateQuestion", questionId, updateFn: updateFn.toString(), }, ); // clearTimeout(requestTimeoutId); const request = async () => { const q = useQuestionsStore.getState().questions.find((q) => q.id === questionId) || useQuestionsStore .getState() .questions.find((q) => q.type !== null && q.content.id === questionId); if (!q) return; if (q.type === null) throw new Error("Cannot send update request for untyped question"); try { const response = await questionApi.edit( questionToEditQuestionRequest(replaceEmptyLinesToSpace(q)), ); //Если мы делаем листочек веточкой - удаляем созданный к нему результ const questionResult = useQuestionsStore .getState() .questions.find( (questionResult) => questionResult.type === "result" && questionResult.content.rule.parentId === q.content.id, ); if (questionResult && q.content.rule.default.length !== 0) deleteQuestion(questionResult.quizId); if (q.backendId !== response.updated) { console.warn( `Question backend id has changed from ${q.backendId} to ${response.updated}`, ); } } catch (error) { if (isAxiosCanceledError(error)) return; devlog("Error editing question", { error, questionId }); enqueueSnackbar("Не удалось сохранить вопрос"); } }; if (skipQueue) { request(); return; } // requestTimeoutId = setTimeout(() => { requestQueue.enqueue(request); // }, REQUEST_DEBOUNCE); }; export const addQuestionVariant = (questionId: string) => { updateQuestion(questionId, (question) => { switch (question.type) { case "variant": case "emoji": case "select": case "images": case "varimg": question.content.variants.push(createQuestionVariant()); break; default: throw new Error( `Cannot add variant to question of type "${question.type}"`, ); } }); }; export const deleteQuestionVariant = ( questionId: string, variantId: string, ) => { updateQuestion(questionId, (question) => { if (!("variants" in question.content)) return; const variantIndex = question.content.variants.findIndex( (variant) => variant.id === variantId, ); if (variantIndex === -1) return; question.content.variants.splice(variantIndex, 1); }); }; export const setQuestionVariantField = ( questionId: string, variantId: string, field: keyof QuestionVariant, value: QuestionVariant[keyof QuestionVariant], ) => { updateQuestion(questionId, (question) => { if (!("variants" in question.content)) return; const variantIndex = question.content.variants.findIndex( (variant) => variant.id === variantId, ); if (variantIndex === -1) return; const variant = question.content.variants[variantIndex]; variant[field] = value; }); }; export const reorderQuestionVariants = ( questionId: string, sourceIndex: number, destinationIndex: number, ) => { if (sourceIndex === destinationIndex) return; updateQuestion(questionId, (question) => { if (!("variants" in question.content)) return; const [removed] = question.content.variants.splice(sourceIndex, 1); question.content.variants.splice(destinationIndex, 0, removed); }); }; export const uploadQuestionImage = async ( questionId: string, quizQid: string | undefined, blob: Blob, updateFn: (question: AnyTypedQuizQuestion, imageId: string) => void, ) => { const question = useQuestionsStore .getState() .questions.find((q) => q.id === questionId); if (!question || !quizQid) return; try { const response = await quizApi.addImages(question.quizId, blob); const values = Object.values(response); if (values.length !== 1) { console.warn("Error uploading image"); return; } const imageId = values[0]; const imageUrl = process.env.REACT_APP_DOMAIN + `/squizimages/${quizQid}/${imageId}`; updateQuestion(questionId, (question) => { updateFn(question, imageUrl); }); return imageUrl; } catch (error) { devlog("Error uploading question image", error); enqueueSnackbar("Не удалось загрузить изображение"); } }; export const setQuestionInnerName = (questionId: string, name: string) => { updateQuestion(questionId, (question) => { question.content.innerName = name; }); }; export const changeQuestionType = (questionId: string, type: QuestionType) => { updateQuestion(questionId, (question) => { const oldId = question.content.id; const oldRule = question.content.rule; oldRule.main = []; question.type = type; question.content = JSON.parse( JSON.stringify(defaultQuestionByType[type].content), ); question.content.id = oldId; question.content.rule = oldRule; }); }; export const createTypedQuestion = async ( questionId: string, type: QuestionType, ) => requestQueue.enqueue(async () => { const questions = useQuestionsStore.getState().questions; const question = questions.find((q) => q.id === questionId); if (!question) return; if (question.type !== null) throw new Error("Cannot upgrade already typed question"); const untypedOrResultQuestionsLength = questions.filter( (q) => q.type === "result" || q.type === null, ).length; try { const createdQuestion = await questionApi.create({ quiz_id: question.quizId, type, title: question.title, description: question.description, page: questions.length - untypedOrResultQuestionsLength, required: false, content: JSON.stringify(defaultQuestionByType[type].content), }); setProducedState( (state) => { const questionIndex = state.questions.findIndex( (q) => q.id === questionId, ); if (questionIndex !== -1) state.questions.splice( questionIndex, 1, rawQuestionToQuestion(createdQuestion), ); }, { type: "createTypedQuestion", question, }, ); updateQuestionOrders(); } catch (error) { devlog("Error creating question", error); enqueueSnackbar("Не удалось создать вопрос"); } }); export const deleteQuestion = async (questionId: string) => requestQueue.enqueue(async () => { const question = useQuestionsStore .getState() .questions.find((q) => q.id === questionId); if (!question) return; if (question.type === null) { removeQuestion(questionId); return; } try { await questionApi.delete(question.backendId); removeQuestion(questionId); updateQuestionOrders(); } catch (error) { devlog("Error deleting question", error); enqueueSnackbar("Не удалось удалить вопрос"); } }); export const copyQuestion = async (questionId: string, quizId: number) => requestQueue.enqueue(async () => { const question = useQuestionsStore .getState() .questions.find((q) => q.id === questionId); if (!question) return; const frontId = nanoid(); if (question.type === null) { const copiedQuestion = structuredClone(question); copiedQuestion.id = frontId; setProducedState( (state) => { state.questions.push(copiedQuestion); }, { type: "copyQuestion", questionId, quizId, }, ); return; } try { const { updated: newQuestionId } = await questionApi.copy( question.backendId, quizId, ); const copiedQuestion = structuredClone(question); copiedQuestion.backendId = newQuestionId; copiedQuestion.id = frontId; copiedQuestion.content.id = frontId; copiedQuestion.content.rule = { main: [], parentId: "", default: "", children: [], }; setProducedState( (state) => { state.questions.push(copiedQuestion); }, { type: "copyQuestion", questionId, quizId, }, ); updateQuestionOrders(); } catch (error) { devlog("Error copying question", error); enqueueSnackbar("Не удалось скопировать вопрос"); } }); function setProducedState( recipe: (state: QuestionsStore) => void, action?: A, ) { useQuestionsStore.setState((state) => produce(state, recipe), false, action); } 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) => { if (questionContentId === null) return null; return ( useQuestionsStore.getState().questions.find((q) => { if (q.type === null) return false; return q.content.id === questionContentId; }) || null ); }; export const clearRuleForAll = () => { const { questions } = useQuestionsStore.getState(); return Promise.allSettled( questions.map((question) => { if ( question.type !== null && (question.content.rule.main.length > 0 || question.content.rule.default.length > 0 || question.content.rule.parentId.length > 0) && question.type !== "result" ) { updateQuestion(question.content.id, (question) => { question.content.rule.parentId = ""; question.content.rule.main = []; question.content.rule.default = ""; }); } }), ); }; export const createResult = async (quizId: number, parentContentId?: string) => requestQueue.enqueue(async () => { if (!quizId || !parentContentId) { console.error( "Нет данных для создания результата. quizId: ", quizId, ", quizId: ", parentContentId, ); } //Мы получили запрос на создание резулта. Анализируем существует ли такой. Если да - просто делаем его активным const question = useQuestionsStore .getState() .questions.find( (q) => q.type !== null && q?.content.rule.parentId === parentContentId, ); if (question) { //существует, делаем активным updateQuestion(question.id, (q) => { q.content.usage = true; }); } else { //не существует, создаём const content = JSON.parse( JSON.stringify(defaultQuestionByType["result"].content), ); content.rule.parentId = parentContentId; try { const createdQuestion: RawQuestion = await questionApi.create({ quiz_id: quizId, type: "result", title: "", description: "", page: 101, required: true, content: JSON.stringify(content), }); setProducedState( (state) => { state.questions.push(rawQuestionToQuestion(createdQuestion)); }, { type: "createBackResult", createdQuestion, }, ); return createdQuestion; } catch (error) { devlog("Error creating question", error); enqueueSnackbar("Не удалось создать вопрос"); } } });