From 323b3823fb14c23e5170640d09c0c541c6e47744 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Wed, 13 Oct 2021 13:34:19 +0300 Subject: [PATCH 01/53] feat: review route --- api/seeds/testData/lessons.js | 59 ++++++++--- api/src/config/lessonService.js | 1 + .../controllers/reviewStudentAnswer.js | 47 +++++++++ api/src/services/lessons-management/routes.js | 7 ++ .../lessonsManagementService.spec.js | 97 ++++++++++++++++++- 5 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 api/src/services/lessons-management/controllers/reviewStudentAnswer.js 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/services/lessons-management/controllers/reviewStudentAnswer.js b/api/src/services/lessons-management/controllers/reviewStudentAnswer.js new file mode 100644 index 00000000..aa241125 --- /dev/null +++ b/api/src/services/lessons-management/controllers/reviewStudentAnswer.js @@ -0,0 +1,47 @@ +const options = { + schema: { + params: { $ref: 'paramsLessonId#' }, + body: { + type: 'object', + properties: { + id: { type: 'string' }, + correctness: { type: 'number' }, + }, + required: ['id', '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 }) { + const { + models: { Result }, + config: { + lessonService: { lessonServiceMessages: messages }, + }, + } = this; + + await Result.query() + .findById(body.id) + .patch({ correctness: body.correctness }); + + 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..8387388a 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 reviewStudentAnswer from './controllers/reviewStudentAnswer'; 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', + reviewStudentAnswer.options, + reviewStudentAnswer.handler, + ); } diff --git a/api/test/integration/lessonsManagementService.spec.js b/api/test/integration/lessonsManagementService.spec.js index b3f4afc3..2509375f 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: { + id: 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); + }); + }); }); From b2cbe24412fb80dcd91cdb7eee2b0ecff5d5283a Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Wed, 13 Oct 2021 13:50:13 +0300 Subject: [PATCH 02/53] refactor: create the setCorrectness results method --- api/src/models/Result.js | 4 ++++ .../lessons-management/controllers/reviewStudentAnswer.js | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/api/src/models/Result.js b/api/src/models/Result.js index 3c75ac91..e8462b3b 100644 --- a/api/src/models/Result.js +++ b/api/src/models/Result.js @@ -103,6 +103,10 @@ class Result extends BaseModel { throw new BadRequestError(error); } } + + static setCorrectness({ resultId, correctness }) { + return this.query().findById(resultId).patch({ correctness }); + } } export default Result; diff --git a/api/src/services/lessons-management/controllers/reviewStudentAnswer.js b/api/src/services/lessons-management/controllers/reviewStudentAnswer.js index aa241125..af4f4228 100644 --- a/api/src/services/lessons-management/controllers/reviewStudentAnswer.js +++ b/api/src/services/lessons-management/controllers/reviewStudentAnswer.js @@ -37,9 +37,10 @@ async function handler({ body }) { }, } = this; - await Result.query() - .findById(body.id) - .patch({ correctness: body.correctness }); + await Result.setCorrectness({ + resultId: body.id, + correctness: body.correctness, + }); return { message: messages.LESSON_MSG_SUCCESS_REVIEW }; } From 36a0ae9f46a1579f96ac1c0bb318d06005b8d33f Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Thu, 14 Oct 2021 10:05:01 +0300 Subject: [PATCH 03/53] fix: PR comments --- api/src/models/Result.js | 4 ++-- .../{reviewStudentAnswer.js => reviewStudentReply.js} | 6 +++++- api/src/services/lessons-management/routes.js | 6 +++--- 3 files changed, 10 insertions(+), 6 deletions(-) rename api/src/services/lessons-management/controllers/{reviewStudentAnswer.js => reviewStudentReply.js} (88%) diff --git a/api/src/models/Result.js b/api/src/models/Result.js index e8462b3b..1e42cb5e 100644 --- a/api/src/models/Result.js +++ b/api/src/models/Result.js @@ -104,8 +104,8 @@ class Result extends BaseModel { } } - static setCorrectness({ resultId, correctness }) { - return this.query().findById(resultId).patch({ correctness }); + static setCorrectness({ resultId, correctness, meta }) { + return this.query().findById(resultId).patch({ correctness, meta }); } } diff --git a/api/src/services/lessons-management/controllers/reviewStudentAnswer.js b/api/src/services/lessons-management/controllers/reviewStudentReply.js similarity index 88% rename from api/src/services/lessons-management/controllers/reviewStudentAnswer.js rename to api/src/services/lessons-management/controllers/reviewStudentReply.js index af4f4228..88530391 100644 --- a/api/src/services/lessons-management/controllers/reviewStudentAnswer.js +++ b/api/src/services/lessons-management/controllers/reviewStudentReply.js @@ -29,7 +29,7 @@ const options = { }, }; -async function handler({ body }) { +async function handler({ body, user }) { const { models: { Result }, config: { @@ -40,6 +40,10 @@ async function handler({ body }) { await Result.setCorrectness({ resultId: body.id, correctness: body.correctness, + meta: { + reviewer: user.id, + reviewedAt: new Date().toISOString(), + }, }); return { message: messages.LESSON_MSG_SUCCESS_REVIEW }; diff --git a/api/src/services/lessons-management/routes.js b/api/src/services/lessons-management/routes.js index 8387388a..02ecf768 100644 --- a/api/src/services/lessons-management/routes.js +++ b/api/src/services/lessons-management/routes.js @@ -11,7 +11,7 @@ import getAllStudents from './controllers/getAllStudents'; import studentsOptions from './controllers/studentsOptions'; import updateStatus from './controllers/updateStatus'; import statusOptions from './controllers/statusOptions'; -import reviewStudentAnswer from './controllers/reviewStudentAnswer'; +import reviewStudentReply from './controllers/reviewStudentReply'; export async function router(instance) { instance.get('/lessons', getLessons.options, getLessons.handler); @@ -67,7 +67,7 @@ export async function router(instance) { instance.post( '/review/:lessonId', - reviewStudentAnswer.options, - reviewStudentAnswer.handler, + reviewStudentReply.options, + reviewStudentReply.handler, ); } From 025f82bab033d8bb4ba7c634ba96cbf4cbecc69e Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Tue, 12 Oct 2021 14:38:39 +0300 Subject: [PATCH 04/53] fix: fix auth UI bugs --- front/src/hooks/useAuthentication.js | 4 ++-- front/src/hooks/useAuthentication.test.js | 4 ++-- front/src/pages/SignIn/SignInForm/SignInForm.jsx | 5 ++++- front/src/pages/SignUp/SignUpForm/SignUpForm.jsx | 7 +++++-- front/src/resources/lang/en/sign_in.js | 1 + front/src/resources/lang/en/sign_up.js | 3 +++ front/src/resources/lang/ru/sign_in.js | 1 + front/src/resources/lang/ru/sign_up.js | 3 +++ 8 files changed, 21 insertions(+), 7 deletions(-) diff --git a/front/src/hooks/useAuthentication.js b/front/src/hooks/useAuthentication.js index 8e40c597..56b9f04e 100644 --- a/front/src/hooks/useAuthentication.js +++ b/front/src/hooks/useAuthentication.js @@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom'; import { setJWT } from '@sb-ui/utils/jwt'; import { HOME } from '@sb-ui/utils/paths'; -export const useAuthentication = (requestFunc) => { +export const useAuthentication = ({ requestFunc, message }) => { const { t } = useTranslation(); const history = useHistory(); const [error, setError] = useState(null); @@ -20,7 +20,7 @@ export const useAuthentication = (requestFunc) => { if (status.toString().startsWith('2')) { setJWT(data); MessageAntd.success({ - content: t('sign_up:email_verification'), + content: t(message), duration: 2, }); history.push(HOME); diff --git a/front/src/hooks/useAuthentication.test.js b/front/src/hooks/useAuthentication.test.js index c3b8928b..1e6e6097 100644 --- a/front/src/hooks/useAuthentication.test.js +++ b/front/src/hooks/useAuthentication.test.js @@ -36,7 +36,7 @@ describe('Test useAuthentication', () => { useTranslation.mockImplementation(() => ({ t: () => WRONG_CREDENTIALS, })); - const [auth] = await useAuthentication(postSignIn); + const [auth] = await useAuthentication({ requestFunc: postSignIn }); postSignIn.mockImplementation(() => ({ status: 401, data: { @@ -65,7 +65,7 @@ describe('Test useAuthentication', () => { useTranslation.mockImplementation(() => ({ t: (x) => x, })); - const [auth] = await useAuthentication(postSignIn); + const [auth] = await useAuthentication({ requestFunc: postSignIn }); const setJWTMocked = jest.fn(); setJWT.mockImplementation(setJWTMocked); postSignIn.mockImplementation(() => ({ diff --git a/front/src/pages/SignIn/SignInForm/SignInForm.jsx b/front/src/pages/SignIn/SignInForm/SignInForm.jsx index 2807760c..0408e232 100644 --- a/front/src/pages/SignIn/SignInForm/SignInForm.jsx +++ b/front/src/pages/SignIn/SignInForm/SignInForm.jsx @@ -34,7 +34,10 @@ const SignInForm = () => { }), [t], ); - const [auth, error, setError, loading] = useAuthentication(postSignIn); + const [auth, error, setError, loading] = useAuthentication({ + requestFunc: postSignIn, + message: 'sign_in:welcome', + }); const [message, setMessage] = useState(''); const [isFormErrors, setIsFormErrors] = useState(false); diff --git a/front/src/pages/SignUp/SignUpForm/SignUpForm.jsx b/front/src/pages/SignUp/SignUpForm/SignUpForm.jsx index 890a7386..47a1ea59 100644 --- a/front/src/pages/SignUp/SignUpForm/SignUpForm.jsx +++ b/front/src/pages/SignUp/SignUpForm/SignUpForm.jsx @@ -19,7 +19,10 @@ const SignUpForm = () => { const [form] = Form.useForm(); - const [auth, error, setError, loading] = useAuthentication(postSignUp); + const [auth, error, setError, loading] = useAuthentication({ + requestFunc: postSignUp, + message: 'sign_up:email_verification', + }); const [message, setMessage] = useState(''); const [isFormErrors, setIsFormErrors] = useState(false); @@ -86,7 +89,7 @@ const SignUpForm = () => { setError(null)} - message={error} + message={t(error)} type="error" showIcon closable diff --git a/front/src/resources/lang/en/sign_in.js b/front/src/resources/lang/en/sign_in.js index 76ddc2d0..bf8c7e6a 100644 --- a/front/src/resources/lang/en/sign_in.js +++ b/front/src/resources/lang/en/sign_in.js @@ -1,4 +1,5 @@ export default { + welcome: 'Welcome', no_account: 'Don’t have an account? Sign up!', forgot_password: 'Forgot password?', button: 'Sign in', diff --git a/front/src/resources/lang/en/sign_up.js b/front/src/resources/lang/en/sign_up.js index ec1724fa..0275bf34 100644 --- a/front/src/resources/lang/en/sign_up.js +++ b/front/src/resources/lang/en/sign_up.js @@ -1,4 +1,7 @@ export default { + errors: { + unique_violation: 'E-mail already registered', + }, have_account: 'Already have an account? Sign in!', title: 'Sign up', first_name: { diff --git a/front/src/resources/lang/ru/sign_in.js b/front/src/resources/lang/ru/sign_in.js index be5a2dac..fad89018 100644 --- a/front/src/resources/lang/ru/sign_in.js +++ b/front/src/resources/lang/ru/sign_in.js @@ -1,4 +1,5 @@ export default { + welcome: 'Добро пожаловать', no_account: 'Нет аккаунта? Зарегестрируйтесь!', forgot_password: 'Забыли пароль?', button: 'Войти', diff --git a/front/src/resources/lang/ru/sign_up.js b/front/src/resources/lang/ru/sign_up.js index b5a321b4..687aa2b4 100644 --- a/front/src/resources/lang/ru/sign_up.js +++ b/front/src/resources/lang/ru/sign_up.js @@ -1,4 +1,7 @@ export default { + errors: { + unique_violation: 'E-mail уже зарегистрирован', + }, have_account: 'Уже есть аккаунт? Ввойдите!', title: 'Регистрация', first_name: { From 92c45ec1519cbad075496e576ebec42541af49c3 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Mon, 11 Oct 2021 10:05:18 +0300 Subject: [PATCH 05/53] feat: show a meaningful message on HTTP 400 --- front/src/resources/lang/en/editorjs.js | 2 +- front/src/resources/lang/ru/editorjs.js | 1 + .../editorjs/EditorJsContainer/EditorJsContainer.jsx | 1 + front/src/utils/editorjs/attach-plugin/plugin.js | 8 ++++++-- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/front/src/resources/lang/en/editorjs.js b/front/src/resources/lang/en/editorjs.js index 7e31092a..bf098a77 100644 --- a/front/src/resources/lang/en/editorjs.js +++ b/front/src/resources/lang/en/editorjs.js @@ -29,7 +29,7 @@ export default { title: 'File', select: 'Select a file to upload', error: 'Failed to upload the file', - description: 'Attach a file', + bad_request: 'Invalid file type or file size limit exceeded', }, paragraph: { title: 'Text', diff --git a/front/src/resources/lang/ru/editorjs.js b/front/src/resources/lang/ru/editorjs.js index 7d98c8df..f8fc39ac 100644 --- a/front/src/resources/lang/ru/editorjs.js +++ b/front/src/resources/lang/ru/editorjs.js @@ -29,6 +29,7 @@ export default { title: 'Прикрепить файл', select: 'Выберите файл для загрузки', error: 'Неудалось загрузить файл', + bad_request: 'Неправильный тип файла или превышен лимит на размер файла', }, paragraph: { title: 'Текст', diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx index a2734852..f59e4277 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx @@ -158,6 +158,7 @@ const EditorJsContainer = forwardRef((props, ref) => { title: t('tools.attach.title'), select: t('tools.attach.select'), error: t('tools.attach.error'), + bad_request: t('tools.attach.bad_request'), }, image: { title: t('tools.image.title'), diff --git a/front/src/utils/editorjs/attach-plugin/plugin.js b/front/src/utils/editorjs/attach-plugin/plugin.js index 567d8852..22c3a33b 100644 --- a/front/src/utils/editorjs/attach-plugin/plugin.js +++ b/front/src/utils/editorjs/attach-plugin/plugin.js @@ -73,10 +73,14 @@ export default class AttachPlugin { } }; - onError = () => { + onError = ({ response }) => { this.isLoading = false; this.nodes.fileInput.disabled = false; - this.nodes.label.innerText = this.api.i18n.t('error'); + if (response.status === 400) { + this.nodes.label.innerText = this.api.i18n.t('bad_request'); + } else { + this.nodes.label.innerText = this.api.i18n.t('error'); + } }; onChange = async () => { From 7c9789d7e6c4c137eacd025c9a6dd1e8057aa8d7 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Mon, 11 Oct 2021 16:49:41 +0300 Subject: [PATCH 06/53] fix: PR comments --- front/src/resources/lang/en/editorjs.js | 3 ++- front/src/resources/lang/ru/editorjs.js | 3 ++- .../editorjs/EditorJsContainer/EditorJsContainer.jsx | 3 ++- front/src/utils/editorjs/attach-plugin/plugin.js | 8 +++----- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/front/src/resources/lang/en/editorjs.js b/front/src/resources/lang/en/editorjs.js index bf098a77..ebd371c2 100644 --- a/front/src/resources/lang/en/editorjs.js +++ b/front/src/resources/lang/en/editorjs.js @@ -29,7 +29,8 @@ export default { title: 'File', select: 'Select a file to upload', error: 'Failed to upload the file', - bad_request: 'Invalid file type or file size limit exceeded', + file_size: 'File size limit exceeded', + file_type: 'Invalid file type', }, paragraph: { title: 'Text', diff --git a/front/src/resources/lang/ru/editorjs.js b/front/src/resources/lang/ru/editorjs.js index f8fc39ac..adbf1583 100644 --- a/front/src/resources/lang/ru/editorjs.js +++ b/front/src/resources/lang/ru/editorjs.js @@ -29,7 +29,8 @@ export default { title: 'Прикрепить файл', select: 'Выберите файл для загрузки', error: 'Неудалось загрузить файл', - bad_request: 'Неправильный тип файла или превышен лимит на размер файла', + file_size: 'Превышен лимит размера файла', + file_type: 'Неправильный тип файла', }, paragraph: { title: 'Текст', diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx index f59e4277..feb590ed 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx @@ -157,8 +157,9 @@ const EditorJsContainer = forwardRef((props, ref) => { attach: { title: t('tools.attach.title'), select: t('tools.attach.select'), + file_size_limit: t('tools.attach.file_size'), + file_invalid_type: t('tools.attach.file_type'), error: t('tools.attach.error'), - bad_request: t('tools.attach.bad_request'), }, image: { title: t('tools.image.title'), diff --git a/front/src/utils/editorjs/attach-plugin/plugin.js b/front/src/utils/editorjs/attach-plugin/plugin.js index 22c3a33b..49bda507 100644 --- a/front/src/utils/editorjs/attach-plugin/plugin.js +++ b/front/src/utils/editorjs/attach-plugin/plugin.js @@ -76,11 +76,9 @@ export default class AttachPlugin { onError = ({ response }) => { this.isLoading = false; this.nodes.fileInput.disabled = false; - if (response.status === 400) { - this.nodes.label.innerText = this.api.i18n.t('bad_request'); - } else { - this.nodes.label.innerText = this.api.i18n.t('error'); - } + const [, errorMessage] = response?.data?.message?.split('.'); + const labelKey = response?.status === 400 ? errorMessage : 'error'; + this.nodes.label.innerText = this.api.i18n.t(labelKey); }; onChange = async () => { From 92824d0d1f82a9d7f13315ae706c99e311f1b52c Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Wed, 13 Oct 2021 13:46:14 +0300 Subject: [PATCH 07/53] fix: the undefined file bugfix --- .../utils/editorjs/attach-plugin/plugin.js | 1 + front/src/utils/editorjs/utils.js | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/front/src/utils/editorjs/attach-plugin/plugin.js b/front/src/utils/editorjs/attach-plugin/plugin.js index 49bda507..88b41388 100644 --- a/front/src/utils/editorjs/attach-plugin/plugin.js +++ b/front/src/utils/editorjs/attach-plugin/plugin.js @@ -90,6 +90,7 @@ export default class AttachPlugin { onSuccess: this.onSuccess, onError: this.onError, }); + this.nodes.fileInput.disabled = false; }; preparePluginTitle() { diff --git a/front/src/utils/editorjs/utils.js b/front/src/utils/editorjs/utils.js index ff5d78b0..5582d3a8 100644 --- a/front/src/utils/editorjs/utils.js +++ b/front/src/utils/editorjs/utils.js @@ -51,18 +51,21 @@ export const uploadFile = async ({ parent, onSuccess, onError }) => { const { files: [file], } = parent; - formData.append('file', file); - const response = await api.post( - `${process.env.REACT_APP_SB_HOST}/api/v1/files`, - formData, - { - headers: { - 'content-type': 'multipart/form-data', + if (file) { + formData.append('file', file); + + const response = await api.post( + `${process.env.REACT_APP_SB_HOST}/api/v1/files`, + formData, + { + headers: { + 'content-type': 'multipart/form-data', + }, }, - }, - ); - onSuccess(response); + ); + onSuccess(response); + } } catch (e) { onError(e); } From 7f0ce564255e5fa28bd98ff78d87d21319358dd0 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Thu, 14 Oct 2021 09:51:35 +0300 Subject: [PATCH 08/53] feat: paragraph plugin title --- .../EditorJsContainer/EditorJsContainer.jsx | 3 +++ .../utils/editorjs/paragraph-plugin/paragraph.css | 13 +++++++++++++ .../utils/editorjs/paragraph-plugin/paragraph.js | 1 + 3 files changed, 17 insertions(+) diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx index feb590ed..ea847a63 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx @@ -172,6 +172,9 @@ const EditorJsContainer = forwardRef((props, ref) => { title: t('tools.next.title'), button: t('tools.next.title'), }, + paragraph: { + title: t('tools.paragraph.title'), + }, list: { title: t('tools.list.title'), }, diff --git a/front/src/utils/editorjs/paragraph-plugin/paragraph.css b/front/src/utils/editorjs/paragraph-plugin/paragraph.css index 1aeb8f04..78608baa 100644 --- a/front/src/utils/editorjs/paragraph-plugin/paragraph.css +++ b/front/src/utils/editorjs/paragraph-plugin/paragraph.css @@ -1,6 +1,19 @@ .ce-paragraph { line-height: 1.6em; outline: none; + display: flex; + flex-direction: column; + padding-top: 0; +} + +.ce-paragraph::before { + content: attr(title); + margin-bottom: 0.5rem; + cursor: default; + user-select: none; + font-size: 12px; + line-height: 20px; + color: rgba(0, 0, 0, 0.45); } .ce-paragraph[data-placeholder]:empty::before { diff --git a/front/src/utils/editorjs/paragraph-plugin/paragraph.js b/front/src/utils/editorjs/paragraph-plugin/paragraph.js index b2e3de09..0ad7237d 100644 --- a/front/src/utils/editorjs/paragraph-plugin/paragraph.js +++ b/front/src/utils/editorjs/paragraph-plugin/paragraph.js @@ -51,6 +51,7 @@ class Paragraph { div.classList.add(this._CSS.wrapper, this._CSS.block); div.contentEditable = false; div.dataset.placeholder = this.api.i18n.t(this._placeholder); + div.title = this.api.i18n.t('title'); if (!this.readOnly) { div.contentEditable = true; From 182c847320acbd3778943d91d4ffaa98ab75195e Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Fri, 15 Oct 2021 15:32:52 +0300 Subject: [PATCH 09/53] fix: PR comments --- front/src/utils/editorjs/paragraph-plugin/paragraph.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/front/src/utils/editorjs/paragraph-plugin/paragraph.css b/front/src/utils/editorjs/paragraph-plugin/paragraph.css index 78608baa..81cd819a 100644 --- a/front/src/utils/editorjs/paragraph-plugin/paragraph.css +++ b/front/src/utils/editorjs/paragraph-plugin/paragraph.css @@ -11,8 +11,8 @@ margin-bottom: 0.5rem; cursor: default; user-select: none; - font-size: 12px; - line-height: 20px; + font-size: 0.75rem; + line-height: 1.25rem; color: rgba(0, 0, 0, 0.45); } From b1f0c45b599b8eeefbec00e7070645dc6e74a302 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Tue, 12 Oct 2021 11:20:26 +0300 Subject: [PATCH 10/53] fix: enroll modal flash --- .../src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx | 4 +++- front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx | 4 +++- .../src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx | 4 +++- front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx index dc6eac92..29b27d89 100644 --- a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx @@ -38,7 +38,7 @@ const EnrollModalDesktop = () => { history.push(LEARN_COURSE_PAGE.replace(':id', id)); }, [history, id]); - const { data: responseData } = useQuery( + const { data: responseData, isFetching } = useQuery( [USER_COURSE_MODAL_BASE_KEY, { id }], getCourse, ); @@ -82,6 +82,8 @@ const EnrollModalDesktop = () => { }); }, [mutatePostEnroll, id, historyPushCourse]); + if (isFetching || responseData?.course?.isEnrolled) return null; + return ( { history.push(LEARN_COURSE_PAGE.replace(':id', id)); }, [history, id]); - const { data: responseData } = useQuery( + const { data: responseData, isFetching } = useQuery( [USER_COURSE_MODAL_BASE_KEY, { id }], getCourse, { keepPreviousData: true }, @@ -107,6 +107,8 @@ const EnrollModalMobile = () => { }); }, [historyPushCourse, id, mutatePostEnroll]); + if (isFetching || responseData?.course?.isEnrolled) return null; + return ( diff --git a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx index 41ea5349..3c67536a 100644 --- a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx @@ -38,7 +38,7 @@ const EnrollModalDesktop = () => { history.push(LEARN_PAGE.replace(':id', id)); }, [history, id]); - const { data: responseData } = useQuery( + const { data: responseData, isFetching } = useQuery( [USER_LESSON_MODAL_BASE_KEY, { id }], getLesson, ); @@ -83,6 +83,8 @@ const EnrollModalDesktop = () => { }); }, [mutatePostEnroll, id, historyPushLesson]); + if (isFetching || responseData?.lesson?.isEnrolled) return null; + return ( { history.push(LEARN_PAGE.replace(':id', id)); }, [history, id]); - const { data: responseData } = useQuery( + const { data: responseData, isFetching } = useQuery( [USER_LESSON_MODAL_BASE_KEY, { id }], getLesson, { keepPreviousData: true }, @@ -108,6 +108,8 @@ const EnrollModalMobile = () => { }); }, [historyPushLesson, id, mutatePostEnroll]); + if (isFetching || responseData?.lesson?.isEnrolled) return null; + return ( From 71db808447cd2abd89f4481f71ea4d9406172c50 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Tue, 12 Oct 2021 13:20:45 +0300 Subject: [PATCH 11/53] fix: PR comments --- .../src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx | 4 +++- front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx | 4 +++- .../src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx | 4 +++- front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx index 29b27d89..1deb34ea 100644 --- a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx @@ -82,7 +82,9 @@ const EnrollModalDesktop = () => { }); }, [mutatePostEnroll, id, historyPushCourse]); - if (isFetching || responseData?.course?.isEnrolled) return null; + if (isFetching || responseData?.course?.isEnrolled) { + return null; + } return ( { }); }, [historyPushCourse, id, mutatePostEnroll]); - if (isFetching || responseData?.course?.isEnrolled) return null; + if (isFetching || responseData?.course?.isEnrolled) { + return null; + } return ( diff --git a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx index 3c67536a..2a15bbf8 100644 --- a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx @@ -83,7 +83,9 @@ const EnrollModalDesktop = () => { }); }, [mutatePostEnroll, id, historyPushLesson]); - if (isFetching || responseData?.lesson?.isEnrolled) return null; + if (isFetching || responseData?.lesson?.isEnrolled) { + return null; + } return ( { }); }, [historyPushLesson, id, mutatePostEnroll]); - if (isFetching || responseData?.lesson?.isEnrolled) return null; + if (isFetching || responseData?.lesson?.isEnrolled) { + return null; + } return ( From 07d92dff01eb24b4f97e5f398b10f59ccfeaedb5 Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Wed, 13 Oct 2021 15:27:25 +0300 Subject: [PATCH 12/53] fix: enroll modal flash bugfix --- front/src/pages/User/CoursePage/CoursePage.jsx | 15 +++++++++++++-- front/src/pages/User/LearnPage/LearnPage.jsx | 14 +++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/front/src/pages/User/CoursePage/CoursePage.jsx b/front/src/pages/User/CoursePage/CoursePage.jsx index 2a1a6557..b5264ea6 100644 --- a/front/src/pages/User/CoursePage/CoursePage.jsx +++ b/front/src/pages/User/CoursePage/CoursePage.jsx @@ -1,12 +1,13 @@ import { Skeleton, Typography } from 'antd'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; -import { useParams } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import Public from '@sb-ui/components/resourceBlocks/Public'; import { PAGE_SIZE } from '@sb-ui/pages/User/Lessons/ResourcesList/constants'; import { getCourseLessons } from '@sb-ui/utils/api/v1/student'; +import { USER_HOME } from '@sb-ui/utils/paths'; import { USER_ENROLLED_COURSE } from '@sb-ui/utils/queries'; import { skeletonArray } from '@sb-ui/utils/utils'; @@ -16,6 +17,7 @@ const { Text } = Typography; const CoursePage = () => { const { t } = useTranslation('user'); + const history = useHistory(); const { id: courseId } = useParams(); const { data: responseData, isLoading } = useQuery( @@ -38,6 +40,15 @@ const CoursePage = () => { [responseData?.course.author], ); + useEffect( + () => () => { + if (history.action === 'POP') { + history.replace(USER_HOME); + } + }, + [history], + ); + return ( diff --git a/front/src/pages/User/LearnPage/LearnPage.jsx b/front/src/pages/User/LearnPage/LearnPage.jsx index 8a463dc4..886166f2 100644 --- a/front/src/pages/User/LearnPage/LearnPage.jsx +++ b/front/src/pages/User/LearnPage/LearnPage.jsx @@ -1,13 +1,15 @@ /* eslint no-use-before-define: "off" */ +import { useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router-dom'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; import Header from '@sb-ui/components/molecules/Header'; import LearnContext from '@sb-ui/contexts/LearnContext'; import InfoBlock from '@sb-ui/pages/User/LearnPage/InfoBlock'; import { getEnrolledLesson, postLessonById } from '@sb-ui/utils/api/v1/student'; import { sbPostfix } from '@sb-ui/utils/constants'; +import { LEARN_PAGE, USER_HOME } from '@sb-ui/utils/paths'; import LearnChunk from './LearnChunk'; import { useLearnChunks } from './useLearnChunks'; @@ -29,6 +31,16 @@ const LearnPage = () => { getEnrolledLesson, postLessonById, }); + const history = useHistory(); + + useEffect( + () => () => { + if (history.action === 'POP') { + history.replace(USER_HOME); + } + }, + [history], + ); return ( <> From 3509dca8d7aa0946f6da8efdf8a79fdb47b8c8cf Mon Sep 17 00:00:00 2001 From: Maksym Herasymov Date: Thu, 14 Oct 2021 10:24:27 +0300 Subject: [PATCH 13/53] fix: PR comments --- front/src/pages/User/CoursePage/CoursePage.jsx | 9 ++++++--- .../pages/User/EnrollCourseModal/EnrollModal.desktop.jsx | 5 ++++- .../pages/User/EnrollCourseModal/EnrollModal.mobile.jsx | 5 ++++- .../pages/User/EnrollLessonModal/EnrollModal.desktop.jsx | 5 ++++- .../pages/User/EnrollLessonModal/EnrollModal.mobile.jsx | 5 ++++- front/src/pages/User/LearnPage/LearnPage.jsx | 9 ++++++--- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/front/src/pages/User/CoursePage/CoursePage.jsx b/front/src/pages/User/CoursePage/CoursePage.jsx index b5264ea6..f67d43b2 100644 --- a/front/src/pages/User/CoursePage/CoursePage.jsx +++ b/front/src/pages/User/CoursePage/CoursePage.jsx @@ -2,7 +2,7 @@ import { Skeleton, Typography } from 'antd'; import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; import Public from '@sb-ui/components/resourceBlocks/Public'; import { PAGE_SIZE } from '@sb-ui/pages/User/Lessons/ResourcesList/constants'; @@ -15,9 +15,12 @@ import * as S from './CoursePage.styled'; const { Text } = Typography; +const HISTORY_BACK = 'POP'; + const CoursePage = () => { const { t } = useTranslation('user'); const history = useHistory(); + const location = useLocation(); const { id: courseId } = useParams(); const { data: responseData, isLoading } = useQuery( @@ -42,11 +45,11 @@ const CoursePage = () => { useEffect( () => () => { - if (history.action === 'POP') { + if (location.state.fromEnroll && history.action === HISTORY_BACK) { history.replace(USER_HOME); } }, - [history], + [history, location], ); return ( diff --git a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx index 1deb34ea..2d325360 100644 --- a/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollCourseModal/EnrollModal.desktop.jsx @@ -35,7 +35,10 @@ const EnrollModalDesktop = () => { }, [query, history]); const historyPushCourse = useCallback(() => { - history.push(LEARN_COURSE_PAGE.replace(':id', id)); + history.push({ + pathname: LEARN_COURSE_PAGE.replace(':id', id), + state: { fromEnroll: true }, + }); }, [history, id]); const { data: responseData, isFetching } = useQuery( diff --git a/front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx b/front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx index 1ba7d93d..79dc310b 100644 --- a/front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx +++ b/front/src/pages/User/EnrollCourseModal/EnrollModal.mobile.jsx @@ -53,7 +53,10 @@ const EnrollModalMobile = () => { const { id } = useParams(); const historyPushCourse = useCallback(() => { - history.push(LEARN_COURSE_PAGE.replace(':id', id)); + history.push({ + pathname: LEARN_COURSE_PAGE.replace(':id', id), + state: { fromEnroll: true }, + }); }, [history, id]); const { data: responseData, isFetching } = useQuery( diff --git a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx index 2a15bbf8..82417143 100644 --- a/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx +++ b/front/src/pages/User/EnrollLessonModal/EnrollModal.desktop.jsx @@ -35,7 +35,10 @@ const EnrollModalDesktop = () => { }, [query, history]); const historyPushLesson = useCallback(() => { - history.push(LEARN_PAGE.replace(':id', id)); + history.push({ + pathname: LEARN_PAGE.replace(':id', id), + state: { fromEnroll: true }, + }); }, [history, id]); const { data: responseData, isFetching } = useQuery( diff --git a/front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx b/front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx index 8f90b764..724b257b 100644 --- a/front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx +++ b/front/src/pages/User/EnrollLessonModal/EnrollModal.mobile.jsx @@ -53,7 +53,10 @@ const EnrollModalMobile = () => { const { id } = useParams(); const historyPushLesson = useCallback(() => { - history.push(LEARN_PAGE.replace(':id', id)); + history.push({ + pathname: LEARN_PAGE.replace(':id', id), + state: { fromEnroll: true }, + }); }, [history, id]); const { data: responseData, isFetching } = useQuery( diff --git a/front/src/pages/User/LearnPage/LearnPage.jsx b/front/src/pages/User/LearnPage/LearnPage.jsx index 886166f2..a322bc77 100644 --- a/front/src/pages/User/LearnPage/LearnPage.jsx +++ b/front/src/pages/User/LearnPage/LearnPage.jsx @@ -9,12 +9,14 @@ import LearnContext from '@sb-ui/contexts/LearnContext'; import InfoBlock from '@sb-ui/pages/User/LearnPage/InfoBlock'; import { getEnrolledLesson, postLessonById } from '@sb-ui/utils/api/v1/student'; import { sbPostfix } from '@sb-ui/utils/constants'; -import { LEARN_PAGE, USER_HOME } from '@sb-ui/utils/paths'; +import { USER_HOME } from '@sb-ui/utils/paths'; import LearnChunk from './LearnChunk'; import { useLearnChunks } from './useLearnChunks'; import * as S from './LearnPage.styled'; +const HISTORY_BACK = 'POP'; + const LearnPage = () => { const { t } = useTranslation('user'); const { id: lessonId } = useParams(); @@ -32,14 +34,15 @@ const LearnPage = () => { postLessonById, }); const history = useHistory(); + const location = useLocation(); useEffect( () => () => { - if (history.action === 'POP') { + if (location.state.fromEnroll && history.action === HISTORY_BACK) { history.replace(USER_HOME); } }, - [history], + [history, location], ); return ( From fc5261d8b8d1730d6e687eb42ae5bb94ed8a753f Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 10:55:28 +0300 Subject: [PATCH 14/53] fix: make lesson learn flow work correctly --- front/src/pages/User/LearnPage/useLearnChunks.js | 4 ++++ .../src/pages/User/LearnPage/useLearnChunks.test.js | 12 +++++++++++- front/src/utils/api/config.js | 1 - 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/front/src/pages/User/LearnPage/useLearnChunks.js b/front/src/pages/User/LearnPage/useLearnChunks.js index 6ffc629d..7776154e 100644 --- a/front/src/pages/User/LearnPage/useLearnChunks.js +++ b/front/src/pages/User/LearnPage/useLearnChunks.js @@ -53,6 +53,9 @@ export const createChunksFromBlocks = ({ if (!isPost && isFinished && isEmptyBlocks) { chunks.push([createFinishBlock(true)]); } + if (!isPost && chunks?.[0]?.[0]?.type !== 'start') { + chunks.splice(0, 0, [createStartBlock(true)]); + } return chunks; }; @@ -78,6 +81,7 @@ export const handleAnswer = ({ data: serverData, prevChunks }) => { if (interactiveBlock) { interactiveBlock.isSolved = true; } + return [ ...prevChunks, ...createChunksFromBlocks({ diff --git a/front/src/pages/User/LearnPage/useLearnChunks.test.js b/front/src/pages/User/LearnPage/useLearnChunks.test.js index 42f5ce77..b700fdb3 100644 --- a/front/src/pages/User/LearnPage/useLearnChunks.test.js +++ b/front/src/pages/User/LearnPage/useLearnChunks.test.js @@ -107,6 +107,7 @@ describe('Test useLearnChunks', () => { isPost: false, }); expect(chunks).toStrictEqual([ + [createStartBlock(true)], [createParagraphBlock(1, 'Paragraph1'), createFinishBlock(false)], ]); }); @@ -121,6 +122,8 @@ describe('Test useLearnChunks', () => { isPost: false, }); expect(chunks).toStrictEqual([ + [createStartBlock(true)], + [ createParagraphBlock(1, 'Paragraph1'), createQuizResultBlock(2, [true, true], [true, true]), @@ -139,6 +142,8 @@ describe('Test useLearnChunks', () => { isPost: false, }); expect(chunks).toStrictEqual([ + [createStartBlock(true)], + [createParagraphBlock(1, 'Paragraph1'), createNextBlock(2, false)], ]); }); @@ -153,6 +158,8 @@ describe('Test useLearnChunks', () => { isPost: false, }); expect(chunks).toStrictEqual([ + [createStartBlock(true)], + [ createParagraphBlock(1, 'Paragraph1'), createQuizResultBlock(2, [true, true], [true, true]), @@ -167,7 +174,10 @@ describe('Test useLearnChunks', () => { isFinished: true, isPost: false, }); - expect(chunks).toStrictEqual([[createFinishBlock(true)]]); + expect(chunks).toStrictEqual([ + [createStartBlock(true)], + [createFinishBlock(true)], + ]); }); }); diff --git a/front/src/utils/api/config.js b/front/src/utils/api/config.js index 13b91763..79c215bb 100644 --- a/front/src/utils/api/config.js +++ b/front/src/utils/api/config.js @@ -9,7 +9,6 @@ export const interactiveTypesBlocks = [ BLOCKS_TYPE.FILL_THE_GAP, BLOCKS_TYPE.BRICKS, BLOCKS_TYPE.MATCH, - BLOCKS_TYPE.FINISH, BLOCKS_TYPE.GRADED_QUESTION, ]; export const staticTypesBlocks = [ From b27066bcb8def03f9211870192d0e21528f08df5 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 11:03:02 +0300 Subject: [PATCH 15/53] feat: remove icon from Warning block --- .../User/LearnPage/BlockElement/Warning/Warning.jsx | 1 - .../BlockElement/Warning/Warning.styled.js | 13 +++---------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.jsx b/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.jsx index 66c3e54c..3efe8be9 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.jsx @@ -7,7 +7,6 @@ const Warning = ({ content }) => { return ( - {title} {message} diff --git a/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.styled.js b/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.styled.js index 5e603155..e11801c1 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/Warning/Warning.styled.js @@ -19,20 +19,13 @@ export const IconTitle = styled.div` } `; -export const Icon = styled.span.attrs((props) => ({ - children: props.children || props.emoji, - 'aria-label': 'hand-up', - role: 'img', -}))` - font-size: large; -`; - export const Title = styled.span` - padding-left: 1rem; font-weight: bold; text-align: center; `; export const Message = styled.span` - padding-left: 1rem; + @media (min-width: 767px) { + padding-left: 1rem; + } `; From 51d4acd114bab61c186a647e63999d517bca97e0 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 11:56:36 +0300 Subject: [PATCH 16/53] feat: add ignoring punctuation in closed question --- .../BlockElement/ClosedQuestion/Result/Result.jsx | 9 +++------ .../ClosedQuestion/Result/verifyAnswers.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/verifyAnswers.js diff --git a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/Result.jsx b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/Result.jsx index cc05d6ba..39448f3f 100644 --- a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/Result.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/Result.jsx @@ -9,6 +9,7 @@ import { import { ChunkWrapper } from '@sb-ui/pages/User/LearnPage/LearnPage.styled'; import AnswerResult from './AnswerResult'; +import { verifyAnswers } from './verifyAnswers'; const Result = ({ answer, data, reply }) => { const { question } = data; @@ -16,12 +17,8 @@ const Result = ({ answer, data, reply }) => { const { value: userValue } = reply; const isCorrect = useMemo( - () => - results?.some( - (result) => - result?.trim()?.toLowerCase() === userValue.trim()?.toLowerCase(), - ), - [userValue, results], + () => verifyAnswers(results, userValue), + [results, userValue], ); return ( diff --git a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/verifyAnswers.js b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/verifyAnswers.js new file mode 100644 index 00000000..dff9025f --- /dev/null +++ b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/verifyAnswers.js @@ -0,0 +1,10 @@ +const valueTransformation = (value) => + value + ?.trim?.() + ?.replace(/([.,/#!$%^&*;:{}=\-_`~()\][])+$/g, '') + ?.toLowerCase?.(); + +export const verifyAnswers = (results, userValue) => + results?.some( + (result) => valueTransformation(result) === valueTransformation(userValue), + ); From a17e94fb32ef5a8796a5d106a8a1698090a802e3 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 12:24:37 +0300 Subject: [PATCH 17/53] fix: add border bottom to text input (Student view) --- .../BlockElement/BlockElement.styled.js | 19 +++++++++++++++ .../ClosedQuestion/Answer/Answer.styled.js | 24 ++++++------------- .../GradedQuestion/GradedQuestion.styled.js | 18 ++++---------- .../BlockElement/Quiz/Quiz.styled.js | 19 ++++----------- 4 files changed, 34 insertions(+), 46 deletions(-) create mode 100644 front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js diff --git a/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js b/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js new file mode 100644 index 00000000..8bf8f3ec --- /dev/null +++ b/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js @@ -0,0 +1,19 @@ +import { Row } from 'antd'; +import styled from 'styled-components'; + +export const BlockElementWrapperWhite = styled(Row)` + width: 100%; + background-color: white; + box-shadow: 0 0 10px 8px rgba(231, 231, 231, 0.5); + border-radius: 8px; + max-width: 614px; + display: flex; + padding: 0.5rem 1rem; + + @media (max-width: 767px) { + box-shadow: 0px -4px 10px rgba(231, 231, 231, 0.5); + max-width: none; + overflow-x: hidden; + width: 100vw; + } +`; diff --git a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Answer/Answer.styled.js b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Answer/Answer.styled.js index 4d94e292..8f74571b 100644 --- a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Answer/Answer.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Answer/Answer.styled.js @@ -1,31 +1,21 @@ -import { Button, Row, Typography } from 'antd'; +import { Button, Typography } from 'antd'; import styled from 'styled-components'; import { RightOutlined as RightOutlinedAntd } from '@ant-design/icons'; import variables from '@sb-ui/theme/variables'; -const { Text } = Typography; +import { BlockElementWrapperWhite } from '../../BlockElement.styled'; -export const BlockWrapperWhite = styled(Row)` - width: 100%; - background-color: white; - box-shadow: 0px -4px 10px rgba(231, 231, 231, 0.5); - border-radius: 8px; - max-width: 614px; - display: flex; - align-items: center; - padding: 0.5rem 1rem; - @media (max-width: 767px) { - max-width: none; - overflow-x: hidden; - width: 100vw; - } -`; +const { Text } = Typography; export const Question = styled(Text)` font-style: italic; `; +export const BlockWrapperWhite = styled(BlockElementWrapperWhite)` + align-items: center; +`; + export const Textarea = styled.textarea.attrs({ rows: 2, })` diff --git a/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.styled.js b/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.styled.js index 8fc2650c..ebb4dd45 100644 --- a/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/GradedQuestion/GradedQuestion.styled.js @@ -1,4 +1,4 @@ -import { Button, Row, Typography, Upload as UploadAntd } from 'antd'; +import { Button, Typography, Upload as UploadAntd } from 'antd'; import styled from 'styled-components'; import { PaperClipOutlined, @@ -7,23 +7,13 @@ import { import variables from '@sb-ui/theme/variables'; +import { BlockElementWrapperWhite } from '../BlockElement.styled'; + const { Text } = Typography; -export const BlockWrapperWhite = styled(Row)` - width: 100%; - background-color: white; - box-shadow: 0px -4px 10px rgba(231, 231, 231, 0.5); - border-radius: 8px; - max-width: 614px; - display: flex; +export const BlockWrapperWhite = styled(BlockElementWrapperWhite)` align-items: center; - padding: 0.5rem 1rem; overflow-y: hidden; - @media (max-width: 767px) { - max-width: none; - overflow-x: hidden; - width: 100vw; - } `; export const Upload = styled(UploadAntd).attrs({ diff --git a/front/src/pages/User/LearnPage/BlockElement/Quiz/Quiz.styled.js b/front/src/pages/User/LearnPage/BlockElement/Quiz/Quiz.styled.js index afb38532..9b7a47ab 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Quiz/Quiz.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/Quiz/Quiz.styled.js @@ -1,6 +1,8 @@ -import { Button, Checkbox, Row, Typography } from 'antd'; +import { Button, Checkbox, Typography } from 'antd'; import styled from 'styled-components'; +import { BlockElementWrapperWhite } from '../BlockElement.styled'; + const { Text } = Typography; export const AnswerWrapper = styled.div` @@ -18,21 +20,8 @@ export const Question = styled(Text)` font-style: italic; `; -export const BlockWrapperWhite = styled(Row)` - width: 100%; - padding: 2rem 2rem 0 2rem; - background-color: white; - box-shadow: 0px -4px 10px rgba(231, 231, 231, 0.5); - border-radius: 8px; - max-width: 614px; - display: flex; +export const BlockWrapperWhite = styled(BlockElementWrapperWhite)` flex-direction: column; - - @media (max-width: 767px) { - max-width: none; - overflow-x: hidden; - width: 100vw; - } `; export const LessonButtonSend = styled(Button).attrs({ From 3f5867ae7cdf4d4411ad00d27ff93deece11fc83 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 13:12:28 +0300 Subject: [PATCH 18/53] feat: remove space before Lesson name (LessonEdit) --- front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js b/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js index 19d0ac91..a3aec722 100644 --- a/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js +++ b/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js @@ -62,7 +62,7 @@ export const InputTitle = styled.input` width: 100%; @media (min-width: 1200px) { - padding: 0 2.25rem; + padding: 0 2.25rem 0 0; } `; From 3a23020dc24ba67d6b7be1eb9b657a6a913200af Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 13:45:06 +0300 Subject: [PATCH 19/53] feat: add spaceing between Header and Intro block --- front/src/components/molecules/Header/Header.styled.js | 1 + front/src/pages/User/LearnPage/LearnPage.styled.jsx | 4 +++- front/src/routes/PrivateRoutes/PrivateRoutes.utils.jsx | 9 +++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/front/src/components/molecules/Header/Header.styled.js b/front/src/components/molecules/Header/Header.styled.js index 0c673172..cfca1b5c 100644 --- a/front/src/components/molecules/Header/Header.styled.js +++ b/front/src/components/molecules/Header/Header.styled.js @@ -85,6 +85,7 @@ export const MenuWrapper = styled.div` z-index: 3; width: 100%; transition: transform 0.3s ease-in-out; + margin-top: 0; transform: ${(props) => props.visible ? `translateY(${HEADER_HEIGHT}px)` : 'translateY(-100%)'}; `; diff --git a/front/src/pages/User/LearnPage/LearnPage.styled.jsx b/front/src/pages/User/LearnPage/LearnPage.styled.jsx index 93634575..0296abf4 100644 --- a/front/src/pages/User/LearnPage/LearnPage.styled.jsx +++ b/front/src/pages/User/LearnPage/LearnPage.styled.jsx @@ -1,6 +1,7 @@ import { Button, Col, Progress as AntdProgress, Row as AntdRow } from 'antd'; import styled, { createGlobalStyle } from 'styled-components'; +import { HEADER_HEIGHT } from '@sb-ui/components/molecules/Header/Header.styled'; import variables from '@sb-ui/theme/variables'; export const Progress = styled(AntdProgress).attrs({ @@ -32,6 +33,7 @@ export const LearnWrapper = styled.div` export const Wrapper = styled.div` height: 100%; + margin-top: ${HEADER_HEIGHT}px; `; export const BlockCell = styled(Col).attrs(() => ({ @@ -47,7 +49,7 @@ export const BlockCell = styled(Col).attrs(() => ({ export const Row = styled(AntdRow)` height: 100%; - padding-top: 4rem; + padding-top: 2rem; justify-content: center; `; diff --git a/front/src/routes/PrivateRoutes/PrivateRoutes.utils.jsx b/front/src/routes/PrivateRoutes/PrivateRoutes.utils.jsx index a0881c5e..0e3a6553 100644 --- a/front/src/routes/PrivateRoutes/PrivateRoutes.utils.jsx +++ b/front/src/routes/PrivateRoutes/PrivateRoutes.utils.jsx @@ -23,9 +23,14 @@ import { } from '@sb-ui/pages/User'; import { Roles } from '@sb-ui/utils/constants'; import * as paths from '@sb-ui/utils/paths'; -import { COURSES_EDIT, LEARN_PAGE, LESSONS_EDIT } from '@sb-ui/utils/paths'; +import { + COURSES_EDIT, + LEARN_PAGE, + LESSONS_EDIT, + LESSONS_PREVIEW, +} from '@sb-ui/utils/paths'; -const SKIP_HEADER = [LESSONS_EDIT, LEARN_PAGE, COURSES_EDIT]; +const SKIP_HEADER = [LESSONS_EDIT, LEARN_PAGE, LESSONS_PREVIEW, COURSES_EDIT]; export const checkPermission = (roles, permissions) => { if (!permissions) return true; From c075023f5dc6ff86e6f0caa2b12a5f2002080638 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 11:40:00 +0300 Subject: [PATCH 20/53] fix: make Results fully work --- .../BlockElement/Results/Results.jsx | 66 ++++++++++++------- .../BlockElement/Results/calculateAnswer.js | 31 +++++++++ .../Results/chunksToInteractiveBlocks.js | 2 + front/src/resources/lang/en/user.js | 1 + front/src/resources/lang/ru/user.js | 1 + front/src/utils/api/config.js | 11 ++++ 6 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 front/src/pages/User/LearnPage/BlockElement/Results/calculateAnswer.js create mode 100644 front/src/pages/User/LearnPage/BlockElement/Results/chunksToInteractiveBlocks.js diff --git a/front/src/pages/User/LearnPage/BlockElement/Results/Results.jsx b/front/src/pages/User/LearnPage/BlockElement/Results/Results.jsx index 4cf2f041..e50c696b 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Results/Results.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Results/Results.jsx @@ -4,47 +4,56 @@ import { useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import LearnContext from '@sb-ui/contexts/LearnContext'; -import { verifyAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/Quiz/verifyAnswers'; +import { chunksToInteractiveBlocks } from '@sb-ui/pages/User/LearnPage/BlockElement/Results/chunksToInteractiveBlocks'; import { BLOCKS_TYPE } from '@sb-ui/pages/User/LearnPage/BlockElement/types'; +import { + interactiveGradedResultTypesBlocks, + interactiveResultTypesBlocks, +} from '@sb-ui/utils/api/config'; +import { + calculateBricks, + calculateClosedQuestion, + calculateFillTheGap, + calculateMatch, + calculateQuiz, +} from './calculateAnswer'; import * as S from './Results.styled'; const { Text, Title } = Typography; -const interactiveAnswerTypes = [BLOCKS_TYPE.QUIZ, BLOCKS_TYPE.CLOSED_QUESTION]; - const Results = ({ callbackRef }) => { const { t } = useTranslation('user'); const { chunks } = useContext(LearnContext); const interactiveAnswerBlocks = useMemo( - () => - chunks - ?.flat() - ?.filter((block) => interactiveAnswerTypes.includes(block.type)) || [], + () => chunksToInteractiveBlocks(chunks, interactiveResultTypesBlocks), + [chunks], + ); + + const interactiveGradedBlocks = useMemo( + () => chunksToInteractiveBlocks(chunks, interactiveGradedResultTypesBlocks), [chunks], ); const correctInteractiveAnswer = useMemo( () => interactiveAnswerBlocks - .map(({ type, reply, answer, content }) => { - if (type === BLOCKS_TYPE.QUIZ) { - const userAnswer = - reply?.response?.map((x) => ({ correct: x })) || - content?.data?.answers; - const { correct } = verifyAnswers(userAnswer, answer?.results); - return correct; - } - if (type === BLOCKS_TYPE.CLOSED_QUESTION) { - const userAnswer = content?.data?.answer; - return answer?.results?.some( - (result) => - result.trim().toLowerCase() === - userAnswer?.trim()?.toLowerCase(), - ); + .map(({ type, ...block }) => { + switch (type) { + case BLOCKS_TYPE.QUIZ: + return calculateQuiz(block); + case BLOCKS_TYPE.CLOSED_QUESTION: + return calculateClosedQuestion(block); + case BLOCKS_TYPE.FILL_THE_GAP: + return calculateFillTheGap(block); + case BLOCKS_TYPE.BRICKS: + return calculateBricks(block); + case BLOCKS_TYPE.MATCH: + return calculateMatch(block); + default: + return false; } - return false; }) .filter((x) => x)?.length, [interactiveAnswerBlocks], @@ -55,6 +64,8 @@ const Results = ({ callbackRef }) => { [correctInteractiveAnswer, interactiveAnswerBlocks.length], ); + const gradedPendingCount = interactiveGradedBlocks.length; + return ( @@ -72,6 +83,15 @@ const Results = ({ callbackRef }) => { value={interactiveAnswerBlocks.length} /> + {gradedPendingCount > 0 && ( + + + + )} + {interactiveAnswerBlocks.length > 0 && ( diff --git a/front/src/pages/User/LearnPage/BlockElement/Results/calculateAnswer.js b/front/src/pages/User/LearnPage/BlockElement/Results/calculateAnswer.js new file mode 100644 index 00000000..91b0c310 --- /dev/null +++ b/front/src/pages/User/LearnPage/BlockElement/Results/calculateAnswer.js @@ -0,0 +1,31 @@ +import { verifyAnswers as verifyBricksAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/Bricks/verifyAnswers'; +import { verifyAnswers as verifyClosedQuestionAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/verifyAnswers'; +import { verifyAnswers as verifyFillTheGapAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/FillTheGap/verifyAnswers'; +import { verifyAnswers as verifyMatchAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/Match/verifyAnswers'; +import { verifyAnswers as verifyQuizAnswers } from '@sb-ui/pages/User/LearnPage/BlockElement/Quiz/verifyAnswers'; + +export const calculateQuiz = ({ reply, answer, content }) => { + const userAnswer = + reply?.response?.map((x) => ({ correct: x })) || content?.data?.answers; + const { correct } = verifyQuizAnswers(userAnswer, answer?.results); + return correct; +}; + +export const calculateClosedQuestion = ({ reply, answer }) => + verifyClosedQuestionAnswers(answer.results, reply.value); + +export const calculateFillTheGap = ({ reply, answer }) => { + const { correct } = verifyFillTheGapAnswers(answer.results, reply.response); + return correct; +}; + +export const calculateBricks = ({ reply, answer }) => { + const { words: answerResults } = answer; + const { words: userWords } = reply; + return verifyBricksAnswers(userWords, answerResults); +}; + +export const calculateMatch = ({ reply, answer }) => { + const { correct } = verifyMatchAnswers(answer?.results, reply?.response); + return correct; +}; diff --git a/front/src/pages/User/LearnPage/BlockElement/Results/chunksToInteractiveBlocks.js b/front/src/pages/User/LearnPage/BlockElement/Results/chunksToInteractiveBlocks.js new file mode 100644 index 00000000..5bf39742 --- /dev/null +++ b/front/src/pages/User/LearnPage/BlockElement/Results/chunksToInteractiveBlocks.js @@ -0,0 +1,2 @@ +export const chunksToInteractiveBlocks = (chunks, typesBlocks) => + chunks?.flat()?.filter((block) => typesBlocks.includes(block.type)) || []; diff --git a/front/src/resources/lang/en/user.js b/front/src/resources/lang/en/user.js index 1712876a..71cc6019 100644 --- a/front/src/resources/lang/en/user.js +++ b/front/src/resources/lang/en/user.js @@ -59,6 +59,7 @@ export default { title: 'Results', correct_answers: 'Correct answers in the lesson', total_answers: 'Total questions in the chapter', + graded_pending: 'Pending graded question', percentage: '{{percentage}}% correct answers', }, answer_result: { diff --git a/front/src/resources/lang/ru/user.js b/front/src/resources/lang/ru/user.js index edbd3f10..dc2e46ab 100644 --- a/front/src/resources/lang/ru/user.js +++ b/front/src/resources/lang/ru/user.js @@ -59,6 +59,7 @@ export default { title: 'Результаты', correct_answers: 'Правильные ответы в уроке', total_answers: 'Общее количество вопросов в уроке', + graded_pending: 'Вопросы с оценкой, ожидающие рассмотрения', percentage: '{{percentage}}% правильных ответов', }, answer_result: { diff --git a/front/src/utils/api/config.js b/front/src/utils/api/config.js index 79c215bb..cafa612f 100644 --- a/front/src/utils/api/config.js +++ b/front/src/utils/api/config.js @@ -11,6 +11,17 @@ export const interactiveTypesBlocks = [ BLOCKS_TYPE.MATCH, BLOCKS_TYPE.GRADED_QUESTION, ]; + +export const interactiveResultTypesBlocks = [ + BLOCKS_TYPE.QUIZ, + BLOCKS_TYPE.CLOSED_QUESTION, + BLOCKS_TYPE.FILL_THE_GAP, + BLOCKS_TYPE.BRICKS, + BLOCKS_TYPE.MATCH, +]; + +export const interactiveGradedResultTypesBlocks = [BLOCKS_TYPE.GRADED_QUESTION]; + export const staticTypesBlocks = [ BLOCKS_TYPE.PARAGRAPH, BLOCKS_TYPE.EMBED, From 7d495f91a58268a99bf33bfa6036c05505eef3dd Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 13:05:36 +0300 Subject: [PATCH 21/53] feat: allow empty answer, add {} to hint (Fill The Gap) --- .../User/LearnPage/BlockElement/FillTheGap/verifyAnswers.js | 2 +- front/src/resources/lang/en/editorjs.js | 3 ++- front/src/resources/lang/ru/editorjs.js | 3 ++- .../utils/editorjs/EditorJsContainer/EditorJsContainer.jsx | 3 ++- front/src/utils/editorjs/fill-the-gap/plugin.js | 6 +++--- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/verifyAnswers.js b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/verifyAnswers.js index 38e5fc8a..1c46535b 100644 --- a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/verifyAnswers.js +++ b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/verifyAnswers.js @@ -21,7 +21,7 @@ export const verifyAnswers = (results, answers) => { .value.find( (res) => res.trim().toLowerCase() === answer.value.trim().toLowerCase(), - ) + ) !== undefined ) { return { correct: true, diff --git a/front/src/resources/lang/en/editorjs.js b/front/src/resources/lang/en/editorjs.js index ebd371c2..1fefcb64 100644 --- a/front/src/resources/lang/en/editorjs.js +++ b/front/src/resources/lang/en/editorjs.js @@ -109,7 +109,8 @@ export default { fill_the_gap: { title: 'Fill The Gap', description: 'Fill missed words', - hint: '* Text inside {{ }} will be hidden for students', + hint_part_one: '* Text inside ', + hint_part_two: ' will be hidden for students', placeholder: 'Enter a text', }, match: { diff --git a/front/src/resources/lang/ru/editorjs.js b/front/src/resources/lang/ru/editorjs.js index adbf1583..581cd883 100644 --- a/front/src/resources/lang/ru/editorjs.js +++ b/front/src/resources/lang/ru/editorjs.js @@ -109,7 +109,8 @@ export default { fill_the_gap: { title: 'Заполнить пропуски', description: 'Заполнить пропущенные слова', - hint: '* Текст внутри {{ }} будет скрыт для студентов', + hint_part_one: '* Текст внутри ', + hint_part_two: ' будет скрыт для студентов', placeholder: 'Введите текст', }, match: { diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx index ea847a63..6efa4ff4 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx @@ -228,7 +228,8 @@ const EditorJsContainer = forwardRef((props, ref) => { }, fillTheGap: { title: t('tools.fill_the_gap.title'), - hint: t('tools.fill_the_gap.hint'), + hint_part_one: t('tools.fill_the_gap.hint_part_one'), + hint_part_two: t('tools.fill_the_gap.hint_part_two'), placeholder: t('tools.fill_the_gap.placeholder'), }, match: { diff --git a/front/src/utils/editorjs/fill-the-gap/plugin.js b/front/src/utils/editorjs/fill-the-gap/plugin.js index a827ba33..c31c71f7 100644 --- a/front/src/utils/editorjs/fill-the-gap/plugin.js +++ b/front/src/utils/editorjs/fill-the-gap/plugin.js @@ -10,7 +10,7 @@ export default class FillTheGap extends PluginBase { constructor({ data, api, readOnly }) { super({ title: api.i18n.t('title'), - hint: api.i18n.t('hint'), + hint: `${api.i18n.t('hint_part_one')}{{ }}${api.i18n.t('hint_part_two')}`, }); this.data = data; @@ -54,7 +54,6 @@ export default class FillTheGap extends PluginBase { match.replace('{{', '').replace('}}', '').trim().split(','), ) ?.map((phrases) => phrases.map((phrase) => phrase.trim())) - ?.map((phrases) => phrases.filter((phrase) => phrase)) ?.map((answer) => [...new Set([...answer])]); const text = inputHTML.replace(this.bracketsRegexp, '{{ # }}'); @@ -118,7 +117,8 @@ export default class FillTheGap extends PluginBase { .join(''); } else { this.input.innerText = - 'Here is an example of a sentence with {{ empty, vacant, blank }} spaces that a {{ learner, student }} will need to fill in.'; + 'Here is an example of a sentence with {{ empty, vacant, blank }} spaces that a {{ learner, student }} will need to fill in. ' + + 'Block also supports {{ }} (no value required)'; } container.appendChild(this.titleWrapper); From 4e3df41a5b9335fcc4c038c0d046a29939f41d5f Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 12 Oct 2021 15:50:03 +0300 Subject: [PATCH 22/53] feat: improve blocks menu (add Search input) --- front/src/resources/lang/en/editorjs.js | 1 + front/src/resources/lang/ru/editorjs.js | 1 + .../EditorJsContainer.styled.js | 11 +++ .../EditorJsContainer/useToolbox/constants.js | 10 ++ .../useToolbox/domToolboxHelpers.js | 14 +++ .../useToolbox/searchHelpers.js | 31 ++++++ .../useToolbox/toolboxItemsHelpers.js | 33 +++++++ .../useToolbox/toolboxObserver.js | 20 ++-- .../EditorJsContainer/useToolbox/useSearch.js | 97 +++++++++++++++++++ .../useToolbox/useToolbox.js | 30 +++++- 10 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js create mode 100644 front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js create mode 100644 front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js diff --git a/front/src/resources/lang/en/editorjs.js b/front/src/resources/lang/en/editorjs.js index 1fefcb64..68c17ba2 100644 --- a/front/src/resources/lang/en/editorjs.js +++ b/front/src/resources/lang/en/editorjs.js @@ -19,6 +19,7 @@ export default { }, }, tools: { + search_placeholder: 'Input block name', hint: 'Click "Tab" for commands', basic_blocks: 'Basic blocks', interactive_blocks: 'Interactive blocks', diff --git a/front/src/resources/lang/ru/editorjs.js b/front/src/resources/lang/ru/editorjs.js index 581cd883..f058e545 100644 --- a/front/src/resources/lang/ru/editorjs.js +++ b/front/src/resources/lang/ru/editorjs.js @@ -19,6 +19,7 @@ export default { }, }, tools: { + search_placeholder: 'Введите название блока', hint: 'Нажмите "Tab" для комманд', basic_blocks: 'Базовые блоки', interactive_blocks: 'Интерактивные блоки', diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js index 832851ae..630d013e 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js @@ -8,6 +8,17 @@ export const GlobalStylesEditorPage = createGlobalStyle` flex-direction: column; gap: 1rem; } + .toolbox-item-none{ + display: none!important; + } + .toolbox-input-search{ + &:focus{ + outline: none; + } + border: 1px solid #d9d9d9; + padding: 0 0.5rem; + margin-bottom: 0.5rem; + } .ce-toolbar__plus::after{ content: '${(props) => props.toolbarHint}'; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js new file mode 100644 index 00000000..a47b2b2e --- /dev/null +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js @@ -0,0 +1,10 @@ +export const TOOLBOX_BUTTON_ACTIVE_CLASS = 'ce-toolbox__button--active'; +export const TOOLBOX_TOOLBOX = 'ce-toolbox'; +export const TOOLBOX_OPENED = 'ce-toolbox--opened'; +export const TOOLBOX_ITEM_NONE = 'toolbox-item-none'; +export const KEYS = { + TAB: 'Tab', + ENTER: 'Enter', + ARROW_UP: 'ArrowUp', + ARROW_DOWN: 'ArrowDown', +}; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js index 63a31786..748da8a5 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js @@ -20,6 +20,20 @@ export const createDivWithClassName = ({ return element; }; +export const createInputWithClassName = ({ + className, + placeholder, + events, +}) => { + const element = document.createElement('input'); + element.classList.add(className); + element.placeholder = placeholder; + Object.entries(events).forEach(([eventName, func]) => { + element.addEventListener(eventName, func); + }); + return element; +}; + export const getToolboxItems = (parent) => parent?.querySelectorAll('.ce-toolbox__button') || []; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js new file mode 100644 index 00000000..4920ef07 --- /dev/null +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js @@ -0,0 +1,31 @@ +import { + TOOLBOX_BUTTON_ACTIVE_CLASS, + TOOLBOX_ITEM_NONE, +} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/constants'; + +import { getTranslationKey } from './toolboxItemsHelpers'; + +export const getItemsFilteredByValue = (value, items, t) => + items?.filter((item) => { + const translationKey = t(getTranslationKey(item.dataset.tool)); + const name = t(`tools.${translationKey}.title`); + return !name.toLowerCase().includes(value.toLowerCase()); + }); + +export const setCurrentRef = (ref, value) => { + // eslint-disable-next-line no-param-reassign + ref.current = value; +}; + +export const setCurrentRefValue = (ref, value) => { + // eslint-disable-next-line no-param-reassign + ref.current.value = value; +}; + +export const makeFirstItemActive = (items, currentItemRef) => { + const goodItems = items?.filter( + (item) => !item.classList.contains(TOOLBOX_ITEM_NONE), + ); + setCurrentRef(currentItemRef, goodItems?.[0] || null); + currentItemRef.current?.classList?.add?.(TOOLBOX_BUTTON_ACTIVE_CLASS); +}; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js index fdc25b9c..17d68ab5 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js @@ -2,6 +2,10 @@ import { getBaseBlocks, getInteractiveBlocks, } from '@sb-ui/pages/Teacher/LessonEdit/utils'; +import { + TOOLBOX_BUTTON_ACTIVE_CLASS, + TOOLBOX_ITEM_NONE, +} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/constants'; const createMenuItems = (blocksName, items) => { const menuItems = new Map(); @@ -66,3 +70,32 @@ export const selectItemsDescKeys = (item) => { }, ]; }; + +export const getSelectingIndexes = (current, items, tabNext) => { + const index = items.findIndex((item) => item === current); + + if (tabNext) { + if (current === null) { + return [-1, 0]; + } + if (index === items.length - 1) { + return [index, 0]; + } + return [index, index + 1]; + } + if (current === null) { + return [-1, items.length - 1]; + } + + if (index === 0) { + return [0, items.length - 1]; + } + return [index, index - 1]; +}; + +export const resetItems = (items) => { + items?.forEach((item) => { + item.classList.remove(TOOLBOX_ITEM_NONE); + item.classList.remove(TOOLBOX_BUTTON_ACTIVE_CLASS); + }); +}; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxObserver.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxObserver.js index 6760a49b..6c4bd1af 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxObserver.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxObserver.js @@ -1,13 +1,19 @@ -const TOOLBOX_BUTTON_ACTIVE_CLASS = 'ce-toolbox__button--active'; +import { + TOOLBOX_BUTTON_ACTIVE_CLASS, + TOOLBOX_OPENED, + TOOLBOX_TOOLBOX, +} from './constants'; -export const initObserver = (targetNode) => { +export const initObserver = (targetNode, { setIsOpen }) => { const observer = new MutationObserver((mutations) => { mutations.forEach(({ attributeName, target }) => { - if ( - attributeName === 'class' && - target.classList.contains(TOOLBOX_BUTTON_ACTIVE_CLASS) - ) { - target.scrollIntoViewIfNeeded(); + if (attributeName === 'class') { + if (target.classList.contains(TOOLBOX_TOOLBOX)) { + setIsOpen(target.classList.contains(TOOLBOX_OPENED)); + } + if (target.classList.contains(TOOLBOX_BUTTON_ACTIVE_CLASS)) { + target.scrollIntoViewIfNeeded(); + } } }); }); diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js new file mode 100644 index 00000000..7fe150ca --- /dev/null +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js @@ -0,0 +1,97 @@ +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + getItemsFilteredByValue, + makeFirstItemActive, + setCurrentRef, + setCurrentRefValue, +} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers'; +import { + getSelectingIndexes, + resetItems, +} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers'; + +import { + KEYS, + TOOLBOX_BUTTON_ACTIVE_CLASS, + TOOLBOX_ITEM_NONE, +} from './constants'; + +export const useSearch = ({ + itemsRef, + inputRef, + currentItemRef, + isOpen, + value, +}) => { + const { t } = useTranslation('editorjs'); + const selectNext = useCallback( + (tabNext) => { + const visibleItems = itemsRef.current.filter( + (item) => !item.classList.contains(TOOLBOX_ITEM_NONE), + ); + const [fromIndex, toIndex] = getSelectingIndexes( + currentItemRef.current, + visibleItems, + tabNext, + ); + if (fromIndex !== -1) { + visibleItems[fromIndex].classList.remove(TOOLBOX_BUTTON_ACTIVE_CLASS); + } + setCurrentRef(currentItemRef, visibleItems[toIndex]); + currentItemRef.current.classList.add(TOOLBOX_BUTTON_ACTIVE_CLASS); + }, + [currentItemRef, itemsRef], + ); + + const handleKeyDown = useCallback( + (e) => { + e.stopImmediatePropagation(); + switch (e.code) { + case KEYS.ARROW_UP: + selectNext(false); + break; + case KEYS.ARROW_DOWN: + selectNext(true); + break; + case KEYS.TAB: + if (e.shiftKey === true) { + selectNext(false); + } else { + selectNext(true); + } + break; + case KEYS.ENTER: + currentItemRef.current?.click?.(); + break; + default: + break; + } + }, + [currentItemRef, selectNext], + ); + + useEffect(() => { + if (isOpen) { + inputRef.current.focus(); + setCurrentRefValue(inputRef, ''); + makeFirstItemActive(itemsRef.current, currentItemRef); + inputRef.current?.addEventListener('keydown', handleKeyDown); + } else { + resetItems(itemsRef.current); + inputRef.current?.removeEventListener('keydown', handleKeyDown); + } + }, [currentItemRef, handleKeyDown, inputRef, isOpen, itemsRef]); + + useEffect(() => { + resetItems(itemsRef.current); + const filtered = getItemsFilteredByValue(value, itemsRef.current, t); + + filtered?.forEach((element) => { + element.classList.add(TOOLBOX_ITEM_NONE); + }); + + makeFirstItemActive(itemsRef.current, currentItemRef); + }, [currentItemRef, itemsRef, t, value]); +}; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js index f142078b..3e3d6145 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js @@ -1,9 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearch } from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/useSearch'; + import { appendItems, createDivWithClassName, + createInputWithClassName, getToolboxItems, transformDefaultMenuItems, updateInnerText, @@ -19,9 +22,19 @@ export const useToolbox = () => { const { t } = useTranslation('editorjs'); const toolbox = useRef(null); const [isReady, setIsReady] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [value, setValue] = useState(''); + const inputRef = useRef(null); + const itemsRef = useRef(null); + const currentItemRef = useRef(null); + + useSearch({ itemsRef, inputRef, currentItemRef, isOpen, value }); + useEffect(() => { if (isReady) { - const observer = initObserver(toolbox.current); + const observer = initObserver(toolbox.current, { + setIsOpen, + }); return () => { destroyObserver(observer); }; @@ -60,9 +73,23 @@ export const useToolbox = () => { className: 'toolbox-interactive-items', }); + const input = createInputWithClassName({ + className: 'toolbox-input-search', + placeholder: t('tools.search_placeholder'), + events: { + input: (e) => { + setValue(e.target.value); + }, + focusout: () => { + input.focus({ preventScroll: true }); + }, + }, + }); + inputRef.current = input; appendItems({ node: wrapper, items: [ + input, createDivWithClassName({ className: 'toolbox-basic-items-title', innerText: t('tools.basic_blocks'), @@ -77,6 +104,7 @@ export const useToolbox = () => { }); const items = Array.from(getToolboxItems(toolbox.current)); + itemsRef.current = items; const [basicItems, interactiveItems] = getBasicAndInteractiveItems(items); transformDefaultMenuItems(interactiveItems, interactiveMenuItemsWrapper, t); From ce97e286684120673baeb913a60348c47938378f Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Wed, 13 Oct 2021 11:23:31 +0300 Subject: [PATCH 23/53] fix: input focus() after Tab after block --- .../utils/editorjs/EditorJsContainer/useToolbox/constants.js | 1 + .../utils/editorjs/EditorJsContainer/useToolbox/useSearch.js | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js index a47b2b2e..c1b3a9e9 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js @@ -2,6 +2,7 @@ export const TOOLBOX_BUTTON_ACTIVE_CLASS = 'ce-toolbox__button--active'; export const TOOLBOX_TOOLBOX = 'ce-toolbox'; export const TOOLBOX_OPENED = 'ce-toolbox--opened'; export const TOOLBOX_ITEM_NONE = 'toolbox-item-none'; +export const INPUT_FOCUS_AFTER_TIME = 50; // ms; export const KEYS = { TAB: 'Tab', ENTER: 'Enter', diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js index 7fe150ca..dfe9cc37 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useSearch.js @@ -13,6 +13,7 @@ import { } from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers'; import { + INPUT_FOCUS_AFTER_TIME, KEYS, TOOLBOX_BUTTON_ACTIVE_CLASS, TOOLBOX_ITEM_NONE, @@ -74,7 +75,9 @@ export const useSearch = ({ useEffect(() => { if (isOpen) { - inputRef.current.focus(); + setTimeout(() => { + inputRef.current?.focus?.(); + }, INPUT_FOCUS_AFTER_TIME); setCurrentRefValue(inputRef, ''); makeFirstItemActive(itemsRef.current, currentItemRef); inputRef.current?.addEventListener('keydown', handleKeyDown); From f036c7bf3809004fc547af9850b0950b315f03b8 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Fri, 15 Oct 2021 10:47:28 +0300 Subject: [PATCH 24/53] refactor: change import paths, add jsdoc to improve readability return value of function --- .../EditorJsContainer/useToolbox/searchHelpers.js | 6 +----- .../useToolbox/toolboxItemsHelpers.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js index 4920ef07..ed1b469b 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/searchHelpers.js @@ -1,8 +1,4 @@ -import { - TOOLBOX_BUTTON_ACTIVE_CLASS, - TOOLBOX_ITEM_NONE, -} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/constants'; - +import { TOOLBOX_BUTTON_ACTIVE_CLASS, TOOLBOX_ITEM_NONE } from './constants'; import { getTranslationKey } from './toolboxItemsHelpers'; export const getItemsFilteredByValue = (value, items, t) => diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js index 17d68ab5..b6d6d8a7 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/toolboxItemsHelpers.js @@ -2,10 +2,8 @@ import { getBaseBlocks, getInteractiveBlocks, } from '@sb-ui/pages/Teacher/LessonEdit/utils'; -import { - TOOLBOX_BUTTON_ACTIVE_CLASS, - TOOLBOX_ITEM_NONE, -} from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/constants'; + +import { TOOLBOX_BUTTON_ACTIVE_CLASS, TOOLBOX_ITEM_NONE } from './constants'; const createMenuItems = (blocksName, items) => { const menuItems = new Map(); @@ -71,6 +69,12 @@ export const selectItemsDescKeys = (item) => { ]; }; +/** + * Get selecting indexes of toolbox + * @returns {Array.} [fromIndex,toIndex] + * first value of array is index from select, + * second value is index to select + */ export const getSelectingIndexes = (current, items, tabNext) => { const index = items.findIndex((item) => item === current); From fb3c96270f526428d0ff44b828dbbe688cc6cc30 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 16:29:33 +0300 Subject: [PATCH 25/53] feat: change sarch input padding --- .../editorjs/EditorJsContainer/EditorJsContainer.styled.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js index 630d013e..fd275eaa 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js @@ -16,7 +16,7 @@ export const GlobalStylesEditorPage = createGlobalStyle` outline: none; } border: 1px solid #d9d9d9; - padding: 0 0.5rem; + padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; } From b9ed0877297fb119d78393cbc6d4390d645a0045 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Mon, 18 Oct 2021 16:10:29 +0300 Subject: [PATCH 26/53] feat: add showing editor js menu based on page location --- .../EditorJsContainer.styled.js | 3 ++ .../EditorJsContainer/useToolbox/constants.js | 2 ++ .../useToolbox/domToolboxHelpers.js | 27 ++++++++++++++ .../useToolbox/useToolbox.js | 36 ++++++++++++++++++- front/src/utils/utils.js | 10 ++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js index fd275eaa..56407d89 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.styled.js @@ -93,4 +93,7 @@ export const GlobalStylesEditorPage = createGlobalStyle` } } } + .cte-toolbox-upper{ + transform: translate3d(0px,calc(-100% + 25px),0px)!important; + } `; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js index c1b3a9e9..ce1369fb 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js @@ -2,7 +2,9 @@ export const TOOLBOX_BUTTON_ACTIVE_CLASS = 'ce-toolbox__button--active'; export const TOOLBOX_TOOLBOX = 'ce-toolbox'; export const TOOLBOX_OPENED = 'ce-toolbox--opened'; export const TOOLBOX_ITEM_NONE = 'toolbox-item-none'; +export const TOOLBOX_UPPER = 'cte-toolbox-upper'; export const INPUT_FOCUS_AFTER_TIME = 50; // ms; +export const DEBOUNCE_SCROLL_TOOLBOX_TIME = 50; // ms; export const KEYS = { TAB: 'Tab', ENTER: 'Enter', diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js index 748da8a5..61a5b62e 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/domToolboxHelpers.js @@ -1,3 +1,5 @@ +import { TOOLBOX_UPPER } from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/constants'; + import { getTranslationKey } from './toolboxItemsHelpers'; export const appendItems = ({ node, items = [] }) => { @@ -79,3 +81,28 @@ export const transformDefaultMenuItems = (items, block, t) => { }); }); }; + +export const TOP_OVERLAPS = 'top'; +export const BOTTOM_OVERLAPS = 'bottom'; + +export const getElementOverlapsPosition = (el) => { + const rect = el.getBoundingClientRect(); + if (rect.top < 0) { + return TOP_OVERLAPS; + } + if ( + rect.bottom > (window.innerHeight || document.documentElement.clientHeight) + ) { + return BOTTOM_OVERLAPS; + } + + return null; +}; + +export const toggleToolboxPosition = (element, position) => { + if (position === BOTTOM_OVERLAPS) { + element.classList.add(TOOLBOX_UPPER); + } else { + element.classList.remove(TOOLBOX_UPPER); + } +}; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js index 3e3d6145..acdf0b96 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js @@ -1,13 +1,21 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearch } from '@sb-ui/utils/editorjs/EditorJsContainer/useToolbox/useSearch'; +import { debounce } from '@sb-ui/utils/utils'; +import { + DEBOUNCE_SCROLL_TOOLBOX_TIME, + TOOLBOX_OPENED, + TOOLBOX_UPPER, +} from './constants'; import { appendItems, createDivWithClassName, createInputWithClassName, + getElementOverlapsPosition, getToolboxItems, + toggleToolboxPosition, transformDefaultMenuItems, updateInnerText, } from './domToolboxHelpers'; @@ -112,6 +120,32 @@ export const useToolbox = () => { setIsReady(true); }, [t]); + useEffect(() => { + if (isOpen) { + const position = getElementOverlapsPosition(toolbox.current); + toggleToolboxPosition(toolbox.current, position); + } else { + toolbox.current?.classList?.remove?.(TOOLBOX_UPPER); + } + }, [isOpen]); + + const handleScroll = useCallback(() => { + const position = getElementOverlapsPosition(toolbox.current); + if (position && toolbox.current.classList.contains(TOOLBOX_OPENED)) { + toggleToolboxPosition(toolbox.current, position); + } + }, []); + + const handleScrollDebounced = useMemo( + () => debounce(handleScroll, DEBOUNCE_SCROLL_TOOLBOX_TIME), + [handleScroll], + ); + + useEffect(() => { + document.addEventListener('scroll', handleScrollDebounced); + return () => document.removeEventListener('scroll', handleScrollDebounced); + }, [handleScrollDebounced]); + return { prepareToolbox, updateLanguage, diff --git a/front/src/utils/utils.js b/front/src/utils/utils.js index 077581fb..e18ab5ab 100644 --- a/front/src/utils/utils.js +++ b/front/src/utils/utils.js @@ -9,6 +9,16 @@ export const sleep = (ms) => }, [ms]); }); +export function debounce(func, timeout = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; +} + export const getQueryPage = (params) => { let incorrect = false; let pageNumber = parseInt(new URLSearchParams(params).get('page'), 10); From c3274c009363048679b16a2d805281d66f792eed Mon Sep 17 00:00:00 2001 From: vitalikprac <42850697+vitalikprac@users.noreply.github.com> Date: Tue, 19 Oct 2021 17:39:17 +0300 Subject: [PATCH 27/53] Revert "(SBL-604) Feature: Remove space before Lesson name (LessonEdit)" --- front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js b/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js index a3aec722..19d0ac91 100644 --- a/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js +++ b/front/src/pages/Teacher/LessonEdit/LessonEdit.styled.js @@ -62,7 +62,7 @@ export const InputTitle = styled.input` width: 100%; @media (min-width: 1200px) { - padding: 0 2.25rem 0 0; + padding: 0 2.25rem; } `; From 1c649c53525bcb18265952ddd76cdd580a5c9fd4 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Fri, 8 Oct 2021 14:24:13 +0300 Subject: [PATCH 28/53] refactor: move lesson funnel story to statistics directory --- front/src/stories/{ => statistics}/LessonFunnel.stories.mdx | 0 front/src/stories/{ => statistics}/generateLessonFunnelData.js | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename front/src/stories/{ => statistics}/LessonFunnel.stories.mdx (100%) rename front/src/stories/{ => statistics}/generateLessonFunnelData.js (100%) diff --git a/front/src/stories/LessonFunnel.stories.mdx b/front/src/stories/statistics/LessonFunnel.stories.mdx similarity index 100% rename from front/src/stories/LessonFunnel.stories.mdx rename to front/src/stories/statistics/LessonFunnel.stories.mdx diff --git a/front/src/stories/generateLessonFunnelData.js b/front/src/stories/statistics/generateLessonFunnelData.js similarity index 100% rename from front/src/stories/generateLessonFunnelData.js rename to front/src/stories/statistics/generateLessonFunnelData.js From 6b6d095b753d04d98f9fefa3917bcca23bb7bb48 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 12 Oct 2021 17:00:46 +0300 Subject: [PATCH 29/53] feat: BarSpark and SparkBars stat atoms --- .../components/atoms/SparkBars/SparkBars.jsx | 66 +++++++++ front/src/components/atoms/SparkBars/index.js | 3 + .../molecules/BarSpark/BarSpark.jsx | 134 ++++++++++++++++++ .../components/molecules/BarSpark/index.js | 3 + .../LessonFunnel/LessonFunnel.styled.js | 15 +- .../LessonFunnel/ResolveSpark.jsx | 6 +- .../LessonFunnel/useStatsTicks.js | 4 +- .../stories/statistics/BarSpark.stories.mdx | 27 ++++ .../statistics/atoms/SparkBars.stories.mdx | 74 ++++++++++ .../statistics/generateLessonFunnelData.js | 110 +++++++++----- 10 files changed, 395 insertions(+), 47 deletions(-) create mode 100644 front/src/components/atoms/SparkBars/SparkBars.jsx create mode 100644 front/src/components/atoms/SparkBars/index.js create mode 100644 front/src/components/molecules/BarSpark/BarSpark.jsx create mode 100644 front/src/components/molecules/BarSpark/index.js create mode 100644 front/src/stories/statistics/BarSpark.stories.mdx create mode 100644 front/src/stories/statistics/atoms/SparkBars.stories.mdx diff --git a/front/src/components/atoms/SparkBars/SparkBars.jsx b/front/src/components/atoms/SparkBars/SparkBars.jsx new file mode 100644 index 00000000..2cfc6e0f --- /dev/null +++ b/front/src/components/atoms/SparkBars/SparkBars.jsx @@ -0,0 +1,66 @@ +import * as d3Scale from 'd3-scale'; +import T from 'prop-types'; +import { useMemo } from 'react'; + +const SPARK_LINE_HEIGHT = 80; +const SPARK_V_PADDING = 10; +const BAND_PADDING = 2; + +const SparkBars = ({ groups, ticks }) => { + const bandWidth = useMemo(() => { + const groupsCount = groups.length; + + return Math.floor((100 - groupsCount * BAND_PADDING) / groupsCount); + }, [groups]); + + const bands = useMemo(() => { + const domainMax = groups.reduce( + (max, { value }) => Math.max(max, value), + -Infinity, + ); + + const heightScale = d3Scale.scaleLinear( + [1, domainMax], + [2, SPARK_LINE_HEIGHT], + ); + + return groups.map((group) => ({ + ...group, + height: heightScale(group.value), + })); + }, [groups]); + + return ( + + {bands.map(({ height, order, title }) => ( + + ))} + {ticks} + + ); +}; + +SparkBars.propTypes = { + groups: T.arrayOf( + T.shape({ + title: T.string, + value: T.number, + order: T.string, + }), + ).isRequired, + ticks: T.node, +}; + +SparkBars.defaultProps = { + ticks: null, +}; + +export default SparkBars; diff --git a/front/src/components/atoms/SparkBars/index.js b/front/src/components/atoms/SparkBars/index.js new file mode 100644 index 00000000..afc1b5e8 --- /dev/null +++ b/front/src/components/atoms/SparkBars/index.js @@ -0,0 +1,3 @@ +import SparkBars from './SparkBars'; + +export default SparkBars; diff --git a/front/src/components/molecules/BarSpark/BarSpark.jsx b/front/src/components/molecules/BarSpark/BarSpark.jsx new file mode 100644 index 00000000..a2445805 --- /dev/null +++ b/front/src/components/molecules/BarSpark/BarSpark.jsx @@ -0,0 +1,134 @@ +import * as d3Scale from 'd3-scale'; +import * as d3Shape from 'd3-shape'; +import T from 'prop-types'; +import { useMemo } from 'react'; + +const SPARK_LINE_WIDTH = 200; +const SPARK_LINE_HEIGHT = 32; +const SPARK_TOP_PADD = 16; +const SPARK_LINE_PADD = 4; +const LEFT_MARGIN = 28; +const SP_WD_RAT = (SPARK_LINE_WIDTH - LEFT_MARGIN - 7 * 4) / 7; + +const svgWidth = `${SPARK_LINE_WIDTH}px`; +const svgHeight = `${SPARK_LINE_HEIGHT + SPARK_LINE_PADD + SPARK_TOP_PADD}px`; +const svgViewBox = `0 0 ${SPARK_LINE_WIDTH} ${ + SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2 +}`; + +const thresHolds = [ + 1000, // 1 + 3000, // 2 + 5000, // 3 + 7000, // 4 + 10000, // 5 + 30000, // 6 + 60000, // 7 +]; + +const makeDistribution = (scale, series) => { + const distDict = series.reduce((dict, replySpeed) => { + // eslint-disable-next-line no-param-reassign + dict[scale(replySpeed)] = (dict[scale(replySpeed)] || 0) + 1; + return dict; + }, {}); + + const distList = Object.entries(distDict); + + const domain = distList.reduce( + ({ min, max }, [, replyCount]) => ({ + min: Math.min(replyCount, min), + max: Math.max(replyCount, max), + }), + { min: Infinity, max: -Infinity }, + ); + + const numberScale = d3Scale.scaleLinear( + [0, domain.max], + [1, SPARK_LINE_HEIGHT], + ); + + return distList.map(([x, count]) => ({ + x: +x, + height: numberScale(count), + count, + })); +}; + +const BarSpark = ({ series }) => { + const bands = useMemo(() => { + const range = [...new Array(7).keys()].map( + (i) => ((SPARK_LINE_WIDTH - LEFT_MARGIN) / 7) * i, + ); + const seriesBandScale = (v) => { + const thresholdScale = d3Scale + .scaleThreshold() + .domain(thresHolds) + .range(range); + + return thresholdScale(v); + }; + + return makeDistribution(seriesBandScale, series); + }, [series]); + + const { + index: indexOfMax, + xOfMax, + heightOfMax, + } = useMemo( + () => + bands.reduce( + (calc, band, index) => { + if (calc.max < band.count) { + return { + max: band.count, + index, + xOfMax: band.x, + heightOfMax: band.height, + }; + } + return calc; + }, + { max: -Infinity, index: -1, xOfMax: 0, heightOfMax: 0 }, + ), + [bands], + ); + + const tickLine = useMemo( + () => + d3Shape.line()([ + [LEFT_MARGIN - 8, 12], + [xOfMax + LEFT_MARGIN + SP_WD_RAT / 2, 12], + [xOfMax + LEFT_MARGIN + SP_WD_RAT / 2, SPARK_LINE_HEIGHT + SPARK_TOP_PADD - heightOfMax], + ]), + [xOfMax, heightOfMax], + ); + + return ( + + {bands.map((value, index) => ( + + ))} + + + {bands[indexOfMax]?.count} + + + ); +}; + +BarSpark.propTypes = { + // seriesBandScale: T.func.isRequired, + series: T.arrayOf(T.number.isRequired).isRequired, +}; + +export default BarSpark; diff --git a/front/src/components/molecules/BarSpark/index.js b/front/src/components/molecules/BarSpark/index.js new file mode 100644 index 00000000..526e34b0 --- /dev/null +++ b/front/src/components/molecules/BarSpark/index.js @@ -0,0 +1,3 @@ +import BarSpark from './BarSpark'; + +export default BarSpark; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js index 87ee722e..097638a0 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js @@ -54,24 +54,25 @@ export const SeriesWrapper = styled.div` display: flex; `; -export const MedianWrapper = styled.div` +const TickWrapper = styled.div` display: flex; + font-size: 0.75em; + position: relative; + color: #888; +`; + +export const MedianWrapper = styled(TickWrapper)` align-items: ${({ isTop }) => (isTop ? 'flex-start' : 'flex-end')}; justify-content: flex-end; text-align: right; - font-size: 0.75em; - position: relative; left: -2px; top: -2px; `; -export const MeanWrapper = styled.div` - display: flex; +export const MeanWrapper = styled(TickWrapper)` align-items: flex-end; justify-content: flex-start; text-align: left; - font-size: 0.75em; - position: relative; left: 2px; top: -0.5em; `; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx b/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx index 760a0612..13aa38ee 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx @@ -34,9 +34,9 @@ const ResolveSpark = ({ replySeries, sparkTimeScale, isStart }) => { {!isStart && ( - - - + + + )} diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js index 09575074..1ab8749b 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js @@ -41,10 +41,10 @@ const useStatsTicks = (replySeries, sparkTimeScale) => medianLine: d3Shape.line()([ [0, 0], [xMedian * SP_WD_RAT, 0], - [xMedian * SP_WD_RAT, SPARK_LINE_HEIGHT], + [xMedian * SP_WD_RAT, 5], ]), meanLine: d3Shape.line()([ - [xMean * SP_WD_RAT, SPARK_LINE_PADD * 2], + [xMean * SP_WD_RAT, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2 - 5], [xMean * SP_WD_RAT, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2], [SPARK_LINE_WIDTH, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2], ]), diff --git a/front/src/stories/statistics/BarSpark.stories.mdx b/front/src/stories/statistics/BarSpark.stories.mdx new file mode 100644 index 00000000..02c3437c --- /dev/null +++ b/front/src/stories/statistics/BarSpark.stories.mdx @@ -0,0 +1,27 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; + +import BarSpark from '@sb-ui/components/molecules/BarSpark'; + +import { getBarSparkArgs } from './generateLessonFunnelData'; + + + +# BarSpark + +Component `BarSpark` render + +export const Template = (args) => ; + +1. Empty value `''` + + + + {Template.bind({})} + + diff --git a/front/src/stories/statistics/atoms/SparkBars.stories.mdx b/front/src/stories/statistics/atoms/SparkBars.stories.mdx new file mode 100644 index 00000000..82dc4475 --- /dev/null +++ b/front/src/stories/statistics/atoms/SparkBars.stories.mdx @@ -0,0 +1,74 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; + +import SparkBars from '@sb-ui/components/atoms/SparkBars'; + +import { getSparkBarGroup } from '../generateLessonFunnelData'; + + + +# BarSpark + +Component `SparkBars` examples + +export const Template = (args) =>
+ +
; + +export const Template2 = (args) =>
+ +
; + +export const Template3 = (args) =>
+ +
; + +1. 7 groups of values with 214 students `150px * 20px` + + + + {Template.bind({})} + + + +2. 10 groups of values with 500 students `300px * 100px` + + + + {Template2.bind({})} + + + +3. Distribution of students by time `160px * 40px` + + + 30s: 38' }, + ], + }} + > + {Template3.bind({})} + + + diff --git a/front/src/stories/statistics/generateLessonFunnelData.js b/front/src/stories/statistics/generateLessonFunnelData.js index b1311198..7fed8467 100644 --- a/front/src/stories/statistics/generateLessonFunnelData.js +++ b/front/src/stories/statistics/generateLessonFunnelData.js @@ -23,6 +23,79 @@ const countLanded = (a, i, bitesCount, initialLanded) => i ? a[i - 1].landed : initialLanded, ); +export const getReplySeries = ({ landed, blocks }, i) => + i + ? new Array(landed) + .fill(1) + .map(() => { + const r1 = d3Random.randomNormal( + 100 * blocks.length + 1000 + 5000 * (i % 3), + 1000, + ); + const r2 = d3Random.randomNormal(100 * blocks.length, 5000); + + return Math.max(r1() * Math.min((i % 4) - 1, 1) + r2() + 1000, 500); + }) + .sort((ai, b) => ai - b) + : null; + +const generateBlocks = () => [ + ...new Array(1 + Math.floor(Math.random() * 5)) + .fill(1) + .map( + () => + staticTypesBlocks[Math.floor(Math.random() * staticTypesBlocks.length)], + ), + interactiveTypesBlocks[ + Math.floor(Math.random() * interactiveTypesBlocks.length) + ], +]; + +export const getBarSparkArgs = () => { + const series = getReplySeries({ landed: 214, blocks: generateBlocks() }, 1); + + const maxReplyTime = Math.max(...series); + const minReplyTime = Math.min(...series); + + const range = 10; + const domain = maxReplyTime - minReplyTime; + + const seriesBandScale = (v) => + Math.round(((v - minReplyTime) / domain + 2) * range); + + return { series, seriesBandScale }; +}; + +export const getSparkBarGroup = ({ count = 7, landed = 214 }) => { + const series = getReplySeries({ landed, blocks: generateBlocks() }, 1); + + const maxReplyTime = Math.max(...series); + const minReplyTime = Math.min(...series); + + const disptibutionItem = (maxReplyTime - minReplyTime) / count; + + const groupsDict = series.reduce((dict, time) => { + const theIndex = Math.min(count - 1, Math.floor(time / disptibutionItem)); + + // eslint-disable-next-line no-param-reassign + dict[theIndex] = (dict[theIndex] || 0) + 1; + return dict; + }, {}); + + return Object.entries(groupsDict) + .sort((a, b) => a[0] - b[0]) + .map(([groupId, value]) => ({ + order: groupId, + value, + title: `${value} students: ${[ + groupId ? `>${Math.floor((disptibutionItem * +groupId) / 1000)}s` : '', + groupId < count - 1 + ? `<${Math.floor((disptibutionItem * (+groupId + 1)) / 1000)}s` + : '', + ].join(' ')}`, + })); +}; + const generateLessonFunnelData = (bitesCount = 11, initialLanded = 572) => new Array(bitesCount) .fill(1) @@ -35,42 +108,9 @@ const generateLessonFunnelData = (bitesCount = 11, initialLanded = 572) => ? countLanded(a, i, bitesCount, initialLanded) : initialLanded, prevLanded: i ? a[i - 1].landed : initialLanded, - blocks: i - ? [ - ...new Array(1 + Math.floor(Math.random() * 5)) - .fill(1) - .map( - () => - staticTypesBlocks[ - Math.floor(Math.random() * staticTypesBlocks.length) - ], - ), - interactiveTypesBlocks[ - Math.floor(Math.random() * interactiveTypesBlocks.length) - ], - ] - : null, + blocks: i ? generateBlocks() : null, get replySeries() { - return i - ? new Array(this.landed) - .fill(1) - .map(() => { - const r1 = d3Random.randomNormal( - 100 * this.blocks.length + 1000 + 5000 * (i % 3), - 1000, - ); - const r2 = d3Random.randomNormal( - 100 * this.blocks.length, - 5000, - ); - - return Math.max( - r1() * Math.min((i % 4) - 1, 1) + r2() + 1000, - 500, - ); - }) - .sort((ai, b) => ai - b) - : null; + return getReplySeries(this, i); }, }; return a; From e62b3449f9f9eb466e0542e76590bc87973ecd64 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 15:47:42 +0300 Subject: [PATCH 30/53] refactor: rename and move to atoms distribution spark (resolve spark) --- .../DistributionSpark/DistributionSpark.jsx | 89 ++++++++++++++++++ .../atoms/DistributionSpark/index.js | 3 + .../atoms/DistributionSpark/styled.js | 51 ++++++++++ .../atoms/DistributionSpark/useSpark.js | 93 +++++++++++++++++++ .../atoms/DistributionSpark/useTimeTicks.js} | 43 +++++---- .../molecules/BarSpark/BarSpark.jsx | 9 +- .../LessonFunnel/FunnelBite.jsx | 9 +- .../LessonFunnel/LessonFunnel.styled.js | 33 ------- .../LessonFunnel/ResolveSpark.jsx | 56 ----------- .../LessonStudents/LessonFunnel/consts.js | 4 - .../LessonStudents/LessonFunnel/useSpark.js | 73 --------------- .../atoms/DistributionSpark.stories.mdx | 87 +++++++++++++++++ .../statistics/generateLessonFunnelData.js | 26 +++++- 13 files changed, 384 insertions(+), 192 deletions(-) create mode 100644 front/src/components/atoms/DistributionSpark/DistributionSpark.jsx create mode 100644 front/src/components/atoms/DistributionSpark/index.js create mode 100644 front/src/components/atoms/DistributionSpark/styled.js create mode 100644 front/src/components/atoms/DistributionSpark/useSpark.js rename front/src/{pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js => components/atoms/DistributionSpark/useTimeTicks.js} (56%) delete mode 100644 front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx delete mode 100644 front/src/pages/Teacher/LessonStudents/LessonFunnel/consts.js delete mode 100644 front/src/pages/Teacher/LessonStudents/LessonFunnel/useSpark.js create mode 100644 front/src/stories/statistics/atoms/DistributionSpark.stories.mdx diff --git a/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx b/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx new file mode 100644 index 00000000..85f7ead1 --- /dev/null +++ b/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx @@ -0,0 +1,89 @@ +import T from 'prop-types'; +import { useTranslation } from 'react-i18next'; + +import useSpark from './useSpark'; +import useTimeTicks from './useTimeTicks'; +import * as S from './styled'; + +const DistributionSpark = ({ + replySeries, + timeCohortScale, + isHeader, + verticalPadding, + sparkHeight, + sparkWidth, + ySparkScale, +}) => { + const { t } = useTranslation(); + const { median, mean, medianLine, meanLine } = useTimeTicks({ + replySeries: replySeries || [], + timeCohortScale, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, + }); + + const replyLine = useSpark({ + timeCohortScale, + replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, + }); + + if (!isHeader && !replySeries?.length) { + return ; + } + + const svgWidth = `${sparkWidth}px`; + const svgHeight = `${sparkHeight + verticalPadding * 2}px`; + const svgViewBox = `0 0 ${sparkWidth} ${sparkHeight + verticalPadding * 2}`; + + const Median = isHeader ? S.HeaderMedianWrapper : S.MedianWrapper; + const Mean = isHeader ? S.HeaderMeanWrapper : S.MeanWrapper; + + return ( + + +
{isHeader ? t('teacher:lesson_funnel.median') : median}
+
+ + {!isHeader && ( + + + + + + )} + + +
{isHeader ? t('teacher:lesson_funnel.mean') : mean}
+
+
+ ); +}; + +DistributionSpark.propTypes = { + timeCohortScale: T.func.isRequired, + replySeries: T.arrayOf(T.number), + isHeader: T.bool, + verticalPadding: T.number, + sparkHeight: T.number, + sparkWidth: T.number, + ySparkScale: T.func, +}; + +const SPARK_LINE_WIDTH = 150; +const SP_WD_RAT = SPARK_LINE_WIDTH / 100; + +DistributionSpark.defaultProps = { + isHeader: false, + verticalPadding: 5, + sparkHeight: 10, + sparkWidth: SPARK_LINE_WIDTH, + ySparkScale: (v) => v * SP_WD_RAT, +}; + +export default DistributionSpark; diff --git a/front/src/components/atoms/DistributionSpark/index.js b/front/src/components/atoms/DistributionSpark/index.js new file mode 100644 index 00000000..87524248 --- /dev/null +++ b/front/src/components/atoms/DistributionSpark/index.js @@ -0,0 +1,3 @@ +import DistributionSpark from './DistributionSpark'; + +export default DistributionSpark; diff --git a/front/src/components/atoms/DistributionSpark/styled.js b/front/src/components/atoms/DistributionSpark/styled.js new file mode 100644 index 00000000..b7807381 --- /dev/null +++ b/front/src/components/atoms/DistributionSpark/styled.js @@ -0,0 +1,51 @@ +import styled from 'styled-components'; + +export const SeriesWrapper = styled.div` + display: flex; +`; + +const TickWrapper = styled.div` + display: flex; + font-size: 0.75em; + position: relative; + color: #aaa; + width: 50px; +`; + +export const MedianWrapper = styled(TickWrapper)` + align-items: flex-start; + justify-content: flex-end; + text-align: right; + left: -2px; + top: -2px; +`; + +export const MeanWrapper = styled(TickWrapper)` + align-items: flex-end; + justify-content: flex-start; + text-align: left; + left: 6px; + top: -0.5em; +`; + +const HeaderWrapper = styled(TickWrapper)` + align-items: flex-end; + top: -4px; +`; + +export const HeaderMeanWrapper = styled(HeaderWrapper)` + justify-content: flex-start; + text-align: left; + left: 2px; +`; + +export const HeaderMedianWrapper = styled(HeaderWrapper)` + text-align: right; + justify-content: flex-end; + left: -2px; +`; + +export const SparkWrapper = styled.div` + padding: 0.5em 1px; + width: ${({ $sparkWidth }) => $sparkWidth}px; +`; diff --git a/front/src/components/atoms/DistributionSpark/useSpark.js b/front/src/components/atoms/DistributionSpark/useSpark.js new file mode 100644 index 00000000..db02a0ea --- /dev/null +++ b/front/src/components/atoms/DistributionSpark/useSpark.js @@ -0,0 +1,93 @@ +import * as d3Scale from 'd3-scale'; +import * as d3Shape from 'd3-shape'; +import { useMemo } from 'react'; + +const makeDistribution = ({ + scale, + series, + cohortsNumber = 10, + ySparkScale, + verticalPadding, + sparkHeight, +}) => { + const distDict = series.reduce((dict, replySpeed) => { + // eslint-disable-next-line no-param-reassign + dict[scale(replySpeed)] = (dict[scale(replySpeed)] || 0) + 1; + + return dict; + }, Object.fromEntries([...new Array(cohortsNumber).keys()].map((index) => [index * (100 / cohortsNumber), 0]))); + + const distList = Object.entries(distDict); + + const domain = distList.reduce( + ({ min, max }, [, replyCount]) => ({ + min: Math.min(replyCount, min), + max: Math.max(replyCount, max), + }), + { min: Infinity, max: -Infinity }, + ); + + const numberScale = d3Scale.scaleLinear( + [0, domain.max], + [sparkHeight + verticalPadding, verticalPadding], + ); + + return distList.map(([x, y]) => [ySparkScale(+x), numberScale(y)]); +}; + +const makeLine = ({ + timeCohortScale, + replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, +}) => { + const dist = makeDistribution({ + scale: timeCohortScale, + series: replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + }); + + return d3Shape.line().curve(d3Shape.curveBasis)([ + [0, sparkHeight + verticalPadding], + [dist[0][0] - 1, sparkHeight + verticalPadding], + ...dist, + [dist[dist.length - 1][0] + 1, sparkHeight + verticalPadding], + [sparkWidth, sparkHeight + verticalPadding], + ]); +}; + +const useSpark = ({ + timeCohortScale, + replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, +}) => + useMemo(() => { + if (!replySeries?.length) { + return ''; + } + + return makeLine({ + timeCohortScale, + replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, + }); + }, [ + timeCohortScale, + replySeries, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, + ]); + +export default useSpark; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js b/front/src/components/atoms/DistributionSpark/useTimeTicks.js similarity index 56% rename from front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js rename to front/src/components/atoms/DistributionSpark/useTimeTicks.js index 1ab8749b..b70bd6b6 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useStatsTicks.js +++ b/front/src/components/atoms/DistributionSpark/useTimeTicks.js @@ -1,13 +1,6 @@ import * as d3Shape from 'd3-shape'; import { useMemo } from 'react'; -import { - SP_WD_RAT, - SPARK_LINE_HEIGHT, - SPARK_LINE_PADD, - SPARK_LINE_WIDTH, -} from './consts'; - const findMean = (arr) => arr.filter((x) => !!x).reduce((s, i) => s + i, 0) / arr.length; @@ -23,7 +16,14 @@ const findMedian = (arr) => { return sorted[Math.floor(sorted.length / 2)]; }; -const useStatsTicks = (replySeries, sparkTimeScale) => +const useTimeTicks = ({ + replySeries, + timeCohortScale, + ySparkScale, + verticalPadding, + sparkHeight, + sparkWidth, +}) => useMemo(() => { if (!replySeries?.length) { return {}; @@ -32,23 +32,30 @@ const useStatsTicks = (replySeries, sparkTimeScale) => const seriesMedian = findMedian(replySeries); const seriesMean = findMean(replySeries); - const xMedian = sparkTimeScale(seriesMedian); - const xMean = sparkTimeScale(seriesMean); + const xMedian = timeCohortScale(seriesMedian); + const xMean = timeCohortScale(seriesMean); return { median: `${(seriesMedian / 1000).toFixed(2)}s`, mean: `${(seriesMean / 1000).toFixed(2)}s`, medianLine: d3Shape.line()([ [0, 0], - [xMedian * SP_WD_RAT, 0], - [xMedian * SP_WD_RAT, 5], + [ySparkScale(xMedian), 0], + [ySparkScale(xMedian), verticalPadding], ]), meanLine: d3Shape.line()([ - [xMean * SP_WD_RAT, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2 - 5], - [xMean * SP_WD_RAT, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2], - [SPARK_LINE_WIDTH, SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2], + [ySparkScale(xMean), sparkHeight + verticalPadding], + [ySparkScale(xMean), sparkHeight + verticalPadding * 2], + [sparkWidth, sparkHeight + verticalPadding * 2], ]), }; - }, [replySeries, sparkTimeScale]); - -export default useStatsTicks; + }, [ + replySeries, + timeCohortScale, + sparkHeight, + verticalPadding, + ySparkScale, + sparkWidth, + ]); + +export default useTimeTicks; diff --git a/front/src/components/molecules/BarSpark/BarSpark.jsx b/front/src/components/molecules/BarSpark/BarSpark.jsx index a2445805..9da7ecd7 100644 --- a/front/src/components/molecules/BarSpark/BarSpark.jsx +++ b/front/src/components/molecules/BarSpark/BarSpark.jsx @@ -7,7 +7,7 @@ const SPARK_LINE_WIDTH = 200; const SPARK_LINE_HEIGHT = 32; const SPARK_TOP_PADD = 16; const SPARK_LINE_PADD = 4; -const LEFT_MARGIN = 28; +const LEFT_MARGIN = 42; const SP_WD_RAT = (SPARK_LINE_WIDTH - LEFT_MARGIN - 7 * 4) / 7; const svgWidth = `${SPARK_LINE_WIDTH}px`; @@ -100,7 +100,10 @@ const BarSpark = ({ series }) => { d3Shape.line()([ [LEFT_MARGIN - 8, 12], [xOfMax + LEFT_MARGIN + SP_WD_RAT / 2, 12], - [xOfMax + LEFT_MARGIN + SP_WD_RAT / 2, SPARK_LINE_HEIGHT + SPARK_TOP_PADD - heightOfMax], + [ + xOfMax + LEFT_MARGIN + SP_WD_RAT / 2, + SPARK_LINE_HEIGHT + SPARK_TOP_PADD - heightOfMax, + ], ]), [xOfMax, heightOfMax], ); @@ -119,7 +122,7 @@ const BarSpark = ({ series }) => { /> ))} - + {bands[indexOfMax]?.count} diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx index 277502b4..946b89bd 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx @@ -1,7 +1,8 @@ import T from 'prop-types'; +import DistributionSpark from '@sb-ui/components/atoms/DistributionSpark'; + import BiteDescription from './BiteDescription'; -import ResolveSpark from './ResolveSpark'; import { Bite } from './types'; import * as S from './LessonFunnel.styled'; @@ -28,10 +29,10 @@ const FunnelBite = ({ bite, sparkTimeScale, bitesNumber, isFirst, isLast }) => { {!isFirst && studentsChangePercent} - ); diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js index 097638a0..7d05cfdc 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js @@ -1,7 +1,5 @@ import styled, { css } from 'styled-components'; -import { SPARK_LINE_WIDTH } from './consts'; - export const FunnelWrapper = styled.div` display: grid; grid-template-columns: 12fr 1fr 1fr 6fr 12fr; @@ -50,37 +48,6 @@ export const DiffNumber = styled.div` justify-content: flex-end; `; -export const SeriesWrapper = styled.div` - display: flex; -`; - -const TickWrapper = styled.div` - display: flex; - font-size: 0.75em; - position: relative; - color: #888; -`; - -export const MedianWrapper = styled(TickWrapper)` - align-items: ${({ isTop }) => (isTop ? 'flex-start' : 'flex-end')}; - justify-content: flex-end; - text-align: right; - left: -2px; - top: -2px; -`; - -export const MeanWrapper = styled(TickWrapper)` - align-items: flex-end; - justify-content: flex-start; - text-align: left; - left: 2px; - top: -0.5em; -`; - -export const SparkWrapper = styled.div` - padding: 0.5em 1px; - width: ${SPARK_LINE_WIDTH}px; -`; export const BiteBarWrapper = styled.div` display: flex; justify-content: flex-end; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx b/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx deleted file mode 100644 index 13aa38ee..00000000 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/ResolveSpark.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import T from 'prop-types'; -import { useTranslation } from 'react-i18next'; - -import { SPARK_LINE_HEIGHT, SPARK_LINE_PADD, SPARK_LINE_WIDTH } from './consts'; -import { ReplySeries } from './types'; -import useSpark from './useSpark'; -import useStatsTicks from './useStatsTicks'; -import * as S from './LessonFunnel.styled'; - -const ResolveSpark = ({ replySeries, sparkTimeScale, isStart }) => { - const { t } = useTranslation(); - const { median, mean, medianLine, meanLine } = useStatsTicks( - replySeries || [], - sparkTimeScale, - ); - - const replyLine = useSpark(replySeries, sparkTimeScale); - - if (!isStart && !replySeries?.length) { - return ; - } - - const svgWidth = `${SPARK_LINE_WIDTH}px`; - const svgHeight = `${SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2}px`; - const svgViewBox = `0 0 ${SPARK_LINE_WIDTH} ${ - SPARK_LINE_HEIGHT + SPARK_LINE_PADD * 2 - }`; - - return ( - - -
{isStart ? t('teacher:lesson_funnel.median') : median}
-
- - {!isStart && ( - - - - - - )} - - -
{isStart ? t('teacher:lesson_funnel.mean') : mean}
-
-
- ); -}; - -ResolveSpark.propTypes = { - sparkTimeScale: T.func.isRequired, - replySeries: ReplySeries, - isStart: T.bool.isRequired, -}; - -export default ResolveSpark; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/consts.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/consts.js deleted file mode 100644 index 0964ef54..00000000 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/consts.js +++ /dev/null @@ -1,4 +0,0 @@ -export const SPARK_LINE_WIDTH = 150; -export const SP_WD_RAT = SPARK_LINE_WIDTH / 100; -export const SPARK_LINE_HEIGHT = 10; -export const SPARK_LINE_PADD = 5; diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useSpark.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/useSpark.js deleted file mode 100644 index 12558de7..00000000 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useSpark.js +++ /dev/null @@ -1,73 +0,0 @@ -import * as d3Scale from 'd3-scale'; -import * as d3Shape from 'd3-shape'; -import { useMemo } from 'react'; - -import { - SP_WD_RAT, - SPARK_LINE_HEIGHT, - SPARK_LINE_PADD, - SPARK_LINE_WIDTH, -} from './consts'; - -const makeDistribution = (scale, series) => { - const distDict = series.reduce( - (dict, replySpeed) => { - // eslint-disable-next-line no-param-reassign - dict[scale(replySpeed)] = (dict[scale(replySpeed)] || 0) + 1; - return dict; - }, - { - 0: 0, - 10: 0, - 20: 0, - 30: 0, - 40: 0, - 50: 0, - 60: 0, - 70: 0, - 80: 0, - 90: 0, - 100: 0, - }, - ); - - const distList = Object.entries(distDict); - - const domain = distList.reduce( - ({ min, max }, [, replyCount]) => ({ - min: Math.min(replyCount, min), - max: Math.max(replyCount, max), - }), - { min: Infinity, max: -Infinity }, - ); - - const numberScale = d3Scale.scaleLinear( - [0, domain.max], - [SPARK_LINE_HEIGHT + SPARK_LINE_PADD, SPARK_LINE_PADD], - ); - - return distList.map(([x, y]) => [+x * SP_WD_RAT, numberScale(y)]); -}; - -const makeLine = (sparkTimeScale, replySeries) => { - const dist = makeDistribution(sparkTimeScale, replySeries); - - return d3Shape.line().curve(d3Shape.curveBasis)([ - [0, SPARK_LINE_HEIGHT + SPARK_LINE_PADD], - [dist[0][0] - 1, SPARK_LINE_HEIGHT + SPARK_LINE_PADD], - ...dist, - [dist[dist.length - 1][0] + 1, SPARK_LINE_HEIGHT + SPARK_LINE_PADD], - [SPARK_LINE_WIDTH, SPARK_LINE_HEIGHT + SPARK_LINE_PADD], - ]); -}; - -const useSpark = (replySeries, sparkTimeScale) => - useMemo(() => { - if (!replySeries?.length) { - return ''; - } - - return makeLine(sparkTimeScale, replySeries); - }, [replySeries, sparkTimeScale]); - -export default useSpark; diff --git a/front/src/stories/statistics/atoms/DistributionSpark.stories.mdx b/front/src/stories/statistics/atoms/DistributionSpark.stories.mdx new file mode 100644 index 00000000..4fa97a9a --- /dev/null +++ b/front/src/stories/statistics/atoms/DistributionSpark.stories.mdx @@ -0,0 +1,87 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; + +import DistributionSpark from '@sb-ui/components/atoms/DistributionSpark'; + +import { getDistSparkArgs } from '../generateLessonFunnelData'; + + + +# BarSpark + +Component `DistributionSpark` examples + +export const sparkArgsData1 = getDistSparkArgs() + +export const TemplateDefaultHeader = (args) => ; +export const TemplateDefault = (args) => ; + +export const TemplateSmall = (args) =>
+ v * (80 / 100)} + /> +
; + +export const TemplateLarge = (args) =>
+ v * (200 / 100)} + /> +
; + + +1. DistributionSpark with default size + + + + {TemplateDefault.bind({})} + + + +2. DistributionSpark with small size + + + + {TemplateSmall.bind({})} + + + +3. DistributionSpark with large size + + + + {TemplateLarge.bind({})} + + + + +DistributionSpark header + + + + {TemplateDefaultHeader.bind({})} + + + + diff --git a/front/src/stories/statistics/generateLessonFunnelData.js b/front/src/stories/statistics/generateLessonFunnelData.js index 7fed8467..f7ec3f40 100644 --- a/front/src/stories/statistics/generateLessonFunnelData.js +++ b/front/src/stories/statistics/generateLessonFunnelData.js @@ -52,7 +52,10 @@ const generateBlocks = () => [ ]; export const getBarSparkArgs = () => { - const series = getReplySeries({ landed: 214, blocks: generateBlocks() }, 1); + const series1 = getReplySeries({ landed: 94, blocks: generateBlocks() }, 1); + const series2 = getReplySeries({ landed: 101, blocks: generateBlocks() }, 1); + + const series = [...series1, ...series2.map((v) => v + 5000)]; const maxReplyTime = Math.max(...series); const minReplyTime = Math.min(...series); @@ -66,6 +69,27 @@ export const getBarSparkArgs = () => { return { series, seriesBandScale }; }; +export const getDistSparkArgs = () => { + const series1 = getReplySeries({ landed: 294, blocks: generateBlocks() }, 1); + const series2 = getReplySeries({ landed: 101, blocks: generateBlocks() }, 1); + + const series = [...series1, ...series2.map((v) => v + 16000)]; + + const maxReplyTime = Math.max(...series); + const minReplyTime = Math.min(...series); + + const range = 10; + const domain = maxReplyTime - minReplyTime; + + const seriesBandScale = (v) => + Math.round(((v - minReplyTime) / domain + 2) * range); + + return { + timeCohortScale: seriesBandScale, + replySeries: series, + }; +}; + export const getSparkBarGroup = ({ count = 7, landed = 214 }) => { const series = getReplySeries({ landed, blocks: generateBlocks() }, 1); From f501bdea437e7c8c77cdf23aefb12cac3c0ee808 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 22:10:47 +0300 Subject: [PATCH 31/53] feat: lesson funnel design improvements --- .../atoms/DistributionSpark/styled.js | 6 ++- .../LessonFunnel/FunnelBite.jsx | 6 ++- .../LessonFunnel/LessonFunnel.jsx | 9 ++++ .../LessonFunnel/LessonFunnel.styled.js | 43 ++++++++++++++++--- front/src/resources/lang/en/teacher.js | 7 ++- front/src/resources/lang/ru/teacher.js | 7 ++- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/front/src/components/atoms/DistributionSpark/styled.js b/front/src/components/atoms/DistributionSpark/styled.js index b7807381..f6a7fe5c 100644 --- a/front/src/components/atoms/DistributionSpark/styled.js +++ b/front/src/components/atoms/DistributionSpark/styled.js @@ -2,14 +2,15 @@ import styled from 'styled-components'; export const SeriesWrapper = styled.div` display: flex; + justify-content: center; `; const TickWrapper = styled.div` display: flex; font-size: 0.75em; position: relative; - color: #aaa; - width: 50px; + color: #888; + width: 40px; `; export const MedianWrapper = styled(TickWrapper)` @@ -31,6 +32,7 @@ export const MeanWrapper = styled(TickWrapper)` const HeaderWrapper = styled(TickWrapper)` align-items: flex-end; top: -4px; + color: #555; `; export const HeaderMeanWrapper = styled(HeaderWrapper)` diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx index 946b89bd..131c87ba 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx @@ -22,12 +22,14 @@ const FunnelBite = ({ bite, sparkTimeScale, bitesNumber, isFirst, isLast }) => { initialLanded={initialLanded} whole={bitesNumber} number={id} - /> + > + {landed} + - {landed} {!isFirst && studentsChangePercent} +
{ + const { t } = useTranslation(); const { sparkTimeScale } = useFunnelScales(bites); const bitesNumber = bites.length; return ( + {t('teacher:lesson_funnel.bar_title')} +
+
+ + {t('teacher:lesson_funnel.content_title')} + + {t('teacher:lesson_funnel.spark_title')} {bites.map((bite, index) => ( div { flex-basis: 2em; - text-align: center; } path { fill: #000; @@ -22,11 +22,11 @@ export const TypeWrapper = styled.div` `; export const LessonStarted = styled.div` - text-align: center; - font-weight: 700; + font-weight: 500; width: 100%; flex-basis: 100%; flex-grow: 1; + text-align: left; `; export const NumberWrapper = styled.div` @@ -36,7 +36,7 @@ export const NumberWrapper = styled.div` padding-left: 1em; `; -const diffColors = ['#d46b08', '#fa541c', '#f5222d']; +const diffColors = ['#8c8c8c', '#d5222d', '#f5222d']; export const DiffNumber = styled.div` color: ${({ value }) => @@ -48,6 +48,17 @@ export const DiffNumber = styled.div` justify-content: flex-end; `; +export const Percentage = styled.div` + color: ${({ value }) => + diffColors[Math.min(Math.max(Math.floor((value / 100) * 7) - 2, 0), 2)]}; + font-size: 0.75em; + display: flex; + align-items: center; + align-self: start; + justify-content: flex-end; + flex-grow: 1; +`; + export const BiteBarWrapper = styled.div` display: flex; justify-content: flex-end; @@ -56,15 +67,33 @@ export const BiteBarWrapper = styled.div` const countWidthPercent = ({ landed, initialLanded }) => Math.floor((landed / initialLanded) * 100); -const getOpacity = ({ whole, number }) => - number === whole ? 1 : 0.2 + (number * 2) / (whole * 3); + const barColor = css` - background-color: rgba(0, 0, 200, ${getOpacity}); + background-color: rgba(9, 140, 140, 0.2); padding: 0; margin: 0; `; export const BiteBar = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; width: ${countWidthPercent}%; ${barColor} `; + +export const LandedNumber = styled.div` + padding: 0.3em; + text-align: right; + font-size: 1em; + font-weight: 300; + color: rgba(0, 0, 0, 0.85); +`; + +export const ColumnsTitle = styled.div` + display: flex; + align-items: flex-end; + justify-content: center; + font-weight: 300; + color: rgba(0, 0, 0, 0.85); +`; diff --git a/front/src/resources/lang/en/teacher.js b/front/src/resources/lang/en/teacher.js index 27aa8657..029549ec 100644 --- a/front/src/resources/lang/en/teacher.js +++ b/front/src/resources/lang/en/teacher.js @@ -79,10 +79,13 @@ export default { all: 'View all', }, lesson_funnel: { - finish_bite: 'Email sumbited!', - start_bite: 'Lesson start', + finish_bite: 'Finish', + start_bite: 'Start', mean: 'Average', median: 'Median', + bar_title: 'Students funnel', + content_title: 'Bite content', + spark_title: 'Time distribution', }, editor_js: { tool_names: { diff --git a/front/src/resources/lang/ru/teacher.js b/front/src/resources/lang/ru/teacher.js index ab3078da..978ceba8 100644 --- a/front/src/resources/lang/ru/teacher.js +++ b/front/src/resources/lang/ru/teacher.js @@ -79,10 +79,13 @@ export default { all: 'Показать всех', }, lesson_funnel: { - finish_bite: 'Емейл оставлен!', - start_bite: 'Начало урока', + finish_bite: 'Урок закончен', + start_bite: 'Урок начат', mean: 'Среднее', median: 'Медиана', + bar_title: 'Воронка студентов', + content_title: 'Содержание байта', + spark_title: 'Распределение по времени', }, editor_js: { tool_names: { From e50b7b1938685f42fe2d969b1f567f555721a849 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 22:45:42 +0300 Subject: [PATCH 32/53] fix: cohorts out of range --- .../Teacher/LessonStudents/LessonFunnel/useFunnelScales.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useFunnelScales.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/useFunnelScales.js index d43e4af5..2036372f 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/useFunnelScales.js +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/useFunnelScales.js @@ -22,7 +22,10 @@ const useFunnelScales = (bites) => const range = MXN_RANGE - MIN_RANGE; const domain = maxReplyTime - minReplyTime; const sparkTimeScale = (v) => - Math.round(((v - minReplyTime) / domain) * range); + Math.min( + MXN_RANGE, + Math.max(MIN_RANGE, Math.round(((v - minReplyTime) / domain) * range)), + ); return { sparkTimeScale, maxReplyTime, minReplyTime }; }, [bites]); From 64a8d47d6712e69df24c550240b8108e01c3a6a7 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 22:51:56 +0300 Subject: [PATCH 33/53] fix: alignment of count --- .../Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx | 1 + .../LessonStudents/LessonFunnel/LessonFunnel.styled.js | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx index 131c87ba..923f084d 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/FunnelBite.jsx @@ -24,6 +24,7 @@ const FunnelBite = ({ bite, sparkTimeScale, bitesNumber, isFirst, isLast }) => { number={id} > {landed} +
 
diff --git a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js index 0104002b..96e63b90 100644 --- a/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js +++ b/front/src/pages/Teacher/LessonStudents/LessonFunnel/LessonFunnel.styled.js @@ -77,12 +77,19 @@ const barColor = css` export const BiteBar = styled.div` display: flex; align-items: center; - justify-content: flex-start; + justify-content: flex-end; width: ${countWidthPercent}%; ${barColor} + + div { + flex-grow: 1; + } `; export const LandedNumber = styled.div` + div& { + flex-grow: 0; + } padding: 0.3em; text-align: right; font-size: 1em; From 3cd765c6e09dfbe013186f4b208a42f60a65f282 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 23:26:23 +0300 Subject: [PATCH 34/53] fix: progress collection --- api/src/models/Lesson.js | 6 +++--- api/src/services/learn/controllers/getLesson.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/models/Lesson.js b/api/src/models/Lesson.js index f36480ab..535b8b6b 100644 --- a/api/src/models/Lesson.js +++ b/api/src/models/Lesson.js @@ -292,13 +292,13 @@ class Lesson extends BaseModel { return query.withGraphFetched('author'); } - static getLessonWithProgress({ lessonId }) { + static getLessonWithProgress({ lessonId, userId }) { return this.query() .select( 'lessons.*', this.knex().raw(` - (select count(*) from results where lesson_id = lessons.id and action in ('next', 'response')) interactive_passed - `), + (select count(*) from results where lesson_id = lessons.id and user_id = ? and action in ('next', 'response')) interactive_passed + `, [userId]), this.knex().raw(` (select count(*) from lesson_block_structure join blocks on blocks.block_id = lesson_block_structure.block_id where blocks.type in ('next', 'quiz') and lesson_block_structure.lesson_id = lessons.id) interactive_total diff --git a/api/src/services/learn/controllers/getLesson.js b/api/src/services/learn/controllers/getLesson.js index d0348476..4bfa93be 100644 --- a/api/src/services/learn/controllers/getLesson.js +++ b/api/src/services/learn/controllers/getLesson.js @@ -74,7 +74,7 @@ async function handler({ user: { id: userId }, params: { lessonId } }) { /** * get lesson */ - const lesson = await Lesson.getLessonWithProgress({ lessonId }); + const lesson = await Lesson.getLessonWithProgress({ lessonId, userId }); /** * get last record from the results table */ From 7a1f7a69ca65cc3b03551e321ed75f85c9318182 Mon Sep 17 00:00:00 2001 From: Dmytro Shaforostov Date: Tue, 19 Oct 2021 23:28:55 +0300 Subject: [PATCH 35/53] fix: style of distribution spark --- .../components/atoms/DistributionSpark/DistributionSpark.jsx | 2 +- front/src/components/atoms/DistributionSpark/styled.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx b/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx index 85f7ead1..42caafbc 100644 --- a/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx +++ b/front/src/components/atoms/DistributionSpark/DistributionSpark.jsx @@ -81,7 +81,7 @@ const SP_WD_RAT = SPARK_LINE_WIDTH / 100; DistributionSpark.defaultProps = { isHeader: false, verticalPadding: 5, - sparkHeight: 10, + sparkHeight: 16, sparkWidth: SPARK_LINE_WIDTH, ySparkScale: (v) => v * SP_WD_RAT, }; diff --git a/front/src/components/atoms/DistributionSpark/styled.js b/front/src/components/atoms/DistributionSpark/styled.js index f6a7fe5c..642ccc1b 100644 --- a/front/src/components/atoms/DistributionSpark/styled.js +++ b/front/src/components/atoms/DistributionSpark/styled.js @@ -48,6 +48,6 @@ export const HeaderMedianWrapper = styled(HeaderWrapper)` `; export const SparkWrapper = styled.div` - padding: 0.5em 1px; + padding: 0.2em 1px; width: ${({ $sparkWidth }) => $sparkWidth}px; `; From 13dbde167b5cd9980aa3d34f947d0caab11ca4b4 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 19 Oct 2021 16:05:14 +0300 Subject: [PATCH 36/53] fix: keywords placeholder with localization --- .../molecules/KeywordsFilter/KeywordsFilter.jsx | 9 ++++++++- front/src/resources/lang/en/common.js | 4 ++++ front/src/resources/lang/ru/common.js | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/front/src/components/molecules/KeywordsFilter/KeywordsFilter.jsx b/front/src/components/molecules/KeywordsFilter/KeywordsFilter.jsx index 9ec32930..7a1d24fa 100644 --- a/front/src/components/molecules/KeywordsFilter/KeywordsFilter.jsx +++ b/front/src/components/molecules/KeywordsFilter/KeywordsFilter.jsx @@ -1,3 +1,4 @@ +import { Empty } from 'antd'; import PropTypes from 'prop-types'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,8 +24,14 @@ const KeywordsFilter = ({ width = '250px', setValues }) => { return ( + } width={width} - placeholder={t('filter.placeholder')} + placeholder={t('keywords_filter.placeholder')} onSearch={handleSearch} onChange={setValues} options={options} diff --git a/front/src/resources/lang/en/common.js b/front/src/resources/lang/en/common.js index 90055519..0670165f 100644 --- a/front/src/resources/lang/en/common.js +++ b/front/src/resources/lang/en/common.js @@ -20,6 +20,10 @@ export default { back_home_button: 'Back Home', refresh_page_button: 'Refresh page', }, + keywords_filter: { + placeholder: 'Select keyword', + no_data: 'No keywords', + }, filter: { placeholder: 'Select item', }, diff --git a/front/src/resources/lang/ru/common.js b/front/src/resources/lang/ru/common.js index fde01bb6..f2d346f0 100644 --- a/front/src/resources/lang/ru/common.js +++ b/front/src/resources/lang/ru/common.js @@ -20,6 +20,10 @@ export default { back_home_button: 'Назад на Главную', refresh_page_button: 'Обновить страницу', }, + keywords_filter: { + placeholder: 'Выберите ключевое слово', + no_data: 'Нет ключевых слов', + }, filter: { placeholder: 'Выберите элемент', }, From 896e91e48d48a1e645eb703203e35383fefab005 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 19 Oct 2021 16:29:19 +0300 Subject: [PATCH 37/53] fix: add to finished lesson and course View button instead of Continue --- .../resourceBlocks/OngoingFull/OngoingFull.desktop.jsx | 9 +++++++-- .../resourceBlocks/OngoingFull/OngoingFull.mobile.jsx | 9 +++++++-- front/src/pages/User/Courses/Courses.jsx | 1 + front/src/pages/User/Lessons/Lessons.jsx | 1 + .../pages/User/Lessons/ResourcesList/ResourcesList.jsx | 3 ++- front/src/pages/User/Lessons/ResourcesList/types.js | 1 + front/src/resources/lang/en/user.js | 1 + front/src/resources/lang/ru/user.js | 1 + 8 files changed, 21 insertions(+), 5 deletions(-) diff --git a/front/src/components/resourceBlocks/OngoingFull/OngoingFull.desktop.jsx b/front/src/components/resourceBlocks/OngoingFull/OngoingFull.desktop.jsx index 05c21e43..6fbee4dc 100644 --- a/front/src/components/resourceBlocks/OngoingFull/OngoingFull.desktop.jsx +++ b/front/src/components/resourceBlocks/OngoingFull/OngoingFull.desktop.jsx @@ -10,7 +10,7 @@ import DefaultLessonImage from '@sb-ui/resources/img/lesson.svg'; import { useResource } from './useResource'; import * as S from './OngoingFull.desktop.styled'; -const OngoingFullDesktop = ({ resource, resourceKey }) => { +const OngoingFullDesktop = ({ resource, resourceKey, isFinished }) => { const { t } = useTranslation('user'); const { name, @@ -36,6 +36,10 @@ const OngoingFullDesktop = ({ resource, resourceKey }) => { return Math.round((interactivePassed / interactiveTotal) * 100); }, [resource, interactivePassed, interactiveTotal]); + const buttonTranslationKey = `home.ongoing_lessons.${ + isFinished ? 'view_button' : 'continue_button' + }`; + return ( <> @@ -69,7 +73,7 @@ const OngoingFullDesktop = ({ resource, resourceKey }) => { @@ -82,6 +86,7 @@ const OngoingFullDesktop = ({ resource, resourceKey }) => { OngoingFullDesktop.propTypes = { resource: LessonType.isRequired, resourceKey: PropTypes.string.isRequired, + isFinished: PropTypes.bool, }; export default OngoingFullDesktop; diff --git a/front/src/components/resourceBlocks/OngoingFull/OngoingFull.mobile.jsx b/front/src/components/resourceBlocks/OngoingFull/OngoingFull.mobile.jsx index e1b78370..3bded7b3 100644 --- a/front/src/components/resourceBlocks/OngoingFull/OngoingFull.mobile.jsx +++ b/front/src/components/resourceBlocks/OngoingFull/OngoingFull.mobile.jsx @@ -10,7 +10,7 @@ import DefaultLessonImage from '@sb-ui/resources/img/lesson.svg'; import { useResource } from './useResource'; import * as S from './OngoingFull.mobile.styled'; -const OngoingFullMobile = ({ resource, resourceKey }) => { +const OngoingFullMobile = ({ resource, resourceKey, isFinished }) => { const { t } = useTranslation('user'); const { name, @@ -36,6 +36,10 @@ const OngoingFullMobile = ({ resource, resourceKey }) => { return Math.round((interactivePassed / interactiveTotal) * 100); }, [resource, interactivePassed, interactiveTotal]); + const buttonTranslationKey = `home.ongoing_lessons.${ + isFinished ? 'view_button' : 'continue_button' + }`; + return ( @@ -65,7 +69,7 @@ const OngoingFullMobile = ({ resource, resourceKey }) => { - {t('home.ongoing_lessons.continue_button')} + {t(buttonTranslationKey)} @@ -79,6 +83,7 @@ const OngoingFullMobile = ({ resource, resourceKey }) => { OngoingFullMobile.propTypes = { resource: LessonType.isRequired, resourceKey: PropTypes.string.isRequired, + isFinished: PropTypes.bool, }; export default OngoingFullMobile; diff --git a/front/src/pages/User/Courses/Courses.jsx b/front/src/pages/User/Courses/Courses.jsx index 025a47d2..fdd2bd48 100644 --- a/front/src/pages/User/Courses/Courses.jsx +++ b/front/src/pages/User/Courses/Courses.jsx @@ -39,6 +39,7 @@ const Courses = () => { resourceKey={COURSES_RESOURCE_KEY} title={t('home.finished_courses.title')} notFound={t('home.finished_courses.not_found')} + isFinished query={{ key: USER_ENROLLED_COURSES_FINISHED_BASE_KEY, func: getEnrolledCoursesFinished, diff --git a/front/src/pages/User/Lessons/Lessons.jsx b/front/src/pages/User/Lessons/Lessons.jsx index 9f5bffe7..0963e34d 100644 --- a/front/src/pages/User/Lessons/Lessons.jsx +++ b/front/src/pages/User/Lessons/Lessons.jsx @@ -40,6 +40,7 @@ const Lessons = () => { resourceKey={LESSONS_RESOURCE_KEY} title={t('home.finished_lessons.title')} notFound={t('home.finished_lessons.not_found')} + isFinished query={{ key: USER_ENROLLED_LESSONS_FINISHED_BASE_KEY, func: getEnrolledLessonsFinished, diff --git a/front/src/pages/User/Lessons/ResourcesList/ResourcesList.jsx b/front/src/pages/User/Lessons/ResourcesList/ResourcesList.jsx index 0e362d75..a3f831bd 100644 --- a/front/src/pages/User/Lessons/ResourcesList/ResourcesList.jsx +++ b/front/src/pages/User/Lessons/ResourcesList/ResourcesList.jsx @@ -17,7 +17,7 @@ import { PAGE_SIZE } from './constants'; import { LessonsListPropTypes } from './types'; import * as S from './ResourcesList.styled'; -const ResourcesList = ({ title, notFound, query, resourceKey }) => { +const ResourcesList = ({ title, notFound, query, resourceKey, isFinished }) => { const { key: queryKey, func: queryFunc } = query; const [currentPage, setCurrentPage] = useState(1); const [searchText, setSearchText] = useState(null); @@ -84,6 +84,7 @@ const ResourcesList = ({ title, notFound, query, resourceKey }) => { ))} diff --git a/front/src/pages/User/Lessons/ResourcesList/types.js b/front/src/pages/User/Lessons/ResourcesList/types.js index cc28fc8e..f733f5d7 100644 --- a/front/src/pages/User/Lessons/ResourcesList/types.js +++ b/front/src/pages/User/Lessons/ResourcesList/types.js @@ -4,6 +4,7 @@ export const LessonsListPropTypes = { title: PropTypes.string.isRequired, notFound: PropTypes.string.isRequired, resourceKey: PropTypes.string.isRequired, + isFinished: PropTypes.bool, query: PropTypes.shape({ key: PropTypes.string.isRequired, func: PropTypes.func.isRequired, diff --git a/front/src/resources/lang/en/user.js b/front/src/resources/lang/en/user.js index 71cc6019..a51285d9 100644 --- a/front/src/resources/lang/en/user.js +++ b/front/src/resources/lang/en/user.js @@ -33,6 +33,7 @@ export default { view_all_lessons: 'View all my lessons', view_all_courses: 'View all my courses', continue_button: 'Continue', + view_button: 'View', }, ongoing_courses: { title: 'Ongoing courses', diff --git a/front/src/resources/lang/ru/user.js b/front/src/resources/lang/ru/user.js index dc2e46ab..eb91222f 100644 --- a/front/src/resources/lang/ru/user.js +++ b/front/src/resources/lang/ru/user.js @@ -33,6 +33,7 @@ export default { view_all_lessons: 'Посмотреть все мои уроки', view_all_courses: 'Посмотреть все мои курсы', continue_button: 'Продолжить', + view_button: 'Посмотреть', }, ongoing_courses: { title: 'Текущие курсы', From bc26d2f13524721db17f8a758e7ff156afe1454f Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Wed, 20 Oct 2021 10:54:57 +0300 Subject: [PATCH 38/53] fix: change space of blocks to 16px, icons - 24px --- .../BlockElement/BlockElement.styled.js | 21 +++++++++++++++++++ .../BlockElement/Bricks/Result/Result.jsx | 9 ++++++-- .../Bricks/Result/Result.styled.js | 14 +++---------- .../Result/AnswerResult/AnswerResult.jsx | 9 +++++--- .../FillTheGap/Result/ResultTitle.jsx | 14 ++++++++----- .../LearnPage/BlockElement/Match/Match.jsx | 6 +++--- .../BlockElement/Quiz/QuizResult.jsx | 6 +++--- .../pages/User/LearnPage/LearnPage.styled.jsx | 2 +- 8 files changed, 53 insertions(+), 28 deletions(-) diff --git a/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js b/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js index 8bf8f3ec..9350029a 100644 --- a/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/BlockElement.styled.js @@ -1,5 +1,8 @@ import { Row } from 'antd'; import styled from 'styled-components'; +import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; + +import variables from '@sb-ui/theme/variables'; export const BlockElementWrapperWhite = styled(Row)` width: 100%; @@ -17,3 +20,21 @@ export const BlockElementWrapperWhite = styled(Row)` width: 100vw; } `; + +export const SuccessCircle = styled(CheckCircleTwoTone).attrs({ + twoToneColor: variables['success-color'], +})` + font-size: 24px; +`; + +export const PartialFailCircle = styled(CloseCircleTwoTone).attrs({ + twoToneColor: variables['partial-wrong-color'], +})` + font-size: 24px; +`; + +export const FailCircle = styled(CloseCircleTwoTone).attrs({ + twoToneColor: variables['wrong-color'], +})` + font-size: 24px; +`; diff --git a/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.jsx b/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.jsx index f9cb32ac..a4cdf326 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.jsx @@ -2,6 +2,7 @@ import { Typography } from 'antd'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; +import { FailCircle, SuccessCircle } from '../../BlockElement.styled'; import * as S from './Result.styled'; const { Text } = Typography; @@ -16,9 +17,13 @@ const Result = ({ correct, results }) => { <> {t(titleKey)} - {correct ? : } + {correct ? : } - {!correct && {results.join(' ')}} + {!correct && ( + + {results.join(' ')} + + )} ); }; diff --git a/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.styled.js b/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.styled.js index a38dc667..d33e5c22 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/Bricks/Result/Result.styled.js @@ -1,19 +1,11 @@ import styled from 'styled-components'; -import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; - -import variables from '@sb-ui/theme/variables'; export const AnswerWrapper = styled.div` display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; `; -export const SuccessCircle = styled(CheckCircleTwoTone).attrs({ - twoToneColor: variables['success-color'], -})``; - -export const FailCircle = styled(CloseCircleTwoTone).attrs({ - twoToneColor: variables['wrong-color'], -})``; +export const ResultWrapper = styled.div` + margin-top: 1rem; +`; diff --git a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/AnswerResult/AnswerResult.jsx b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/AnswerResult/AnswerResult.jsx index 6e5b69ec..ccf6d243 100644 --- a/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/AnswerResult/AnswerResult.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/ClosedQuestion/Result/AnswerResult/AnswerResult.jsx @@ -1,8 +1,11 @@ import { Typography } from 'antd'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; +import { + FailCircle, + SuccessCircle, +} from '@sb-ui/pages/User/LearnPage/BlockElement/BlockElement.styled'; import * as S from '@sb-ui/pages/User/LearnPage/BlockElement/Quiz/Quiz.styled'; const { Text } = Typography; @@ -14,7 +17,7 @@ const AnswerResult = ({ correct, results, explanation }) => { {correct ? ( {t('lesson.answer_result.correct')} - + ) : ( <> @@ -24,7 +27,7 @@ const AnswerResult = ({ correct, results, explanation }) => { results ? `${results?.join(', ')}.` : '' } ${explanation || ''}`} - + )} diff --git a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/Result/ResultTitle.jsx b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/Result/ResultTitle.jsx index e6ba9ee2..44aafb9c 100644 --- a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/Result/ResultTitle.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/Result/ResultTitle.jsx @@ -1,7 +1,6 @@ import { Typography } from 'antd'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; import { CORRECT_ALL, @@ -9,7 +8,12 @@ import { CORRECT_PARTIAL, } from '@sb-ui/pages/User/LearnPage/BlockElement/FillTheGap/constants'; import * as S from '@sb-ui/pages/User/LearnPage/BlockElement/FillTheGap/Result/Result.styled'; -import variables from '@sb-ui/theme/variables'; + +import { + FailCircle, + PartialFailCircle, + SuccessCircle, +} from '../../BlockElement.styled'; const { Text } = Typography; @@ -20,21 +24,21 @@ const ResultTitle = ({ correct }) => { return ( {t('lesson.answer_result.correct')} - + ); case CORRECT_PARTIAL: return ( {t('lesson.answer_result.partially_wrong')} - + ); case CORRECT_NONE: return ( {t('lesson.answer_result.wrong')} - + ); default: diff --git a/front/src/pages/User/LearnPage/BlockElement/Match/Match.jsx b/front/src/pages/User/LearnPage/BlockElement/Match/Match.jsx index 242154a0..04841831 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Match/Match.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Match/Match.jsx @@ -1,7 +1,6 @@ import { Typography } from 'antd'; import { useCallback, useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; import LearnContext from '@sb-ui/contexts/LearnContext'; import MatchSelect from '@sb-ui/pages/User/LearnPage/BlockElement/Match/MatchSelect/MatchSelect'; @@ -18,6 +17,7 @@ import { ChunkWrapper } from '@sb-ui/pages/User/LearnPage/LearnPage.styled'; import { RESPONSE_TYPE } from '@sb-ui/pages/User/LearnPage/utils'; import { verifyAnswers } from './verifyAnswers'; +import { FailCircle, SuccessCircle } from '../BlockElement.styled'; import * as S from './Match.styled'; const { Text } = Typography; @@ -81,14 +81,14 @@ const Match = ({ blockId, revision, answer, content, reply, isSolved }) => { {correct ? ( {t('lesson.answer_result.correct')} - + ) : ( <> {t('lesson.answer_result.wrong')} - + diff --git a/front/src/pages/User/LearnPage/BlockElement/Quiz/QuizResult.jsx b/front/src/pages/User/LearnPage/BlockElement/Quiz/QuizResult.jsx index 6400aa37..a9e45ae6 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Quiz/QuizResult.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Quiz/QuizResult.jsx @@ -1,10 +1,10 @@ import { Typography } from 'antd'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; -import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'; import ColumnDisabledCheckbox from '@sb-ui/components/atoms/ColumnDisabledCheckbox'; +import { FailCircle, SuccessCircle } from '../BlockElement.styled'; import * as S from './Quiz.styled'; const { Text } = Typography; @@ -16,7 +16,7 @@ const QuizResult = ({ correct, value, options }) => { return ( {t('lesson.answer_result.correct')} - + ); } @@ -25,7 +25,7 @@ const QuizResult = ({ correct, value, options }) => { <> {t('lesson.answer_result.wrong')} - + diff --git a/front/src/pages/User/LearnPage/LearnPage.styled.jsx b/front/src/pages/User/LearnPage/LearnPage.styled.jsx index 0296abf4..12819e68 100644 --- a/front/src/pages/User/LearnPage/LearnPage.styled.jsx +++ b/front/src/pages/User/LearnPage/LearnPage.styled.jsx @@ -57,7 +57,7 @@ export const ChunkWrapper = styled.div` width: 100%; background-color: ${variables['learn-chunk-background']}; border-radius: 8px; - padding: 2rem; + padding: 1rem; @media (max-width: 767px) { margin-top: ${(props) => (props.isBottom ? 'auto' : '')}; From 2e045c9ba083d812ccae11a2dc7da0e3215204ef Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Wed, 20 Oct 2021 12:06:10 +0300 Subject: [PATCH 39/53] fix: enroll to lesson without modal and navigate to lesson (course lessons) --- .../resourceBlocks/Public/Public.desktop.jsx | 36 +++++----- .../resourceBlocks/Public/Public.mobile.jsx | 36 +++++----- .../resourceBlocks/Public/useResource.js | 66 ++++++++++++++++++- .../src/pages/User/CoursePage/CoursePage.jsx | 2 +- 4 files changed, 95 insertions(+), 45 deletions(-) diff --git a/front/src/components/resourceBlocks/Public/Public.desktop.jsx b/front/src/components/resourceBlocks/Public/Public.desktop.jsx index 76e5b3df..28d434c2 100644 --- a/front/src/components/resourceBlocks/Public/Public.desktop.jsx +++ b/front/src/components/resourceBlocks/Public/Public.desktop.jsx @@ -12,9 +12,14 @@ import * as S from './Public.desktop.styled'; const PublicDesktop = ({ resource, isCourse, isCourseLesson }) => { const { t } = useTranslation('user'); - const { name, description, isEnrolled, keywords, image } = resource; - const { fullName, firstNameLetter, handleContinueLesson, handleEnroll } = - useResource({ resource, isCourse }); + const { name, description, keywords, image } = resource; + const { + fullName, + firstNameLetter, + buttonType, + buttonTitleKey, + buttonClickHandler, + } = useResource({ resource, isCourse, isCourseLesson }); const isButtonDisabled = isCourseLesson && !resource.isFinished && !resource.isCurrent; @@ -61,24 +66,13 @@ const PublicDesktop = ({ resource, isCourse, isCourseLesson }) => { - {isEnrolled ? ( - - ) : ( - - )} + diff --git a/front/src/components/resourceBlocks/Public/Public.mobile.jsx b/front/src/components/resourceBlocks/Public/Public.mobile.jsx index 1fc928dc..ccace8bc 100644 --- a/front/src/components/resourceBlocks/Public/Public.mobile.jsx +++ b/front/src/components/resourceBlocks/Public/Public.mobile.jsx @@ -12,9 +12,14 @@ import * as S from './Public.mobile.styled'; const PublicMobile = ({ resource, isCourse, isCourseLesson }) => { const { t } = useTranslation('user'); - const { name, description, isEnrolled, image, keywords } = resource; - const { fullName, firstNameLetter, handleContinueLesson, handleEnroll } = - useResource({ resource, isCourse }); + const { name, description, image, keywords } = resource; + const { + fullName, + firstNameLetter, + buttonType, + buttonTitleKey, + buttonClickHandler, + } = useResource({ resource, isCourse, isCourseLesson }); const isButtonDisabled = isCourseLesson && !resource.isFinished && !resource.isCurrent; @@ -47,24 +52,13 @@ const PublicMobile = ({ resource, isCourse, isCourseLesson }) => { - {isEnrolled ? ( - - {t('home.ongoing_lessons.continue_button')} - - ) : ( - - {t('home.open_lessons.enroll_button')} - - )} + + {t(buttonTitleKey)} + {firstNameLetter} diff --git a/front/src/components/resourceBlocks/Public/useResource.js b/front/src/components/resourceBlocks/Public/useResource.js index 56b7df4f..783fa29f 100644 --- a/front/src/components/resourceBlocks/Public/useResource.js +++ b/front/src/components/resourceBlocks/Public/useResource.js @@ -1,6 +1,8 @@ import { useCallback, useMemo } from 'react'; +import { useMutation } from 'react-query'; import { useHistory, useLocation } from 'react-router-dom'; +import { enrollLesson } from '@sb-ui/utils/api/v1/lessons'; import { LEARN_COURSE_PAGE, LEARN_PAGE, @@ -8,10 +10,15 @@ import { USER_ENROLL_LESSON, } from '@sb-ui/utils/paths'; -export const useResource = ({ resource: { id, author }, isCourse = false }) => { +export const useResource = ({ + resource: { id, author, isEnrolled, isFinished }, + isCourse = false, + isCourseLesson, +}) => { const location = useLocation(); const query = useMemo(() => location.search, [location]); const history = useHistory(); + const { mutate: mutatePostEnroll } = useMutation(enrollLesson); const fullName = useMemo( () => `${author?.firstName} ${author?.lastName}`.trim(), @@ -39,5 +46,60 @@ export const useResource = ({ resource: { id, author }, isCourse = false }) => { [history, id, isCourse], ); - return { fullName, firstNameLetter, handleEnroll, handleContinueLesson }; + const handleEnrollAndContinueLesson = useCallback(() => { + mutatePostEnroll(id, { + onSettled: () => { + handleContinueLesson(); + }, + }); + }, [handleContinueLesson, id, mutatePostEnroll]); + + const buttonTitleKey = useMemo(() => { + if (isFinished) { + return 'home.ongoing_lessons.view_button'; + } + if (isCourseLesson && !isEnrolled) { + return 'home.open_lessons.start_button'; + } + if (isEnrolled || isCourseLesson) { + return 'home.ongoing_lessons.continue_button'; + } + return 'home.open_lessons.enroll_button'; + }, [isCourseLesson, isEnrolled, isFinished]); + + const buttonClickHandler = useMemo(() => { + if (isFinished) { + return handleContinueLesson; + } + if (isCourseLesson) { + return handleEnrollAndContinueLesson; + } + if (isEnrolled) { + return handleContinueLesson; + } + + return handleEnroll; + }, [ + handleContinueLesson, + handleEnroll, + handleEnrollAndContinueLesson, + isCourseLesson, + isEnrolled, + isFinished, + ]); + + const buttonType = useMemo(() => { + if (isFinished || isEnrolled || isCourseLesson) { + return 'primary'; + } + return 'secondary'; + }, [isCourseLesson, isEnrolled, isFinished]); + + return { + fullName, + firstNameLetter, + buttonType, + buttonTitleKey, + buttonClickHandler, + }; }; diff --git a/front/src/pages/User/CoursePage/CoursePage.jsx b/front/src/pages/User/CoursePage/CoursePage.jsx index f67d43b2..abe7e628 100644 --- a/front/src/pages/User/CoursePage/CoursePage.jsx +++ b/front/src/pages/User/CoursePage/CoursePage.jsx @@ -45,7 +45,7 @@ const CoursePage = () => { useEffect( () => () => { - if (location.state.fromEnroll && history.action === HISTORY_BACK) { + if (location?.state?.fromEnroll && history.action === HISTORY_BACK) { history.replace(USER_HOME); } }, From 7a628fd6cd1514203d8f342278153000cf03778c Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Wed, 20 Oct 2021 14:21:22 +0300 Subject: [PATCH 40/53] fix: force focus to block after 'Esc' when tools menu is open --- .../editorjs/EditorJsContainer/EditorJsContainer.jsx | 5 +++-- .../EditorJsContainer/useToolbox/constants.js | 1 + .../EditorJsContainer/useToolbox/useToolbox.js | 12 +++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx index 6efa4ff4..9c702bc2 100644 --- a/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx +++ b/front/src/utils/editorjs/EditorJsContainer/EditorJsContainer.jsx @@ -12,7 +12,9 @@ import * as S from './EditorJsContainer.styled'; const EditorJsContainer = forwardRef((props, ref) => { const mounted = useRef(); const { t } = useTranslation('editorjs'); - const { prepareToolbox, updateLanguage } = useToolbox(); + const instance = useRef(null); + + const { prepareToolbox, updateLanguage } = useToolbox({ editor: instance }); const { children, language } = props; const holder = useMemo( @@ -24,7 +26,6 @@ const EditorJsContainer = forwardRef((props, ref) => { ); const initialLanguage = useRef(language); - const instance = useRef(null); const handleChange = useCallback( async (api) => { diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js index ce1369fb..064abd84 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/constants.js @@ -8,6 +8,7 @@ export const DEBOUNCE_SCROLL_TOOLBOX_TIME = 50; // ms; export const KEYS = { TAB: 'Tab', ENTER: 'Enter', + ESCAPE: 'Escape', ARROW_UP: 'ArrowUp', ARROW_DOWN: 'ArrowDown', }; diff --git a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js index acdf0b96..77be5443 100644 --- a/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js +++ b/front/src/utils/editorjs/EditorJsContainer/useToolbox/useToolbox.js @@ -6,6 +6,7 @@ import { debounce } from '@sb-ui/utils/utils'; import { DEBOUNCE_SCROLL_TOOLBOX_TIME, + KEYS, TOOLBOX_OPENED, TOOLBOX_UPPER, } from './constants'; @@ -26,7 +27,7 @@ import { } from './toolboxItemsHelpers'; import { destroyObserver, initObserver } from './toolboxObserver'; -export const useToolbox = () => { +export const useToolbox = ({ editor }) => { const { t } = useTranslation('editorjs'); const toolbox = useRef(null); const [isReady, setIsReady] = useState(false); @@ -69,6 +70,12 @@ export const useToolbox = () => { const prepareToolbox = useCallback(() => { toolbox.current = document.querySelector('.ce-toolbox'); const wrapper = toolbox.current; + wrapper.addEventListener('keydown', (e) => { + if (e.code === KEYS.ESCAPE) { + const currentBlockIndex = editor.current.blocks.getCurrentBlockIndex(); + editor.current.caret.setToBlock(currentBlockIndex); + } + }); const basicCheck = wrapper?.querySelector('.toolbox-basic-items'); if (!wrapper || basicCheck) { return; @@ -118,6 +125,9 @@ export const useToolbox = () => { transformDefaultMenuItems(interactiveItems, interactiveMenuItemsWrapper, t); transformDefaultMenuItems(basicItems, basicMenuItemsWrapper, t); setIsReady(true); + // EditorJS instance ref is passing (creating with useRef()) + // No need passing to useCallback dependencies + // eslint-disable-next-line react-hooks/exhaustive-deps }, [t]); useEffect(() => { From 1017a12c34d3882bcf403a6c0951ddcf1dad1aac Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Thu, 21 Oct 2021 14:40:25 +0300 Subject: [PATCH 41/53] feat: make all 'Draft' lessons 'CourseOnly' when Publish Course --- api/src/models/Lesson.js | 20 +++++++++++++++++-- .../controllers/updateStatus.js | 12 +++++++++-- front/src/hooks/useCoursePublish.js | 2 ++ .../pages/Teacher/LessonEdit/LessonEdit.jsx | 15 +++++++------- .../pages/Teacher/LessonEdit/statusHelper.js | 10 ++++++++++ front/src/resources/lang/en/teacher.js | 2 +- front/src/resources/lang/ru/teacher.js | 2 +- 7 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 front/src/pages/Teacher/LessonEdit/statusHelper.js diff --git a/api/src/models/Lesson.js b/api/src/models/Lesson.js index 535b8b6b..b29f0c49 100644 --- a/api/src/models/Lesson.js +++ b/api/src/models/Lesson.js @@ -296,9 +296,12 @@ class Lesson extends BaseModel { return this.query() .select( 'lessons.*', - this.knex().raw(` + this.knex().raw( + ` (select count(*) from results where lesson_id = lessons.id and user_id = ? and action in ('next', 'response')) interactive_passed - `, [userId]), + `, + [userId], + ), this.knex().raw(` (select count(*) from lesson_block_structure join blocks on blocks.block_id = lesson_block_structure.block_id where blocks.type in ('next', 'quiz') and lesson_block_structure.lesson_id = lessons.id) interactive_total @@ -312,6 +315,19 @@ class Lesson extends BaseModel { return this.query().findById(lessonId).patch({ status }).returning('*'); } + static updateLessonsStatus({ lessons, status }) { + return this.query() + .insert( + lessons.map(({ id, name }) => ({ + id, + name, + status, + })), + ) + .onConflict('id') + .merge('status'); + } + static getAllFinishedLessons({ userId, offset: start, diff --git a/api/src/services/courses-management/controllers/updateStatus.js b/api/src/services/courses-management/controllers/updateStatus.js index c9ee91be..3bb3d62d 100644 --- a/api/src/services/courses-management/controllers/updateStatus.js +++ b/api/src/services/courses-management/controllers/updateStatus.js @@ -44,16 +44,24 @@ export const options = { async function handler({ body: { status }, params: { courseId } }) { const { - models: { Course }, + models: { Course, Lesson }, } = this; const course = await Course.getCourseWithLessons({ courseId }); if (status === 'Public') { const isCanPublish = !course.lessons?.some( - (lesson) => lesson.status === 'Draft' || lesson.status === 'Archived', + (lesson) => lesson.status === 'Archived', ); if (isCanPublish) { + const draftLessons = course.lessons.filter( + (lesson) => lesson.status === 'Draft', + ); + await Lesson.updateLessonsStatus({ + lessons: draftLessons, + status: 'CourseOnly', + }); + return Course.updateCourseStatus({ courseId, status }); } throw new BadRequestError(errors.USER_ERR_PUBLISH_RESTRICTED); diff --git a/front/src/hooks/useCoursePublish.js b/front/src/hooks/useCoursePublish.js index 8fb49323..56229c1d 100644 --- a/front/src/hooks/useCoursePublish.js +++ b/front/src/hooks/useCoursePublish.js @@ -9,6 +9,7 @@ import { patchCourseStatus } from '@sb-ui/utils/api/v1/courses-management'; import { TEACHER_COURSE_BASE_KEY, TEACHER_COURSES_BASE_KEY, + TEACHER_LESSONS_BASE_KEY, } from '@sb-ui/utils/queries'; export const useCoursePublish = ({ courseId }) => { @@ -17,6 +18,7 @@ export const useCoursePublish = ({ courseId }) => { const { mutateAsync: updateCourseStatus, isLoading: isUpdateInProgress } = useMutation(patchCourseStatus, { onSuccess: () => { + queryClient.invalidateQueries(TEACHER_LESSONS_BASE_KEY); queryClient.invalidateQueries(TEACHER_COURSES_BASE_KEY); queryClient.invalidateQueries([ TEACHER_COURSE_BASE_KEY, diff --git a/front/src/pages/Teacher/LessonEdit/LessonEdit.jsx b/front/src/pages/Teacher/LessonEdit/LessonEdit.jsx index 55a2f0cc..1644fe07 100644 --- a/front/src/pages/Teacher/LessonEdit/LessonEdit.jsx +++ b/front/src/pages/Teacher/LessonEdit/LessonEdit.jsx @@ -10,6 +10,7 @@ import Header from '@sb-ui/components/molecules/Header'; import KeywordsSelect from '@sb-ui/components/molecules/KeywordsSelect'; import { useLessonStatus } from '@sb-ui/hooks/useLessonStatus'; import { Statuses } from '@sb-ui/pages/Teacher/Home/Dashboard/constants'; +import { convertStatusToTranslation } from '@sb-ui/pages/Teacher/LessonEdit/statusHelper'; import { queryClient } from '@sb-ui/query'; import { createLesson, @@ -256,6 +257,12 @@ const LessonEdit = () => { const [headerHide, setHeaderHide] = useState(false); + const lessonStatusKey = useMemo(() => { + const status = lessonData?.lesson?.status; + const key = status ? convertStatusToTranslation(status) : 'draft'; + return `lesson_dashboard.status.${key}`; + }, [lessonData?.lesson?.status]); + return ( <> @@ -305,13 +312,7 @@ const LessonEdit = () => { /> - - {lessonData?.lesson.status - ? t( - `lesson_dashboard.status.${lessonData?.lesson.status.toLocaleLowerCase()}`, - ) - : t('lesson_dashboard.status.draft')} - + {t(lessonStatusKey)} {isRenderEditor && isEditorDisabled === true && ( diff --git a/front/src/pages/Teacher/LessonEdit/statusHelper.js b/front/src/pages/Teacher/LessonEdit/statusHelper.js new file mode 100644 index 00000000..176a3d38 --- /dev/null +++ b/front/src/pages/Teacher/LessonEdit/statusHelper.js @@ -0,0 +1,10 @@ +import { Statuses } from '@sb-ui/pages/Teacher/Home/Dashboard/constants'; + +export const convertStatusToTranslation = (status) => { + switch (status) { + case Statuses.COURSE_ONLY: + return 'course_only'; + default: + return status.toLowerCase(); + } +}; diff --git a/front/src/resources/lang/en/teacher.js b/front/src/resources/lang/en/teacher.js index 029549ec..61aaf34e 100644 --- a/front/src/resources/lang/en/teacher.js +++ b/front/src/resources/lang/en/teacher.js @@ -128,7 +128,7 @@ export default { publish_modal_fail: { title: 'Can not publish course', content: - 'Can not publish course if at least one of its Lesson have status “Draft“ or “Archived”', + 'Can not publish course if at least one of its Lesson have status “Archived”', ok: 'Ok', }, message: { diff --git a/front/src/resources/lang/ru/teacher.js b/front/src/resources/lang/ru/teacher.js index 978ceba8..28ef3fe1 100644 --- a/front/src/resources/lang/ru/teacher.js +++ b/front/src/resources/lang/ru/teacher.js @@ -128,7 +128,7 @@ export default { publish_modal_fail: { title: 'Невозможно опубликовать курс', content: - 'Невозможно опубликовать курс так как один из уроков имеет статус "Черновик" или "Архивирован"', + 'Невозможно опубликовать курс так как один из уроков имеет статус "Архивирован"', ok: 'Ок', }, message: { From e4c486dae872e75a5d6ecd5c2b52f4b53b8b74c5 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Wed, 20 Oct 2021 12:29:46 +0300 Subject: [PATCH 42/53] fix: make cursor aligned correctly (EditorJs) --- front/src/utils/editorjs/paragraph-plugin/paragraph.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/front/src/utils/editorjs/paragraph-plugin/paragraph.css b/front/src/utils/editorjs/paragraph-plugin/paragraph.css index 81cd819a..ee7bdbd0 100644 --- a/front/src/utils/editorjs/paragraph-plugin/paragraph.css +++ b/front/src/utils/editorjs/paragraph-plugin/paragraph.css @@ -4,6 +4,7 @@ display: flex; flex-direction: column; padding-top: 0; + padding-bottom: 0; } .ce-paragraph::before { @@ -21,6 +22,7 @@ color: #707684; font-weight: normal; opacity: 0; + margin-bottom: 0; } /** Show placeholder at the first paragraph if Editor is empty */ From 6228c9637693539c7a497e8f64b5fef10c97cdc0 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Thu, 21 Oct 2021 10:55:46 +0300 Subject: [PATCH 43/53] fix: remove query pagination for Courses (due to Lessons pagination not working) --- .../User/Home/OpenCourses/OpenCourses.jsx | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/front/src/pages/User/Home/OpenCourses/OpenCourses.jsx b/front/src/pages/User/Home/OpenCourses/OpenCourses.jsx index 7fa238d3..a9c41cdc 100644 --- a/front/src/pages/User/Home/OpenCourses/OpenCourses.jsx +++ b/front/src/pages/User/Home/OpenCourses/OpenCourses.jsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from 'react-query'; -import { useHistory, useLocation } from 'react-router-dom'; import { FilterOutlined } from '@ant-design/icons'; import FilterMobile from '@sb-ui/components/molecules/FilterMobile'; @@ -13,15 +12,11 @@ import emptyImg from '@sb-ui/resources/img/empty.svg'; import { getCourses } from '@sb-ui/utils/api/v1/courses'; import { fetchKeywords } from '@sb-ui/utils/api/v1/keywords'; import { USER_PUBLIC_COURSES_BASE_KEY } from '@sb-ui/utils/queries'; -import { getQueryPage } from '@sb-ui/utils/utils'; import OpenCoursesBlock from '../OpenResourcesBlock'; const OpenCourses = () => { const { t } = useTranslation('user'); - const location = useLocation(); - const queryPage = useMemo(() => location.search, [location]); - const history = useHistory(); const [searchText, setSearchText] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [keywords, setKeywords] = useState([]); @@ -47,34 +42,9 @@ const OpenCourses = () => { const { courses, total } = useMemo(() => responseData || {}, [responseData]); - useEffect(() => { - if (courses?.length === 0 && total !== 0) { - setCurrentPage(1); - history.replace({ - search: ``, - }); - } - }, [courses, history, total]); - - useEffect(() => { - const { incorrect, page } = getQueryPage(queryPage); + const handlePageChange = useCallback((page) => { setCurrentPage(page); - if (incorrect || page === 1) { - history.replace({ - search: ``, - }); - } - }, [history, queryPage]); - - const handlePageChange = useCallback( - (page) => { - setCurrentPage(page); - history.push({ - search: `?page=${page}`, - }); - }, - [history], - ); + }, []); const isEmpty = !isLoading && total === 0 && courses?.length === 0; const isPaginationDisplayed = !isLoading && total > PAGE_SIZE; From 708cd2e1a3e07ce78318adaae85cae5aef519775 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 19 Oct 2021 16:10:31 +0300 Subject: [PATCH 44/53] feat: remove 'Example' placeholder from Fill The Gap (StudentView) --- .../LearnPage/BlockElement/FillTheGap/GapsInput/GapsInput.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/GapsInput/GapsInput.jsx b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/GapsInput/GapsInput.jsx index 41851700..cbb3223d 100644 --- a/front/src/pages/User/LearnPage/BlockElement/FillTheGap/GapsInput/GapsInput.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/FillTheGap/GapsInput/GapsInput.jsx @@ -1,14 +1,11 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { useTranslation } from 'react-i18next'; import { htmlToReact } from '@sb-ui/pages/User/LearnPage/utils'; import * as S from './GapsInput.styled'; const GapsInput = ({ gaps, setGaps, disabled, result }) => { - const { t } = useTranslation('user'); - const handleInputChange = (id, value) => { setGaps((prev) => { const newGaps = [...prev]; @@ -42,7 +39,6 @@ const GapsInput = ({ gaps, setGaps, disabled, result }) => { handleInputChange(id, e.target.value)} disabled={disabled} /> From 519abc692fc81803551e9bc8a4f9bdfe939c5171 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 19 Oct 2021 16:15:50 +0300 Subject: [PATCH 45/53] feat: add spacing under Video block (Student view) --- .../User/LearnPage/BlockElement/Embed/Embed.jsx | 14 +++++++------- .../LearnPage/BlockElement/Embed/Embed.styled.js | 11 +++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.jsx b/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.jsx index 22ab0047..79722511 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.jsx +++ b/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.jsx @@ -1,4 +1,4 @@ -import { Col, Row, Typography } from 'antd'; +import { Typography } from 'antd'; import { htmlToReact } from '@sb-ui/pages/User/LearnPage/utils'; @@ -11,8 +11,8 @@ const { Text } = Typography; const Embed = ({ content }) => { const { caption, embed, height } = content.data; return ( - - + + { src={embed} allowFullScreen /> - + {caption && ( - + {htmlToReact(caption)} - + )} - + ); }; diff --git a/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.styled.js b/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.styled.js index ea9abec9..bacad516 100644 --- a/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.styled.js +++ b/front/src/pages/User/LearnPage/BlockElement/Embed/Embed.styled.js @@ -1,5 +1,16 @@ +import { Col as ColAntd, Row as RowAntd } from 'antd'; import styled from 'styled-components'; export const StyledIframe = styled.iframe` border: none; `; + +export const Row = styled(RowAntd).attrs({ + gutter: [0, 16], +})` + margin-bottom: 1rem; +`; + +export const Col = styled(ColAntd).attrs({ + span: 24, +})``; From 4fc4ae361bb105a0bd1239c16a12715357404e95 Mon Sep 17 00:00:00 2001 From: Vitaliy Prachov Date: Tue, 19 Oct 2021 16:49:16 +0300 Subject: [PATCH 46/53] feat: disable not working button and links (LessonEdit,CourseEdit) --- .../pages/Teacher/CourseEdit/CourseEdit.jsx | 29 ++++++++++--------- .../Teacher/CourseEdit/CourseEdit.styled.js | 5 ++++ .../src/pages/Teacher/CourseEdit/useCourse.js | 4 +-- .../pages/Teacher/LessonEdit/LessonEdit.jsx | 26 ++++++++++++----- .../Teacher/LessonEdit/LessonEdit.styled.js | 10 +++++++ .../LessonEdit/LessonImage/LessonImage.jsx | 4 ++- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/front/src/pages/Teacher/CourseEdit/CourseEdit.jsx b/front/src/pages/Teacher/CourseEdit/CourseEdit.jsx index 3709e4e1..b3b2261f 100644 --- a/front/src/pages/Teacher/CourseEdit/CourseEdit.jsx +++ b/front/src/pages/Teacher/CourseEdit/CourseEdit.jsx @@ -1,4 +1,4 @@ -import { Button, Col, Input, message, Row, Typography } from 'antd'; +import { Button, Col, Input, message, Row } from 'antd'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; @@ -36,7 +36,7 @@ const CourseEdit = () => { handlePublish, handleDraft, isUpdateInProgress, - isSaveButtonDisabled, + isButtonsDisabled, } = useCourse({ isEditCourse, courseId, @@ -283,7 +283,7 @@ const CourseEdit = () => { } > @@ -293,25 +293,23 @@ const CourseEdit = () => { - - {t('lesson_edit.links.invite')} - + {t('lesson_edit.links.invite')} - + {t('lesson_edit.links.students')} - + - + {t('lesson_edit.links.analytics')} - + - + {t('lesson_edit.links.archive')} - + @@ -319,6 +317,7 @@ const CourseEdit = () => {