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:
+
+
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:
+
+
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:
+
+
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:
+
+
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.
+
+
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 (
+