import { questionApi } from "@api/question"; 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 { QuestionsStore, useQuestionsStore } from "./store"; import { QUESTIONS_DUMMY } from "../../constants/questions.dummy"; export const setDefaultState = (quizId: number) => setProducedState( (state) => { QUESTIONS_DUMMY.forEach((question) => { state.questions.push(question); }); }, { type: "setDefaultState", quizId, } ); export const setQuestions = (questions: RawQuestion[] | null) => setProducedState( (state) => { const untypedQuestions = state.questions.filter((q) => q.type === null); state.questions = questions?.map(rawQuestionToQuestion) ?? []; state.questions.push(...untypedQuestions); }, { type: "setQuestions", questions, } ); export const createUntypedQuestion = (quizId: number) => setProducedState( (state) => { state.questions.push({ id: nanoid(), quizId, type: null, title: "", description: "", deleted: false, expanded: true, }); }, { 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, } ); 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, } ); }; 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 REQUEST_DEBOUNCE = 200; const requestQueue = new RequestQueue(); let requestTimeoutId: ReturnType; export const updateQuestion = ( questionId: string, updateFn: (question: AnyTypedQuizQuestion) => void ) => { setProducedState( (state) => { const question = state.questions.find((q) => q.id === questionId); if (!question) return; if (question.type === null) throw new Error( "Cannot update untyped question, use 'updateUntypedQuestion' instead" ); updateFn(question); }, { type: "updateQuestion", questionId, updateFn: updateFn.toString(), } ); clearTimeout(requestTimeoutId); requestTimeoutId = setTimeout(() => { requestQueue .enqueue(async () => { const q = useQuestionsStore .getState() .questions.find((q) => q.id === questionId); if (!q) return; if (q.type === null) throw new Error("Cannot send update request for untyped question"); const response = await questionApi.edit( questionToEditQuestionRequest(q) ); setQuestionBackendId(questionId, response.updated); }) .catch((error) => { if (isAxiosCanceledError(error)) return; devlog("Error editing question", { error, questionId }); enqueueSnackbar("Не удалось сохранить вопрос"); }); }, 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]; if (value) { 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 setQuestionBackgroundImage = (questionId: string, url: string) => { updateQuestion(questionId, (question) => { if (question.content.back === url) return; if (question.content.back !== question.content.originalBack) URL.revokeObjectURL(question.content.back); question.content.back = url; }); }; export const setQuestionOriginalBackgroundImage = ( questionId: string, url: string ) => { updateQuestion(questionId, (question) => { if (question.content.originalBack === url) return; URL.revokeObjectURL(question.content.originalBack); question.content.originalBack = url; }); }; export const setVariantImageUrl = ( questionId: string, variantId: string, url: string ) => { updateQuestion(questionId, (question) => { if (!("variants" in question.content)) return; const variant = question.content.variants.find( (variant) => variant.id === variantId ); if (!variant) return; if (variant.extendedText === url) return; if (variant.extendedText !== variant.originalImageUrl) URL.revokeObjectURL(variant.extendedText); variant.extendedText = url; }); }; export const setVariantOriginalImageUrl = ( questionId: string, variantId: string, url: string ) => { updateQuestion(questionId, (question) => { if (!("variants" in question.content)) return; const variant = question.content.variants.find( (variant) => variant.id === variantId ) as QuestionVariant | undefined; if (!variant) return; if (variant.originalImageUrl === url) return; if (variant.originalImageUrl) { URL.revokeObjectURL(variant.originalImageUrl); } variant.originalImageUrl = url; }); }; export const setPageQuestionPicture = (questionId: string, url: string) => { updateQuestion(questionId, (question) => { if (question.type !== "page") return; if (question.content.picture === url) return; if (question.content.picture !== question.content.originalPicture) URL.revokeObjectURL(question.content.picture); question.content.picture = url; }); }; export const setPageQuestionOriginalPicture = ( questionId: string, url: string ) => { updateQuestion(questionId, (question) => { if (question.type !== "page") return; if (question.content.originalPicture === url) return; URL.revokeObjectURL(question.content.originalPicture); question.content.originalPicture = url; }); }; export const setQuestionInnerName = (questionId: string, name: string) => { updateQuestion(questionId, (question) => { question.content.innerName = name; }); }; export const changeQuestionType = (questionId: string, type: QuestionType) => { updateQuestion(questionId, (question) => { question.type = type; question.content = defaultQuestionByType[type].content; }); }; export const createTypedQuestion = async ( questionId: string, type: QuestionType ) => requestQueue.enqueue(async () => { const question = useQuestionsStore .getState() .questions.find((q) => q.id === questionId); if (!question) return; if (question.type !== null) throw new Error("Cannot upgrade already typed question"); try { const createdQuestion = await questionApi.create({ quiz_id: question.quizId, type, title: question.title, description: question.description, page: 0, required: true, content: JSON.stringify(defaultQuestionByType[type].content), }); setProducedState( (state) => { const questionIndex = state.questions.findIndex( (q) => q.id === questionId ); if (questionIndex !== -1) state.questions.splice( questionIndex, 1, rawQuestionToQuestion(createdQuestion) ); }, { type: "createTypedQuestion", question, } ); } 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); } 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; if (question.type === null) { const copiedQuestion = structuredClone(question); copiedQuestion.id = nanoid(); 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 = nanoid(); setProducedState( (state) => { state.questions.push(copiedQuestion); }, { type: "copyQuestion", questionId, quizId, } ); } catch (error) { devlog("Error copying question", error); enqueueSnackbar("Не удалось скопировать вопрос"); } }); function setProducedState( recipe: (state: QuestionsStore) => void, action?: A ) { useQuestionsStore.setState((state) => produce(state, recipe), false, action); }