diff --git a/frontend/src/Components/Input/Input.tsx b/frontend/src/Components/Input/Input.tsx index 99c26479d..982c25a43 100644 --- a/frontend/src/Components/Input/Input.tsx +++ b/frontend/src/Components/Input/Input.tsx @@ -2,19 +2,23 @@ import classNames from 'classnames'; import * as React from 'react'; import styles from './Input.module.scss'; -export interface InputProps extends React.InputHTMLAttributes { - onChange: (...event: unknown[]) => void; +export interface InputProps extends Omit, 'value' | 'onChange'> { + onChange: (value: string | number | readonly string[] | null) => void; + value: string | number | readonly string[] | null | undefined; } -export const Input = React.forwardRef(({ className, type, onChange, ...props }, ref) => { - return ( - (type === 'number' ? onChange?.(+event.target.value) : onChange?.(event.target.value))} - ref={ref} - {...props} - /> - ); -}); +export const Input = React.forwardRef( + ({ className, type, onChange, value, ...props }, ref) => { + return ( + (type === 'number' ? onChange?.(+event.target.value) : onChange?.(event.target.value))} + ref={ref} + value={value === null ? '' : value} + {...props} + /> + ); + }, +); Input.displayName = 'Input'; diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.module.scss b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.module.scss index 8c9795666..62d68bc7d 100644 --- a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.module.scss @@ -7,12 +7,12 @@ width: 100%; display: flex; flex-direction: column; + gap: 1rem; } .row { display: flex; flex-direction: column; - margin-top: 1em; gap: 1em; @include for-tablet-up { flex-direction: row; @@ -24,3 +24,8 @@ justify-content: center; margin-top: 20%; } + +.item { + flex-grow: 1; + max-width: calc(50% - 0.5em); +} diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx index 809fd2e03..82148d1d6 100644 --- a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx @@ -1,10 +1,11 @@ +import { zodResolver } from '@hookform/resolvers/zod'; import { useEffect, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useRouteLoaderData } from 'react-router-dom'; import { toast } from 'react-toastify'; +import { Button, Dropdown, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from '~/Components'; import type { DropDownOption } from '~/Components/Dropdown/Dropdown'; -import { SamfForm } from '~/Forms/SamfForm'; -import { SamfFormField } from '~/Forms/SamfFormField'; import { getOrganizations, postRecruitment, putRecruitment } from '~/api'; import type { OrganizationDto, RecruitmentDto } from '~/dto'; import { useTitle } from '~/hooks'; @@ -14,29 +15,17 @@ import { ROUTES } from '~/routes'; import { dbT, getObjectFieldOrNumber, lowerCapitalize, utcTimestampToLocal } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentFormAdminPage.module.scss'; - -type FormType = { - name_nb: string; - name_en: string; - visible_from: string; - shown_application_deadline: string; - actual_application_deadline: string; - reprioritization_deadline_for_applicant: string; - reprioritization_deadline_for_groups: string; - organization: number; -}; +import { type recruitmentFormType, recruitmentSchema } from './recruitmentSchema'; export function RecruitmentFormAdminPage() { const { t } = useTranslation(); const navigate = useNavigate(); const data = useRouteLoaderData('recruitment') as RecruitmentLoader | undefined; - // Form data const { recruitmentId } = useParams(); const [organizationOptions, setOrganizationOptions] = useState[]>([]); useEffect(() => { - // Fetch organizations. getOrganizations().then((data) => { const organizations = data.map((organization: OrganizationDto) => ({ label: organization.name, @@ -46,19 +35,25 @@ export function RecruitmentFormAdminPage() { }); }, []); - const initialData: Partial = { - name_nb: data?.recruitment?.name_nb, - name_en: data?.recruitment?.name_en, - visible_from: utcTimestampToLocal(data?.recruitment?.visible_from), - actual_application_deadline: utcTimestampToLocal(data?.recruitment?.actual_application_deadline), - shown_application_deadline: utcTimestampToLocal(data?.recruitment?.shown_application_deadline), - reprioritization_deadline_for_applicant: utcTimestampToLocal( - data?.recruitment?.reprioritization_deadline_for_applicant, - ), - reprioritization_deadline_for_groups: utcTimestampToLocal(data?.recruitment?.reprioritization_deadline_for_groups), - organization: getObjectFieldOrNumber(data?.recruitment?.organization, 'id'), + const initialData: Partial = { + name_nb: data?.recruitment?.name_nb || '', + name_en: data?.recruitment?.name_en || '', + visible_from: utcTimestampToLocal(data?.recruitment?.visible_from, false) || '', + actual_application_deadline: utcTimestampToLocal(data?.recruitment?.actual_application_deadline, false) || '', + shown_application_deadline: utcTimestampToLocal(data?.recruitment?.shown_application_deadline, false) || '', + reprioritization_deadline_for_applicant: + utcTimestampToLocal(data?.recruitment?.reprioritization_deadline_for_applicant, false) || '', + reprioritization_deadline_for_groups: + utcTimestampToLocal(data?.recruitment?.reprioritization_deadline_for_groups, false) || '', + organization: getObjectFieldOrNumber(data?.recruitment?.organization, 'id') || 1, + max_applications: data?.recruitment?.max_applications, }; + const form = useForm({ + resolver: zodResolver(recruitmentSchema), + defaultValues: initialData, + }); + const title = recruitmentId ? `${t(KEY.common_edit)} ${dbT(data?.recruitment, 'name')}` : lowerCapitalize(`${t(KEY.common_create)} ${t(KEY.common_recruitment)}`); @@ -67,30 +62,21 @@ export function RecruitmentFormAdminPage() { const submitText = recruitmentId ? t(KEY.common_save) : t(KEY.common_create); - function handleOnSubmit(data: FormType) { - const errors = validateForm(data); - if (Object.keys(errors).length > 0) { - for (const error of Object.values(errors)) { - toast.error(error); - } - return; - } + function onSubmit(data: recruitmentFormType) { if (recruitmentId) { - // Update page. putRecruitment(recruitmentId, data as RecruitmentDto) .then(() => { toast.success(t(KEY.common_update_successful)); + navigate(ROUTES.frontend.admin_recruitment); }) .catch(() => { toast.error(t(KEY.common_something_went_wrong)); }); - navigate(ROUTES.frontend.admin_recruitment); } else { - // Post new page. postRecruitment(data as RecruitmentDto) .then(() => { - navigate(ROUTES.frontend.admin_recruitment); toast.success(t(KEY.common_creation_successful)); + navigate(ROUTES.frontend.admin_recruitment); }) .catch(() => { toast.error(t(KEY.common_something_went_wrong)); @@ -98,102 +84,146 @@ export function RecruitmentFormAdminPage() { } } - function validateForm(data: FormType) { - const errors: Partial = {}; - - const visibleFrom = new Date(data.visible_from); - const shownApplicationDeadline = new Date(data.shown_application_deadline); - const actualApplicationDeadline = new Date(data.actual_application_deadline); - const reprioritizationDeadlineForApplicant = new Date(data.reprioritization_deadline_for_applicant); - const reprioritizationDeadlineForGroups = new Date(data.reprioritization_deadline_for_groups); - - if (shownApplicationDeadline < visibleFrom) { - errors.shown_application_deadline = t(KEY.error_recruitment_form_1); - } - if (actualApplicationDeadline < shownApplicationDeadline) { - errors.actual_application_deadline = t(KEY.error_recruitment_form_2); - } - if (reprioritizationDeadlineForApplicant < actualApplicationDeadline) { - errors.reprioritization_deadline_for_applicant = t(KEY.error_recruitment_form_3); - } - if (reprioritizationDeadlineForGroups < reprioritizationDeadlineForApplicant) { - errors.reprioritization_deadline_for_groups = t(KEY.error_recruitment_form_4); - } - return errors; - } - - // TODO: Add validation for the dates return ( -
- - onSubmit={handleOnSubmit} - initialData={initialData} - submitText={submitText} - validateOn={'submit'} - > -
- - field="name_nb" - type="text" - label={`${t(KEY.common_name)} ${t(KEY.common_english)}`} - required={true} - /> - - field="name_en" - type="text" - label={`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`} - required={true} - /> -
-
- -
-
- - -
-
- - -
-
- - +
+ +
+
+ ( + + {`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`} + + + + + + )} + /> + ( + + {`${t(KEY.common_name)} ${t(KEY.common_english)}`} + + + + + + )} + /> +
+
+ ( + + {t(KEY.recruitment_visible_from)} + + + + + + )} + /> +
+
+ ( + + {t(KEY.shown_application_deadline)} + + + + + + )} + /> + ( + + {t(KEY.actual_application_deadline)} + + + + + + )} + /> +
+
+ ( + + {t(KEY.reprioritization_deadline_for_applicant)} + + + + + + )} + /> + ( + + {t(KEY.reprioritization_deadline_for_groups)} + + + + + + )} + /> +
+
+ ( + + {t(KEY.max_applications)} + + + + + + )} + /> + ( + + {t(KEY.recruitment_organization)} + + field.onChange(value)} + initialValue={field.value} + /> + + + + )} + /> +
+
- -
+ + ); } diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts new file mode 100644 index 000000000..c721d7d2e --- /dev/null +++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/recruitmentSchema.ts @@ -0,0 +1,64 @@ +import i18next from 'i18next'; +import { z } from 'zod'; +import { KEY } from '~/i18n/constants'; +import { LOCAL_DATETIME } from '~/schema/dates'; +import { NON_EMPTY_STRING } from '~/schema/strings'; + +export const recruitmentSchema = z + .object({ + name_nb: NON_EMPTY_STRING, + name_en: NON_EMPTY_STRING, + visible_from: LOCAL_DATETIME, + shown_application_deadline: LOCAL_DATETIME, + actual_application_deadline: LOCAL_DATETIME, + reprioritization_deadline_for_applicant: LOCAL_DATETIME, + reprioritization_deadline_for_groups: LOCAL_DATETIME, + organization: z.number().min(1, { message: 'Organization is required' }), + max_applications: z.number().nullable(), + }) + .refine( + (data) => { + const visibleFrom = new Date(data.visible_from); + const shownApplicationDeadline = new Date(data.shown_application_deadline); + return shownApplicationDeadline > visibleFrom; + }, + { + message: i18next.t(KEY.error_recruitment_form_1), + path: ['shown_application_deadline'], + }, + ) + .refine( + (data) => { + const shownApplicationDeadline = new Date(data.shown_application_deadline); + const actualApplicationDeadline = new Date(data.actual_application_deadline); + return actualApplicationDeadline > shownApplicationDeadline; + }, + { + message: i18next.t(KEY.error_recruitment_form_2), + path: ['actual_application_deadline'], + }, + ) + .refine( + (data) => { + const actualApplicationDeadline = new Date(data.actual_application_deadline); + const reprioritizationDeadlineForApplicant = new Date(data.reprioritization_deadline_for_applicant); + return reprioritizationDeadlineForApplicant > actualApplicationDeadline; + }, + { + message: i18next.t(KEY.error_recruitment_form_3), + path: ['reprioritization_deadline_for_applicant'], + }, + ) + .refine( + (data) => { + const reprioritizationDeadlineForApplicant = new Date(data.reprioritization_deadline_for_applicant); + const reprioritizationDeadlineForGroups = new Date(data.reprioritization_deadline_for_groups); + return reprioritizationDeadlineForGroups > reprioritizationDeadlineForApplicant; + }, + { + message: i18next.t(KEY.error_recruitment_form_4), + path: ['reprioritization_deadline_for_groups'], + }, + ); + +export type recruitmentFormType = z.infer; diff --git a/frontend/src/constants/constants.ts b/frontend/src/constants/constants.ts index ff04d61b4..e8127e4cb 100644 --- a/frontend/src/constants/constants.ts +++ b/frontend/src/constants/constants.ts @@ -36,6 +36,8 @@ export const THEME_KEY = 'data-theme'; // Valid html tag attribute. export const SUPPORT_EMAIL = 'mg-web@samfundet.no'; export const PHONENUMBER_REGEX = /^\+?\s*(\d\s*){8,15}$/; + +export const LOCAL_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; /** * Screen sizes, breakpoint (bp). * These values are also in _constants.scss diff --git a/frontend/src/schema/dates.ts b/frontend/src/schema/dates.ts new file mode 100644 index 000000000..6735582af --- /dev/null +++ b/frontend/src/schema/dates.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; +import { LOCAL_DATETIME_REGEX } from '~/constants'; + +export const LOCAL_DATETIME = z.string().regex(LOCAL_DATETIME_REGEX, 'Invalid datetime'); diff --git a/frontend/src/schema/strings.ts b/frontend/src/schema/strings.ts new file mode 100644 index 000000000..edf686654 --- /dev/null +++ b/frontend/src/schema/strings.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const NON_EMPTY_STRING = z.string().min(1); diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 89afb786b..6aade2554 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -211,19 +211,23 @@ export function getTicketTypeKey(ticketType: EventTicketTypeValue): TranslationK * Converts a UTC timestring from django to * a local timestring suitable for html input elements * @param time timestring in django utc format, eg '2028-03-31T02:33:31.835Z' + * @param includeSeconds boolean flag to include seconds in the output * @returns timestamp in local format, eg. '2023-04-05T20:15' */ -export function utcTimestampToLocal(time: string | undefined): string { - return new Date(time ?? '') - .toLocaleString('sv-SE', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }) - .replace(' ', 'T'); +export function utcTimestampToLocal(time: string | undefined, includeSeconds = true): string { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }; + + if (includeSeconds) { + options.second = '2-digit'; + } + + return new Date(time ?? '').toLocaleString('sv-SE', options).replace(' ', 'T'); } /**