add api request & new stores

This commit is contained in:
nflnkr 2023-11-02 19:45:28 +03:00
parent c9f9f3a4b0
commit 31c13b990d
24 changed files with 779 additions and 710 deletions

1
.yarnrc Normal file

@ -0,0 +1 @@
"@frontend:registry" "https://penahub.gitlab.yandexcloud.net/api/v4/packages/npm/"

@ -6,6 +6,7 @@
"@craco/craco": "^7.0.0",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@frontend/kitui": "^1.0.54",
"@mui/icons-material": "^5.10.14",
"@mui/material": "^5.10.14",
"@mui/x-date-pickers": "^6.16.1",

100
src/api/question.ts Normal file

@ -0,0 +1,100 @@
import { makeRequest } from "@frontend/kitui";
import { CreateQuestionRequest } from "model/question/create";
import { Question } from "model/question/question";
import { GetQuestionListRequest, GetQuestionListResponse } from "model/question/getList";
import { EditQuestionRequest, EditQuestionResponse } from "model/question/edit";
import { DeleteQuestionRequest, DeleteQuestionResponse } from "model/question/delete";
import { CopyQuestionRequest, CopyQuestionResponse } from "model/question/copy";
const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz";
export function createQuestion(body: CreateQuestionRequest = defaultCreateQuestionBody) {
return makeRequest<CreateQuestionRequest, Question>({
url: `${baseUrl}/question/create`,
body,
method: "POST",
});
}
export function getQuestionList(body: GetQuestionListRequest = defaultGetQuestionListBody) {
return makeRequest<GetQuestionListRequest, GetQuestionListResponse>({
url: `${baseUrl}/question/getList`,
body,
method: "GET",
});
}
export function editQuestion(updatedQuestion: Question, signal?: AbortSignal) {
const body: EditQuestionRequest = {
id: updatedQuestion.id,
title: updatedQuestion.title,
desc: updatedQuestion.description,
type: updatedQuestion.type,
required: updatedQuestion.required,
page: updatedQuestion.page,
};
return makeRequest<EditQuestionRequest, EditQuestionResponse>({
url: `${baseUrl}/question/edit`,
body,
method: "PATCH",
signal,
});
}
export function copyQuestion(copyQuestionBody: CopyQuestionRequest) {
return makeRequest<CopyQuestionRequest, CopyQuestionResponse>({
url: `${baseUrl}/question/copy`,
body: copyQuestionBody,
method: "POST",
});
}
export function deleteQuestion(id: number) {
return makeRequest<DeleteQuestionRequest, DeleteQuestionResponse>({
url: `${baseUrl}/question/delete`,
body: { id },
method: "DELETE",
});
}
export const questionApi = {
create: createQuestion,
getList: getQuestionList,
edit: editQuestion,
copy: copyQuestion,
delete: deleteQuestion,
};
const defaultCreateQuestionBody: CreateQuestionRequest = {
"quiz_id": 0,
"title": "string",
"description": "string",
"type": "string",
"required": true,
"page": 0,
"content": "string",
};
const defaultGetQuestionListBody: GetQuestionListRequest = {
"limit": 0,
"offset": 0,
"from": 0,
"to": 0,
"search": "string",
"type": "string",
"deleted": true,
"required": true,
"quiz_id": 0
};
const defaultEditQuestionBody: EditQuestionRequest = {
"id": 0,
"title": "string",
"desc": "string",
"type": "",
"required": true,
"page": 0
};

142
src/api/quiz.ts Normal file

