From 9513e38e6d7f1e2f3c659aab58f64be1b30fc51c Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 20:58:49 +0100 Subject: [PATCH 01/20] Only listen to primary button press in TimeslotContainer (#1563) * Only listen to primary button press in TimeslotContainer * Add comment on primary button --- .../src/Components/TimeslotContainer/TimeslotContainer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx index 388531b2b..3a9ae63f5 100644 --- a/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx +++ b/frontend/src/Components/TimeslotContainer/TimeslotContainer.tsx @@ -157,7 +157,11 @@ export function TimeslotContainer({ key={timeslot} active={active} disabled={disabled || false} - onMouseDown={() => { + onMouseDown={(event) => { + if (event.button !== 0) { + // Ignore if not primary mouse button + return; + } toggleTimeslot(selectedDate, timeslot); setDragSetSelected(!active); }} From cd40560330b823fa780d81d68e88c0c3c831cce5 Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:03:27 +0100 Subject: [PATCH 02/20] Create root error boundary (#1560) * Update Error component to use H1 * Rename Error component to ErrorDisplay (to avoid shadowing Error) * Add some error translations * Create RootErrorBoundary component * Wrap public routes with RootErrorBoundary * Wrap admin routes in RootErrorBoundary * Add styling * Move to components dir --- .../src/Components/Error/Error.module.scss | 8 - frontend/src/Components/Error/index.ts | 1 - .../ErrorDisplay/ErrorDisplay.module.scss | 4 + .../ErrorDisplay.stories.tsx} | 6 +- .../ErrorDisplay.tsx} | 10 +- frontend/src/Components/ErrorDisplay/index.ts | 1 + .../RootErrorBoundary.module.scss | 14 + .../RootErrorBoundary/RootErrorBoundary.tsx | 28 + .../src/Components/RootErrorBoundary/index.ts | 1 + frontend/src/Components/index.ts | 3 +- frontend/src/i18n/constants.ts | 9 + frontend/src/i18n/translations.ts | 19 + frontend/src/router/router.tsx | 790 +++++++++--------- 13 files changed, 486 insertions(+), 408 deletions(-) delete mode 100644 frontend/src/Components/Error/Error.module.scss delete mode 100644 frontend/src/Components/Error/index.ts create mode 100644 frontend/src/Components/ErrorDisplay/ErrorDisplay.module.scss rename frontend/src/Components/{Error/Error.stories.tsx => ErrorDisplay/ErrorDisplay.stories.tsx} (65%) rename frontend/src/Components/{Error/Error.tsx => ErrorDisplay/ErrorDisplay.tsx} (53%) create mode 100644 frontend/src/Components/ErrorDisplay/index.ts create mode 100644 frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss create mode 100644 frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx create mode 100644 frontend/src/Components/RootErrorBoundary/index.ts diff --git a/frontend/src/Components/Error/Error.module.scss b/frontend/src/Components/Error/Error.module.scss deleted file mode 100644 index 016166cd9..000000000 --- a/frontend/src/Components/Error/Error.module.scss +++ /dev/null @@ -1,8 +0,0 @@ -.error_content { - line-height: 2rem; -} - -.header { - font-size: 2rem; - font-weight: 700; -} diff --git a/frontend/src/Components/Error/index.ts b/frontend/src/Components/Error/index.ts deleted file mode 100644 index 9b35c9f37..000000000 --- a/frontend/src/Components/Error/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Error } from './Error'; diff --git a/frontend/src/Components/ErrorDisplay/ErrorDisplay.module.scss b/frontend/src/Components/ErrorDisplay/ErrorDisplay.module.scss new file mode 100644 index 000000000..09cbcc887 --- /dev/null +++ b/frontend/src/Components/ErrorDisplay/ErrorDisplay.module.scss @@ -0,0 +1,4 @@ +.error_content { + line-height: 2rem; +} + diff --git a/frontend/src/Components/Error/Error.stories.tsx b/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx similarity index 65% rename from frontend/src/Components/Error/Error.stories.tsx rename to frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx index 67ca825df..5ca655371 100644 --- a/frontend/src/Components/Error/Error.stories.tsx +++ b/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx @@ -1,13 +1,13 @@ import type { ComponentStory } from '@storybook/react'; // biome-ignore lint/suspicious/noShadowRestrictedNames: -import { Error } from './Error'; +import { ErrorDisplay } from './ErrorDisplay'; export default { title: 'Components/Error', - comonent: Error, + comonent: ErrorDisplay, }; -const Template: ComponentStory = (args) => ; +const Template: ComponentStory = (args) => ; export const Default = Template.bind({}); Default.args = { diff --git a/frontend/src/Components/Error/Error.tsx b/frontend/src/Components/ErrorDisplay/ErrorDisplay.tsx similarity index 53% rename from frontend/src/Components/Error/Error.tsx rename to frontend/src/Components/ErrorDisplay/ErrorDisplay.tsx index e990c93ca..ddbdc13ad 100644 --- a/frontend/src/Components/Error/Error.tsx +++ b/frontend/src/Components/ErrorDisplay/ErrorDisplay.tsx @@ -1,17 +1,17 @@ +import { H1 } from '~/Components'; import type { Children } from '~/types'; -import styles from './Error.module.scss'; +import styles from './ErrorDisplay.module.scss'; -interface ErrorProps { +interface ErrorDisplayProps { header: string; message: string; children?: Children; } -// biome-ignore lint/suspicious/noShadowRestrictedNames: -export function Error({ header, message, children }: ErrorProps) { +export function ErrorDisplay({ header, message, children }: ErrorDisplayProps) { return (
-

{header}

+

{header}

{message}

{children} diff --git a/frontend/src/Components/ErrorDisplay/index.ts b/frontend/src/Components/ErrorDisplay/index.ts new file mode 100644 index 000000000..4bf4dbfcc --- /dev/null +++ b/frontend/src/Components/ErrorDisplay/index.ts @@ -0,0 +1 @@ +export { ErrorDisplay } from './ErrorDisplay'; diff --git a/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss new file mode 100644 index 000000000..d6408c820 --- /dev/null +++ b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.module.scss @@ -0,0 +1,14 @@ +@import 'src/constants'; + +@import 'src/mixins'; + +.container { + width: $primary-content-width-wide; + max-width: 100%; + margin: 0 auto; + padding: 2rem 0.75rem; + + @include for-desktop-up { + padding: 6rem 4rem; + } +} diff --git a/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx new file mode 100644 index 000000000..9dd58cefc --- /dev/null +++ b/frontend/src/Components/RootErrorBoundary/RootErrorBoundary.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next'; +import { isRouteErrorResponse, useRouteError } from 'react-router-dom'; +import { ErrorDisplay } from '~/Components'; +import { KEY } from '~/i18n/constants'; +import styles from './RootErrorBoundary.module.scss'; + +export function RootErrorBoundary() { + const { t } = useTranslation(); + const error = useRouteError(); + + let errorDisplay = ; + + if (isRouteErrorResponse(error)) { + if (error.status === 404) { + errorDisplay = ; + } + if (error.status === 403) { + errorDisplay = ; + } + if (error.status === 500) { + errorDisplay = ( + + ); + } + } + + return
{errorDisplay}
; +} diff --git a/frontend/src/Components/RootErrorBoundary/index.ts b/frontend/src/Components/RootErrorBoundary/index.ts new file mode 100644 index 000000000..8439a2010 --- /dev/null +++ b/frontend/src/Components/RootErrorBoundary/index.ts @@ -0,0 +1 @@ +export { RootErrorBoundary } from './RootErrorBoundary'; diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index 385a98598..fb401a786 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -13,7 +13,7 @@ export { ContentCard } from './ContentCard'; export { Countdown } from './Countdown'; export { CrudButtons } from './CrudButtons'; export { Dropdown } from './Dropdown'; -export { Error } from './Error'; +export { ErrorDisplay } from './ErrorDisplay'; export { EventCard } from './EventCard'; export { EventQuery } from './EventQuery'; export { ExpandableHeader } from './ExpandableHeader'; @@ -54,6 +54,7 @@ export { PulseEffect } from './PulseEffect'; export { RadioButton } from './RadioButton'; export { RecruitmentApplicantsStatus } from './RecruitmentApplicantsStatus'; export { RecruitmentWithoutInterviewTable } from './RecruitmentWithoutInterviewTable'; +export { RootErrorBoundary } from './RootErrorBoundary'; export { SamfOutlet } from './SamfOutlet'; export { SamfundetLogo } from './SamfundetLogo'; export { SamfundetLogoSpinner } from './SamfundetLogoSpinner'; diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 009105469..3a4bb968f 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -461,6 +461,15 @@ export const KEY = { notfoundpage_contact_prompt: 'notfoundpage_contact_prompt', saksdokumentpage_publication_date: 'saksdokumentpage_publication_date', eventsadminpage_successful_delete_toast: 'eventsadminpage_successful_delete_toast', + + error_generic: 'error_generic', + error_generic_description: 'error_generic_description', + error_not_found: 'error_not_found', + error_not_found_description: 'error_not_found_description', + error_forbidden: 'error_forbidden', + error_forbidden_description: 'error_forbidden_description', + error_server_error: 'error_server_error', + error_server_error_description: 'error_server_error_description', } as const; // This will ensure that each value matches the key exactly. diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index ef45d675f..26862f9f1 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -462,6 +462,16 @@ export const nb = prepareTranslations({ [KEY.notfoundpage_contact_prompt]: 'Hvis du tror dette er en feil, vennligst', [KEY.admin_saksdokumenter_cannot_reupload]: 'Det er ikke mulig å endre filen som er lastet opp.', [KEY.eventsadminpage_successful_delete_toast]: 'Slettingen av arrangementet var vellykket.', + + // Errors + [KEY.error_generic]: 'Uventet feil', + [KEY.error_generic_description]: 'En uventet feil oppsto, vennligst prøv igjen', + [KEY.error_not_found]: 'Ikke funnet', + [KEY.error_not_found_description]: 'Ressursen du forsøkte å hente ble ikke funnet', + [KEY.error_forbidden]: 'Ingen adgang', + [KEY.error_forbidden_description]: 'Du har ikke adgang til å se denne ressursen', + [KEY.error_server_error]: 'Serverfeil', + [KEY.error_server_error_description]: 'En serverfeil har opptstått', }); export const en = prepareTranslations({ @@ -928,4 +938,13 @@ export const en = prepareTranslations({ [KEY.inputfile_no_file_selected]: 'No file selected', [KEY.notfoundpage_title]: 'Page not found', [KEY.notfoundpage_contact_prompt]: 'If you believe this is an error, please', + + [KEY.error_generic]: 'Unexpected error', + [KEY.error_generic_description]: 'An unexpected error has occurred', + [KEY.error_not_found]: 'Not found', + [KEY.error_not_found_description]: 'The resource you requested was not found', + [KEY.error_forbidden]: 'Forbidden', + [KEY.error_forbidden_description]: 'You do not have permission to view this resource', + [KEY.error_server_error]: 'Server error', + [KEY.error_server_error_description]: 'A server error has occurred', }); diff --git a/frontend/src/router/router.tsx b/frontend/src/router/router.tsx index cd8e3fba5..4272a5eae 100644 --- a/frontend/src/router/router.tsx +++ b/frontend/src/router/router.tsx @@ -1,5 +1,5 @@ import { Outlet, Route, type UIMatch, createBrowserRouter, createRoutesFromElements } from 'react-router-dom'; -import { Link, PermissionRoute, ProtectedRoute, SamfOutlet, SultenOutlet } from '~/Components'; +import { Link, PermissionRoute, ProtectedRoute, RootErrorBoundary, SamfOutlet, SultenOutlet } from '~/Components'; import { AboutPage, AdminPage, @@ -100,34 +100,36 @@ export const router = createBrowserRouter( {/* PUBLIC ROUTES */} - } /> - } /> - } /> - } /> - } /> - } />}> - } /> - } /> + } errorElement={}> + } /> + } /> + } /> + } /> + } /> + } />}> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> {/* ADMIN ROUTES @@ -136,469 +138,477 @@ export const router = createBrowserRouter( handle={{ crumb: () => {t(KEY.control_panel_title)} }} element={} > - } />} - /> - {/* Gangs */} - } - handle={{ crumb: () => {t(KEY.common_gangs)} }} - > - } />} - /> - {t(KEY.common_create)} }} - element={} />} - /> - } />} - loader={gangLoader} - handle={{ - crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, - }} - /> - - {/* Users */} - } - handle={{ crumb: () => {t(KEY.common_users)} }} - > - } />} - /> - - {/* Roles */} - } - handle={{ crumb: () => {t(KEY.common_roles)} }} - > - } />} - /> - } />} - loader={roleLoader} - handle={{ - crumb: ({ pathname }: UIMatch, { role }: RoleLoader) => {role?.name}, - }} - /> - - {/* Events */} - } - handle={{ crumb: () => {t(KEY.common_events)} }} - > - } />} - /> - {t(KEY.common_create)} }} - element={} />} - /> - {t(KEY.common_edit)} }} - element={} />} - /> - - {/* - Info pages - NOTE: edit/create uses custom views - */} - {t(KEY.information_page)} }} - element={ - } /> - } - /> - {/* Opening hours, TODO ADD OPENING HOURS PERMISSIONS*/} - {t(KEY.common_opening_hours)} }} - element={} />} - /> - {/* Closed period */} - } - handle={{ - crumb: () => {t(KEY.command_menu_shortcut_closed)}, - }} - > + } errorElement={}> } /> - } - /> - {t(KEY.common_create)} }} - element={ - } /> - } - /> - {t(KEY.common_edit)} }} - element={ - } - /> - } - /> - - {/* Images */} - } - handle={{ crumb: () => {t(KEY.admin_images_title)} }} - > - } />} - /> - {t(KEY.common_create)} }} - element={} />} - /> - - {/* Saksdokumenter */} - } - handle={{ crumb: () => {t(KEY.admin_saksdokument)} }} - > - } /> - } - /> - {t(KEY.common_create)}, - }} - element={ - } /> - } - /> - {t(KEY.common_edit)} }} - element={ - } - /> - } - /> - - ( - - {t(KEY.common_sulten)} {t(KEY.common_menu)} - - ), - }} - element={} />} - /> - } /> - } - /> - } /> - } - /> - {/* Recruitment */} - } - path={ROUTES.frontend.admin_recruitment} - handle={{ crumb: () => {t(KEY.common_recruitment)} }} - > - } /> - } + path={ROUTES.frontend.admin} + element={} />} /> + {/* Gangs */} } /> - } - /> - } /> - } - handle={{ crumb: ({ pathname }: UIMatch) => {t(KEY.common_create)} }} - /> + element={} + handle={{ crumb: () => {t(KEY.common_gangs)} }} + > + } />} + /> + {t(KEY.common_create)} }} + element={} />} + /> + } />} + loader={gangLoader} + handle={{ + crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, + }} + /> + + {/* Users */} } - /> + element={} + handle={{ crumb: () => {t(KEY.common_users)} }} + > + } />} + /> + + {/* Roles */} } - /> + element={} + handle={{ crumb: () => {t(KEY.common_roles)} }} + > + } />} + /> + } />} + loader={roleLoader} + handle={{ + crumb: ({ pathname }: UIMatch, { role }: RoleLoader) => {role?.name}, + }} + /> + + {/* Events */} } - /> + element={} + handle={{ crumb: () => {t(KEY.common_events)} }} + > + } />} + /> + {t(KEY.common_create)} }} + element={} />} + /> + {t(KEY.common_edit)} }} + element={} />} + /> + + {/* + Info pages + NOTE: edit/create uses custom views + */} {t(KEY.information_page)} }} element={ - } - /> + } /> } /> + {/* Opening hours, TODO ADD OPENING HOURS PERMISSIONS*/} } + path={ROUTES.frontend.admin_opening_hours} + handle={{ crumb: ({ pathname }: UIMatch) => {t(KEY.common_opening_hours)} }} + element={} />} /> - {/* Specific recruitment */} - {/* TODO ADD PERMISSIONS */} + {/* Closed period */} } - id="recruitment" - loader={recruitmentLoader} handle={{ - crumb: ({ params }: UIMatch, { recruitment }: RecruitmentLoader) => ( - - {recruitment ? dbT(recruitment, 'name') : t(KEY.common_unknown)} - - ), + crumb: () => {t(KEY.command_menu_shortcut_closed)}, }} > } - /> + } /> } - handle={{ - crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, - }} /> } - handle={{ - crumb: ({ pathname }: UIMatch) => {t(KEY.recruitment_recruiter_dashboard)}, - }} + path={ROUTES.frontend.admin_closed_create} + handle={{ crumb: ({ pathname }: UIMatch) => {t(KEY.common_create)} }} + element={ + } /> + } /> {t(KEY.common_edit)} }} element={ } + required={[PERM.SAMFUNDET_CHANGE_CLOSEDPERIOD]} + element={} /> } + /> + + {/* Images */} + } + handle={{ crumb: () => {t(KEY.admin_images_title)} }} + > + } />} + /> + {t(KEY.common_create)} }} + element={} />} + /> + + {/* Saksdokumenter */} + } + handle={{ + crumb: () => {t(KEY.admin_saksdokument)}, + }} + > + } /> + } + /> + ( - - {t(KEY.common_create)} {t(KEY.recruitment_gangs_with_separate_positions)} - - ), + crumb: ({ pathname }: UIMatch) => {t(KEY.common_create)}, }} + element={ + } /> + } /> {t(KEY.common_edit)} }} element={ } + required={[PERM.SAMFUNDET_CHANGE_SAKSDOKUMENT]} + element={} /> } - loader={separatePositionLoader} - handle={{ - crumb: ({ pathname }: UIMatch, { separatePosition }: SeparatePositionLoader) => ( - - {t(KEY.common_edit)} {t(KEY.recruitment_gangs_with_separate_positions)} -{' '} - {separatePosition ? dbT(separatePosition, 'name') : t(KEY.common_unknown)} - - ), - }} /> + + ( + + {t(KEY.common_sulten)} {t(KEY.common_menu)} + + ), + }} + element={} />} + /> + } /> + } + /> + } /> + } + /> + {/* Recruitment */} + } + path={ROUTES.frontend.admin_recruitment} + handle={{ crumb: () => {t(KEY.common_recruitment)} }} + > } - loader={recruitmentLoader} - handle={{ - crumb: ({ pathname }: UIMatch) => ( - {t(KEY.recruitment_show_unprocessed_applicants)} - ), - }} + path={ROUTES.frontend.admin_recruitment} + element={ + } /> + } /> - } - loader={recruitmentLoader} - handle={{ - crumb: ({ pathname }: UIMatch) => ( - {t(KEY.recruitment_show_applicants_without_interview)} - ), - }} + path={ROUTES.frontend.admin_recruitment_overview} + element={ + } /> + } /> } - handle={{ - crumb: ({ pathname }: UIMatch) => {t(KEY.recruitment_applet_room_overview)}, - }} + path={ROUTES.frontend.admin_recruitment_create} + element={ + } /> + } + handle={{ crumb: ({ pathname }: UIMatch) => {t(KEY.common_create)} }} /> - } /> - } /> } />} + path={ROUTES.frontend.admin_recruitment_gang_all_applications} + element={} /> } - loader={recruitmentGangLoader} - handle={{ - crumb: ({ pathname }: UIMatch) => ( - {t(KEY.recruitment_show_applicants_without_interview)} - ), - }} /> } + /> + } + element={} /> } - handle={{ - crumb: ({ pathname }: UIMatch) => ( - {t(KEY.recruitment_applicants_open_to_other_positions)} - ), - }} /> + } + /> + {/* Specific recruitment */} + {/* TODO ADD PERMISSIONS */} } - loader={gangLoader} + id="recruitment" + loader={recruitmentLoader} handle={{ - crumb: ({ params }: UIMatch, { gang }: GangLoader) => ( + crumb: ({ params }: UIMatch, { recruitment }: RecruitmentLoader) => ( - {gang ? dbT(gang, 'name') : t(KEY.common_unknown)} + {recruitment ? dbT(recruitment, 'name') : t(KEY.common_unknown)} ), }} > } />} + path={ROUTES.frontend.admin_recruitment_edit} + element={ + } + /> + } + handle={{ + crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, + }} /> } />} - loader={recruitmentGangLoader} + path={ROUTES.frontend.admin_recruitment_recruiter_dashboard} + element={} handle={{ crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_recruiter_dashboard)} + ), + }} + /> + } + /> + } + handle={{ + crumb: ({ pathname }: UIMatch) => ( + + {t(KEY.common_create)} {t(KEY.recruitment_gangs_with_separate_positions)} + + ), + }} + /> + } + /> + } + loader={separatePositionLoader} + handle={{ + crumb: ({ pathname }: UIMatch, { separatePosition }: SeparatePositionLoader) => ( - {lowerCapitalize(`${t(KEY.common_create)} ${t(KEY.recruitment_position)}`)} + {t(KEY.common_edit)} {t(KEY.recruitment_gangs_with_separate_positions)} -{' '} + {separatePosition ? dbT(separatePosition, 'name') : t(KEY.common_unknown)} ), }} /> - {/* Position */} + } + loader={recruitmentLoader} + handle={{ + crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_show_unprocessed_applicants)} + ), + }} + /> + + } + loader={recruitmentLoader} + handle={{ + crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_show_applicants_without_interview)} + ), + }} + /> + } + handle={{ + crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_applet_room_overview)} + ), + }} + /> + } /> + } /> + } />} + /> + } + loader={recruitmentGangLoader} + handle={{ + crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_show_applicants_without_interview)} + ), + }} + /> + } + /> + } + handle={{ + crumb: ({ pathname }: UIMatch) => ( + {t(KEY.recruitment_applicants_open_to_other_positions)} + ), + }} + /> } - loader={recruitmentGangPositionLoader} + loader={gangLoader} handle={{ - crumb: ({ params }: UIMatch, { position }: RecruitmentLoader & GangLoader & PositionLoader) => ( + crumb: ({ params }: UIMatch, { gang }: GangLoader) => ( - {position ? dbT(position, 'name') : t(KEY.common_unknown)} + {gang ? dbT(gang, 'name') : t(KEY.common_unknown)} ), }} > } />} + path={ROUTES.frontend.admin_recruitment_gang_position_overview} + element={} />} /> } />} - loader={recruitmentGangPositionLoader} + loader={recruitmentGangLoader} handle={{ - crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, + crumb: ({ pathname }: UIMatch) => ( + + {lowerCapitalize(`${t(KEY.common_create)} ${t(KEY.recruitment_position)}`)} + + ), }} /> + {/* Position */} + } + loader={recruitmentGangPositionLoader} + handle={{ + crumb: ({ params }: UIMatch, { position }: RecruitmentLoader & GangLoader & PositionLoader) => ( + + {position ? dbT(position, 'name') : t(KEY.common_unknown)} + + ), + }} + > + } />} + /> + } />} + loader={recruitmentGangPositionLoader} + handle={{ + crumb: ({ pathname }: UIMatch) => {t(KEY.common_edit)}, + }} + /> + + {/* Sulten Admin */} + } /> + } + /> + {/* + Info pages + Custom layout for edit/create + */} + } /> + } + /> + } + /> + } + /> - {/* Sulten Admin */} - } /> - } - /> - {/* - Info pages - Custom layout for edit/create - */} - } /> - } - /> - } - /> - } - /> {/* SULTEN ROUTES From 7e3930d08272c49cdf587d0982f1bc2a8f16d168 Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:11:49 +0100 Subject: [PATCH 03/20] Properly style inputs (#1547) * Properly style inputs * stylelint * Disable no-descending-specificity check for number * Add missing Input types * Add type="text" to remaining uses --- .../src/Components/Input/Input.module.scss | 18 ++++++++++++++++-- frontend/src/Components/Input/Input.tsx | 2 +- .../src/Pages/ComponentPage/ExampleForm.tsx | 2 +- .../components/GangForm/GangForm.tsx | 8 ++++---- .../RecruitmentFormAdminPage.tsx | 4 ++-- .../RecruitmentPositionForm.tsx | 6 +++--- 6 files changed, 27 insertions(+), 13 deletions(-) diff --git a/frontend/src/Components/Input/Input.module.scss b/frontend/src/Components/Input/Input.module.scss index 10c43a170..541b45e61 100644 --- a/frontend/src/Components/Input/Input.module.scss +++ b/frontend/src/Components/Input/Input.module.scss @@ -2,7 +2,18 @@ @import 'src/mixins'; -.input_field { +.input[type="date"], +.input[type="datetime-local"], +.input[type="email"], +.input[type="month"], +.input[type="number"], +.input[type="password"], +.input[type="search"], +.input[type="tel"], +.input[type="text"], +.input[type="time"], +.input[type="url"], +.input[type="week"] { @include rounded-lighter; padding: 0.75rem 1rem; border: 1px solid $grey-35; @@ -17,6 +28,7 @@ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); outline: 1px solid rgba(0, 0, 0, 0.1); } + &.error { border: 1px solid $red; } @@ -30,11 +42,13 @@ } } +/* stylelint-disable no-descending-specificity */ // Hide up and down buttons unless hover/focus -.number_input { +.input[type="number"] { -webkit-appearance: textfield; &:hover, &:focus { -moz-appearance: initial; } } +/* stylelint-enable no-descending-specificity */ diff --git a/frontend/src/Components/Input/Input.tsx b/frontend/src/Components/Input/Input.tsx index 982c25a43..114d9bc71 100644 --- a/frontend/src/Components/Input/Input.tsx +++ b/frontend/src/Components/Input/Input.tsx @@ -12,7 +12,7 @@ export const Input = React.forwardRef( return ( (type === 'number' ? onChange?.(+event.target.value) : onChange?.(event.target.value))} ref={ref} value={value === null ? '' : value} diff --git a/frontend/src/Pages/ComponentPage/ExampleForm.tsx b/frontend/src/Pages/ComponentPage/ExampleForm.tsx index f2aa727c3..bd0c095ca 100644 --- a/frontend/src/Pages/ComponentPage/ExampleForm.tsx +++ b/frontend/src/Pages/ComponentPage/ExampleForm.tsx @@ -61,7 +61,7 @@ export function ExampleForm() { Brukernavn - + diff --git a/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx b/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx index 454ce60b6..488ef50d6 100644 --- a/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx +++ b/frontend/src/PagesAdmin/GangsFormAdminPage/components/GangForm/GangForm.tsx @@ -103,7 +103,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) { {lowerCapitalize(`${t(KEY.common_norwegian)} ${t(KEY.common_name)}`)} - + @@ -116,7 +116,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) { {lowerCapitalize(`${t(KEY.common_english)} ${t(KEY.common_name)}`)} - + @@ -131,7 +131,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) { {lowerCapitalize(t(KEY.admin_gangsadminpage_abbreviation))} - + @@ -144,7 +144,7 @@ export function GangForm({ gang, onSuccess, onError }: Props) { {lowerCapitalize(t(KEY.admin_gangsadminpage_webpage))} - + diff --git a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx index 69f641b58..7e167917f 100644 --- a/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentFormAdminPage/RecruitmentFormAdminPage.tsx @@ -97,7 +97,7 @@ export function RecruitmentFormAdminPage() { {`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`} - + @@ -110,7 +110,7 @@ export function RecruitmentFormAdminPage() { {`${t(KEY.common_name)} ${t(KEY.common_english)}`} - + diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx index 89cb38f72..3b49bb871 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx @@ -116,7 +116,7 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId {`${t(KEY.common_name)} ${t(KEY.common_norwegian)}`} - + @@ -129,7 +129,7 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId {`${t(KEY.common_name)} ${t(KEY.common_english)}`} - + @@ -245,7 +245,7 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId {t(KEY.common_tags)} - + From aea203e39eb98498f2fd8a29b517f6ad936e0808 Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:19:06 +0100 Subject: [PATCH 04/20] Tweak MiniCalendar styling (#1561) * Tweak MiniCalendar styling * Tweak day font size * Make disabled day color darker in dark mode * Make hover bg color different from selected --- .../MiniCalendar/MiniCalendar.module.scss | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/Components/MiniCalendar/MiniCalendar.module.scss b/frontend/src/Components/MiniCalendar/MiniCalendar.module.scss index a1ca6fe6f..2d612aa69 100644 --- a/frontend/src/Components/MiniCalendar/MiniCalendar.module.scss +++ b/frontend/src/Components/MiniCalendar/MiniCalendar.module.scss @@ -5,7 +5,7 @@ .container { padding: 10px; border-radius: 10px; - width: 300px; + width: 260px; } .label { @@ -21,16 +21,17 @@ text-align: center; justify-items: center; align-items: center; + gap: 0.5rem 0; } .days_header { font-weight: 500; font-size: 0.9rem; text-transform: capitalize; - border-bottom: 1px solid $grey-3; padding: 0.25rem 0; user-select: none; color: $grey-2; + margin-bottom: 0.25rem; @include theme-dark { color: $grey-2; @@ -41,21 +42,27 @@ background: none; border: none; font-family: inherit; - font-size: inherit; + font-size: 0.875rem; height: 2rem; width: 2rem; - border-radius: 1.5rem; + border-radius: 0.25rem; color: $grey-0; cursor: pointer; position: relative; + &:not(.disabled_day, .selected_day):hover { + background: $grey-4; + color: $grey-0; + } + @include theme-dark { color: $grey-4; - } - &:not(.disabled_day):hover { - background: $red-samf; - color: $white; + /* stylelint-disable-next-line max-nesting-depth */ + &:not(.disabled_day, .selected_day):hover { + background: $grey-0; + color: $grey-4; + } } } @@ -115,6 +122,6 @@ color: $grey-3; @include theme-dark { - color: $grey-2; + color: $grey-1; } } From dcbe400da24295eb7c53e91b8740460a742d4dbc Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:30:08 +0100 Subject: [PATCH 05/20] Create DatePicker component (#1565) * Begin creating DatePicker component * Place calendar in popover. Normalize styling * Add DatePicker to ExampleForm * Fix lighten sass * Ignore no-invalid-position-at-import-rule --- .../DatePicker/DatePicker.module.scss | 78 +++++++++++++++++++ .../DatePicker/DatePicker.stories.tsx | 20 +++++ .../src/Components/DatePicker/DatePicker.tsx | 76 ++++++++++++++++++ frontend/src/Components/DatePicker/index.ts | 1 + frontend/src/Components/index.ts | 1 + .../src/Pages/ComponentPage/ExampleForm.tsx | 15 ++++ frontend/src/i18n/constants.ts | 1 + frontend/src/i18n/translations.ts | 2 + 8 files changed, 194 insertions(+) create mode 100644 frontend/src/Components/DatePicker/DatePicker.module.scss create mode 100644 frontend/src/Components/DatePicker/DatePicker.stories.tsx create mode 100644 frontend/src/Components/DatePicker/DatePicker.tsx create mode 100644 frontend/src/Components/DatePicker/index.ts diff --git a/frontend/src/Components/DatePicker/DatePicker.module.scss b/frontend/src/Components/DatePicker/DatePicker.module.scss new file mode 100644 index 000000000..3cd9f31c3 --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.module.scss @@ -0,0 +1,78 @@ +@use 'sass:color'; + +/* stylelint-disable-next-line no-invalid-position-at-import-rule */ +@import 'src/constants'; + +/* stylelint-disable-next-line no-invalid-position-at-import-rule */ +@import 'src/mixins'; + +.container { + position: relative; + width: 260px; +} + +.button { + @include rounded-lighter; + display: flex; + gap: 0.25rem; + align-items: center; + width: 100%; + justify-content: flex-start; + font-size: 0.875rem; + padding: 0.75rem 2.5rem 0.75rem 1rem; + color: $black; + cursor: pointer; + margin-top: 0.5em; // Make sure this is the same for all inputs that should be used together + border: 1px solid $grey-35; + background-color: $white; + font-weight: initial; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + transition: background-color 0.15s cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + background-color: $grey-4; + } + + &:focus { + border-color: $grey-3; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); + outline: 1px solid rgba(0, 0, 0, 0.1); + } + + @include theme-dark { + background-color: $theme-dark-input-bg; + color: white; + border-color: $grey-0; + &:focus { + border-color: $grey-1; + outline: 1px solid rgba(255, 255, 255, 0.6); + } + &:hover { + background-color: color.scale($theme-dark-input-bg, $lightness: 8%); + } + } +} + +.popover { + position: absolute; + left: 0; + top: 100%; + margin-top: 4px; + padding: 0.25rem; + background: $white; + border-radius: 0.5rem; + z-index: 100; + //box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.1); + //box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px 4px -2px; + border: 1px solid $grey-35; + + @include theme-dark { + background: $black-1; + border-color: $grey-0; + } +} + +.hidden { + display: none; +} diff --git a/frontend/src/Components/DatePicker/DatePicker.stories.tsx b/frontend/src/Components/DatePicker/DatePicker.stories.tsx new file mode 100644 index 000000000..ecac4211b --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DatePicker } from './DatePicker'; + +// Local component config. +const meta = { + title: 'Components/DatePicker', + component: DatePicker, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const onChange = (value: unknown) => console.log('Selected:', value); + +export const Basic: Story = { + args: { + onChange, + }, +}; diff --git a/frontend/src/Components/DatePicker/DatePicker.tsx b/frontend/src/Components/DatePicker/DatePicker.tsx new file mode 100644 index 000000000..4e6e9c7b9 --- /dev/null +++ b/frontend/src/Components/DatePicker/DatePicker.tsx @@ -0,0 +1,76 @@ +import { Icon } from '@iconify/react'; +import classNames from 'classnames'; +import { format } from 'date-fns'; +import React, { useMemo } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, MiniCalendar } from '~/Components'; +import { useClickOutside } from '~/hooks'; +import { KEY } from '~/i18n/constants'; +import styles from './DatePicker.module.scss'; + +type DatePickerProps = { + label?: string; + disabled?: boolean; + value?: Date | null; + buttonClassName?: string; + onChange?: (date: Date | null) => void; + className?: string; + + minDate?: Date; + maxDate?: Date; +}; + +export function DatePicker({ + value: initialValue, + onChange, + disabled, + label, + buttonClassName, + minDate, + maxDate, +}: DatePickerProps) { + const isControlled = initialValue !== undefined; + + const [date, setDate] = useState(null); + const [open, setOpen] = useState(false); + + const { t } = useTranslation(); + + const clickOutsideRef = useClickOutside(() => setOpen(false)); + + const value = useMemo(() => { + if (isControlled) { + return initialValue; + } + return date; + }, [isControlled, initialValue, date]); + + function handleChange(d: Date | null) { + setDate(d); + onChange?.(d); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/Components/DatePicker/index.ts b/frontend/src/Components/DatePicker/index.ts new file mode 100644 index 000000000..a4eb7f5cd --- /dev/null +++ b/frontend/src/Components/DatePicker/index.ts @@ -0,0 +1 @@ +export { DatePicker } from './DatePicker'; diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index fb401a786..0f4aa98b1 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -12,6 +12,7 @@ export { CommandMenu } from './CommandMenu'; export { ContentCard } from './ContentCard'; export { Countdown } from './Countdown'; export { CrudButtons } from './CrudButtons'; +export { DatePicker } from './DatePicker'; export { Dropdown } from './Dropdown'; export { ErrorDisplay } from './ErrorDisplay'; export { EventCard } from './EventCard'; diff --git a/frontend/src/Pages/ComponentPage/ExampleForm.tsx b/frontend/src/Pages/ComponentPage/ExampleForm.tsx index bd0c095ca..2219e17f4 100644 --- a/frontend/src/Pages/ComponentPage/ExampleForm.tsx +++ b/frontend/src/Pages/ComponentPage/ExampleForm.tsx @@ -5,6 +5,7 @@ import { z } from 'zod'; import { Button, Checkbox, + DatePicker, Dropdown, Form, FormControl, @@ -21,6 +22,7 @@ const schema = z.object({ password: PASSWORD, organization: z.string().nullish().optional(), duration: z.number().min(15).max(60), + date: z.date(), confirm: z.boolean().refine((v) => v, 'Påkrevd'), }); @@ -106,6 +108,19 @@ export function ExampleForm() { )} /> + ( + + Dato + + + + + + )} + /> Date: Tue, 29 Oct 2024 21:36:27 +0100 Subject: [PATCH 06/20] Tweak timeslot button styling (#1562) * Tweak timeslot button styling * Improve dark theme styling * Use slightly darker grey for dark theme color --- .../TimeslotContainer.module.scss | 2 +- .../TimeslotButton/TimeslotButton.module.scss | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss b/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss index 9674f6298..a44ddc21b 100644 --- a/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss +++ b/frontend/src/Components/TimeslotContainer/TimeslotContainer.module.scss @@ -11,5 +11,5 @@ .timeslots { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; + gap: 0.25rem; } diff --git a/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss index 49fc7ce95..f4004c7a5 100644 --- a/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss +++ b/frontend/src/Components/TimeslotContainer/components/TimeslotButton/TimeslotButton.module.scss @@ -5,8 +5,8 @@ .timeslot { padding: 0.5rem 1rem; border-radius: 5px; - border: 1px solid $grey-0; - color: $grey-0; + border: 1px solid $grey-35; + color: inherit; cursor: pointer; background: none; position: relative; @@ -15,6 +15,12 @@ gap: 0.25rem; font-size: 0.9rem; font-weight: 500; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + + @include theme-dark { + border-color: $grey-0; + color: $grey-35; + } } .dot { @@ -23,7 +29,11 @@ height: $size; border-radius: $size; display: inline-block; - background: $grey-0; + border: 1px solid $grey-0; + + @include theme-dark { + border-color: $grey-2; + } } .timeslot_active { @@ -32,6 +42,12 @@ .dot { background: $red; + border-color: $red; + } + + @include theme-dark { + color: $red; + border-color: $red; } } From 03c9f8aaef658f631c542028443697d9e8d29f17 Mon Sep 17 00:00:00 2001 From: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:40:38 +0100 Subject: [PATCH 07/20] add interviewers to form (#1537) * add interviewers to form --- backend/samfundet/serializers.py | 37 +++++++------ .../RecruitmentPositionForm.tsx | 53 +++++++++++++++++-- .../RecruitmentPositionFormAdminPage.tsx | 10 +++- frontend/src/api.ts | 4 +- frontend/src/dto.ts | 10 +++- frontend/src/i18n/constants.ts | 2 + frontend/src/i18n/translations.ts | 4 ++ 7 files changed, 94 insertions(+), 26 deletions(-) diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 8331dda81..94da3771c 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -770,6 +770,7 @@ class RecruitmentPositionSerializer(CustomBaseSerializer): gang = GangSerializer(read_only=True) interviewers = InterviewerSerializer(many=True, read_only=True) + interviewer_ids = serializers.ListField(child=serializers.IntegerField(), write_only=True, required=False) class Meta: model = RecruitmentPosition @@ -779,37 +780,43 @@ def _update_interviewers( self, *, recruitment_position: RecruitmentPosition, - interviewer_objects: list[dict], + interviewer_ids: list[int], ) -> None: - try: - interviewers = [] - if interviewer_objects: - interviewer_ids = [interviewer.get('id') for interviewer in interviewer_objects] - if interviewer_ids: - interviewers = User.objects.filter(id__in=interviewer_ids) - recruitment_position.interviewers.set(interviewers) - except (TypeError, KeyError): - raise ValidationError('Invalid data for interviewers.') from None + if interviewer_ids: + try: + interviewers = User.objects.filter(id__in=interviewer_ids) + found_ids = set(interviewers.values_list('id', flat=True)) + invalid_ids = set(interviewer_ids) - found_ids + + if invalid_ids: + raise ValidationError(f'Invalid interviewer IDs: {invalid_ids}') + + recruitment_position.interviewers.set(interviewers) + except (TypeError, ValueError): + raise ValidationError('Invalid interviewer IDs format.') from None + else: + recruitment_position.interviewers.clear() def validate(self, data: dict) -> dict: - gang_id = self.initial_data.get('gang').get('id') + gang_id = self.initial_data.get('gang', {}).get('id') if gang_id: try: data['gang'] = Gang.objects.get(id=gang_id) except Gang.DoesNotExist: raise serializers.ValidationError('Invalid gang id') from None + + self.interviewer_ids = data.pop('interviewer_ids', []) + return super().validate(data) def create(self, validated_data: dict) -> RecruitmentPosition: recruitment_position = super().create(validated_data) - interviewer_objects = self.initial_data.get('interviewers', []) - self._update_interviewers(recruitment_position=recruitment_position, interviewer_objects=interviewer_objects) + self._update_interviewers(recruitment_position=recruitment_position, interviewer_ids=self.interviewer_ids) return recruitment_position def update(self, instance: RecruitmentPosition, validated_data: dict) -> RecruitmentPosition: updated_instance = super().update(instance, validated_data) - interviewer_objects = self.initial_data.get('interviewers', []) - self._update_interviewers(recruitment_position=updated_instance, interviewer_objects=interviewer_objects) + self._update_interviewers(recruitment_position=updated_instance, interviewer_ids=self.interviewer_ids) return updated_instance def get_total_applicants(self, recruitment_position: RecruitmentPosition) -> int: diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx index 3b49bb871..481dbdfc1 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionForm.tsx @@ -17,7 +17,9 @@ import { Input, Textarea, } from '~/Components'; +import { MultiSelect } from '~/Components/MultiSelect'; import { postRecruitmentPosition, putRecruitmentPosition } from '~/api'; +import type { RecruitmentPositionDto, UserDto } from '~/dto'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; @@ -36,24 +38,25 @@ const schema = z.object({ default_application_letter_nb: NON_EMPTY_STRING, default_application_letter_en: NON_EMPTY_STRING, tags: NON_EMPTY_STRING, + interviewer_ids: z.array(z.number()).optional().nullable(), }); type SchemaType = z.infer; interface FormProps { - initialData: Partial; + initialData: Partial; positionId?: string; recruitmentId?: string; gangId?: string; + users?: Partial; } -export function RecruitmentPositionForm({ initialData, positionId, recruitmentId, gangId }: FormProps) { +export function RecruitmentPositionForm({ initialData, positionId, recruitmentId, gangId, users }: FormProps) { const { t } = useTranslation(); const navigate = useNavigate(); const form = useForm({ resolver: zodResolver(schema), - defaultValues: initialData, }); const submitText = positionId ? t(KEY.common_save) : t(KEY.common_create); @@ -63,7 +66,7 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId ...data, gang: { id: Number.parseInt(gangId ?? '') }, recruitment: recruitmentId ?? '', - interviewers: [], + interviewer_ids: data.interviewer_ids || [], }; const action = positionId @@ -87,9 +90,24 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId }; useEffect(() => { - form.reset(initialData); + form.reset({ + ...initialData, + interviewer_ids: initialData.interviewers?.map((interviewer) => interviewer.id) || [], + }); }, [initialData, form]); + // Convert users array to dropdown options + const interviewerOptions = + users + ?.filter((user) => user?.id && (user?.username || user?.first_name)) + .map((user) => ({ + value: user?.id, + label: user?.username || `${user?.first_name} ${user?.last_name}`, + })) || []; + + // Get currently selected interviewers + const selectedInterviewers = form.watch('interviewer_ids') || []; + return (
@@ -251,6 +269,31 @@ export function RecruitmentPositionForm({ initialData, positionId, recruitmentId )} /> + + ( + + {t(KEY.recruitment_interviewers)} + + option.value && selectedInterviewers.includes(option.value), + )} + onChange={(values) => { + field.onChange(values); + }} + optionsLabel="Available Interviewers" + selectedLabel="Selected Interviewers" + /> + + + + )} + /> + diff --git a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx index b52b53060..0536248ec 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionFormAdminPage/RecruitmentPositionFormAdminPage.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { getRecruitmentPosition } from '~/api'; -import type { RecruitmentPositionDto } from '~/dto'; +import { getRecruitmentPosition, getUsers } from '~/api'; +import type { RecruitmentPositionDto, UserDto } from '~/dto'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; @@ -16,6 +16,7 @@ export function RecruitmentPositionFormAdminPage() { const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); const [position, setPosition] = useState>(); + const [users, setUsers] = useState>(); useEffect(() => { if (positionId) { @@ -34,6 +35,9 @@ export function RecruitmentPositionFormAdminPage() { ); }); } + getUsers().then((data) => { + setUsers(data); + }); }, [positionId, recruitmentId, gangId, navigate, t]); const initialData: Partial = { @@ -48,6 +52,7 @@ export function RecruitmentPositionFormAdminPage() { default_application_letter_nb: position?.default_application_letter_nb || '', default_application_letter_en: position?.default_application_letter_en || '', tags: position?.tags || '', + interviewers: position?.interviewers || [], }; const title = positionId @@ -63,6 +68,7 @@ export function RecruitmentPositionFormAdminPage() { positionId={positionId} recruitmentId={recruitmentId} gangId={gangId} + users={users} /> ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 01a649592..9024107cf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -632,7 +632,7 @@ export async function getRecruitmentPosition(positionId: string): Promise { +export async function postRecruitmentPosition(recruitmentPosition: RecruitmentPositionPostDto): Promise { const url = BACKEND_DOMAIN + ROUTES.backend.samfundet__recruitment_position_list; const response = await axios.post(url, recruitmentPosition, { withCredentials: true }); @@ -641,7 +641,7 @@ export async function postRecruitmentPosition(recruitmentPosition: RecruitmentPo export async function putRecruitmentPosition( positionId: string, - recruitment: Partial, + recruitment: Partial, ): Promise { const url = BACKEND_DOMAIN + diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 427107d77..15b926a4b 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -468,9 +468,15 @@ export type RecruitmentPositionDto = { accepted_applicants?: number; }; -export type RecruitmentPositionPostDto = Omit & { gang: { id: number } }; +export type RecruitmentPositionPostDto = Omit & { + gang: { id: number }; + interviewer_ids?: number[]; +}; -export type RecruitmentPositionPutDto = Omit & { gang: { id: number } }; +export type RecruitmentPositionPutDto = Omit & { + gang: { id: number }; + interviewer_ids?: number[]; +}; export type RecruitmentRecruitmentPositionDto = { id: number; diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index 6c86ddaa5..1c30b926c 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -271,6 +271,8 @@ export const KEY = { recruitment_position: 'recruitment_position', recruitment_positions: 'recruitment_positions', recruitment_applicant: 'recruitment_applicant', + recruitment_interviewer: 'recruitment_interviewer', + recruitment_interviewers: 'recruitment_interviewers', recruitment_interviews: 'recruitment_interviews', recruitment_no_interviews: 'recruitment_no_interviews', recruitment_interview_set: 'recruitment_interview_set', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index 554b8cfde..511ba0c62 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -259,6 +259,8 @@ export const nb = prepareTranslations({ [KEY.recruitment_not_applied]: 'Du har ikke sendt søknader til noen stillinger ennå', [KEY.recruitment_will_be_anonymized]: 'All info relatert til dine søknader vil bli slettet 3 uker etter opptaket', [KEY.recruitment_interviews]: 'Intervjuer', + [KEY.recruitment_interviewer]: 'Intervjuer', + [KEY.recruitment_interviewers]: 'Intervjuere', [KEY.recruitment_no_interviews]: 'Ingen intervjuer', [KEY.recruitment_interview_set]: 'Sett intervju', [KEY.recruitment_interview_groups]: 'Intervjugrupper', @@ -733,6 +735,8 @@ export const en = prepareTranslations({ [KEY.recruitment_will_be_anonymized]: 'All info related to the applications will be anonymized three weeks after the recruitment is over', [KEY.recruitment_interviews]: 'Interviews', + [KEY.recruitment_interviewer]: 'Interviewer', + [KEY.recruitment_interviewers]: 'Interviewers', [KEY.recruitment_no_interviews]: 'No interviews', [KEY.recruitment_interview_set]: 'Set Interview', [KEY.recruitment_interview_groups]: 'Interview groups', From f677cf947ed00d4773cff2fedcd632215ae24ac1 Mon Sep 17 00:00:00 2001 From: Robin <16273164+robines@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:53:45 +0100 Subject: [PATCH 08/20] Allow deselecting date in MiniCalendar (#1564) --- frontend/src/Components/MiniCalendar/MiniCalendar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/Components/MiniCalendar/MiniCalendar.tsx b/frontend/src/Components/MiniCalendar/MiniCalendar.tsx index ab30a15d1..eec6e4ac1 100644 --- a/frontend/src/Components/MiniCalendar/MiniCalendar.tsx +++ b/frontend/src/Components/MiniCalendar/MiniCalendar.tsx @@ -129,8 +129,10 @@ export function MiniCalendar({ [styles.selected_day]: isSelected, })} onClick={() => { - onChange?.(d); - setSelectedDate(d); + // If we click the currently selected date, deselect it + const newDate = isSelected ? null : d; + onChange?.(newDate); + setSelectedDate(newDate); }} disabled={!valid} > From 294d046d1884576548c6b9a1f68b87e2406646a1 Mon Sep 17 00:00:00 2001 From: amaliejvik <125203980+amaliejvik@users.noreply.github.com> Date: Tue, 29 Oct 2024 21:58:26 +0100 Subject: [PATCH 09/20] 1468 improved styling and layout (#1571) * styling: make button in setinterviewmanuallymodal nicer Related to #1468 * styling: enhance styling on RecruitmentApplicantStatus table in RecruitmentPositionOverviewPage Related to #1468 * fix stylelint Related to #1469 * fix: remove changing font size of Text component Related to #1468 --- .../RecruitmentApplicantsStatus.module.scss | 24 +++++---- .../RecruitmentApplicantsStatus.tsx | 54 ++++++++++--------- .../SetInterviewManually.module.scss | 6 +++ .../SetInterviewManuallyModal.tsx | 7 ++- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss index b5e9957fa..4fafd89eb 100644 --- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss +++ b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss @@ -23,25 +23,26 @@ .interviewField { display: flex; justify-content: center; - background-color: $pending; + background-color: $white; } .header { - font-size: 0.7em; - background-color: $grey-3; - color: $black; + font-size: 1.0em; + background-color: $red-samf; + padding: 0.5em 0.8em; + color: $white; &:hover { - background-color: $grey-2; - color: $black; + background-color: $red_samf_hover; + color: $white; } } .rows { - padding: 0.1em 0.2em; + padding: 0.2em 0.8em; } .pending { - background-color: $pending; + background-color: $white; } .top_reserve { @@ -82,6 +83,11 @@ } .tableRow { - border-color: $grey-3; + border-color: $grey-35; border-width: 0.01em; } + +.crud { + display: flex; + justify-content: center; +} diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx index 11cfe544b..e9f008802 100644 --- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx +++ b/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx @@ -57,13 +57,13 @@ export function RecruitmentApplicantsStatus({ const navigate = useCustomNavigate(); const tableColumns = [ - { content: t(KEY.recruitment_applicant), sortable: true, hideSortButton: true }, - { content: t(KEY.recruitment_priority), sortable: true, hideSortButton: true }, + { content: t(KEY.recruitment_applicant), sortable: true, hideSortButton: false }, + { content: t(KEY.recruitment_priority), sortable: true, hideSortButton: false }, { content: t(KEY.recruitment_interview_set), sortable: false, hideSortButton: true }, - { content: t(KEY.recruitment_interview_time), sortable: true, hideSortButton: true }, - { content: t(KEY.recruitment_interview_location), sortable: true, hideSortButton: true }, - { content: t(KEY.recruitment_recruiter_priority), sortable: true, hideSortButton: true }, - { content: t(KEY.recruitment_recruiter_status), sortable: true, hideSortButton: true }, + { content: t(KEY.recruitment_interview_time), sortable: true, hideSortButton: false }, + { content: t(KEY.recruitment_interview_location), sortable: true, hideSortButton: false }, + { content: t(KEY.recruitment_recruiter_priority), sortable: true, hideSortButton: false }, + { content: t(KEY.recruitment_recruiter_status), sortable: true, hideSortButton: false }, { content: t(KEY.recruitment_interview_notes), sortable: false, hideSortButton: true }, ]; @@ -131,7 +131,7 @@ export function RecruitmentApplicantsStatus({ content: ( @@ -186,25 +186,27 @@ export function RecruitmentApplicantsStatus({ { style: applicationStatusStyle, content: ( - { - navigate({ - url: reverse({ - pattern: ROUTES.frontend.admin_recruitment_gang_position_applicants_interview_notes, - urlParams: { - recruitmentId: recruitmentId, - gangId: gangId, - positionId: positionId, - interviewId: application.interview?.id, - }, - }), - }); - } - : undefined - } - /> +
+ { + navigate({ + url: reverse({ + pattern: ROUTES.frontend.admin_recruitment_gang_position_applicants_interview_notes, + urlParams: { + recruitmentId: recruitmentId, + gangId: gangId, + positionId: positionId, + interviewId: application.interview?.id, + }, + }), + }); + } + : undefined + } + /> +
), }, ], diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss index dbcbdb6ae..db6fcac59 100644 --- a/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss +++ b/frontend/src/Components/SetInterviewManually/SetInterviewManually.module.scss @@ -9,6 +9,12 @@ padding: 0.5em; } +.set_interview_button { + width: 94%; + font-size: small; + padding: 8px 17px; +} + .date_container { display: grid; grid-template-columns: repeat(2, 1fr); diff --git a/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx index bc5142eb6..ba021575b 100644 --- a/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx +++ b/frontend/src/Components/SetInterviewManually/SetInterviewManuallyModal.tsx @@ -26,7 +26,12 @@ export function SetInterviewManuallyModal({ return ( <> - From dc91bf4c41ace7d466b3554447c6116567d07bd1 Mon Sep 17 00:00:00 2001 From: Mathias Aas <54811233+Mathias-a@users.noreply.github.com> Date: Tue, 29 Oct 2024 22:17:25 +0100 Subject: [PATCH 10/20] Create dynamic outlets for recruitment routes (#1572) * Create dynamic outlets for recruitment routes --- .../DynamicOrgOutlet/DynamicOrgOutlet.tsx | 26 +++++++++++++++++++ .../ErrorDisplay/ErrorDisplay.stories.tsx | 1 - .../Components/IsfitOutlet/IsfitNavbar.tsx | 9 +++++++ .../IsfitOutlet/IsfitOutlet.module.scss | 3 +++ .../Components/IsfitOutlet/IsfitOutlet.tsx | 15 +++++++++++ frontend/src/Components/IsfitOutlet/index.ts | 1 + .../src/Components/SamfOutlet/SamfOutlet.tsx | 13 +++++++--- .../src/Components/UkaOutlet/UkaNavbar.tsx | 9 +++++++ .../UkaOutlet/UkaOutlet.module.scss | 3 +++ .../src/Components/UkaOutlet/UkaOutlet.tsx | 15 +++++++++++ frontend/src/Components/UkaOutlet/index.ts | 1 + frontend/src/Components/index.ts | 2 ++ frontend/src/router/router.tsx | 20 ++++++++------ frontend/src/utils.ts | 4 +++ 14 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 frontend/src/Components/DynamicOrgOutlet/DynamicOrgOutlet.tsx create mode 100644 frontend/src/Components/IsfitOutlet/IsfitNavbar.tsx create mode 100644 frontend/src/Components/IsfitOutlet/IsfitOutlet.module.scss create mode 100644 frontend/src/Components/IsfitOutlet/IsfitOutlet.tsx create mode 100644 frontend/src/Components/IsfitOutlet/index.ts create mode 100644 frontend/src/Components/UkaOutlet/UkaNavbar.tsx create mode 100644 frontend/src/Components/UkaOutlet/UkaOutlet.module.scss create mode 100644 frontend/src/Components/UkaOutlet/UkaOutlet.tsx create mode 100644 frontend/src/Components/UkaOutlet/index.ts diff --git a/frontend/src/Components/DynamicOrgOutlet/DynamicOrgOutlet.tsx b/frontend/src/Components/DynamicOrgOutlet/DynamicOrgOutlet.tsx new file mode 100644 index 000000000..88eaf98bc --- /dev/null +++ b/frontend/src/Components/DynamicOrgOutlet/DynamicOrgOutlet.tsx @@ -0,0 +1,26 @@ +import { useRouteLoaderData } from 'react-router-dom'; +import type { RecruitmentLoader } from '~/router/loaders'; +import { OrgNameType } from '~/types'; +import { IsNumber } from '~/utils'; +import { IsfitOutlet } from '../IsfitOutlet/IsfitOutlet'; +import { SamfOutlet } from '../SamfOutlet/SamfOutlet'; +import { UkaOutlet } from '../UkaOutlet'; + +export function DynamicOrgOutlet() { + const data = useRouteLoaderData('publicRecruitment') as RecruitmentLoader | undefined; + + if (IsNumber(data?.recruitment?.organization)) { + // TODO: This should either check for org, or typing should be fixed + return ; + } + + if (data?.recruitment?.organization.name === OrgNameType.ISFIT_NAME) { + return ; + } + + if (data?.recruitment?.organization.name === OrgNameType.UKA_NAME) { + return ; + } + + return ; +} diff --git a/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx b/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx index 5ca655371..12c1c508e 100644 --- a/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx +++ b/frontend/src/Components/ErrorDisplay/ErrorDisplay.stories.tsx @@ -1,5 +1,4 @@ import type { ComponentStory } from '@storybook/react'; -// biome-ignore lint/suspicious/noShadowRestrictedNames: import { ErrorDisplay } from './ErrorDisplay'; export default { diff --git a/frontend/src/Components/IsfitOutlet/IsfitNavbar.tsx b/frontend/src/Components/IsfitOutlet/IsfitNavbar.tsx new file mode 100644 index 000000000..703e2dd3a --- /dev/null +++ b/frontend/src/Components/IsfitOutlet/IsfitNavbar.tsx @@ -0,0 +1,9 @@ +import { IsfitLogo } from '../Logo/components'; + +export function IsfitNavbar() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/Components/IsfitOutlet/IsfitOutlet.module.scss b/frontend/src/Components/IsfitOutlet/IsfitOutlet.module.scss new file mode 100644 index 000000000..c7d2f84fe --- /dev/null +++ b/frontend/src/Components/IsfitOutlet/IsfitOutlet.module.scss @@ -0,0 +1,3 @@ +.deleteme { + color: red; +} diff --git a/frontend/src/Components/IsfitOutlet/IsfitOutlet.tsx b/frontend/src/Components/IsfitOutlet/IsfitOutlet.tsx new file mode 100644 index 000000000..c1a7cd52d --- /dev/null +++ b/frontend/src/Components/IsfitOutlet/IsfitOutlet.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import { IsfitNavbar } from './IsfitNavbar'; +import styles from './IsfitOutlet.module.scss'; + +export function IsfitOutlet() { + return ( + <> + +
+ +
+ {/*