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