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 20dbd2037..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: diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index a1b38a841..c794ee481 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 @@ -739,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 @@ -770,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 @@ -779,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: 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