add quiz & question edit debounce + request queue
This commit is contained in:
parent
d8a1351268
commit
0ba8472220
@ -16,9 +16,9 @@ export interface EditQuestionResponse {
|
|||||||
updated: number;
|
updated: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function questionToEditQuestionRequest(question: AnyQuizQuestion): EditQuestionRequest {
|
export function questionToEditQuestionRequest(question: AnyQuizQuestion, newId?: number): EditQuestionRequest {
|
||||||
return {
|
return {
|
||||||
id: question.id,
|
id: newId ?? question.id,
|
||||||
title: question.title,
|
title: question.title,
|
||||||
desc: question.description,
|
desc: question.description,
|
||||||
type: question.type,
|
type: question.type,
|
||||||
|
@ -43,9 +43,9 @@ export interface EditQuizResponse {
|
|||||||
updated: number;
|
updated: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function quizToEditQuizRequest(quiz: Quiz): EditQuizRequest {
|
export function quizToEditQuizRequest(quiz: Quiz, newId?: number): EditQuizRequest {
|
||||||
return {
|
return {
|
||||||
id: quiz.id,
|
id: newId ?? quiz.id,
|
||||||
fp: quiz.fingerprinting,
|
fp: quiz.fingerprinting,
|
||||||
rep: quiz.repeatable,
|
rep: quiz.repeatable,
|
||||||
note_prevented: quiz.note_prevented,
|
note_prevented: quiz.note_prevented,
|
||||||
|
@ -91,7 +91,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
|
|||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
defaultValue={question.title}
|
defaultValue={question.title}
|
||||||
placeholder={"Заголовок вопроса2"}
|
placeholder={"Заголовок вопроса"}
|
||||||
onChange={({ target }) => setTitle(target.value)}
|
onChange={({ target }) => setTitle(target.value)}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { questionApi } from "@api/question";
|
import { questionApi } from "@api/question";
|
||||||
import { devlog } from "@frontend/kitui";
|
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 { QuestionType, RawQuestion, rawQuestionToQuestion } from "@model/question/question";
|
||||||
import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared";
|
import { AnyQuizQuestion, ImageQuestionVariant, QuestionVariant, createQuestionImageVariant, createQuestionVariant } from "@model/questionTypes/shared";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
@ -8,6 +8,7 @@ import { enqueueSnackbar } from "notistack";
|
|||||||
import { notReachable } from "../../utils/notReachable";
|
import { notReachable } from "../../utils/notReachable";
|
||||||
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
|
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
|
||||||
import { QuestionsStore, useQuestionsStore } from "./store";
|
import { QuestionsStore, useQuestionsStore } from "./store";
|
||||||
|
import { RequestQueue } from "../../utils/requestQueue";
|
||||||
|
|
||||||
|
|
||||||
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
|
export const setQuestions = (questions: RawQuestion[] | null) => setProducedState(state => {
|
||||||
@ -276,10 +277,9 @@ export const setQuestionInnerName = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REQUEST_DEBOUNCE = 1000;
|
||||||
|
const requestQueue = new RequestQueue<EditQuestionResponse>();
|
||||||
let savedOriginalQuestion: AnyQuizQuestion | null = null;
|
let requestTimeoutId: ReturnType<typeof setTimeout>;
|
||||||
let controller: AbortController | null = null;
|
|
||||||
|
|
||||||
export const updateQuestionWithFnOptimistic = async (
|
export const updateQuestionWithFnOptimistic = async (
|
||||||
questionId: number,
|
questionId: number,
|
||||||
@ -288,38 +288,25 @@ export const updateQuestionWithFnOptimistic = async (
|
|||||||
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
|
const question = useQuestionsStore.getState().questions.find(q => q.id === questionId);
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
const currentUpdatedQuestion = produce(question, updateFn);
|
const updatedQuestion = produce(question, updateFn);
|
||||||
|
setQuestion(updatedQuestion);
|
||||||
|
|
||||||
controller?.abort();
|
clearTimeout(requestTimeoutId);
|
||||||
controller = new AbortController();
|
requestTimeoutId = setTimeout(async () => {
|
||||||
savedOriginalQuestion ??= question;
|
requestQueue.enqueue(async (prevResponse) => {
|
||||||
|
const questionId = prevResponse?.updated ?? updatedQuestion.id;
|
||||||
|
const response = await questionApi.edit(questionToEditQuestionRequest(updatedQuestion, questionId));
|
||||||
|
|
||||||
setQuestion(currentUpdatedQuestion);
|
setQuestionField(questionId, "id", response.updated);
|
||||||
try {
|
|
||||||
const { updated: newId } = await questionApi.edit(
|
|
||||||
questionToEditQuestionRequest(currentUpdatedQuestion),
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
setQuestionField(question.id, "id", newId);
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
controller = null;
|
|
||||||
savedOriginalQuestion = null;
|
|
||||||
} catch (error) {
|
|
||||||
if (isAxiosCanceledError(error)) return;
|
if (isAxiosCanceledError(error)) return;
|
||||||
|
|
||||||
devlog("Error editing question", { error, question, currentUpdatedQuestion });
|
devlog("Error editing question", { error, question, updatedQuestion });
|
||||||
enqueueSnackbar("Не удалось сохранить вопрос");
|
enqueueSnackbar("Не удалось сохранить вопрос");
|
||||||
|
});
|
||||||
if (!savedOriginalQuestion) {
|
}, REQUEST_DEBOUNCE);
|
||||||
devlog("Cannot rollback question");
|
|
||||||
throw new Error("Cannot rollback question");
|
|
||||||
}
|
|
||||||
|
|
||||||
setQuestion(savedOriginalQuestion);
|
|
||||||
controller = null;
|
|
||||||
savedOriginalQuestion = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createQuestion = async (quizId: number, type: QuestionType = "variant") => {
|
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);
|
const question = state.questions.find(q => q.id === questionId);
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
|
|
||||||
|
console.log(question);
|
||||||
const copiedQuestion = structuredClone(question);
|
const copiedQuestion = structuredClone(question);
|
||||||
copiedQuestion.id = newQuestionId;
|
copiedQuestion.id = newQuestionId;
|
||||||
state.questions.push(copiedQuestion);
|
state.questions.push(copiedQuestion);
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { quizApi } from "@api/quiz";
|
import { quizApi } from "@api/quiz";
|
||||||
import { devlog, getMessageFromFetchError } from "@frontend/kitui";
|
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 { Quiz, RawQuiz, rawQuizToQuiz } from "@model/quiz/quiz";
|
||||||
import { QuizConfig, QuizSetupStep, maxQuizSetupSteps } from "@model/quizSettings";
|
import { QuizConfig, QuizSetupStep, maxQuizSetupSteps } from "@model/quizSettings";
|
||||||
|
import { createQuestion } from "@root/questions/actions";
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { enqueueSnackbar } from "notistack";
|
import { enqueueSnackbar } from "notistack";
|
||||||
import { NavigateFunction } from "react-router-dom";
|
import { NavigateFunction } from "react-router-dom";
|
||||||
import { QuizStore, useQuizStore } from "./store";
|
|
||||||
import { isAxiosCanceledError } from "../../utils/isAxiosCanceledError";
|
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 => {
|
export const setEditQuizId = (quizId: number | null) => setProducedState(state => {
|
||||||
@ -115,62 +116,44 @@ export const setQuizStartpageType = (
|
|||||||
incrementCurrentStep();
|
incrementCurrentStep();
|
||||||
};
|
};
|
||||||
|
|
||||||
let savedOriginalQuiz: Quiz | null = null;
|
const REQUEST_DEBOUNCE = 1000;
|
||||||
let controller: AbortController | null = null;
|
const requestQueue = new RequestQueue<EditQuizResponse>();
|
||||||
|
let requestTimeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
export const updateQuizWithFnOptimistic = async (
|
export const updateQuizWithFnOptimistic = async (
|
||||||
quizId: number | null,
|
quizId: number | null,
|
||||||
updateFn: (quiz: Quiz) => void,
|
updateFn: (quiz: Quiz) => void,
|
||||||
rollbackOnError = true,
|
|
||||||
) => {
|
) => {
|
||||||
if (!quizId) return;
|
if (!quizId) return;
|
||||||
|
|
||||||
const quiz = useQuizStore.getState().quizById[quizId] ?? null;
|
const quiz = useQuizStore.getState().quizById[quizId];
|
||||||
if (!quiz) return;
|
if (!quiz) return;
|
||||||
|
|
||||||
const currentUpdatedQuiz = produce(quiz, updateFn);
|
const updatedQuiz = produce(quiz, updateFn);
|
||||||
|
setQuiz(updatedQuiz);
|
||||||
|
|
||||||
controller?.abort();
|
clearTimeout(requestTimeoutId);
|
||||||
controller = new AbortController();
|
requestTimeoutId = setTimeout(async () => {
|
||||||
savedOriginalQuiz ??= quiz;
|
requestQueue.enqueue(async (prevResponse) => {
|
||||||
|
const quizId = prevResponse?.updated ?? updatedQuiz.id;
|
||||||
|
const response = await quizApi.edit(quizToEditQuizRequest(updatedQuiz, quizId));
|
||||||
|
|
||||||
setQuiz(currentUpdatedQuiz);
|
setQuizField(quizId, "id", response.updated);
|
||||||
try {
|
setEditQuizId(response.updated);
|
||||||
const { updated: newId } = await quizApi.edit(
|
|
||||||
quizToEditQuizRequest(currentUpdatedQuiz),
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
setQuizField(quiz.id, "id", newId);
|
return response;
|
||||||
setEditQuizId(newId);
|
}).catch(error => {
|
||||||
|
|
||||||
controller = null;
|
|
||||||
savedOriginalQuiz = null;
|
|
||||||
} catch (error) {
|
|
||||||
if (isAxiosCanceledError(error)) return;
|
if (isAxiosCanceledError(error)) return;
|
||||||
|
|
||||||
devlog("Error editing quiz", { error, quiz, currentUpdatedQuiz });
|
devlog("Error editing quiz", { error, quiz, updatedQuiz });
|
||||||
enqueueSnackbar("Не удалось сохранить настройки квиза");
|
enqueueSnackbar("Не удалось сохранить настройки квиза");
|
||||||
|
});
|
||||||
if (rollbackOnError) {
|
}, REQUEST_DEBOUNCE);
|
||||||
if (!savedOriginalQuiz) {
|
|
||||||
devlog("Cannot rollback quiz");
|
|
||||||
throw new Error("Cannot rollback quiz");
|
|
||||||
}
|
|
||||||
setQuiz(savedOriginalQuiz);
|
|
||||||
}
|
|
||||||
|
|
||||||
controller = null;
|
|
||||||
savedOriginalQuiz = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createQuiz = async (navigate: NavigateFunction) => {
|
export const createQuiz = async (navigate: NavigateFunction) => {
|
||||||
try {
|
try {
|
||||||
const quiz = await quizApi.create({
|
const quiz = await quizApi.create();
|
||||||
name: "Quiz name",
|
|
||||||
description: "Quiz description",
|
|
||||||
});
|
|
||||||
|
|
||||||
setQuiz(rawQuizToQuiz(quiz));
|
setQuiz(rawQuizToQuiz(quiz));
|
||||||
setEditQuizId(quiz.id);
|
setEditQuizId(quiz.id);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Quiz } from "@model/quiz/quiz";
|
import { Quiz } from "@model/quiz/quiz";
|
||||||
import { QuizSetupStep } from "@model/quizSettings";
|
import { QuizSetupStep } from "@model/quizSettings";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { devtools } from "zustand/middleware";
|
import { devtools, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
|
||||||
export type QuizStore = {
|
export type QuizStore = {
|
||||||
@ -17,6 +17,7 @@ const initialState: QuizStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useQuizStore = create<QuizStore>()(
|
export const useQuizStore = create<QuizStore>()(
|
||||||
|
persist(
|
||||||
devtools(
|
devtools(
|
||||||
() => initialState,
|
() => initialState,
|
||||||
{
|
{
|
||||||
@ -24,5 +25,12 @@ export const useQuizStore = create<QuizStore>()(
|
|||||||
enabled: process.env.NODE_ENV === "development",
|
enabled: process.env.NODE_ENV === "development",
|
||||||
trace: process.env.NODE_ENV === "development",
|
trace: process.env.NODE_ENV === "development",
|
||||||
}
|
}
|
||||||
|
), {
|
||||||
|
name: "QuizStore",
|
||||||
|
partialize: state => ({
|
||||||
|
editQuizId: state.editQuizId,
|
||||||
|
currentStep: state.currentStep,
|
||||||
|
}),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
41
src/utils/requestQueue.ts
Normal file
41
src/utils/requestQueue.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export class RequestQueue<T> {
|
||||||
|
private pendingPromise = false;
|
||||||
|
private items: Array<{
|
||||||
|
action: (prevPayload?: T | null) => Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: any) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
enqueue(action: (prevPayload?: T | null) => Promise<T>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user