add quiz & question edit debounce + request queue

This commit is contained in:
nflnkr 2023-11-23 22:07:57 +03:00
parent d8a1351268
commit 0ba8472220
7 changed files with 108 additions and 88 deletions

@ -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,

@ -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,

@ -91,7 +91,7 @@ export default function QuestionsPageCard({ question, draggableProps, isDragging
>
<TextField
defaultValue={question.title}
placeholder={"Заголовок вопроса2"}
placeholder={"Заголовок вопроса"}
onChange={({ target }) => setTitle(target.value)}
InputProps={{
startAdornment: (

@ -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<EditQuestionResponse>();
let requestTimeoutId: ReturnType<typeof setTimeout>;
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);

@ -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<EditQuizResponse>();
let requestTimeoutId: ReturnType<typeof setTimeout>;
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);

@ -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<QuizStore>()(
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,
}),
}
)
);

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);
}
}
}