diff --git a/.eslintrc.js b/.eslintrc.js index 43951dc24cf..19e944b92c3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -234,6 +234,8 @@ module.exports = { "JSON.parse", "addFilter", "removeFilter", + "setError", + "clearErrors", ], }, "object-properties": { diff --git a/services/main-frontend/src/components/forms/NewExamForm.tsx b/services/main-frontend/src/components/forms/NewExamForm.tsx index f7bd52d270c..bf4ca08bce8 100644 --- a/services/main-frontend/src/components/forms/NewExamForm.tsx +++ b/services/main-frontend/src/components/forms/NewExamForm.tsx @@ -1,5 +1,6 @@ import { css } from "@emotion/css" -import React, { useState } from "react" +import { parseISO } from "date-fns" +import React, { useEffect, useState } from "react" import { useForm } from "react-hook-form" import { useTranslation } from "react-i18next" @@ -22,12 +23,12 @@ interface NewExamFormProps { interface NewExamFields { name: string - startsAt: Date - endsAt: Date + startsAt: string + endsAt: string timeMinutes: number parentId: string | null automaticCompletionEnabled: boolean - minimumPointsTreshold: number + minimumPointsThreshold: number manualGradingEnabled: boolean } @@ -37,68 +38,181 @@ const NewExamForm: React.FC> = ({ exams, onCreateNewExam, onDuplicateExam, - onCancel, }) => { const { t } = useTranslation() + const [startTimeWarning, setStartTimeWarning] = useState(null) const { register, handleSubmit, formState: { errors }, clearErrors, - setValue, - getValues, watch, + setError, } = useForm() const [exam, setExam] = useState(initialData) const [parentId, setParentId] = useState(null) const [duplicateExam, setDuplicateExam] = useState(false) + const startsAt = watch("startsAt") + + useEffect(() => { + try { + if (startsAt) { + const start = parseISO(startsAt) + // Check if it's a valid date + if (!isNaN(start.getTime())) { + const now = new Date() + if (start < now) { + setStartTimeWarning(t("start-time-in-past-warning")) + } else { + setStartTimeWarning(null) + } + } + } else { + setStartTimeWarning(null) + } + } catch (e) { + // Invalid date format, clear warning + setStartTimeWarning(null) + } + }, [startsAt, t]) + + const validateDates = (data: NewExamFields): boolean => { + const start = parseISO(data.startsAt) + const end = parseISO(data.endsAt) + + if (end <= start) { + setError("endsAt", { message: t("end-date-must-be-after-start") }) + return false + } + + return true + } + + const validateForm = (data: NewExamFields): boolean => { + let isValid = true + clearErrors(["startsAt", "endsAt", "timeMinutes", "minimumPointsThreshold"]) + + // Validate numbers + if (isNaN(Number(data.timeMinutes))) { + setError("timeMinutes", { message: t("invalid-number-format") }) + isValid = false + } else if (!Number.isInteger(Number(data.timeMinutes)) || Number(data.timeMinutes) <= 0) { + setError("timeMinutes", { message: t("time-must-be-a-positive-integer") }) + isValid = false + } + + if (data.automaticCompletionEnabled) { + if (isNaN(Number(data.minimumPointsThreshold))) { + setError("minimumPointsThreshold", { message: t("invalid-number-format") }) + isValid = false + } else if ( + !Number.isInteger(Number(data.minimumPointsThreshold)) || + Number(data.minimumPointsThreshold) < 0 + ) { + setError("minimumPointsThreshold", { message: t("points-must-be-a-non-negative-integer") }) + isValid = false + } + } + + // Validate dates are parseable + try { + parseISO(data.startsAt).toISOString() + parseISO(data.endsAt).toISOString() + } catch (e) { + setError("startsAt", { message: t("invalid-date-format") }) + setError("endsAt", { message: t("invalid-date-format") }) + isValid = false + } + + // Validate date logic + if (isValid && !validateDates(data)) { + isValid = false + } + + return isValid + } + const onCreateNewExamWrapper = handleSubmit((data) => { + if (!validateForm(data)) { + return + } + onCreateNewExam({ organization_id: organizationId, name: data.name, - starts_at: new Date(data.startsAt).toISOString(), - ends_at: new Date(data.endsAt).toISOString(), + starts_at: parseISO(data.startsAt).toISOString(), + ends_at: parseISO(data.endsAt).toISOString(), time_minutes: Number(data.timeMinutes), minimum_points_treshold: data.automaticCompletionEnabled - ? Number(data.minimumPointsTreshold) + ? Number(data.minimumPointsThreshold) : 0, grade_manually: data.manualGradingEnabled, }) }) const onDuplicateExamWrapper = handleSubmit((data) => { - if (exam) { - const newExam: NewExam = { - organization_id: organizationId, - name: data.name, - starts_at: new Date(data.startsAt).toISOString(), - ends_at: new Date(data.endsAt).toISOString(), - time_minutes: Number(data.timeMinutes), - minimum_points_treshold: data.automaticCompletionEnabled - ? Number(data.minimumPointsTreshold) - : 0, - grade_manually: data.manualGradingEnabled, - } - const examId = String(parentId) - onDuplicateExam(examId, newExam) + if (!validateForm(data)) { + return + } + + if (duplicateExam && !parentId) { + setError("parentId", { type: "manual", message: t("required-field") }) + return + } + + if (!exam) { + setError("parentId", { message: t("exam-not-found") }) + return + } + + const newExam: NewExam = { + organization_id: organizationId, + name: data.name, + starts_at: parseISO(data.startsAt).toISOString(), + ends_at: parseISO(data.endsAt).toISOString(), + time_minutes: Number(data.timeMinutes), + minimum_points_treshold: data.automaticCompletionEnabled + ? Number(data.minimumPointsThreshold) + : 0, + grade_manually: data.manualGradingEnabled, } + const examId = String(parentId) + onDuplicateExam(examId, newExam) }) const handleSetExamToDuplicate = (examId: string) => { clearErrors() - setParentId(examId) - const exam = exams.filter((e) => e.id === examId)[0] - setExam(exam) - if (getValues("timeMinutes").toString() === "") { - setValue("timeMinutes", exam.time_minutes) + const selectedExam = exams.find((e) => e.id === examId) + if (!selectedExam) { + setError("parentId", { message: t("exam-not-found") }) + return } + + setParentId(examId) + setExam(selectedExam) } const automaticEnabled = watch("automaticCompletionEnabled") + const handleDuplicateToggle = () => { + if (!duplicateExam) { + // Switching to duplicate mode + setDuplicateExam(true) + if (exams.length > 0) { + handleSetExamToDuplicate(exams[0].id) + } + } else { + // Switching back to create mode + setDuplicateExam(false) + setParentId(null) + setExam(null) + clearErrors() + } + } + return (
@@ -115,6 +229,7 @@ const NewExamForm: React.FC> = ({ /> > = ({ id={"timeMinutes"} error={errors.timeMinutes?.message} label={t("label-time-minutes")} - {...register("timeMinutes", { required: t("required-field") })} + type="number" + {...register("timeMinutes", { + required: t("required-field"), + min: { value: 1, message: t("time-must-be-positive") }, + valueAsNumber: true, + })} /> > = ({ {automaticEnabled && ( )} setDuplicateExam(!duplicateExam)} + onChange={handleDuplicateToggle} /> - {duplicateExam && ( + {duplicateExam && exams.length > 0 && ( handleSetExamToDuplicate(value)} - options={exams.map((e) => { - return { label: e.name, value: e.id } - })} + options={exams.map((e) => ({ + label: e.name, + value: e.id, + }))} defaultValue={exams[0].id} /> )} diff --git a/shared-module/packages/common/src/components/InputFields/DateTimeLocal.tsx b/shared-module/packages/common/src/components/InputFields/DateTimeLocal.tsx index 6c12e717407..3695320863a 100644 --- a/shared-module/packages/common/src/components/InputFields/DateTimeLocal.tsx +++ b/shared-module/packages/common/src/components/InputFields/DateTimeLocal.tsx @@ -4,23 +4,32 @@ import React, { forwardRef, InputHTMLAttributes } from "react" import { baseTheme } from "../../styles" import { dateToString } from "../../utils/time" -const error = css` +const errorStyle = css` color: #f76d82; font-size: 14px; display: inline-block; margin-top: -15px; ` +const warningStyle = css` + color: #b3440d; + font-size: 14px; + display: inline-block; + margin-top: -15px; +` + export interface TimePickerProps extends InputHTMLAttributes { label: string onChangeByValue?: (value: string, name?: string) => void error?: string + warning?: string className?: string } const DateTimeLocal = forwardRef( (props: TimePickerProps, ref) => { - const { onChangeByValue, onChange, className, defaultValue, value, ...rest } = props + const { onChangeByValue, onChange, className, defaultValue, value, warning, error, ...rest } = + props const handleOnChange = (event: React.ChangeEvent) => { if (onChangeByValue) { @@ -83,7 +92,17 @@ const DateTimeLocal = forwardRef( > {effectiveValue && typeof effectiveValue === "string" && ( @@ -97,9 +116,15 @@ const DateTimeLocal = forwardRef( )} - {rest.error && ( - - {rest.error} + {error && ( + + {error} + + )} + + {warning && !error && ( + + {warning} )}
diff --git a/shared-module/packages/common/src/locales/ar/main-frontend.json b/shared-module/packages/common/src/locales/ar/main-frontend.json index f20e1c3274f..1be1204fb04 100644 --- a/shared-module/packages/common/src/locales/ar/main-frontend.json +++ b/shared-module/packages/common/src/locales/ar/main-frontend.json @@ -178,6 +178,7 @@ "enable-generating-new-certificates": "تمكين توليد الشهادات الجديدة", "enable-module-completion-certificates": "السماح للطلاب بتوليد شهادة لإكمال الوحدة", "end-date": "تاريخ الانتهاء", + "end-date-must-be-after-start": "يجب أن يكون تاريخ الانتهاء بعد تاريخ البدء", "ends": "ينتهي", "english": "الإنجليزية", "enter-a-valid-email": "أدخل بريدًا إلكترونيًا صالحًا!", @@ -212,6 +213,7 @@ "exam-duplicated-succesfully": "تم تكرار الامتحان بنجاح", "exam-edited-successfully": "تم تعديل الامتحان بنجاح", "exam-list": "الامتحانات", + "exam-not-found": "الامتحان غير موجود", "exercise": "تمرين", "exercise-repositories-add": "إضافة مستودع التمارين", "exercise-repositories-added": "تمت إضافة مستودع التمارين", @@ -300,6 +302,8 @@ "instance-is-currently-open-and-has-no-set-ending-time": "الدورة مفتوحة حاليًا ولا تحتوي على وقت انتهاء محدد", "instance-is-open-and-ends-at-time": "الدورة مفتوحة وتنتهي في {{time}}", "instance-opens-at-time": "تفتح الدورة في {{time}}", + "invalid-date-format": "تنسيق التاريخ غير صالح", + "invalid-number-format": "تنسيق الرقم غير صالح", "invalid-service-info": "معلومات الخدمة غير صالحة", "invalid-url": "عنوان URL غير صالح", "joinable-by-code-only": "يمكن الانضمام فقط بواسطة الكود", @@ -543,6 +547,8 @@ "please-check-the-following-preview-results-before-submitting": "يرجى التحقق من نتائج المعاينة التالية قبل الإرسال.", "point-summary": "ملخص النقاط", "points": "النقاط", + "points-must-be-a-non-negative-integer": "يجب أن تكون النقاط عدداً صحيحاً غير سالب", + "points-must-be-non-negative": "يجب أن تكون النقاط غير سالبة", "position-x": "الموقع (X)", "position-y": "الموقع (Y)", "previous-title-current-title": "السابق: {{current-title}} | الحالي: {{selected-title}}", @@ -617,6 +623,7 @@ "sort-by-role": "ترتيب حسب الدور", "start-date": "تاريخ البدء", "start-date-must-be-before-end-date": "يجب أن يكون تاريخ البدء قبل تاريخ الانتهاء", + "start-time-in-past-warning": "تحذير: وقت بدء الامتحان في الماضي. سيتمكن الطلاب من بدء الامتحان فوراً.", "starts": "يبدأ", "stats": "الإحصاءات", "status": "الحالة", @@ -653,6 +660,8 @@ "threshold-added-successfully": "تمت إضافة العتبة بنجاح", "threshold-created-successfully": "تم إنشاء العتبة بنجاح", "tick-the-box-if-you-want-email-after-credits-have-been-registered": "إذا كنت ترغب في إشعار بالبريد الإلكتروني عند تسجيل الاعتمادات، ضع علامة في المربع 'سيتم إرسال إشعار بالدراسات المكتملة إلى بريدي الإلكتروني (بما في ذلك الدرجة)'", + "time-must-be-a-positive-integer": "يجب أن يكون الوقت عدداً صحيحاً موجباً", + "time-must-be-positive": "يجب أن يكون الوقت موجباً", "title-all-course-instances": "جميع حالات الدورة", "title-all-course-language-versions": "جميع إصدارات لغة الدورة", "title-all-exercises": "تمارين في هذه الدورة", diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 52b814cb612..aa0ca667b39 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -178,6 +178,7 @@ "enable-generating-new-certificates": "Enable generating new certificates", "enable-module-completion-certificates": "Allow students to generate a certificate for completing the module", "end-date": "End date", + "end-date-must-be-after-start": "End date must be after start date", "ends": "Ends", "english": "English", "enter-a-valid-email": "Enter a valid email!", @@ -212,6 +213,7 @@ "exam-duplicated-succesfully": "Exam duplicated succesfully", "exam-edited-successfully": "Exam edited successfully", "exam-list": "Exams", + "exam-not-found": "Exam not found", "exercise": "Exercise", "exercise-repositories-add": "Add exercise repository", "exercise-repositories-added": "Added exercise repository", @@ -300,6 +302,8 @@ "instance-is-currently-open-and-has-no-set-ending-time": "Instance is currently open and has no set ending time", "instance-is-open-and-ends-at-time": "Instance is open and ends at {{time}}", "instance-opens-at-time": "Instance opens at {{time}}", + "invalid-date-format": "Invalid date format", + "invalid-number-format": "Invalid number format", "invalid-service-info": "Invalid service info", "invalid-url": "Invalid URL", "joinable-by-code-only": "Joinable by code only", @@ -543,13 +547,15 @@ "please-check-the-following-preview-results-before-submitting": "Please check the following preview results before submitting.", "point-summary": "Point summary", "points": "Points", + "points-must-be-a-non-negative-integer": "Points must be a non-negative integer", + "points-must-be-non-negative": "Points must be non-negative", "position-x": "Position (X)", "position-y": "Position (Y)", "previous-title-current-title": "Previous: {{current-title}} | Current: {{selected-title}}", "private-spec": "Private spec", "public-spec-explanation": "Public spec is used for rendering the user interface when the user is starting to answer an exercise.", "publish-grading-results": "Publish grading results", - "publish-grading-results-info": "The students won’t automatically see the grading results. After publishing the grading results, the students can see the points and the feedback you have given to them. Also, the students who pass the course can register their completion to the study register.", + "publish-grading-results-info": "The students won't automatically see the grading results. After publishing the grading results, the students can see the points and the feedback you have given to them. Also, the students who pass the course can register their completion to the study register.", "published": "Published", "question": "Question", "question-n": "Question {{n}}", @@ -617,6 +623,7 @@ "sort-by-role": "Sort by role", "start-date": "Start date", "start-date-must-be-before-end-date": "Start date must be before end date", + "start-time-in-past-warning": "Warning: The exam start time is in the past. Students will be able to start the exam immediately.", "starts": "Starts", "stats": "Stats", "status": "Status", @@ -653,6 +660,8 @@ "threshold-added-successfully": "Threshold added successfully", "threshold-created-successfully": "Threshold successfully created", "tick-the-box-if-you-want-email-after-credits-have-been-registered": "If you want an email notification when the credits have been registered, tick the box 'A notification of completed studies will be sent to my email (including the grade)'", + "time-must-be-a-positive-integer": "Time must be a positive integer", + "time-must-be-positive": "Time must be positive", "title-all-course-instances": "All course instances", "title-all-course-language-versions": "All course language versions", "title-all-exercises": "Exercises in this course", diff --git a/shared-module/packages/common/src/locales/fi/main-frontend.json b/shared-module/packages/common/src/locales/fi/main-frontend.json index 7af11178a64..e488ec519fe 100644 --- a/shared-module/packages/common/src/locales/fi/main-frontend.json +++ b/shared-module/packages/common/src/locales/fi/main-frontend.json @@ -180,6 +180,7 @@ "enable-generating-new-certificates": "Salli uusien sertifikaattien luonti", "enable-module-completion-certificates": "Anna oppilaan generoida sertifikaatti moduulin suorituksesta", "end-date": "Päättymispäivä", + "end-date-must-be-after-start": "Päättymispäivän on oltava aloituspäivän jälkeen", "ends": "Loppu", "english": "Englanti", "enter-a-valid-email": "Syötä hyväksyttävä sähköpostiosoite!", @@ -214,6 +215,7 @@ "exam-duplicated-succesfully": "Koe monistettu", "exam-edited-successfully": "Koe muokattu", "exam-list": "Kokeet", + "exam-not-found": "Koetta ei löydy", "exercise": "Tehtävä", "exercise-repositories-add": "Add exercise repository", "exercise-repositories-added": "Added exercise repository", @@ -304,6 +306,8 @@ "instance-is-currently-open-and-has-no-set-ending-time": "Kurssiversio on auki eikä sille ole asetettu päättymispäivää", "instance-is-open-and-ends-at-time": "Kurssiversio on auki ja sulkeutuu {{time}}", "instance-opens-at-time": "Kurssiversio aukeaa {{time}}", + "invalid-date-format": "Virheellinen päivämäärämuoto", + "invalid-number-format": "Virheellinen numeromuoto", "invalid-service-info": "Virheellinen palvelun tieto", "invalid-url": "Epäkelpo osoite", "joinable-by-code-only": "Liittyminen vain koodilla", @@ -548,6 +552,8 @@ "please-check-the-following-preview-results-before-submitting": "Tarkista oheiset tiedot ennen suoritusten lähettämistä.", "point-summary": "Pisteet", "points": "Pisteitä", + "points-must-be-a-non-negative-integer": "Pisteiden on oltava nollasta ylöspäin oleva kokonaisluku", + "points-must-be-non-negative": "Pisteiden on oltava nollasta ylöspäin", "position-x": "Sijainti (X)", "position-y": "Sijainti (Y)", "previous-title-current-title": "Edellinen: {{current-title}} | Nykyinen: {{selected-title}}", @@ -622,6 +628,7 @@ "sort-by-role": "Järjestä roolin mukaan", "start-date": "Aloituspäivä", "start-date-must-be-before-end-date": "Aloituspäivän on oltava ennen päättymispäivää", + "start-time-in-past-warning": "Varoitus: Kokeen aloitusaika on menneisyydessä. Opiskelijat voivat aloittaa kokeen välittömästi.", "starts": "Alku", "stats": "Tilastot", "status": "Tila", @@ -659,6 +666,8 @@ "threshold-added-successfully": "Kynnysarvo lisätty onnistuneesti", "threshold-created-successfully": "Kynnysarvo luotu onnistuneesti", "tick-the-box-if-you-want-email-after-credits-have-been-registered": "Jos haluat sähköpostiviestin kun suoritus on kirjattu, täytä ruutu 'Opintosuorituksista lähetetään ilmoitus sähköpostiini (sisältää arvosanan)'.", + "time-must-be-a-positive-integer": "Ajan on oltava positiivinen kokonaisluku", + "time-must-be-positive": "Ajan on oltava positiivinen", "title-all-course-instances": "Kaikki kurssin versiot", "title-all-course-language-versions": "Kaikki kurssin kieliversiot", "title-all-exercises": "Kurssin tehtävät", diff --git a/shared-module/packages/common/src/locales/uk/main-frontend.json b/shared-module/packages/common/src/locales/uk/main-frontend.json index f82ad930273..e9fb81e9b02 100644 --- a/shared-module/packages/common/src/locales/uk/main-frontend.json +++ b/shared-module/packages/common/src/locales/uk/main-frontend.json @@ -179,6 +179,7 @@ "enable-generating-new-certificates": "Увімкнути створення нових сертифікатів", "enable-module-completion-certificates": "Дозволити студентам створити сертифікат про проходження модуля", "end-date": "Дата завершення", + "end-date-must-be-after-start": "Кінцева дата повинна бути після початкової дати", "ends": "Кінці", "english": "Англійська", "enter-a-valid-email": "Введіть дійсний email!", @@ -213,6 +214,7 @@ "exam-duplicated-succesfully": "Іспит успішно продубльовано", "exam-edited-successfully": "Іспит успішно відредаговано", "exam-list": "Іспити", + "exam-not-found": "Екзамен не знайдено", "exercise": "Вправи", "exercise-repositories-add": "Додайте репозитарій вправ", "exercise-repositories-added": "Додано репозитарій вправ", @@ -301,6 +303,8 @@ "instance-is-currently-open-and-has-no-set-ending-time": "Примірник наразі відкритий і не має встановленого часу завершення", "instance-is-open-and-ends-at-time": "Примірник відкритий і закінчується о {{time}}", "instance-opens-at-time": "Примірник відкривається о {{time}}", + "invalid-date-format": "Неправильний формат дати", + "invalid-number-format": "Неправильний формат числа", "invalid-service-info": "Недійсна сервісна інформація", "invalid-url": "Недійсний URL", "joinable-by-code-only": "Можна приєднатися тільки за кодом", @@ -544,6 +548,8 @@ "please-check-the-following-preview-results-before-submitting": "Перед надсиланням перевірте наведені нижче результати попереднього перегляду.", "point-summary": "Підсумок балів", "points": "Бали", + "points-must-be-a-non-negative-integer": "Бали повинні бути не від'ємним цілим числом", + "points-must-be-non-negative": "Бали повинні бути не від'ємними", "position-x": "Позиція (X)", "position-y": "Позиція (Y)", "previous-title-current-title": "Попередній: {{current-title}} | Поточний: {{selected-title}}", @@ -619,6 +625,7 @@ "sort-by-role": "Сортувати за ролями", "start-date": "Дата початку", "start-date-must-be-before-end-date": "Дата початку повинна бути перед датою завершення", + "start-time-in-past-warning": "Попередження: Час початку екзамену в минулому. Студенти зможуть почати екзамен негайно.", "starts": "Починається", "stats": "Статистика", "status": "Статус", @@ -655,6 +662,8 @@ "threshold-added-successfully": "Поріг успішно додано", "threshold-created-successfully": "Поріг успішно створено", "tick-the-box-if-you-want-email-after-credits-have-been-registered": "Якщо ви хочете отримувати сповіщення електронною поштою, коли кредити будуть зареєстровані, поставте прапорець «Повідомлення про завершені навчання буде надіслано на мою електронну адресу (включно з оцінкою)»", + "time-must-be-a-positive-integer": "Час повинен бути позитивним цілим числом", + "time-must-be-positive": "Час повинен бути позитивним", "title-all-course-instances": "Усі випадки курсу", "title-all-course-language-versions": "Усі мовні версії курсу", "title-all-exercises": "Вправи в цьому курсі",