diff --git a/src/model/question/edit.ts b/src/model/question/edit.ts index 1e25edfe..484ef709 100644 --- a/src/model/question/edit.ts +++ b/src/model/question/edit.ts @@ -16,9 +16,9 @@ export interface EditQuestionResponse { updated: number; } -export function questionToEditQuestionRequest(question: AnyQuizQuestion): EditQuestionRequest { +export function questionToEditQuestionRequest(question: AnyQuizQuestion, newId?: number): EditQuestionRequest { return { - id: question.id, + id: newId ?? question.id, title: question.title, desc: question.description, type: question.type, diff --git a/src/model/quiz/edit.ts b/src/model/quiz/edit.ts index 850ddfa3..539decfb 100644 --- a/src/model/quiz/edit.ts +++ b/src/model/quiz/edit.ts @@ -43,9 +43,9 @@ export interface EditQuizResponse { updated: number; } -export function quizToEditQuizRequest(quiz: Quiz): EditQuizRequest { +export function quizToEditQuizRequest(quiz: Quiz, newId?: number): EditQuizRequest { return { - id: quiz.id, + id: newId ?? quiz.id, fp: quiz.fingerprinting, rep: quiz.repeatable, note_prevented: quiz.note_prevented, diff --git a/src/pages/Questions/DraggableList/QuestionPageCard.tsx b/src/pages/Questions/DraggableList/QuestionPageCard.tsx index 2ad965ce..fcea54b2 100644 --- a/src/pages/Questions/DraggableList/QuestionPageCard.tsx +++ b/src/pages/Questions/DraggableList/QuestionPageCard.tsx @@ -91,7 +91,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging > setTitle(target.value)} InputProps={{ startAdornment: ( diff --git a/src/stores/questions/actions.ts b/src/stores/questions/actions.ts index 7b5587dd..98bebd65 100644 --- a/src/stores/questions/actions.ts +++ b/src/stores/questions/actions.ts @@ -1,6 +1,6 @@ import { questionApi } from "@api/question"; import { devlog } from "@frontend/kitui"; -import { questionToEditQuestionRequest } from "@model/question/edit"; +import { EditQuestionResponse, questionToEditQuestionRequest } from "@model/question/edit"; import { QuestionType, RawQuestion, rawQuestionToQuestion } from "@model/question/question"; import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared"; import { produce } from "immer"; @@ -8,6 +8,7 @@ import { enqueueSnackbar } from "notistack"; import { notReachable } from "../../utils/notReachable"; import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; import { QuestionsStore, useQuestionsStore } from "./store"; +import { RequestQueue } from "../../utils/requestQueue"; export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => { @@ -276,10 +277,9 @@ export const setQuestionInnerName = ( }); }; - - -let savedOriginalQuestion: AnyQuizQuestion | null = null; -let controller: AbortController | null = null; +const REQUEST_DEBOUNCE = 1000; +const requestQueue = new RequestQueue(); +let requestTimeoutId: ReturnType; export const updateQuestionWithFnOptimistic = async ( questionId: number, @@ -288,38 +288,25 @@ export const updateQuestionWithFnOptimistic = async ( const question = useQuestionsStore.getState().questions.find(q => q.id === questionId); if (!question) return; - const currentUpdatedQuestion = produce(question, updateFn); + const updatedQuestion = produce(question, updateFn); + setQuestion(updatedQuestion); - controller?.abort(); - controller = new AbortController(); - savedOriginalQuestion ??= question; + clearTimeout(requestTimeoutId); + requestTimeoutId = setTimeout(async () => { + requestQueue.enqueue(async (prevResponse) => { + const questionId = prevResponse?.updated ?? updatedQuestion.id; + const response = await questionApi.edit(questionToEditQuestionRequest(updatedQuestion, questionId)); - setQuestion(currentUpdatedQuestion); - try { - const { updated: newId } = await questionApi.edit( - questionToEditQuestionRequest(currentUpdatedQuestion), - controller.signal, - ); + setQuestionField(questionId, "id", response.updated); - setQuestionField(question.id, "id", newId); + return response; + }).catch(error => { + if (isAxiosCanceledError(error)) return; - 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; - } + devlog("Error editing question", { error, question, updatedQuestion }); + enqueueSnackbar("Не удалось сохранить вопрос"); + }); + }, REQUEST_DEBOUNCE); }; export const createQuestion = async (quizId: number, type: QuestionType = "variant") => { @@ -355,6 +342,7 @@ export const copyQuestion = async (questionId: number, quizId: number) => { const question = state.questions.find(q => q.id === questionId); if (!question) return; + console.log(question); const copiedQuestion = structuredClone(question); copiedQuestion.id = newQuestionId; state.questions.push(copiedQuestion); diff --git a/src/stores/quizes/actions.ts b/src/stores/quizes/actions.ts index 9456b224..7eac11fa 100644 --- a/src/stores/quizes/actions.ts +++ b/src/stores/quizes/actions.ts @@ -1,14 +1,15 @@ import { quizApi } from "@api/quiz"; import { devlog, getMessageFromFetchError } from "@frontend/kitui"; -import { quizToEditQuizRequest } from "@model/quiz/edit"; +import { EditQuizResponse, quizToEditQuizRequest } from "@model/quiz/edit"; import { Quiz, RawQuiz, rawQuizToQuiz } from "@model/quiz/quiz"; import { QuizConfig, QuizSetupStep, maxQuizSetupSteps } from "@model/quizSettings"; +import { createQuestion } from "@root/questions/actions"; import { produce } from "immer"; import { enqueueSnackbar } from "notistack"; import { NavigateFunction } from "react-router-dom"; -import { QuizStore, useQuizStore } from "./store"; import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError"; -import { createQuestion } from "@root/questions/actions"; +import { RequestQueue } from "../../utils/requestQueue"; +import { QuizStore, useQuizStore } from "./store"; export const setEditQuizId = (quizId: number | null) => setProducedState(state => { @@ -115,62 +116,44 @@ export const setQuizStartpageType = ( incrementCurrentStep(); }; -let savedOriginalQuiz: Quiz | null = null; -let controller: AbortController | null = null; +const REQUEST_DEBOUNCE = 1000; +const requestQueue = new RequestQueue(); +let requestTimeoutId: ReturnType; export const updateQuizWithFnOptimistic = async ( quizId: number | null, updateFn: (quiz: Quiz) => void, - rollbackOnError = true, ) => { if (!quizId) return; - const quiz = useQuizStore.getState().quizById[quizId] ?? null; + const quiz = useQuizStore.getState().quizById[quizId]; if (!quiz) return; - const currentUpdatedQuiz = produce(quiz, updateFn); + const updatedQuiz = produce(quiz, updateFn); + setQuiz(updatedQuiz); - controller?.abort(); - controller = new AbortController(); - savedOriginalQuiz ??= quiz; + clearTimeout(requestTimeoutId); + requestTimeoutId = setTimeout(async () => { + requestQueue.enqueue(async (prevResponse) => { + const quizId = prevResponse?.updated ?? updatedQuiz.id; + const response = await quizApi.edit(quizToEditQuizRequest(updatedQuiz, quizId)); - setQuiz(currentUpdatedQuiz); - try { - const { updated: newId } = await quizApi.edit( - quizToEditQuizRequest(currentUpdatedQuiz), - controller.signal, - ); + setQuizField(quizId, "id", response.updated); + setEditQuizId(response.updated); - setQuizField(quiz.id, "id", newId); - setEditQuizId(newId); + return response; + }).catch(error => { + if (isAxiosCanceledError(error)) return; - controller = null; - savedOriginalQuiz = null; - } catch (error) { - if (isAxiosCanceledError(error)) return; - - devlog("Error editing quiz", { error, quiz, currentUpdatedQuiz }); - enqueueSnackbar("Не удалось сохранить настройки квиза"); - - if (rollbackOnError) { - if (!savedOriginalQuiz) { - devlog("Cannot rollback quiz"); - throw new Error("Cannot rollback quiz"); - } - setQuiz(savedOriginalQuiz); - } - - controller = null; - savedOriginalQuiz = null; - } + devlog("Error editing quiz", { error, quiz, updatedQuiz }); + enqueueSnackbar("Не удалось сохранить настройки квиза"); + }); + }, REQUEST_DEBOUNCE); }; export const createQuiz = async (navigate: NavigateFunction) => { try { - const quiz = await quizApi.create({ - name: "Quiz name", - description: "Quiz description", - }); + const quiz = await quizApi.create(); setQuiz(rawQuizToQuiz(quiz)); setEditQuizId(quiz.id); diff --git a/src/stores/quizes/store.ts b/src/stores/quizes/store.ts index a641cb7f..6b71d873 100644 --- a/src/stores/quizes/store.ts +++ b/src/stores/quizes/store.ts @@ -1,7 +1,7 @@ import { Quiz } from "@model/quiz/quiz"; import { QuizSetupStep } from "@model/quizSettings"; import { create } from "zustand"; -import { devtools } from "zustand/middleware"; +import { devtools, persist } from "zustand/middleware"; export type QuizStore = { @@ -17,12 +17,20 @@ const initialState: QuizStore = { }; export const useQuizStore = create()( - devtools( - () => initialState, - { - name: "QuizStore", - enabled: process.env.NODE_ENV === "development", - trace: process.env.NODE_ENV === "development", - } + persist( + devtools( + () => initialState, + { + name: "QuizStore", + enabled: process.env.NODE_ENV === "development", + trace: process.env.NODE_ENV === "development", + } + ), { + name: "QuizStore", + partialize: state => ({ + editQuizId: state.editQuizId, + currentStep: state.currentStep, + }), + } ) ); diff --git a/src/utils/requestQueue.ts b/src/utils/requestQueue.ts new file mode 100644 index 00000000..9eb9a251 --- /dev/null +++ b/src/utils/requestQueue.ts @@ -0,0 +1,41 @@ +export class RequestQueue { + private pendingPromise = false; + private items: Array<{ + action: (prevPayload?: T | null) => Promise; + resolve: (value: T) => void; + reject: (reason?: any) => void; + }> = []; + + enqueue(action: (prevPayload?: T | null) => Promise) { + return new Promise((resolve, reject) => { + if (this.items.length === 2) { + this.items[1] = { action, resolve, reject }; + } else { + this.items.push({ action, resolve, reject }); + } + this.dequeue(); + }); + } + + async dequeue(prevPayload?: T | null) { + if (this.pendingPromise) return; + + const item = this.items.shift(); + if (!item) return; + + let payload: T | null = null; + + try { + this.pendingPromise = true; + payload = await item.action(prevPayload); + this.pendingPromise = false; + + item.resolve(payload); + } catch (e) { + this.pendingPromise = false; + item.reject(e); + } finally { + this.dequeue(payload); + } + } +}