diff --git a/backend/root/management/commands/seed_scripts/__init__.py b/backend/root/management/commands/seed_scripts/__init__.py index 820e79bc5..be8025b4a 100755 --- a/backend/root/management/commands/seed_scripts/__init__.py +++ b/backend/root/management/commands/seed_scripts/__init__.py @@ -24,6 +24,7 @@ recruitment_separate_position, recruitment_interviewavailability, recruitment_position_interviewers, + recruitment_sharedinterviewgroups, ) # Insert seed scripts here (in order of priority) @@ -50,6 +51,7 @@ ('merch', merch.seed), ('recruitment', recruitment.seed), ('recruitment_position', recruitment_position.seed), + ('recruitment_position_shared_interview', recruitment_sharedinterviewgroups.seed), ('recruitment_interviewavailability', recruitment_interviewavailability.seed), ('recruitment_separate_position', recruitment_separate_position.seed), ('recruitment_applications', recruitment_applications.seed), diff --git a/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py b/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py new file mode 100644 index 000000000..f424a3515 --- /dev/null +++ b/backend/root/management/commands/seed_scripts/recruitment_sharedinterviewgroups.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import random + +from root.utils.samfundet_random import words + +from samfundet.models.recruitment import Recruitment, RecruitmentPosition, RecruitmentPositionSharedInterviewGroup + + +def seed(): + yield 0, 'recruitment_positions_shared_interview' + RecruitmentPositionSharedInterviewGroup.objects.all().delete() + yield 0, 'Deleted old recruitmentpositionsharedgroup' + + recruitments = Recruitment.objects.all() + created_count = 0 + for recruitment in recruitments: + for i in range(3): + RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=recruitment, name_nb=f'{words(2)} {i}', name_en=f'{words(2)} {i}') + created_count += 1 + shared_groups = list(RecruitmentPositionSharedInterviewGroup.objects.filter(recruitment=recruitment)) + positions = random.sample(list(RecruitmentPosition.objects.filter(recruitment=recruitment)), 6) + for pos in positions: + pos.shared_interview_group = random.choice(shared_groups) + pos.save() + + yield 100, f'Created {created_count} recruitment_position_shared_groups' diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index dc9c7cff4..74cc0fc7e 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -391,6 +391,15 @@ admin__samfundet_recruitmentapplication_delete = 'admin:samfundet_recruitmentapplication_delete' admin__samfundet_recruitmentapplication_change = 'admin:samfundet_recruitmentapplication_change' adminsamfundetrecruitmentapplication__objectId = '' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_user = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_user' +admin__samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_group = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_permissions_manage_group' +admin__samfundet_recruitmentpositionsharedinterviewgroup_changelist = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_changelist' +admin__samfundet_recruitmentpositionsharedinterviewgroup_add = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_add' +admin__samfundet_recruitmentpositionsharedinterviewgroup_history = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_history' +admin__samfundet_recruitmentpositionsharedinterviewgroup_delete = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_delete' +admin__samfundet_recruitmentpositionsharedinterviewgroup_change = 'admin:samfundet_recruitmentpositionsharedinterviewgroup_change' +adminsamfundetrecruitmentpositionsharedinterviewgroup__objectId = '' admin__samfundet_organization_permissions = 'admin:samfundet_organization_permissions' admin__samfundet_organization_permissions_manage_user = 'admin:samfundet_organization_permissions_manage_user' admin__samfundet_organization_permissions_manage_group = 'admin:samfundet_organization_permissions_manage_group' @@ -502,6 +511,8 @@ samfundet__saksdokument_detail = 'samfundet:saksdokument-detail' samfundet__profile_list = 'samfundet:profile-list' samfundet__profile_detail = 'samfundet:profile-detail' +samfundet__permissions_list = 'samfundet:permissions-list' +samfundet__permissions_detail = 'samfundet:permissions-detail' samfundet__menu_list = 'samfundet:menu-list' samfundet__menu_detail = 'samfundet:menu-detail' samfundet__menu_items_list = 'samfundet:menu_items-list' @@ -575,6 +586,7 @@ samfundet__recruitment_positions = 'samfundet:recruitment_positions' samfundet__recruitment_show_unprocessed_applicants = 'samfundet:recruitment_show_unprocessed_applicants' samfundet__recruitment_positions_gang_for_applicants = 'samfundet:recruitment_positions_gang_for_applicants' +samfundet__recruitment_shared_interviews = 'samfundet:recruitment_shared_interviews' samfundet__recruitment_positions_gang_for_gangs = 'samfundet:recruitment_positions_gang_for_gangs' samfundet__recruitment_set_interview = 'samfundet:recruitment_set_interview' samfundet__recruitment_application_states_choices = 'samfundet:recruitment_application_states_choices' diff --git a/backend/samfundet/admin.py b/backend/samfundet/admin.py index 3df2c2de2..a29e3de55 100644 --- a/backend/samfundet/admin.py +++ b/backend/samfundet/admin.py @@ -63,6 +63,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) # Common fields: @@ -715,6 +716,28 @@ class RecruitmentApplicationAdmin(CustomBaseAdmin): list_select_related = True +@admin.register(RecruitmentPositionSharedInterviewGroup) +class RecruitmentPositionSharedInterviewGroupAdmin(CustomBaseAdmin): + sortable_by = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + list_display = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + search_fields = [ + 'recruitment', + 'name_en', + 'name_nb', + '__str__', + ] + + @admin.register(Organization) class OrganizationAdmin(CustomBaseAdmin): sortable_by = ['id', 'name'] diff --git a/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py b/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py new file mode 100644 index 000000000..51dd8d85e --- /dev/null +++ b/backend/samfundet/migrations/0009_recruitmentpositionsharedinterviewgroup_name_en_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.1 on 2024-10-29 17:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("samfundet", "0008_event_entrance_en_event_entrance_nb"), + ] + + operations = [ + migrations.AddField( + model_name="recruitmentpositionsharedinterviewgroup", + name="name_en", + field=models.CharField( + default="hi", + help_text="Name of the recruitmentgroup (EN)", + max_length=100, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="recruitmentpositionsharedinterviewgroup", + name="name_nb", + field=models.CharField( + default="hi", + help_text="Name of the recruitmentgroup (NB)", + max_length=100, + ), + preserve_default=False, + ), + ] diff --git a/backend/samfundet/migrations/0010_recruitment_promo_media.py b/backend/samfundet/migrations/0010_recruitment_promo_media.py new file mode 100644 index 000000000..8aa5e3431 --- /dev/null +++ b/backend/samfundet/migrations/0010_recruitment_promo_media.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.1 on 2024-10-31 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0009_recruitmentpositionsharedinterviewgroup_name_en_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recruitment', + name='promo_media', + field=models.CharField(blank=True, default=None, help_text='Youtube video id', max_length=11, null=True), + ), + ] diff --git a/backend/samfundet/models/recruitment.py b/backend/samfundet/models/recruitment.py index 2df6249d9..514fcda9d 100644 --- a/backend/samfundet/models/recruitment.py +++ b/backend/samfundet/models/recruitment.py @@ -31,6 +31,7 @@ class Recruitment(CustomBaseModel): organization = models.ForeignKey(null=False, blank=False, to=Organization, on_delete=models.CASCADE, help_text='The organization that is recruiting') max_applications = models.PositiveIntegerField(null=True, blank=True, verbose_name='Max applications per applicant') + promo_media = models.CharField(max_length=11, help_text='Youtube video id', null=True, default=None, blank=True) def resolve_org(self, *, return_id: bool = False) -> Organization | int: if return_id: @@ -115,8 +116,11 @@ class RecruitmentPositionSharedInterviewGroup(CustomBaseModel): blank=True, ) + name_nb = models.CharField(max_length=100, null=False, blank=False, help_text='Name of the recruitmentgroup (NB)') + name_en = models.CharField(max_length=100, null=False, blank=False, help_text='Name of the recruitmentgroup (EN)') + def __str__(self) -> str: - return f'{self.recruitment} Interviewgroup {self.id}' + return f'{self.recruitment} Interviewgroup {self.name_nb} {", ".join(list(self.positions.values_list("name_nb", flat=True)))}' class RecruitmentPosition(CustomBaseModel): @@ -261,6 +265,7 @@ class Interview(CustomBaseModel): help_text='Room where the interview is held', related_name='interviews', ) + interviewers = models.ManyToManyField(to='samfundet.User', help_text='Interviewers for this interview', blank=True, related_name='interviews') notes = models.TextField(help_text='Notes for the interview', null=True, blank=True) diff --git a/backend/samfundet/models/tests/test_recruitment.py b/backend/samfundet/models/tests/test_recruitment.py index 392ed364b..5a754d239 100644 --- a/backend/samfundet/models/tests/test_recruitment.py +++ b/backend/samfundet/models/tests/test_recruitment.py @@ -248,7 +248,7 @@ def test_interview_group_autoadd_on_create( assert fixture_recruitment_position2.shared_interview_group is None # setup interview group - shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment) + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment, name_en='name', name_nb='navn') fixture_recruitment_position.shared_interview_group = shared_group fixture_recruitment_position2.shared_interview_group = shared_group fixture_recruitment_position.save() @@ -288,7 +288,7 @@ def test_interview_group_autoset_on_set( assert fixture_recruitment_application2.recruitment_position == fixture_recruitment_position2 # setup interview group - shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment) + shared_group = RecruitmentPositionSharedInterviewGroup.objects.create(recruitment=fixture_recruitment, name_en='name', name_nb='navn') fixture_recruitment_position.shared_interview_group = shared_group fixture_recruitment_position2.shared_interview_group = shared_group fixture_recruitment_position.save() diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index d900e7ae7..3e8488380 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import itertools from typing import TYPE_CHECKING from collections import defaultdict @@ -64,6 +65,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices @@ -738,11 +740,22 @@ class Meta: class RecruitmentSerializer(CustomBaseSerializer): separate_positions = RecruitmentSeparatePositionSerializer(many=True, read_only=True) + promo_media = serializers.CharField(max_length=100, allow_blank=True, allow_null=True) class Meta: model = Recruitment fields = '__all__' + def validate_promo_media(self, value: str | None) -> str | None: + if value is None or value == '': + return None + match = re.search(r'(youtu.*be.*)\/(watch\?v=|embed\/|v|shorts|)(.*?((?=[&#?])|$))', value) + if match: + return match.group(3) + if len(value) == 11: + return value + raise ValidationError('Invalid youtube url') + def to_representation(self, instance: Recruitment) -> dict: data = super().to_representation(instance) data['organization'] = OrganizationSerializer(instance.organization).data @@ -769,6 +782,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 @@ -778,37 +792,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: @@ -858,6 +878,20 @@ class Meta: ] +class RecruitmentPositionSharedInterviewGroupSerializer(serializers.ModelSerializer): + positions = RecruitmentPositionForApplicantSerializer(many=True, read_only=True) + + class Meta: + model = RecruitmentPositionSharedInterviewGroup + fields = [ + 'id', + 'recruitment', + 'positions', + 'name_en', + 'name_nb', + ] + + class RecruitmentApplicationForApplicantSerializer(CustomBaseSerializer): interview = ApplicantInterviewSerializer(read_only=True) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index a0246ae45..3761d75c3 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -88,6 +88,11 @@ views.RecruitmentPositionsPerGangForApplicantView.as_view(), name='recruitment_positions_gang_for_applicants', ), + path( + 'recruitment-shared-interview-groups//', + views.RecruitmentInterviewGroupView.as_view(), + name='recruitment_shared_interviews', + ), path('recruitment-positions-gang-for-gangs/', views.RecruitmentPositionsPerGangForGangView.as_view(), name='recruitment_positions_gang_for_gangs'), path('recruitment-set-interview//', views.RecruitmentApplicationSetInterviewView.as_view(), name='recruitment_set_interview'), path( diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 5d1d4aa9a..875f80229 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -93,6 +93,7 @@ RecruitmentApplicationForRecruiterSerializer, RecruitmentApplicationUpdateForGangSerializer, RecruitmentShowUnprocessedApplicationsSerializer, + RecruitmentPositionSharedInterviewGroupSerializer, ) from .models.event import ( Event, @@ -137,6 +138,7 @@ RecruitmentApplication, RecruitmentSeparatePosition, RecruitmentInterviewAvailability, + RecruitmentPositionSharedInterviewGroup, ) from .models.model_choices import RecruitmentStatusChoices, RecruitmentPriorityChoices @@ -1109,6 +1111,20 @@ def get_queryset(self) -> Response: return Recruitment.objects.filter(visible_from__lte=timezone.now(), actual_application_deadline__gte=timezone.now()) +class RecruitmentInterviewGroupView(APIView): + permission_classes = [IsAuthenticated] + + def get( + self, + request: Request, + recruitment_id: int, + ) -> HttpResponse: + recruitment = get_object_or_404(Recruitment, id=recruitment_id) + interview_groups = RecruitmentPositionSharedInterviewGroup.objects.filter(recruitment=recruitment) + + return Response(data=RecruitmentPositionSharedInterviewGroupSerializer(interview_groups, many=True).data, status=status.HTTP_200_OK) + + class DownloadRecruitmentApplicationGangCSV(APIView): permission_classes = [IsAuthenticated] diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc index f21a2335d..74d014423 100644 --- a/frontend/.stylelintrc +++ b/frontend/.stylelintrc @@ -80,7 +80,7 @@ "declaration-block-single-line-max-declarations": null, "declaration-property-max-values": null, "max-nesting-depth": [ - 1, + 2, { "ignoreAtRules": [""], "ignore": ["pseudo-classes", "blockless-at-rules"] diff --git a/frontend/src/Components/Carousel/Carousel.module.scss b/frontend/src/Components/Carousel/Carousel.module.scss index 3c1f6d60e..05626f115 100644 --- a/frontend/src/Components/Carousel/Carousel.module.scss +++ b/frontend/src/Components/Carousel/Carousel.module.scss @@ -74,11 +74,11 @@ transition: 0.2s; cursor: pointer; - /* stylelint-disable-next-line selector-max-class, max-nesting-depth */ + /* stylelint-disable-next-line selector-max-class */ &.left { left: 1em; } - /* stylelint-disable-next-line selector-max-class, max-nesting-depth */ + /* stylelint-disable-next-line selector-max-class */ &.right { right: 3em; } diff --git a/frontend/src/Components/CommandMenu/CommandMenu.scss b/frontend/src/Components/CommandMenu/CommandMenu.scss index 7b3e02660..b5fbc994b 100644 --- a/frontend/src/Components/CommandMenu/CommandMenu.scss +++ b/frontend/src/Components/CommandMenu/CommandMenu.scss @@ -1,6 +1,5 @@ /* stylelint-disable selector-max-compound-selectors */ /* stylelint-disable selector-max-combinators */ -/* stylelint-disable max-nesting-depth */ @import 'src/constants'; @import 'src/mixins'; 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/Dropdown/Dropdown.module.scss b/frontend/src/Components/Dropdown/Dropdown.module.scss index 99a3ed5b1..aba01c1e6 100644 --- a/frontend/src/Components/Dropdown/Dropdown.module.scss +++ b/frontend/src/Components/Dropdown/Dropdown.module.scss @@ -2,6 +2,10 @@ @import 'src/mixins'; +.italic { + font-style: italic; +} + // label som wrapper .select_wrapper { display: flex; diff --git a/frontend/src/Components/Dropdown/Dropdown.stories.tsx b/frontend/src/Components/Dropdown/Dropdown.stories.tsx index 933f471c6..efa82ab29 100644 --- a/frontend/src/Components/Dropdown/Dropdown.stories.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.stories.tsx @@ -1,42 +1,74 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory, Meta, StoryObj } from '@storybook/react'; import { Dropdown } from './Dropdown'; // Local component config. -export default { +const meta = { title: 'Components/Dropdown', component: Dropdown, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const options = [ + { label: 'Apple', value: 'apple' }, + { label: 'Banana', value: 'banana' }, + { label: 'Orange', value: 'orange' }, + { label: 'Mango', value: 'mango' }, +]; + +const onChange = (value: unknown) => console.log('Selected:', value); + +// Basic uncontrolled dropdown +export const Basic: Story = { + args: { + options, + onChange, + }, +}; + +// With default value +export const WithDefaultValue: Story = { + args: { + options, + defaultValue: 'banana', + onChange, + }, +}; + +// With null option +export const WithNullOption: Story = { args: { - name: 'name', - label: 'Choose option', + options, + nullOption: true, + onChange, }, -} as ComponentMeta; +}; -const Template: ComponentStory = (args) => ; +// With custom null option +export const WithCustomNullOption: Story = { + args: { + options, + nullOption: { label: 'Select a fruit...', disabled: false }, + onChange, + }, +}; -export const Basic = Template.bind({}); -Basic.args = { - options: [ - { label: 'alternativ 1', value: 1 }, - { label: 'alternativ 2', value: 2 }, - ], +// With disabled null option (can't reselect null after choosing a value) +export const WithDisabledNullOption: Story = { + args: { + options, + nullOption: { label: 'Select a fruit...', disabled: true }, + onChange, + }, }; -export const Many = Template.bind({}); -Many.args = { - options: [ - { label: 'alternativ 1', value: 1 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - { label: 'alternativ 2', value: 2 }, - ], + +// With label +export const WithLabel: Story = { + args: { + options, + label: 'Favorite Fruit', + onChange, + }, }; diff --git a/frontend/src/Components/Dropdown/Dropdown.tsx b/frontend/src/Components/Dropdown/Dropdown.tsx index 5a8ffc68e..8be044f1e 100644 --- a/frontend/src/Components/Dropdown/Dropdown.tsx +++ b/frontend/src/Components/Dropdown/Dropdown.tsx @@ -1,26 +1,43 @@ import { Icon } from '@iconify/react'; import { default as classNames, default as classnames } from 'classnames'; -import React, { type ChangeEvent, type ReactElement } from 'react'; +import React, { type ChangeEvent, type ReactNode, useMemo } from 'react'; import styles from './Dropdown.module.scss'; -export type DropDownOption = { +export type DropdownOption = { label: string; value: T; + disabled?: boolean; +}; + +type NullOption = { + label: string; + disabled?: boolean; }; -export type DropdownProps = { +type PrimitiveDropdownProps = { className?: string; classNameSelect?: string; - defaultValue?: DropDownOption; // issue 1089 - value?: T; - disableIcon?: boolean; - options?: DropDownOption[]; - label?: string | ReactElement; + options?: DropdownOption[]; + label?: string | ReactNode; disabled?: boolean; error?: boolean; - onChange?: (value?: T) => void; + disableIcon?: boolean; + nullOption?: boolean | NullOption; + onChange?: (value: T) => void; +}; + +type ControlledDropdownProps = PrimitiveDropdownProps & { + value: T | null; + defaultValue?: never; +}; + +type UncontrolledDropdownProps = PrimitiveDropdownProps & { + value?: never; + defaultValue?: T | null; }; +export type DropdownProps = ControlledDropdownProps | UncontrolledDropdownProps; + function DropdownInner( { options = [], @@ -32,23 +49,45 @@ function DropdownInner( label, disabled = false, disableIcon = false, + nullOption = false, error, }: DropdownProps, ref: React.Ref, ) { - /** - * Handles the raw change event from