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

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