@ -0,0 +1,142 @@
import { makeRequest } from "@frontend/kitui";
import { CopyQuizRequest, CopyQuizResponse } from "model/quiz/copy";
import { CreateQuizRequest } from "model/quiz/create";
import { DeleteQuizRequest, DeleteQuizResponse } from "model/quiz/delete";
import { EditQuizRequest, EditQuizResponse } from "model/quiz/edit";
import { GetQuizRequest, GetQuizResponse } from "model/quiz/get";
import { GetQuizListRequest, GetQuizListResponse } from "model/quiz/getList";
import { BackendQuiz } from "model/quiz/quiz";
const baseUrl = process.env.NODE_ENV === "production" ? "/squiz" : "https://squiz.pena.digital/squiz";
function createQuiz(body: CreateQuizRequest = defaultCreateQuizBody) {
return makeRequest<CreateQuizRequest, BackendQuiz>({
url: `${baseUrl}/quiz/create`,
body,
method: "POST",
});
}
function getQuizList(body: GetQuizListRequest = defaultGetQuizListBody) {
return makeRequest<GetQuizListRequest, GetQuizListResponse>({
url: `${baseUrl}/quiz/getList`,
body,
method: "GET",
});
}
function getQuiz(body: GetQuizRequest = defaultGetQuizBody) {
return makeRequest<GetQuizRequest, GetQuizResponse>({
url: `${baseUrl}/quiz/get`,
body,
method: "GET",
});
}
function editQuiz(body: EditQuizRequest = defaultEditQuizBody) {
return makeRequest<EditQuizRequest, EditQuizResponse>({
url: `${baseUrl}/quiz/edit`,
body,
method: "PATCH",
});
}
function copyQuiz(id: number) {
return makeRequest<CopyQuizRequest, CopyQuizResponse>({
url: `${baseUrl}/quiz/copy`,
body: { id },
method: "POST",
});
}
function deleteQuiz(id: number) {
return makeRequest<DeleteQuizRequest, DeleteQuizResponse>({
url: `${baseUrl}/quiz/delete`,
body: { id },
method: "DELETE",
});
}
function addQuizImages(quizId: number, image: Blob) {
const formData = new FormData();
formData.append("quiz", quizId.toString());
formData.append("image", image);
return makeRequest<FormData, never>({
url: `${baseUrl}/quiz/putImages`,
body: formData,
method: "POST",
});
}
export const quizApi = {
create: createQuiz,
getList: getQuizList,
get: getQuiz,
edit: editQuiz,
copy: copyQuiz,
delete: deleteQuiz,
addImages: addQuizImages,
};
const defaultCreateQuizBody: CreateQuizRequest = {
"fingerprinting": true,
"repeatable": true,
"note_prevented": true,
"mail_notifications": true,
"unique_answers": true,
"name": "string",
"description": "string",
"config": "string",
"status": "string",
"limit": 0,
"due_to": 0,
"time_of_passing": 0,
"pausable": true,
"question_cnt": 0,
"super": true,
"group_id": 0,
};
const defaultEditQuizBody: EditQuizRequest = {
"id": 0,
"fp": true,
"rep": true,
"note_prevented": true,
"mailing": true,
"uniq": true,
"name": "string",
"desc": "string",
"conf": "string",
"status": "string",
"limit": 0,
"due_to": 0,
"time_of_passing": 0,
"pausable": true,
"question_cnt": 0,
"super": true,
"group_id": 0,
};
const defaultGetQuizBody: GetQuizRequest = {
"quiz_id": "string",
"limit": 0,
"page": 0,
"need_config": true,
};
const defaultGetQuizListBody: GetQuizListRequest = {
"limit": 0,
"offset": 0,
"from": 0,
"to": 0,
"search": "string",
"status": "string",
"deleted": true,
"archived": true,
"super": true,
"group_id": 0,
};

@ -0,0 +1,8 @@
export interface CopyQuestionRequest {
id: number;
quiz_id: number;
}
export interface CopyQuestionResponse {
updated: number;
}

@ -0,0 +1,9 @@
export interface CreateQuestionRequest {
quiz_id: number;
title: string;
description: string;
type: string;
required: boolean;
page: number;
content: string;
}

@ -0,0 +1,7 @@
export interface DeleteQuestionRequest {
id: number;
}
export interface DeleteQuestionResponse {
deactivated: number;
}

@ -0,0 +1,12 @@
export interface EditQuestionRequest {
id: number;
title: string;
desc: string;
type: "test" | "button" | "file" | "checkbox" | "select" | "none" | "";
required: boolean;
page: number;
}
export interface EditQuestionResponse {
updated: number;
}

@ -0,0 +1,16 @@
export interface GetQuestionListRequest {
limit: number;
offset: number;
from: number;
to: number;
search: string;
type: string;
deleted: boolean;
required: boolean;
quiz_id: number;
}
export interface GetQuestionListResponse {
count: number;
items: unknown[]; // TODO
}

