diff --git a/api/seeds/testData/lessons.js b/api/seeds/testData/lessons.js index affb0a62..90efba07 100644 --- a/api/seeds/testData/lessons.js +++ b/api/seeds/testData/lessons.js @@ -67,6 +67,33 @@ export const russian = { }, }; +export const ukrainian = { + id: 10005, + name: 'Ukrainian', + status: 'Public', + _blocks: { + _indexesOfInteractive: [0], + _current: [ + { + id: '0b7e5d54-a78c-4340-abec-ee08713d43bc', + block_id: '7142e20a-0d30-47e5-aea8-546e1ec5e395', + _revisions: [ + { + content: { + data: { + question: 'Graded question', + }, + }, + type: 'gradedQuestion', + answer: {}, + revision: '06421c44-a853-4708-8f40-81c55a0e8862', + }, + ], + }, + ].map(assingParents), + }, +}; + export const french = { id: 20003, name: 'French', @@ -163,13 +190,14 @@ export const lessons = [ literature, french, russian, + ukrainian, ].map((lesson) => ({ id: lesson.id, name: lesson.name, status: lesson.status, })); -export const lessonBlockStructure = [math, french, russian].reduce( +export const lessonBlockStructure = [math, french, russian, ukrainian].reduce( (structure, lesson) => { const lessonStructure = lesson._blocks._current.map((structureItem) => ({ id: structureItem.id, @@ -184,21 +212,24 @@ export const lessonBlockStructure = [math, french, russian].reduce( [], ); -export const blocks = [math, french, russian].reduce((blocksList, lesson) => { - const lessonBlocks = lesson._blocks._current.reduce( - (revisions, structureItem) => { - const itemBlocks = structureItem._revisions.map((block) => ({ - ...block, - block_id: structureItem.block_id, - })); +export const blocks = [math, french, russian, ukrainian].reduce( + (blocksList, lesson) => { + const lessonBlocks = lesson._blocks._current.reduce( + (revisions, structureItem) => { + const itemBlocks = structureItem._revisions.map((block) => ({ + ...block, + block_id: structureItem.block_id, + })); - return [...revisions, ...itemBlocks]; - }, - [], - ); + return [...revisions, ...itemBlocks]; + }, + [], + ); - return [...blocksList, ...lessonBlocks]; -}, []); + return [...blocksList, ...lessonBlocks]; + }, + [], +); function assingParents(structureItem, index, list) { const parent = list[index - 1] || null; diff --git a/api/src/config/lessonService.js b/api/src/config/lessonService.js index 17810e56..b46b6335 100644 --- a/api/src/config/lessonService.js +++ b/api/src/config/lessonService.js @@ -6,4 +6,5 @@ export const lessonServiceErrors = { export const lessonServiceMessages = { LESSON_MSG_SUCCESS_ENROLL: 'messages.success_enroll', + LESSON_MSG_SUCCESS_REVIEW: 'messages.success_review', }; diff --git a/api/src/models/Result.js b/api/src/models/Result.js index 5cffdb71..51859473 100644 --- a/api/src/models/Result.js +++ b/api/src/models/Result.js @@ -118,6 +118,10 @@ class Result extends BaseModel { throw new BadRequestError(error); } } + + static setCorrectness({ resultId, correctness, meta }) { + return this.query().findById(resultId).patch({ correctness, meta }); + } } export default Result; diff --git a/api/src/models/UserRole.js b/api/src/models/UserRole.js index 1cfad636..e9b00b72 100644 --- a/api/src/models/UserRole.js +++ b/api/src/models/UserRole.js @@ -103,6 +103,7 @@ class UserRole extends BaseModel { .modifyGraph('results', (builder) => { builder.where('lesson_id', resourceId); builder.withGraphFetched('block'); + builder.orderBy('created_at'); }); } return query; diff --git a/api/src/services/lessons-management/controllers/reviewStudentReply.js b/api/src/services/lessons-management/controllers/reviewStudentReply.js new file mode 100644 index 00000000..82e6adf3 --- /dev/null +++ b/api/src/services/lessons-management/controllers/reviewStudentReply.js @@ -0,0 +1,52 @@ +const options = { + schema: { + params: { $ref: 'paramsLessonId#' }, + body: { + type: 'object', + properties: { + resultId: { type: 'string' }, + correctness: { type: 'number' }, + }, + required: ['resultId', 'correctness'], + }, + response: { + '4xx': { $ref: '4xx#' }, + '5xx': { $ref: '5xx#' }, + }, + }, + async onRequest(req) { + await this.auth({ req }); + }, + async preHandler({ user, params }) { + const { resources, roles } = this.config.globals; + + await this.access({ + userId: user.id, + resourceId: params.lessonId, + resourceType: resources.LESSON.name, + roleId: roles.MAINTAINER.id, + }); + }, +}; + +async function handler({ body, user }) { + const { + models: { Result }, + config: { + lessonService: { lessonServiceMessages: messages }, + }, + } = this; + + await Result.setCorrectness({ + resultId: body.resultId, + correctness: body.correctness, + meta: { + reviewer: user.id, + reviewedAt: new Date().toISOString(), + }, + }); + + return { message: messages.LESSON_MSG_SUCCESS_REVIEW }; +} + +export default { options, handler }; diff --git a/api/src/services/lessons-management/routes.js b/api/src/services/lessons-management/routes.js index 387146f8..02ecf768 100644 --- a/api/src/services/lessons-management/routes.js +++ b/api/src/services/lessons-management/routes.js @@ -11,6 +11,7 @@ import getAllStudents from './controllers/getAllStudents'; import studentsOptions from './controllers/studentsOptions'; import updateStatus from './controllers/updateStatus'; import statusOptions from './controllers/statusOptions'; +import reviewStudentReply from './controllers/reviewStudentReply'; export async function router(instance) { instance.get('/lessons', getLessons.options, getLessons.handler); @@ -63,4 +64,10 @@ export async function router(instance) { studentsOptions.options, studentsOptions.handler, ); + + instance.post( + '/review/:lessonId', + reviewStudentReply.options, + reviewStudentReply.handler, + ); } diff --git a/api/test/integration/lessonsManagementService.spec.js b/api/test/integration/lessonsManagementService.spec.js index b3f4afc3..2e193a4f 100644 --- a/api/test/integration/lessonsManagementService.spec.js +++ b/api/test/integration/lessonsManagementService.spec.js @@ -1,18 +1,22 @@ +/* eslint-disable no-underscore-dangle */ import { v4 } from 'uuid'; import build from '../../src/app'; import { - teacherMike, - teacherNathan, defaultPassword, studentJohn, + teacherMike, + teacherNathan, } from '../../seeds/testData/users'; -import { french } from '../../seeds/testData/lessons'; +import { french, ukrainian } from '../../seeds/testData/lessons'; import { authorizeUser, createLesson, prepareLessonFromSeed } from './utils'; -import { userServiceErrors as errors } from '../../src/config'; +import { + lessonServiceMessages, + userServiceErrors as errors, +} from '../../src/config'; describe('Maintainer flow', () => { const testContext = {}; @@ -532,4 +536,89 @@ describe('Maintainer flow', () => { expect(payload.total).toBe(0); }); }); + + describe('Result review mechanics', () => { + let lessonWithGradedQuestion; + let gradedQuestionResult; + const CORRECTNESS = 0.4; + + beforeAll(async () => { + lessonWithGradedQuestion = await createLesson({ + app: testContext.app, + credentials: teacherCredentials, + body: prepareLessonFromSeed(ukrainian), + }); + + await testContext.studentRequest({ + url: `lessons/${lessonWithGradedQuestion.lesson.id}/enroll`, + }); + + await testContext.studentRequest({ + url: `learn/lessons/${lessonWithGradedQuestion.lesson.id}/reply`, + body: { + action: 'start', + }, + }); + + await testContext.studentRequest({ + url: `learn/lessons/${lessonWithGradedQuestion.lesson.id}/reply`, + body: { + action: 'response', + blockId: + lessonWithGradedQuestion.lesson.blocks[ + ukrainian._blocks._indexesOfInteractive[0] + ].blockId, + revision: + lessonWithGradedQuestion.lesson.blocks[ + ukrainian._blocks._indexesOfInteractive[0] + ].revision, + reply: { + files: [], + value: 'answer', + }, + }, + }); + + const response = await testContext.request({ + method: 'GET', + url: `lessons/${lessonWithGradedQuestion.lesson.id}/students`, + }); + + const [student] = JSON.parse(response.payload).students; + [, gradedQuestionResult] = student.results; + }); + + it('correctness should be null after student`s response', () => { + expect(gradedQuestionResult.correctness).toBe(null); + }); + + it('should successfully set correctness', async () => { + const response = await testContext.request({ + url: `review/${lessonWithGradedQuestion.lesson.id}`, + body: { + resultId: gradedQuestionResult.id, + correctness: CORRECTNESS, + }, + }); + + const payload = JSON.parse(response.payload); + + expect(response.statusCode).toBe(200); + expect(payload).toMatchObject({ + message: lessonServiceMessages.LESSON_MSG_SUCCESS_REVIEW, + }); + }); + + it('correctness should be changed successfully', async () => { + const response = await testContext.request({ + method: 'GET', + url: `lessons/${lessonWithGradedQuestion.lesson.id}/students`, + }); + + const [student] = JSON.parse(response.payload).students; + const [, newResult] = student.results; + + expect(newResult.correctness).toBe(CORRECTNESS); + }); + }); }); diff --git a/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.jsx b/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.jsx index 315c28ab..2d207582 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.jsx @@ -2,6 +2,7 @@ import T from 'prop-types'; import LearnContext from '@sb-ui/contexts/LearnContext'; import ResultItem from '@sb-ui/pages/Teacher/LessonStudents/LessonResults/ResultItem'; +import { BLOCKS_TYPE_LIST_RATED } from '@sb-ui/pages/Teacher/LessonStudents/LessonResults/ResultItem/constants'; import { useBlockIcons } from '@sb-ui/pages/Teacher/LessonStudents/LessonResults/useBlockIcons'; import BlockElement from '@sb-ui/pages/User/LearnPage/BlockElement'; import { LearnWrapper } from '@sb-ui/pages/User/LearnPage/LearnPage.styled'; @@ -14,37 +15,44 @@ const InteractiveResults = ({ interactiveResults }) => { return ( {} }}> - {interactiveResults.map(({ block, correctness, time, data }) => { - const isResult = !!correctness || correctness === 0; - return ( - - } - > - {isResult && ( - - { + const isResult = + !!correctness || + correctness === 0 || + BLOCKS_TYPE_LIST_RATED.includes(block?.type); + return ( + - - )} - - ); - })} + } + > + {isResult && ( + + + + )} + + ); + }, + )} ); diff --git a/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.styled.js b/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.styled.js index 0a6f8081..7223b09a 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.styled.js +++ b/front/src/pages/Teacher/LessonStudents/LessonResults/InteractiveResults/InteractiveResults.styled.js @@ -17,6 +17,9 @@ export const Panel = styled(PanelAntd).attrs({ .ant-collapse-header { pointer-events: ${(props) => (props.$isResult ? 'auto' : 'none')}; } + .ant-collapse-content { + pointer-events: all; + } pointer-events: none; padding: 0.5rem 1rem; `; diff --git a/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResulItem.styled.js b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResulItem.styled.js index 84e0b510..afbf8f32 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResulItem.styled.js +++ b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResulItem.styled.js @@ -42,4 +42,12 @@ export const Correctness = styled.div` flex: 1 0 33%; display: flex; justify-content: flex-end; + align-items: center; +`; + +export const RateWrapper = styled.div` + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: white; + border-radius: 0.5rem; `; diff --git a/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResultItem.jsx b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResultItem.jsx index b6a2ba21..c11ff18c 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResultItem.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/ResultItem.jsx @@ -1,12 +1,29 @@ +import { message, Rate } from 'antd'; import T from 'prop-types'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useMutation } from 'react-query'; import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; +import { BLOCKS_TYPE_LIST_RATED } from '@sb-ui/pages/Teacher/LessonStudents/LessonResults/ResultItem/constants'; +import { putReview } from '@sb-ui/utils/api/v1/teacher'; + import { getTitleAndIcon } from './getTitleAndIcon'; import * as S from './ResulItem.styled'; -const ResultItem = ({ icons, block, correctness, time, showCircle }) => { +const convertRateToCorrectness = (star) => star * 0.2; +const convertCorrectnessToRate = (correctness) => + parseFloat((correctness / 0.2).toFixed(1)); + +const ResultItem = ({ + id, + lessonId, + icons, + block, + correctness, + time, + showCircle, +}) => { const { t } = useTranslation(['teacher', 'editorjs']); const { icon, title } = useMemo( () => getTitleAndIcon(icons, block, t), @@ -21,6 +38,41 @@ const ResultItem = ({ icons, block, correctness, time, showCircle }) => { [t, time], ); + const updateReviewMutation = useMutation(putReview, { + onSuccess: () => { + message.success({ + content: t('lesson_students_results.grade.success'), + duration: 2, + }); + }, + onError: () => { + message.error({ + content: t('lesson_students_results.grade.error'), + duration: 2, + }); + }, + }); + + const handleChangeRate = useCallback( + (number) => { + updateReviewMutation.mutate({ + correctness: convertRateToCorrectness(number), + resultId: id, + lessonId, + }); + }, + [id, lessonId, updateReviewMutation], + ); + + const handleRateWrapper = useCallback((e) => { + e.stopPropagation(); + }, []); + + const showRate = useMemo( + () => BLOCKS_TYPE_LIST_RATED.includes(block.type), + [block.type], + ); + return ( @@ -29,14 +81,28 @@ const ResultItem = ({ icons, block, correctness, time, showCircle }) => { {blockTime} - {showCircle && ( - <> + {showRate && ( + + index + 1} + /> + + )} + {showCircle && !showRate && ( + {correctness === 1 ? ( ) : ( )} - > + )} @@ -44,8 +110,13 @@ const ResultItem = ({ icons, block, correctness, time, showCircle }) => { }; ResultItem.propTypes = { + id: T.string, icons: T.shape({}), - block: T.shape({}), + lessonId: T.number, + block: T.shape({ + blockId: T.string, + type: T.string, + }), correctness: T.number, time: T.number, showCircle: T.bool, diff --git a/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/constants.js b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/constants.js new file mode 100644 index 00000000..19589d6c --- /dev/null +++ b/front/src/pages/Teacher/LessonStudents/LessonResults/ResultItem/constants.js @@ -0,0 +1,3 @@ +import { BLOCKS_TYPE } from '@sb-ui/pages/User/LearnPage/BlockElement/types'; + +export const BLOCKS_TYPE_LIST_RATED = [BLOCKS_TYPE.GRADED_QUESTION]; diff --git a/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.jsx b/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.jsx index 61d517d9..d5ca6c86 100644 --- a/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.jsx @@ -45,7 +45,7 @@ const GradedQuestion = ({ blockId, revision, content, reply, isSolved }) => { {isSolved ? ( {value && {value}} - {files.length && ( + {files.length > 0 && ( {files.map(({ location, name }) => ( diff --git a/front/src/resources/lang/en/teacher.js b/front/src/resources/lang/en/teacher.js index 61aaf34e..901371e1 100644 --- a/front/src/resources/lang/en/teacher.js +++ b/front/src/resources/lang/en/teacher.js @@ -198,6 +198,10 @@ export default { finish: 'Finish', seconds: '{{time}} seconds', short_seconds: '{{time}} sec.', + grade: { + success: 'Grade success!', + error: 'Grade failed!', + }, }, students: { title: 'Lessons students ({{studentsCount}})', diff --git a/front/src/resources/lang/ru/teacher.js b/front/src/resources/lang/ru/teacher.js index 28ef3fe1..574fa3e8 100644 --- a/front/src/resources/lang/ru/teacher.js +++ b/front/src/resources/lang/ru/teacher.js @@ -177,6 +177,10 @@ export default { finish: 'Конец', seconds: '{{time}} секунд', short_seconds: '{{time}} сек.', + grade: { + success: 'Оценка выставлена успешно!', + error: 'Не удалось выставить оценку', + }, }, lesson_students: { title: 'Студенты урока ({{studentsCount}})', diff --git a/front/src/stories/atoms/LessonResults/LessonResults.stories.mdx b/front/src/stories/atoms/LessonResults/LessonResults.stories.mdx index b8ba28b8..23483448 100644 --- a/front/src/stories/atoms/LessonResults/LessonResults.stories.mdx +++ b/front/src/stories/atoms/LessonResults/LessonResults.stories.mdx @@ -1,7 +1,10 @@ import { Meta, Story, Canvas } from '@storybook/addon-docs'; import LessonResults from '@sb-ui/pages/Teacher/LessonStudents/LessonResults'; -import { LearnContextDecorator } from '@sb-ui/stories/decorators'; +import { + LearnContextDecorator, + QueryContextDecorator, +} from '@sb-ui/stories/decorators'; import { exampleData, allBlocksData, @@ -14,9 +17,11 @@ import { title="atoms/LessonResults" decorators={[ (Story) => ( - - - + + + + + ), ]} component={LessonResults} diff --git a/front/src/stories/atoms/LessonResults/data.js b/front/src/stories/atoms/LessonResults/data.js index ab87d666..8d6fd77f 100644 --- a/front/src/stories/atoms/LessonResults/data.js +++ b/front/src/stories/atoms/LessonResults/data.js @@ -3,6 +3,7 @@ export const exampleData = { { action: 'start', createdAt: '2021-09-21T11:12:07.697Z' }, { action: 'response', + lessonId: 1, block: { id: '1', revision: '1', diff --git a/front/src/stories/decorators.js b/front/src/stories/decorators.js index 37a73186..38390fb8 100644 --- a/front/src/stories/decorators.js +++ b/front/src/stories/decorators.js @@ -1,10 +1,12 @@ import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; +import { QueryClientProvider } from 'react-query'; // eslint-disable-next-line import/no-extraneous-dependencies import { action } from '@storybook/addon-actions'; import LearnContext from '@sb-ui/contexts/LearnContext'; import { getConfig } from '@sb-ui/pages/Teacher/LessonEdit/utils'; +import { queryClient } from '@sb-ui/query'; import EditorJs from '@sb-ui/utils/editorjs/EditorJsContainer/EditorJsContainer'; const newConfigTool = (configTools, allowedToolbox) => { @@ -31,6 +33,14 @@ EditorJsDecorator.propTypes = { allowedToolbox: PropTypes.arrayOf(PropTypes.string), }; +export const QueryContextDecorator = ({ children }) => ( + {children} +); + +QueryContextDecorator.propTypes = { + children: PropTypes.node, +}; + export const LearnContextDecorator = ({ children }) => { const handleInteractiveClick = (params) => { action('Interactive click')(params); diff --git a/front/src/utils/api/v1/teacher.js b/front/src/utils/api/v1/teacher.js index 8f1b70ba..c2c1f7b9 100644 --- a/front/src/utils/api/v1/teacher.js +++ b/front/src/utils/api/v1/teacher.js @@ -12,6 +12,11 @@ export const putLesson = async (params) => { return data; }; +export const putReview = async (params) => { + const { data } = await api.post(`${PATH}/review/${params.lessonId}`, params); + return data; +}; + export const getLesson = async ({ queryKey }) => { const [, { id }] = queryKey;
{value}