From e6dddcaadd6fee87a34a5060c704fb2e29029018 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Tue, 31 Dec 2024 21:29:45 +0300 Subject: [PATCH 01/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/components/text-editor/editor/editor.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index e9b87219..59094762 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -24,8 +24,6 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { const { uploadHomeworkFile } = useHomeworkFileUpload(); const { enqueueSnackbar } = useSnackbar(); - const baseUrl = import.meta.env.VITE_APP_ENDPOINT; - const handleNewImageFiles = useCallback( async (files: File[], insertPosition?: number): Promise => { if (!rteRef.current?.editor || !homeWorkId) { @@ -38,7 +36,7 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { const uploadedFile = await uploadHomeworkFile(file, homeWorkId); if (uploadedFile) { - const serverUrl = `${baseUrl}/homework/${homeWorkId}/file/${uploadedFile.id}`; + const serverUrl = `/homework/${homeWorkId}/file/${uploadedFile.id}`; return { src: serverUrl, From b32da439e79ee5ad5b99a7cff78c12079b43ca74 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:45:30 +0300 Subject: [PATCH 02/21] =?UTF-8?q?QAGDEV-689=20-=20=D0=94=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=94=D0=97=20?= =?UTF-8?q?=D1=83=20=D1=81=D1=82=D1=83=D0=B4=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../homework-details-full/homework-details-full.tsx | 8 +++++++- src/shared/helpers/get-updated-allowed-columns.ts | 10 +++++++--- src/shared/hooks/use-drag-effect.ts | 11 ++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/features/kanban/views/homework-details-full/homework-details-full.tsx b/src/features/kanban/views/homework-details-full/homework-details-full.tsx index adbd3b03..36d0dd25 100644 --- a/src/features/kanban/views/homework-details-full/homework-details-full.tsx +++ b/src/features/kanban/views/homework-details-full/homework-details-full.tsx @@ -8,6 +8,8 @@ import Homework from "shared/features/homework/view"; import StatusText from "shared/components/status-text"; import HomeworkBaseInfo from "shared/components/homework-base-info"; import { formatId } from "shared/helpers"; +import { useRoleAccess } from "shared/hooks"; +import { UserRole } from "api/graphql/generated/graphql"; import StatusSelect from "../../views/status-select"; import { IHomeworkDescriptionFull } from "./homework-details-full.types"; @@ -27,10 +29,14 @@ const HomeworkDetailsFull: FC = ({ data }) => { id, } = data?.homeWork!; + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + const currentUserId = useReactiveVar(userIdVar); const isCurrentMentor = currentUserId === mentor?.id; const hasNoMentor = mentor?.id === undefined; - const showSelect = isCurrentMentor || hasNoMentor; + const showSelect = hasAdminRoleAcces || isCurrentMentor || hasNoMentor; return ( diff --git a/src/shared/helpers/get-updated-allowed-columns.ts b/src/shared/helpers/get-updated-allowed-columns.ts index 238c9a3c..7350cbb2 100644 --- a/src/shared/helpers/get-updated-allowed-columns.ts +++ b/src/shared/helpers/get-updated-allowed-columns.ts @@ -16,6 +16,10 @@ export const getUpdatedAllowedColumns = ( allowedRoles: [UserRole.Mentor, UserRole.Lector, UserRole.Admin], }); + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + if (!hasDraggAccess) { return []; } @@ -27,17 +31,17 @@ export const getUpdatedAllowedColumns = ( allowedColumns = [STATUS_COLUMN.IN_REVIEW]; break; case STATUS_COLUMN.IN_REVIEW: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = [STATUS_COLUMN.APPROVED, STATUS_COLUMN.NOT_APPROVED]; } break; case STATUS_COLUMN.APPROVED: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = []; } break; case STATUS_COLUMN.NOT_APPROVED: - if (currentUserId === mentorId) { + if (hasAdminRoleAcces || currentUserId === mentorId) { allowedColumns = [STATUS_COLUMN.APPROVED]; } break; diff --git a/src/shared/hooks/use-drag-effect.ts b/src/shared/hooks/use-drag-effect.ts index 1696245a..748f9bcd 100644 --- a/src/shared/hooks/use-drag-effect.ts +++ b/src/shared/hooks/use-drag-effect.ts @@ -32,6 +32,10 @@ export const useDragEffect = ({ allowedRoles: [UserRole.Mentor, UserRole.Lector, UserRole.Admin], }); + const hasAdminRoleAcces = useRoleAccess({ + allowedRoles: [UserRole.Admin], + }); + const currentUserId = useReactiveVar(userIdVar); const mentorId = card.mentor?.id; @@ -54,7 +58,11 @@ export const useDragEffect = ({ return; } - if (currentUserId !== mentorId && (isFromInReview || isFromNotApproved)) { + if ( + !hasAdminRoleAcces && + currentUserId !== mentorId && + (isFromInReview || isFromNotApproved) + ) { enqueueSnackbar("Вы не можете поменять статус чужой домашней работы"); return; } @@ -76,5 +84,6 @@ export const useDragEffect = ({ mentorId, enqueueSnackbar, setDraggingState, + hasAdminRoleAcces, ]); }; From edc0e02d734fe21fc7800e19f58cb8308ec065c2 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Wed, 15 Jan 2025 22:36:15 +0300 Subject: [PATCH 03/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3=20v2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rest/homework-file-service.ts | 25 +++++++++---------- src/api/rest/training-upload-service.ts | 11 ++++++-- .../components/text-editor/editor/editor.tsx | 12 +++++++-- .../text-editor/hooks/use-extensions.ts | 16 +++++++++++- src/shared/utils/index.ts | 1 + src/shared/utils/url-utils.ts | 10 ++++++++ 6 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 src/shared/utils/index.ts create mode 100644 src/shared/utils/url-utils.ts diff --git a/src/api/rest/homework-file-service.ts b/src/api/rest/homework-file-service.ts index b01016fe..29e71b80 100644 --- a/src/api/rest/homework-file-service.ts +++ b/src/api/rest/homework-file-service.ts @@ -5,6 +5,8 @@ import { HOMEWORK_FILE_UPLOAD_URI, } from "config"; +import { createUrlWithParams } from "shared/utils"; + export interface HomeworkFileResponse { id: string; fileName: string; @@ -21,11 +23,9 @@ export default class HomeworkFileService { const formData = new FormData(); formData.append("file", file); - const uploadFileUrl = HOMEWORK_FILE_UPLOAD_URI.replace( - // eslint-disable-next-line sonarjs/no-duplicate-string - ":homeWorkId", - homeWorkId - ); + const uploadFileUrl = createUrlWithParams(HOMEWORK_FILE_UPLOAD_URI, { + homeWorkId, + }); return axios({ method: "POST", @@ -39,10 +39,10 @@ export default class HomeworkFileService { homeWorkId: string, fileId: string ): Promise> { - const getFileUrl = HOMEWORK_FILE_GET_URI.replace( - ":homeWorkId", - homeWorkId - ).replace(":fileId", fileId); + const getFileUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId, + }); return axios({ method: "GET", @@ -52,10 +52,9 @@ export default class HomeworkFileService { } static deleteFile(homeWorkId: string): Promise> { - const deleteUrl = HOMEWORK_FILE_DELETE_URI.replace( - ":homeWorkId", - homeWorkId - ); + const deleteUrl = createUrlWithParams(HOMEWORK_FILE_DELETE_URI, { + homeWorkId, + }); return axios({ method: "DELETE", diff --git a/src/api/rest/training-upload-service.ts b/src/api/rest/training-upload-service.ts index 622bff67..2300778b 100644 --- a/src/api/rest/training-upload-service.ts +++ b/src/api/rest/training-upload-service.ts @@ -1,5 +1,7 @@ import axios, { type AxiosResponse } from "axios"; +import { createUrlWithParams } from "shared/utils"; + import { TRAINING_DELETE_URI, TRAINING_UPLOAD_URI } from "../../config"; export interface TrainingUploadResponse { @@ -15,7 +17,9 @@ export default class TrainingUploadService { formData.append("file", file); - const uploadUrl = TRAINING_UPLOAD_URI.replace(":id", trainingId); + const uploadUrl = createUrlWithParams(TRAINING_UPLOAD_URI, { + id: trainingId, + }); return axios({ method: "POST", @@ -26,7 +30,10 @@ export default class TrainingUploadService { } static delete(trainingId: string): Promise> { - const deleteUrl = TRAINING_DELETE_URI.replace(":id", trainingId); + const deleteUrl = createUrlWithParams(TRAINING_DELETE_URI, { + id: trainingId, + }); + return axios({ method: "DELETE", url: deleteUrl, diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index 59094762..9283d0d6 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -3,7 +3,9 @@ import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; import { FC, useCallback, useState } from "react"; import { useSnackbar } from "notistack"; +import { HOMEWORK_FILE_GET_URI } from "config"; +import { createUrlWithParams } from "shared/utils"; import { insertFiles, insertImages } from "shared/lib/mui-tiptap/utils"; import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; @@ -36,7 +38,10 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { const uploadedFile = await uploadHomeworkFile(file, homeWorkId); if (uploadedFile) { - const serverUrl = `/homework/${homeWorkId}/file/${uploadedFile.id}`; + const serverUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId: uploadedFile.id, + }); return { src: serverUrl, @@ -77,7 +82,10 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { const uploadedFile = await uploadHomeworkFile(file, homeWorkId); if (uploadedFile) { - const serverUrl = `/homework/${homeWorkId}/file/${uploadedFile.id}`; + const serverUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId: uploadedFile.id, + }); return { href: serverUrl, diff --git a/src/shared/components/text-editor/hooks/use-extensions.ts b/src/shared/components/text-editor/hooks/use-extensions.ts index d6889f82..fb947b06 100644 --- a/src/shared/components/text-editor/hooks/use-extensions.ts +++ b/src/shared/components/text-editor/hooks/use-extensions.ts @@ -188,6 +188,20 @@ const CustomParagraph = Paragraph.extend({ content: "inline*", // Поддержка всех inline-узлов, включая file }); +const CustomResizableImage = ResizableImage.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "300px", + }, + height: { + default: "auto", + }, + }; + }, +}); + export default function useExtensions({ placeholder, }: UseExtensionsOptions = {}): EditorOptions["extensions"] { @@ -238,7 +252,7 @@ export default function useExtensions({ Highlight.configure({ multicolor: true }), HorizontalRule, - ResizableImage.configure({ + CustomResizableImage.configure({ allowBase64: true, }), diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 00000000..48b8ff99 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1 @@ +export { createUrlWithParams } from "./url-utils"; diff --git a/src/shared/utils/url-utils.ts b/src/shared/utils/url-utils.ts new file mode 100644 index 00000000..5a4be762 --- /dev/null +++ b/src/shared/utils/url-utils.ts @@ -0,0 +1,10 @@ +export const createUrlWithParams = ( + urlTemplate: string, + params: Record +): string => { + let url = urlTemplate; + for (const [key, value] of Object.entries(params)) { + url = url.replace(`:${key}`, encodeURIComponent(String(value))); + } + return url; +}; From 683af6a20f696fa3343cf5a5fdbe1c7a92e399e4 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 3 Mar 2025 15:56:48 +0300 Subject: [PATCH 04/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 12 +- .env.production | 12 +- .../homework/create-homework-to-check.graphql | 1 + .../homework-by-lecture-and-training.graphql | 1 + .../homework/send-homework-to-check.graphql | 36 ------ .../graphql/homework/update-homework.graphql | 8 ++ src/api/graphql/lecture/lecture.graphql | 8 ++ src/api/rest/lecture-file-service.ts | 67 ++++++++++++ src/api/rest/lecture-homework-file-service.ts | 70 ++++++++++++ src/config.ts | 11 ++ .../views/edit-lecture/edit-lecture.tsx | 88 ++++++++++++++- .../components/text-editor/editor/editor.tsx | 103 ++++++------------ .../editor/ui/editor-menu-controls.tsx | 4 +- .../components/text-editor/types/index.ts | 9 +- .../homework-content/homework-content.tsx | 4 +- .../container/send-homework-container.tsx | 45 ++++++-- .../view/send-homework.styled.ts | 6 +- .../send-homework/view/send-homework.tsx | 99 +++++++++++++---- .../send-homework/view/send-homework.types.ts | 5 +- .../view/update-homework.styled.ts | 8 +- .../update-homework/view/update-homework.tsx | 66 +++++++++-- src/shared/hooks/index.ts | 6 + .../hooks/use-homework-file-delete.ts | 0 .../hooks/use-homework-file-get.ts | 0 .../hooks/use-homework-file-upload.ts | 0 src/shared/hooks/use-lecture-file-get.ts | 31 ++++++ src/shared/hooks/use-lecture-file-upload.ts | 39 +++++++ .../hooks/use-lecture-homework-file-upload.ts | 42 +++++++ vite.config.ts | 18 +-- 29 files changed, 626 insertions(+), 173 deletions(-) delete mode 100644 src/api/graphql/homework/send-homework-to-check.graphql create mode 100644 src/api/rest/lecture-file-service.ts create mode 100644 src/api/rest/lecture-homework-file-service.ts rename src/shared/{components/text-editor => }/hooks/use-homework-file-delete.ts (100%) rename src/shared/{components/text-editor => }/hooks/use-homework-file-get.ts (100%) rename src/shared/{components/text-editor => }/hooks/use-homework-file-upload.ts (100%) create mode 100644 src/shared/hooks/use-lecture-file-get.ts create mode 100644 src/shared/hooks/use-lecture-file-upload.ts create mode 100644 src/shared/hooks/use-lecture-homework-file-upload.ts diff --git a/.env.development b/.env.development index 320240aa..7a92d63b 100644 --- a/.env.development +++ b/.env.development @@ -6,7 +6,13 @@ VITE_AVATAR_UPLOAD_URI=/upload/avatar VITE_AVATAR_DELETE_URI=/upload/avatar VITE_TRAINING_UPLOAD_URI=/upload/training/:id VITE_TRAINING_DELETE_URI=/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_UPLOAD_URI=/homework/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/homework/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/homework/student/homework/:homeWorkId/file/:fileId +VITE_LECTURE_FILE_UPLOAD_URI=/lecture/:lectureId/file +VITE_LECTURE_FILE_GET_URI=/lecture/:lectureId/file/:fileId +VITE_LECTURE_FILE_DELETE_URI=/lecture/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/lecture/homework/:lectureId/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/lecture/homework/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/lecture/homework/:lectureId/file/:fileId VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" diff --git a/.env.production b/.env.production index 24b4831a..e7da24df 100644 --- a/.env.production +++ b/.env.production @@ -6,6 +6,12 @@ VITE_AVATAR_UPLOAD_URI=/api/upload/avatar VITE_AVATAR_DELETE_URI=/api/upload/avatar VITE_TRAINING_UPLOAD_URI=/api/upload/training/:id VITE_TRAINING_DELETE_URI=/api/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/api/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/api/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/:homeWorkId/file \ No newline at end of file +VITE_HOMEWORK_FILE_UPLOAD_URI=/api/homework/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/api/homework/student/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/student/:homeWorkId/file/:fileId +VITE_LECTURE_FILE_UPLOAD_URI=/api/lecture/:lectureId/file +VITE_LECTURE_FILE_GET_URI=/api/lecture/:lectureId/file/:fileId +VITE_LECTURE_FILE_DELETE_URI=/api/lecture/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/api/lecture/homework/:lectureId/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/api/lecture/homework/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/api/lecture/homework/:lectureId/file/:fileId diff --git a/src/api/graphql/homework/create-homework-to-check.graphql b/src/api/graphql/homework/create-homework-to-check.graphql index f15ffd66..2a318ced 100644 --- a/src/api/graphql/homework/create-homework-to-check.graphql +++ b/src/api/graphql/homework/create-homework-to-check.graphql @@ -17,6 +17,7 @@ mutation createHomeWorkToCheck( answer status training { + id techStack } student { diff --git a/src/api/graphql/homework/homework-by-lecture-and-training.graphql b/src/api/graphql/homework/homework-by-lecture-and-training.graphql index 7b6d0062..ef02412c 100644 --- a/src/api/graphql/homework/homework-by-lecture-and-training.graphql +++ b/src/api/graphql/homework/homework-by-lecture-and-training.graphql @@ -9,6 +9,7 @@ query homeWorkByLectureAndTraining($lectureId: ID!, $trainingId: ID!) { answer status training { + id techStack } student { diff --git a/src/api/graphql/homework/send-homework-to-check.graphql b/src/api/graphql/homework/send-homework-to-check.graphql deleted file mode 100644 index b6174b10..00000000 --- a/src/api/graphql/homework/send-homework-to-check.graphql +++ /dev/null @@ -1,36 +0,0 @@ -mutation sendHomeWorkToCheck($homeWorkId: ID!) { - sendHomeWorkToCheck(homeWorkId: $homeWorkId) { - id - lecture { - id - subject - contentHomeWork - } - answer - status - training { - techStack - } - student { - id - firstName - avatar - lastName - rating { - rating - } - } - mentor { - id - firstName - avatar - lastName - rating { - rating - } - } - creationDate - startCheckingDate - endCheckingDate - } -} diff --git a/src/api/graphql/homework/update-homework.graphql b/src/api/graphql/homework/update-homework.graphql index 78b315c5..7b0f6189 100644 --- a/src/api/graphql/homework/update-homework.graphql +++ b/src/api/graphql/homework/update-homework.graphql @@ -9,6 +9,7 @@ mutation updateHomework($id: ID!, $content: String!) { answer status training { + id techStack } student { @@ -29,6 +30,13 @@ mutation updateHomework($id: ID!, $content: String!) { rating } } + filesHomeWork { + id + creationDate + fileName + contentType + size + } creationDate startCheckingDate endCheckingDate diff --git a/src/api/graphql/lecture/lecture.graphql b/src/api/graphql/lecture/lecture.graphql index f792ca23..7f49c08f 100644 --- a/src/api/graphql/lecture/lecture.graphql +++ b/src/api/graphql/lecture/lecture.graphql @@ -14,5 +14,13 @@ query lecture($id: ID!) { subject description content + files { + id + homeWork + creationDate + fileName + contentType + size + } } } diff --git a/src/api/rest/lecture-file-service.ts b/src/api/rest/lecture-file-service.ts new file mode 100644 index 00000000..3d3a99f5 --- /dev/null +++ b/src/api/rest/lecture-file-service.ts @@ -0,0 +1,67 @@ +import axios, { type AxiosResponse } from "axios"; +import { + LECTURE_FILE_UPLOAD_URI, + LECTURE_FILE_GET_URI, + LECTURE_FILE_DELETE_URI, +} from "config"; +import { createUrlWithParams } from "shared/utils"; + +export interface LectureFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class LectureFileService { + static uploadFile( + lectureId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams(LECTURE_FILE_UPLOAD_URI, { + lectureId, + }); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + lectureId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(LECTURE_FILE_GET_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + lectureId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(LECTURE_FILE_DELETE_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/api/rest/lecture-homework-file-service.ts b/src/api/rest/lecture-homework-file-service.ts new file mode 100644 index 00000000..a206414c --- /dev/null +++ b/src/api/rest/lecture-homework-file-service.ts @@ -0,0 +1,70 @@ +import axios, { type AxiosResponse } from "axios"; +import { + LECTURE_HOMEWORK_FILE_UPLOAD_URI, + LECTURE_HOMEWORK_FILE_GET_URI, + LECTURE_HOMEWORK_FILE_DELETE_URI, +} from "config"; +import { createUrlWithParams } from "shared/utils"; + +export interface LectureHomeworkFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class LectureHomeworkFileService { + static uploadFile( + lectureId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams( + LECTURE_HOMEWORK_FILE_UPLOAD_URI, + { + lectureId, + } + ); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + lectureId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(LECTURE_HOMEWORK_FILE_GET_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + lectureId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(LECTURE_HOMEWORK_FILE_DELETE_URI, { + lectureId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/config.ts b/src/config.ts index d1a868ef..058966bb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,3 +11,14 @@ export const HOMEWORK_FILE_UPLOAD_URI = import.meta.env export const HOMEWORK_FILE_GET_URI = import.meta.env.VITE_HOMEWORK_FILE_GET_URI; export const HOMEWORK_FILE_DELETE_URI = import.meta.env .VITE_HOMEWORK_FILE_DELETE_URI; +export const LECTURE_FILE_UPLOAD_URI = import.meta.env + .VITE_LECTURE_FILE_UPLOAD_URI; +export const LECTURE_FILE_GET_URI = import.meta.env.VITE_LECTURE_FILE_GET_URI; +export const LECTURE_FILE_DELETE_URI = import.meta.env + .VITE_LECTURE_FILE_DELETE_URI; +export const LECTURE_HOMEWORK_FILE_UPLOAD_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI; +export const LECTURE_HOMEWORK_FILE_GET_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_GET_URI; +export const LECTURE_HOMEWORK_FILE_DELETE_URI = import.meta.env + .VITE_LECTURE_HOMEWORK_FILE_DELETE_URI; diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 1f2188ff..137ae301 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -4,11 +4,14 @@ import { FC, useRef, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { Clear, Save } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; +import { LECTURE_FILE_GET_URI } from "config"; import { InputText } from "shared/components/form"; import { Editor } from "shared/components/text-editor"; import { RichTextEditorRef } from "shared/lib/mui-tiptap"; import { UserRole } from "api/graphql/generated/graphql"; +import { PendingFile } from "shared/components/text-editor/types"; +import { createUrlWithParams } from "shared/utils"; import { SelectLectors } from "../../containers"; import { @@ -22,16 +25,23 @@ import { } from "./edit-lecture.styled"; import { IEditLecture, LectureInput } from "./edit-lecture.types"; import EditDescription from "../edit-description"; +import { + useLectureFileUpload, + useLectureHomeworkFileUpload, +} from "shared/hooks"; const EditLecture: FC = ({ dataLecture, updateLecture, dataLectureHomework, }) => { - const { id, subject, speakers, content } = dataLecture.lecture!; + const { id: lectureId, subject, speakers, content } = dataLecture.lecture!; const contentHomework = dataLectureHomework.lectureHomeWork; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); + const [pendingFiles, setPendingFiles] = useState([]); + const { uploadLectureFile } = useLectureFileUpload(); + const { uploadLectureHomeworkFile } = useLectureHomeworkFileUpload(); const [description, setDescription] = useState( dataLecture?.lecture?.description! @@ -40,7 +50,7 @@ const EditLecture: FC = ({ const rteRefContentHomeWork = useRef(null); const { handleSubmit, control } = useForm({ - defaultValues: { id, subject, description, speakers }, + defaultValues: { id: lectureId, subject, description, speakers }, }); const onSubmit: SubmitHandler = async (data) => { @@ -48,12 +58,67 @@ const EditLecture: FC = ({ const emails = speakers?.map((speaker) => speaker?.email); + let content = rteRefContent.current?.editor?.getHTML().trim(); + let contentHomework = rteRefContentHomeWork.current?.editor + ?.getHTML() + .trim(); + + if (!lectureId) { + return; + } + + const lectureFiles = pendingFiles.filter( + (file) => file.source === "lecture" + ); + const homeworkFiles = pendingFiles.filter( + (file) => file.source === "lectureHomework" + ); + + const lectureUploadPromises = lectureFiles.map( + async ({ file, localUrl }) => { + const uploadedFile = await uploadLectureFile(file, lectureId); + return { + localUrl, + realUrl: createUrlWithParams(LECTURE_FILE_GET_URI, { + lectureId, + fileId: uploadedFile?.id!, + }), + }; + } + ); + + const lectureHomeworkUploadPromises = homeworkFiles.map( + async ({ file, localUrl }) => { + const uploadedFile = await uploadLectureHomeworkFile(file, lectureId); + return { + localUrl, + realUrl: createUrlWithParams(LECTURE_FILE_GET_URI, { + lectureId, + fileId: uploadedFile?.id!, + }), + }; + } + ); + + const uploadedLectureFiles = await Promise.all(lectureUploadPromises); + const uploadedLectureHomeworkFiles = await Promise.all( + lectureHomeworkUploadPromises + ); + + uploadedLectureFiles.forEach(({ localUrl, realUrl }) => { + content = content?.replaceAll(localUrl, realUrl); + }); + + uploadedLectureHomeworkFiles.forEach(({ localUrl, realUrl }) => { + contentHomework = contentHomework?.replaceAll(localUrl, realUrl); + }); + const submissionData = { ...restData, speakers: emails, description, - content: rteRefContent.current?.editor?.getHTML(), - contentHomeWork: rteRefContentHomeWork.current?.editor?.getHTML(), + content, + contentHomeWork: contentHomework, }; await updateLecture({ @@ -70,10 +135,14 @@ const EditLecture: FC = ({ ); }, }); + + setPendingFiles([]); + rteRefContent.current?.editor?.commands.clearContent(); + rteRefContentHomeWork.current?.editor?.commands.clearContent(); }; const handleBack = () => { - const newPathname = location.pathname.replace(`/${id}`, ""); + const newPathname = location.pathname.replace(`/${lectureId}`, ""); navigate(newPathname); }; @@ -116,7 +185,12 @@ const EditLecture: FC = ({ Материалы урока - + @@ -125,6 +199,8 @@ const EditLecture: FC = ({ diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index 9283d0d6..4c25d63d 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -2,10 +2,7 @@ import { Lock, LockOpen, TextFields } from "@mui/icons-material"; import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; import { FC, useCallback, useState } from "react"; -import { useSnackbar } from "notistack"; -import { HOMEWORK_FILE_GET_URI } from "config"; -import { createUrlWithParams } from "shared/utils"; import { insertFiles, insertImages } from "shared/lib/mui-tiptap/utils"; import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; @@ -14,104 +11,73 @@ import { EditorMenuControls } from "./ui"; import { ITextEditor } from "../types"; import useExtensions from "../hooks/use-extensions"; import { fileListToImageFiles } from "../utils/file-list-to-image-files"; -import { useHomeworkFileUpload } from "../hooks/use-homework-file-upload"; -const Editor: FC = ({ rteRef, content, homeWorkId }) => { +const Editor: FC = ({ + rteRef, + content, + setPendingFiles, + source, +}) => { const extensions = useExtensions({ placeholder: "Введите текст...", }); const [isEditable, setIsEditable] = useState(true); const [showMenuBar, setShowMenuBar] = useState(true); - const { uploadHomeworkFile } = useHomeworkFileUpload(); - const { enqueueSnackbar } = useSnackbar(); - const handleNewImageFiles = useCallback( - async (files: File[], insertPosition?: number): Promise => { - if (!rteRef.current?.editor || !homeWorkId) { - return; - } - - const attributesForImageFiles = await Promise.all( - files.map(async (file) => { - try { - const uploadedFile = await uploadHomeworkFile(file, homeWorkId); + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) return; - if (uploadedFile) { - const serverUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { - homeWorkId, - fileId: uploadedFile.id, - }); + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); - return { - src: serverUrl, - alt: uploadedFile.fileName, - }; - } - } catch { - enqueueSnackbar(`Не удалось загрузить файл: ${file.name}`, { - variant: "error", - }); - } + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); - return { - src: "", - alt: file.name, - }; + const attributesForImageFiles = filesWithUrl.map( + ({ file, localUrl }) => ({ + src: localUrl, + alt: file.name, }) ); insertImages({ - images: attributesForImageFiles.filter((img) => img.src), + images: attributesForImageFiles, editor: rteRef.current.editor, position: insertPosition, }); }, - [rteRef, homeWorkId, uploadHomeworkFile] + [rteRef, setPendingFiles] ); const handleNewFiles = useCallback( - async (files: File[], insertPosition?: number): Promise => { - if (!rteRef.current?.editor || !homeWorkId) { + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) { return; } - const attributesForFiles = await Promise.all( - files.map(async (file) => { - try { - const uploadedFile = await uploadHomeworkFile(file, homeWorkId); + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); - if (uploadedFile) { - const serverUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { - homeWorkId, - fileId: uploadedFile.id, - }); + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); - return { - href: serverUrl, - fileName: uploadedFile.fileName, - }; - } - } catch { - enqueueSnackbar(`Не удалось загрузить файл: ${file.name}`, { - variant: "error", - }); - } - - return { - href: "", - fileName: file.name, - }; - }) - ); + const attributesForFiles = filesWithUrl.map(({ localUrl, file }) => ({ + href: localUrl, + fileName: file.name, + })); insertFiles({ - files: attributesForFiles.filter((file) => file.href), + files: attributesForFiles, editor: rteRef.current.editor, position: insertPosition, }); }, - [rteRef, homeWorkId, uploadHomeworkFile, enqueueSnackbar] + [rteRef, setPendingFiles] ); const handleDrop: NonNullable = @@ -190,7 +156,6 @@ const Editor: FC = ({ rteRef, content, homeWorkId }) => { }} renderControls={() => ( diff --git a/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx b/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx index 2e227157..77753109 100644 --- a/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx +++ b/src/shared/components/text-editor/editor/ui/editor-menu-controls.tsx @@ -38,8 +38,8 @@ import { Maybe } from "api/graphql/generated/graphql"; interface EditorMenuControlsProps { homeWorkId?: Maybe; - onUploadImageFiles: (files: File[]) => Promise; - onUploadFiles: (files: File[]) => Promise; + onUploadImageFiles: (files: File[]) => any; + onUploadFiles: (files: File[]) => any; } export default function EditorMenuControls({ diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index 62a8c09a..b59f6aa0 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -9,6 +9,12 @@ export type MentionSuggestion = { mentionLabel: string; }; +export type PendingFile = { + file: File; + localUrl: string; + source: "lectureHomework" | "lecture" | "studentHomework"; +}; + export type SuggestionListRef = { onKeyDown: NonNullable< ReturnType< @@ -22,5 +28,6 @@ export type SuggestionListProps = SuggestionProps; export interface ITextEditor { rteRef: RefObject; content?: Maybe; - homeWorkId?: Maybe; + setPendingFiles?: React.Dispatch>; + source: "lectureHomework" | "lecture" | "studentHomework"; } diff --git a/src/shared/features/homework-content/homework-content.tsx b/src/shared/features/homework-content/homework-content.tsx index ec6840ab..5e4017ea 100644 --- a/src/shared/features/homework-content/homework-content.tsx +++ b/src/shared/features/homework-content/homework-content.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { TextView } from "shared/components/text-editor"; import UpdateHomeworkItem from "shared/features/update-homework/container"; -import SendHomeworkItem from "shared/features/send-homework/container"; +import CreateHomeworkItem from "shared/features/send-homework/container"; import { IHomeworkContent } from "./homework-content.types"; @@ -22,7 +22,7 @@ const HomeworkContent: FC = (props) => { /> ); } else { - homeworkContent = ; + homeworkContent = ; } return <>{homeworkContent}; diff --git a/src/shared/features/send-homework/container/send-homework-container.tsx b/src/shared/features/send-homework/container/send-homework-container.tsx index dfa959bb..3f0e5ae6 100644 --- a/src/shared/features/send-homework/container/send-homework-container.tsx +++ b/src/shared/features/send-homework/container/send-homework-container.tsx @@ -6,17 +6,16 @@ import { HomeWorkByLectureAndTrainingQuery, Maybe, useCreateHomeWorkToCheckMutation, + useUpdateHomeworkMutation, } from "api/graphql/generated/graphql"; import SendHomework from "../view"; -const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ - homeWorkId, -}) => { +const SendHomeworkContainer: FC = () => { const { lectureId, trainingId } = useParams(); - const [createHomeWorkToCheck, { loading }] = useCreateHomeWorkToCheckMutation( - { + const [createHomeWorkToCheck, { loading: loadingCreateHomeWorkToCheck }] = + useCreateHomeWorkToCheckMutation({ update: (cache, { data }) => { const newCreateHomeWorkToCheck = data?.createHomeWorkToCheck; @@ -29,7 +28,7 @@ const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ const updatedHomeWorkByLectureAndTraining = { homeWorkByLectureAndTraining: { ...existingHomeWorkByLectureAndTraining?.homeWorkByLectureAndTraining, - answer: newCreateHomeWorkToCheck?.answer, + ...newCreateHomeWorkToCheck, }, }; @@ -39,14 +38,40 @@ const SendHomeworkContainer: FC<{ homeWorkId?: Maybe }> = ({ data: updatedHomeWorkByLectureAndTraining, }); }, - } - ); + }); + + const [updateHomework, { loading: loadingUpdateHomework }] = + useUpdateHomeworkMutation({ + update: (cache, { data }) => { + const newUpdateHomework = data?.updateHomeWork; + + const existingHomeWorkByLectureAndTraining: Maybe = + cache.readQuery({ + query: HomeWorkByLectureAndTrainingDocument, + variables: { lectureId: lectureId!, trainingId: trainingId! }, + }); + + const updatedHomeWorkByLectureAndTraining = { + homeWorkByLectureAndTraining: { + ...existingHomeWorkByLectureAndTraining?.homeWorkByLectureAndTraining, + ...newUpdateHomework, + }, + }; + + cache.writeQuery({ + query: HomeWorkByLectureAndTrainingDocument, + variables: { lectureId: lectureId!, trainingId: trainingId! }, + data: updatedHomeWorkByLectureAndTraining, + }); + }, + }); return ( ); }; diff --git a/src/shared/features/send-homework/view/send-homework.styled.ts b/src/shared/features/send-homework/view/send-homework.styled.ts index 0420a2c3..3175ba10 100644 --- a/src/shared/features/send-homework/view/send-homework.styled.ts +++ b/src/shared/features/send-homework/view/send-homework.styled.ts @@ -1,6 +1,6 @@ import { styled } from "@mui/system"; import { LoadingButton } from "@mui/lab"; -import { Stack, Box, FormHelperText } from "@mui/material"; +import { Stack, Box, FormHelperText, IconButton } from "@mui/material"; export const StyledBox = styled(Box)({ width: "100%", @@ -21,3 +21,7 @@ export const StyledStack = styled(Stack)({ flexDirection: "column", alignItems: "flex-end", }); + +export const StyledIconButton = styled(IconButton)({ + padding: "10px 4px", +}); diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 950fc063..6b76b259 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -1,48 +1,105 @@ import { FC, useRef, useState } from "react"; import { useParams } from "react-router-dom"; +import { HOMEWORK_FILE_GET_URI } from "config"; +import { createUrlWithParams } from "shared/utils"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; +import { useHomeworkFileUpload } from "shared/hooks"; +import { PendingFile } from "shared/components/text-editor/types"; import SendButtons from "shared/components/send-buttons"; import { ISendHomeWork } from "./send-homework.types"; import { StyledBox, StyledFormHelperText } from "./send-homework.styled"; const SendHomework: FC = (props) => { - const { createHomeWorkToCheck, loading, homeWorkId } = props; + const { + createHomeWorkToCheck, + loadingCreateHomeWorkToCheck, + loadingUpdateHomework, + updateHomework, + } = props; const { lectureId, trainingId } = useParams(); - const [error, setError] = useState(""); + const rteRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState([]); + const { uploadHomeworkFile } = useHomeworkFileUpload(); + const [error, setError] = useState(""); const handleSendHomeWork = () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; - - if (content.trim() !== "" && content.trim() !== "

") { - try { - createHomeWorkToCheck({ - variables: { - lectureId: lectureId!, - trainingId: trainingId!, - content: rteRef.current?.editor?.getHTML() ?? "", - }, - }); - setError(""); - rteRef.current?.editor?.commands.clearContent(); - } catch (error) { - setError("Произошла ошибка при отправке д/з."); - } - } else { + if (!rteRef.current?.editor) return; + + let content = rteRef.current.editor.getHTML().trim(); + if (!content || content === "

") { setError("Введите текст"); + return; + } + + try { + createHomeWorkToCheck({ + variables: { + lectureId: lectureId!, + trainingId: trainingId!, + content, + }, + onCompleted: async (response) => { + const homeWorkId = response?.createHomeWorkToCheck?.id; + + if (!homeWorkId) { + return; + } + + const uploadPromises = pendingFiles.map( + async ({ file, localUrl }) => { + const uploadedFile = await uploadHomeworkFile(file, homeWorkId); + + const realUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId: uploadedFile?.id!, + }); + + return { localUrl, realUrl }; + } + ); + + const results = await Promise.all(uploadPromises); + + results.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + + await updateHomework({ + variables: { + id: homeWorkId, + content, + }, + }); + + setPendingFiles([]); + setError(""); + rteRef.current?.editor?.commands.clearContent(); + }, + }); + } catch (error) { + setError("Произошла ошибка при отправке д/з."); } }; return (
- + {error && {error}} + + - ); }; diff --git a/src/shared/features/send-homework/view/send-homework.types.ts b/src/shared/features/send-homework/view/send-homework.types.ts index 26dc7512..8ac6872f 100644 --- a/src/shared/features/send-homework/view/send-homework.types.ts +++ b/src/shared/features/send-homework/view/send-homework.types.ts @@ -1,10 +1,13 @@ import { CreateHomeWorkToCheckMutationFn, Maybe, + UpdateCommentMutationFn, } from "api/graphql/generated/graphql"; export interface ISendHomeWork { createHomeWorkToCheck: CreateHomeWorkToCheckMutationFn; - loading: boolean; + updateHomework: UpdateCommentMutationFn; + loadingCreateHomeWorkToCheck: boolean; + loadingUpdateHomework: boolean; homeWorkId?: Maybe; } diff --git a/src/shared/features/update-homework/view/update-homework.styled.ts b/src/shared/features/update-homework/view/update-homework.styled.ts index 8e974920..9926ff52 100644 --- a/src/shared/features/update-homework/view/update-homework.styled.ts +++ b/src/shared/features/update-homework/view/update-homework.styled.ts @@ -1,5 +1,5 @@ import { styled } from "@mui/system"; -import { Button, Stack, Box } from "@mui/material"; +import { Button, Stack, Box, FormHelperText } from "@mui/material"; import { LoadingButton } from "@mui/lab"; export const StyledWrapper = styled(Stack)(({ theme }) => ({ @@ -29,3 +29,9 @@ export const StyledLoadingButton = styled(LoadingButton)(({ theme }) => ({ export const StyledCancelButton = styled(Button)(({ theme }) => ({ color: theme.palette.app.black, })); + +export const StyledFormHelperText = styled(FormHelperText)(({ theme }) => ({ + position: "absolute", + color: theme.palette.app.red, + margin: "5px 5px 0", +})); diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index 4954f15d..debfc241 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -1,28 +1,67 @@ -import { FC, useRef } from "react"; +import { FC, useRef, useState } from "react"; +import { HOMEWORK_FILE_GET_URI } from "config"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; +import { createUrlWithParams } from "shared/utils"; +import { useHomeworkFileUpload } from "shared/hooks"; import { IUpdateHomeWork } from "./update-homework.types"; -import { StyledBox, StyledWrapper } from "./update-homework.styled"; +import { + StyledBox, + StyledFormHelperText, + StyledWrapper, +} from "./update-homework.styled"; +import { PendingFile } from "shared/components/text-editor/types"; const UpdateHomework: FC = (props) => { const { loading, updateHomework, setOpenHomeWorkEdit, answer, homeWorkId } = props; const rteRef = useRef(null); - const handleUpdateHomework = () => { - if (rteRef && homeWorkId) { - updateHomework({ - variables: { - id: homeWorkId, - content: rteRef.current?.editor?.getHTML() ?? "", - }, + const [pendingFiles, setPendingFiles] = useState([]); + const [error, setError] = useState(""); + const { uploadHomeworkFile } = useHomeworkFileUpload(); + + const handleUpdateHomework = async () => { + if (!rteRef.current?.editor || !homeWorkId) return; + + let content = rteRef.current.editor.getHTML().trim(); + if (!content || content === "

") { + setError("Введите текст"); + return; + } + + try { + const uploadPromises = pendingFiles.map(async ({ file, localUrl }) => { + const uploadedFile = await uploadHomeworkFile(file, homeWorkId); + + const realUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { + homeWorkId, + fileId: uploadedFile?.id!, + }); + + return { localUrl, realUrl }; + }); + + const results = await Promise.all(uploadPromises); + + results.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + + await updateHomework({ + variables: { id: homeWorkId, content }, onCompleted: () => { setOpenHomeWorkEdit(false); + setPendingFiles([]); + setError(""); + rteRef.current?.editor?.commands.clearContent(); }, }); + } catch (err) { + setError("Произошла ошибка при редактировании д/з."); } }; @@ -30,7 +69,14 @@ const UpdateHomework: FC = (props) => {
- + + {error && {error}} + setOpenHomeWorkEdit(false)} diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index b75e953b..42c53df5 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -8,3 +8,9 @@ export { useResponsive } from "./use-responsive"; export { useRoleAccess } from "./use-role-access"; export { useSettings } from "./use-theme"; export { useDragEffect } from "./use-drag-effect"; +export { useHomeworkFileDelete } from "./use-homework-file-delete"; +export { useHomeworkFileGet } from "./use-homework-file-get"; +export { useHomeworkFileUpload } from "./use-homework-file-upload"; +export { useLectureFileGet } from "./use-lecture-file-get"; +export { useLectureFileUpload } from "./use-lecture-file-upload"; +export { useLectureHomeworkFileUpload } from "./use-lecture-homework-file-upload"; diff --git a/src/shared/components/text-editor/hooks/use-homework-file-delete.ts b/src/shared/hooks/use-homework-file-delete.ts similarity index 100% rename from src/shared/components/text-editor/hooks/use-homework-file-delete.ts rename to src/shared/hooks/use-homework-file-delete.ts diff --git a/src/shared/components/text-editor/hooks/use-homework-file-get.ts b/src/shared/hooks/use-homework-file-get.ts similarity index 100% rename from src/shared/components/text-editor/hooks/use-homework-file-get.ts rename to src/shared/hooks/use-homework-file-get.ts diff --git a/src/shared/components/text-editor/hooks/use-homework-file-upload.ts b/src/shared/hooks/use-homework-file-upload.ts similarity index 100% rename from src/shared/components/text-editor/hooks/use-homework-file-upload.ts rename to src/shared/hooks/use-homework-file-upload.ts diff --git a/src/shared/hooks/use-lecture-file-get.ts b/src/shared/hooks/use-lecture-file-get.ts new file mode 100644 index 00000000..3141fff0 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-get.ts @@ -0,0 +1,31 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import LectureFileService from "api/rest/lecture-file-service"; +import { Maybe } from "api/graphql/generated/graphql"; + +export const useLectureFileGet = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState>(null); + + const getLectureFile = async (lectureId: string, fileId: string) => { + setLoading(true); + setError(null); + + try { + const response = await LectureFileService.getFile(lectureId, fileId); + setLoading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось получить файл`, { variant: "error" }); + setLoading(false); + return null; + } + }; + + return { getLectureFile, loading, error }; +}; diff --git a/src/shared/hooks/use-lecture-file-upload.ts b/src/shared/hooks/use-lecture-file-upload.ts new file mode 100644 index 00000000..55547be8 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-upload.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import LectureFileService from "api/rest/lecture-file-service"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useLectureFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadLectureFile = async (file: File, lectureId: string) => { + setUploading(true); + setError(null); + + try { + const response = await LectureFileService.uploadFile(lectureId, file); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadLectureFile, uploading, error }; +}; diff --git a/src/shared/hooks/use-lecture-homework-file-upload.ts b/src/shared/hooks/use-lecture-homework-file-upload.ts new file mode 100644 index 00000000..bd814ca7 --- /dev/null +++ b/src/shared/hooks/use-lecture-homework-file-upload.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import LectureHomeworkFileService from "api/rest/lecture-homework-file-service"; + +export const useLectureHomeworkFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadLectureHomeworkFile = async (file: File, lectureId: string) => { + setUploading(true); + setError(null); + + try { + const response = await LectureHomeworkFileService.uploadFile( + lectureId, + file + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadLectureHomeworkFile, uploading, error }; +}; diff --git a/vite.config.ts b/vite.config.ts index ccd3828f..a8b43559 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,14 +6,18 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default ({ mode }: any) => { process.env = { ...process.env, ...loadEnv(mode, process.cwd()) }; + const API_URL = process.env.VITE_APP_ENDPOINT; + const proxyConfig = { - "^/graphql": process.env.VITE_APP_ENDPOINT, - "^/login": process.env.VITE_APP_ENDPOINT, - "^/logout": process.env.VITE_APP_ENDPOINT, - "^/refreshtoken": process.env.VITE_APP_ENDPOINT, - "^/upload/avatar": process.env.VITE_APP_ENDPOINT, - "^/upload/training/.*": process.env.VITE_APP_ENDPOINT, - "^/homework/.*": process.env.VITE_APP_ENDPOINT, + "^/graphql": API_URL, + "^/login": API_URL, + "^/logout": API_URL, + "^/refreshtoken": API_URL, + "^/upload/avatar": API_URL, + "^/upload/training/.*": API_URL, + "^/homework/student/homework/.*": API_URL, + "^/lecture/.*": API_URL, + "^/lecture/homework/.*": API_URL, }; return defineConfig({ From cf7438fbc810087ddf32f5159d37f161a43d167c Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 3 Mar 2025 15:57:16 +0300 Subject: [PATCH 05/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/rest/lecture-file-service.ts | 1 + src/api/rest/lecture-homework-file-service.ts | 1 + .../edit-training/views/edit-lecture/edit-lecture.tsx | 8 ++++---- .../features/update-homework/view/update-homework.tsx | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/api/rest/lecture-file-service.ts b/src/api/rest/lecture-file-service.ts index 3d3a99f5..a1776bc9 100644 --- a/src/api/rest/lecture-file-service.ts +++ b/src/api/rest/lecture-file-service.ts @@ -4,6 +4,7 @@ import { LECTURE_FILE_GET_URI, LECTURE_FILE_DELETE_URI, } from "config"; + import { createUrlWithParams } from "shared/utils"; export interface LectureFileResponse { diff --git a/src/api/rest/lecture-homework-file-service.ts b/src/api/rest/lecture-homework-file-service.ts index a206414c..7cdf71df 100644 --- a/src/api/rest/lecture-homework-file-service.ts +++ b/src/api/rest/lecture-homework-file-service.ts @@ -4,6 +4,7 @@ import { LECTURE_HOMEWORK_FILE_GET_URI, LECTURE_HOMEWORK_FILE_DELETE_URI, } from "config"; + import { createUrlWithParams } from "shared/utils"; export interface LectureHomeworkFileResponse { diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 137ae301..4042267a 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -12,6 +12,10 @@ import { RichTextEditorRef } from "shared/lib/mui-tiptap"; import { UserRole } from "api/graphql/generated/graphql"; import { PendingFile } from "shared/components/text-editor/types"; import { createUrlWithParams } from "shared/utils"; +import { + useLectureFileUpload, + useLectureHomeworkFileUpload, +} from "shared/hooks"; import { SelectLectors } from "../../containers"; import { @@ -25,10 +29,6 @@ import { } from "./edit-lecture.styled"; import { IEditLecture, LectureInput } from "./edit-lecture.types"; import EditDescription from "../edit-description"; -import { - useLectureFileUpload, - useLectureHomeworkFileUpload, -} from "shared/hooks"; const EditLecture: FC = ({ dataLecture, diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index debfc241..89a3b531 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -6,6 +6,7 @@ import { Editor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; import { createUrlWithParams } from "shared/utils"; import { useHomeworkFileUpload } from "shared/hooks"; +import { PendingFile } from "shared/components/text-editor/types"; import { IUpdateHomeWork } from "./update-homework.types"; import { @@ -13,7 +14,6 @@ import { StyledFormHelperText, StyledWrapper, } from "./update-homework.styled"; -import { PendingFile } from "shared/components/text-editor/types"; const UpdateHomework: FC = (props) => { const { loading, updateHomework, setOpenHomeWorkEdit, answer, homeWorkId } = From 1fb1b4dcbe5b5162f14c22961d44c649b9bd3be1 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 3 Mar 2025 16:09:46 +0300 Subject: [PATCH 06/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/components/text-editor/types/index.ts | 10 ++++++++-- .../features/answer-comment/view/answer-comment.tsx | 2 +- src/shared/features/send-comment/view/send-comment.tsx | 2 +- .../features/update-comment/view/update-comment.tsx | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index b59f6aa0..4bc36f88 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -9,10 +9,16 @@ export type MentionSuggestion = { mentionLabel: string; }; +export type FileSourceType = + | "lectureHomework" + | "lecture" + | "studentHomework" + | "comment"; + export type PendingFile = { file: File; localUrl: string; - source: "lectureHomework" | "lecture" | "studentHomework"; + source: FileSourceType; }; export type SuggestionListRef = { @@ -29,5 +35,5 @@ export interface ITextEditor { rteRef: RefObject; content?: Maybe; setPendingFiles?: React.Dispatch>; - source: "lectureHomework" | "lecture" | "studentHomework"; + source: FileSourceType; } diff --git a/src/shared/features/answer-comment/view/answer-comment.tsx b/src/shared/features/answer-comment/view/answer-comment.tsx index 40e1c3a9..19c80a29 100644 --- a/src/shared/features/answer-comment/view/answer-comment.tsx +++ b/src/shared/features/answer-comment/view/answer-comment.tsx @@ -50,7 +50,7 @@ const AnswerComment: FC = (props) => { - + {error && {error}} = (props) => { return ( - + {error && {error}} diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index c3281b40..aaa4d5ba 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -41,7 +41,7 @@ const UpdateComment: FC = (props) => { - + {error && {error}} Date: Wed, 5 Mar 2025 11:40:59 +0300 Subject: [PATCH 07/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.production | 4 ++-- src/api/rest/homework-file-service.ts | 6 +++++- .../components/text-editor/editor/editor.tsx | 14 ++++++++++++++ src/shared/components/text-editor/types/index.ts | 4 ++++ .../update-homework/view/update-homework.tsx | 4 +++- src/shared/hooks/use-homework-file-delete.ts | 4 ++-- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.env.production b/.env.production index e7da24df..99064f20 100644 --- a/.env.production +++ b/.env.production @@ -7,8 +7,8 @@ VITE_AVATAR_DELETE_URI=/api/upload/avatar VITE_TRAINING_UPLOAD_URI=/api/upload/training/:id VITE_TRAINING_DELETE_URI=/api/upload/training/:id VITE_HOMEWORK_FILE_UPLOAD_URI=/api/homework/student/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/api/homework/student/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/student/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_GET_URI=/api/homework/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/student/homework/:homeWorkId/file/:fileId VITE_LECTURE_FILE_UPLOAD_URI=/api/lecture/:lectureId/file VITE_LECTURE_FILE_GET_URI=/api/lecture/:lectureId/file/:fileId VITE_LECTURE_FILE_DELETE_URI=/api/lecture/:lectureId/file/:fileId diff --git a/src/api/rest/homework-file-service.ts b/src/api/rest/homework-file-service.ts index 29e71b80..e5d52778 100644 --- a/src/api/rest/homework-file-service.ts +++ b/src/api/rest/homework-file-service.ts @@ -51,9 +51,13 @@ export default class HomeworkFileService { }); } - static deleteFile(homeWorkId: string): Promise> { + static deleteFile( + homeWorkId: string, + fileId: string + ): Promise> { const deleteUrl = createUrlWithParams(HOMEWORK_FILE_DELETE_URI, { homeWorkId, + fileId, }); return axios({ diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index 4c25d63d..736d8be9 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -17,6 +17,7 @@ const Editor: FC = ({ content, setPendingFiles, source, + deleteHomeworkFile, }) => { const extensions = useExtensions({ placeholder: "Введите текст...", @@ -134,6 +135,16 @@ const Editor: FC = ({ [handleNewImageFiles, handleNewFiles] ); + const handleKeyDown = useCallback( + (view: any, event: { key: string }) => { + // console.log("keydown event", event.key); + // if (event.key === "Backspace" || event.key === "Delete") { + // } + return false; + }, + [deleteHomeworkFile] + ); + return ( <> = ({ editorProps={{ handleDrop, handlePaste, + handleDOMEvents: { + keydown: handleKeyDown, + }, }} renderControls={() => ( ; setPendingFiles?: React.Dispatch>; source: FileSourceType; + deleteHomeworkFile?: ( + homeWorkId: string, + fileId: string + ) => Promise>; } diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index 89a3b531..1117667e 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -5,7 +5,7 @@ import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; import { createUrlWithParams } from "shared/utils"; -import { useHomeworkFileUpload } from "shared/hooks"; +import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; import { PendingFile } from "shared/components/text-editor/types"; import { IUpdateHomeWork } from "./update-homework.types"; @@ -23,6 +23,7 @@ const UpdateHomework: FC = (props) => { const [pendingFiles, setPendingFiles] = useState([]); const [error, setError] = useState(""); const { uploadHomeworkFile } = useHomeworkFileUpload(); + const { deleteHomeworkFile } = useHomeworkFileDelete(); const handleUpdateHomework = async () => { if (!rteRef.current?.editor || !homeWorkId) return; @@ -74,6 +75,7 @@ const UpdateHomework: FC = (props) => { rteRef={rteRef} setPendingFiles={setPendingFiles} source="studentHomework" + deleteHomeworkFile={deleteHomeworkFile} /> {error && {error}} diff --git a/src/shared/hooks/use-homework-file-delete.ts b/src/shared/hooks/use-homework-file-delete.ts index 79bc7aca..be9abbff 100644 --- a/src/shared/hooks/use-homework-file-delete.ts +++ b/src/shared/hooks/use-homework-file-delete.ts @@ -9,12 +9,12 @@ export const useHomeworkFileDelete = () => { const [deleting, setDeleting] = useState(false); const [error, setError] = useState>(null); - const deleteHomeworkFile = async (homeWorkId: string) => { + const deleteHomeworkFile = async (homeWorkId: string, fileId: string) => { setDeleting(true); setError(null); try { - const response = await HomeworkFileService.deleteFile(homeWorkId); + const response = await HomeworkFileService.deleteFile(homeWorkId, fileId); if (response.status === RESPONSE_STATUS.SUCCESSFUL) { setDeleting(false); From 00c2334c5cd9b3455f8822ee2e2b84493bd292f0 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Fri, 7 Mar 2025 10:53:26 +0300 Subject: [PATCH 08/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 6 +++--- .env.production | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.development b/.env.development index 7a92d63b..80853aac 100644 --- a/.env.development +++ b/.env.development @@ -6,9 +6,9 @@ VITE_AVATAR_UPLOAD_URI=/upload/avatar VITE_AVATAR_DELETE_URI=/upload/avatar VITE_TRAINING_UPLOAD_URI=/upload/training/:id VITE_TRAINING_DELETE_URI=/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/homework/student/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/homework/student/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/homework/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_UPLOAD_URI=/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/student/homework/:homeWorkId/file/:fileId VITE_LECTURE_FILE_UPLOAD_URI=/lecture/:lectureId/file VITE_LECTURE_FILE_GET_URI=/lecture/:lectureId/file/:fileId VITE_LECTURE_FILE_DELETE_URI=/lecture/:lectureId/file/:fileId diff --git a/.env.production b/.env.production index 99064f20..11bd0e2a 100644 --- a/.env.production +++ b/.env.production @@ -6,9 +6,9 @@ VITE_AVATAR_UPLOAD_URI=/api/upload/avatar VITE_AVATAR_DELETE_URI=/api/upload/avatar VITE_TRAINING_UPLOAD_URI=/api/upload/training/:id VITE_TRAINING_DELETE_URI=/api/upload/training/:id -VITE_HOMEWORK_FILE_UPLOAD_URI=/api/homework/student/homework/:homeWorkId/file -VITE_HOMEWORK_FILE_GET_URI=/api/homework/student/homework/:homeWorkId/file/:fileId -VITE_HOMEWORK_FILE_DELETE_URI=/api/homework/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_UPLOAD_URI=/api/student/homework/:homeWorkId/file +VITE_HOMEWORK_FILE_GET_URI=/api/student/homework/:homeWorkId/file/:fileId +VITE_HOMEWORK_FILE_DELETE_URI=/api/student/homework/:homeWorkId/file/:fileId VITE_LECTURE_FILE_UPLOAD_URI=/api/lecture/:lectureId/file VITE_LECTURE_FILE_GET_URI=/api/lecture/:lectureId/file/:fileId VITE_LECTURE_FILE_DELETE_URI=/api/lecture/:lectureId/file/:fileId From f458eecff14c7e2f4f33eca9c27acbdcc378b2b2 Mon Sep 17 00:00:00 2001 From: george <12208636+georg3k@users.noreply.github.com> Date: Sun, 23 Mar 2025 21:32:02 +0300 Subject: [PATCH 09/21] Jenkins tests --- .github/workflows/frontend_deploy_test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/frontend_deploy_test.yml b/.github/workflows/frontend_deploy_test.yml index c8e1ab65..6b05593e 100644 --- a/.github/workflows/frontend_deploy_test.yml +++ b/.github/workflows/frontend_deploy_test.yml @@ -33,3 +33,12 @@ jobs: run: # rm -rf /var/www/app.qa.guru/html/* cp -r dist/* /var/www/app-stage.qa.guru/html/ + + runtest_jenkins: + runs-on: stage-runner + needs: yarn-and-node + steps: + - name: jenkins + run: curl -X POST "https://jenkins.autotests.cloud/buildByToken/buildWithParameters?job=qa-guru-app-tests&token=$TOKEN&TAG=UI" + env: + TOKEN: ${{ secrets.JENKINS_TOKEN }} From 04583894712ceacf9d14aa205489586b26b70823 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Wed, 2 Apr 2025 20:58:28 +0300 Subject: [PATCH 10/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 9 +- .env.production | 9 +- src/api/rest/auth-service.ts | 3 +- src/api/rest/avatar-upload-service.ts | 3 +- src/api/rest/homework-comment-serivce.ts | 71 ++++++++++++ src/api/rest/training-upload-service.ts | 3 +- src/config.ts | 6 ++ .../views/edit-lecture/edit-lecture.tsx | 31 +++++- .../comment-editor/comment-editor.tsx | 102 +++++++++++++++++- .../ui/editor-menu-controls.tsx | 23 +++- .../components/text-editor/editor/editor.tsx | 23 ++-- .../components/text-editor/types/index.ts | 5 +- .../container/send-comment-container.tsx | 91 ++++++++++------ .../send-comment/view/send-comment.tsx | 101 +++++++++++++---- .../send-comment/view/send-comment.types.ts | 10 +- .../update-comment/view/update-comment.tsx | 72 ++++++++++--- .../update-homework/view/update-homework.tsx | 13 ++- src/shared/helpers/extract-file-id.ts | 12 +++ src/shared/helpers/index.ts | 1 + src/shared/hooks/index.ts | 5 + .../hooks/use-homework-comment-file-delete.ts | 45 ++++++++ .../hooks/use-homework-comment-file-get.ts | 34 ++++++ .../hooks/use-homework-comment-file-upload.ts | 42 ++++++++ src/shared/hooks/use-lecture-file-delete.ts | 39 +++++++ .../hooks/use-lecture-homework-file-delete.ts | 45 ++++++++ vite.config.ts | 5 +- 26 files changed, 705 insertions(+), 98 deletions(-) create mode 100644 src/api/rest/homework-comment-serivce.ts create mode 100644 src/shared/helpers/extract-file-id.ts create mode 100644 src/shared/hooks/use-homework-comment-file-delete.ts create mode 100644 src/shared/hooks/use-homework-comment-file-get.ts create mode 100644 src/shared/hooks/use-homework-comment-file-upload.ts create mode 100644 src/shared/hooks/use-lecture-file-delete.ts create mode 100644 src/shared/hooks/use-lecture-homework-file-delete.ts diff --git a/.env.development b/.env.development index 80853aac..ea70b39b 100644 --- a/.env.development +++ b/.env.development @@ -12,7 +12,10 @@ VITE_HOMEWORK_FILE_DELETE_URI=/student/homework/:homeWorkId/file/:fileId VITE_LECTURE_FILE_UPLOAD_URI=/lecture/:lectureId/file VITE_LECTURE_FILE_GET_URI=/lecture/:lectureId/file/:fileId VITE_LECTURE_FILE_DELETE_URI=/lecture/:lectureId/file/:fileId -VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/lecture/homework/:lectureId/file -VITE_LECTURE_HOMEWORK_FILE_GET_URI=/lecture/homework/:lectureId/file/:fileId -VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/lecture/homework/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/lecture/:lectureId/homework/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/lecture/:lectureId/homework/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/lecture/:lectureId/homework/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI=/homework/comment/:commentId/file +VITE_HOMEWORK_COMMENT_FILE_GET_URI=/homework/comment/:commentId/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_DELETE_URI=/homework/comment/:commentId/file/:fileId VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" diff --git a/.env.production b/.env.production index 11bd0e2a..f3954bc0 100644 --- a/.env.production +++ b/.env.production @@ -12,6 +12,9 @@ VITE_HOMEWORK_FILE_DELETE_URI=/api/student/homework/:homeWorkId/file/:fileId VITE_LECTURE_FILE_UPLOAD_URI=/api/lecture/:lectureId/file VITE_LECTURE_FILE_GET_URI=/api/lecture/:lectureId/file/:fileId VITE_LECTURE_FILE_DELETE_URI=/api/lecture/:lectureId/file/:fileId -VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/api/lecture/homework/:lectureId/file -VITE_LECTURE_HOMEWORK_FILE_GET_URI=/api/lecture/homework/:lectureId/file/:fileId -VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/api/lecture/homework/:lectureId/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_UPLOAD_URI=/api/lecture/:lectureId/homework/file +VITE_LECTURE_HOMEWORK_FILE_GET_URI=/api/lecture/:lectureId/homework/file/:fileId +VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/api/lecture/:lectureId/homework/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI=/api/homework/comment/:commentId/file +VITE_HOMEWORK_COMMENT_FILE_GET_URI=/api/homework/comment/:commentId/file/:fileId +VITE_HOMEWORK_COMMENT_FILE_DELETE_URI=/api/homework/comment/:commentId/file/:fileId \ No newline at end of file diff --git a/src/api/rest/auth-service.ts b/src/api/rest/auth-service.ts index 9f06fc82..d4e5e678 100644 --- a/src/api/rest/auth-service.ts +++ b/src/api/rest/auth-service.ts @@ -1,8 +1,7 @@ import axios, { type AxiosResponse } from "axios"; +import { LOGIN_URI, LOGOUT_URI, REFRESH_TOKEN_URI } from "config"; import qs from "qs"; -import { LOGIN_URI, LOGOUT_URI, REFRESH_TOKEN_URI } from "../../config"; - export interface LoginResponse { username: string; password: string; diff --git a/src/api/rest/avatar-upload-service.ts b/src/api/rest/avatar-upload-service.ts index 006f5d4e..279714cd 100644 --- a/src/api/rest/avatar-upload-service.ts +++ b/src/api/rest/avatar-upload-service.ts @@ -1,6 +1,5 @@ import axios, { type AxiosResponse } from "axios"; - -import { AVATAR_UPLOAD_URI, AVATAR_DELETE_URI } from "../../config"; +import { AVATAR_DELETE_URI, AVATAR_UPLOAD_URI } from "config"; export interface AvatarUploadResponse { file: string | File; diff --git a/src/api/rest/homework-comment-serivce.ts b/src/api/rest/homework-comment-serivce.ts new file mode 100644 index 00000000..0b29dbdd --- /dev/null +++ b/src/api/rest/homework-comment-serivce.ts @@ -0,0 +1,71 @@ +import axios, { type AxiosResponse } from "axios"; +import { + HOMEWORK_COMMENT_FILE_UPLOAD_URI, + HOMEWORK_COMMENT_FILE_GET_URI, + HOMEWORK_COMMENT_FILE_DELETE_URI, +} from "config"; + +import { createUrlWithParams } from "shared/utils"; + +export interface HomeworkFileResponse { + id: string; + fileName: string; + contentType: string; + size: number; + creationDate: string; +} + +export default class HomeworkCommentFileService { + static uploadFile( + commentId: string, + file: File + ): Promise> { + const formData = new FormData(); + formData.append("file", file); + + const uploadFileUrl = createUrlWithParams( + HOMEWORK_COMMENT_FILE_UPLOAD_URI, + { + commentId, + } + ); + + return axios({ + method: "POST", + url: uploadFileUrl, + headers: { "Content-Type": "multipart/form-data" }, + data: formData, + }); + } + + static getFile( + commentId: string, + fileId: string + ): Promise> { + const getFileUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { + commentId, + fileId, + }); + + return axios({ + method: "GET", + url: getFileUrl, + responseType: "blob", + }); + } + + static deleteFile( + commentId: string, + fileId: string + ): Promise> { + const deleteUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_DELETE_URI, { + commentId, + fileId, + }); + + return axios({ + method: "DELETE", + url: deleteUrl, + }); + } +} diff --git a/src/api/rest/training-upload-service.ts b/src/api/rest/training-upload-service.ts index 2300778b..36c002cf 100644 --- a/src/api/rest/training-upload-service.ts +++ b/src/api/rest/training-upload-service.ts @@ -1,9 +1,8 @@ import axios, { type AxiosResponse } from "axios"; +import { TRAINING_DELETE_URI, TRAINING_UPLOAD_URI } from "config"; import { createUrlWithParams } from "shared/utils"; -import { TRAINING_DELETE_URI, TRAINING_UPLOAD_URI } from "../../config"; - export interface TrainingUploadResponse { file: string | File; } diff --git a/src/config.ts b/src/config.ts index 058966bb..5771aff5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,3 +22,9 @@ export const LECTURE_HOMEWORK_FILE_GET_URI = import.meta.env .VITE_LECTURE_HOMEWORK_FILE_GET_URI; export const LECTURE_HOMEWORK_FILE_DELETE_URI = import.meta.env .VITE_LECTURE_HOMEWORK_FILE_DELETE_URI; +export const HOMEWORK_COMMENT_FILE_UPLOAD_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI; +export const HOMEWORK_COMMENT_FILE_GET_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_GET_URI; +export const HOMEWORK_COMMENT_FILE_DELETE_URI = import.meta.env + .VITE_HOMEWORK_COMMENT_FILE_DELETE_URI; diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 4042267a..7e311b59 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -4,7 +4,7 @@ import { FC, useRef, useState } from "react"; import { SubmitHandler, useForm } from "react-hook-form"; import { Clear, Save } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; -import { LECTURE_FILE_GET_URI } from "config"; +import { LECTURE_FILE_GET_URI, LECTURE_HOMEWORK_FILE_GET_URI } from "config"; import { InputText } from "shared/components/form"; import { Editor } from "shared/components/text-editor"; @@ -13,7 +13,9 @@ import { UserRole } from "api/graphql/generated/graphql"; import { PendingFile } from "shared/components/text-editor/types"; import { createUrlWithParams } from "shared/utils"; import { + useLectureFileDelete, useLectureFileUpload, + useLectureHomeworkFileDelete, useLectureHomeworkFileUpload, } from "shared/hooks"; @@ -29,6 +31,7 @@ import { } from "./edit-lecture.styled"; import { IEditLecture, LectureInput } from "./edit-lecture.types"; import EditDescription from "../edit-description"; +import { extractFileId } from "shared/helpers"; const EditLecture: FC = ({ dataLecture, @@ -42,6 +45,8 @@ const EditLecture: FC = ({ const [pendingFiles, setPendingFiles] = useState([]); const { uploadLectureFile } = useLectureFileUpload(); const { uploadLectureHomeworkFile } = useLectureHomeworkFileUpload(); + const { deleteLectureFile } = useLectureFileDelete(); + const { deleteLectureHomeworkFile } = useLectureHomeworkFileDelete(); const [description, setDescription] = useState( dataLecture?.lecture?.description! @@ -92,7 +97,7 @@ const EditLecture: FC = ({ const uploadedFile = await uploadLectureHomeworkFile(file, lectureId); return { localUrl, - realUrl: createUrlWithParams(LECTURE_FILE_GET_URI, { + realUrl: createUrlWithParams(LECTURE_HOMEWORK_FILE_GET_URI, { lectureId, fileId: uploadedFile?.id!, }), @@ -153,6 +158,26 @@ const EditLecture: FC = ({ })(); }; + const handleDeleteLectureFiles = async (content: string) => { + const fileIds = extractFileId(content); + + for (const fileId of fileIds) { + if (lectureId) { + await deleteLectureFile(lectureId, fileId); + } + } + }; + + const handleDeleteHomeworkFiles = async (content: string) => { + const fileIds = extractFileId(content); + + for (const fileId of fileIds) { + if (lectureId) { + await deleteLectureHomeworkFile(lectureId, fileId); + } + } + }; + return ( @@ -190,6 +215,7 @@ const EditLecture: FC = ({ rteRef={rteRefContent} setPendingFiles={setPendingFiles} source="lecture" + handleDeleteFile={handleDeleteLectureFiles} /> @@ -201,6 +227,7 @@ const EditLecture: FC = ({ rteRef={rteRefContentHomeWork} setPendingFiles={setPendingFiles} source="lectureHomework" + handleDeleteFile={handleDeleteHomeworkFiles} /> diff --git a/src/shared/components/text-editor/comment-editor/comment-editor.tsx b/src/shared/components/text-editor/comment-editor/comment-editor.tsx index f78f6ed7..4d75ce1b 100644 --- a/src/shared/components/text-editor/comment-editor/comment-editor.tsx +++ b/src/shared/components/text-editor/comment-editor/comment-editor.tsx @@ -1,17 +1,30 @@ import { Lock, LockOpen, TextFields } from "@mui/icons-material"; import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; +import { EditorView } from "@tiptap/pm/view"; import { FC, useCallback, useState } from "react"; -import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; +import { + insertFiles, + insertImages, + LinkBubbleMenu, + RichTextEditor, +} from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; +import { extractFileId } from "shared/helpers"; import { EditorMenuControls } from "./ui"; import { fileListToImageFiles } from "../utils/file-list-to-image-files"; import useExtensions from "../hooks/use-extensions"; import { ITextEditor } from "../types"; -const CommentEditor: FC = ({ rteRef, content }) => { +const CommentEditor: FC = ({ + rteRef, + content, + setPendingFiles, + source, + handleDeleteFile, +}) => { const extensions = useExtensions({ placeholder: "Введите текст...", }); @@ -43,6 +56,81 @@ const CommentEditor: FC = ({ rteRef, content }) => { return pastedImageFiles.length > 0; }, []); + const handleNewImageFiles = useCallback( + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) return; + + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); + + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); + + const attributesForImageFiles = filesWithUrl.map( + ({ file, localUrl }) => ({ + src: localUrl, + alt: file.name, + }) + ); + + insertImages({ + images: attributesForImageFiles, + editor: rteRef.current.editor, + position: insertPosition, + }); + }, + [rteRef, setPendingFiles] + ); + + const handleNewFiles = useCallback( + (files: File[], insertPosition?: number): void => { + if (!rteRef.current?.editor) { + return; + } + + const filesWithUrl = files.map((file) => ({ + file, + localUrl: URL.createObjectURL(file), + source, + })); + + setPendingFiles?.((prev) => [...prev, ...filesWithUrl]); + + const attributesForFiles = filesWithUrl.map(({ localUrl, file }) => ({ + href: localUrl, + fileName: file.name, + })); + + insertFiles({ + files: attributesForFiles, + editor: rteRef.current.editor, + position: insertPosition, + }); + }, + [rteRef, setPendingFiles] + ); + + const handleKeyDown = useCallback( + (view: EditorView, event: { key: string }) => { + if (event.key === "Backspace" || event.key === "Delete") { + const content = rteRef.current?.editor?.getHTML(); + + if (content) { + const fileIds = extractFileId(content); + + if (fileIds.length > 0) { + handleDeleteFile?.(content); + } + } + } + + return false; + }, + [handleDeleteFile] + ); + return ( <> = ({ rteRef, content }) => { editorProps={{ handleDrop, handlePaste, + handleDOMEvents: { + keydown: handleKeyDown, + }, }} - renderControls={() => } + renderControls={() => ( + + )} RichTextFieldProps={{ variant: "outlined", MenuBarProps: { diff --git a/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx b/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx index 55dadbb9..0228adfc 100644 --- a/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx +++ b/src/shared/components/text-editor/comment-editor/ui/editor-menu-controls.tsx @@ -2,6 +2,8 @@ import { MenuButtonCodeBlock, MenuButtonEditLink, MenuButtonEmoji, + MenuButtonFileUpload, + MenuButtonImageUpload, MenuButtonRedo, MenuButtonUndo, MenuButtonYoutube, @@ -9,8 +11,18 @@ import { MenuDivider, } from "shared/lib/mui-tiptap/controls"; import { useResponsive } from "shared/hooks"; +import { Maybe } from "api/graphql/generated/graphql"; -export default function EditorMenuControls() { +interface EditorMenuControlsProps { + homeWorkId?: Maybe; + onUploadImageFiles: (files: File[]) => any; + onUploadFiles: (files: File[]) => any; +} + +export default function EditorMenuControls({ + onUploadImageFiles, + onUploadFiles, +}: EditorMenuControlsProps) { const { isDesktop } = useResponsive(); return ( @@ -29,6 +41,15 @@ export default function EditorMenuControls() { + + + {isDesktop && ( diff --git a/src/shared/components/text-editor/editor/editor.tsx b/src/shared/components/text-editor/editor/editor.tsx index 736d8be9..4ae59189 100644 --- a/src/shared/components/text-editor/editor/editor.tsx +++ b/src/shared/components/text-editor/editor/editor.tsx @@ -2,10 +2,12 @@ import { Lock, LockOpen, TextFields } from "@mui/icons-material"; import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; import { FC, useCallback, useState } from "react"; +import { EditorView } from "@tiptap/pm/view"; import { insertFiles, insertImages } from "shared/lib/mui-tiptap/utils"; import { LinkBubbleMenu, RichTextEditor } from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; +import { extractFileId } from "shared/helpers"; import { EditorMenuControls } from "./ui"; import { ITextEditor } from "../types"; @@ -17,7 +19,7 @@ const Editor: FC = ({ content, setPendingFiles, source, - deleteHomeworkFile, + handleDeleteFile, }) => { const extensions = useExtensions({ placeholder: "Введите текст...", @@ -136,13 +138,22 @@ const Editor: FC = ({ ); const handleKeyDown = useCallback( - (view: any, event: { key: string }) => { - // console.log("keydown event", event.key); - // if (event.key === "Backspace" || event.key === "Delete") { - // } + (view: EditorView, event: { key: string }) => { + if (event.key === "Backspace" || event.key === "Delete") { + const content = rteRef.current?.editor?.getHTML(); + + if (content) { + const fileIds = extractFileId(content); + + if (fileIds.length > 0) { + handleDeleteFile?.(content); + } + } + } + return false; }, - [deleteHomeworkFile] + [handleDeleteFile] ); return ( diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index f44071b7..3adc5e49 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -36,8 +36,5 @@ export interface ITextEditor { content?: Maybe; setPendingFiles?: React.Dispatch>; source: FileSourceType; - deleteHomeworkFile?: ( - homeWorkId: string, - fileId: string - ) => Promise>; + handleDeleteFile?: (content: string) => Promise; } diff --git a/src/shared/features/send-comment/container/send-comment-container.tsx b/src/shared/features/send-comment/container/send-comment-container.tsx index 323885aa..08be63e1 100644 --- a/src/shared/features/send-comment/container/send-comment-container.tsx +++ b/src/shared/features/send-comment/container/send-comment-container.tsx @@ -5,6 +5,7 @@ import { CommentsHomeWorkByHomeWorkQuery, useSendCommentMutation, Maybe, + useUpdateCommentMutation, } from "api/graphql/generated/graphql"; import { INDEX_OFFSET, @@ -18,11 +19,25 @@ import SendComment from "../view"; const SendCommentContainer: FC = (props) => { const { homeworkId } = props; - const [sendComment, { loading }] = useSendCommentMutation({ - update: (cache, { data }) => { - const newComment = data?.sendComment; - const existingComments: Maybe = - cache.readQuery({ + const [sendComment, { loading: loadingSendComment }] = useSendCommentMutation( + { + update: (cache, { data }) => { + const newComment = data?.sendComment; + const existingComments: Maybe = + cache.readQuery({ + query: CommentsHomeWorkByHomeWorkDocument, + variables: { + offset: QUERY_DEFAULTS.OFFSET, + limit: QUERY_DEFAULTS.LIMIT, + sort: { + field: "CREATION_DATE", + order: "DESC", + }, + homeWorkId: homeworkId, + }, + }); + + cache.writeQuery({ query: CommentsHomeWorkByHomeWorkDocument, variables: { offset: QUERY_DEFAULTS.OFFSET, @@ -33,41 +48,49 @@ const SendCommentContainer: FC = (props) => { }, homeWorkId: homeworkId, }, + data: { + commentsHomeWorkByHomeWork: { + ...existingComments?.commentsHomeWorkByHomeWork, + items: [ + newComment, + ...(existingComments?.commentsHomeWorkByHomeWork?.items || []), + ], + totalElements: + parseInt( + existingComments?.commentsHomeWorkByHomeWork?.totalElements, + PARSE_INT_RADIX + ) + INDEX_OFFSET, + }, + }, }); + }, + } + ); - cache.writeQuery({ - query: CommentsHomeWorkByHomeWorkDocument, - variables: { - offset: QUERY_DEFAULTS.OFFSET, - limit: QUERY_DEFAULTS.LIMIT, - sort: { - field: "CREATION_DATE", - order: "DESC", - }, - homeWorkId: homeworkId, - }, - data: { - commentsHomeWorkByHomeWork: { - ...existingComments?.commentsHomeWorkByHomeWork, - items: [ - newComment, - ...(existingComments?.commentsHomeWorkByHomeWork?.items || []), - ], - totalElements: - parseInt( - existingComments?.commentsHomeWorkByHomeWork?.totalElements, - PARSE_INT_RADIX - ) + INDEX_OFFSET, - }, - }, - }); - }, - }); + const [updateComment, { loading: loadingUpdateComment }] = + useUpdateCommentMutation({ + update: (cache, { data }) => { + const updateComment = data?.updateComment; + + if (updateComment) { + cache.modify({ + id: cache.identify(updateComment), + fields: { + content() { + return updateComment?.content; + }, + }, + }); + } + }, + }); return ( ); diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index 634c4669..eaeec885 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -1,45 +1,108 @@ import { FC, useRef, useState } from "react"; +import { HOMEWORK_COMMENT_FILE_GET_URI } from "config"; import { CommentEditor } from "shared/components/text-editor"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import SendButtons from "shared/components/send-buttons"; +import { PendingFile } from "shared/components/text-editor/types"; +import { useHomeworkCommentFileUpload } from "shared/hooks"; +import { createUrlWithParams } from "shared/utils"; import { ISendComment } from "./send-comment.types"; import { StyledBox, StyledFormHelperText } from "./send-comment.styled"; const SendComment: FC = (props) => { - const { sendComment, loading, homeworkId } = props; + const { + sendComment, + loadingUpdateComment, + loadingSendComment, + homeworkId, + updateComment, + } = props; const rteRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState([]); + const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); const [error, setError] = useState(""); const handleSendComment = async () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; - - if (homeworkId && content.trim() !== "" && content.trim() !== "

") { - try { - await sendComment({ - variables: { - homeWorkId: homeworkId, - content, - }, - }); - setError(""); - rteRef.current?.editor?.commands.clearContent(); - } catch (error) { - setError("Произошла ошибка при отправке комментария."); - } - } else { + if (!rteRef.current?.editor) return; + + let content = rteRef.current.editor.getHTML().trim(); + if (!content || content === "

") { setError("Введите текст"); + return; + } + + try { + await sendComment({ + variables: { + homeWorkId: homeworkId!, + content, + }, + onCompleted: async (response) => { + const commentId = response?.sendComment?.id; + + if (!commentId) { + return; + } + + const uploadPromises = pendingFiles.map( + async ({ file, localUrl }) => { + const uploadedFile = await uploadHomeworkCommentFile( + file, + commentId + ); + + const realUrl = createUrlWithParams( + HOMEWORK_COMMENT_FILE_GET_URI, + { + commentId, + fileId: uploadedFile?.id!, + } + ); + + return { localUrl, realUrl }; + } + ); + + const results = await Promise.all(uploadPromises); + + results.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + + await updateComment({ + variables: { + id: commentId, + content, + }, + }); + + setPendingFiles([]); + setError(""); + rteRef.current?.editor?.commands.clearContent(); + }, + }); + } catch (error) { + setError("Произошла ошибка при отправке комментария."); } }; return ( - + {error && {error}} - + ); }; diff --git a/src/shared/features/send-comment/view/send-comment.types.ts b/src/shared/features/send-comment/view/send-comment.types.ts index 0a9b1a3d..690c9618 100644 --- a/src/shared/features/send-comment/view/send-comment.types.ts +++ b/src/shared/features/send-comment/view/send-comment.types.ts @@ -1,7 +1,13 @@ -import { SendCommentMutationFn, Maybe } from "api/graphql/generated/graphql"; +import { + SendCommentMutationFn, + Maybe, + UpdateCommentMutationFn, +} from "api/graphql/generated/graphql"; export interface ISendComment { sendComment: SendCommentMutationFn; - loading: boolean; + updateComment: UpdateCommentMutationFn; + loadingUpdateComment: boolean; + loadingSendComment: boolean; homeworkId?: Maybe; } diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index aaa4d5ba..1790ac28 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -1,9 +1,17 @@ import { FC, useRef, useState } from "react"; +import { HOMEWORK_COMMENT_FILE_GET_URI } from "config"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { CommentEditor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; import { useComment } from "shared/hooks/use-comment"; +import { + useHomeworkCommentFileDelete, + useHomeworkCommentFileUpload, +} from "shared/hooks"; +import { PendingFile } from "shared/components/text-editor/types"; +import { createUrlWithParams } from "shared/utils"; +import { extractFileId } from "shared/helpers"; import { IUpdateComment } from "./update-comment.types"; import { @@ -16,24 +24,58 @@ const UpdateComment: FC = (props) => { const { loading, updateComment, commentId, content } = props; const rteRef = useRef(null); const [error, setError] = useState(""); + const [pendingFiles, setPendingFiles] = useState([]); + const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); const { setSelectedComment } = useComment(); + const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); - const handleUpdateComment = () => { - const content = rteRef.current?.editor?.getHTML() ?? ""; + const handleUpdateComment = async () => { + if (!rteRef.current?.editor || !commentId) return; - if (commentId && content.trim() !== "" && content.trim() !== "

") { - updateComment({ - variables: { - id: commentId, - content: rteRef.current?.editor?.getHTML() ?? "", - }, + let content = rteRef.current.editor.getHTML().trim(); + if (!content || content === "

") { + setError("Введите текст"); + return; + } + + try { + const uploadPromises = pendingFiles.map(async ({ file, localUrl }) => { + const uploadedFile = await uploadHomeworkCommentFile(file, commentId); + + const realUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { + commentId, + fileId: uploadedFile?.id!, + }); + + return { localUrl, realUrl }; + }); + + const results = await Promise.all(uploadPromises); + + results.forEach(({ localUrl, realUrl }) => { + content = content.replaceAll(localUrl, realUrl); + }); + await updateComment({ + variables: { id: commentId, content }, onCompleted: () => { setSelectedComment(null); + setPendingFiles([]); + setError(""); + rteRef.current?.editor?.commands.clearContent(); }, }); - setError(""); - } else { - setError("Введите текст"); + } catch (err) { + setError("Произошла ошибка при редактировании комментария"); + } + }; + + const handleDeleteFile = async (content: string) => { + const fileIds = extractFileId(content); + + for (const fileId of fileIds) { + if (commentId) { + await deleteHomeworkCommentFile(commentId, fileId); + } } }; @@ -41,7 +83,13 @@ const UpdateComment: FC = (props) => {
- + {error && {error}} = (props) => { } }; + const handleDeleteFile = async (content: string) => { + const fileIds = extractFileId(content); + + for (const fileId of fileIds) { + if (homeWorkId) { + await deleteHomeworkFile(homeWorkId, fileId); + } + } + }; + return ( @@ -75,7 +86,7 @@ const UpdateHomework: FC = (props) => { rteRef={rteRef} setPendingFiles={setPendingFiles} source="studentHomework" - deleteHomeworkFile={deleteHomeworkFile} + handleDeleteFile={handleDeleteFile} /> {error && {error}} diff --git a/src/shared/helpers/extract-file-id.ts b/src/shared/helpers/extract-file-id.ts new file mode 100644 index 00000000..e75c271e --- /dev/null +++ b/src/shared/helpers/extract-file-id.ts @@ -0,0 +1,12 @@ +export const extractFileId = (content: string): string[] => { + const regex = /\/file\/(\d+)/g; + const matches = []; + let match; + + // Ищем все совпадения по регулярному выражению + while ((match = regex.exec(content)) !== null) { + matches.push(match[1]); // Добавляем найденный fileId в список + } + + return matches; +}; diff --git a/src/shared/helpers/index.ts b/src/shared/helpers/index.ts index c5d6c439..fa5430c8 100644 --- a/src/shared/helpers/index.ts +++ b/src/shared/helpers/index.ts @@ -7,3 +7,4 @@ export { isColumnHighlight } from "./is-column-highlight"; export { getUpdatedAllowedColumns } from "./get-updated-allowed-columns"; export { getAllowedColumns } from "./get-allowed-columns"; export { generateUniqueId } from "./generate-unique-id"; +export { extractFileId } from "./extract-file-id"; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 42c53df5..b4f9a9fa 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -13,4 +13,9 @@ export { useHomeworkFileGet } from "./use-homework-file-get"; export { useHomeworkFileUpload } from "./use-homework-file-upload"; export { useLectureFileGet } from "./use-lecture-file-get"; export { useLectureFileUpload } from "./use-lecture-file-upload"; +export { useLectureFileDelete } from "./use-lecture-file-delete"; export { useLectureHomeworkFileUpload } from "./use-lecture-homework-file-upload"; +export { useLectureHomeworkFileDelete } from "./use-lecture-homework-file-delete"; +export { useHomeworkCommentFileGet } from "./use-homework-comment-file-get"; +export { useHomeworkCommentFileUpload } from "./use-homework-comment-file-upload"; +export { useHomeworkCommentFileDelete } from "./use-homework-comment-file-delete"; diff --git a/src/shared/hooks/use-homework-comment-file-delete.ts b/src/shared/hooks/use-homework-comment-file-delete.ts new file mode 100644 index 00000000..c49c1db1 --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-delete.ts @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; + +export const useHomeworkCommentFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteHomeworkCommentFile = async ( + commentId: string, + fileId: string + ) => { + setDeleting(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.deleteFile( + commentId, + fileId + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteHomeworkCommentFile, deleting, error }; +}; diff --git a/src/shared/hooks/use-homework-comment-file-get.ts b/src/shared/hooks/use-homework-comment-file-get.ts new file mode 100644 index 00000000..ea7d42ec --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-get.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; +import { Maybe } from "api/graphql/generated/graphql"; + +export const useHomeworkCommentFileGet = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState>(null); + + const getHomeworkCommentFile = async (commentId: string, fileId: string) => { + setLoading(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.getFile( + commentId, + fileId + ); + setLoading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось получить файл`, { variant: "error" }); + setLoading(false); + return null; + } + }; + + return { getHomeworkCommentFile, loading, error }; +}; diff --git a/src/shared/hooks/use-homework-comment-file-upload.ts b/src/shared/hooks/use-homework-comment-file-upload.ts new file mode 100644 index 00000000..9b2882f1 --- /dev/null +++ b/src/shared/hooks/use-homework-comment-file-upload.ts @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import HomeworkCommentFileService from "api/rest/homework-comment-serivce"; +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useHomeworkCommentFileUpload = () => { + const [uploading, setUploading] = useState(false); + const [error, setError] = useState>(null); + + const uploadHomeworkCommentFile = async (file: File, commentId: string) => { + setUploading(true); + setError(null); + + try { + const response = await HomeworkCommentFileService.uploadFile( + commentId, + file + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setUploading(false); + enqueueSnackbar(`Файл успешно загружен`, { + variant: "success", + }); + return response.data; + } else { + setUploading(false); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось загрузить файл`, { variant: "error" }); + setUploading(false); + return null; + } + }; + + return { uploadHomeworkCommentFile, uploading, error }; +}; diff --git a/src/shared/hooks/use-lecture-file-delete.ts b/src/shared/hooks/use-lecture-file-delete.ts new file mode 100644 index 00000000..f9969971 --- /dev/null +++ b/src/shared/hooks/use-lecture-file-delete.ts @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; +import LectureFileService from "api/rest/lecture-file-service"; + +export const useLectureFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteLectureFile = async (homeWorkId: string, fileId: string) => { + setDeleting(true); + setError(null); + + try { + const response = await LectureFileService.deleteFile(homeWorkId, fileId); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteLectureFile, deleting, error }; +}; diff --git a/src/shared/hooks/use-lecture-homework-file-delete.ts b/src/shared/hooks/use-lecture-homework-file-delete.ts new file mode 100644 index 00000000..5a76d8cb --- /dev/null +++ b/src/shared/hooks/use-lecture-homework-file-delete.ts @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { enqueueSnackbar } from "notistack"; + +import LectureHomeworkFileService from "api/rest/lecture-homework-file-service"; +import { Maybe } from "api/graphql/generated/graphql"; +import { RESPONSE_STATUS } from "shared/constants"; + +export const useLectureHomeworkFileDelete = () => { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState>(null); + + const deleteLectureHomeworkFile = async ( + homeWorkId: string, + fileId: string + ) => { + setDeleting(true); + setError(null); + + try { + const response = await LectureHomeworkFileService.deleteFile( + homeWorkId, + fileId + ); + + if (response.status === RESPONSE_STATUS.SUCCESSFUL) { + setDeleting(false); + enqueueSnackbar(`Файл успешно удален`, { + variant: "success", + }); + return response.data; + } else { + setDeleting(false); + enqueueSnackbar(`Не удалось удалить файл`); + return null; + } + } catch (err) { + setError(err as Error); + enqueueSnackbar(`Не удалось удалить файл`); + setDeleting(false); + return null; + } + }; + + return { deleteLectureHomeworkFile, deleting, error }; +}; diff --git a/vite.config.ts b/vite.config.ts index a8b43559..d17c0492 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,9 +15,10 @@ export default ({ mode }: any) => { "^/refreshtoken": API_URL, "^/upload/avatar": API_URL, "^/upload/training/.*": API_URL, - "^/homework/student/homework/.*": API_URL, "^/lecture/.*": API_URL, - "^/lecture/homework/.*": API_URL, + "^/student/homework/.*": API_URL, + "^/lecture/.*/homework/.*": API_URL, + "^/homework/comment/.*": API_URL, }; return defineConfig({ From 69b802869852dae295fcd9d060658b9595260529 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Wed, 2 Apr 2025 20:59:02 +0300 Subject: [PATCH 11/21] =?UTF-8?q?QAGDEV-681=20-=20=D0=9F=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BA=20S3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/edit-training/views/edit-lecture/edit-lecture.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 7e311b59..9186fdee 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -18,6 +18,7 @@ import { useLectureHomeworkFileDelete, useLectureHomeworkFileUpload, } from "shared/hooks"; +import { extractFileId } from "shared/helpers"; import { SelectLectors } from "../../containers"; import { @@ -31,7 +32,6 @@ import { } from "./edit-lecture.styled"; import { IEditLecture, LectureInput } from "./edit-lecture.types"; import EditDescription from "../edit-description"; -import { extractFileId } from "shared/helpers"; const EditLecture: FC = ({ dataLecture, From 742b80f882a94684fe5cbdcd9cada1ffc466f064 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 6 Apr 2025 13:42:31 +0300 Subject: [PATCH 12/21] =?UTF-8?q?QAGDEV-707=20-=20=D0=9D=D0=B5=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=82=D0=B0=D1=89=D0=B8=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BC=D0=B0?= =?UTF-8?q?=D1=88=D0=BD=D0=B5=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=20=D0=B1=D0=BE=D1=80=D0=B4=D0=B5=20(?= =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B5=20->=20=D0=9D=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../homework/send-hom-work-to-check.graphql | 44 +++++++++++++++++++ .../homeworks/homeworks-container.tsx | 2 +- .../kanban-lecture/views/board/board.tsx | 2 +- .../homeworks/homeworks-container.tsx | 2 +- .../hooks/use-update-homework-status.ts | 4 +- .../kanban-mentor/views/board/board.tsx | 2 +- .../views/status-select/status-select.tsx | 2 +- .../status-select/status-select.types.ts | 2 +- .../homeworks/homeworks-container.tsx | 2 +- .../kanban-student/views/board/board.tsx | 2 +- .../views/mobile-board/mobile-board.types.ts | 2 +- .../homeworks/homeworks-container.tsx | 3 +- .../hooks/use-update-homework-status.ts | 5 +-- src/features/kanban/views/board/board.tsx | 2 +- .../views/status-select/status-select.tsx | 2 +- .../components/status-text/status-text.tsx | 2 +- src/shared/constants/constants.ts | 2 +- .../homeworks/homeworks-container.tsx | 2 +- .../views/board/board.tsx | 2 +- .../homeworks/homeworks-container.tsx | 2 +- .../views/board/board.tsx | 2 +- .../container/send-homework-container.tsx | 6 +++ .../send-homework/view/send-homework.tsx | 14 +++++- .../send-homework/view/send-homework.types.ts | 3 ++ src/shared/helpers/format-status.ts | 2 +- src/shared/helpers/get-allowed-columns.ts | 2 +- 26 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 src/api/graphql/homework/send-hom-work-to-check.graphql diff --git a/src/api/graphql/homework/send-hom-work-to-check.graphql b/src/api/graphql/homework/send-hom-work-to-check.graphql new file mode 100644 index 00000000..35c147c9 --- /dev/null +++ b/src/api/graphql/homework/send-hom-work-to-check.graphql @@ -0,0 +1,44 @@ +mutation sendHomeWorkToCheck($homeWorkId: ID!) { + sendHomeWorkToCheck(homeWorkId: $homeWorkId) { + id + lecture { + id + subject + description + contentHomeWork + } + training { + techStack + } + answer + student { + id + firstName + avatar + lastName + rating { + rating + } + } + mentor { + id + firstName + avatar + lastName + rating { + rating + } + } + filesHomeWork { + id + creationDate + fileName + contentType + size + } + creationDate + updateDate + startCheckingDate + endCheckingDate + } +} diff --git a/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx b/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx index 955f07e6..d33cc641 100644 --- a/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-lecture/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-lecture/views/board/board.tsx b/src/features/kanban-lecture/views/board/board.tsx index 73447aa7..1ab4673e 100644 --- a/src/features/kanban-lecture/views/board/board.tsx +++ b/src/features/kanban-lecture/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx b/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx index 8823833d..79e74ebf 100644 --- a/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-mentor/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { status: StudentHomeWorkStatus.New }, + filter: { status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-mentor/hooks/use-update-homework-status.ts b/src/features/kanban-mentor/hooks/use-update-homework-status.ts index 13aa127c..ba3a060b 100644 --- a/src/features/kanban-mentor/hooks/use-update-homework-status.ts +++ b/src/features/kanban-mentor/hooks/use-update-homework-status.ts @@ -42,7 +42,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, @@ -79,7 +79,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, diff --git a/src/features/kanban-mentor/views/board/board.tsx b/src/features/kanban-mentor/views/board/board.tsx index fda66f8d..8cc5365a 100644 --- a/src/features/kanban-mentor/views/board/board.tsx +++ b/src/features/kanban-mentor/views/board/board.tsx @@ -46,7 +46,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-mentor/views/status-select/status-select.tsx b/src/features/kanban-mentor/views/status-select/status-select.tsx index 2eba26f9..bc9fa73f 100644 --- a/src/features/kanban-mentor/views/status-select/status-select.tsx +++ b/src/features/kanban-mentor/views/status-select/status-select.tsx @@ -24,7 +24,7 @@ const StatusSelect: FC = ({ currentStatus, homeworkId }) => { currentStatus?: Maybe ) => { switch (currentStatus) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: return [StudentHomeWorkStatus.InReview]; case StudentHomeWorkStatus.InReview: return [ diff --git a/src/features/kanban-mentor/views/status-select/status-select.types.ts b/src/features/kanban-mentor/views/status-select/status-select.types.ts index 424aa9b7..10f6dc43 100644 --- a/src/features/kanban-mentor/views/status-select/status-select.types.ts +++ b/src/features/kanban-mentor/views/status-select/status-select.types.ts @@ -11,7 +11,7 @@ export interface IStatusSelect { export const states = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/features/kanban-student/containers/homeworks/homeworks-container.tsx b/src/features/kanban-student/containers/homeworks/homeworks-container.tsx index a45ceb7d..7ff6e313 100644 --- a/src/features/kanban-student/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban-student/containers/homeworks/homeworks-container.tsx @@ -37,7 +37,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/features/kanban-student/views/board/board.tsx b/src/features/kanban-student/views/board/board.tsx index d9d54647..26016ab3 100644 --- a/src/features/kanban-student/views/board/board.tsx +++ b/src/features/kanban-student/views/board/board.tsx @@ -36,7 +36,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban-student/views/mobile-board/mobile-board.types.ts b/src/features/kanban-student/views/mobile-board/mobile-board.types.ts index 7ac66b57..c72c224b 100644 --- a/src/features/kanban-student/views/mobile-board/mobile-board.types.ts +++ b/src/features/kanban-student/views/mobile-board/mobile-board.types.ts @@ -14,7 +14,7 @@ export interface IMobileBoard { export const states = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/features/kanban/containers/homeworks/homeworks-container.tsx b/src/features/kanban/containers/homeworks/homeworks-container.tsx index 07d1caab..d9d0b3ac 100644 --- a/src/features/kanban/containers/homeworks/homeworks-container.tsx +++ b/src/features/kanban/containers/homeworks/homeworks-container.tsx @@ -43,8 +43,7 @@ const HomeworksContainer: FC = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, - mentorId: undefined, + status: StudentHomeWorkStatus.Review, }, }, }); diff --git a/src/features/kanban/hooks/use-update-homework-status.ts b/src/features/kanban/hooks/use-update-homework-status.ts index 23b833bf..a0648896 100644 --- a/src/features/kanban/hooks/use-update-homework-status.ts +++ b/src/features/kanban/hooks/use-update-homework-status.ts @@ -46,8 +46,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, - mentorId: undefined, + status: StudentHomeWorkStatus.Review, }, }, }); @@ -83,7 +82,7 @@ const useUpdateHomeworkStatus = () => { }, filter: { ...filterObject, - status: StudentHomeWorkStatus.New, + status: StudentHomeWorkStatus.Review, mentorId: undefined, }, }, diff --git a/src/features/kanban/views/board/board.tsx b/src/features/kanban/views/board/board.tsx index fda66f8d..8cc5365a 100644 --- a/src/features/kanban/views/board/board.tsx +++ b/src/features/kanban/views/board/board.tsx @@ -46,7 +46,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/features/kanban/views/status-select/status-select.tsx b/src/features/kanban/views/status-select/status-select.tsx index df5bcc51..44de06a9 100644 --- a/src/features/kanban/views/status-select/status-select.tsx +++ b/src/features/kanban/views/status-select/status-select.tsx @@ -24,7 +24,7 @@ const StatusSelect: FC = ({ currentStatus, homeworkId }) => { currentStatus?: Maybe ) => { switch (currentStatus) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: return [StudentHomeWorkStatus.InReview]; case StudentHomeWorkStatus.InReview: return [ diff --git a/src/shared/components/status-text/status-text.tsx b/src/shared/components/status-text/status-text.tsx index e9e20ca8..3252a8a9 100644 --- a/src/shared/components/status-text/status-text.tsx +++ b/src/shared/components/status-text/status-text.tsx @@ -20,7 +20,7 @@ const StatusText: FC = ({ status }) => { let statusText; switch (status) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: icon = ; statusText = "Новые"; break; diff --git a/src/shared/constants/constants.ts b/src/shared/constants/constants.ts index 99409720..e6c8f15b 100644 --- a/src/shared/constants/constants.ts +++ b/src/shared/constants/constants.ts @@ -47,7 +47,7 @@ export const HOMEWORKS_QUERY_DEFAULTS = { export const STATES = [ { - value: "NEW", + value: "REVIEW", Icon: Clock, text: "Новые", }, diff --git a/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx b/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx index ca1eb73e..bf0e06e6 100644 --- a/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx +++ b/src/shared/features/kanban-profile-mentor/containers/homeworks/homeworks-container.tsx @@ -41,7 +41,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { status: StudentHomeWorkStatus.New }, + filter: { status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/shared/features/kanban-profile-mentor/views/board/board.tsx b/src/shared/features/kanban-profile-mentor/views/board/board.tsx index fb44a61d..d5e86818 100644 --- a/src/shared/features/kanban-profile-mentor/views/board/board.tsx +++ b/src/shared/features/kanban-profile-mentor/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx b/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx index 5c9b9bce..e8b86135 100644 --- a/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx +++ b/src/shared/features/kanban-profile-student/containers/homeworks/homeworks-container.tsx @@ -41,7 +41,7 @@ const HomeworksContainer: FC = () => { field: StudentHomeWorkSortField.CreationDate, order: Order.Desc, }, - filter: { ...filterObject, status: StudentHomeWorkStatus.New }, + filter: { ...filterObject, status: StudentHomeWorkStatus.Review }, }, }); diff --git a/src/shared/features/kanban-profile-student/views/board/board.tsx b/src/shared/features/kanban-profile-student/views/board/board.tsx index fb44a61d..d5e86818 100644 --- a/src/shared/features/kanban-profile-student/views/board/board.tsx +++ b/src/shared/features/kanban-profile-student/views/board/board.tsx @@ -35,7 +35,7 @@ const Board: FC = ({ setColumns([ createColumnItem( STATUS_COLUMN.NEW, - StudentHomeWorkStatus.New, + StudentHomeWorkStatus.Review, newItems as StudentHomeWorkDto[], newTotalElements ), diff --git a/src/shared/features/send-homework/container/send-homework-container.tsx b/src/shared/features/send-homework/container/send-homework-container.tsx index 3f0e5ae6..849ca25c 100644 --- a/src/shared/features/send-homework/container/send-homework-container.tsx +++ b/src/shared/features/send-homework/container/send-homework-container.tsx @@ -6,6 +6,7 @@ import { HomeWorkByLectureAndTrainingQuery, Maybe, useCreateHomeWorkToCheckMutation, + useSendHomeWorkToCheckMutation, useUpdateHomeworkMutation, } from "api/graphql/generated/graphql"; @@ -66,11 +67,16 @@ const SendHomeworkContainer: FC = () => { }, }); + const [sendHomeWorkToCheck, { loading: loadingSendHomeWorkToCheck }] = + useSendHomeWorkToCheckMutation(); + return ( ); diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 6b76b259..baeecd59 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -15,7 +15,9 @@ import { StyledBox, StyledFormHelperText } from "./send-homework.styled"; const SendHomework: FC = (props) => { const { createHomeWorkToCheck, + sendHomeWorkToCheck, loadingCreateHomeWorkToCheck, + loadingSendHomeWorkToCheck, loadingUpdateHomework, updateHomework, } = props; @@ -75,6 +77,12 @@ const SendHomework: FC = (props) => { }, }); + await sendHomeWorkToCheck({ + variables: { + homeWorkId, + }, + }); + setPendingFiles([]); setError(""); rteRef.current?.editor?.commands.clearContent(); @@ -97,7 +105,11 @@ const SendHomework: FC = (props) => { diff --git a/src/shared/features/send-homework/view/send-homework.types.ts b/src/shared/features/send-homework/view/send-homework.types.ts index 8ac6872f..bd7dc54d 100644 --- a/src/shared/features/send-homework/view/send-homework.types.ts +++ b/src/shared/features/send-homework/view/send-homework.types.ts @@ -1,13 +1,16 @@ import { CreateHomeWorkToCheckMutationFn, Maybe, + SendHomeWorkToCheckMutationFn, UpdateCommentMutationFn, } from "api/graphql/generated/graphql"; export interface ISendHomeWork { createHomeWorkToCheck: CreateHomeWorkToCheckMutationFn; updateHomework: UpdateCommentMutationFn; + sendHomeWorkToCheck: SendHomeWorkToCheckMutationFn; loadingCreateHomeWorkToCheck: boolean; loadingUpdateHomework: boolean; + loadingSendHomeWorkToCheck: boolean; homeWorkId?: Maybe; } diff --git a/src/shared/helpers/format-status.ts b/src/shared/helpers/format-status.ts index c698aecf..c8522d1a 100644 --- a/src/shared/helpers/format-status.ts +++ b/src/shared/helpers/format-status.ts @@ -4,7 +4,7 @@ export const formatStatus = (status: string) => { return "Принято"; case "IN_REVIEW": return "На проверке"; - case "NEW": + case "REVIEW": return "Новые"; case "NOT_APPROVED": return "Не принято"; diff --git a/src/shared/helpers/get-allowed-columns.ts b/src/shared/helpers/get-allowed-columns.ts index d90e027d..3d911a98 100644 --- a/src/shared/helpers/get-allowed-columns.ts +++ b/src/shared/helpers/get-allowed-columns.ts @@ -6,7 +6,7 @@ export const getAllowedColumns = (title: StudentHomeWorkStatus) => { let allowedColumns: string[] = []; switch (title) { - case StudentHomeWorkStatus.New: + case StudentHomeWorkStatus.Review: allowedColumns = [STATUS_COLUMN.IN_REVIEW]; break; case StudentHomeWorkStatus.InReview: From 9fa7f48bb0b65a4b9af3a09d86082cc28b3ed820 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 27 Apr 2025 17:38:41 +0300 Subject: [PATCH 13/21] =?UTF-8?q?QAGDEV-717=20-=20=D0=9F=D1=80=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0/?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=20s3=20-=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=81=D0=B5,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BA=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/edit-lecture/edit-lecture.tsx | 43 ++++++------ .../comment-editor/comment-editor.tsx | 27 +------- .../components/text-editor/editor/editor.tsx | 28 ++------ .../text-editor/hooks/use-extensions.ts | 8 ++- .../components/text-editor/types/index.ts | 2 +- .../send-comment/view/send-comment.tsx | 22 +++++- .../send-homework/view/send-homework.tsx | 20 +++++- .../update-comment/view/update-comment.tsx | 23 ++++--- .../update-homework/view/update-homework.tsx | 33 +++++---- src/shared/helpers/extract-file-id.ts | 12 ---- src/shared/helpers/index.ts | 1 - .../extensions/file-deletion-tracker.ts | 68 +++++++++++++++++++ 12 files changed, 179 insertions(+), 108 deletions(-) delete mode 100644 src/shared/helpers/extract-file-id.ts create mode 100644 src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 9186fdee..cb8697ad 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -18,7 +18,6 @@ import { useLectureHomeworkFileDelete, useLectureHomeworkFileUpload, } from "shared/hooks"; -import { extractFileId } from "shared/helpers"; import { SelectLectors } from "../../containers"; import { @@ -43,6 +42,13 @@ const EditLecture: FC = ({ const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); const [pendingFiles, setPendingFiles] = useState([]); + const [deletedLectureFileIds, setDeletedLectureFileIds] = useState( + [] + ); + const [deletedHomeworkFileIds, setDeletedHomeworkFileIds] = useState< + string[] + >([]); + const { uploadLectureFile } = useLectureFileUpload(); const { uploadLectureHomeworkFile } = useLectureHomeworkFileUpload(); const { deleteLectureFile } = useLectureFileDelete(); @@ -130,7 +136,14 @@ const EditLecture: FC = ({ variables: { input: submissionData, }, - onCompleted: () => { + onCompleted: async () => { + for (const fileId of deletedLectureFileIds) { + await deleteLectureFile(lectureId, fileId); + } + for (const fileId of deletedHomeworkFileIds) { + await deleteLectureHomeworkFile(lectureId, fileId); + } + enqueueSnackbar("Урок обновлен", { variant: "success" }); }, onError: () => { @@ -142,6 +155,8 @@ const EditLecture: FC = ({ }); setPendingFiles([]); + setDeletedLectureFileIds([]); + setDeletedHomeworkFileIds([]); rteRefContent.current?.editor?.commands.clearContent(); rteRefContentHomeWork.current?.editor?.commands.clearContent(); }; @@ -158,24 +173,12 @@ const EditLecture: FC = ({ })(); }; - const handleDeleteLectureFiles = async (content: string) => { - const fileIds = extractFileId(content); - - for (const fileId of fileIds) { - if (lectureId) { - await deleteLectureFile(lectureId, fileId); - } - } + const handleDeleteLectureFile = async (fileId: string) => { + setDeletedLectureFileIds((prev) => [...prev, fileId]); }; - const handleDeleteHomeworkFiles = async (content: string) => { - const fileIds = extractFileId(content); - - for (const fileId of fileIds) { - if (lectureId) { - await deleteLectureHomeworkFile(lectureId, fileId); - } - } + const handleDeleteHomeworkFile = async (fileId: string) => { + setDeletedHomeworkFileIds((prev) => [...prev, fileId]); }; return ( @@ -215,7 +218,7 @@ const EditLecture: FC = ({ rteRef={rteRefContent} setPendingFiles={setPendingFiles} source="lecture" - handleDeleteFile={handleDeleteLectureFiles} + handleDeleteFile={handleDeleteLectureFile} /> @@ -227,7 +230,7 @@ const EditLecture: FC = ({ rteRef={rteRefContentHomeWork} setPendingFiles={setPendingFiles} source="lectureHomework" - handleDeleteFile={handleDeleteHomeworkFiles} + handleDeleteFile={handleDeleteHomeworkFile} /> diff --git a/src/shared/components/text-editor/comment-editor/comment-editor.tsx b/src/shared/components/text-editor/comment-editor/comment-editor.tsx index 4d75ce1b..f74564bb 100644 --- a/src/shared/components/text-editor/comment-editor/comment-editor.tsx +++ b/src/shared/components/text-editor/comment-editor/comment-editor.tsx @@ -1,7 +1,6 @@ import { Lock, LockOpen, TextFields } from "@mui/icons-material"; import { Box, Stack } from "@mui/material"; import type { EditorOptions } from "@tiptap/core"; -import { EditorView } from "@tiptap/pm/view"; import { FC, useCallback, useState } from "react"; import { @@ -11,7 +10,6 @@ import { RichTextEditor, } from "shared/lib/mui-tiptap"; import { TableBubbleMenu, MenuButton } from "shared/lib/mui-tiptap/controls"; -import { extractFileId } from "shared/helpers"; import { EditorMenuControls } from "./ui"; import { fileListToImageFiles } from "../utils/file-list-to-image-files"; @@ -27,6 +25,9 @@ const CommentEditor: FC = ({ }) => { const extensions = useExtensions({ placeholder: "Введите текст...", + onFileDelete: async (fileId: string) => { + await handleDeleteFile?.(fileId); + }, }); const [isEditable, setIsEditable] = useState(true); const [showMenuBar, setShowMenuBar] = useState(true); @@ -112,25 +113,6 @@ const CommentEditor: FC = ({ [rteRef, setPendingFiles] ); - const handleKeyDown = useCallback( - (view: EditorView, event: { key: string }) => { - if (event.key === "Backspace" || event.key === "Delete") { - const content = rteRef.current?.editor?.getHTML(); - - if (content) { - const fileIds = extractFileId(content); - - if (fileIds.length > 0) { - handleDeleteFile?.(content); - } - } - } - - return false; - }, - [handleDeleteFile] - ); - return ( <> = ({ editorProps={{ handleDrop, handlePaste, - handleDOMEvents: { - keydown: handleKeyDown, - }, }} renderControls={() => ( = ({ }) => { const extensions = useExtensions({ placeholder: "Введите текст...", + onFileDelete: async (fileId: string) => { + await handleDeleteFile?.(fileId); + }, }); + const [isEditable, setIsEditable] = useState(true); const [showMenuBar, setShowMenuBar] = useState(true); @@ -137,25 +139,6 @@ const Editor: FC = ({ [handleNewImageFiles, handleNewFiles] ); - const handleKeyDown = useCallback( - (view: EditorView, event: { key: string }) => { - if (event.key === "Backspace" || event.key === "Delete") { - const content = rteRef.current?.editor?.getHTML(); - - if (content) { - const fileIds = extractFileId(content); - - if (fileIds.length > 0) { - handleDeleteFile?.(content); - } - } - } - - return false; - }, - [handleDeleteFile] - ); - return ( <> = ({ editorProps={{ handleDrop, handlePaste, - handleDOMEvents: { - keydown: handleKeyDown, - }, }} renderControls={() => ( ({ export type UseExtensionsOptions = { placeholder?: string; + onFileDelete?: (fileId: string) => void; }; const CustomLinkExtension = Link.extend({ @@ -204,6 +206,7 @@ const CustomResizableImage = ResizableImage.extend({ export default function useExtensions({ placeholder, + onFileDelete, }: UseExtensionsOptions = {}): EditorOptions["extensions"] { return useMemo(() => { return [ @@ -281,6 +284,9 @@ export default function useExtensions({ History, FileNode, + FileDeletionTracker.configure({ + onFileDelete, + }), ]; - }, [placeholder]); + }, [placeholder, onFileDelete]); } diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index 3adc5e49..f25f0fdd 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -36,5 +36,5 @@ export interface ITextEditor { content?: Maybe; setPendingFiles?: React.Dispatch>; source: FileSourceType; - handleDeleteFile?: (content: string) => Promise; + handleDeleteFile?: (fileId: string) => Promise; } diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index eaeec885..fb477ba2 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -5,7 +5,10 @@ import { CommentEditor } from "shared/components/text-editor"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import SendButtons from "shared/components/send-buttons"; import { PendingFile } from "shared/components/text-editor/types"; -import { useHomeworkCommentFileUpload } from "shared/hooks"; +import { + useHomeworkCommentFileDelete, + useHomeworkCommentFileUpload, +} from "shared/hooks"; import { createUrlWithParams } from "shared/utils"; import { ISendComment } from "./send-comment.types"; @@ -21,7 +24,9 @@ const SendComment: FC = (props) => { } = props; const rteRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); + const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); const [error, setError] = useState(""); const handleSendComment = async () => { @@ -78,6 +83,10 @@ const SendComment: FC = (props) => { }, }); + for (const fileId of deletedFileIds) { + await deleteHomeworkCommentFile(commentId, fileId); + } + setPendingFiles([]); setError(""); rteRef.current?.editor?.commands.clearContent(); @@ -88,6 +97,16 @@ const SendComment: FC = (props) => { } }; + const handleDeleteFile = async (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + setDeletedFileIds((prev) => [...prev, fileId]); + } + }; + return (
@@ -95,6 +114,7 @@ const SendComment: FC = (props) => { rteRef={rteRef} source="comment" setPendingFiles={setPendingFiles} + handleDeleteFile={handleDeleteFile} /> {error && {error}} diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index baeecd59..93ee1e0f 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -5,7 +5,7 @@ import { HOMEWORK_FILE_GET_URI } from "config"; import { createUrlWithParams } from "shared/utils"; import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; -import { useHomeworkFileUpload } from "shared/hooks"; +import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; import { PendingFile } from "shared/components/text-editor/types"; import SendButtons from "shared/components/send-buttons"; @@ -25,7 +25,9 @@ const SendHomework: FC = (props) => { const rteRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); const { uploadHomeworkFile } = useHomeworkFileUpload(); + const { deleteHomeworkFile } = useHomeworkFileDelete(); const [error, setError] = useState(""); const handleSendHomeWork = () => { @@ -77,6 +79,10 @@ const SendHomework: FC = (props) => { }, }); + for (const id of deletedFileIds) { + await deleteHomeworkFile(homeWorkId, id); + } + await sendHomeWorkToCheck({ variables: { homeWorkId, @@ -84,6 +90,7 @@ const SendHomework: FC = (props) => { }); setPendingFiles([]); + setDeletedFileIds([]); setError(""); rteRef.current?.editor?.commands.clearContent(); }, @@ -93,12 +100,23 @@ const SendHomework: FC = (props) => { } }; + const handleDeleteFile = async (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + setDeletedFileIds((prev) => [...prev, fileId]); + } + }; + return ( {error && {error}} diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index 1790ac28..22311810 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -11,7 +11,6 @@ import { } from "shared/hooks"; import { PendingFile } from "shared/components/text-editor/types"; import { createUrlWithParams } from "shared/utils"; -import { extractFileId } from "shared/helpers"; import { IUpdateComment } from "./update-comment.types"; import { @@ -25,6 +24,7 @@ const UpdateComment: FC = (props) => { const rteRef = useRef(null); const [error, setError] = useState(""); const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); const { setSelectedComment } = useComment(); const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); @@ -57,9 +57,13 @@ const UpdateComment: FC = (props) => { }); await updateComment({ variables: { id: commentId, content }, - onCompleted: () => { + onCompleted: async () => { + for (const fileId of deletedFileIds) { + await deleteHomeworkCommentFile(commentId, fileId); + } setSelectedComment(null); setPendingFiles([]); + setDeletedFileIds([]); setError(""); rteRef.current?.editor?.commands.clearContent(); }, @@ -69,13 +73,14 @@ const UpdateComment: FC = (props) => { } }; - const handleDeleteFile = async (content: string) => { - const fileIds = extractFileId(content); - - for (const fileId of fileIds) { - if (commentId) { - await deleteHomeworkCommentFile(commentId, fileId); - } + const handleDeleteFile = async (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + // серверный файл — добавить в список на удаление + setDeletedFileIds((prev) => [...prev, fileId]); } }; diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index 3d404119..74f337cd 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -7,7 +7,6 @@ import SendButtons from "shared/components/send-buttons"; import { createUrlWithParams } from "shared/utils"; import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; import { PendingFile } from "shared/components/text-editor/types"; -import { extractFileId } from "shared/helpers"; import { IUpdateHomeWork } from "./update-homework.types"; import { @@ -22,6 +21,7 @@ const UpdateHomework: FC = (props) => { const rteRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); const [error, setError] = useState(""); const { uploadHomeworkFile } = useHomeworkFileUpload(); const { deleteHomeworkFile } = useHomeworkFileDelete(); @@ -55,25 +55,30 @@ const UpdateHomework: FC = (props) => { await updateHomework({ variables: { id: homeWorkId, content }, - onCompleted: () => { - setOpenHomeWorkEdit(false); - setPendingFiles([]); - setError(""); - rteRef.current?.editor?.commands.clearContent(); - }, }); + + for (const id of deletedFileIds) { + await deleteHomeworkFile(homeWorkId, id); + } + + setOpenHomeWorkEdit(false); + setPendingFiles([]); + setDeletedFileIds([]); + setError(""); + rteRef.current?.editor?.commands.clearContent(); } catch (err) { + console.error(err); setError("Произошла ошибка при редактировании д/з."); } }; - const handleDeleteFile = async (content: string) => { - const fileIds = extractFileId(content); - - for (const fileId of fileIds) { - if (homeWorkId) { - await deleteHomeworkFile(homeWorkId, fileId); - } + const handleDeleteFile = async (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => + prev.filter((pending) => pending.localUrl !== fileId) + ); + } else { + setDeletedFileIds((prev) => [...prev, fileId]); } }; diff --git a/src/shared/helpers/extract-file-id.ts b/src/shared/helpers/extract-file-id.ts deleted file mode 100644 index e75c271e..00000000 --- a/src/shared/helpers/extract-file-id.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const extractFileId = (content: string): string[] => { - const regex = /\/file\/(\d+)/g; - const matches = []; - let match; - - // Ищем все совпадения по регулярному выражению - while ((match = regex.exec(content)) !== null) { - matches.push(match[1]); // Добавляем найденный fileId в список - } - - return matches; -}; diff --git a/src/shared/helpers/index.ts b/src/shared/helpers/index.ts index fa5430c8..c5d6c439 100644 --- a/src/shared/helpers/index.ts +++ b/src/shared/helpers/index.ts @@ -7,4 +7,3 @@ export { isColumnHighlight } from "./is-column-highlight"; export { getUpdatedAllowedColumns } from "./get-updated-allowed-columns"; export { getAllowedColumns } from "./get-allowed-columns"; export { generateUniqueId } from "./generate-unique-id"; -export { extractFileId } from "./extract-file-id"; diff --git a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts new file mode 100644 index 00000000..34028361 --- /dev/null +++ b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts @@ -0,0 +1,68 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "prosemirror-state"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +export const FileDeletionTracker = Extension.create<{ + onFileDelete: (fileIdOrBlob: string) => void; +}>({ + name: "fileDeletionTracker", + + addOptions() { + return { + onFileDelete: () => {}, + }; + }, + + addProseMirrorPlugins() { + const onFileDelete = this.options?.onFileDelete; + + return [ + new Plugin({ + key: new PluginKey("file-deletion-tracker"), + appendTransaction(transactions, oldState, newState) { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return; + + const oldFileIds = collectFileIds(oldState.doc); + const newFileIds = collectFileIds(newState.doc); + + const deletedIds = oldFileIds.filter( + (id) => !newFileIds.includes(id) + ); + + if (typeof onFileDelete === "function") { + deletedIds.forEach((id) => { + onFileDelete(id); + }); + } + + return undefined; + }, + }), + ]; + }, +}); + +function collectFileIds(doc: ProseMirrorNode): string[] { + const ids: string[] = []; + + doc.descendants((node: any) => { + if ( + (node.type.name === "image" || node.type.name === "file") && + (node.attrs?.src || node.attrs?.href) + ) { + const url = node.attrs.src || node.attrs.href; + + if (url.startsWith("blob:")) { + ids.push(url); + } else { + const match = url.match(/\/file\/(\d+)/); + if (match) { + ids.push(match[1]); + } + } + } + }); + + return ids; +} From fadbe355b67c827ada0809566cae9c9d8380ab52 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 27 Apr 2025 18:18:33 +0300 Subject: [PATCH 14/21] =?UTF-8?q?QAGDEV-703=20-=20=D0=9F=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D0=BE=D0=B7=D0=B2?= =?UTF-8?q?=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=20=D1=82=D0=B5=D0=BB=D0=B5=D1=84=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/graphql/user/user.graphql | 1 + .../edit-training/views/edit-lecture/edit-lecture.tsx | 5 ++--- src/shared/components/form/input-phone/input-phone.tsx | 7 ++----- src/shared/components/text-editor/types/index.ts | 2 +- src/shared/features/send-comment/view/send-comment.tsx | 2 +- src/shared/features/send-homework/view/send-homework.tsx | 2 +- src/shared/features/update-comment/view/update-comment.tsx | 2 +- .../features/update-homework/view/update-homework.tsx | 2 +- .../lib/mui-tiptap/extensions/file-deletion-tracker.ts | 4 ++-- 9 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/api/graphql/user/user.graphql b/src/api/graphql/user/user.graphql index 94106d40..452dffbe 100644 --- a/src/api/graphql/user/user.graphql +++ b/src/api/graphql/user/user.graphql @@ -11,6 +11,7 @@ query user { linkedin avatar creationDate + phoneNumber rating { rating } diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index cb8697ad..2f3040af 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -172,12 +172,11 @@ const EditLecture: FC = ({ navigate("/"); })(); }; - - const handleDeleteLectureFile = async (fileId: string) => { + const handleDeleteLectureFile = (fileId: string) => { setDeletedLectureFileIds((prev) => [...prev, fileId]); }; - const handleDeleteHomeworkFile = async (fileId: string) => { + const handleDeleteHomeworkFile = (fileId: string) => { setDeletedHomeworkFileIds((prev) => [...prev, fileId]); }; diff --git a/src/shared/components/form/input-phone/input-phone.tsx b/src/shared/components/form/input-phone/input-phone.tsx index f66e7884..fb6182c5 100644 --- a/src/shared/components/form/input-phone/input-phone.tsx +++ b/src/shared/components/form/input-phone/input-phone.tsx @@ -21,6 +21,8 @@ const InputPhone = ({ onChange(newInputValue)} getOptionLabel={(option) => { if (typeof option === "string") { return option; @@ -41,12 +43,7 @@ const InputPhone = ({ )} renderInput={(params) => ( ; setPendingFiles?: React.Dispatch>; source: FileSourceType; - handleDeleteFile?: (fileId: string) => Promise; + handleDeleteFile?: (fileId: string) => void; } diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index fb477ba2..8ba6e52a 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -97,7 +97,7 @@ const SendComment: FC = (props) => { } }; - const handleDeleteFile = async (fileId: string) => { + const handleDeleteFile = (fileId: string) => { if (fileId.startsWith("blob:")) { setPendingFiles((prev) => prev.filter((pending) => pending.localUrl !== fileId) diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 93ee1e0f..f391c027 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -100,7 +100,7 @@ const SendHomework: FC = (props) => { } }; - const handleDeleteFile = async (fileId: string) => { + const handleDeleteFile = (fileId: string) => { if (fileId.startsWith("blob:")) { setPendingFiles((prev) => prev.filter((pending) => pending.localUrl !== fileId) diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index 22311810..dbd91755 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -73,7 +73,7 @@ const UpdateComment: FC = (props) => { } }; - const handleDeleteFile = async (fileId: string) => { + const handleDeleteFile = (fileId: string) => { if (fileId.startsWith("blob:")) { setPendingFiles((prev) => prev.filter((pending) => pending.localUrl !== fileId) diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index 74f337cd..e3cadb51 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -72,7 +72,7 @@ const UpdateHomework: FC = (props) => { } }; - const handleDeleteFile = async (fileId: string) => { + const handleDeleteFile = (fileId: string) => { if (fileId.startsWith("blob:")) { setPendingFiles((prev) => prev.filter((pending) => pending.localUrl !== fileId) diff --git a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts index 34028361..acec83b7 100644 --- a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts +++ b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts @@ -21,7 +21,7 @@ export const FileDeletionTracker = Extension.create<{ key: new PluginKey("file-deletion-tracker"), appendTransaction(transactions, oldState, newState) { const docChanged = transactions.some((tr) => tr.docChanged); - if (!docChanged) return; + if (!docChanged) return null; const oldFileIds = collectFileIds(oldState.doc); const newFileIds = collectFileIds(newState.doc); @@ -36,7 +36,7 @@ export const FileDeletionTracker = Extension.create<{ }); } - return undefined; + return null; }, }), ]; From 5b23a2733ea362b52f7042d7c37ed93a8e058450 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 28 Apr 2025 20:58:23 +0300 Subject: [PATCH 15/21] =?UTF-8?q?QAGDEV-704=20-=20=D0=9D=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D1=8B=D1=85=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/authorization/context/auth-context.tsx | 4 +++- src/features/header/containers/user/user-container.tsx | 2 +- .../containers/lecture-detail/lecture-detail-container.tsx | 6 +++--- .../training-lectures/training-lectures-container.tsx | 2 +- .../profile/containers/user-info/user-info-container.tsx | 2 +- .../training-lectures/training-lectures-container.tsx | 4 ++-- .../containers/user-by-id/user-by-id-container.tsx | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/features/authorization/context/auth-context.tsx b/src/features/authorization/context/auth-context.tsx index 6d346528..77a015d8 100644 --- a/src/features/authorization/context/auth-context.tsx +++ b/src/features/authorization/context/auth-context.tsx @@ -1,6 +1,7 @@ import { FC, ReactNode, createContext, useContext, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useSnackbar } from "notistack"; +import { client } from "api"; import { userRolesVar } from "cache"; import AuthService from "api/rest/auth-service"; @@ -96,9 +97,10 @@ export const AuthProvider: FC = ({ children }) => { const logout = async () => { setIsLoading(true); await AuthService.logout() - .then((response) => { + .then(async (response) => { if (response.status === RESPONSE_STATUS.SUCCESSFUL) { localStorage.removeItem("isAuth"); + await client.clearStore(); setIsLoading(false); navigate(ROUTES.AUTHORIZATION); } else { diff --git a/src/features/header/containers/user/user-container.tsx b/src/features/header/containers/user/user-container.tsx index 524370b7..3f8ee3fc 100644 --- a/src/features/header/containers/user/user-container.tsx +++ b/src/features/header/containers/user/user-container.tsx @@ -13,7 +13,7 @@ const UserContainer: FC = () => { onCompleted: (data) => { userIdVar(data?.user?.id); }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loading) return ; diff --git a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx index 43636231..274ba6d5 100644 --- a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx +++ b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx @@ -20,20 +20,20 @@ const LectureDetailContainer: FC = () => { const { data: dataLecture, loading: loadingLecture } = useLectureQuery({ variables: { id: lectureId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataTrainingLectures, loading: loadingTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataLectureHomework, loading: loadingLectureHomeWork } = useLectureHomeWorkQuery({ variables: { lectureId: lectureId! }, skip: !tariffHomework, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if ( diff --git a/src/features/lecture-detail/containers/training-lectures/training-lectures-container.tsx b/src/features/lecture-detail/containers/training-lectures/training-lectures-container.tsx index 8ba43b1d..9ebfc9b9 100644 --- a/src/features/lecture-detail/containers/training-lectures/training-lectures-container.tsx +++ b/src/features/lecture-detail/containers/training-lectures/training-lectures-container.tsx @@ -10,7 +10,7 @@ const TrainingLecturesContainer: FC = () => { const { trainingId } = useParams(); const { data: dataTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); return ; diff --git a/src/features/profile/containers/user-info/user-info-container.tsx b/src/features/profile/containers/user-info/user-info-container.tsx index c6b2b752..50ca35f1 100644 --- a/src/features/profile/containers/user-info/user-info-container.tsx +++ b/src/features/profile/containers/user-info/user-info-container.tsx @@ -9,7 +9,7 @@ import UserInfo from "../../views/user-info"; const UserInfoContainer: FC = () => { const { loading, data } = useUserQuery({ - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loading) return ; diff --git a/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx b/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx index dbf22631..fe360201 100644 --- a/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx +++ b/src/features/training-lectures/containers/training-lectures/training-lectures-container.tsx @@ -17,11 +17,11 @@ const TrainingLecturesContainer: FC = () => { const { data: dataTrainingLectures, loading: loadingTrainingLectures } = useTrainingLecturesQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); const { data: dataTraining, loading: loadingTraining } = useTrainingQuery({ variables: { id: trainingId! }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); if (loadingTrainingLectures || loadingTraining) return ; diff --git a/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx b/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx index c5d152cd..2e1a537a 100644 --- a/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx +++ b/src/features/user-detail/containers/user-by-id/user-by-id-container.tsx @@ -14,7 +14,7 @@ const UserByIdContainer: FC = () => { variables: { id: userId, }, - fetchPolicy: FETCH_POLICY.CACHE_FIRST, + fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, onError: () => { return ; }, From fea12ea477a0b42d6b16aa7867c21e5a35b3b77e Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Thu, 1 May 2025 18:00:04 +0300 Subject: [PATCH 16/21] =?UTF-8?q?QAGDEV-716=20-=20=D0=94=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=B2=D0=B5=D1=81=D1=8C=20?= =?UTF-8?q?=D1=84=D1=80=D0=B5=D0=B9=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/kanban/constants.ts | 5 --- .../kanban/views/board/board.styled.ts | 32 +++++++------------ .../views/desktop-board/desktop-board.tsx | 6 ++-- .../homework-details.styled.tsx | 13 ++++---- 4 files changed, 22 insertions(+), 34 deletions(-) diff --git a/src/features/kanban/constants.ts b/src/features/kanban/constants.ts index 5baa5191..c3ea81af 100644 --- a/src/features/kanban/constants.ts +++ b/src/features/kanban/constants.ts @@ -16,11 +16,6 @@ export const STANDARD_QUERY_DEFAULTS = { LIMIT: 100, }; -export const UI_CONSTANTS = { - MIN_COLUMN_WIDTH: "65%", - MAX_COLUMN_WIDTH: "100%", -}; - export const ROUTES = { KANBAN: "/kanban", }; diff --git a/src/features/kanban/views/board/board.styled.ts b/src/features/kanban/views/board/board.styled.ts index 354f6843..61607f39 100644 --- a/src/features/kanban/views/board/board.styled.ts +++ b/src/features/kanban/views/board/board.styled.ts @@ -1,17 +1,14 @@ import { Box, Button, Stack, StepLabel, Stepper } from "@mui/material"; import { styled } from "@mui/system"; -import { UI_CONSTANTS } from "../../constants"; - -interface IColumnBox { - showHomeworkDetails: boolean; - isUpLg: boolean; -} - -export const StyledWrapper = styled(Box)({ - display: "flex", - minWidth: "100%", -}); +export const StyledWrapper = styled(Box)<{ showHomeworkDetails: boolean }>( + ({ showHomeworkDetails }) => ({ + display: "grid", + gridTemplateColumns: showHomeworkDetails ? "65% 1fr" : "100%", + width: "100%", + height: "100vh", + }) +); export const StyledStack = styled(Stack)(({ theme }) => ({ flexDirection: "row", @@ -52,12 +49,7 @@ export const StyledStepperButton = styled(Button)({ minWidth: "5px", }); -export const StyledColumnBox = styled(Box, { - shouldForwardProp: (prop) => - !["showHomeworkDetails", "isUpLg"].includes(prop as string), -})(({ showHomeworkDetails, isUpLg }) => ({ - width: - showHomeworkDetails && isUpLg - ? UI_CONSTANTS.MIN_COLUMN_WIDTH - : UI_CONSTANTS.MAX_COLUMN_WIDTH, -})); +export const StyledColumnBox = styled(Box)({ + height: "100%", + overflow: "auto", +}); diff --git a/src/features/kanban/views/desktop-board/desktop-board.tsx b/src/features/kanban/views/desktop-board/desktop-board.tsx index c4ab41ff..b64eb012 100644 --- a/src/features/kanban/views/desktop-board/desktop-board.tsx +++ b/src/features/kanban/views/desktop-board/desktop-board.tsx @@ -64,10 +64,10 @@ const DesktopBoard: FC = ({ ); return ( - + {columns?.map((column, index) => ( diff --git a/src/features/kanban/views/homework-details/homework-details.styled.tsx b/src/features/kanban/views/homework-details/homework-details.styled.tsx index e91a8604..37e6c2d9 100644 --- a/src/features/kanban/views/homework-details/homework-details.styled.tsx +++ b/src/features/kanban/views/homework-details/homework-details.styled.tsx @@ -10,17 +10,18 @@ import { import OpenInNewIcon from "@mui/icons-material/OpenInNew"; export const StyledHomeworkDetails = styled(Box)(({ theme }) => ({ - position: "relative", - maxWidth: "33vw", + height: "100%", + overflowY: "auto", backgroundColor: alpha(theme.palette.app.secondary, 0.2), - marginLeft: "10px", + display: "flex", + flexDirection: "column", + marginTop: "15px", })); export const StyledBox = styled(Box)(({ theme }) => ({ - marginTop: "20px", padding: "35px 25px", - maxHeight: "calc(100vh - 217px)", - overflowY: "scroll", + overflowY: "auto", + flex: 1, backgroundColor: theme.palette.app.lightGrey, })); From 6e79f238d56d6c986fe6c6a8d837988190a4bb4e Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Mon, 9 Jun 2025 11:35:38 +0300 Subject: [PATCH 17/21] =?UTF-8?q?QAGDEV-721=20-=20=D0=A3=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B5=D0=B3=D0=BE=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20command+Z=20/=20cntrl+Z?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/edit-lecture/edit-lecture.tsx | 119 ++++++++++++------ .../text-editor/hooks/use-extensions.ts | 30 +++-- .../components/text-editor/types/index.ts | 2 +- .../send-comment/view/send-comment.tsx | 76 +++++++---- .../send-homework/view/send-homework.tsx | 48 +++++-- .../update-comment/view/update-comment.tsx | 85 +++++++------ .../update-homework/view/update-homework.tsx | 80 ++++++------ src/shared/hooks/index.ts | 1 + .../hooks/use-rich-text-file-manager.ts | 105 ++++++++++++++++ .../extensions/file-deletion-tracker.ts | 27 +--- .../mui-tiptap/extensions/file-node-view.tsx | 81 ++++++++++++ .../lib/mui-tiptap/utils/blob-url-to-file.ts | 14 +++ .../lib/mui-tiptap/utils/collect-file-ids.ts | 25 ++++ .../lib/mui-tiptap/utils/find-node-by-url.ts | 21 ++++ src/shared/lib/mui-tiptap/utils/index.ts | 3 + 15 files changed, 528 insertions(+), 189 deletions(-) create mode 100644 src/shared/hooks/use-rich-text-file-manager.ts create mode 100644 src/shared/lib/mui-tiptap/extensions/file-node-view.tsx create mode 100644 src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts create mode 100644 src/shared/lib/mui-tiptap/utils/collect-file-ids.ts create mode 100644 src/shared/lib/mui-tiptap/utils/find-node-by-url.ts diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 2f3040af..8ae8e7fd 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -8,7 +8,12 @@ import { LECTURE_FILE_GET_URI, LECTURE_HOMEWORK_FILE_GET_URI } from "config"; import { InputText } from "shared/components/form"; import { Editor } from "shared/components/text-editor"; -import { RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { + blobUrlToFile, + collectFileIds, + findNodeByUrl, + RichTextEditorRef, +} from "shared/lib/mui-tiptap"; import { UserRole } from "api/graphql/generated/graphql"; import { PendingFile } from "shared/components/text-editor/types"; import { createUrlWithParams } from "shared/utils"; @@ -66,64 +71,98 @@ const EditLecture: FC = ({ const onSubmit: SubmitHandler = async (data) => { const { speakers, ...restData } = data; + const emails = speakers?.map((s) => s?.email); + if (!lectureId) return; - const emails = speakers?.map((speaker) => speaker?.email); + let content = rteRefContent.current?.editor?.getHTML().trim() || ""; + let contentHomework = + rteRefContentHomeWork.current?.editor?.getHTML().trim() || ""; - let content = rteRefContent.current?.editor?.getHTML().trim(); - let contentHomework = rteRefContentHomeWork.current?.editor - ?.getHTML() - .trim(); + const editorContent = rteRefContent.current?.editor!; + const editorHomework = rteRefContentHomeWork.current?.editor!; - if (!lectureId) { - return; - } + const contentBlobUrls = Array.from( + content.matchAll(/(blob:[^"'\s>]+)/g) + ).map((m) => m[1]); + const homeworkBlobUrls = Array.from( + contentHomework.matchAll(/(blob:[^"'\s>]+)/g) + ).map((m) => m[1]); - const lectureFiles = pendingFiles.filter( - (file) => file.source === "lecture" + const recoveredLecture = await Promise.all( + contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map(async (url) => { + const node = findNodeByUrl(editorContent.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + const file = await blobUrlToFile(url, fileName); + return { file, localUrl: url, source: "lecture" as const }; + }) ); - const homeworkFiles = pendingFiles.filter( - (file) => file.source === "lectureHomework" + + const recoveredHomework = await Promise.all( + homeworkBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map(async (url) => { + const node = findNodeByUrl(editorHomework.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + const file = await blobUrlToFile(url, fileName); + return { file, localUrl: url, source: "lectureHomework" as const }; + }) ); - const lectureUploadPromises = lectureFiles.map( - async ({ file, localUrl }) => { - const uploadedFile = await uploadLectureFile(file, lectureId); + const allFiles = [ + ...pendingFiles, + ...recoveredLecture, + ...recoveredHomework, + ]; + const lectureFiles = allFiles.filter((f) => f.source === "lecture"); + const homeworkFiles = allFiles.filter( + (f) => f.source === "lectureHomework" + ); + + const uploadedLectureFiles = await Promise.all( + lectureFiles.map(async ({ file, localUrl }) => { + const uploaded = await uploadLectureFile(file, lectureId); return { localUrl, realUrl: createUrlWithParams(LECTURE_FILE_GET_URI, { lectureId, - fileId: uploadedFile?.id!, + fileId: uploaded?.id!, }), }; - } + }) ); - const lectureHomeworkUploadPromises = homeworkFiles.map( - async ({ file, localUrl }) => { - const uploadedFile = await uploadLectureHomeworkFile(file, lectureId); + const uploadedHomeworkFiles = await Promise.all( + homeworkFiles.map(async ({ file, localUrl }) => { + const uploaded = await uploadLectureHomeworkFile(file, lectureId); return { localUrl, realUrl: createUrlWithParams(LECTURE_HOMEWORK_FILE_GET_URI, { lectureId, - fileId: uploadedFile?.id!, + fileId: uploaded?.id!, }), }; - } - ); - - const uploadedLectureFiles = await Promise.all(lectureUploadPromises); - const uploadedLectureHomeworkFiles = await Promise.all( - lectureHomeworkUploadPromises + }) ); uploadedLectureFiles.forEach(({ localUrl, realUrl }) => { - content = content?.replaceAll(localUrl, realUrl); + content = content.replaceAll(localUrl, realUrl); }); - uploadedLectureHomeworkFiles.forEach(({ localUrl, realUrl }) => { - contentHomework = contentHomework?.replaceAll(localUrl, realUrl); + uploadedHomeworkFiles.forEach(({ localUrl, realUrl }) => { + contentHomework = contentHomework.replaceAll(localUrl, realUrl); }); + const contentNode = rteRefContent.current?.editor?.state.doc; + const contentHomeworkNode = + rteRefContentHomeWork.current?.editor?.state.doc; + + const currentFileIds = [ + ...collectFileIds(contentNode!), + ...collectFileIds(contentHomeworkNode!), + ]; + const submissionData = { ...restData, speakers: emails, @@ -133,24 +172,24 @@ const EditLecture: FC = ({ }; await updateLecture({ - variables: { - input: submissionData, - }, + variables: { input: submissionData }, onCompleted: async () => { for (const fileId of deletedLectureFileIds) { - await deleteLectureFile(lectureId, fileId); + if (!currentFileIds.includes(fileId)) { + await deleteLectureFile(lectureId, fileId); + } } + for (const fileId of deletedHomeworkFileIds) { - await deleteLectureHomeworkFile(lectureId, fileId); + if (!currentFileIds.includes(fileId)) { + await deleteLectureHomeworkFile(lectureId, fileId); + } } enqueueSnackbar("Урок обновлен", { variant: "success" }); }, onError: () => { - enqueueSnackbar( - "Не удалось обновить данные. Пожалуйста, попробуйте снова", - { variant: "error" } - ); + enqueueSnackbar("Ошибка при обновлении", { variant: "error" }); }, }); diff --git a/src/shared/components/text-editor/hooks/use-extensions.ts b/src/shared/components/text-editor/hooks/use-extensions.ts index 45f9cee1..c3df3a75 100644 --- a/src/shared/components/text-editor/hooks/use-extensions.ts +++ b/src/shared/components/text-editor/hooks/use-extensions.ts @@ -31,6 +31,7 @@ import { TextAlign } from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; import { Underline } from "@tiptap/extension-underline"; import { Youtube } from "@tiptap/extension-youtube"; +import { ReactNodeViewRenderer } from "@tiptap/react"; import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import { useMemo } from "react"; import { common, createLowlight } from "lowlight"; @@ -43,6 +44,7 @@ import { } from "shared/lib/mui-tiptap/extensions"; import { HeadingWithAnchor } from "shared/lib/mui-tiptap/hooks"; import { FileDeletionTracker } from "shared/lib/mui-tiptap/extensions/file-deletion-tracker"; +import FileNodeView from "shared/lib/mui-tiptap/extensions/file-node-view"; import { mentionSuggestionOptions } from "../utils/mention-suggestion-options"; @@ -106,7 +108,7 @@ const Iframe = Node.create({ }, }); -export const FileNode = Node.create({ +export const FileNode = Node.create({ name: "file", group: "inline", @@ -127,36 +129,44 @@ export const FileNode = Node.create({ parseHTML() { return [ { - tag: "a[data-file]", + tag: "file-node", + getAttrs: (el) => { + if (!(el instanceof HTMLElement)) return false; + + return { + href: el.getAttribute("href"), + fileName: el.getAttribute("fileName") || el.textContent, + }; + }, }, ]; }, renderHTML({ HTMLAttributes }) { return [ - "a", + "file-node", mergeAttributes(HTMLAttributes, { - "data-file": "", - href: HTMLAttributes.href, - download: HTMLAttributes.fileName, - target: "_blank", + "data-file-node": "true", }), - HTMLAttributes.fileName || "Download file", ]; }, addCommands() { return { setFile: - (options) => + (attrs) => ({ commands }) => { return commands.insertContent({ type: this.name, - attrs: options, + attrs, }); }, }; }, + + addNodeView() { + return ReactNodeViewRenderer(FileNodeView); + }, }); export type UseExtensionsOptions = { diff --git a/src/shared/components/text-editor/types/index.ts b/src/shared/components/text-editor/types/index.ts index b6cd89ff..46c17213 100644 --- a/src/shared/components/text-editor/types/index.ts +++ b/src/shared/components/text-editor/types/index.ts @@ -18,7 +18,7 @@ export type FileSourceType = export type PendingFile = { file: File; localUrl: string; - source: FileSourceType; + source?: FileSourceType; }; export type SuggestionListRef = { diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index 8ba6e52a..5056a08d 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -2,7 +2,7 @@ import { FC, useRef, useState } from "react"; import { HOMEWORK_COMMENT_FILE_GET_URI } from "config"; import { CommentEditor } from "shared/components/text-editor"; -import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { collectFileIds, type RichTextEditorRef } from "shared/lib/mui-tiptap"; import SendButtons from "shared/components/send-buttons"; import { PendingFile } from "shared/components/text-editor/types"; import { @@ -10,6 +10,8 @@ import { useHomeworkCommentFileUpload, } from "shared/hooks"; import { createUrlWithParams } from "shared/utils"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; import { ISendComment } from "./send-comment.types"; import { StyledBox, StyledFormHelperText } from "./send-comment.styled"; @@ -22,6 +24,7 @@ const SendComment: FC = (props) => { homeworkId, updateComment, } = props; + const rteRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); const [deletedFileIds, setDeletedFileIds] = useState([]); @@ -30,10 +33,11 @@ const SendComment: FC = (props) => { const [error, setError] = useState(""); const handleSendComment = async () => { - if (!rteRef.current?.editor) return; + const editor = rteRef.current?.editor; + if (!editor || !homeworkId) return; - let content = rteRef.current.editor.getHTML().trim(); - if (!content || content === "

") { + let html = editor.getHTML().trim(); + if (!html || html === "

") { setError("Введите текст"); return; } @@ -41,58 +45,78 @@ const SendComment: FC = (props) => { try { await sendComment({ variables: { - homeWorkId: homeworkId!, - content, + homeWorkId: homeworkId, + content: html, }, onCompleted: async (response) => { const commentId = response?.sendComment?.id; + if (!commentId) return; + + const contentBlobUrls = Array.from( + html.matchAll(/(blob:[^"'\s>]+)/g) + ).map((match) => match[1]); + + const recoveredBlobs = contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map((url) => { + const node = findNodeByUrl(editor.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + return { file: blobUrlToFile(url, fileName), localUrl: url }; + }); + + const resolvedRecoveredFiles = await Promise.all( + recoveredBlobs.map(async ({ file, localUrl }) => ({ + localUrl, + file: await file, + })) + ); - if (!commentId) { - return; - } - - const uploadPromises = pendingFiles.map( - async ({ file, localUrl }) => { - const uploadedFile = await uploadHomeworkCommentFile( - file, - commentId - ); + const allFilesToUpload = [...pendingFiles, ...resolvedRecoveredFiles]; + const uploadResults = await Promise.all( + allFilesToUpload.map(async ({ file, localUrl }) => { + const uploaded = await uploadHomeworkCommentFile(file, commentId); const realUrl = createUrlWithParams( HOMEWORK_COMMENT_FILE_GET_URI, { commentId, - fileId: uploadedFile?.id!, + fileId: uploaded?.id!, } ); return { localUrl, realUrl }; - } + }) ); - const results = await Promise.all(uploadPromises); - - results.forEach(({ localUrl, realUrl }) => { - content = content.replaceAll(localUrl, realUrl); + uploadResults.forEach(({ localUrl, realUrl }) => { + html = html.replaceAll(localUrl, realUrl); }); await updateComment({ variables: { id: commentId, - content, + content: html, }, }); - for (const fileId of deletedFileIds) { - await deleteHomeworkCommentFile(commentId, fileId); + const contentNode = editor.state.doc; + const currentFileIds = collectFileIds(contentNode); + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { + await deleteHomeworkCommentFile(commentId, id); } setPendingFiles([]); + setDeletedFileIds([]); setError(""); - rteRef.current?.editor?.commands.clearContent(); + editor.commands.clearContent(); }, }); } catch (error) { + console.error(error); setError("Произошла ошибка при отправке комментария."); } }; diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index f391c027..7e1c2cd0 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -3,11 +3,13 @@ import { useParams } from "react-router-dom"; import { HOMEWORK_FILE_GET_URI } from "config"; import { createUrlWithParams } from "shared/utils"; -import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; +import { collectFileIds, type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; import { PendingFile } from "shared/components/text-editor/types"; import SendButtons from "shared/components/send-buttons"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; import { ISendHomeWork } from "./send-homework.types"; import { StyledBox, StyledFormHelperText } from "./send-homework.styled"; @@ -48,12 +50,33 @@ const SendHomework: FC = (props) => { }, onCompleted: async (response) => { const homeWorkId = response?.createHomeWorkToCheck?.id; + if (!homeWorkId) return; + + const editor = rteRef.current?.editor; + if (!editor) return; + + const contentBlobUrls = Array.from( + content.matchAll(/(blob:[^"'\s>]+)/g) + ).map((match) => match[1]); + + const recoveredBlobs = contentBlobUrls + .filter((url) => !pendingFiles.find((f) => f.localUrl === url)) + .map((url) => { + const node = findNodeByUrl(editor.state.doc, url); + const fileName = node?.fileName || "recovered_file"; + return { file: blobUrlToFile(url, fileName), localUrl: url }; + }); + + const resolvedRecoveredFiles = await Promise.all( + recoveredBlobs.map(async ({ file, localUrl }) => ({ + localUrl, + file: await file, + })) + ); - if (!homeWorkId) { - return; - } + const allFilesToUpload = [...pendingFiles, ...resolvedRecoveredFiles]; - const uploadPromises = pendingFiles.map( + const uploadPromises = allFilesToUpload.map( async ({ file, localUrl }) => { const uploadedFile = await uploadHomeworkFile(file, homeWorkId); @@ -79,23 +102,28 @@ const SendHomework: FC = (props) => { }, }); - for (const id of deletedFileIds) { + const contentNode = editor.state.doc; + const currentFileIds = collectFileIds(contentNode); + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { await deleteHomeworkFile(homeWorkId, id); } await sendHomeWorkToCheck({ - variables: { - homeWorkId, - }, + variables: { homeWorkId }, }); setPendingFiles([]); setDeletedFileIds([]); setError(""); - rteRef.current?.editor?.commands.clearContent(); + editor.commands.clearContent(); }, }); } catch (error) { + console.error(error); setError("Произошла ошибка при отправке д/з."); } }; diff --git a/src/shared/features/update-comment/view/update-comment.tsx b/src/shared/features/update-comment/view/update-comment.tsx index dbd91755..529cf5b9 100644 --- a/src/shared/features/update-comment/view/update-comment.tsx +++ b/src/shared/features/update-comment/view/update-comment.tsx @@ -8,8 +8,8 @@ import { useComment } from "shared/hooks/use-comment"; import { useHomeworkCommentFileDelete, useHomeworkCommentFileUpload, + useRichTextFileManager, } from "shared/hooks"; -import { PendingFile } from "shared/components/text-editor/types"; import { createUrlWithParams } from "shared/utils"; import { IUpdateComment } from "./update-comment.types"; @@ -23,67 +23,74 @@ const UpdateComment: FC = (props) => { const { loading, updateComment, commentId, content } = props; const rteRef = useRef(null); const [error, setError] = useState(""); - const [pendingFiles, setPendingFiles] = useState([]); - const [deletedFileIds, setDeletedFileIds] = useState([]); const { uploadHomeworkCommentFile } = useHomeworkCommentFileUpload(); - const { setSelectedComment } = useComment(); const { deleteHomeworkCommentFile } = useHomeworkCommentFileDelete(); + const { setSelectedComment } = useComment(); + + const { + pendingFiles, + setPendingFiles, + deleteFile: handleDeleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + } = useRichTextFileManager({ + upload: async (file, commentId) => { + const result = await uploadHomeworkCommentFile(file, commentId); + if (!result?.id) throw new Error("Upload failed"); + return { id: result.id }; + }, + remove: async (commentId, fileId) => { + const result = await deleteHomeworkCommentFile(commentId, fileId); + if (result === null) throw new Error("Delete failed"); + }, + fileUrlBuilder: (fileId, commentId) => + createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { + commentId, + fileId, + }), + getEntityId: () => commentId!, + }); const handleUpdateComment = async () => { - if (!rteRef.current?.editor || !commentId) return; + const editor = rteRef.current?.editor; + if (!editor || !commentId) return; - let content = rteRef.current.editor.getHTML().trim(); - if (!content || content === "

") { + let html = editor.getHTML().trim(); + if (!html || html === "

") { setError("Введите текст"); return; } try { - const uploadPromises = pendingFiles.map(async ({ file, localUrl }) => { - const uploadedFile = await uploadHomeworkCommentFile(file, commentId); - - const realUrl = createUrlWithParams(HOMEWORK_COMMENT_FILE_GET_URI, { - commentId, - fileId: uploadedFile?.id!, - }); - - return { localUrl, realUrl }; - }); + const blobUrls = extractBlobUrls(html); + const recoveredFiles = await recoverMissingFiles( + blobUrls, + editor.state.doc + ); + const allFiles = [...pendingFiles, ...recoveredFiles]; - const results = await Promise.all(uploadPromises); + html = await uploadAllFiles(allFiles, html); - results.forEach(({ localUrl, realUrl }) => { - content = content.replaceAll(localUrl, realUrl); - }); await updateComment({ - variables: { id: commentId, content }, + variables: { id: commentId, content: html }, onCompleted: async () => { - for (const fileId of deletedFileIds) { - await deleteHomeworkCommentFile(commentId, fileId); - } + await removeDeletedFiles(editor.state.doc); + setSelectedComment(null); - setPendingFiles([]); - setDeletedFileIds([]); + resetState(); setError(""); - rteRef.current?.editor?.commands.clearContent(); + editor.commands.clearContent(); }, }); } catch (err) { + console.error(err); setError("Произошла ошибка при редактировании комментария"); } }; - const handleDeleteFile = (fileId: string) => { - if (fileId.startsWith("blob:")) { - setPendingFiles((prev) => - prev.filter((pending) => pending.localUrl !== fileId) - ); - } else { - // серверный файл — добавить в список на удаление - setDeletedFileIds((prev) => [...prev, fileId]); - } - }; - return ( diff --git a/src/shared/features/update-homework/view/update-homework.tsx b/src/shared/features/update-homework/view/update-homework.tsx index e3cadb51..2697c47d 100644 --- a/src/shared/features/update-homework/view/update-homework.tsx +++ b/src/shared/features/update-homework/view/update-homework.tsx @@ -5,8 +5,11 @@ import { type RichTextEditorRef } from "shared/lib/mui-tiptap"; import { Editor } from "shared/components/text-editor"; import SendButtons from "shared/components/send-buttons"; import { createUrlWithParams } from "shared/utils"; -import { useHomeworkFileDelete, useHomeworkFileUpload } from "shared/hooks"; -import { PendingFile } from "shared/components/text-editor/types"; +import { + useHomeworkFileDelete, + useHomeworkFileUpload, + useRichTextFileManager, +} from "shared/hooks"; import { IUpdateHomeWork } from "./update-homework.types"; import { @@ -19,69 +22,70 @@ const UpdateHomework: FC = (props) => { const { loading, updateHomework, setOpenHomeWorkEdit, answer, homeWorkId } = props; const rteRef = useRef(null); - - const [pendingFiles, setPendingFiles] = useState([]); - const [deletedFileIds, setDeletedFileIds] = useState([]); const [error, setError] = useState(""); const { uploadHomeworkFile } = useHomeworkFileUpload(); const { deleteHomeworkFile } = useHomeworkFileDelete(); + const { + pendingFiles, + setPendingFiles, + deleteFile: handleDeleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + } = useRichTextFileManager({ + upload: async (file, homeWorkId) => { + const result = await uploadHomeworkFile(file, homeWorkId); + if (!result?.id) throw new Error("Upload failed or missing file ID"); + return { id: result.id }; + }, + remove: async (homeWorkId, fileId) => { + const result = await deleteHomeworkFile(homeWorkId, fileId); + if (result === null) throw new Error("Deletion failed"); + }, + fileUrlBuilder: (fileId, homeWorkId) => + createUrlWithParams(HOMEWORK_FILE_GET_URI, { homeWorkId, fileId }), + getEntityId: () => homeWorkId!, + }); + const handleUpdateHomework = async () => { - if (!rteRef.current?.editor || !homeWorkId) return; + const editor = rteRef.current?.editor; + if (!editor || !homeWorkId) return; - let content = rteRef.current.editor.getHTML().trim(); + let content = editor.getHTML().trim(); if (!content || content === "

") { setError("Введите текст"); return; } try { - const uploadPromises = pendingFiles.map(async ({ file, localUrl }) => { - const uploadedFile = await uploadHomeworkFile(file, homeWorkId); - - const realUrl = createUrlWithParams(HOMEWORK_FILE_GET_URI, { - homeWorkId, - fileId: uploadedFile?.id!, - }); - - return { localUrl, realUrl }; - }); - - const results = await Promise.all(uploadPromises); + const blobUrls = extractBlobUrls(content); + const recoveredFiles = await recoverMissingFiles( + blobUrls, + editor.state.doc + ); + const allFiles = [...pendingFiles, ...recoveredFiles]; - results.forEach(({ localUrl, realUrl }) => { - content = content.replaceAll(localUrl, realUrl); - }); + content = await uploadAllFiles(allFiles, content); await updateHomework({ variables: { id: homeWorkId, content }, }); - for (const id of deletedFileIds) { - await deleteHomeworkFile(homeWorkId, id); - } + await removeDeletedFiles(editor.state.doc); setOpenHomeWorkEdit(false); - setPendingFiles([]); - setDeletedFileIds([]); + resetState(); setError(""); - rteRef.current?.editor?.commands.clearContent(); + editor.commands.clearContent(); } catch (err) { console.error(err); setError("Произошла ошибка при редактировании д/з."); } }; - const handleDeleteFile = (fileId: string) => { - if (fileId.startsWith("blob:")) { - setPendingFiles((prev) => - prev.filter((pending) => pending.localUrl !== fileId) - ); - } else { - setDeletedFileIds((prev) => [...prev, fileId]); - } - }; - return ( diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index b4f9a9fa..6bdc99d4 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -19,3 +19,4 @@ export { useLectureHomeworkFileDelete } from "./use-lecture-homework-file-delete export { useHomeworkCommentFileGet } from "./use-homework-comment-file-get"; export { useHomeworkCommentFileUpload } from "./use-homework-comment-file-upload"; export { useHomeworkCommentFileDelete } from "./use-homework-comment-file-delete"; +export { useRichTextFileManager } from "./use-rich-text-file-manager"; diff --git a/src/shared/hooks/use-rich-text-file-manager.ts b/src/shared/hooks/use-rich-text-file-manager.ts new file mode 100644 index 00000000..2342cafb --- /dev/null +++ b/src/shared/hooks/use-rich-text-file-manager.ts @@ -0,0 +1,105 @@ +import { useState } from "react"; +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +import { collectFileIds } from "shared/lib/mui-tiptap"; +import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; +import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; +import type { PendingFile } from "shared/components/text-editor/types"; + +interface UseFileManagerParams { + upload: (file: File, entityId: string) => Promise<{ id: string }>; + remove: (entityId: string, fileId: string) => Promise; + fileUrlBuilder: (fileId: string, entityId: string) => string; + getEntityId: () => string; +} + +export function useRichTextFileManager({ + upload, + remove, + fileUrlBuilder, + getEntityId, +}: UseFileManagerParams) { + const [pendingFiles, setPendingFiles] = useState([]); + const [deletedFileIds, setDeletedFileIds] = useState([]); + + const extractBlobUrls = (html: string): string[] => + Array.from(html.matchAll(/(blob:[^"'\s>]+)/g)).map((m) => m[1]); + + const recoverMissingFiles = async ( + blobUrls: string[], + doc: ProseMirrorNode + // eslint-disable-next-line require-await + ): Promise => { + const missing = blobUrls.filter( + (url) => !pendingFiles.find((f) => f.localUrl === url) + ); + + return Promise.all( + missing.map(async (url) => { + const node = findNodeByUrl(doc, url); + const file = await blobUrlToFile(url, node?.fileName || "recovered"); + return { file, localUrl: url }; + }) + ); + }; + + const uploadAllFiles = async ( + allFiles: PendingFile[], + html: string + ): Promise => { + const entityId = getEntityId(); + + const uploads = await Promise.all( + allFiles.map(async ({ file, localUrl }) => { + const uploaded = await upload(file, entityId); + const realUrl = fileUrlBuilder(uploaded.id, entityId); + return { localUrl, realUrl }; + }) + ); + + let updatedHtml = html; + uploads.forEach(({ localUrl, realUrl }) => { + updatedHtml = updatedHtml.replaceAll(localUrl, realUrl); + }); + + return updatedHtml; + }; + + const removeDeletedFiles = async (doc: ProseMirrorNode) => { + const entityId = getEntityId(); + const currentFileIds = collectFileIds(doc); + + const stillDeleted = deletedFileIds.filter( + (id) => !currentFileIds.includes(id) + ); + + for (const id of stillDeleted) { + await remove(entityId, id); + } + }; + + const deleteFile = (fileId: string) => { + if (fileId.startsWith("blob:")) { + setPendingFiles((prev) => prev.filter((f) => f.localUrl !== fileId)); + } else { + setDeletedFileIds((prev) => [...prev, fileId]); + } + }; + + const resetState = () => { + setPendingFiles([]); + setDeletedFileIds([]); + }; + + return { + pendingFiles, + setPendingFiles, + deletedFileIds, + deleteFile, + extractBlobUrls, + recoverMissingFiles, + uploadAllFiles, + removeDeletedFiles, + resetState, + }; +} diff --git a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts index acec83b7..819a63e8 100644 --- a/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts +++ b/src/shared/lib/mui-tiptap/extensions/file-deletion-tracker.ts @@ -1,6 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; -import type { Node as ProseMirrorNode } from "prosemirror-model"; + +import { collectFileIds } from "../utils"; export const FileDeletionTracker = Extension.create<{ onFileDelete: (fileIdOrBlob: string) => void; @@ -42,27 +43,3 @@ export const FileDeletionTracker = Extension.create<{ ]; }, }); - -function collectFileIds(doc: ProseMirrorNode): string[] { - const ids: string[] = []; - - doc.descendants((node: any) => { - if ( - (node.type.name === "image" || node.type.name === "file") && - (node.attrs?.src || node.attrs?.href) - ) { - const url = node.attrs.src || node.attrs.href; - - if (url.startsWith("blob:")) { - ids.push(url); - } else { - const match = url.match(/\/file\/(\d+)/); - if (match) { - ids.push(match[1]); - } - } - } - }); - - return ids; -} diff --git a/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx new file mode 100644 index 00000000..2f5aa088 --- /dev/null +++ b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx @@ -0,0 +1,81 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { + IconButton, + Typography, + Tooltip, + Paper, + useTheme, +} from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import CloseIcon from "@mui/icons-material/Close"; + +export default function FileNodeView({ node, deleteNode }: any) { + const { href, fileName } = node.attrs; + const theme = useTheme(); + + return ( + + + + + + + + + + {fileName} + + + + + + + + + + ); +} diff --git a/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts b/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts new file mode 100644 index 00000000..99181717 --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/blob-url-to-file.ts @@ -0,0 +1,14 @@ +export async function blobUrlToFile( + blobUrl: string, + name: string +): Promise { + const res = await fetch(blobUrl); + const blob = await res.blob(); + + const hasExtension = /\.[a-zA-Z0-9]+$/.test(name); + const finalName = hasExtension + ? name + : `${name}.${blob.type.split("/")[1] || "bin"}`; + + return new File([blob], finalName, { type: blob.type }); +} diff --git a/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts b/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts new file mode 100644 index 00000000..045dc72e --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/collect-file-ids.ts @@ -0,0 +1,25 @@ +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +export default function collectFileIds(doc: ProseMirrorNode): string[] { + const ids: string[] = []; + + doc.descendants((node: any) => { + if ( + (node.type.name === "image" || node.type.name === "file") && + (node.attrs?.src || node.attrs?.href) + ) { + const url = node.attrs.src || node.attrs.href; + + if (url.startsWith("blob:")) { + ids.push(url); + } else { + const match = url.match(/\/file\/(\d+)/); + if (match) { + ids.push(match[1]); + } + } + } + }); + + return ids; +} diff --git a/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts b/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts new file mode 100644 index 00000000..306b17ae --- /dev/null +++ b/src/shared/lib/mui-tiptap/utils/find-node-by-url.ts @@ -0,0 +1,21 @@ +import type { Node as ProseMirrorNode } from "prosemirror-model"; + +export function findNodeByUrl( + doc: ProseMirrorNode, + url: string +): { fileName?: string } | null { + let result: { fileName?: string } | null = null; + + doc.descendants((node: any) => { + if ( + (node.type.name === "image" || node.type.name === "file") && + (node.attrs?.src === url || node.attrs?.href === url) + ) { + result = { fileName: node.attrs?.fileName || node.attrs?.alt }; + return false; + } + return true; + }); + + return result; +} diff --git a/src/shared/lib/mui-tiptap/utils/index.ts b/src/shared/lib/mui-tiptap/utils/index.ts index a71766ff..a832a6f5 100644 --- a/src/shared/lib/mui-tiptap/utils/index.ts +++ b/src/shared/lib/mui-tiptap/utils/index.ts @@ -8,3 +8,6 @@ export { getModShortcutKey, isMac, isTouchDevice } from "./platform"; export { default as slugify } from "./slugify"; export { default as truncateMiddle } from "./truncateMiddle"; export * from "./files"; +export { default as collectFileIds } from "./collect-file-ids"; +export { blobUrlToFile } from "./blob-url-to-file"; +export { findNodeByUrl } from "./find-node-by-url"; From ef8d3e52a9bc9d719faaa8e1aaaae8d303b1b93b Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Tue, 24 Jun 2025 15:15:02 +0300 Subject: [PATCH 18/21] =?UTF-8?q?QAGDEV-721=20-=20=D0=A3=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B5=D0=B3=D0=BE=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20command+Z=20/=20cntrl+Z?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/edit-lecture/edit-lecture.tsx | 4 ++-- .../features/send-comment/view/send-comment.tsx | 2 +- .../features/send-homework/view/send-homework.tsx | 2 +- src/shared/hooks/use-rich-text-file-manager.ts | 2 +- .../lib/mui-tiptap/extensions/file-node-view.tsx | 15 +++++++++++++-- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 8ae8e7fd..9536c710 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -212,11 +212,11 @@ const EditLecture: FC = ({ })(); }; const handleDeleteLectureFile = (fileId: string) => { - setDeletedLectureFileIds((prev) => [...prev, fileId]); + setDeletedLectureFileIds((prev) => Array.from(new Set([...prev, fileId]))); }; const handleDeleteHomeworkFile = (fileId: string) => { - setDeletedHomeworkFileIds((prev) => [...prev, fileId]); + setDeletedHomeworkFileIds((prev) => Array.from(new Set([...prev, fileId]))); }; return ( diff --git a/src/shared/features/send-comment/view/send-comment.tsx b/src/shared/features/send-comment/view/send-comment.tsx index 5056a08d..10d347a4 100644 --- a/src/shared/features/send-comment/view/send-comment.tsx +++ b/src/shared/features/send-comment/view/send-comment.tsx @@ -127,7 +127,7 @@ const SendComment: FC = (props) => { prev.filter((pending) => pending.localUrl !== fileId) ); } else { - setDeletedFileIds((prev) => [...prev, fileId]); + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); } }; diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 7e1c2cd0..80dbc1d6 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -134,7 +134,7 @@ const SendHomework: FC = (props) => { prev.filter((pending) => pending.localUrl !== fileId) ); } else { - setDeletedFileIds((prev) => [...prev, fileId]); + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); } }; diff --git a/src/shared/hooks/use-rich-text-file-manager.ts b/src/shared/hooks/use-rich-text-file-manager.ts index 2342cafb..8f1c5328 100644 --- a/src/shared/hooks/use-rich-text-file-manager.ts +++ b/src/shared/hooks/use-rich-text-file-manager.ts @@ -82,7 +82,7 @@ export function useRichTextFileManager({ if (fileId.startsWith("blob:")) { setPendingFiles((prev) => prev.filter((f) => f.localUrl !== fileId)); } else { - setDeletedFileIds((prev) => [...prev, fileId]); + setDeletedFileIds((prev) => Array.from(new Set([...prev, fileId]))); } }; diff --git a/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx index 2f5aa088..ec840e62 100644 --- a/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx +++ b/src/shared/lib/mui-tiptap/extensions/file-node-view.tsx @@ -14,7 +14,15 @@ export default function FileNodeView({ node, deleteNode }: any) { const theme = useTheme(); return ( - + @@ -54,6 +64,7 @@ export default function FileNodeView({ node, deleteNode }: any) { flex: 1, fontWeight: 500, fontSize: 14, + minWidth: 0, }} title={fileName} > From eed2aaf16abbad649b564b9d3877b403668db92b Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 20 Jul 2025 19:35:54 +0300 Subject: [PATCH 19/21] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20(v1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codegen.ts | 5 +- src/api/schema.graphql | 819 ++++++++++++++++++ .../test/containers/test-container.tsx | 175 ++++ src/features/test/views/test-view.tsx | 166 ++++ src/pages/test.tsx | 9 + 5 files changed, 1173 insertions(+), 1 deletion(-) create mode 100644 src/api/schema.graphql create mode 100644 src/features/test/containers/test-container.tsx create mode 100644 src/features/test/views/test-view.tsx create mode 100644 src/pages/test.tsx diff --git a/codegen.ts b/codegen.ts index cd79220c..47aebe23 100644 --- a/codegen.ts +++ b/codegen.ts @@ -2,7 +2,10 @@ import type { CodegenConfig } from "@graphql-codegen/cli"; import "dotenv/config"; const config: CodegenConfig = { - schema: `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, + schema: [ + // `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, // Серверная схема + "src/api/schema.graphql", + ], documents: ["src/**/*.graphql"], generates: { "src/api/graphql/generated/graphql.tsx": { diff --git a/src/api/schema.graphql b/src/api/schema.graphql new file mode 100644 index 00000000..0b39548a --- /dev/null +++ b/src/api/schema.graphql @@ -0,0 +1,819 @@ +schema { + query: Query + mutation: Mutation +} + +directive @javaType(name: String!) on SCALAR + +"Mutation root" +type Mutation { + """ + user section + """ + createUser(input: UserCreateInput!): UserDto + updateUser(input: UserUpdateInput!): UserDto + updateRole(id: ID!, roles: [UserRole]): UserDto + lockUser(id: ID!): Void + unlockUser(id: ID!): Void + changePassword(newPassword: String!, oldPassword: String!): Void + changePasswordByUserId(userId: ID!, password: String!): Void + resetPassword(email: String!): Void + setPassword(token: String!, newPassword: String!): Void + createSkill(name: String!): SkillDto + updateSkill(id: ID!, name: String!): SkillDto + deleteSkill(id: ID!): Void + + """ + lecture section + """ + updateLecture(input: LectureInput!): LectureInfoDto + deleteLecture(id: ID!): Void + """ + lecture home work level section + """ + updateLectureHomeWorkLevel( + input: LectureHomeWorkLevelInput! + ): LectureHomeWorkLevelDto + deleteLectureHomeWorkLevel(id: ID!): Void + """ + training purchase section + """ + updateTrainingPurchase(input: TrainingPurchaseInput!): TrainingPurchaseDto + updateUserTrainingPurchase( + userEmail: String! + tariffCodes: [String]! + ): [TrainingPurchaseDto] + """ + training tariff + """ + updateTrainingTariff(input: TrainingTariffInput!): TrainingTariffDto + deleteTrainingTariff(id: ID!): Void + """ + training section + """ + updateTraining(input: TrainingInput!): TrainingDto + deleteTraining(id: ID!): Void + """ + training lecture + """ + updateTrainingLecture(id: ID!, lectureIds: [ID!]): [TrainingLectureDto] + """ + studentHomeWork section + """ + createHomeWorkToCheck( + lectureId: ID! + trainingId: ID! + content: String! + ): StudentHomeWorkDto + sendHomeWorkToCheck(homeWorkId: ID): StudentHomeWorkDto + updateHomeWork(id: ID!, content: String!): StudentHomeWorkDto + deleteHomeWork(id: ID!): Void + takeForReview(homeWorkId: ID!): StudentHomeWorkDto + approved(homeWorkId: ID!): StudentHomeWorkDto + notApproved(homeWorkId: ID!): StudentHomeWorkDto + resetState(homeWorkId: ID!): StudentHomeWorkDto + """ + student TestAttempt section + """ + startTest(lectureId: ID!, trainingId: ID!): TestAttemptShortDto + sendTestAnswer( + questionId: ID! + attemptId: ID! + testAnswerIds: [ID]! + ): TestAttemptDto + sendTestAnswerToReview(attemptId: ID!): StudentHomeWorkDto + """ + commentHomeWork section + """ + sendComment(homeWorkId: ID!, content: String!): CommentHomeWorkDto + answerComment(parentID: ID!, content: String!): CommentHomeWorkDto + updateComment(id: ID!, content: String!): CommentHomeWorkDto + deleteComment(id: ID!): Void + likeComment(id: ID!): CommentHomeWorkDto + """ + test lecture section + """ + updateTestQuestion(input: TestQuestionInput!): TestQuestionDto + deleteTestQuestion(id: ID!): Void + updateTestGroup(input: TestGroupInput!): TestGroupDto + deleteTestGroup(id: ID!): Void + updateTestAnswer(input: TestAnswerInput!): TestAnswerDto + deleteTestAnswer(id: ID!): Void +} + +"Query root" +type Query { + """ + user section + """ + user: UserDto + userById(id: ID): UserDto + userRoles: [UserRoleDto] + users( + offset: Int! + limit: Int! + sort: UserSort + filter: UsersFilter + ): UsersDto + usersRating(offset: Int!, limit: Int!, sort: UserSort): UsersRatingDto + mentors(offset: Int!, limit: Int!, sort: UserSort): UsersDto + checkResetPasswordToken(token: String!): Void + skills( + offset: Int! + limit: Int! + sort: SkillSort + filter: SkillFilter + ): SkillsDto + """ + lecture section + """ + lecture(id: ID): LectureInfoShortDto + lectureHomeWork(lectureId: ID): String + lectureTest(lectureId: ID): TestGroupNameDto + lectures(offset: Int!, limit: Int!, sort: LectureSort): LecturesDto + """ + lecture home work level section + """ + lectureHomeWorkLevel(id: ID!): LectureHomeWorkLevelDto + lectureHomeWorkLevels: [LectureHomeWorkLevelDto] + """ + purchase section + """ + trainingPurchases: [TrainingPurchaseDto] + trainingPurchasesByUserId(userId: ID!): [TrainingPurchaseDto] + """ + training tariff + """ + trainingTariffs( + offset: Int! + limit: Int! + sort: TrainingTariffSort + ): TrainingTariffsDto + """ + training section + """ + training(id: ID!): TrainingDto + trainingsByMentor(offset: Int!, limit: Int!, sort: TrainingSort): TrainingsDto + trainings(offset: Int!, limit: Int!, sort: TrainingSort): TrainingsDto + """ + training lecture + """ + trainingLectures(id: ID!): [TrainingLectureDto] + """ + studentHomeWork section + """ + homeWork(id: ID!): StudentHomeWorkDto + homeWorkByStudentAndLectureAndTraining( + studentId: ID! + lectureId: ID! + trainingId: ID! + ): StudentHomeWorkDto + homeWorkByLectureAndTraining( + lectureId: ID! + trainingId: ID! + ): StudentHomeWorkDto + homeWorks( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + filter: StudentHomeWorkFilter + ): StudentHomeWorksDto + homeWorksByLectureId( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + filter: HomeWorksFilter + lectureId: ID! + ): StudentHomeWorksDto + homeWorksByStatus( + offset: Int! + limit: Int! + sort: StudentHomeWorkSort + status: StudentHomeWorkStatus! + ): StudentHomeWorksDto + """ + student TestAttempt section + """ + testAttempts(lectureId: ID!, trainingId: ID!): [TestAttemptShortDto] + testAttempt(id: ID!): TestAttemptDto + testAttemptQuestions(attemptId: ID!): [TestAttemptQuestionResultDto] + """ + commentHomeWork section + """ + commentHomeWorkById(id: ID!): CommentHomeWorksDto + commentsHomeWorkByHomeWork( + offset: Int! + limit: Int! + sort: CommentHomeWorkSort + homeWorkId: ID! + ): CommentHomeWorksDto + """ + statistics section + """ + trainingsHomeWorksStatistic(id: ID): [TrainingHomeWorksStatisticDto] + homeWorksStatistic(id: ID): [HomeWorksStatisticDto] + """ + rating + """ + rating: RatingDto + ratingByUser(id: ID): RatingDto + """ + test lecture section + """ + testTestGroups: [TestGroupShortDto] + testTestGroupsById(id: ID): TestGroupDto + testQuestions: [TestQuestionDto] + testAnswerByQuestion(questionId: ID): [TestAnswerDto] +} + +type TrainingPurchaseDto { + id: ID + user: UserDto! + trainingTariff: TrainingTariffDto! +} + +input TrainingPurchaseInput { + id: ID + userEmail: String + trainingTariffCode: String +} + +input TrainingTariffInput { + id: ID + name: String! + code: String! + price: Float + homeWork: Boolean + trainingName: String! + description: String +} + +type TrainingTariffDto { + id: ID + name: String + code: String + price: Float + homeWork: Boolean + training: TrainingDto + description: String +} + +type TrainingDto { + id: ID! + name: String! + description: String + content: String + techStack: TechStack! + picture: String + mentors: [UserDto] + tariffs: [TrainingTariffDto] +} + +input TrainingInput { + id: ID + name: String + description: String + content: String + techStack: TechStack! + mentors: [String] +} + +input TrainingLectureInput { + lecture: ID! + lastLecture: ID + locking: Boolean +} + +type TrainingLectureDto { + id: ID + number: Int + lecture: LectureDto + lastLecture: LectureDto + locking: Boolean +} + +input LectureInput { + id: ID + homeWorkLevelCode: String + subject: String + description: [String] + testGroupId: ID + content: String + contentHomeWork: String + speakers: [String] +} + +type LectureInfoDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + description: [String] + content: String + contentHomeWork: String + testGroup: TestGroupDto + files: [LectureFilesDto] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureInfoShortDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + description: [String] + content: String + testGroup: TestGroupDto + files: [LectureFilesDto] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureDto { + id: ID + homeWorkLevel: LectureHomeWorkLevelDto + subject: String + testGroup: TestGroupNameDto + description: [String] + speakers: [UserDto] + creationDate: LocalDateTime + modificationDate: LocalDateTime +} + +type LectureHomeWorkLevelDto { + id: ID + code: String + description: String + estimate: Int +} + +input LectureHomeWorkLevelInput { + id: ID + code: String + description: String + estimate: Int +} + +type LecturesDto { + items: [LectureDto] + offset: Int + limit: Int + totalElements: Long +} + +type StudentHomeWorkDto { + id: ID + lecture: LectureInfoDto + training: TrainingDto + status: StudentHomeWorkStatus + answer: String + testAttempts: [TestAttemptDto] + student: UserDto + mentor: UserDto + filesHomeWork: [StudentHomeWorkFilesDto] + creationDate: LocalDateTime + updateDate: LocalDateTime + startCheckingDate: LocalDateTime + endCheckingDate: LocalDateTime +} + +type StudentHomeWorkFilesDto { + id: ID + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type LectureFilesDto { + id: ID + homeWork: Boolean + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type StudentHomeWorksDto { + items: [StudentHomeWorkDto] + offset: Int + limit: Int + totalElements: Long +} + +type CommentHomeWorksDto { + items: [CommentHomeWorkDto] + offset: Int + limit: Int + totalElements: Long +} + +type CommentHomeWorkDto { + id: ID + homeWork: StudentHomeWorkDto + creator: UserDto + content: String + filesComment: [CommentHomeWorkFilesDto] + likes: Long + userLike: Boolean + children: [CommentHomeWorkDto] + creationDate: LocalDateTime +} + +type CommentHomeWorkFilesDto { + id: ID + creationDate: LocalDateTime + fileName: String + contentType: String + size: Long +} + +type TrainingsDto { + items: [TrainingDto] + offset: Int + limit: Int + totalElements: Long +} + +type TrainingTariffsDto { + items: [TrainingTariffDto] + offset: Int + limit: Int + totalElements: Long +} + +type ContentFileDto { + id: ID + size: Long + type: String + name: String + fileLocation: String +} + +input UserCreateInput { + id: ID + email: String! + password: String! + firstName: String! + lastName: String! + middleName: String + phoneNumber: String +} + +input UserUpdateInput { + id: ID + email: String! + firstName: String! + lastName: String! + middleName: String + phoneNumber: String + vkId: String + git: String + telegram: String + stackOverflow: String + linkedin: String + website: String + skills: [ID] +} + +type UserDto { + id: ID + email: String + firstName: String + lastName: String + middleName: String + phoneNumber: String + rating: RatingUserDto + vkId: String + git: String + telegram: String + stackOverflow: String + linkedin: String + website: String + avatar: String + skills: [SkillDto] + roles: [UserRole] + locked: Boolean + creationDate: LocalDateTime + confirmationDate: LocalDateTime + updateDate: LocalDateTime +} + +type SkillDto { + id: ID + name: String +} + +type UserRatingDto { + id: ID + firstName: String + lastName: String + middleName: String + avatar: String + creationDate: LocalDateTime + rating: RatingUserDto +} + +type UserRoleDto { + name: String + description: String +} + +type UsersDto { + items: [UserDto] + offset: Int + limit: Int + totalElements: Long +} + +type SkillsDto { + items: [SkillDto] + offset: Int + limit: Int + totalElements: Long +} + +input UsersFilter { + email: String + name: String + phoneNumber: String + role: UserRole +} + +input SkillFilter { + name: String +} + +type UsersRatingDto { + items: [UserRatingDto] + offset: Int + limit: Int + totalElements: Long +} + +type TrainingHomeWorksStatisticDto { + training: TrainingDto + homeworks: [HomeWorksStatisticDto] +} + +type HomeWorksStatisticDto { + status: StudentHomeWorkStatus + count: Long +} + +type RatingUserDto { + rating: Long +} + +type RatingDto { + rating: Long + products: [RatingProductsDto] +} + +type RatingProductsDto { + training: TrainingDto + roles: [RatingProductsByUserRoleDto] +} + +type RatingProductsByUserRoleDto { + role: UserRoleDto + rating: Long + types: [RatingProductsByRatingTypeDto] +} + +type RatingProductsByRatingTypeDto { + type: RatingTypeDto + rating: Long +} + +type TestGroupNameDto { + id: ID + testName: String +} + +type TestGroupDto { + id: ID + testName: String + successThreshold: Int + testQuestions: [TestQuestionDto] +} + +type TestGroupShortDto { + id: ID + testName: String + successThreshold: Int +} + +input TestGroupInput { + id: ID + testName: String + successThreshold: Int + testQuestions: [TestQuestionInput] +} + +type TestQuestionDto { + id: ID + text: String + testAnswers: [TestAnswerShortDto] +} +input TestQuestionInput { + id: ID + text: String +} + +type TestAnswerDto { + id: ID + text: String + correct: Boolean +} + +type TestAnswerShortDto { + id: ID + text: String +} + +input TestAnswerInput { + id: ID + text: String + correct: Boolean + testQuestion: TestQuestionInput +} + +type TestAttemptShortDto { + id: ID + startTime: LocalDateTime + endTime: LocalDateTime + successfulCount: Int + errorsCount: Int + result: Boolean +} + +type TestAttemptDto { + id: ID + startTime: LocalDateTime + endTime: LocalDateTime + successfulCount: Int + errorsCount: Int + result: Boolean + testAttemptQuestionResults: [TestAttemptQuestionResultDto] +} + +type TestAttemptQuestionResultDto { + testQuestion: TestQuestionDto + result: Boolean + testAnswerResults: [TestAnswerResultsDto] +} + +type TestAnswerResultsDto { + testAnswer: TestAnswerShortDto + result: Boolean + answer: Boolean +} + +type TestQuestionInfoDto { + testQuestion: TestQuestionDto + result: Boolean +} + +type RatingTypeDto { + name: String + description: String +} + +input UserSort { + field: UserSortField + order: Order +} + +input SkillSort { + field: SkillSortField + order: Order +} + +enum TechStack { + JAVA + PYTHON +} + +enum UserRole { + ADMIN + STUDENT + MENTOR + LECTOR +} + +enum Order { + ASC + DESC +} + +enum UserSortField { + LAST_NAME + PHONE + EMAIL + RATING +} + +enum SkillSortField { + NAME +} + +enum StudentHomeWorkStatus { + NEW + REVIEW + IN_REVIEW + APPROVED + NOT_APPROVED +} + +input LectureSort { + field: LectureSortField + order: Order +} + +input StudentHomeWorkFilter { + status: StudentHomeWorkStatus + trainingId: ID + lectureId: ID + mentorId: ID + studentId: ID + creationDateFrom: LocalDateTime + creationDateTo: LocalDateTime +} + +input HomeWorksFilter { + status: StudentHomeWorkStatus +} + +input StudentHomeWorkSort { + field: StudentHomeWorkSortField + order: Order +} + +input CommentHomeWorkSort { + field: CommentHomeWorkSortField + order: Order +} + +input TrainingSort { + field: TrainingSortField + order: Order +} + +input TrainingTariffSort { + field: TrainingTariffField + order: Order +} + +enum LectureSortField { + SUBJECT + CREATION_DATE +} + +enum StudentHomeWorkSortField { + LECTOR + STUDENT + STATE + CREATION_DATE + START_CHECKING_DATE + END_CHECKING_DATE +} + +enum CommentHomeWorkSortField { + CREATOR + CREATION_DATE +} + +enum TrainingSortField { + NAME + CREATION_DATE +} + +enum TrainingTariffField { + NAME + CODE + CREATION_DATE +} + +scalar BigDecimal @javaType(name: "java.math.BigDecimal") + +scalar BigInteger @javaType(name: "java.math.BigInteger") + +scalar Date @javaType(name: "java.time.LocalDate") + +scalar DateTime @javaType(name: "java.time.OffsetDateTime") + +scalar LocalDateTime @javaType(name: "java.time.LocalDateTime") + +scalar LocalTime @javaType(name: "java.time.LocalTime") + +scalar Long @javaType(name: "java.lang.Long") + +scalar Time @javaType(name: "java.time.OffsetTime") + +scalar Timestamp @javaType(name: "java.util.Date") + +scalar Url @javaType(name: "java.net.URL") + +scalar Void @javaType(name: "java.lang.Void") diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx new file mode 100644 index 00000000..a51f09f1 --- /dev/null +++ b/src/features/test/containers/test-container.tsx @@ -0,0 +1,175 @@ +import { FC, useState, useEffect } from "react"; +import { useLazyQuery } from "@apollo/client"; + +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; +import { + useTestTestGroupsByIdQuery, + TestAnswerByQuestionDocument, +} from "api/graphql/generated/graphql"; + +import TestView from "../views/test-view"; + +// Типы для теста (пока без GraphQL, потом можно заменить на сгенерированные) +interface TestQuestion { + id: string; + text: string; +} + +interface TestAnswer { + id: string; + text: string; + correct: boolean; + testQuestion: TestQuestion; +} + +interface UserAnswer { + questionId: string; + answerId: string; +} + +const TestContainer: FC = () => { + // const { testId } = useParams<{ testId: string }>(); + const testId = "3"; + const [userAnswers, setUserAnswers] = useState([]); + const [isCompleted, setIsCompleted] = useState(false); + const [score, setScore] = useState(0); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [currentQuestionAnswers, setCurrentQuestionAnswers] = useState< + TestAnswer[] + >([]); + const [allLoadedAnswers, setAllLoadedAnswers] = useState([]); + const [answersLoading, setAnswersLoading] = useState(false); + + const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ + variables: { id: testId ?? "" }, + }); + + const [getTestAnswers] = useLazyQuery(TestAnswerByQuestionDocument); + + // Получаем список вопросов + const testQuestions = + testData?.testTestGroupsById?.testQuestions?.filter((q) => q != null) ?? []; + const currentQuestion = testQuestions[currentQuestionIndex]; + + // Загружаем ответы для текущего вопроса + useEffect(() => { + if (!currentQuestion?.id) return; + + // Проверяем, есть ли уже загруженные ответы для этого вопроса + const existingAnswers = allLoadedAnswers.filter( + (answer) => answer.testQuestion.id === currentQuestion.id + ); + + if (existingAnswers.length > 0) { + setCurrentQuestionAnswers(existingAnswers); + return; + } + + setAnswersLoading(true); + setCurrentQuestionAnswers([]); + + const fetchCurrentAnswers = async () => { + try { + const { data } = await getTestAnswers({ + variables: { questionId: currentQuestion.id }, + }); + + if (data?.testAnswerByQuestion) { + const answers = data.testAnswerByQuestion + .filter((answer: any) => answer != null) + .map((answer: any) => ({ + id: answer.id!, + text: answer.text!, + correct: answer.correct!, + testQuestion: { + id: currentQuestion.id!, + text: currentQuestion.text!, + }, + })); + + setCurrentQuestionAnswers(answers); + // Сохраняем все ответы для финальной проверки + setAllLoadedAnswers((prev) => [...prev, ...answers]); + } + } catch (error) { + console.error( + "Error fetching answers for question:", + currentQuestion.id, + error + ); + } finally { + setAnswersLoading(false); + } + }; + + fetchCurrentAnswers(); + }, [currentQuestion, getTestAnswers]); + + const handleAnswerSelect = (questionId: string, answerId: string) => { + setUserAnswers((prev) => { + const filtered = prev.filter((ua) => ua.questionId !== questionId); + return [...filtered, { questionId, answerId }]; + }); + }; + + const handleNextQuestion = () => { + if (currentQuestionIndex < testQuestions.length - 1) { + setCurrentQuestionIndex((prev) => prev + 1); + } else { + // Завершаем тест + handleSubmitTest(); + } + }; + + const handleSubmitTest = () => { + let correctAnswers = 0; + + userAnswers.forEach((userAnswer) => { + const answer = allLoadedAnswers.find( + (ta) => ta.id === userAnswer.answerId + ); + if (answer?.correct) { + correctAnswers++; + } + }); + + setScore(correctAnswers); + setIsCompleted(true); + }; + + // Проверяем, ответил ли пользователь на текущий вопрос + const currentAnswer = userAnswers.find( + (ua) => ua.questionId === currentQuestion?.id + ); + const isCurrentQuestionAnswered = !!currentAnswer; + + if (testLoading || answersLoading) return ; + if (!testData?.testTestGroupsById || !currentQuestion) + return ; + + // Приводим currentQuestion к правильному типу + const typedCurrentQuestion: TestQuestion = { + id: currentQuestion.id!, + text: currentQuestion.text!, + }; + + return ( + + ); +}; + +export default TestContainer; diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx new file mode 100644 index 00000000..dd12f8b8 --- /dev/null +++ b/src/features/test/views/test-view.tsx @@ -0,0 +1,166 @@ +import { FC } from "react"; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, + Alert, + LinearProgress, +} from "@mui/material"; + +import { TestGroupDto } from "api/graphql/generated/graphql"; + +// Типы (дублируем из контейнера, потом вынесем в отдельный файл) +interface TestQuestion { + id: string; + text: string; +} + +interface TestAnswer { + id: string; + text: string; + correct: boolean; + testQuestion: TestQuestion; +} + +interface UserAnswer { + questionId: string; + answerId: string; +} + +interface TestViewProps { + testData: TestGroupDto; + testAnswers: TestAnswer[]; + userAnswers: UserAnswer[]; + isCompleted: boolean; + score: number; + currentQuestion: TestQuestion; + currentQuestionIndex: number; + totalQuestions: number; + isCurrentQuestionAnswered: boolean; + onAnswerSelect: (questionId: string, answerId: string) => void; + onNextQuestion: () => void; + onSubmitTest: () => void; +} + +const TestView: FC = ({ + testData, + testAnswers, + userAnswers, + isCompleted, + score, + currentQuestion, + currentQuestionIndex, + totalQuestions, + isCurrentQuestionAnswered, + onAnswerSelect, + onNextQuestion, + onSubmitTest, +}) => { + const getUserAnswerForQuestion = (questionId: string) => { + return userAnswers.find((ua) => ua.questionId === questionId)?.answerId; + }; + + const successThreshold = testData.successThreshold ?? 0; + const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; + const isPassed = score >= successThreshold; + + if (isCompleted) { + return ( + + + + + Тест завершён! + + + + {isPassed + ? `Поздравляем! Вы прошли тест с результатом ${score}/${totalQuestions}` + : `Тест не пройден. Результат: ${score}/${totalQuestions}. Требуется: ${successThreshold}`} + + + + Правильных ответов: {score} из {totalQuestions} + + + Проходной балл: {successThreshold} + + + + + ); + } + + const selectedAnswer = getUserAnswerForQuestion(currentQuestion.id); + const isLastQuestion = currentQuestionIndex === totalQuestions - 1; + + return ( + + + {testData.testName} + + + + + Вопрос {currentQuestionIndex + 1} из {totalQuestions} + + + + + + + + {currentQuestionIndex + 1}. {currentQuestion.text} + + + + + onAnswerSelect(currentQuestion.id, e.target.value) + } + > + {testAnswers.map((answer) => ( + } + label={answer.text} + /> + ))} + + + + + + + + {userAnswers.length} из {totalQuestions} вопросов отвечено + + + + + + ); +}; + +export default TestView; diff --git a/src/pages/test.tsx b/src/pages/test.tsx new file mode 100644 index 00000000..d1f9955b --- /dev/null +++ b/src/pages/test.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import TestContainer from "features/test/containers/test-container"; + +const TestPage: FC = () => { + return ; +}; + +export default TestPage; From afcb98bc996f5548a1e13886df265fe452f4bbea Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 20 Jul 2025 20:28:01 +0300 Subject: [PATCH 20/21] =?UTF-8?q?QAGDEV-726=20-=20=D0=91=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D1=87=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=82=D0=B0=D1=80=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=20=D0=A1=D0=B0=D0=BC=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- codegen.ts | 2 +- .../lecture-detail/lecture-detail-container.tsx | 15 ++++++++------- src/features/lecture-detail/hooks/use-tariff.ts | 14 ++++++++------ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/codegen.ts b/codegen.ts index 47aebe23..59c5f940 100644 --- a/codegen.ts +++ b/codegen.ts @@ -3,7 +3,7 @@ import "dotenv/config"; const config: CodegenConfig = { schema: [ - // `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, // Серверная схема + `${process.env.APP_ENDPOINT}${process.env.GRAPHQL_URI}`, "src/api/schema.graphql", ], documents: ["src/**/*.graphql"], diff --git a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx index 274ba6d5..16b76eb8 100644 --- a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx +++ b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx @@ -36,16 +36,17 @@ const LectureDetailContainer: FC = () => { fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK, }); - if ( - loadingLecture || - loadingLectureHomeWork || - loadingTrainingLectures || - !tariffHomework - ) + if (loadingLecture || loadingTrainingLectures) { return ; + } - if (!dataLecture || !lectureId || !dataTrainingLectures) + if (tariffHomework && loadingLectureHomeWork) { + return ; + } + + if (!dataLecture || !lectureId || !dataTrainingLectures) { return ; + } return ( { const { data } = useTrainingPurchasesQuery(); const tariffHomework = useMemo(() => { - return ( - data?.trainingPurchases?.some( - (item) => - item?.trainingTariff.training?.id === trainingId && - item?.trainingTariff.homeWork - ) ?? false + if (!trainingId || !data?.trainingPurchases) { + return false; + } + + return data.trainingPurchases.some( + (item) => + item?.trainingTariff.training?.id === trainingId && + item?.trainingTariff.homeWork ); }, [data, trainingId]); From 8db1c07a5db1b2ab2b3ff4f7752b8b85ae95b9cd Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:51:54 +0300 Subject: [PATCH 21/21] =?UTF-8?q?QAGDEV-726=20-=20=D0=91=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D1=87=D0=BD=D0=B0=D1=8F=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BD=D0=B0=20=D1=82=D0=B0=D1=80=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=20=D0=A1=D0=B0=D0=BC=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 2 +- .../test/containers/test-container.tsx | 175 ------------------ src/features/test/views/test-view.tsx | 166 ----------------- src/pages/test.tsx | 9 - 4 files changed, 1 insertion(+), 351 deletions(-) delete mode 100644 src/features/test/containers/test-container.tsx delete mode 100644 src/features/test/views/test-view.tsx delete mode 100644 src/pages/test.tsx diff --git a/.env.development b/.env.development index ea70b39b..b0aedc46 100644 --- a/.env.development +++ b/.env.development @@ -18,4 +18,4 @@ VITE_LECTURE_HOMEWORK_FILE_DELETE_URI=/lecture/:lectureId/homework/file/:fileId VITE_HOMEWORK_COMMENT_FILE_UPLOAD_URI=/homework/comment/:commentId/file VITE_HOMEWORK_COMMENT_FILE_GET_URI=/homework/comment/:commentId/file/:fileId VITE_HOMEWORK_COMMENT_FILE_DELETE_URI=/homework/comment/:commentId/file/:fileId -VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" +VITE_APP_ENDPOINT="http://app-stage.qa.guru:8080" \ No newline at end of file diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx deleted file mode 100644 index a51f09f1..00000000 --- a/src/features/test/containers/test-container.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { FC, useState, useEffect } from "react"; -import { useLazyQuery } from "@apollo/client"; - -import { AppSpinner } from "shared/components/spinners"; -import NoDataErrorMessage from "shared/components/no-data-error-message"; -import { - useTestTestGroupsByIdQuery, - TestAnswerByQuestionDocument, -} from "api/graphql/generated/graphql"; - -import TestView from "../views/test-view"; - -// Типы для теста (пока без GraphQL, потом можно заменить на сгенерированные) -interface TestQuestion { - id: string; - text: string; -} - -interface TestAnswer { - id: string; - text: string; - correct: boolean; - testQuestion: TestQuestion; -} - -interface UserAnswer { - questionId: string; - answerId: string; -} - -const TestContainer: FC = () => { - // const { testId } = useParams<{ testId: string }>(); - const testId = "3"; - const [userAnswers, setUserAnswers] = useState([]); - const [isCompleted, setIsCompleted] = useState(false); - const [score, setScore] = useState(0); - const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [currentQuestionAnswers, setCurrentQuestionAnswers] = useState< - TestAnswer[] - >([]); - const [allLoadedAnswers, setAllLoadedAnswers] = useState([]); - const [answersLoading, setAnswersLoading] = useState(false); - - const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ - variables: { id: testId ?? "" }, - }); - - const [getTestAnswers] = useLazyQuery(TestAnswerByQuestionDocument); - - // Получаем список вопросов - const testQuestions = - testData?.testTestGroupsById?.testQuestions?.filter((q) => q != null) ?? []; - const currentQuestion = testQuestions[currentQuestionIndex]; - - // Загружаем ответы для текущего вопроса - useEffect(() => { - if (!currentQuestion?.id) return; - - // Проверяем, есть ли уже загруженные ответы для этого вопроса - const existingAnswers = allLoadedAnswers.filter( - (answer) => answer.testQuestion.id === currentQuestion.id - ); - - if (existingAnswers.length > 0) { - setCurrentQuestionAnswers(existingAnswers); - return; - } - - setAnswersLoading(true); - setCurrentQuestionAnswers([]); - - const fetchCurrentAnswers = async () => { - try { - const { data } = await getTestAnswers({ - variables: { questionId: currentQuestion.id }, - }); - - if (data?.testAnswerByQuestion) { - const answers = data.testAnswerByQuestion - .filter((answer: any) => answer != null) - .map((answer: any) => ({ - id: answer.id!, - text: answer.text!, - correct: answer.correct!, - testQuestion: { - id: currentQuestion.id!, - text: currentQuestion.text!, - }, - })); - - setCurrentQuestionAnswers(answers); - // Сохраняем все ответы для финальной проверки - setAllLoadedAnswers((prev) => [...prev, ...answers]); - } - } catch (error) { - console.error( - "Error fetching answers for question:", - currentQuestion.id, - error - ); - } finally { - setAnswersLoading(false); - } - }; - - fetchCurrentAnswers(); - }, [currentQuestion, getTestAnswers]); - - const handleAnswerSelect = (questionId: string, answerId: string) => { - setUserAnswers((prev) => { - const filtered = prev.filter((ua) => ua.questionId !== questionId); - return [...filtered, { questionId, answerId }]; - }); - }; - - const handleNextQuestion = () => { - if (currentQuestionIndex < testQuestions.length - 1) { - setCurrentQuestionIndex((prev) => prev + 1); - } else { - // Завершаем тест - handleSubmitTest(); - } - }; - - const handleSubmitTest = () => { - let correctAnswers = 0; - - userAnswers.forEach((userAnswer) => { - const answer = allLoadedAnswers.find( - (ta) => ta.id === userAnswer.answerId - ); - if (answer?.correct) { - correctAnswers++; - } - }); - - setScore(correctAnswers); - setIsCompleted(true); - }; - - // Проверяем, ответил ли пользователь на текущий вопрос - const currentAnswer = userAnswers.find( - (ua) => ua.questionId === currentQuestion?.id - ); - const isCurrentQuestionAnswered = !!currentAnswer; - - if (testLoading || answersLoading) return ; - if (!testData?.testTestGroupsById || !currentQuestion) - return ; - - // Приводим currentQuestion к правильному типу - const typedCurrentQuestion: TestQuestion = { - id: currentQuestion.id!, - text: currentQuestion.text!, - }; - - return ( - - ); -}; - -export default TestContainer; diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx deleted file mode 100644 index dd12f8b8..00000000 --- a/src/features/test/views/test-view.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { FC } from "react"; -import { - Box, - Button, - Card, - CardContent, - FormControl, - FormControlLabel, - Radio, - RadioGroup, - Typography, - Alert, - LinearProgress, -} from "@mui/material"; - -import { TestGroupDto } from "api/graphql/generated/graphql"; - -// Типы (дублируем из контейнера, потом вынесем в отдельный файл) -interface TestQuestion { - id: string; - text: string; -} - -interface TestAnswer { - id: string; - text: string; - correct: boolean; - testQuestion: TestQuestion; -} - -interface UserAnswer { - questionId: string; - answerId: string; -} - -interface TestViewProps { - testData: TestGroupDto; - testAnswers: TestAnswer[]; - userAnswers: UserAnswer[]; - isCompleted: boolean; - score: number; - currentQuestion: TestQuestion; - currentQuestionIndex: number; - totalQuestions: number; - isCurrentQuestionAnswered: boolean; - onAnswerSelect: (questionId: string, answerId: string) => void; - onNextQuestion: () => void; - onSubmitTest: () => void; -} - -const TestView: FC = ({ - testData, - testAnswers, - userAnswers, - isCompleted, - score, - currentQuestion, - currentQuestionIndex, - totalQuestions, - isCurrentQuestionAnswered, - onAnswerSelect, - onNextQuestion, - onSubmitTest, -}) => { - const getUserAnswerForQuestion = (questionId: string) => { - return userAnswers.find((ua) => ua.questionId === questionId)?.answerId; - }; - - const successThreshold = testData.successThreshold ?? 0; - const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; - const isPassed = score >= successThreshold; - - if (isCompleted) { - return ( - - - - - Тест завершён! - - - - {isPassed - ? `Поздравляем! Вы прошли тест с результатом ${score}/${totalQuestions}` - : `Тест не пройден. Результат: ${score}/${totalQuestions}. Требуется: ${successThreshold}`} - - - - Правильных ответов: {score} из {totalQuestions} - - - Проходной балл: {successThreshold} - - - - - ); - } - - const selectedAnswer = getUserAnswerForQuestion(currentQuestion.id); - const isLastQuestion = currentQuestionIndex === totalQuestions - 1; - - return ( - - - {testData.testName} - - - - - Вопрос {currentQuestionIndex + 1} из {totalQuestions} - - - - - - - - {currentQuestionIndex + 1}. {currentQuestion.text} - - - - - onAnswerSelect(currentQuestion.id, e.target.value) - } - > - {testAnswers.map((answer) => ( - } - label={answer.text} - /> - ))} - - - - - - - - {userAnswers.length} из {totalQuestions} вопросов отвечено - - - - - - ); -}; - -export default TestView; diff --git a/src/pages/test.tsx b/src/pages/test.tsx deleted file mode 100644 index d1f9955b..00000000 --- a/src/pages/test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { FC } from "react"; - -import TestContainer from "features/test/containers/test-container"; - -const TestPage: FC = () => { - return ; -}; - -export default TestPage;