frontPanel/src/stores/questions/actions.ts

690 lines
18 KiB
TypeScript
Raw Normal View History

2023-11-02 16:45:28 +00:00
import { questionApi } from "@api/question";
2023-12-01 18:05:59 +00:00
import { quizApi } from "@api/quiz";
2023-11-02 16:45:28 +00:00
import { devlog } from "@frontend/kitui";
2023-11-27 23:07:24 +00:00
import { questionToEditQuestionRequest } from "@model/question/edit";
2023-12-31 02:53:25 +00:00
import {
QuestionType,
RawQuestion,
rawQuestionToQuestion,
} from "@model/question/question";
import {
AnyTypedQuizQuestion,
QuestionVariant,
UntypedQuizQuestion,
createQuestionVariant,
} from "@model/questionTypes/shared";
2023-11-29 13:49:52 +00:00
import { defaultQuestionByType } from "../../constants/default";
2023-11-02 16:45:28 +00:00
import { produce } from "immer";
2023-11-28 19:16:00 +00:00
import { nanoid } from "nanoid";
2023-11-02 16:45:28 +00:00
import { enqueueSnackbar } from "notistack";
2023-11-14 16:44:27 +00:00
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
import { RequestQueue } from "../../utils/requestQueue";
2023-12-10 17:41:57 +00:00
import { updateRootContentId } from "@root/quizes/actions";
import { useCurrentQuiz } from "@root/quizes/hooks";
2023-11-27 23:07:24 +00:00
import { QuestionsStore, useQuestionsStore } from "./store";
2023-12-14 13:56:26 +00:00
import { useUiTools } from "../uiTools/store";
import { withErrorBoundary } from "react-error-boundary";
import { QuizQuestionResult } from "@model/questionTypes/result";
2023-12-28 12:54:04 +00:00
import { replaceEmptyLinesToSpace } from "../../utils/replaceEmptyLinesToSpace";
2023-11-02 16:45:28 +00:00
2023-12-31 02:53:25 +00:00
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 = {
2023-11-29 13:49:52 +00:00
id: nanoid(),
quizId,
type: null,
title: "",
description: "",
deleted: false,
expanded: true,
2023-12-31 02:53:25 +00:00
};
2023-12-12 14:47:47 +00:00
2023-12-31 02:53:25 +00:00
if (insertAfterQuestionId) {
const index = state.questions.findIndex(
(q) => q.id === insertAfterQuestionId,
);
2023-12-12 14:47:47 +00:00
if (index === -1) return;
state.questions.splice(index + 1, 0, newUntypedQuestion);
return;
2023-12-31 02:53:25 +00:00
}
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,
},
);
2023-11-15 18:38:02 +00:00
2023-11-29 13:49:52 +00:00
export const updateUntypedQuestion = (
2023-12-31 02:53:25 +00:00
questionId: string,
updateFn: (question: UntypedQuizQuestion) => void,
2023-11-29 13:49:52 +00:00
) => {
2023-12-31 02:53:25 +00:00
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(),
},
);
2023-11-29 13:49:52 +00:00
};
2023-12-31 02:53:25 +00:00
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,
},
);
2023-11-02 16:45:28 +00:00
2023-12-08 13:36:00 +00:00
const updateQuestionOrders = () => {
2023-12-31 02:53:25 +00:00
const questions = useQuestionsStore
.getState()
.questions.filter(
(question): question is AnyTypedQuizQuestion =>
question.type !== null && question.type !== "result",
2023-12-08 13:36:00 +00:00
);
2023-12-31 02:53:25 +00:00
questions.forEach((question, index) => {
updateQuestion(
question.id,
(question) => {
question.page = index;
},
true,
);
});
2023-12-08 13:36:00 +00:00
};
export const reorderQuestions = (
2023-12-31 02:53:25 +00:00
sourceIndex: number,
destinationIndex: number,
) => {
2023-12-31 02:53:25 +00:00
if (sourceIndex === destinationIndex) return;
setProducedState(
(state) => {
const [removed] = state.questions.splice(sourceIndex, 1);
state.questions.splice(destinationIndex, 0, removed);
},
{
type: "reorderQuestions",
sourceIndex,
destinationIndex,
},
);
updateQuestionOrders();
};
2023-12-31 02:53:25 +00:00
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");
2023-11-29 13:49:52 +00:00
2023-12-12 19:05:30 +00:00
const DELETE_TIMEOUT = 5000;
2023-12-31 02:53:25 +00:00
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));
2023-12-12 19:05:30 +00:00
return;
2023-12-31 02:53:25 +00:00
}
2023-12-12 19:05:30 +00:00
2023-12-31 02:53:25 +00:00
question.deleted = true;
clearTimeout(question.deleteTimeoutId);
question.deleteTimeoutId = window.setTimeout(() => {
2023-12-12 19:05:30 +00:00
deleteFn(questionId);
2023-12-31 02:53:25 +00:00
}, 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;
2023-12-12 19:05:30 +00:00
2023-12-31 02:53:25 +00:00
question.deleted = false;
clearTimeout(question.deleteTimeoutId);
},
{
type: "cancelQuestionDeletion",
questionId,
},
);
2023-11-29 13:49:52 +00:00
const REQUEST_DEBOUNCE = 200;
const requestQueue = new RequestQueue();
let requestTimeoutId: ReturnType<typeof setTimeout>;
export const updateQuestion = async <T = AnyTypedQuizQuestion>(
2023-12-31 02:53:25 +00:00
questionId: string,
updateFn: (question: T) => void,
skipQueue = false,
2023-11-29 13:49:52 +00:00
) => {
2023-12-31 02:53:25 +00:00
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");
2023-11-29 13:49:52 +00:00
2023-12-31 02:53:25 +00:00
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;
2023-12-08 13:36:00 +00:00
2023-12-31 02:53:25 +00:00
devlog("Error editing question", { error, questionId });
enqueueSnackbar("Не удалось сохранить вопрос");
2023-12-08 13:36:00 +00:00
}
2023-12-31 02:53:25 +00:00
};
2023-12-08 13:36:00 +00:00
2023-12-31 02:53:25 +00:00
if (skipQueue) {
request();
return;
}
// requestTimeoutId = setTimeout(() => {
requestQueue.enqueue(request);
// }, REQUEST_DEBOUNCE);
2023-11-29 13:49:52 +00:00
};
2023-11-27 23:07:24 +00:00
export const addQuestionVariant = (questionId: string) => {
2023-12-31 02:53:25 +00:00
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}"`,
);
}
});
2023-11-15 18:38:02 +00:00
};
2023-12-31 02:53:25 +00:00
export const deleteQuestionVariant = (
questionId: string,
variantId: string,
) => {
updateQuestion(questionId, (question) => {
if (!("variants" in question.content)) return;
2023-11-15 18:38:02 +00:00
2023-12-31 02:53:25 +00:00
const variantIndex = question.content.variants.findIndex(
(variant) => variant.id === variantId,
);
if (variantIndex === -1) return;
2023-11-15 18:38:02 +00:00
2023-12-31 02:53:25 +00:00
question.content.variants.splice(variantIndex, 1);
});
2023-11-15 18:38:02 +00:00
};
export const setQuestionVariantField = (
2023-12-31 02:53:25 +00:00
questionId: string,
variantId: string,
field: keyof QuestionVariant,
value: QuestionVariant[keyof QuestionVariant],
2023-11-15 18:38:02 +00:00
) => {
2023-12-31 02:53:25 +00:00
updateQuestion(questionId, (question) => {
if (!("variants" in question.content)) return;
2023-12-01 14:33:55 +00:00
2023-12-31 02:53:25 +00:00
const variantIndex = question.content.variants.findIndex(
(variant) => variant.id === variantId,
);
if (variantIndex === -1) return;
2023-12-01 14:33:55 +00:00
2023-12-31 02:53:25 +00:00
const variant = question.content.variants[variantIndex];
variant[field] = value;
});
2023-11-15 18:38:02 +00:00
};
export const reorderQuestionVariants = (
2023-12-31 02:53:25 +00:00
questionId: string,
sourceIndex: number,
destinationIndex: number,
2023-11-15 18:38:02 +00:00
) => {
2023-12-31 02:53:25 +00:00
if (sourceIndex === destinationIndex) return;
2023-11-15 18:38:02 +00:00
2023-12-31 02:53:25 +00:00
updateQuestion(questionId, (question) => {
if (!("variants" in question.content)) return;
2023-12-01 14:33:55 +00:00
2023-12-31 02:53:25 +00:00
const [removed] = question.content.variants.splice(sourceIndex, 1);
question.content.variants.splice(destinationIndex, 0, removed);
});
2023-11-15 18:38:02 +00:00
};
2023-12-01 18:05:59 +00:00
export const uploadQuestionImage = async (
2023-12-31 02:53:25 +00:00
questionId: string,
quizQid: string | undefined,
blob: Blob,
updateFn: (question: AnyTypedQuizQuestion, imageId: string) => void,
) => {
2023-12-31 02:53:25 +00:00
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;
}
2023-12-31 02:53:25 +00:00
const imageId = values[0];
const imageUrl = process.env.REACT_APP_DOMAIN + `/squizimages/${quizQid}/${imageId}`;
2023-12-31 02:53:25 +00:00
updateQuestion(questionId, (question) => {
updateFn(question, imageUrl);
});
2023-12-31 02:53:25 +00:00
return imageUrl;
} catch (error) {
devlog("Error uploading question image", error);
2023-12-31 02:53:25 +00:00
enqueueSnackbar("Не удалось загрузить изображение");
}
};
2023-12-31 02:53:25 +00:00
export const setQuestionInnerName = (questionId: string, name: string) => {
updateQuestion(questionId, (question) => {
question.content.innerName = name;
});
};
2023-12-31 02:53:25 +00:00
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),
);
2023-12-31 02:53:25 +00:00
question.content.id = oldId;
question.content.rule = oldRule;
});
2023-11-02 16:45:28 +00:00
};
2023-11-29 13:49:52 +00:00
export const createTypedQuestion = async (
2023-12-31 02:53:25 +00:00
questionId: string,
type: QuestionType,
) =>
requestQueue.enqueue(async () => {
2023-12-08 13:36:00 +00:00
const questions = useQuestionsStore.getState().questions;
2023-12-31 02:53:25 +00:00
const question = questions.find((q) => q.id === questionId);
2023-11-29 13:49:52 +00:00
if (!question) return;
2023-12-31 02:53:25 +00:00
if (question.type !== null)
throw new Error("Cannot upgrade already typed question");
2023-12-03 13:09:10 +00:00
2023-12-31 02:53:25 +00:00
const untypedOrResultQuestionsLength = questions.filter(
(q) => q.type === "result" || q.type === null,
).length;
2023-12-10 17:41:57 +00:00
2023-11-02 16:45:28 +00:00
try {
2023-12-31 02:53:25 +00:00
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),
2023-11-29 13:49:52 +00:00
);
2023-12-31 02:53:25 +00:00
},
{
type: "createTypedQuestion",
question,
},
);
updateQuestionOrders();
2023-11-02 16:45:28 +00:00
} catch (error) {
2023-12-31 02:53:25 +00:00
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
2023-11-02 16:45:28 +00:00
}
2023-12-31 02:53:25 +00:00
});
2023-12-04 13:33:43 +00:00
2023-12-31 02:53:25 +00:00
export const deleteQuestion = async (questionId: string) =>
requestQueue.enqueue(async () => {
const question = useQuestionsStore
.getState()
.questions.find((q) => q.id === questionId);
2023-11-27 23:07:24 +00:00
if (!question) return;
2023-11-02 16:45:28 +00:00
2023-11-29 13:49:52 +00:00
if (question.type === null) {
2023-12-31 02:53:25 +00:00
removeQuestion(questionId);
return;
2023-11-29 13:49:52 +00:00
}
2023-11-02 16:45:28 +00:00
try {
2023-12-31 02:53:25 +00:00
await questionApi.delete(question.backendId);
2023-12-12 14:47:47 +00:00
2023-12-31 02:53:25 +00:00
removeQuestion(questionId);
2023-12-10 17:41:57 +00:00
2023-12-31 02:53:25 +00:00
updateQuestionOrders();
2023-11-02 16:45:28 +00:00
} catch (error) {
2023-12-31 02:53:25 +00:00
devlog("Error deleting question", error);
enqueueSnackbar("Не удалось удалить вопрос");
2023-11-02 16:45:28 +00:00
}
2023-12-31 02:53:25 +00:00
});
2023-11-27 23:07:24 +00:00
2023-12-31 02:53:25 +00:00
export const copyQuestion = async (questionId: string, quizId: number) =>
requestQueue.enqueue(async () => {
const question = useQuestionsStore
.getState()
.questions.find((q) => q.id === questionId);
2023-11-27 23:07:24 +00:00
if (!question) return;
2023-11-02 16:45:28 +00:00
2023-12-04 13:33:43 +00:00
const frontId = nanoid();
2023-11-29 13:49:52 +00:00
if (question.type === null) {
2023-12-31 02:53:25 +00:00
const copiedQuestion = structuredClone(question);
copiedQuestion.id = frontId;
setProducedState(
(state) => {
state.questions.push(copiedQuestion);
},
{
type: "copyQuestion",
questionId,
quizId,
},
);
return;
2023-11-29 13:49:52 +00:00
}
2023-11-15 18:38:02 +00:00
try {
2023-12-31 02:53:25 +00:00
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();
2023-11-15 18:38:02 +00:00
} catch (error) {
2023-12-31 02:53:25 +00:00
devlog("Error copying question", error);
enqueueSnackbar("Не удалось скопировать вопрос");
2023-11-15 18:38:02 +00:00
}
2023-12-31 02:53:25 +00:00
});
2023-11-02 16:45:28 +00:00
2023-12-31 02:53:25 +00:00
function setProducedState<A extends string | { type: unknown }>(
recipe: (state: QuestionsStore) => void,
action?: A,
2023-11-02 16:45:28 +00:00
) {
2023-12-31 02:53:25 +00:00
useQuestionsStore.setState((state) => produce(state, recipe), false, action);
}
2023-11-29 15:45:15 +00:00
export const getQuestionById = (questionId: string | null) => {
2023-12-31 02:53:25 +00:00
if (questionId === null) return null;
return (
useQuestionsStore.getState().questions.find((q) => q.id === questionId) ||
null
);
2023-11-29 15:45:15 +00:00
};
export const getQuestionByContentId = (questionContentId: string | null) => {
2023-12-31 02:53:25 +00:00
if (questionContentId === null) return null;
return (
useQuestionsStore.getState().questions.find((q) => {
if (q.type === null) return false;
return q.content.id === questionContentId;
}) || null
);
};
2023-11-29 15:45:15 +00:00
export const clearRuleForAll = () => {
2023-12-31 02:53:25 +00:00
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 = "";
});
}
}),
);
2023-12-10 17:41:57 +00:00
};
2023-12-31 02:53:25 +00:00
export const createResult = async (quizId: number, parentContentId?: string) =>
requestQueue.enqueue(async () => {
if (!quizId || !parentContentId) {
2023-12-31 02:53:25 +00:00
console.error(
"Нет данных для создания результата. quizId: ",
quizId,
", quizId: ",
parentContentId,
);
}
2023-12-07 21:30:26 +00:00
//Мы получили запрос на создание резулта. Анализируем существует ли такой. Если да - просто делаем его активным
2023-12-31 02:53:25 +00:00
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),
});
2023-12-31 02:53:25 +00:00
setProducedState(
(state) => {
state.questions.push(rawQuestionToQuestion(createdQuestion));
},
{
type: "createBackResult",
createdQuestion,
},
);
return createdQuestion;
} catch (error) {
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
}
}
});