@ -0,0 +1,15 @@
export interface Question {
id: number;
quiz_id: number;
title: string;
description: string;
type: "test" | "button" | "file" | "checkbox" | "select" | "none" | "";
required: boolean;
deleted: boolean;
page: number;
content: string;
version: number;
parent_ids: number[];
created_at: string;
updated_at: string;
}

7
src/model/quiz/copy.ts Normal file

@ -0,0 +1,7 @@
export interface CopyQuizRequest {
id: number;
}
export interface CopyQuizResponse {
updated: number;
}

18
src/model/quiz/create.ts Normal file

@ -0,0 +1,18 @@
export interface CreateQuizRequest {
fingerprinting: boolean;
repeatable: boolean;
note_prevented: boolean;
mail_notifications: boolean;
unique_answers: boolean;
name: string;
description: string;
config: string;
status: string;
limit: number;
due_to: number;
time_of_passing: number;
pausable: boolean;
question_cnt: number;
super: boolean;
group_id: number;
}

7
src/model/quiz/delete.ts Normal file

@ -0,0 +1,7 @@
export interface DeleteQuizRequest {
id: number;
}
export interface DeleteQuizResponse {
deactivated: number;
}

23
src/model/quiz/edit.ts Normal file

@ -0,0 +1,23 @@
export interface EditQuizRequest {
id: number;
fp: boolean;
rep: boolean;
note_prevented: boolean;
mailing: boolean;
uniq: boolean;
name: string;
desc: string;
conf: string;
status: string;
limit: number;
due_to: number;
time_of_passing: number;
pausable: boolean;
question_cnt: number;
super: boolean;
group_id: number;
}
export interface EditQuizResponse {
updated: number;
}

29
src/model/quiz/get.ts Normal file

@ -0,0 +1,29 @@
export interface GetQuizRequest {
quiz_id: string;
limit: number;
page: number;
need_config: boolean;
}
export interface GetQuizResponse {
cnt: number;
settings: {
fp: boolean;
rep: boolean;
name: string;
cfg: string;
lim: number;
due: number;
delay: number;
pausable: boolean;
};
items: {
id: number;
title: string;
desc: string;
typ: string;
req: boolean;
p: number;
c: string;
}[];
}

17
src/model/quiz/getList.ts Normal file

@ -0,0 +1,17 @@
export interface GetQuizListRequest {
limit: number;
offset: number;
from: number;
to: number;
search: string;
status: string;
deleted: boolean;
archived: boolean;
super: boolean;
group_id: number;
}
export interface GetQuizListResponse {
count: number;
items: unknown[]; // TODO
}

29
src/model/quiz/quiz.ts Normal file

@ -0,0 +1,29 @@
export interface BackendQuiz {
id: number;
qid: string;
deleted: boolean;
archived: boolean;
fingerprinting: boolean;
repeatable: boolean;
note_prevented: boolean;
mail_notifications: boolean;
unique_answers: boolean;
name: string;
description: string;
config: string;
status: string;
limit: number;
due_to: number;
time_of_passing: number;
pausable: boolean;
version: number;
version_comment: string;
parent_ids: number[];
created_at: string;
updated_at: string;
question_cnt: number;
passed_count: number;
average_time: number;
super: boolean;
group_id: number;
}

