import { questionApi } from "@api/question"; import { devlog } from "@frontend/kitui"; import { questionToEditQuestionRequest } from "@model/question/edit"; import { RawQuestion, rawQuestionToQuestion } from "@model/question/question"; import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared"; import { produce } from "immer"; import { enqueueSnackbar } from "notistack"; import { notReachable } from "utils/notReachable"; import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; import { QuestionsStore, useQuestionsStore } from "./store"; export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => { state.questionsById = {}; if (questions === null) return; questions.forEach(question => state.questionsById[question.id] = rawQuestionToQuestion(question)); }, { type: "setQuestions", questions, }); export const setQuestion = (question: AnyQuizQuestion) => setProducedState(state => { state.questionsById[question.id] = question; }, { type: "setQuestion", question, }); export const removeQuestion = (questionId: number) => setProducedState(state => { delete state.questionsById[questionId]; }, { type: "removeQuestion", questionId, }); export const setQuestionField = ( questionId: number, field: T, value: AnyQuizQuestion[T], ) => setProducedState(state => { const question = state.questionsById[questionId]; if (!question) return; const oldId = question.id; question[field] = value; if (field === "id") { delete state.questionsById[oldId]; state.questionsById[value as number] = question; } }, { type: "setQuestionField", questionId, field, value, }); export const toggleExpandQuestion = (questionId: number) => setProducedState(state => { const question = state.questionsById[questionId]; if (!question) return; question.expanded = !question.expanded; }); export const toggleOpenQuestionModal = (questionId: number) => setProducedState(state => { const question = state.questionsById[questionId]; if (!question) return; question.openedModalSettings = !question.openedModalSettings; }); export const addQuestionVariant = (questionId: number) => { updateQuestionWithFnOptimistic(questionId, question => { switch (question.type) { case "variant": case "emoji": case "select": question.content.variants.push(createQuestionVariant()); break; case "images": case "varimg": question.content.variants.push(createQuestionImageVariant()); break; case "text": case "date": case "number": case "file": case "page": case "rating": throw new Error(`Cannot add variant to question of type "${question.type}"`); default: notReachable(question); } }); }; export const deleteQuestionVariant = (questionId: number, variantId: string) => { updateQuestionWithFnOptimistic(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: number, variantId: string, field: keyof QuestionVariant, value: QuestionVariant[keyof QuestionVariant], ) => { updateQuestionWithFnOptimistic(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 setQuestionImageVariantField = ( questionId: number, variantId: string, field: keyof ImageQuestionVariant, value: ImageQuestionVariant[keyof ImageQuestionVariant], ) => { updateQuestionWithFnOptimistic(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 (!("originalImageUrl" in variant)) return; variant[field] = value; }); }; export const reorderQuestionVariants = ( questionId: number, sourceIndex: number, destinationIndex: number, ) => { if (sourceIndex === destinationIndex) return; updateQuestionWithFnOptimistic(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: number, url: string, ) => { updateQuestionWithFnOptimistic(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: number, url: string, ) => { updateQuestionWithFnOptimistic(questionId, question => { if (question.content.originalBack === url) return; URL.revokeObjectURL(question.content.originalBack); question.content.originalBack = url; }); }; let savedOriginalQuestion: AnyQuizQuestion | null = null; let controller: AbortController | null = null; export const updateQuestionWithFnOptimistic = async ( questionId: number, updateFn: (question: AnyQuizQuestion) => void, ) => { const question = useQuestionsStore.getState().questionsById[questionId] ?? null; if (!question) return; const currentUpdatedQuestion = produce(question, updateFn); controller?.abort(); controller = new AbortController(); savedOriginalQuestion ??= question; setQuestion(currentUpdatedQuestion); try { const { updated: newId } = await questionApi.edit( questionToEditQuestionRequest(currentUpdatedQuestion), controller.signal, ); setQuestionField(question.id, "id", newId); controller = null; savedOriginalQuestion = null; } catch (error) { if (isAxiosCanceledError(error)) return; devlog("Error editing question", { error, question, currentUpdatedQuestion }); enqueueSnackbar("Не удалось сохранить вопрос"); if (!savedOriginalQuestion) { devlog("Cannot rollback question"); throw new Error("Cannot rollback question"); } setQuestion(savedOriginalQuestion); controller = null; savedOriginalQuestion = null; } }; export const createQuestion = async (quizId: number) => { try { const question = await questionApi.create({ quiz_id: quizId, }); setQuestion(rawQuestionToQuestion(question)); } catch (error) { devlog("Error creating question", error); enqueueSnackbar("Не удалось создать вопрос"); } }; export const deleteQuestion = async (questionId: number) => { try { await questionApi.delete(questionId); removeQuestion(questionId); } catch (error) { devlog("Error deleting question", error); enqueueSnackbar("Не удалось удалить вопрос"); } }; export const copyQuestion = async (questionId: number, quizId: number) => { try { const { updated: newQuestionId } = await questionApi.copy(questionId, quizId); setProducedState(state => { const question = state.questionsById[questionId]; if (!question) return; state.questionsById[newQuestionId] = question; }, { 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); }