diff --git a/frontend/packages/vkt/public/i18n/fi-FI/examiner.json b/frontend/packages/vkt/public/i18n/fi-FI/examiner.json new file mode 100644 index 000000000..291fa6f8b --- /dev/null +++ b/frontend/packages/vkt/public/i18n/fi-FI/examiner.json @@ -0,0 +1,33 @@ +{ + "vkt": { + "component": { + "examinerDetails": { + "buttons": { + "saveAndClose": "Tallenna ja sulje" + }, + "examinationDetails": { + "heading": "Tutkinnon perustiedot", + "information": "Nämä tiedot näkyvät julkisessa listauksessa." + }, + "incorrectDetailsDialog": { + "description": "Korjaa puuttuvat tai virheelliset tiedot:", + "title": "Tiedoissa on korjattavaa!" + }, + "heading": "Omat tiedot", + "labels": { + "email": "Sähköpostiosoite", + "examLanguages": "Tutkinnon kieli", + "firstName": "Etunimi:", + "lastName": "Sukunimi:", + "isPublic": "Julkaise", + "municipalities": "Tutkintopaikka/Tutkintopaikat", + "phoneNumber": "Puhelinnumero" + }, + "personalDetails": { + "heading": "Henkilötiedot", + "information": "Tiedoista vain nimet näkyvät julkisessa listauksessa." + } + } + } + } +} diff --git a/frontend/packages/vkt/public/i18n/sv-SE/examiner.json b/frontend/packages/vkt/public/i18n/sv-SE/examiner.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/frontend/packages/vkt/public/i18n/sv-SE/examiner.json @@ -0,0 +1 @@ +{} diff --git a/frontend/packages/vkt/src/configs/i18n.ts b/frontend/packages/vkt/src/configs/i18n.ts index 37d35ea0a..78635d4f3 100644 --- a/frontend/packages/vkt/src/configs/i18n.ts +++ b/frontend/packages/vkt/src/configs/i18n.ts @@ -11,11 +11,13 @@ import { DateUtils } from 'shared/utils'; import accessibilityFI from 'public/i18n/fi-FI/accessibility.json'; import clerkFI from 'public/i18n/fi-FI/clerk.json'; import commonFI from 'public/i18n/fi-FI/common.json'; +import examinerFI from 'public/i18n/fi-FI/examiner.json'; import koodistoMunicipalitiesFI from 'public/i18n/fi-FI/koodisto_municipalities.json'; import publicFI from 'public/i18n/fi-FI/public.json'; import accessibilitySV from 'public/i18n/sv-SE/accessibility.json'; import clerkSV from 'public/i18n/sv-SE/clerk.json'; import commonSV from 'public/i18n/sv-SE/common.json'; +import examinerSV from 'public/i18n/sv-SE/examiner.json'; import koodistoMunicipalitiesSV from 'public/i18n/sv-SE/koodisto_municipalities.json'; import publicSV from 'public/i18n/sv-SE/public.json'; @@ -25,6 +27,10 @@ const langSV = AppLanguage.Swedish; const supportedLangs = [langFI, langSV]; +enum VktI18nNamespace { + Examiner = 'examiner', +} + const resources = { [langFI]: { [I18nNamespace.Accessibility]: accessibilityFI, @@ -32,6 +38,7 @@ const resources = { [I18nNamespace.Common]: commonFI, [I18nNamespace.Public]: publicFI, [I18nNamespace.KoodistoMunicipalities]: koodistoMunicipalitiesFI, + [VktI18nNamespace.Examiner]: examinerFI, }, [langSV]: { [I18nNamespace.Accessibility]: accessibilitySV, @@ -39,6 +46,7 @@ const resources = { [I18nNamespace.Common]: commonSV, [I18nNamespace.Public]: publicSV, [I18nNamespace.KoodistoMunicipalities]: koodistoMunicipalitiesSV, + [VktI18nNamespace.Examiner]: examinerSV, }, }; @@ -76,7 +84,7 @@ export const initI18n = () => { const useAppTranslation = ( options: UseTranslationOptions, - ns: I18nNamespace, + ns: I18nNamespace | VktI18nNamespace, ) => { return useTranslation(ns, options); }; @@ -122,6 +130,12 @@ export const useKoodistoMunicipalitiesTranslation = () => { return t; }; +export const useExaminerTranslation = ( + options: UseTranslationOptions, +) => { + return useAppTranslation(options, VktI18nNamespace.Examiner); +}; + export const translateOutsideComponent = () => { return t; }; diff --git a/frontend/packages/vkt/src/interfaces/clerkUser.ts b/frontend/packages/vkt/src/interfaces/clerkUser.ts index eb744e008..b9604a175 100644 --- a/frontend/packages/vkt/src/interfaces/clerkUser.ts +++ b/frontend/packages/vkt/src/interfaces/clerkUser.ts @@ -1,4 +1,4 @@ -import { APIResponseStatus } from "shared/enums"; +import { APIResponseStatus } from 'shared/enums'; export interface ClerkUser { oid: string; diff --git a/frontend/packages/vkt/src/interfaces/examinerDetails.ts b/frontend/packages/vkt/src/interfaces/examinerDetails.ts index b5148e74e..403bece03 100644 --- a/frontend/packages/vkt/src/interfaces/examinerDetails.ts +++ b/frontend/packages/vkt/src/interfaces/examinerDetails.ts @@ -1,6 +1,7 @@ import { APIResponseStatus } from 'shared/enums'; import { WithId } from 'shared/interfaces'; -import { Municipality } from 'interfaces/municipality'; + +import { MunicipalityCode } from 'interfaces/municipality'; export interface ExaminerDetailsState { status: APIResponseStatus; @@ -17,7 +18,7 @@ export interface ExaminerDetails extends WithId { phoneNumber: string; examLanguageFinnish: boolean; examLanguageSwedish: boolean; - municipalities: Array; + municipalities: Array; isPublic: boolean; } @@ -30,3 +31,9 @@ export interface ExaminerDetailsInitState { status: APIResponseStatus; initData?: ExaminerDetailsInit; } + +export function isExaminerDetails( + details: ExaminerDetailsInit, +): details is ExaminerDetails { + return details.hasOwnProperty('id'); +} diff --git a/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts new file mode 100644 index 000000000..11a7e7de4 --- /dev/null +++ b/frontend/packages/vkt/src/interfaces/examinerDetailsUpsert.ts @@ -0,0 +1,19 @@ +import { APIResponseStatus } from 'shared/enums'; + +import { MunicipalityCode } from 'interfaces/municipality'; + +export interface ExaminerDetailsUpsert { + id?: number; + oid: string; + email: string; + phoneNumber: string; + examLanguageFinnish: boolean; + examLanguageSwedish: boolean; + isPublic: boolean; + municipalities: Array; +} + +export interface ExaminerDetailsUpsertState { + status: APIResponseStatus; + examinerDetails: Partial; +} diff --git a/frontend/packages/vkt/src/interfaces/municipality.ts b/frontend/packages/vkt/src/interfaces/municipality.ts index 2314fc45d..253fb04e7 100644 --- a/frontend/packages/vkt/src/interfaces/municipality.ts +++ b/frontend/packages/vkt/src/interfaces/municipality.ts @@ -1,4 +1,10 @@ +// TODO Consider removing Municipality interface +// At the moment, the localised name information per code is found in localisation files. export interface Municipality { - fi: string; - sv: string; + fi: string; + sv: string; +} + +export interface MunicipalityCode { + code: string; } diff --git a/frontend/packages/vkt/src/interfaces/publicExaminer.ts b/frontend/packages/vkt/src/interfaces/publicExaminer.ts index 9a3efc3ce..9cd7dedc2 100644 --- a/frontend/packages/vkt/src/interfaces/publicExaminer.ts +++ b/frontend/packages/vkt/src/interfaces/publicExaminer.ts @@ -5,13 +5,13 @@ import { WithId } from 'shared/interfaces'; import { ExamLanguage } from 'enums/app'; import { Municipality } from 'interfaces/municipality'; - interface PublicExaminerExamDate { examDate: Dayjs; isFull: boolean; } -interface PublicExaminerExamDateResponse extends Omit { +interface PublicExaminerExamDateResponse + extends Omit { examDate: string; } diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx new file mode 100644 index 000000000..df514cc3c --- /dev/null +++ b/frontend/packages/vkt/src/pages/examiner/ExaminerDetailsPage.tsx @@ -0,0 +1,528 @@ +import { + Checkbox, + Divider, + FormControl, + FormControlLabel, + FormGroup, + Grid, + Paper, +} from '@mui/material'; +import { Box } from '@mui/system'; +import { + ChangeEvent, + SyntheticEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import { + CustomButton, + CustomSwitch, + H1, + H2, + LabeledMultipleCheckboxDropdown, + LabeledTextField, + LoadingProgressIndicator, + Text, +} from 'shared/components'; +import { + APIResponseStatus, + Color, + CustomTextFieldErrors, + InputAutoComplete, + Severity, + TextFieldTypes, + TextFieldVariant, + Variant, +} from 'shared/enums'; +import { useDialog } from 'shared/hooks'; +import { ComboBoxOption } from 'shared/interfaces'; +import { InputFieldUtils, StringUtils } from 'shared/utils'; + +import { + useCommonTranslation, + useExaminerTranslation, + useKoodistoMunicipalitiesTranslation, +} from 'configs/i18n'; +import { useAppDispatch, useAppSelector } from 'configs/redux'; +import { ExamLanguage } from 'enums/app'; +import { useMunicipalityOptions } from 'hooks/useKoodistoMunicipalities'; +import { + ExaminerDetails, + ExaminerDetailsInit, + isExaminerDetails, +} from 'interfaces/examinerDetails'; +import { ExaminerDetailsUpsert } from 'interfaces/examinerDetailsUpsert'; +import { loadExaminerDetails } from 'redux/reducers/examinerDetails'; +import { loadExaminerDetailsInit } from 'redux/reducers/examinerDetailsInit'; +import { + resetExaminerDetailsUpsert, + startExaminerDetailsUpsert, + updateExaminerDetailsUpsert, +} from 'redux/reducers/examinerDetailsUpsert'; +import { examinerDetailsSelector } from 'redux/selectors/examinerDetails'; +import { examinerDetailsInitSelector } from 'redux/selectors/examinerDetailsInit'; +import { examinerDetailsUpsertSelector } from 'redux/selectors/examinerDetailsUpsert'; + +interface InputFieldValidation { + email: string; + phoneNumber: string; + examLanguages: string; + municipalities: string; +} + +interface LabeledFieldProps { + id: string; + label: string; + helperText: string; + error: boolean; +} + +const useExaminerDetailsUpsertErrors = (showErrors: boolean) => { + const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector); + if (showErrors) { + return { + email: InputFieldUtils.validateCustomTextFieldErrors({ + type: TextFieldTypes.Email, + value: examinerDetails.email, + required: true, + }), + phoneNumber: InputFieldUtils.validateCustomTextFieldErrors({ + type: TextFieldTypes.PhoneNumber, + value: examinerDetails.phoneNumber, + required: true, + }), + municipalities: + examinerDetails.municipalities && + examinerDetails.municipalities.length > 0 + ? '' + : CustomTextFieldErrors.Required, + examLanguages: + examinerDetails.examLanguageFinnish || + examinerDetails.examLanguageSwedish + ? '' + : CustomTextFieldErrors.Required, + }; + } else { + return { + email: '', + phoneNumber: '', + municipalities: '', + examLanguages: '', + }; + } +}; + +const useExaminerDetails = (): + | ExaminerDetails + | ExaminerDetailsInit + | undefined => { + const { examiner } = useAppSelector(examinerDetailsSelector); + const { initData } = useAppSelector(examinerDetailsInitSelector); + if (examiner) { + return examiner; + } else { + return initData; + } +}; + +const ExamLanguagesSelection = ({ label, error }: LabeledFieldProps) => { + const translateCommon = useCommonTranslation(); + + const legendErrorStyle = error ? { color: 'error.main' } : {}; + const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector); + const dispatch = useAppDispatch(); + const toggleCheckbox = + (fieldName: 'examLanguageFinnish' | 'examLanguageSwedish') => + (_event: ChangeEvent) => { + dispatch( + updateExaminerDetailsUpsert({ + [fieldName]: !examinerDetails[fieldName], + }), + ); + }; + + return ( +
+ +
+ + + {label} + + + + + } + label={translateCommon(`examLanguage.${ExamLanguage.FI}`)} + /> + + } + label={translateCommon(`examLanguage.${ExamLanguage.SV}`)} + /> + +
+
+
+ ); +}; + +const MunicipalitiesSelection = ({ + id, + label, + error, + helperText, +}: LabeledFieldProps) => { + const translateMunicipality = useKoodistoMunicipalitiesTranslation(); + const municipalityOptions = useMunicipalityOptions(); + const municipalityToOption = (municipality: string) => ({ + value: municipality, + label: translateMunicipality(municipality), + }); + const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector); + const dispatch = useAppDispatch(); + const updateMunicipalities = useCallback( + (_: SyntheticEvent, options: Array) => { + dispatch( + updateExaminerDetailsUpsert({ + municipalities: options.map((v) => ({ + code: v.value, + })), + }), + ); + }, + [dispatch], + ); + + return ( +
+ + municipalityToOption(code), + ) + : [] + } + onChange={updateMunicipalities} + /> +
+ ); +}; + +const IsPublicSelection = () => { + const { t } = useExaminerTranslation({ + keyPrefix: 'vkt.component.examinerDetails', + }); + const translateCommon = useCommonTranslation(); + const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector); + const dispatch = useAppDispatch(); + + return ( +
+
+ + + {t('labels.isPublic')} + + +
+ { + dispatch(updateExaminerDetailsUpsert({ isPublic: checked })); + }} + /> +
+
+
+ ); +}; + +const ControlButtons = ({ + setShowErrors, +}: { + setShowErrors: (v: boolean) => void; +}) => { + const errors = useExaminerDetailsUpsertErrors(true); + const hasErrors = !!Object.values(errors).find((errorMsg) => + StringUtils.isNonBlankString(errorMsg), + ); + + const translateCommon = useCommonTranslation(); + const { t } = useExaminerTranslation({ + keyPrefix: 'vkt.component.examinerDetails', + }); + const { showDialog } = useDialog(); + const dispatch = useAppDispatch(); + const { status } = useAppSelector(examinerDetailsUpsertSelector); + const knownExaminerDetails = useExaminerDetails(); + + const onSave = () => { + if (hasErrors) { + const dialogContent = ( +
+ {t('incorrectDetailsDialog.description')} +
    + {Object.entries(errors) + .filter(([_, val]) => val) + .map(([field, _]) => ( +
  • + {t(`labels.${field}`)} +
  • + ))} +
+
+ ); + setShowErrors(true); + showDialog({ + title: t('incorrectDetailsDialog.title'), + severity: Severity.Error, + content: dialogContent, + actions: [ + { title: translateCommon('back'), variant: Variant.Contained }, + ], + }); + } else { + dispatch(startExaminerDetailsUpsert()); + } + }; + + const isLoading = status === APIResponseStatus.InProgress; + const examinerDetailsInitialized = + knownExaminerDetails && isExaminerDetails(knownExaminerDetails); + + return ( +
+ {examinerDetailsInitialized && ( + + {translateCommon('cancel')} + + )} + + + {t('buttons.saveAndClose')} + + +
+ ); +}; + +const CreateOrUpdateExaminerDetails = () => { + const { t } = useExaminerTranslation({ + keyPrefix: 'vkt.component.examinerDetails', + }); + const translateCommon = useCommonTranslation(); + const [showErrors, setShowErrors] = useState(false); + + const knownExaminerDetails = useExaminerDetails(); + const { examinerDetails } = useAppSelector(examinerDetailsUpsertSelector); + const dispatch = useAppDispatch(); + + // Initialize upsertable data from known examiner details + useEffect(() => { + if (knownExaminerDetails) { + if (isExaminerDetails(knownExaminerDetails)) { + const { + oid, + email, + phoneNumber, + examLanguageFinnish, + examLanguageSwedish, + municipalities, + isPublic, + } = knownExaminerDetails; + dispatch( + updateExaminerDetailsUpsert({ + oid, + email, + phoneNumber, + examLanguageFinnish, + examLanguageSwedish, + municipalities, + isPublic, + }), + ); + } else { + dispatch( + updateExaminerDetailsUpsert({ oid: knownExaminerDetails.oid }), + ); + } + } + }, [dispatch, knownExaminerDetails]); + + // Reset state on unmount + useEffect(() => { + return () => { + dispatch(resetExaminerDetailsUpsert()); + }; + }, [dispatch]); + + const updateExaminerDetails = ( + fieldName: keyof Omit, + value: string | boolean, + ) => { + dispatch(updateExaminerDetailsUpsert({ [fieldName]: value })); + }; + + const errors = useExaminerDetailsUpsertErrors(showErrors); + + const handleChange = + (fieldName: 'email' | 'phoneNumber') => + (event: ChangeEvent) => { + updateExaminerDetails(fieldName, event.target.value); + }; + + const handleBlur = + (fieldName: 'email' | 'phoneNumber') => + (event: ChangeEvent) => { + const trimmedValue = event.target.value ? event.target.value.trim() : ''; + if (fieldName === 'phoneNumber') { + updateExaminerDetails(fieldName, trimmedValue.replace(/\s/g, '')); + } else { + updateExaminerDetails(fieldName, trimmedValue); + } + }; + + const getLabeledFieldProps = ( + fieldName: keyof InputFieldValidation, + ): LabeledFieldProps => { + return { + id: `examiner-details__${fieldName}`, + label: t(`labels.${fieldName}`) + ' *', + error: showErrors && !!errors[fieldName], + helperText: errors[fieldName] ? translateCommon(errors[fieldName]) : '', + }; + }; + + const getLabeledTextFieldAttributes = ( + fieldName: 'email' | 'phoneNumber', + ) => { + const type = + fieldName === 'email' ? TextFieldTypes.Email : TextFieldTypes.PhoneNumber; + const autoCompleteType = + fieldName === 'email' + ? InputAutoComplete.Email + : InputAutoComplete.PhoneNumber; + + return { + type, + autoComplete: `work ${autoCompleteType}`, + value: examinerDetails[fieldName] || '', + onChange: handleChange(fieldName), + onBlur: handleBlur(fieldName), + }; + }; + + return ( + +
+ +

{t('personalDetails.heading')}

+ {t('personalDetails.information')} +
+
+ + {t('labels.lastName')} + + {knownExaminerDetails?.lastName} +
+
+ + {t('labels.firstName')} + + {knownExaminerDetails?.firstName} +
+ + +
+ +

{t('examinationDetails.heading')}

+ {t('examinationDetails.information')} + + + +
+
+ ); +}; + +export const ExaminerDetailsPage = () => { + const { t } = useExaminerTranslation({ + keyPrefix: 'vkt.component.examinerDetails', + }); + const dispatch = useAppDispatch(); + const { + oid, + status: examinerDetailsStatus, + initialized, + } = useAppSelector(examinerDetailsSelector); + const { status: initStatus } = useAppSelector(examinerDetailsInitSelector); + useEffect(() => { + if (examinerDetailsStatus === APIResponseStatus.NotStarted && oid) { + dispatch(loadExaminerDetails(oid)); + } + }, [dispatch, examinerDetailsStatus, oid]); + + useEffect(() => { + if ( + initialized === false && + initStatus === APIResponseStatus.NotStarted && + oid + ) { + dispatch(loadExaminerDetailsInit(oid)); + } + }); + const examinerDetails = useExaminerDetails(); + + // TODO Navigate away from page if details are saved successfully + // TODO Navigate away from page if cancel is pressed + // TODO Perhaps navigation protection if dirty fields? + return ( + + + +

{t('heading')}

+
+ {examinerDetails && } +
+
+ ); +}; diff --git a/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx b/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx index 8649816cb..e75827c09 100644 --- a/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx +++ b/frontend/packages/vkt/src/pages/examiner/ExaminerHomePage.tsx @@ -1,157 +1,14 @@ -import { - Box, - Checkbox, - Divider, - FormControlLabel, - FormGroup, - Grid, - Paper, -} from '@mui/material'; -import { FC, useEffect, useState } from 'react'; +import { Box, Grid } from '@mui/material'; +import { FC, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { - H1, - H2, - LabeledMultipleCheckboxDropdown, - LabeledTextField, - Text, -} from 'shared/components'; -import { - APIResponseStatus, - InputAutoComplete, - TextFieldTypes, - TextFieldVariant, -} from 'shared/enums'; +import { H1 } from 'shared/components'; +import { APIResponseStatus } from 'shared/enums'; -import { useKoodistoMunicipalitiesTranslation } from 'configs/i18n'; import { useAppDispatch, useAppSelector } from 'configs/redux'; import { AppRoutes } from 'enums/app'; -import { useMunicipalityOptions } from 'hooks/useKoodistoMunicipalities'; import { loadExaminerDetails } from 'redux/reducers/examinerDetails'; -import { loadExaminerDetailsInit } from 'redux/reducers/examinerDetailsInit'; import { clerkUserSelector } from 'redux/selectors/clerkUser'; import { examinerDetailsSelector } from 'redux/selectors/examinerDetails'; -import { examinerDetailsInitSelector } from 'redux/selectors/examinerDetailsInit'; - -const InitializeExaminerDetails = () => { - const translateMunicipality = useKoodistoMunicipalitiesTranslation(); - const { initData } = useAppSelector(examinerDetailsInitSelector); - const municipalityOptions = useMunicipalityOptions(); - const municipalityToOption = (municipality: string) => ({ - value: municipality, - label: translateMunicipality(municipality), - }); - const [municipalities, setMunicipalities] = useState>([]); - - return ( - -
-

Henkilötiedot

- Tiedoista vain nimet näkyvät julkisessa listauksessa. -
-
- - Sukunimi: - - {initData?.lastName} -
-
- - Etunimi: - - {initData?.lastName} -
- - -
- -

Tutkinnon perustiedot

- Nämä tiedot näkyvät julkisessa listauksessa. -
-
- - - Tutkinnon kieli * - - - - } label="suomi" /> - } label="ruotsi" /> - -
-
-
- { - setMunicipalities(options.map((v) => v.value)); - }} - /> -
-
-
- ); -}; - -export const ExaminerDetailsPage = () => { - const dispatch = useAppDispatch(); - const { - oid, - status: examinerDetailsStatus, - initialized, - } = useAppSelector(examinerDetailsSelector); - const { status: initStatus } = useAppSelector(examinerDetailsInitSelector); - useEffect(() => { - if (examinerDetailsStatus === APIResponseStatus.NotStarted && oid) { - dispatch(loadExaminerDetails(oid)); - } - }, [dispatch, examinerDetailsStatus, oid]); - - useEffect(() => { - if ( - initialized === false && - initStatus === APIResponseStatus.NotStarted && - oid - ) { - dispatch(loadExaminerDetailsInit(oid)); - } - }); - - return ( - - - -

Omat tiedot

-
- - {initialized === false && } - -
-
- ); -}; export const ExaminerHomePage: FC = () => { const navigate = useNavigate(); diff --git a/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts b/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts index e681edcab..366591b45 100644 --- a/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts +++ b/frontend/packages/vkt/src/redux/reducers/examinerDetails.ts @@ -25,7 +25,7 @@ const examinerDetailsSlice = createSlice({ storeExaminerDetails(state, action: PayloadAction) { state.status = APIResponseStatus.Success; state.examiner = action.payload; - state.initialized = undefined; + state.initialized = true; }, setExaminerOid(state, action: PayloadAction) { state.oid = action.payload; diff --git a/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts new file mode 100644 index 000000000..905adbcfc --- /dev/null +++ b/frontend/packages/vkt/src/redux/reducers/examinerDetailsUpsert.ts @@ -0,0 +1,48 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { APIResponseStatus } from 'shared/enums'; + +import { + ExaminerDetailsUpsert, + ExaminerDetailsUpsertState, +} from 'interfaces/examinerDetailsUpsert'; + +const initialState: ExaminerDetailsUpsertState = { + status: APIResponseStatus.NotStarted, + examinerDetails: { + isPublic: true, + }, +}; + +const examinerDetailsUpsertSlice = createSlice({ + name: 'examinerDetailsUpsert', + initialState, + reducers: { + acceptExaminerDetailsUpsert(state) { + state.status = APIResponseStatus.Success; + }, + rejectExaminerDetailsUpsert(state) { + state.status = APIResponseStatus.Error; + }, + resetExaminerDetailsUpsert(_) { + return initialState; + }, + startExaminerDetailsUpsert(state) { + state.status = APIResponseStatus.InProgress; + }, + updateExaminerDetailsUpsert( + state, + action: PayloadAction>, + ) { + state.examinerDetails = { ...state.examinerDetails, ...action.payload }; + }, + }, +}); + +export const { + acceptExaminerDetailsUpsert, + rejectExaminerDetailsUpsert, + resetExaminerDetailsUpsert, + startExaminerDetailsUpsert, + updateExaminerDetailsUpsert, +} = examinerDetailsUpsertSlice.actions; +export const examinerDetailsUpsertReducer = examinerDetailsUpsertSlice.reducer; diff --git a/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts b/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts index fb916041a..b2d8bc049 100644 --- a/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts +++ b/frontend/packages/vkt/src/redux/sagas/examinerDetails.ts @@ -22,8 +22,6 @@ function* loadExaminerDetailsSaga(action: PayloadAction) { let initialized = true; if (isAxiosError(error)) { const errorCode = error.response?.data?.errorCode; - // eslint-disable-next-line no-console - console.log('moiccuuuuu! errorCode', errorCode); if (errorCode === APIError.ExaminerNotFound) { initialized = false; } diff --git a/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts new file mode 100644 index 000000000..e9646da99 --- /dev/null +++ b/frontend/packages/vkt/src/redux/sagas/examinerDetailsUpsert.ts @@ -0,0 +1,40 @@ +import { AxiosError } from 'axios'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; + +import axiosInstance from 'configs/axios'; +import { APIEndpoints } from 'enums/api'; +import { ExaminerDetailsUpsert } from 'interfaces/examinerDetailsUpsert'; +import { setAPIError } from 'redux/reducers/APIError'; +import { + acceptExaminerDetailsUpsert, + rejectExaminerDetailsUpsert, + startExaminerDetailsUpsert, +} from 'redux/reducers/examinerDetailsUpsert'; +import { examinerDetailsUpsertSelector } from 'redux/selectors/examinerDetailsUpsert'; +import { NotifierUtils } from 'utils/notifier'; + +function* startExaminerDetailsUpsertSaga() { + try { + const { examinerDetails }: { examinerDetails: ExaminerDetailsUpsert } = + yield select(examinerDetailsUpsertSelector); + + const { oid: _oid, id: _id, ...detailsToSubmit } = examinerDetails; + yield call( + axiosInstance.post, + APIEndpoints.ExaminerDetails.replace(/:oid/, examinerDetails.oid), + detailsToSubmit, + ); + yield put(acceptExaminerDetailsUpsert()); + } catch (error) { + const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError); + yield put(setAPIError(errorMessage)); + yield put(rejectExaminerDetailsUpsert()); + } +} + +export function* watchExaminerDetailsUpsert() { + yield takeLatest( + startExaminerDetailsUpsert.type, + startExaminerDetailsUpsertSaga, + ); +} diff --git a/frontend/packages/vkt/src/redux/sagas/index.ts b/frontend/packages/vkt/src/redux/sagas/index.ts index 0b893ad20..00d2f0f11 100644 --- a/frontend/packages/vkt/src/redux/sagas/index.ts +++ b/frontend/packages/vkt/src/redux/sagas/index.ts @@ -7,6 +7,7 @@ import { watchClerkNewExamDate } from 'redux/sagas/clerkNewExamDate'; import { watchClerkUser } from 'redux/sagas/clerkUser'; import { watchExaminerDetails } from 'redux/sagas/examinerDetails'; import { watchExaminerDetailsInit } from 'redux/sagas/examinerDetailsInit'; +import { watchExaminerDetailsUpsert } from 'redux/sagas/examinerDetailsUpsert'; import { watchFeatureFlags } from 'redux/sagas/featureFlags'; import { watchPublicEducation } from 'redux/sagas/publicEducation'; import { watchPublicEnrollments } from 'redux/sagas/publicEnrollment'; @@ -31,5 +32,6 @@ export default function* rootSaga() { watchPublicExaminers(), watchExaminerDetails(), watchExaminerDetailsInit(), + watchExaminerDetailsUpsert(), ]); } diff --git a/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts b/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts new file mode 100644 index 000000000..02728eb06 --- /dev/null +++ b/frontend/packages/vkt/src/redux/selectors/examinerDetailsUpsert.ts @@ -0,0 +1,7 @@ +import { RootState } from 'configs/redux'; +import { ExaminerDetailsUpsertState } from 'interfaces/examinerDetailsUpsert'; + +export const examinerDetailsUpsertSelector: ( + state: RootState, +) => ExaminerDetailsUpsertState = (state: RootState) => + state.examinerDetailsUpsert; diff --git a/frontend/packages/vkt/src/redux/store/index.ts b/frontend/packages/vkt/src/redux/store/index.ts index e8a514f05..8fe85e051 100644 --- a/frontend/packages/vkt/src/redux/store/index.ts +++ b/frontend/packages/vkt/src/redux/store/index.ts @@ -12,6 +12,7 @@ import { clerkNewExamDateReducer } from 'redux/reducers/clerkNewExamDate'; import { clerkUserReducer } from 'redux/reducers/clerkUser'; import { examinerDetailsReducer } from 'redux/reducers/examinerDetails'; import { examinerDetailsInitReducer } from 'redux/reducers/examinerDetailsInit'; +import { examinerDetailsUpsertReducer } from 'redux/reducers/examinerDetailsUpsert'; import { featureFlagsReducer } from 'redux/reducers/featureFlags'; import { publicEducationReducer } from 'redux/reducers/publicEducation'; import { publicEnrollmentReducer } from 'redux/reducers/publicEnrollment'; @@ -44,6 +45,7 @@ const reducer = combineReducers({ publicExaminer: publicExaminerReducer, examinerDetails: examinerDetailsReducer, examinerDetailsInit: examinerDetailsInitReducer, + examinerDetailsUpsert: examinerDetailsUpsertReducer, }); const persistedReducer = persistReducer(persistConfig, reducer); diff --git a/frontend/packages/vkt/src/routers/AppRouter.tsx b/frontend/packages/vkt/src/routers/AppRouter.tsx index 4b89b562e..76febc29c 100644 --- a/frontend/packages/vkt/src/routers/AppRouter.tsx +++ b/frontend/packages/vkt/src/routers/AppRouter.tsx @@ -28,10 +28,8 @@ import { ClerkExamEventCreatePage } from 'pages/ClerkExamEventCreatePage'; import { ClerkExamEventOverviewPage } from 'pages/ClerkExamEventOverviewPage'; import { ClerkExcellentLevelPage } from 'pages/ClerkExcellentLevelPage'; import { ClerkGoodAndSatisfactoryLevelPage } from 'pages/ClerkGoodAndSatisfactoryLevelPage'; -import { - ExaminerDetailsPage, - ExaminerHomePage, -} from 'pages/examiner/ExaminerHomePage'; +import { ExaminerDetailsPage } from 'pages/examiner/ExaminerDetailsPage'; +import { ExaminerHomePage } from 'pages/examiner/ExaminerHomePage'; import { ExaminerRedirectPage } from 'pages/examiner/ExaminerRedirectPage'; import { ExaminerRootPage } from 'pages/examiner/ExaminerRootPage'; import { PublicEnrollmentPage } from 'pages/excellentLevel/PublicEnrollmentPage'; diff --git a/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss b/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss index e31f9e518..d7c7d2ec1 100644 --- a/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss +++ b/frontend/packages/vkt/src/styles/pages/_examiner-details-page.scss @@ -29,4 +29,12 @@ margin-bottom: 1rem; } } + + & &__is-public { + fieldset { + border: 0; + margin-inline: unset; + padding: unset; + } + } }