2023-11-02 16:45:28 +00:00
|
|
|
|
import { questionApi } from "@api/question";
|
|
|
|
|
import { devlog } from "@frontend/kitui";
|
2023-11-15 18:38:02 +00:00
|
|
|
|
import { questionToEditQuestionRequest } from "@model/question/edit";
|
2023-11-16 16:41:25 +00:00
|
|
|
|
import { QuestionType, RawQuestion, rawQuestionToQuestion } from "@model/question/question";
|
2023-11-15 18:38:02 +00:00
|
|
|
|
import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared";
|
2023-11-02 16:45:28 +00:00
|
|
|
|
import { produce } from "immer";
|
|
|
|
|
import { enqueueSnackbar } from "notistack";
|
2023-11-15 18:38:02 +00:00
|
|
|
|
import { notReachable } from "utils/notReachable";
|
2023-11-14 16:44:27 +00:00
|
|
|
|
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
|
|
|
|
|
import { QuestionsStore, useQuestionsStore } from "./store";
|
2023-11-02 16:45:28 +00:00
|
|
|
|
|
|
|
|
|
|
2023-11-14 20:15:52 +00:00
|
|
|
|
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
state.questions = questions?.map(rawQuestionToQuestion) ?? [];
|
2023-11-14 16:44:27 +00:00
|
|
|
|
}, {
|
2023-11-14 20:15:52 +00:00
|
|
|
|
type: "setQuestions",
|
|
|
|
|
questions,
|
2023-11-14 16:44:27 +00:00
|
|
|
|
});
|
2023-11-02 16:45:28 +00:00
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const setQuestion = (question: AnyQuizQuestion) => setProducedState(state => {
|
|
|
|
|
const index = state.questions.findIndex(q => q.id === question.id);
|
|
|
|
|
state.questions.splice(index, 1, question);
|
2023-11-02 16:45:28 +00:00
|
|
|
|
}, {
|
|
|
|
|
type: "setQuestion",
|
|
|
|
|
question,
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const removeQuestion = (questionId: number) => setProducedState(state => {
|
|
|
|
|
const index = state.questions.findIndex(q => q.id === questionId);
|
|
|
|
|
state.questions.splice(index, 1);
|
2023-11-15 18:38:02 +00:00
|
|
|
|
}, {
|
|
|
|
|
type: "removeQuestion",
|
|
|
|
|
questionId,
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const setQuestionField = <T extends keyof AnyQuizQuestion>(
|
2023-11-02 16:45:28 +00:00
|
|
|
|
questionId: number,
|
|
|
|
|
field: T,
|
2023-11-14 20:15:52 +00:00
|
|
|
|
value: AnyQuizQuestion[T],
|
2023-11-02 16:45:28 +00:00
|
|
|
|
) => setProducedState(state => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const question = state.questions.find(q => q.id === questionId);
|
2023-11-02 16:45:28 +00:00
|
|
|
|
if (!question) return;
|
|
|
|
|
|
|
|
|
|
question[field] = value;
|
|
|
|
|
}, {
|
|
|
|
|
type: "setQuestionField",
|
|
|
|
|
questionId,
|
|
|
|
|
field,
|
|
|
|
|
value,
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
export const toggleExpandQuestion = (questionId: number) => setProducedState(state => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const question = state.questions.find(q => q.id === questionId);
|
2023-11-15 18:38:02 +00:00
|
|
|
|
if (!question) return;
|
|
|
|
|
|
|
|
|
|
question.expanded = !question.expanded;
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
export const collapseAllQuestions = () => setProducedState(state => {
|
|
|
|
|
state.questions.forEach(question => question.expanded = false);
|
|
|
|
|
});
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
export const toggleOpenQuestionModal = (questionId: number) => setProducedState(state => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const question = state.questions.find(q => q.id === questionId);
|
2023-11-15 18:38:02 +00:00
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
export const setVariantImageUrl = (
|
|
|
|
|
questionId: number,
|
|
|
|
|
variantId: string,
|
|
|
|
|
url: string,
|
|
|
|
|
) => {
|
|
|
|
|
updateQuestionWithFnOptimistic(questionId, question => {
|
|
|
|
|
if (!("variants" in question.content)) return;
|
|
|
|
|
|
|
|
|
|
const variant = question.content.variants.find(variant => variant.id === variantId);
|
|
|
|
|
if (!variant || !("originalImageUrl" in variant)) return;
|
|
|
|
|
|
|
|
|
|
if (variant.extendedText === url) return;
|
|
|
|
|
|
|
|
|
|
if (variant.extendedText !== variant.originalImageUrl) URL.revokeObjectURL(variant.extendedText);
|
|
|
|
|
variant.extendedText = url;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const setVariantOriginalImageUrl = (
|
|
|
|
|
questionId: number,
|
|
|
|
|
variantId: string,
|
|
|
|
|
url: string,
|
|
|
|
|
) => {
|
|
|
|
|
updateQuestionWithFnOptimistic(questionId, question => {
|
|
|
|
|
if (!("variants" in question.content)) return;
|
|
|
|
|
|
|
|
|
|
const variant = question.content.variants.find(
|
|
|
|
|
variant => variant.id === variantId
|
|
|
|
|
) as ImageQuestionVariant | undefined;
|
|
|
|
|
if (!variant || !("originalImageUrl" in variant)) return;
|
|
|
|
|
|
|
|
|
|
if (variant.originalImageUrl === url) return;
|
|
|
|
|
|
|
|
|
|
URL.revokeObjectURL(variant.originalImageUrl);
|
|
|
|
|
variant.originalImageUrl = url;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const setPageQuestionPicture = (
|
|
|
|
|
questionId: number,
|
|
|
|
|
url: string,
|
|
|
|
|
) => {
|
|
|
|
|
updateQuestionWithFnOptimistic(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: number,
|
|
|
|
|
url: string,
|
|
|
|
|
) => {
|
|
|
|
|
updateQuestionWithFnOptimistic(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: number,
|
|
|
|
|
name: string,
|
|
|
|
|
) => {
|
|
|
|
|
updateQuestionWithFnOptimistic(questionId, question => {
|
|
|
|
|
question.content.innerName = name;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
|
2023-11-14 20:15:52 +00:00
|
|
|
|
let savedOriginalQuestion: AnyQuizQuestion | null = null;
|
2023-11-02 16:45:28 +00:00
|
|
|
|
let controller: AbortController | null = null;
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
export const updateQuestionWithFnOptimistic = async (
|
2023-11-02 16:45:28 +00:00
|
|
|
|
questionId: number,
|
2023-11-15 18:38:02 +00:00
|
|
|
|
updateFn: (question: AnyQuizQuestion) => void,
|
2023-11-02 16:45:28 +00:00
|
|
|
|
) => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
|
2023-11-02 16:45:28 +00:00
|
|
|
|
if (!question) return;
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
const currentUpdatedQuestion = produce(question, updateFn);
|
|
|
|
|
|
2023-11-02 16:45:28 +00:00
|
|
|
|
controller?.abort();
|
|
|
|
|
controller = new AbortController();
|
|
|
|
|
savedOriginalQuestion ??= question;
|
|
|
|
|
|
|
|
|
|
setQuestion(currentUpdatedQuestion);
|
|
|
|
|
try {
|
2023-11-15 18:38:02 +00:00
|
|
|
|
const { updated: newId } = await questionApi.edit(
|
2023-11-14 20:15:52 +00:00
|
|
|
|
questionToEditQuestionRequest(currentUpdatedQuestion),
|
|
|
|
|
controller.signal,
|
|
|
|
|
);
|
2023-11-02 16:45:28 +00:00
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
setQuestionField(question.id, "id", newId);
|
|
|
|
|
|
2023-11-02 16:45:28 +00:00
|
|
|
|
controller = null;
|
|
|
|
|
savedOriginalQuestion = null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isAxiosCanceledError(error)) return;
|
|
|
|
|
|
2023-11-13 18:04:51 +00:00
|
|
|
|
devlog("Error editing question", { error, question, currentUpdatedQuestion });
|
2023-11-02 16:45:28 +00:00
|
|
|
|
enqueueSnackbar("Не удалось сохранить вопрос");
|
2023-11-15 18:38:02 +00:00
|
|
|
|
|
2023-11-02 16:45:28 +00:00
|
|
|
|
if (!savedOriginalQuestion) {
|
|
|
|
|
devlog("Cannot rollback question");
|
|
|
|
|
throw new Error("Cannot rollback question");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setQuestion(savedOriginalQuestion);
|
|
|
|
|
controller = null;
|
|
|
|
|
savedOriginalQuestion = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
export const createQuestion = async (quizId: number, type: QuestionType = "variant") => {
|
2023-11-02 16:45:28 +00:00
|
|
|
|
try {
|
2023-11-14 16:44:27 +00:00
|
|
|
|
const question = await questionApi.create({
|
|
|
|
|
quiz_id: quizId,
|
2023-11-16 16:41:25 +00:00
|
|
|
|
type,
|
2023-11-14 16:44:27 +00:00
|
|
|
|
});
|
2023-11-02 16:45:28 +00:00
|
|
|
|
|
2023-11-14 20:15:52 +00:00
|
|
|
|
setQuestion(rawQuestionToQuestion(question));
|
2023-11-02 16:45:28 +00:00
|
|
|
|
} 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("Не удалось удалить вопрос");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-15 18:38:02 +00:00
|
|
|
|
export const copyQuestion = async (questionId: number, quizId: number) => {
|
|
|
|
|
try {
|
|
|
|
|
const { updated: newQuestionId } = await questionApi.copy(questionId, quizId);
|
|
|
|
|
|
|
|
|
|
setProducedState(state => {
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const question = state.questions.find(q => q.id === questionId);
|
2023-11-15 18:38:02 +00:00
|
|
|
|
if (!question) return;
|
|
|
|
|
|
2023-11-16 16:41:25 +00:00
|
|
|
|
const copiedQuestion = structuredClone(question);
|
|
|
|
|
copiedQuestion.id = newQuestionId;
|
|
|
|
|
state.questions.push(copiedQuestion);
|
2023-11-15 18:38:02 +00:00
|
|
|
|
}, {
|
|
|
|
|
type: "copyQuestion",
|
|
|
|
|
questionId,
|
|
|
|
|
quizId,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
devlog("Error copying question", error);
|
|
|
|
|
enqueueSnackbar("Не удалось скопировать вопрос");
|
|
|
|
|
}
|
|
|
|
|
};
|
2023-11-02 16:45:28 +00:00
|
|
|
|
|
|
|
|
|
function setProducedState<A extends string | { type: unknown; }>(
|
|
|
|
|
recipe: (state: QuestionsStore) => void,
|
|
|
|
|
action?: A,
|
|
|
|
|
) {
|
|
|
|
|
useQuestionsStore.setState(state => produce(state, recipe), false, action);
|
|
|
|
|
}
|