frontPanel/src/stores/questions/actions.ts

564 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { withErrorBoundary } from "react-error-boundary";
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
const untypedResultQuestions = state.questions.filter(q => q.type === null || q.type === "result");
state.questions = questions?.map(rawQuestionToQuestion) ?? [];
state.questions.push(...untypedResultQuestions);
}, {
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,
});
const updateQuestionOrders = () => {
const questions = useQuestionsStore.getState().questions.filter(
(question): question is AnyTypedQuizQuestion => question.type !== null && question.type !== "result"
);
console.log(questions)
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 REQUEST_DEBOUNCE = 200;
const requestQueue = new RequestQueue();
let requestTimeoutId: ReturnType<typeof setTimeout>;
export const updateQuestion = (
questionId: string,
updateFn: (question: AnyTypedQuizQuestion) => 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);
}, {
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(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)
deleteQuestion
setQuestionBackendId(questionId, 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 = `https://squiz.pena.digital/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 = 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");
try {
const createdQuestion = await questionApi.create({
quiz_id: question.quizId,
type,
title: question.title,
description: question.description,
page: questions.length,
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,
});
} catch (error) {
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
}
});
export const deleteQuestion = async (questionId: string, quizId: 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);
if (question.content.rule.parentId === "root") { //удалить из стора root и очистить rule всем вопросам
updateRootContentId(quizId, "")
clearRuleForAll()
} else if (question.content.rule.parentId.length > 0) { //удалить из стора вопрос из дерева и очистить его потомков
const clearQuestions = [] as string[]
const getChildren = (parentQuestion: AnyTypedQuizQuestion) => {
questions.forEach((targetQuestion) => {
if (targetQuestion.content.rule.parentId === parentQuestion.content.id) {//если у вопроса совпал родитель с родителем => он потомок, в кучу его
if (!clearQuestions.includes(targetQuestion.content.id)) clearQuestions.push(targetQuestion.content.id)
getChildren(targetQuestion) //и ищем его потомков
}
})
}
getChildren(question)
//чистим потомков от инфы ветвления
clearQuestions.forEach((id) => {
updateQuestion(id, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
})
//чистим rule родителя
const parentQuestion = getQuestionByContentId(question.content.rule.parentId)
const newRule = {}
newRule.main = parentQuestion.content.rule.main.filter((data) => data.next !== question.content.id) //удаляем условия перехода от родителя к этому вопросу
newRule.parentId = parentQuestion.content.rule.parentId
newRule.default = questions.filter((q) => {
return q.content.rule.parentId === question.content.rule.parentId && q.content.id !== question.content.id
})[0]?.content.id || ""
//Если этот вопрос был дефолтным у родителя - чистим дефолт
//Смотрим можем ли мы заменить id на один из main
updateQuestion(question.content.rule.parentId, (PQ) => {
PQ.content.rule = newRule
})
}
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;
const frontId = nanoid();
if (question.type === null) {
const copiedQuestion = structuredClone(question);
copiedQuestion.id = frontId
copiedQuestion.content.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: "" };
setProducedState(state => {
state.questions.push(copiedQuestion);
}, {
type: "copyQuestion",
questionId,
quizId,
});
updateQuestionOrders();
} catch (error) {
devlog("Error copying question", error);
enqueueSnackbar("Не удалось скопировать вопрос");
}
});
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuestionsStore) => void,
action?: A,
) {
useQuestionsStore.setState(state => produce(state, recipe), false, action);
};
export const cleardragQuestionContentId = () => {
useQuestionsStore.setState({ dragQuestionContentId: null });
};
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 updateOpenedModalSettingsId = (id?: string) => useQuestionsStore.setState({ openedModalSettingsId: id ? id : null });
export const updateDragQuestionContentId = (contentId?: string) => {
useQuestionsStore.setState({ dragQuestionContentId: contentId ? contentId : null });
};
export const clearRuleForAll = () => {
const { questions } = useQuestionsStore.getState()
questions.forEach(question => {
if (question.type !== null && (question.content.rule.main.length > 0 || question.content.rule.default.length > 0 || question.content.rule.parentId.length > 0)) {
updateQuestion(question.content.id, question => {
question.content.rule.parentId = ""
question.content.rule.main = []
question.content.rule.default = ""
})
}
});
}
export const updateOpenBranchingPanel = (value: boolean) => useQuestionsStore.setState({ openBranchingPanel: value });
let UDTOABM: ReturnType<typeof setTimeout>;
export const updateDesireToOpenABranchingModal = (contentId: string) => {
useQuestionsStore.setState({ desireToOpenABranchingModal: contentId })
clearTimeout(UDTOABM)
UDTOABM = setTimeout(() => {
useQuestionsStore.setState({ desireToOpenABranchingModal: null })
}, 7000)
}
export const clearDesireToOpenABranchingModal = () => {
useQuestionsStore.setState({ desireToOpenABranchingModal: null })
}
export const updateEditSomeQuestion = (contentId?: string) => {
useQuestionsStore.setState({ editSomeQuestion: contentId === undefined ? null : contentId })
}
export const createFrontResult = (quizId: number, parentContentId?: string) => setProducedState(state => {
const frontId = nanoid()
const content = JSON.parse(JSON.stringify(defaultQuestionByType["result"].content))
content.id = frontId
if (parentContentId) content.rule.parentId = parentContentId
state.questions.push({
id: frontId,
quizId,
type: "result",
title: "",
description: "",
deleted: false,
expanded: true,
page: 101,
required: true,
content
});
}, {
type: "createFrontResult",
quizId,
});
export const createBackResult = async (
questionId: string,
type: QuestionType,
) => requestQueue.enqueue(async () => {
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
if (!question) return;
if (question.type !== "result") 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("Не удалось создать вопрос");
}
});