diff --git a/.vscode/extensions.json b/.vscode/extensions.json index c1843a2c0..d93c29e5e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -14,6 +14,7 @@ "yzhang.markdown-all-in-one", "stylelint.vscode-stylelint", "ms-python.mypy-type-checker", - "visualstudioexptteam.vscodeintellicode" + "visualstudioexptteam.vscodeintellicode", + "ms-vscode-remote.vscode-remote-extensionpack" ] } diff --git a/README.md b/README.md index 912135fd4..949710e1c 100644 --- a/README.md +++ b/README.md @@ -2,55 +2,58 @@ -## Documentation +## Introduction -- **[Technical Documentation](./docs/technical/README.md)** -- [Work Methodology](./docs/work-methodology.md) -- [Useful Commands](./docs/useful-commands.md) -- [Technologies used on Samf4 🤖](./docs/technical/Samf4Tech.md) -- [Project Specific Commands](./docs/docker-project-specific-commands.md) -- [Useful Docker aliases](./docs/docker-project-specific-commands.md) -- [🌐 API documentation](./docs/api-docs.md) +Samfundet4 is the latest and greatest iteration of samfundet.no. It's built using Django and React. + + +## Documentation Overview + +> [!TIP] +> If you're new, start by going through the [Introduction to Samfundet4](./docs/introduction.md) guide. -## Installation +### Frontend -We have a script that handles all installation for you. To run the script, a Github Personal Access Token (PAT) is required. -You can make one here https://github.com/settings/tokens/new. Tick scopes `repo`, `read:org` and `admin:public_key`), -then store the token somewhere safe (Github will never show it again). +- [Creating react components (conventions)](./docs/technical/frontend/components.md) +- [Forms and schemas](./docs/technical/frontend/forms.md) + - [*Deprecated: SamfForm*](./docs/technical/frontend/samfform.md) +- [Cypress Setup Documentation](./docs/technical/frontend/cypress.md) +- [Data fetching and State management](./docs/technical/frontend/data-fetching.md) -Copy these commands (press button on the right-hand side of the block) -and run from the directory you would clone the project. +### Backend -```sh -# Interactive -read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=y /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; -``` +- [🌐 API documentation](./docs/api-docs.md) +- [Billig (payment system)](./docs/technical/backend/billig.md) +- [Seed scripts](./docs/technical/backend/seed.md) +- [Role system](./docs/technical/backend/rolesystem.md) + +### Other + +- [Automatic Interview Scheduling](./docs/intervew-scheduling.md) + +### Workflow -
-Non-interactive (show/hide) +- [Work Methodology](./docs/work-methodology.md) + - How to contribute to the project +- [Useful Commands](./docs/useful-commands.md) +- [Useful Docker aliases](./docs/docker-project-specific-commands.md) +- [Common error messages](./docs/common-errors.md) -```sh -# Non-interactive -read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=n /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; -``` +### Pipelines & Deployment - -
+- [Pipeline (mypy, Biome, tsc, ...)](./docs/technical/pipeline.md) -
-Flags explained (show/hide) +### Install -> - X_INTERACTIVE (y/n): determines how many prompts you receive before performing an action. -> curl: -> - -f: fail fast -> - -s: silent, no progress-meter -> - -S: show error on fail -> - -L: follow redirect +- Linux: [Docker](./docs/install/linux-docker.md) – [Native](./docs/install/linux-native.md) +- MacOS: [Docker](./docs/install/mac-docker.md) – [Native](./docs/install/mac-native.md) +- Windows: [Docker](./docs/install/windows-docker.md) – [WSL](./docs/install/windows-wsl.md) +- [Install script](./docs/install/install-script.md) +- [Post-install instructions](./docs/install/post-install.md) -
+### Editor configuration -
-
-
+* [JetBrains (WebStorm, PyCharm, etc...)](./docs/editors/jetbrains.md) +* [VS Code](./docs/editors/vscode.md) +* [Vim/Neovim](./docs/editors/vim.md) +* [Emacs](./docs/editors/emacs.md) diff --git a/backend/root/utils/routes.py b/backend/root/utils/routes.py index c9d4702b5..c851a18b2 100644 --- a/backend/root/utils/routes.py +++ b/backend/root/utils/routes.py @@ -540,6 +540,7 @@ samfundet__merch_detail = 'samfundet:merch-detail' samfundet__role_list = 'samfundet:role-list' samfundet__role_detail = 'samfundet:role-detail' +samfundet__role_users = 'samfundet:role-users' samfundet__recruitment_list = 'samfundet:recruitment-list' samfundet__recruitment_detail = 'samfundet:recruitment-detail' samfundet__recruitment_gangs = 'samfundet:recruitment-gangs' @@ -607,5 +608,6 @@ samfundet__recruitment_availability = 'samfundet:recruitment_availability' samfundet__feedback = 'samfundet:feedback' samfundet__purchase_feedback = 'samfundet:purchase_feedback' +samfundet__gang_application_stats = 'samfundet:gang-application-stats' static__path = '' media__path = '' diff --git a/backend/samfundet/serializers.py b/backend/samfundet/serializers.py index 5f4e9b332..6d16ac0f3 100644 --- a/backend/samfundet/serializers.py +++ b/backend/samfundet/serializers.py @@ -19,7 +19,7 @@ from root.constants import PHONE_NUMBER_REGEX from root.utils.mixins import CustomBaseSerializer -from .models.role import Role +from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .models.event import Event, EventGroup, EventCustomTicket, PurchaseFeedbackModel, PurchaseFeedbackQuestion, PurchaseFeedbackAlternative from .models.billig import BilligEvent, BilligPriceGroup, BilligTicketGroup from .models.general import ( @@ -40,6 +40,7 @@ KeyValue, MenuItem, TextItem, + GangSection, Reservation, ClosedPeriod, FoodCategory, @@ -426,6 +427,12 @@ class Meta: fields = '__all__' +class GangSectionSerializer(CustomBaseSerializer): + class Meta: + model = GangSection + fields = '__all__' + + class RecruitmentGangSerializer(CustomBaseSerializer): recruitment_positions = serializers.SerializerMethodField(method_name='get_positions_count', read_only=True) @@ -499,6 +506,54 @@ class Meta: fields = '__all__' +class UserOrgRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + org_role = serializers.SerializerMethodField() + + class Meta: + model = UserOrgRole + fields = ('user', 'org_role') + + def get_org_role(self, obj: UserOrgRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'organization': OrganizationSerializer(obj.obj).data, + } + + +class UserGangRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + gang_role = serializers.SerializerMethodField() + + class Meta: + model = UserGangRole + fields = ('user', 'gang_role') + + def get_gang_role(self, obj: UserGangRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'gang': GangSerializer(obj.obj).data, + } + + +class UserGangSectionRoleSerializer(CustomBaseSerializer): + user = UserSerializer() + section_role = serializers.SerializerMethodField() + + class Meta: + model = UserGangSectionRole + fields = ('user', 'section_role') + + def get_section_role(self, obj: UserGangSectionRole) -> dict: + return { + 'created_at': obj.created_at, + 'created_by': UserSerializer(obj.created_by).data, + 'section': GangSectionSerializer(obj.obj).data, + } + + class SaksdokumentSerializer(CustomBaseSerializer): # Read only url file path used in frontend url = serializers.SerializerMethodField(method_name='get_url', read_only=True) diff --git a/backend/samfundet/urls.py b/backend/samfundet/urls.py index 472fbb396..e746ccf61 100644 --- a/backend/samfundet/urls.py +++ b/backend/samfundet/urls.py @@ -150,4 +150,5 @@ path('recruitment//availability/', views.RecruitmentAvailabilityView.as_view(), name='recruitment_availability'), path('feedback/', views.UserFeedbackView.as_view(), name='feedback'), path('purchase-feedback/', views.PurchaseFeedbackView.as_view(), name='purchase_feedback'), + path('recruitment//gang//stats/', views.GangApplicationCountView.as_view(), name='gang-application-stats'), ] diff --git a/backend/samfundet/utils.py b/backend/samfundet/utils.py index 6755ad78d..6964f4003 100644 --- a/backend/samfundet/utils.py +++ b/backend/samfundet/utils.py @@ -42,6 +42,15 @@ def event_query(*, query: QueryDict, events: QuerySet[Event] = None) -> QuerySet return events +def user_query(*, query: QueryDict, users: QuerySet[User] = None) -> QuerySet[User]: + if not users: + users = User.objects.all() + search = query.get('search', None) + if search: + users = users.filter(Q(username__icontains=search) | Q(first_name__icontains=search) | Q(last_name__icontains=search)) + return users + + def generate_timeslots(start_time: datetime.time, end_time: datetime.time, interval_minutes: int) -> list[str]: # Convert from datetime.time objects to datetime.datetime start_datetime = datetime.datetime.combine(datetime.datetime.today(), start_time) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index e77fdb5d5..be356f2c6 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -5,6 +5,7 @@ import hmac import hashlib from typing import Any +from itertools import chain from guardian.shortcuts import get_objects_for_user @@ -38,9 +39,9 @@ REQUESTED_IMPERSONATE_USER, ) -from .utils import event_query, generate_timeslots, get_occupied_timeslots_from_request +from .utils import user_query, event_query, generate_timeslots, get_occupied_timeslots_from_request from .homepage import homepage -from .models.role import Role +from .models.role import Role, UserOrgRole, UserGangRole, UserGangSectionRole from .serializers import ( TagSerializer, GangSerializer, @@ -67,11 +68,13 @@ EventGroupSerializer, PermissionSerializer, RecruitmentSerializer, + UserOrgRoleSerializer, ClosedPeriodSerializer, FoodCategorySerializer, OrganizationSerializer, SaksdokumentSerializer, UserFeedbackSerializer, + UserGangRoleSerializer, InterviewRoomSerializer, FoodPreferenceSerializer, UserPreferenceSerializer, @@ -82,6 +85,7 @@ ReservationCheckSerializer, UserForRecruitmentSerializer, RecruitmentPositionSerializer, + UserGangSectionRoleSerializer, RecruitmentStatisticsSerializer, RecruitmentForRecruiterSerializer, RecruitmentSeparatePositionSerializer, @@ -134,6 +138,7 @@ Recruitment, InterviewRoom, OccupiedTimeslot, + RecruitmentGangStat, RecruitmentPosition, RecruitmentStatistics, RecruitmentApplication, @@ -325,6 +330,22 @@ class RoleView(ModelViewSet): serializer_class = RoleSerializer queryset = Role.objects.all() + @action(detail=True, methods=['get']) + def users(self, request: Request, pk: int) -> Response: + role = get_object_or_404(Role, id=pk) + + org_roles = UserOrgRole.objects.filter(role=role).select_related('user', 'obj') + gang_roles = UserGangRole.objects.filter(role=role).select_related('user', 'obj') + section_roles = UserGangSectionRole.objects.filter(role=role).select_related('user', 'obj') + + org_data = UserOrgRoleSerializer(org_roles, many=True).data + gang_data = UserGangRoleSerializer(gang_roles, many=True).data + section_data = UserGangSectionRoleSerializer(section_roles, many=True).data + + combined = list(chain(org_data, gang_data, section_data)) + + return Response(combined) + # =============================== # # Sulten # @@ -473,6 +494,10 @@ class AllUsersView(ListAPIView): serializer_class = UserSerializer queryset = User.objects.all() + def get(self, request: Request) -> Response: + users = user_query(query=request.query_params) + return Response(data=UserSerializer(users, many=True).data) + class ImpersonateView(APIView): permission_classes = [IsAuthenticated] # TODO: Permission check. @@ -1349,3 +1374,21 @@ def post(self, request: Request) -> Response: form=purchase_model, ) return Response(status=status.HTTP_201_CREATED, data={'message': 'Feedback submitted successfully!'}) + + +class GangApplicationCountView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request: Request, recruitment_id: int, gang_id: int) -> Response: + # Get total applications from RecruitmentGangStat + gang_stat = get_object_or_404(RecruitmentGangStat, gang_id=gang_id, recruitment_stats__recruitment_id=recruitment_id) + + return Response( + { + 'total_applications': gang_stat.application_count, + 'total_applicants': gang_stat.applicant_count, + 'average_priority': gang_stat.average_priority, + 'total_accepted': gang_stat.total_accepted, + 'total_rejected': gang_stat.total_rejected, + } + ) diff --git a/docs/api-docs.md b/docs/api-docs.md index 963faf179..d2126b96c 100644 --- a/docs/api-docs.md +++ b/docs/api-docs.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../README.md#documentation-overview) + # API docs API docs are generated by [drf-spectacular](https://drf-spectacular.readthedocs.io/en/latest/readme.html). @@ -6,14 +8,16 @@ API documentation is available as two different interfaces: [Swagger-UI](http://localhost:8000/schema/swagger-ui/#/) or [Redoc](http://localhost:8000/schema/redoc/) - - 🐋 _When backend server is running_ ## API schema file If you want a schema file for the API you can go to [http://localhost:8000/schema/](http://localhost:8000/schema/). -A schema file will be downloaded which can be used for multiple purposes, like sharing API documentation, or to generate code for recreating or testing the API. +A schema file will be downloaded which can be used for multiple purposes, like sharing API documentation, or to generate +code for recreating or testing the API. -> 💡 Note: You might encounter some error messages during this process. These errors are typically related to drf-spectacular not being able to parse certain views in views.py. However, the tool will still attempt to generate the documentation, though the results might not be fully comprehensive. +> [!NOTE] +> You might encounter some error messages during this process. These errors are typically related to drf-spectacular not +> being able to parse certain views in views.py. However, the tool will still attempt to generate the documentation, +> though the results might not be fully comprehensive. diff --git a/docs/common-errors.md b/docs/common-errors.md index 140e6662e..c016372e6 100644 --- a/docs/common-errors.md +++ b/docs/common-errors.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../README.md#documentation-overview) + # Common error messages ## Rule of thumb @@ -20,4 +22,4 @@ exec /app/entrypoint.sh: no such file or directory Cannot connect to the Docker daemon at ../../.../default/docker.sock. Is the docker daemon running? ``` ### Fix: -Make sure docker desktop is running (Windows) or run `colima start`on Mac. +Make sure docker desktop is running (Windows) or run `colima start` (or start Docker Desktop) on Mac. diff --git a/docs/dependencies.md b/docs/dependencies.md deleted file mode 100644 index 4ba44c4ae..000000000 --- a/docs/dependencies.md +++ /dev/null @@ -1,3 +0,0 @@ -## i18next - -Used for language translation diff --git a/docs/docker-project-specific-commands.md b/docs/docker-project-specific-commands.md index 4b2014d66..c3ab04921 100644 --- a/docs/docker-project-specific-commands.md +++ b/docs/docker-project-specific-commands.md @@ -1,6 +1,9 @@ -# Useful project spesific Docker actions -### For frontend actions -All commands has to be run inside a shell in a container. +[**← Back: Documentation Overview**](../README.md#documentation-overview) + +# Useful project specific Docker actions + +## Frontend +All commands have to be run inside a shell in a container. ```bash docker compose exec frontend bash #Command to open the frontend container in a shell @@ -25,9 +28,11 @@ yarn run tsc:check #runs TypeScript Compiler check, like in GitHub Actions pipeline, but in Docker ``` -## For backend actions: +--- + +## Backend -All commands has to be run inside a shell in a container. +All commands have to be run inside a shell in a container. ```bash docker compose exec backend bash #Command to open container in a shell diff --git a/docs/editors/assets/biome_config.png b/docs/editors/assets/biome_config.png new file mode 100644 index 000000000..22dfea774 Binary files /dev/null and b/docs/editors/assets/biome_config.png differ diff --git a/docs/editors/assets/pycharm_add_interpreter.png b/docs/editors/assets/pycharm_add_interpreter.png new file mode 100644 index 000000000..797b396e2 Binary files /dev/null and b/docs/editors/assets/pycharm_add_interpreter.png differ diff --git a/docs/editors/assets/pycharm_interpreter_bar.png b/docs/editors/assets/pycharm_interpreter_bar.png new file mode 100644 index 000000000..17b6408f9 Binary files /dev/null and b/docs/editors/assets/pycharm_interpreter_bar.png differ diff --git a/docs/editors/emacs.md b/docs/editors/emacs.md new file mode 100644 index 000000000..22c80203e --- /dev/null +++ b/docs/editors/emacs.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# Emacs setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/editors/jetbrains.md b/docs/editors/jetbrains.md new file mode 100644 index 000000000..5402037e9 --- /dev/null +++ b/docs/editors/jetbrains.md @@ -0,0 +1,90 @@ +[**← Back: Getting started**](../introduction.md) + +# JetBrains setup + +This will give some pointers on how to set up your JetBrains IDE (WebStorm/PyCharm). Luckily for you, there's not much +to be done, since JetBrains IDEs require very little configuration to be productive. + +Keep in mind that all the mentioned plugins are just recommended, they're not required to develop on the project. The +linters and formatters can be run through the terminal, but having them integrated in your IDE does make life a bit +easier. + +Contents: + +* [PyCharm](#pycharm) + * [Plugins](#plugins) + * [Python Interpreter](#python-interpreter) +* [WebStorm](#webstorm) + * [Plugins](#plugins-1) + * [Dependencies](#dependencies) + +--- + +## PyCharm + +### Plugins + +* [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) + * The most important one + * Add `set relativenumber` to `~/.ideavimrc` to get relative line numbering in your editor! + * You can open this file by clicking the "V" logo in the bottom right of your editor then `Open ~/.ideavimrc` +* [ruff](https://plugins.jetbrains.com/plugin/20574-ruff) + * Formatter/linter for Python + +### Python Interpreter + +Not having the correct interpreter selected in PyCharm can cause the IDE to not understand what Python version the +project is using, and it'll fail to resolve dependencies, causing a lot of red lines! If you are running the project in +Docker, you will also need to install dependencies locally, since the IDE doesn't check files inside the Docker +container. + +You can see what interpreter is currently selected in the bottom toolbar: + +![Currently selected interpreter](./assets/pycharm_interpreter_bar.png) + +To create an interpreter, click the button on the toolbar shown above, +then `Add new interpreter -> Add Local Interpreter...`. + +Select `Samfundet4/backend/.venv` as the location, and select the correct Python version as the Base interpreter. If +your system's Python version differs from what Samfundet4 expects (3.11 at the time of writing this), then you can +use [pyenv](https://github.com/pyenv/pyenv) to easily download another version. Then click OK to add it. + +![Add interpreter](./assets/pycharm_add_interpreter.png) + +After the interpreter has been created and selected, you can then install the dependencies inside the virtual +environment: + +```bash +~/Samfundet4 » source .venv/bin/activate +(.venv) ~/Samfundet4 » poetry install +``` + +--- + +## WebStorm + +### Plugins + +* [IdeaVim](https://plugins.jetbrains.com/plugin/164-ideavim) + * The most important one + * Add `set relativenumber` to `~/.ideavimrc` to get relative line numbering in your editor! + * You can open this file by clicking the "V" logo in the bottom right of your editor then `Open ~/.ideavimrc` +* [Biome](https://plugins.jetbrains.com/plugin/22761-biome) + * Formatter/linter for frontend code. + * Below is the recommended configuration (`Settings -> Language & Frameworks -> Biome`). It'll automatically format + and apply safe fixes on save (which in the + JetBrains world means when you tab/switch windows) + ![Biome configuration](./assets/biome_config.png) + +### Dependencies + +If you are running the project in Docker, you will also need to install dependencies locally, since the IDE doesn't +check files inside the Docker container. + +To do so, ensure you have [node](https://nodejs.org/en) and [yarn](https://classic.yarnpkg.com/lang/en/docs/install/) +installed. Then simply run yarn to install the dependencies. + +```bash +~/Samfundet4 » cd frontend +~/Samfundet4/frontend » yarn +``` diff --git a/docs/editors/vim.md b/docs/editors/vim.md new file mode 100644 index 000000000..ea79eb65e --- /dev/null +++ b/docs/editors/vim.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# Vim setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/editors/vscode.md b/docs/editors/vscode.md new file mode 100644 index 000000000..a300794e4 --- /dev/null +++ b/docs/editors/vscode.md @@ -0,0 +1,5 @@ +[**← Back: Getting started**](../introduction.md) + +# VS Code setup + +This guide hasn't been written yet. Maybe you want to? :-) diff --git a/docs/install/git-setup.md b/docs/install/git-setup.md new file mode 100644 index 000000000..ef1054dc0 --- /dev/null +++ b/docs/install/git-setup.md @@ -0,0 +1,41 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Git setup + +Git is a Version Control System (VCS). You're required to set up Git in order to be able to pull and push to the +Samfundet4 project. + +## Creating an SSH key + +
+Windows +
+ +
+Linux/MacOS/WSL + +In your terminal, run `ssh-keygen` + +This will generate two files: `~/.ssh/id_rsa` and `~/.ssh/id_rsa.pub`. +
+ +## Adding it to GitHub + +Copy the contents of the `id_rsa.pub` file and go to the [SSH and GPG keys](https://github.com/settings/keys) GitHub +settings page. Click the green "New SSH key" button, paste the file contents in the big text box, and click "Add SSH +key". + +> [!WARNING] +> Ensure you copy the right file. `id_rsa` is a private key, never meant to be shared with anyone, unlike `id_rsa.pub`. + +## Configuring Git + +You can configure Git both locally and globally. Locally meaning your configuration only applies to a specific +directory (i.e. project), or globally for all directories. Local configuration overrides global configuration. + +## Further reading + +Want to git gud to become a git god? diff --git a/docs/install/install-script.md b/docs/install/install-script.md new file mode 100644 index 000000000..f6297fb1c --- /dev/null +++ b/docs/install/install-script.md @@ -0,0 +1,44 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This script has not been maintained in a while and may not work. + +# Install script + +We have a script that handles all installation for you. To run the script, a Github Personal Access Token (PAT) is +required. +You can make one here https://github.com/settings/tokens/new. Tick scopes `repo`, `read:org` and `admin:public_key`), +then store the token somewhere safe (Github will never show it again). + +Copy these commands (press button on the right-hand side of the block) +and run from the directory you would clone the project. + +```sh +# Interactive +read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=y /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; +``` + +
+Non-interactive (show/hide) + +```sh +# Non-interactive +read -s -p "Github PAT token: " TOKEN ; X_INTERACTIVE=n /bin/bash -c "$(curl -fsSL https://$TOKEN@raw.githubusercontent.com/Samfundet/Samfundet4/master/{bash_utils.sh,install.sh})" && . ~/.bash_profile && cd Samfundet4; unset TOKEN; unset X_INTERACTIVE; +``` + + +
+ +
+Flags explained (show/hide) + +> - X_INTERACTIVE (y/n): determines how many prompts you receive before performing an action. + > curl: +> - -f: fail fast +> - -s: silent, no progress-meter +> - -S: show error on fail +> - -L: follow redirect + +
diff --git a/docs/install/linux-docker.md b/docs/install/linux-docker.md new file mode 100644 index 000000000..0cdd96296 --- /dev/null +++ b/docs/install/linux-docker.md @@ -0,0 +1,14 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Installing on Linux (Docker) + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/linux-native.md b/docs/install/linux-native.md new file mode 100644 index 000000000..272d30990 --- /dev/null +++ b/docs/install/linux-native.md @@ -0,0 +1,14 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Installing on Linux (Native) + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/mac-docker.md b/docs/install/mac-docker.md new file mode 100644 index 000000000..51d814545 --- /dev/null +++ b/docs/install/mac-docker.md @@ -0,0 +1,60 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on MacOS (Docker) + +## Requirements + +* [Homebrew](https://docs.brew.sh/Installation) +* [colima](https://github.com/abiosoft/colima?tab=readme-ov-file#getting-started) + or [Docker Desktop](https://www.docker.com/products/docker-desktop/) + * colima can be more performant than Docker Desktop, but is less easy to use + +## Installing + +First clone the Samfundet4 repository. + +```bash +git clone git@github.com:Samfundet/Samfundet4.git +``` + +If you use colima, run `colima start` to start the engine. + +## Environment files + +Both the `frontend` and `backend` directories contain a `.docker.example.env` file. Copy these files to `.docker.env` +and adjust any values as needed. You may for example want to change the default Django superuser username and +password (`DJANGO_SUPERUSER_USERNAME` and `DJANGO_SUPERUSER_USERNAME`). + +## Building + +This builds all the Samfundet4 containers: + +```bash +cd Samfundet4 +docker compose build + +# You can also choose to build only specific containers if you want: +docker compose build frontend backend +``` + +## Running + +This will start the `backend` and `frontend` containers: + +```bash +docker compose up backend frontend +``` + +## Dependency issues? + +Editors/IDEs typically don't have access to installed dependencies which lie inside Docker containers. This means you +may have to install dependencies locally too, in order for your editor/IDE to resolve them. See +the [Editor configuration](../introduction.md#editor-configuration) guide for more information. + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/mac-native.md b/docs/install/mac-native.md new file mode 100644 index 000000000..256f79059 --- /dev/null +++ b/docs/install/mac-native.md @@ -0,0 +1,73 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on MacOS (Native) + +## Requirements + +* [Homebrew](https://docs.brew.sh/Installation) + * MacOS package manager +* [Poetry](https://python-poetry.org/docs/) + * Backend dependency manager +* [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable) + * Frontend dependency manager +* [pyenv](https://github.com/pyenv/pyenv) + * Python version manager. Lets you easily install the same Python version that Samfundet4 expects. + +## Installing + +First clone the Samfundet4 repository. + +```bash +git clone git@github.com:Samfundet/Samfundet4.git +``` + +Install the frontend dependencies + +```bash +cd Samfundet4/frontend +yarn +``` + +Install the backend dependencies + +```bash +cd ../backend +poetry install +``` + +Then apply migrations and run seed script (the seed script adds test data to our database) + +```bash +poetry run python3 manage.py migrate +poetry run python3 manage.py seed +``` + +## Environment files + +Both the `backend` and `frontend` directories have an `.env.example` file. In each directory, copy this file to `.env` +and adjust any values as needed. You may for example want to change the default Django superuser username and +password (`DJANGO_SUPERUSER_USERNAME` and `DJANGO_SUPERUSER_USERNAME`). + +## Running + +Start backend: + +```bash +cd backend +poetry run python3 manage.py runserver +``` + +Start frontend: + +```bash +cd frontend +yarn start +``` + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/post-install.md b/docs/install/post-install.md new file mode 100644 index 000000000..c20b51c15 --- /dev/null +++ b/docs/install/post-install.md @@ -0,0 +1,36 @@ +[**← Back: Getting started**](../introduction.md) + +# Post-install + +You've now (hopefully) successfully installed and started the Samfundet4 project! What now? + +We recommend spending some time ensuring your editor/IDE is properly configured. This is of course a very subjective +topic, but we give some pointers in the [Editor configuration](../introduction.md#editor-configuration) section. + +After you've set up your editor/IDE, we recommend diving in head-first and just picking +an [issue](https://github.com/Samfundet/Samfundet4/issues) you'd like to solve. Be sure to have +the [Documentation Overview](../README.md) open and ready for *when* you get stuck :-) If you find that some parts of +the documentation are lacking, don't be afraid to create a PR to fix it! + +## Resetting the database + +You'll likely encounter a situation where you'd like to "reset" the database, by deleting all its data and seeding it +again. It's quite easy to do this, the first step is to stop the backend server. Then in the `backend/database` +directory, delete either the `db.sqlite3` if you're running native (or WSL), or the `docker.db.sqlite3` file if you're +running in Docker. + +Then simply start the backend server again. This will automatically create the database file and seed it automatically. + +## Resetting migrations + +If you've done some work in backend and changed/created any models, you'll also have created migration files. You'll +occasionally encounter a situation where you and another developer have both commited migrations with the same number. +Typically the other developer will have gotten their migration file merged to master, resulting in a number conflict in +your branch. + +The easiest way to fix this is to simply delete the migration file you have created, and running the `makemigrations` +command again: + +* Docker: `docker compose exec backend bash` + * Then run the same Poetry command as in the line below +* Native: `poetry run python3 manage.py makemigrations` diff --git a/docs/install/windows-docker.md b/docs/install/windows-docker.md new file mode 100644 index 000000000..aa44d4c91 --- /dev/null +++ b/docs/install/windows-docker.md @@ -0,0 +1,30 @@ +[**← Back: Getting started**](../introduction.md) + +> [!WARNING] +> This guide is not complete! Feel free to submit a PR to improve it :-) + +# Installing on Windows (Docker in WSL) + +## Install WSL + +To run the project in WSL, you obviousy first need WSL. +Follow [this guide](https://learn.microsoft.com/en-us/windows/wsl/install) by Microsoft. The main step is running the +following in an administrator PowerShell or command prompt: + +```shell +wsl --install +``` + +From this point on, any commands you are instructed to run, are meant to be run inside WSL unless otherwise specified. + +## Install Docker + +Next, install docker. Follow [this guide](https://docs.docker.com/desktop/install/windows-install/). + +## Post-install + +Now that you've got the project up and running, check out the post-install instructions: + +

+→ Next: Post-install +

diff --git a/docs/install/windows-wsl.md b/docs/install/windows-wsl.md new file mode 100644 index 000000000..20a69c3e5 --- /dev/null +++ b/docs/install/windows-wsl.md @@ -0,0 +1,32 @@ +[**← Back: Getting started**](../introduction.md) + +# Installing on Windows (WSL) + +> [!WARNING] +> When cloning the project, please ensure you do **not** do it inside OneDrive. Working with the project inside OneDrive +> will be incredibly slow in all aspects. + +## Introduction + +The Windows Subsystem of Linux (WSL) lets you run programs which traditionally only run in Linux. + +## Install WSL + +To run the project in WSL, you obviousy first need WSL. +Follow [this guide](https://learn.microsoft.com/en-us/windows/wsl/install) by Microsoft. The main step is running the +following in an administrator PowerShell or command prompt: + +```shell +wsl --install +``` + +This should install WSL with the Ubuntu distribution. + +## Next steps + +Since you now have a working Linux environment, the remaining steps of setting up the project are identical with the +Linux guide, so click the link below to continue the process. + +

+→ Next: Installing on Linux (Native) +

diff --git a/docs/intervew-scheduling.md b/docs/intervew-scheduling.md new file mode 100644 index 000000000..4b5db06a0 --- /dev/null +++ b/docs/intervew-scheduling.md @@ -0,0 +1,68 @@ +[**← Back: Documentation Overview**](../README.md#documentation-overview) + +> [!NOTE] +> This document is a work in progress. + +# Automatic Interview Scheduling + +The aim of this document is to describe the Automatic Interview Scheduling system of Samfundet4. + +It's hard to describe concisely how the system works, so this document contains a few different user stories, describing +how each user role would interact with the system. This will hopefully help you get a better understanding of how the +system works. There's probably going to be a bit of unnecessary rambling in this document, feel free to submit a PR to +fix that:) + +First off, this system is **not** meant to be fully automatic, but semi-automatic. We don't want there to be a lot of +magic happening in the background which nobody understands, and we don't want a system that makes changes without user +interaction. Having a system that automatically schedules interviews without any supervision sounds like a recipe for +disaster. + +The goal is to help recruitment admins save time, by +automatically scheduling interviews, something which notoriously takes a long time to do manually. There are so many +edge cases in scheduling interviews, so it's important that the system is intuitive and easy to use. + +The system must also allow each gang to use the scheduling algorithm of their choosing. + +## Admin's perspective + +1. Navigate to my gang's recruitment overview page and click on a position. +2. Hit the "Automatically schedule interviews" button. +3. We are shown a dialog (or page), with a list of interviews the algorithm has suggested. + 1. Each interview suggestion shows the date, time, and partaking interviewers + 2. We are able to manually edit these interviews if we wish. This will allow us to manually edit the date, time, + location and partaking interviewers. +4. We click the submit button, which saves the interviews and sends out emails to affected applicants and interviewers, + notifying them of the upcoming interview. + 1. The email must not contain sensitive information, it should only contain the name of the gang/section/position + the applicant has applied for, as well as the date, time, and location of the interview. + +## Interviewer's perspective + +## User's perspective + +## Algorithms + +Owner refers to either the Gang or Section which owns the position. + +### Samfundet + +1. Fetch all unscheduled interviews for this position. +2. Fetch all rooms booked by Owner (if any) +3. Fetch unavailability data for all interviewers and applicants +4. For each unscheduled interview, do: + 1. + +### UKA/ISFiT + +1. + +### ISFiT + +1. + +## Race conditions and conflict + +The scheduling algorithms described above are very prone to race conditions and conflict if running them at the same +time, which isn't unthinkable since we want to allow multiple people to work on the recruitment system simultaneously. +We solve this by only allowing the interview scheduling to be run by a single process. To run the scheduler, we send a +request to add scheduling for a given position to the queue. The process fetches tasks from the queue and executes them. diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 000000000..6f763d5d0 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,36 @@ +[**← Back: Documentation Overview**](../README.md#documentation-overview) + +# Introduction to Samfundet4 + +Welcome to Samfundet4! This guide will introduce you to the technologies used in the project, and guide you through +installing the project on your machine. + +## Technologies + +Samfundet4 is built using [Django](https://www.djangoproject.com/) and [React](https://react.dev/). Django is a Python +framework, which we use as our backend. React is a library for building frontend applications, and we use it with +the [TypeScript](https://www.typescriptlang.org/) language. + +For a more in-depth introduction to all the technologies we use, check out this +document: [Technologies used in Samfundet4](./technical/Samf4Tech.md) + +## Installation + +There are multiple ways of running this project, all with their own pros and cons. Running with +Docker is likely the easiest, but will require some initial setup and tweaking depending on your system. + +Below is a set of install guides for the various methods of installing and running the project, depending on your OS. If +you're not sure which to pick, just ask someone in MG::Web! + +- Linux: [Docker](./install/linux-docker.md) – [Native](./install/linux-native.md) +- MacOS: [Docker](./install/mac-docker.md) – [Native](./install/mac-native.md) +- Windows: [Docker](./install/windows-docker.md) – [WSL](./install/windows-wsl.md) +- [Install script](./install/install-script.md) +- [Post-install instructions](./install/post-install.md) + +## Editor configuration + +* [JetBrains (WebStorm, PyCharm, etc...)](./editors/jetbrains.md) +* [VS Code](./editors/vscode.md) +* [Vim/Neovim](./editors/vim.md) +* [Emacs](./editors/emacs.md) diff --git a/docs/technical/README.md b/docs/technical/README.md deleted file mode 100644 index 75f09d871..000000000 --- a/docs/technical/README.md +++ /dev/null @@ -1,22 +0,0 @@ - -[👈 back](/README.md) - -# Samfundet4 - Technical Documentation - - - -### Frontend - -- [Creating react components (conventions)](/docs/technical/frontend/components.md) -- [Forms and schemas](/docs/technical/frontend/forms.md) -- [Cypress Setup Documentation](/docs/technical/frontend/cypress.md) -- [Data fetching](./frontend/data-fetching.md) - -### Backend - -- [Billig (payment system)](/docs/technical/backend/billig.md) -- [Seed scripts](/docs/technical/backend/seed.md) -- [Role System](/docs/technical/backend/rolesystem.md) - -### Pipelines & Deployment -- [Pipeline (mypy, biome, tsc, ...)](/docs/technical/pipeline.md) diff --git a/docs/technical/Samf4Tech.md b/docs/technical/Samf4Tech.md index defd33130..ecbf8ec2d 100644 --- a/docs/technical/Samf4Tech.md +++ b/docs/technical/Samf4Tech.md @@ -1,6 +1,6 @@ -[👈 back](/README.md) +[**← Back: Introduction to Samfundet4**](../introduction.md) -# Technologies used on Samfundet4 +# Technologies used in Samfundet4 This text aims to both sum up the main technologies used to develop on Samfundet4 and to get a person with minimal webdev experience up to speed on the most important concepts. There is a lot this text does not cover, which might be found in other docs linked to in the [README](/README.md). diff --git a/docs/technical/backend/billig.md b/docs/technical/backend/billig.md index 321743951..7b431350f 100644 --- a/docs/technical/backend/billig.md +++ b/docs/technical/backend/billig.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) # Billig Integration diff --git a/docs/technical/backend/rolesystem.md b/docs/technical/backend/rolesystem.md index 833e73633..b16bc93ec 100644 --- a/docs/technical/backend/rolesystem.md +++ b/docs/technical/backend/rolesystem.md @@ -1,4 +1,6 @@ -# Role System +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) + +# Role system The role system in Samfundet4 builds on the Django "authentication backend" concept. Our system adds a [custom auth backend](https://docs.djangoproject.com/en/5.0/topics/auth/customizing/). The goal of the system is to diff --git a/docs/technical/backend/seed.md b/docs/technical/backend/seed.md index 23c084427..6cc980cd5 100644 --- a/docs/technical/backend/seed.md +++ b/docs/technical/backend/seed.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) # Seeding diff --git a/docs/technical/frontend/components.md b/docs/technical/frontend/components.md index 16edd34ff..25590da5b 100644 --- a/docs/technical/frontend/components.md +++ b/docs/technical/frontend/components.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) # Components diff --git a/docs/technical/frontend/cypress.md b/docs/technical/frontend/cypress.md index fcdc7e0f2..bfb8842ed 100644 --- a/docs/technical/frontend/cypress.md +++ b/docs/technical/frontend/cypress.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) + # Cypress Setup Documentation This document outlines the steps for setting up Cypress in your project. Cypress is an end-to-end testing framework designed to make it easy to write and run tests for web applications. This guide will cover how to set up Cypress both in a Docker container and locally on your machine. diff --git a/docs/technical/frontend/data-fetching.md b/docs/technical/frontend/data-fetching.md index ebb5b9245..20c1ccce6 100644 --- a/docs/technical/frontend/data-fetching.md +++ b/docs/technical/frontend/data-fetching.md @@ -1,19 +1,130 @@ -# Data fetching +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) -We use the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) (built into browsers) to fetch data. -The `fetch` function is quite simple to use. It returns -a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) that resolves to -the response of the request. +# Data fetching and State management + +We use the [Axios HTTP client](https://axios-http.com/docs/intro) to fetch data. It's quite simple to use, returning a +[Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) resolving to the +response of the request. We use [React Query](https://tanstack.com/query/v3) for state management. State management in React is notoriously hard to get 100% right and safe, which is why using a library such as RQ is a good idea. It saves us from a lot of potential common bugs and headaches with managing state all by ourselves. -If you're not convinced, read this [great article](https://tkdodo.eu/blog/why-you-want-react-query) by TkDodo on Why You -~~Want~~ Need React Query. It explores a lot of common pitfalls/bugs. At the time of me writing this, these +If you're not convinced, read this [great article](https://tkdodo.eu/blog/why-you-want-react-query) by TkDodo on *Why +You ~~Want~~ Need React Query*. It explores a lot of common pitfalls/bugs. At the time of me writing this, these pitfalls/bugs are found absolutely everywhere we do data fetching in Samfundet4. Hopefully over time, we will replace these instances with safe state management using RQ. ## Getting started +So how do these two libraries hang together in our project? Well, we typically start by writing a very simple function +which only contains an Axios call to send a request and return the data from the response. We do this +in `frontend/src/api.ts`. Then in our components and pages and +whatnot, we use React Query to be responsible for actually calling this function when needed. + +To get started, in our component/page, call the `useQuery` hook like so, providing a query key and the query function: + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: ['informationpages'], + queryFn: getInformationPages, +}); +``` + +The project has a single global *Query Client*. This acts kind of like a global request and response cache. It contains +all the data fetched by the `useQuery` hook. You can think of it as a simple Key-Value store. In the above example, the +data fetched by the `getInformationPages` function is stored by the Query Client using the query key. The query key +therefore needs to be unique for the data we're fetching. + +## Query key factory + +Since it's extremely important that the query keys are unique, we employ a "query key factory" to generate them for us. +The factories are all defined in one place ([queryKeys.ts](../../../frontend/src/queryKeys.ts)), which should eliminate +the possibility of key collisions. Here are some examples of how to use the factories: + +```ts +// Get all information pages +const { data } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, +}); +``` + +```ts +// Get specific user +const { data } = useQuery({ + queryKey: userKeys.detail(userId), + queryFn: () => getUser(userId), +}); +``` + +```ts +// Get information pages with filter +const { data } = useQuery({ + queryKey: infoPageKeys.list([search, page]), + queryFn: () => getInformationPages(search, page), +}); +``` + +## Invalidating data + +Sometimes we need to invalidate data which has been fetched. An example case is when we create and save a new object, an +information page for instance, we want to clear our "information pages" cache, so that when we request all information +pages again, the data returned includes the new object. + +Doing this is quite simple with the query key factory, and we are able to invalidate on multiple levels of granularity: + +```ts +// Invalidate absolutely all information page data +queryClient.invalidateQueries({ + queryKey: infoPageKeys.all +}); + +// Invalidate all information page lists +queryClient.invalidateQueries({ + queryKey: infoPageKeys.lists() +}); + +// Invalidate a specific information page's data +queryClient.invalidateQueries({ + queryKey: infoPageKeys.detail(id) +}); +``` + +## Error handling + +We have a very simple error handler defined in the *Query Client*. If the query function returns an error (for instance, +HTTP 500), we will log it as an error to the console, and display a toast with an error message. This error message is +by default generic (i.e. "Something went wrong!"), but it can be overwritten by the useQuery-caller if desired. We do +this using the `meta` and `errorMsg` options in the useQuery hook. + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, + meta: { + errorMsg: "We couldn't find the pages!" + } +}); +``` + +In the example above, if `getInformationPages` raises an error, we'll get a toast with "We couldn't find the pages!". +Note that you can (and should) use translations here as well: + +```ts +const { data, isLoading, isError } = useQuery({ + queryKey: infoPageKeys.all, + queryFn: getInformationPages, + meta: { + errorMsg: t(KEY.something_something) + } +}); +``` + +## Further reading + +React Query doesn't only have to be used for API calls, we also use it for other async tasks. + Please check out the [RQ docs Quick Start](https://tanstack.com/query/latest/docs/framework/react/quick-start). + +Also check out TkDodo's (RQ maintainer) [blog posts](https://tkdodo.eu/blog/practical-react-query) diff --git a/docs/technical/frontend/forms.md b/docs/technical/frontend/forms.md index 4be7686dd..bb7f23aac 100644 --- a/docs/technical/frontend/forms.md +++ b/docs/technical/frontend/forms.md @@ -1,4 +1,4 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) # Forms @@ -39,7 +39,7 @@ To get started, create a new file, for example `YourForm.tsx`. This file will co itself. Define a schema using zod. Remember to reuse fields when possible as mentioned in the section above (we won't do this here for example's sake). -```typescript jsx +```ts import { z } from 'zod'; const schema = z.object({ @@ -51,16 +51,16 @@ Create your form component, and use the `useForm` hook to create the form. Create the form component, and use the `useForm` hook with your schema,. -```typescript jsx +```jsx export function YourForm() { // 1. Define the form - const form = useForm>({ + const form = useForm < z.infer < typeof schema >> ({ resolver: zodResolver(schema), defaultValues: { username: '', }, }); - + // 2. Define the submit handler function onSubmit(values: z.infer) { // These values are type-safe and validated @@ -71,7 +71,7 @@ export function YourForm() { Now use the `Form` wrapper components to build our form. -```typescript jsx +```jsx export function YourForm() { // ... @@ -84,6 +84,7 @@ export function YourForm() { render={({ field }) => ( Username + Pick wisely, this cannot be changed later! @@ -97,6 +98,143 @@ export function YourForm() { } ``` +## Files + +Defining a schema type for files is a bit more complicated. Below is an example which defines a schema with an +optional `avatar` file field. + +```jsx +const schema = z.object({ + image_file: z + .instanceof(File) + .refine((file) => file.size < 1024 * 1024 * 2, { + message: "File can't be larger than 2 MB" + }) + .nullable(), +}); +``` + +And in the form below. Please note that this input must +be [uncontrolled](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components), so +we do not set `value` on it. We must also extract relevant information from the `onChange` event. In the example below, +we only want a single file, so we return the first item in the `FileList`. + +```jsx + ( + + + onChange(event.target.files?.[0])} + {...fieldProps} + /> + + + + )} +/> +``` + +## Numbers + +All HTML input values are strings. If we require a number type from an input, we must therefore convert it, as well as +deal with all non-numeric input. This can quickly become cumbersome using just the Input component. Luckily we have the +NumberInput component which does all this for us. + +```jsx + ( + + Duration + + + + + + )} +/> +``` + +## Dropdown + +Dropdowns can be used +either [controlled or uncontrolled](https://react.dev/learn/sharing-state-between-components#controlled-and-uncontrolled-components). +If you provide `value` to Dropdown, it'll be controlled. If you don't, it will be uncontrolled. + +```ts +const options: DropdownOption[] = [ + { label: 'Samfundet', value: 'samfundet' }, + { label: 'UKA', value: 'uka' }, + { label: 'ISFiT', value: 'isfit' }, +]; +``` + +Controlled: + +```jsx + ( + + Organization + Which organization does this object belong to? + + + + + )} +> + +``` + +Uncontrolled: + +```jsx + ( + + Organization + Which organization does this object belong to? + + + + + )} +> + +``` + +You can also add a "null option". This is a blank option which is added to the top of the dropdown list. This is useful +if you need the Dropdown to be optional. The label of the null option can be customized, and it can also be disabled in +order to force users to select another option. If the null option is selected, an italic font style is applied to the +dropdown, to further indicate that a special option is selected. Examples of some possibilities below: + +```jsx +// Add a simple blank null option + +``` + +```jsx +// Null option with custom label + +``` + +```jsx +// Disabled null option with custom label + +``` + + ## Example To see an example form in action, check out the form on the [components page](http://localhost:3000/components), diff --git a/docs/technical/frontend/samfform.md b/docs/technical/frontend/samfform.md index 0132e1e3b..296882b0a 100644 --- a/docs/technical/frontend/samfform.md +++ b/docs/technical/frontend/samfform.md @@ -1,4 +1,7 @@ -[👈 back](/docs/technical/README.md) +[**← Back: Documentation Overview**](../../../README.md#documentation-overview) + +> [!WARNING] +> SamfForm is deprecated, and will slowly be replaced with [our wrappers](./forms.md) around React Hook Form. # SamfForm diff --git a/docs/technical/meme.jpeg b/docs/technical/meme.jpeg deleted file mode 100644 index 533857b25..000000000 Binary files a/docs/technical/meme.jpeg and /dev/null differ diff --git a/docs/technical/pipeline.md b/docs/technical/pipeline.md index cc32770c0..6c91c4da9 100644 --- a/docs/technical/pipeline.md +++ b/docs/technical/pipeline.md @@ -1,3 +1,5 @@ +[**← Back: Documentation Overview**](../../README.md#documentation-overview) + # Pipelines Is your PR not passing the pipeline checks? Look no further. @@ -18,7 +20,7 @@ _Run Biome_ yarn biome:ci ``` -_fix biome_ +_Run Biome fix_ ``` yarn biome:fix diff --git a/docs/useful-commands.md b/docs/useful-commands.md index e43aef7eb..f523b23f5 100644 --- a/docs/useful-commands.md +++ b/docs/useful-commands.md @@ -1,4 +1,4 @@ -[👈 back](/README.md) +[**← Back: Documentation Overview**](../README.md#documentation-overview) # Useful commands @@ -206,6 +206,8 @@ python -m poetry run python manage.py collectstatic
+Be sure to check out the documentation for [Docker command aliases](./docker-project-specific-commands.md). + ### 🐳 Docker: Run command inside container > `` is defined under `services` in [docker-compose.yml](/docker-compose.yml). ```bash diff --git a/docs/work-methodology.md b/docs/work-methodology.md index 1894e3f7c..b170c8de5 100644 --- a/docs/work-methodology.md +++ b/docs/work-methodology.md @@ -1,4 +1,4 @@ -[👈 back](/README.md) +[**← Back: Documentation Overview**](../README.md#documentation-overview) # Work methodology diff --git a/frontend/src/Components/Button/Button.module.scss b/frontend/src/Components/Button/Button.module.scss index 6c0871b46..978f711ff 100644 --- a/frontend/src/Components/Button/Button.module.scss +++ b/frontend/src/Components/Button/Button.module.scss @@ -14,7 +14,8 @@ justify-content: center; // Remove underline when button is a link - &, &:hover { + &, + &:hover { text-decoration: none; } @@ -37,6 +38,19 @@ color: black; } +.button_selected { + background-color: white; + color: black; + box-shadow: inset 0 0 1.5px 2px rgba(0, 0, 0, 0.15); + cursor: default; + + // Override hover effects for selected buttons + &:hover:not([disabled]) { + filter: none; + box-shadow: inset 0 0 1.5px 2px rgba(0, 0, 0, 0.15); + } +} + .button_samf { background-color: $red-samf; color: white; @@ -169,4 +183,3 @@ text-decoration: underline; } } - diff --git a/frontend/src/Components/Button/Button.tsx b/frontend/src/Components/Button/Button.tsx index 012bbef08..d259b547f 100644 --- a/frontend/src/Components/Button/Button.tsx +++ b/frontend/src/Components/Button/Button.tsx @@ -5,7 +5,7 @@ import styles from './Button.module.scss'; import type { ButtonDisplay, ButtonTheme } from './types'; import { displayToStyleMap, themeToStyleMap } from './utils'; -type ButtonProps = { +export type ButtonProps = { name?: string; theme?: ButtonTheme; display?: ButtonDisplay; diff --git a/frontend/src/Components/Button/index.ts b/frontend/src/Components/Button/index.ts index b8ca3aaac..c7cf1a21a 100644 --- a/frontend/src/Components/Button/index.ts +++ b/frontend/src/Components/Button/index.ts @@ -1,2 +1,3 @@ export { Button } from './Button'; +export type { ButtonProps } from './Button'; export type { ButtonDisplay, ButtonTheme } from './types'; diff --git a/frontend/src/Components/Button/utils.ts b/frontend/src/Components/Button/utils.ts index f4152a17a..853dbd25a 100644 --- a/frontend/src/Components/Button/utils.ts +++ b/frontend/src/Components/Button/utils.ts @@ -3,6 +3,7 @@ import styles from './Button.module.scss'; export const themeToStyleMap = { basic: styles.button_basic, + selected: styles.button_selected, pure: styles.pure, text: styles.button_text, samf: styles.button_samf, diff --git a/frontend/src/Components/ExpandableHeader/ExpandableHeader.module.scss b/frontend/src/Components/ExpandableHeader/ExpandableHeader.module.scss index fa089cbb8..619f88fa4 100644 --- a/frontend/src/Components/ExpandableHeader/ExpandableHeader.module.scss +++ b/frontend/src/Components/ExpandableHeader/ExpandableHeader.module.scss @@ -2,13 +2,12 @@ @import 'src/mixins'; - .container { width: 100%; border: 2px solid $grey-3; overflow: hidden; - @include theme-dark{ + @include theme-dark { border: 1px solid $grey-0; } } @@ -16,9 +15,9 @@ .parent { border-radius: 10px; border-color: $grey-4; - background-color: $grey_4; + background-color: $grey-4; - @include theme-dark{ + @include theme-dark { background-color: #1d0809; border-color: transparent; } @@ -30,7 +29,7 @@ margin: 10px; width: auto; - @include theme-dark{ + @include theme-dark { border-color: transparent; } } @@ -51,16 +50,16 @@ text-align: left; width: 100%; - @include theme-dark{ + @include theme-dark { background-color: #1d0809; color: white; } } -.extendable_header_wrapper:hover{ +.extendable_header_wrapper:hover { background-color: #dbdbdb; - @include theme-dark{ + @include theme-dark { background-color: #1d0809; } } diff --git a/frontend/src/Components/NumberInput/NumberInput.tsx b/frontend/src/Components/NumberInput/NumberInput.tsx index 4b9b4cce5..bfa44b165 100644 --- a/frontend/src/Components/NumberInput/NumberInput.tsx +++ b/frontend/src/Components/NumberInput/NumberInput.tsx @@ -4,16 +4,44 @@ import { Input, type InputProps } from '~/Components'; export interface NumberInputProps extends Omit { onChange?: (...event: unknown[]) => void; allowDecimal?: boolean; + clamp?: boolean; } export const NumberInput = React.forwardRef( - ({ onChange, value, type, allowDecimal = true, ...props }, ref) => { + ({ onChange, value, type, min, max, allowDecimal = true, clamp = true, onBlur, ...props }, ref) => { const [inputValue, setInputValue] = useState(value || ''); useEffect(() => { setInputValue(value || ''); }, [value]); + const canClamp = clamp && (min !== undefined || max !== undefined); + + function clampValue(n: number) { + if (!canClamp) { + return n; + } + let clamped = n; + if (min !== undefined) { + clamped = Math.max(clamped, Number(min)); + } + if (max !== undefined) { + clamped = Math.min(clamped, Number(max)); + } + return clamped; + } + + function handleOnBlur(event: React.FocusEvent) { + if (canClamp) { + const clamped = clampValue(Number(event.target.value) || 0); + if (!Number.isNaN(clamped)) { + setInputValue(clamped); + onChange?.(clamped); + } + } + onBlur?.(event); + } + function isValidPartial(s: string) { // Allows for partial inputs like "-" or "1." const re = allowDecimal ? /^-?[0-9]*\.?[0-9]*$/ : /^-?[0-9]*$/; @@ -69,7 +97,7 @@ export const NumberInput = React.forwardRef( if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); const add = event.key === 'ArrowUp' ? 1 : -1; - const newVal = (Number(inputValue) || 0) + add; + const newVal = clampValue((Number(inputValue) || 0) + add); if (!Number.isNaN(newVal)) { setInputValue(newVal); onChange?.(newVal); @@ -99,6 +127,7 @@ export const NumberInput = React.forwardRef( onKeyDown={onKeyDown} onBeforeInput={onBeforeInput} onChange={handleOnChange} + onBlur={handleOnBlur} {...props} /> ); diff --git a/frontend/src/Components/Pagination/PagedPagination.module.scss b/frontend/src/Components/Pagination/PagedPagination.module.scss new file mode 100644 index 000000000..fb4dcf1ec --- /dev/null +++ b/frontend/src/Components/Pagination/PagedPagination.module.scss @@ -0,0 +1,6 @@ +.container { + display: flex; + justify-content: center; + margin: 1rem; + width: 100%; +} diff --git a/frontend/src/Components/Pagination/PagedPagination.stories.tsx b/frontend/src/Components/Pagination/PagedPagination.stories.tsx new file mode 100644 index 000000000..4d72e528e --- /dev/null +++ b/frontend/src/Components/Pagination/PagedPagination.stories.tsx @@ -0,0 +1,153 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import { PagedPagination } from './PagedPagination'; + +export default { + title: 'Components/DRFPagination', + component: PagedPagination, + argTypes: { + currentPage: { + control: 'number', + description: 'Current active page', + }, + totalItems: { + control: 'number', + description: 'Total number of items to paginate', + }, + pageSize: { + control: 'number', + description: 'Number of items per page', + }, + siblingCount: { + control: 'number', + description: 'Number of sibling pages around the current page', + defaultValue: 1, + }, + boundaryCount: { + control: 'number', + description: 'Number of pages to display at the start and end', + defaultValue: 1, + }, + className: { + control: 'text', + description: 'Custom class for the pagination container', + }, + itemClassName: { + control: 'text', + description: 'Custom class for individual pagination items', + }, + }, + parameters: { + docs: { + description: { + component: 'A pagination component designed to work with Django Rest Framework pagination.', + }, + }, + }, +} as ComponentMeta; + +// Template with state management +const Template: ComponentStory = (args) => { + const [currentPage, setCurrentPage] = useState(args.currentPage); + + return ( +
+ +

Current page: {currentPage}

+
+ ); +}; + +// Basic usage +export const Basic = Template.bind({}); +Basic.args = { + currentPage: 1, + totalItems: 100, + pageSize: 10, +}; +Basic.parameters = { + docs: { + description: { + story: 'Basic pagination with default styling', + }, + }, +}; + +// Many pages example +export const ManyPages = Template.bind({}); +ManyPages.args = { + ...Basic.args, + totalItems: 2500, + currentPage: 7, +}; +ManyPages.parameters = { + docs: { + description: { + story: 'Pagination with many pages showing ellipsis', + }, + }, +}; + +// Minimal pages example +export const MinimalPages = Template.bind({}); +MinimalPages.args = { + ...Basic.args, + totalItems: 30, + pageSize: 10, +}; +MinimalPages.parameters = { + docs: { + description: { + story: 'Pagination with only a few pages using text theme', + }, + }, +}; + +// Example with increased sibling count +export const SiblingCountTwo = Template.bind({}); +SiblingCountTwo.args = { + ...Basic.args, + totalItems: 250, + siblingCount: 2, + currentPage: 5, +}; +SiblingCountTwo.parameters = { + docs: { + description: { + story: 'Pagination showing two sibling pages around the current page.', + }, + }, +}; + +// Example with increased boundary count +export const BoundaryCountTwo = Template.bind({}); +BoundaryCountTwo.args = { + ...Basic.args, + totalItems: 250, + boundaryCount: 2, + currentPage: 10, +}; +BoundaryCountTwo.parameters = { + docs: { + description: { + story: 'Pagination with two boundary pages displayed at the start and end.', + }, + }, +}; + +// Combination of increased sibling and boundary count +export const SiblingAndBoundary = Template.bind({}); +SiblingAndBoundary.args = { + ...Basic.args, + totalItems: 250, + siblingCount: 2, + boundaryCount: 2, + currentPage: 12, +}; +SiblingAndBoundary.parameters = { + docs: { + description: { + story: 'Pagination showing two sibling pages and two boundary pages.', + }, + }, +}; diff --git a/frontend/src/Components/Pagination/PagedPagination.tsx b/frontend/src/Components/Pagination/PagedPagination.tsx new file mode 100644 index 000000000..2eb18487a --- /dev/null +++ b/frontend/src/Components/Pagination/PagedPagination.tsx @@ -0,0 +1,115 @@ +import { Icon } from '@iconify/react'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import styles from './PagedPagination.module.scss'; +import { Pagination, PaginationButton, PaginationContent, PaginationEllipsis, PaginationItem } from './components'; + +type PagedPaginationPaginationItemType = (number | 'ellipsis')[]; + +interface PagedPaginationnProps { + currentPage: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; + siblingCount?: number; // Controls the number of sibling pages around the current page + boundaryCount?: number; // Controls the number of boundary pages on each end + paginationClassName?: string; + itemClassName?: string; +} + +// Helper function to generate sequential page numbers +const generateSequentialPages = (start: number, end: number): number[] => { + return Array.from({ length: end - start + 1 }, (_, i) => start + i); +}; + +// Adjusted ellipsis helper functions +const showStartEllipsis = (current: number, boundaryCount: number, siblingCount: number): boolean => + boundaryCount > 0 && siblingCount > 0 && current > boundaryCount + siblingCount + 1; +const showEndEllipsis = (current: number, total: number, boundaryCount: number, siblingCount: number): boolean => + boundaryCount > 0 && siblingCount > 0 && current < total - boundaryCount - siblingCount; + +export function PagedPagination({ + currentPage, + totalItems, + pageSize, + onPageChange, + siblingCount = 1, + boundaryCount = 1, + paginationClassName, + itemClassName, +}: PagedPaginationnProps) { + const totalPages = Math.ceil(totalItems / pageSize); + + const paginationItems = useMemo(() => { + const pages: PagedPaginationPaginationItemType = []; + const startPages = generateSequentialPages(1, Math.min(boundaryCount, totalPages)); + const endPages = generateSequentialPages(Math.max(totalPages - boundaryCount + 1, boundaryCount + 1), totalPages); + + // Early return for simple pagination case + if (totalPages <= 7 + siblingCount * 2 + boundaryCount * 2) { + return generateSequentialPages(1, totalPages); + } + + // Add boundary pages at the start + pages.push(...startPages); + + // Conditionally add start ellipsis + if (showStartEllipsis(currentPage, boundaryCount, siblingCount)) { + pages.push('ellipsis'); + } + + // Add sibling pages around the current page + const startSibling = Math.max(boundaryCount + 1, currentPage - siblingCount); + const endSibling = Math.min(totalPages - boundaryCount, currentPage + siblingCount); + pages.push(...generateSequentialPages(startSibling, endSibling)); + + // Conditionally add end ellipsis + if (showEndEllipsis(currentPage, totalPages, boundaryCount, siblingCount)) { + pages.push('ellipsis'); + } + + // Add boundary pages at the end + pages.push(...endPages); + + return pages; + }, [currentPage, totalPages, siblingCount, boundaryCount]); + + return ( +
+ + + + } + onClick={() => currentPage > 1 && onPageChange(currentPage - 1)} + disabled={currentPage === 1} + /> + + + {paginationItems.map((page, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + {page === 'ellipsis' ? ( + + ) : ( + onPageChange(page)} + /> + )} + + ))} + + + } + onClick={() => currentPage < totalPages && onPageChange(currentPage + 1)} + disabled={currentPage === totalPages} + /> + + + +
+ ); +} diff --git a/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss b/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss new file mode 100644 index 000000000..7385cf5f2 --- /dev/null +++ b/frontend/src/Components/Pagination/components/Pagination/Pagination.module.scss @@ -0,0 +1,5 @@ +.nav { + display: flex; + align-items: center; + width: 100%; +} diff --git a/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx b/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx new file mode 100644 index 000000000..eb9624caa --- /dev/null +++ b/frontend/src/Components/Pagination/components/Pagination/Pagination.tsx @@ -0,0 +1,8 @@ +import classNames from 'classnames'; +import React from 'react'; +import styles from './Pagination.module.scss'; + +export const Pagination = React.forwardRef>(({ className, ...props }, ref) => ( +