@ -1,46 +1,38 @@
import {Button, Typography, useTheme} from "@mui/material";
import ComplexNavText from "./ComplexNavText";
import { Button, Typography } from "@mui/material";
import { createQuiz } from "@root/quizesV2";
import SectionWrapper from "@ui_kit/SectionWrapper";
import {quizStore} from "@root/quizes";
import {Link, useNavigate} from "react-router-dom";
import ComplexNavText from "./ComplexNavText";
import { useNavigate } from "react-router";
function getRandom(min: number, max:number) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min)) + min;
}
export default function FirstQuiz() {
const {listQuizes, updateQuizesList, removeQuiz, createBlank} = quizStore()
const navigate = useNavigate()
const navigate = useNavigate();
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: "25px",
mb: "70px",
}}
>
<ComplexNavText text1="Кабинет квизов" />
<Typography
variant="h4"
sx={{
mt: "20px",
mb: "30px",
}}
>
Создайте свой первый квиз
</Typography>
<Button
variant="contained"
data-cy="create-quiz"
onClick={() => {
navigate(`/setting/${createBlank()}`);
}}
>
Создать +
</Button>
</SectionWrapper>
);
return (
<SectionWrapper
maxWidth="lg"
sx={{
mt: "25px",
mb: "70px",
}}
>
<ComplexNavText text1="Кабинет квизов" />
<Typography
variant="h4"
sx={{
mt: "20px",
mb: "30px",
}}
>
Создайте свой первый квиз
</Typography>
<Button
variant="contained"
data-cy="create-quiz"
onClick={() => createQuiz(navigate)}
>
Создать +
</Button>
</SectionWrapper>
);
}

@ -14,6 +14,7 @@ import React from "react";
import { quizStore } from "@root/quizes";
import FirstQuiz from "./FirstQuiz";
import { useNavigate } from "react-router-dom";
import { createQuiz } from "@root/quizesV2";
interface Props {
outerContainerSx?: SxProps<Theme>;
children?: React.ReactNode;
@ -51,9 +52,7 @@ export default function MyQuizzesFull({
padding: isMobile ? "10px" : "10px 47px",
minWidth: "44px",
}}
onClick={() => {
navigate(`/setting/${createBlank()}`);
}}
onClick={() => createQuiz(navigate)}
>
{isMobile ? "+" : "Создать +"}
</Button>

144
src/stores/questionsV2.ts Normal file

@ -0,0 +1,144 @@
import { questionApi } from "@api/question";
import { devlog } from "@frontend/kitui";
import { produce } from "immer";
import { Question } from "model/question/question";
import { enqueueSnackbar } from "notistack";
import { isAxiosCanceledError } from "utils/isAxiosCanceledError";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type QuestionsStore = {
questionsById: Record<number, Question | undefined>;
};
const initialState: QuestionsStore = {
questionsById: {},
};
export const useQuestionsStore = create<QuestionsStore>()(
devtools(
() => initialState,
{
name: "QuestionsStore",
enabled: process.env.NODE_ENV === "development",
}
)
);
export const setQuestions = (questions: QuestionsStore["questionsById"]) => useQuestionsStore.setState({ questionsById: questions });
export const setQuestion = (question: Question) => setProducedState(state => {
state.questionsById[question.id] = question;
}, {
type: "setQuestion",
question,
});
export const setQuestionField = <T extends keyof Question>(
questionId: number,
field: T,
value: Question[T],
) => setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
question[field] = value;
}, {
type: "setQuestionField",
questionId,
field,
value,
});
let savedOriginalQuestion: Question | null = null;
let controller: AbortController | null = null;
export const setQuestionFieldOptimistic = async <T extends keyof Question>(
questionId: number,
field: T,
value: Question[T],
) => {
const question = useQuestionsStore.getState().questionsById[questionId] ?? null;
if (!question) return;
const currentUpdatedQuestion = produce(question, draft => {
draft[field] = value;
});
controller?.abort();
controller = new AbortController();
savedOriginalQuestion ??= question;
setQuestion(currentUpdatedQuestion);
try {
const { updated } = await questionApi.edit(currentUpdatedQuestion, controller.signal);
// await new Promise((resolve, reject) => setTimeout(reject, 2000, new Error("Api rejected")));
setQuestionField(question.id, "version", updated);
controller = null;
savedOriginalQuestion = null;
} catch (error) {
if (isAxiosCanceledError(error)) return;
devlog("Error editing question", { error, question: question, currentUpdatedQuestion });
enqueueSnackbar("Не удалось сохранить вопрос");
if (!savedOriginalQuestion) {
devlog("Cannot rollback question");
throw new Error("Cannot rollback question");
}
setQuestion(savedOriginalQuestion);
controller = null;
savedOriginalQuestion = null;
}
};
export const updateQuestionWithFn = (
questionId: number,
updateFn: (question: Question) => void,
) => setProducedState(state => {
const question = state.questionsById[questionId];
if (!question) return;
updateFn(question);
}, {
type: "updateQuestion",
questionId,
updateFn: updateFn.toString(),
});
export const createQuestion = async () => {
try {
const question = await questionApi.create();
setQuestion(question);
} catch (error) {
devlog("Error creating question", error);
enqueueSnackbar("Не удалось создать вопрос");
}
};
export const deleteQuestion = async (questionId: number) => {
try {
await questionApi.delete(questionId);
removeQuestion(questionId);
} catch (error) {
devlog("Error deleting question", error);
enqueueSnackbar("Не удалось удалить вопрос");
}
};
export const removeQuestion = (questionId: number) => setProducedState(state => {
delete state.questionsById[questionId];
}, {
type: "removeQuestion",
questionId,
});
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuestionsStore) => void,
action?: A,
) {
useQuestionsStore.setState(state => produce(state, recipe), false, action);
}

103
src/stores/quizesV2.ts Normal file

@ -0,0 +1,103 @@
import { quizApi } from "@api/quiz";
import { devlog } from "@frontend/kitui";
import { produce } from "immer";
import { BackendQuiz } from "model/quiz/quiz";
import { enqueueSnackbar } from "notistack";
import { NavigateFunction } from "react-router";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
type QuizStore = {
quizes: Record<number, BackendQuiz | undefined>;
};
const initialState: QuizStore = {
quizes: {},
};
export const useQuizStore = create<QuizStore>()(
devtools(
() => initialState,
{
name: "QuizStore",
enabled: process.env.NODE_ENV === "development",
}
)
);
export const setQuizes = (quizes: QuizStore["quizes"]) => useQuizStore.setState({ quizes });
export const setQuiz = (quiz: BackendQuiz) => setProducedState(state => {
state.quizes[quiz.id] = quiz;
}, {
type: "setQuiz",
quiz,
});
export const removeQuiz = (quizId: number) => setProducedState(state => {
delete state.quizes[quizId];
}, {
type: "removeQuiz",
quizId,
});
export const setQuizField = <T extends keyof BackendQuiz>(
quizId: number,
field: T,
value: BackendQuiz[T],
) => setProducedState(state => {
const quiz = state.quizes[quizId];
if (!quiz) return;
quiz[field] = value;
}, {
type: "setQuizField",
quizId,
field,
value,
});
export const updateQuiz = (
quizId: number,
updateFn: (quiz: BackendQuiz) => void,
) => setProducedState(state => {
const quiz = state.quizes[quizId];
if (!quiz) return;
updateFn(quiz);
}, {
type: "updateQuiz",
quizId,
updateFn: updateFn.toString(),
});
export const createQuiz = async (navigate: NavigateFunction) => {
try {
const quiz = await quizApi.create();
setQuiz(quiz);
navigate(`/settings/${quiz.id}`);
} catch (error) {
devlog("Error creating quiz", error);
enqueueSnackbar("Не удалось создать квиз");
}
};
export const deleteQuiz = async (quizId: number) => {
try {
await quizApi.delete(quizId);
removeQuiz(quizId);
} catch (error) {
devlog("Error deleting quiz", error);
enqueueSnackbar("Не удалось удалить квиз");
}
};
function setProducedState<A extends string | { type: unknown; }>(
recipe: (state: QuizStore) => void,
action?: A,
) {
useQuizStore.setState(state => produce(state, recipe), false, action);
}

@ -0,0 +1,6 @@
import { isAxiosError } from "axios";
export function isAxiosCanceledError(error: unknown) {
return isAxiosError(error) && error.code === "ERR_CANCELED";
}

@ -1,16 +1,19 @@
{
"compilerOptions":{
"baseUrl":"./src",
"paths":{
"@ui_kit/*":[
"./ui_kit/*"
],
"@icons/*":[
"./assets/icons/*"
],
"@root/*": [
"./stores/*"
]
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@ui_kit/*": [
"./ui_kit/*"
],
"@icons/*": [
"./assets/icons/*"
],
"@root/*": [
"./stores/*"
],
"@api/*": [
"./api/*"
]
}
}
}
}
}

687
yarn.lock

File diff suppressed because it is too large Load Diff