From 6d79f1d29719dd7182763ea94b6099de4df1b4d3 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 17 Jul 2023 10:46:10 +0100 Subject: [PATCH 01/72] Fix last name validation on user manager --- backend/app/models/user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 2458205..208d401 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -8,7 +8,7 @@ class UserManager(BaseUserManager): """ - Custom user model manager with email is the identifier. + Custom user model manager with email as the identifier. """ model: Type["User"] @@ -19,7 +19,7 @@ def create_user(self, email, password, first_name, last_name, **extra_fields): if not first_name: raise ValueError("Users must have a first name") if not last_name: - raise ValueError("Users must have a password") + raise ValueError("Users must have a last name") email = self.normalize_email(email) user = self.model( email=email, first_name=first_name, last_name=last_name, **extra_fields From 614df12561e30b7ea20ee3600f13f0b326390051 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 17 Jul 2023 10:48:29 +0100 Subject: [PATCH 02/72] Rename all application references to projects --- .dictionary/custom.txt | 1 + backend/app/migrations/0001_initial.py | 89 +++++++++---------- backend/app/models/__init__.py | 2 +- .../app/models/{application.py => project.py} | 10 +-- ...on_serializer.py => project_serializer.py} | 20 ++--- backend/app/tests/test_views.py | 18 ++-- backend/app/utils/permissions.py | 22 ++--- backend/app/views/application_views.py | 56 ------------ backend/app/views/project_views.py | 56 ++++++++++++ backend/backend/urls.py | 10 +-- 10 files changed, 142 insertions(+), 142 deletions(-) rename backend/app/models/{application.py => project.py} (69%) rename backend/app/serializers/{application_serializer.py => project_serializer.py} (60%) delete mode 100644 backend/app/views/application_views.py create mode 100644 backend/app/views/project_views.py diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 6415565..8cf50fc 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -1,3 +1,4 @@ +projs evenodd heebo svgr diff --git a/backend/app/migrations/0001_initial.py b/backend/app/migrations/0001_initial.py index 0370128..75e13f4 100644 --- a/backend/app/migrations/0001_initial.py +++ b/backend/app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.5 on 2023-05-12 12:00 +# Generated by Django 4.1.5 on 2023-07-17 09:42 from django.conf import settings import django.core.validators @@ -111,21 +111,6 @@ class Migration(migrations.Migration): "abstract": False, }, ), - migrations.CreateModel( - name="Application", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=100)), - ], - ), migrations.CreateModel( name="Element", fields=[ @@ -262,6 +247,21 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="Project", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ], + ), migrations.CreateModel( name="Selector", fields=[ @@ -286,30 +286,8 @@ class Migration(migrations.Migration): ), ], ), - migrations.AddField( - model_name="element", - name="event", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="associated_elements", - to="app.event", - ), - ), - migrations.AddField( - model_name="element", - name="parent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="children", - to="app.element", - ), - ), migrations.CreateModel( - name="ApplicationMembership", + name="ProjectMembership", fields=[ ( "id", @@ -329,10 +307,9 @@ class Migration(migrations.Migration): ), ), ( - "application", + "project", models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="app.application", + on_delete=django.db.models.deletion.CASCADE, to="app.project" ), ), ( @@ -345,14 +322,36 @@ class Migration(migrations.Migration): ], ), migrations.AddField( - model_name="application", + model_name="project", name="members", field=models.ManyToManyField( - related_name="applications", - through="app.ApplicationMembership", + related_name="projects", + through="app.ProjectMembership", to=settings.AUTH_USER_MODEL, ), ), + migrations.AddField( + model_name="element", + name="event", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="associated_elements", + to="app.event", + ), + ), + migrations.AddField( + model_name="element", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="app.element", + ), + ), migrations.CreateModel( name="ButtonElement", fields=[], diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 41afb4d..4705c4f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,5 +11,5 @@ ButtonElement, ) from .user import User -from .application import Application, ApplicationMembership +from .project import Project, ProjectMembership from .selector import Selector diff --git a/backend/app/models/application.py b/backend/app/models/project.py similarity index 69% rename from backend/app/models/application.py rename to backend/app/models/project.py index 4db3e75..44cc29a 100644 --- a/backend/app/models/application.py +++ b/backend/app/models/project.py @@ -2,25 +2,25 @@ from app.models import User -class Application(models.Model): +class Project(models.Model): name = models.CharField(max_length=100) members = models.ManyToManyField( User, - through="ApplicationMembership", - related_name="applications", + through="ProjectMembership", + related_name="projects", ) def __str__(self): return self.name -class ApplicationMembership(models.Model): +class ProjectMembership(models.Model): class MembershipType(models.TextChoices): ADMIN = "ADM", "Admin" MEMBER = "MEM", "Member" user = models.ForeignKey(User, on_delete=models.CASCADE) - application = models.ForeignKey(Application, on_delete=models.CASCADE) + project = models.ForeignKey(Project, on_delete=models.CASCADE) type = models.CharField( max_length=3, diff --git a/backend/app/serializers/application_serializer.py b/backend/app/serializers/project_serializer.py similarity index 60% rename from backend/app/serializers/application_serializer.py rename to backend/app/serializers/project_serializer.py index 49696a6..9007cb6 100644 --- a/backend/app/serializers/application_serializer.py +++ b/backend/app/serializers/project_serializer.py @@ -1,22 +1,22 @@ from rest_framework import serializers -from app.models import Application -from app.models.application import ApplicationMembership +from app.models import Project +from app.models.project import ProjectMembership -class ApplicationMembershipSerializer(serializers.ModelSerializer): +class ProjectMembershipSerializer(serializers.ModelSerializer): """Used as a nested serializer by ApplicationSerializer.""" class Meta(object): - model = ApplicationMembership + model = ProjectMembership fields = ["id", "user", "type"] depth = 1 -class ApplicationSerializer(serializers.ModelSerializer): +class ProjectSerializer(serializers.ModelSerializer): members = serializers.SerializerMethodField() class Meta: - model = Application + model = Project fields = ["id", "name", "members"] def __init__(self, *args, **kwargs): @@ -33,7 +33,7 @@ def __init__(self, *args, **kwargs): for field_name in existing - allowed: self.fields.pop(field_name) - def get_members(self, obj): - """obj is an Application instance. Returns list of dicts""" - query_set = ApplicationMembership.objects.filter(application=obj) - return [ApplicationMembershipSerializer(m).data for m in query_set] + def get_members(self, obj: Project): + """obj is an Project instance. Returns list of dicts""" + query_set = ProjectMembership.objects.filter(project=obj) + return [ProjectMembershipSerializer(m).data for m in query_set] diff --git a/backend/app/tests/test_views.py b/backend/app/tests/test_views.py index 48f3ab1..1a2dcca 100644 --- a/backend/app/tests/test_views.py +++ b/backend/app/tests/test_views.py @@ -1,7 +1,7 @@ from rest_framework.test import APIClient, APITestCase from rest_framework_simplejwt.tokens import RefreshToken from rest_framework import status -from app.models import User, Application +from app.models import User, Project class TestViews(APITestCase): @@ -25,9 +25,9 @@ def api_client( client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}") return client - def test_application_views(self): + def test_project_views(self): """ - Test creation and retrieval /application endpoints. + Test creation and retrieval /project endpoints. """ client = self.api_client( email="tom@opencrumpet.com", @@ -35,18 +35,18 @@ def test_application_views(self): first_name="Tom", last_name="Titherington", ) - application_name: str = "Test Application" + project_name: str = "Test Project" response = client.post( - "/api/applications/", {"name": application_name}, format="json" + "/api/projects/", {"name": project_name}, format="json" ) self.assertEquals(response.status_code, status.HTTP_201_CREATED) - response = client.get("/api/applications/") + response = client.get("/api/projects/") self.assertEquals(response.status_code, status.HTTP_200_OK) - response = client.get("/api/application/1/") + response = client.get("/api/project/1/") self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data["name"], application_name) + self.assertEquals(response.data["name"], project_name) another_client = self.api_client( email="tom.titherington@gmail.com", @@ -54,7 +54,7 @@ def test_application_views(self): first_name="Tom", last_name="Titherington", ) - response = another_client.get("/api/application/1/") + response = another_client.get("/api/project/1/") self.assertEquals(response.status_code, status.HTTP_403_FORBIDDEN) def test_user_views(self): diff --git a/backend/app/utils/permissions.py b/backend/app/utils/permissions.py index d1f1b68..28036b0 100644 --- a/backend/app/utils/permissions.py +++ b/backend/app/utils/permissions.py @@ -4,25 +4,25 @@ from rest_framework.permissions import BasePermission from rest_framework.request import Request -from app.models import Application -from app.models.application import ApplicationMembership +from app.models import Project +from app.models.project import ProjectMembership from app.models.user import User -def get_application(object: Model) -> Application: - if isinstance(object, Application): +def get_project(object: Model) -> Project: + if isinstance(object, Project): return object - raise ValueError("Object not an instance of Application.") + raise ValueError("Object not an instance of Project.") -class ApplicationMemberPermission(BasePermission): - """Require application membership to perform any CRUD operation.""" +class ProjectMemberPermission(BasePermission): + """Require project membership to perform any CRUD operation.""" - message = "You do not have access to this application." + message = "You do not have access to this project." def has_object_permission(self, request: Request, view, object: Model) -> bool: - application = get_application(object) - return ApplicationMembership.objects.filter( + project = get_project(object) + return ProjectMembership.objects.filter( user=cast(User, request.user), - application=application, + project=project, ).exists() diff --git a/backend/app/views/application_views.py b/backend/app/views/application_views.py deleted file mode 100644 index a585eb0..0000000 --- a/backend/app/views/application_views.py +++ /dev/null @@ -1,56 +0,0 @@ -from rest_framework import mixins, generics, exceptions, status -from rest_framework.permissions import IsAuthenticated -from rest_framework.views import Response - - -from app.serializers.application_serializer import ApplicationSerializer -from app.models import Application, ApplicationMembership -from app.utils.permissions import ApplicationMemberPermission - - -class ApplicationList(generics.GenericAPIView): - queryset = Application.objects.all() - serializer_class = ApplicationSerializer - - def get(self, request, format=None): - user_apps = Application.objects.filter(members__pk=request.user.pk) - serializer = ApplicationSerializer(user_apps, many=True) - return Response(serializer.data) - - def post(self, request, format=None): - serializer = ApplicationSerializer(data=request.data, fields=("id", "name")) - if serializer.is_valid(): - application = serializer.save() - # create link membership - ApplicationMembership.objects.create( - application=application, - user=request.user, - type=ApplicationMembership.MembershipType.ADMIN, - ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class ApplicationDetail( - generics.GenericAPIView, mixins.UpdateModelMixin, mixins.DestroyModelMixin -): - """ - Retrieve, update and delete application instances. - """ - - queryset = Application.objects.all() - serializer_class = ApplicationSerializer - permission_classes = [IsAuthenticated, ApplicationMemberPermission] - - def get_object(self, pk, user) -> Application: - try: - app = Application.objects.get(pk=pk) - self.check_object_permissions(self.request, app) - return app - except Application.DoesNotExist: - raise exceptions.NotFound - - def get(self, request, pk, format=None): - application = self.get_object(pk=pk, user=request.user) - serializer = ApplicationSerializer(application) - return Response(serializer.data) diff --git a/backend/app/views/project_views.py b/backend/app/views/project_views.py new file mode 100644 index 0000000..0957669 --- /dev/null +++ b/backend/app/views/project_views.py @@ -0,0 +1,56 @@ +from rest_framework import mixins, generics, exceptions, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.views import Response + + +from app.serializers.project_serializer import ProjectSerializer +from app.models import Project, ProjectMembership +from app.utils.permissions import ProjectMemberPermission + + +class ProjectList(generics.GenericAPIView): + queryset = Project.objects.all() + serializer_class = ProjectSerializer + + def get(self, request, format=None): + user_projs = Project.objects.filter(members__pk=request.user.pk) + serializer = ProjectSerializer(user_projs, many=True) + return Response(serializer.data) + + def post(self, request, format=None): + serializer = ProjectSerializer(data=request.data, fields=("id", "name")) + if serializer.is_valid(): + project = serializer.save() + # create link membership + ProjectMembership.objects.create( + project=project, + user=request.user, + type=ProjectMembership.MembershipType.ADMIN, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectDetail( + generics.GenericAPIView, mixins.UpdateModelMixin, mixins.DestroyModelMixin +): + """ + Retrieve, update and delete project instances. + """ + + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permission_classes = [IsAuthenticated, ProjectMemberPermission] + + def get_object(self, pk, user) -> Project: + try: + proj = Project.objects.get(pk=pk) + self.check_object_permissions(self.request, proj) + return proj + except Project.DoesNotExist: + raise exceptions.NotFound + + def get(self, request, pk, format=None): + project = self.get_object(pk=pk, user=request.user) + serializer = ProjectSerializer(project) + return Response(serializer.data) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index c13a1a9..d3b88bf 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -25,7 +25,7 @@ from rest_framework import routers -from app.views.application_views import ApplicationDetail, ApplicationList +from app.views.project_views import ProjectDetail, ProjectList from app.views.register_view import RegisterView from app.views.user_view import UserDetailView @@ -42,11 +42,11 @@ # API Views path("api/register/", RegisterView.as_view(), name="register"), path("api/user/", UserDetailView.as_view(), name="user-detail"), - path("api/applications/", ApplicationList.as_view(), name="application-list"), + path("api/projects/", ProjectList.as_view(), name="project-list"), path( - "api/application//", - ApplicationDetail.as_view(), - name="application-detail", + "api/project//", + ProjectDetail.as_view(), + name="project-detail", ), # Meta Views path( From fb8d737311f26cc46d6604eeddd2f8eb035e15c1 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 17 Jul 2023 12:55:37 +0100 Subject: [PATCH 03/72] Refactor project views to use ModelViewSet and add tests --- .dictionary/custom.txt | 1 + backend/app/tests/base_api_test.py | 26 ++++++++++++ backend/app/tests/test_projects.py | 64 ++++++++++++++++++++++++++++++ backend/app/tests/test_views.py | 32 --------------- backend/app/views/project_views.py | 54 ++++++++----------------- backend/backend/urls.py | 12 ++---- 6 files changed, 111 insertions(+), 78 deletions(-) create mode 100644 backend/app/tests/base_api_test.py create mode 100644 backend/app/tests/test_projects.py diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 8cf50fc..6e7f155 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -1,3 +1,4 @@ +viewsets projs evenodd heebo diff --git a/backend/app/tests/base_api_test.py b/backend/app/tests/base_api_test.py new file mode 100644 index 0000000..8fac635 --- /dev/null +++ b/backend/app/tests/base_api_test.py @@ -0,0 +1,26 @@ +from rest_framework.test import APITestCase, APIClient +from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth import get_user_model +from rest_framework import status + +User = get_user_model() + + +class BaseAPITest(APITestCase): + def create_user_and_return_client( + self, + email: str, + password: str, + first_name: str, + last_name: str, + ) -> APIClient: + user = User.objects.create_user( + email=email, + password=password, + first_name=first_name, + last_name=last_name, + ) + client = APIClient() + refresh = RefreshToken.for_user(user) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}") + return client diff --git a/backend/app/tests/test_projects.py b/backend/app/tests/test_projects.py new file mode 100644 index 0000000..92ed530 --- /dev/null +++ b/backend/app/tests/test_projects.py @@ -0,0 +1,64 @@ +from rest_framework import status +from app.models import Project, ProjectMembership, User +from .base_api_test import BaseAPITest + + +class ProjectViewTest(BaseAPITest): + def setUp(self): + self.user1_client = self.create_user_and_return_client( + "user1@test.com", "pass", "User", "One" + ) + self.user2_client = self.create_user_and_return_client( + "user2@test.com", "pass", "User", "Two" + ) + + self.user1 = User.objects.get(email="user1@test.com") + self.user2 = User.objects.get(email="user2@test.com") + + self.project1 = Project.objects.create(name="project1") + self.project2 = Project.objects.create(name="project2") + + ProjectMembership.objects.create( + user=self.user1, project=self.project1, type=ProjectMembership.MembershipType.ADMIN + ) + ProjectMembership.objects.create( + user=self.user2, project=self.project2, type=ProjectMembership.MembershipType.ADMIN + ) + + def test_user_can_access_their_projects(self): + response = self.user1_client.get("/projects/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["name"], "project1") + + def test_user_cannot_access_other_users_projects(self): + response = self.user1_client.get("/projects/") + for project in response.data: + self.assertNotEqual(project["name"], "project2") + + def test_user_can_create_a_project(self): + response = self.user1_client.post("/projects/", {"name": "new_project"}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.filter(name="new_project").exists(), True) + + def test_user_can_update_their_project(self): + response = self.user1_client.patch( + f"/projects/{self.project1.id}/", {"name": "updated_project"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(id=self.project1.id).name, "updated_project") + + def test_user_cannot_update_other_users_project(self): + response = self.user1_client.patch( + f"/projects/{self.project2.id}/", {"name": "updated_project"} + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_user_can_delete_their_project(self): + response = self.user1_client.delete(f"/projects/{self.project1.id}/") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.filter(id=self.project1.id).exists(), False) + + def test_user_cannot_delete_other_users_project(self): + response = self.user1_client.delete(f"/projects/{self.project2.id}/") + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/app/tests/test_views.py b/backend/app/tests/test_views.py index 1a2dcca..ce11a0e 100644 --- a/backend/app/tests/test_views.py +++ b/backend/app/tests/test_views.py @@ -25,38 +25,6 @@ def api_client( client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}") return client - def test_project_views(self): - """ - Test creation and retrieval /project endpoints. - """ - client = self.api_client( - email="tom@opencrumpet.com", - password="aVerYSecurEpassw0rd", - first_name="Tom", - last_name="Titherington", - ) - project_name: str = "Test Project" - response = client.post( - "/api/projects/", {"name": project_name}, format="json" - ) - self.assertEquals(response.status_code, status.HTTP_201_CREATED) - - response = client.get("/api/projects/") - self.assertEquals(response.status_code, status.HTTP_200_OK) - - response = client.get("/api/project/1/") - self.assertEquals(response.status_code, status.HTTP_200_OK) - self.assertEquals(response.data["name"], project_name) - - another_client = self.api_client( - email="tom.titherington@gmail.com", - password="aVerYSecurEpassw0rd", - first_name="Tom", - last_name="Titherington", - ) - response = another_client.get("/api/project/1/") - self.assertEquals(response.status_code, status.HTTP_403_FORBIDDEN) - def test_user_views(self): """ Test /user endpoints. diff --git a/backend/app/views/project_views.py b/backend/app/views/project_views.py index 0957669..c4de171 100644 --- a/backend/app/views/project_views.py +++ b/backend/app/views/project_views.py @@ -1,6 +1,5 @@ -from rest_framework import mixins, generics, exceptions, status +from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated -from rest_framework.views import Response from app.serializers.project_serializer import ProjectSerializer @@ -8,49 +7,28 @@ from app.utils.permissions import ProjectMemberPermission -class ProjectList(generics.GenericAPIView): +class ProjectsView(viewsets.ModelViewSet): queryset = Project.objects.all() serializer_class = ProjectSerializer + permission_classes = [IsAuthenticated, ProjectMemberPermission] + + def get_queryset(self): + return self.queryset.filter(members__pk=self.request.user.pk) - def get(self, request, format=None): - user_projs = Project.objects.filter(members__pk=request.user.pk) - serializer = ProjectSerializer(user_projs, many=True) - return Response(serializer.data) + def get_object(self): + # Fetch the object and check if the request user has the necessary permissions. + # Note: If the object is not in the queryset returned by get_queryset (i.e. the request user is not a member of the project), + # a Http404 exception will be raised before permissions are even checked. This is a security feature to prevent revealing + # the existence of an object that the user doesn't have access to (security through obscurity). + obj = super().get_object() + self.check_object_permissions(self.request, obj) + return obj - def post(self, request, format=None): - serializer = ProjectSerializer(data=request.data, fields=("id", "name")) + def perform_create(self, serializer): if serializer.is_valid(): project = serializer.save() - # create link membership ProjectMembership.objects.create( project=project, - user=request.user, + user=self.request.user, type=ProjectMembership.MembershipType.ADMIN, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class ProjectDetail( - generics.GenericAPIView, mixins.UpdateModelMixin, mixins.DestroyModelMixin -): - """ - Retrieve, update and delete project instances. - """ - - queryset = Project.objects.all() - serializer_class = ProjectSerializer - permission_classes = [IsAuthenticated, ProjectMemberPermission] - - def get_object(self, pk, user) -> Project: - try: - proj = Project.objects.get(pk=pk) - self.check_object_permissions(self.request, proj) - return proj - except Project.DoesNotExist: - raise exceptions.NotFound - - def get(self, request, pk, format=None): - project = self.get_object(pk=pk, user=request.user) - serializer = ProjectSerializer(project) - return Response(serializer.data) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index d3b88bf..8a5314b 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -25,13 +25,15 @@ from rest_framework import routers -from app.views.project_views import ProjectDetail, ProjectList +from app.views.project_views import ProjectsView from app.views.register_view import RegisterView from app.views.user_view import UserDetailView router = routers.SimpleRouter() +router.register(r"projects", ProjectsView, basename="projects") + urlpatterns = [ # Admin path("admin/", admin.site.urls), @@ -42,12 +44,6 @@ # API Views path("api/register/", RegisterView.as_view(), name="register"), path("api/user/", UserDetailView.as_view(), name="user-detail"), - path("api/projects/", ProjectList.as_view(), name="project-list"), - path( - "api/project//", - ProjectDetail.as_view(), - name="project-detail", - ), # Meta Views path( "openapi", @@ -55,7 +51,7 @@ title="backend", description="The rest api for backend", version="1.0.0", - permission_classes=[] + permission_classes=[], ), name="openapi-schema", ), From 9695f7ae53449758109a2b80c5f7a575845cdec0 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 17 Jul 2023 14:04:03 +0100 Subject: [PATCH 04/72] Modify projects view to prevent non-admins updating and deleting --- backend/app/permissions/__init__.py | 1 + backend/app/permissions/base.py | 7 ++++++ .../projects.py} | 14 ++++++++++-- backend/app/tests/test_projects.py | 22 +++++++++++++++++++ backend/app/views/project_views.py | 7 +++++- 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 backend/app/permissions/__init__.py create mode 100644 backend/app/permissions/base.py rename backend/app/{utils/permissions.py => permissions/projects.py} (59%) diff --git a/backend/app/permissions/__init__.py b/backend/app/permissions/__init__.py new file mode 100644 index 0000000..fdefc01 --- /dev/null +++ b/backend/app/permissions/__init__.py @@ -0,0 +1 @@ +from .projects import ProjectAdminPermission, ProjectMemberPermission diff --git a/backend/app/permissions/base.py b/backend/app/permissions/base.py new file mode 100644 index 0000000..36d945b --- /dev/null +++ b/backend/app/permissions/base.py @@ -0,0 +1,7 @@ +from rest_framework import permissions + + +class CrumpetBasePermission(permissions.BasePermission): + """ + A base class for custom permissions. + """ diff --git a/backend/app/utils/permissions.py b/backend/app/permissions/projects.py similarity index 59% rename from backend/app/utils/permissions.py rename to backend/app/permissions/projects.py index 28036b0..1922dd2 100644 --- a/backend/app/utils/permissions.py +++ b/backend/app/permissions/projects.py @@ -7,6 +7,7 @@ from app.models import Project from app.models.project import ProjectMembership from app.models.user import User +from .base import CrumpetBasePermission def get_project(object: Model) -> Project: @@ -15,13 +16,22 @@ def get_project(object: Model) -> Project: raise ValueError("Object not an instance of Project.") +class ProjectAdminPermission(CrumpetBasePermission): + """Only allow admin project members to update and delete projects.""" + + def has_object_permission(self, request, view, obj): + return ProjectMembership.objects.filter( + user=request.user, project=obj, type=ProjectMembership.MembershipType.ADMIN + ).exists() + + class ProjectMemberPermission(BasePermission): """Require project membership to perform any CRUD operation.""" message = "You do not have access to this project." - def has_object_permission(self, request: Request, view, object: Model) -> bool: - project = get_project(object) + def has_object_permission(self, request: Request, view, obj: Model) -> bool: + project = get_project(obj) return ProjectMembership.objects.filter( user=cast(User, request.user), project=project, diff --git a/backend/app/tests/test_projects.py b/backend/app/tests/test_projects.py index 92ed530..6f7d9cb 100644 --- a/backend/app/tests/test_projects.py +++ b/backend/app/tests/test_projects.py @@ -11,9 +11,13 @@ def setUp(self): self.user2_client = self.create_user_and_return_client( "user2@test.com", "pass", "User", "Two" ) + self.user3_client = self.create_user_and_return_client( + "user3@test.com", "pass", "User", "Three" + ) self.user1 = User.objects.get(email="user1@test.com") self.user2 = User.objects.get(email="user2@test.com") + self.user3 = User.objects.get(email="user3@test.com") self.project1 = Project.objects.create(name="project1") self.project2 = Project.objects.create(name="project2") @@ -25,6 +29,10 @@ def setUp(self): user=self.user2, project=self.project2, type=ProjectMembership.MembershipType.ADMIN ) + ProjectMembership.objects.create( + user=self.user3, project=self.project1, type=ProjectMembership.MembershipType.MEMBER + ) + def test_user_can_access_their_projects(self): response = self.user1_client.get("/projects/") self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -62,3 +70,17 @@ def test_user_can_delete_their_project(self): def test_user_cannot_delete_other_users_project(self): response = self.user1_client.delete(f"/projects/{self.project2.id}/") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_non_admin_user_cannot_update_project(self): + response = self.user3_client.patch( + f"/projects/{self.project1.id}/", {"name": "updated_project"} + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # Make sure the name did not change + self.assertNotEqual(Project.objects.get(id=self.project1.id).name, "updated_project") + + def test_non_admin_user_cannot_delete_project(self): + response = self.user3_client.delete(f"/projects/{self.project1.id}/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + # Make sure the project still exists + self.assertEqual(Project.objects.filter(id=self.project1.id).exists(), True) diff --git a/backend/app/views/project_views.py b/backend/app/views/project_views.py index c4de171..e5bec52 100644 --- a/backend/app/views/project_views.py +++ b/backend/app/views/project_views.py @@ -4,7 +4,7 @@ from app.serializers.project_serializer import ProjectSerializer from app.models import Project, ProjectMembership -from app.utils.permissions import ProjectMemberPermission +from app.permissions import ProjectMemberPermission, ProjectAdminPermission class ProjectsView(viewsets.ModelViewSet): @@ -24,6 +24,11 @@ def get_object(self): self.check_object_permissions(self.request, obj) return obj + def get_permissions(self): + if self.action in ["update", "partial_update", "destroy"]: + self.permission_classes = [IsAuthenticated, ProjectAdminPermission] + return super().get_permissions() + def perform_create(self, serializer): if serializer.is_valid(): project = serializer.save() From b5c3243aa79bac9b4f5a1c2107c0480d7b7a843f Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 17 Jul 2023 14:29:24 +0100 Subject: [PATCH 05/72] Add new router to allow optional trailing slash --- backend/app/utils/__init__.py | 1 + backend/app/utils/routing.py | 9 +++++++++ backend/backend/urls.py | 6 +++--- backend/requirements.txt | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 backend/app/utils/routing.py diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py index e69de29..900ea0f 100644 --- a/backend/app/utils/__init__.py +++ b/backend/app/utils/__init__.py @@ -0,0 +1 @@ +from .routing import OptionalTrailingSlashRouter diff --git a/backend/app/utils/routing.py b/backend/app/utils/routing.py new file mode 100644 index 0000000..75e7c81 --- /dev/null +++ b/backend/app/utils/routing.py @@ -0,0 +1,9 @@ +from rest_framework_extensions.routers import ExtendedDefaultRouter + + +class OptionalTrailingSlashRouter(ExtendedDefaultRouter): + """DefaultRouter with optional trailing slash and drf-extensions nesting.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.trailing_slash = r"/?" diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 8a5314b..8454895 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -24,13 +24,13 @@ ) -from rest_framework import routers +from app.utils import OptionalTrailingSlashRouter from app.views.project_views import ProjectsView - from app.views.register_view import RegisterView from app.views.user_view import UserDetailView -router = routers.SimpleRouter() + +router = OptionalTrailingSlashRouter() router.register(r"projects", ProjectsView, basename="projects") diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b1122d..55e223c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,6 +11,7 @@ django-cors-headers==3.13.0 django-rest-swagger==2.2.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 +drf-extensions-0.7.1 et-xmlfile==1.1.0 filelock==3.9.0 flake8==6.0.0 From 2338867c805e6599f313307780d4cc093e39ec16 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 3 Sep 2023 14:30:25 +0100 Subject: [PATCH 06/72] Connect login to use api and generate sdk stubs - OpenAPI schema has been generated and added to frontend - urls changed slightly (removed /api/ prefix ) - Small changes to login page to call login logic / useAuth hook --- .dictionary/custom.txt | 4 + backend/.env | 10 +- backend/app/tests/__init__.py | 3 + backend/app/tests/test_user_auth.py | 4 +- backend/app/tests/test_views.py | 2 +- backend/backend/urls.py | 10 +- backend/requirements.txt | 12 +- frontend/openapi-schema.yml | 335 +++ frontend/src/api/auth/useAuthentication.ts | 2 +- .../src/api/schema/.openapi-generator/VERSION | 2 +- frontend/src/api/schema/api.ts | 2102 ++++------------- frontend/src/api/schema/common.ts | 2 +- frontend/src/components/sidebarMenu/index.tsx | 80 +- frontend/src/pages/Authentication/useLogin.ts | 19 +- frontend/src/routes/useRouter.tsx | 44 +- 15 files changed, 890 insertions(+), 1741 deletions(-) create mode 100644 frontend/openapi-schema.yml diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 6e7f155..536f378 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -71,3 +71,7 @@ xlwt xmlfile gitcommitmessage headlessui +isort +pylint +tomli +tomlkit diff --git a/backend/.env b/backend/.env index f218ca7..54131f2 100644 --- a/backend/.env +++ b/backend/.env @@ -2,9 +2,9 @@ DEBUG=True DEVELOPMENT_MODE=True DJANGO_ALLOWED_HOSTS='localhost,0.0.0.0,127.0.0.1,192.168.1.220' -DB_NAME = default_db -DB_USERNAME = default_admin -DB_PASSWORD = default_password -DB_HOST = default_host -DB_PORT = 25060 +DB_NAME = crumpet_db +DB_USERNAME = head_baker +DB_PASSWORD = Crumpet2023 +DB_HOST = localhost +DB_PORT = 5432 DB_SSL_MODE = require diff --git a/backend/app/tests/__init__.py b/backend/app/tests/__init__.py index 91bf3d1..9c4d0ae 100644 --- a/backend/app/tests/__init__.py +++ b/backend/app/tests/__init__.py @@ -1 +1,4 @@ from .test_openapi import * +from .test_user_auth import * +from .test_views import * +from .test_projects import * diff --git a/backend/app/tests/test_user_auth.py b/backend/app/tests/test_user_auth.py index fcd4e33..0fe74e4 100644 --- a/backend/app/tests/test_user_auth.py +++ b/backend/app/tests/test_user_auth.py @@ -7,7 +7,7 @@ class TestUserAuth(APITestCase): def test_register(self): response = self.client.post( - "/api/register/", + "/register/", { "email": "tom@opencrumpet.com", "password": "aVerYSecurEpassw0rd!", @@ -27,7 +27,7 @@ def test_auth(self): user.save() response = self.client.post( - "/api/token/", + "/token/", { "first_name": "Tom", "last_name": "Titherington", diff --git a/backend/app/tests/test_views.py b/backend/app/tests/test_views.py index ce11a0e..eae1354 100644 --- a/backend/app/tests/test_views.py +++ b/backend/app/tests/test_views.py @@ -36,5 +36,5 @@ def test_user_views(self): last_name="Titherington", ) - response = client.delete("/api/user/") + response = client.delete("/user/") self.assertEquals(response.status_code, status.HTTP_200_OK) diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 8454895..7ef5e93 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -38,12 +38,12 @@ # Admin path("admin/", admin.site.urls), # Auth - path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), - path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), - path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), # API Views - path("api/register/", RegisterView.as_view(), name="register"), - path("api/user/", UserDetailView.as_view(), name="user-detail"), + path("register/", RegisterView.as_view(), name="register"), + path("user/", UserDetailView.as_view(), name="user-detail"), # Meta Views path( "openapi", diff --git a/backend/requirements.txt b/backend/requirements.txt index 55e223c..0880323 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,9 +1,11 @@ asgiref==3.6.0 +astroid==2.15.6 certifi==2022.12.7 cfgv==3.3.1 charset-normalizer==3.0.1 coreapi==2.3.3 coreschema==0.0.4 +dill==0.3.6 distlib==0.3.6 dj-database-url==1.2.0 Django==4.1.5 @@ -11,15 +13,18 @@ django-cors-headers==3.13.0 django-rest-swagger==2.2.0 djangorestframework==3.14.0 djangorestframework-simplejwt==5.2.2 -drf-extensions-0.7.1 +drf-extensions==0.7.1 et-xmlfile==1.1.0 filelock==3.9.0 flake8==6.0.0 +flake8-django==1.3 gunicorn==20.1.0 identify==2.5.18 idna==3.4 +isort==5.12.0 itypes==1.2.0 Jinja2==3.1.2 +lazy-object-proxy==1.9.0 MarkupSafe==2.1.2 mccabe==0.7.0 nodeenv==1.7.0 @@ -31,15 +36,20 @@ psycopg2-binary==2.9.5 pycodestyle==2.10.0 pyflakes==3.0.1 PyJWT==1.7.1 +pylint==2.17.4 python-dotenv==0.21.1 pytz==2022.7.1 PyYAML==6.0 requests==2.28.2 simplejson==3.18.3 sqlparse==0.4.3 +tomli==2.0.1 +tomlkit==0.11.8 +typing_extensions==4.7.1 uritemplate==4.1.1 urllib3==1.26.14 virtualenv==20.19.0 +wrapt==1.15.0 xlrd==2.0.1 xlutils==2.0.0 xlwt==1.3.0 diff --git a/frontend/openapi-schema.yml b/frontend/openapi-schema.yml new file mode 100644 index 0000000..1878190 --- /dev/null +++ b/frontend/openapi-schema.yml @@ -0,0 +1,335 @@ +openapi: 3.0.2 +info: + title: '' + version: '' +paths: + /user/: + get: + operationId: listUserDetails + description: Return the user details. + parameters: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: {} + description: '' + tags: + - user + delete: + operationId: destroyUserDetail + description: Delete the current user. + parameters: [] + responses: + '204': + description: '' + tags: + - user + /projects/: + get: + operationId: listProjects + description: '' + parameters: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + description: '' + tags: + - projects + post: + operationId: createProject + description: '' + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Project' + multipart/form-data: + schema: + $ref: '#/components/schemas/Project' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + description: '' + tags: + - projects + /projects/{id}/: + get: + operationId: retrieveProject + description: '' + parameters: + - name: id + in: path + required: true + description: A unique integer value identifying this project. + schema: + type: string + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + description: '' + tags: + - projects + put: + operationId: updateProject + description: '' + parameters: + - name: id + in: path + required: true + description: A unique integer value identifying this project. + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Project' + multipart/form-data: + schema: + $ref: '#/components/schemas/Project' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + description: '' + tags: + - projects + patch: + operationId: partialUpdateProject + description: '' + parameters: + - name: id + in: path + required: true + description: A unique integer value identifying this project. + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Project' + multipart/form-data: + schema: + $ref: '#/components/schemas/Project' + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + description: '' + tags: + - projects + delete: + operationId: destroyProject + description: '' + parameters: + - name: id + in: path + required: true + description: A unique integer value identifying this project. + schema: + type: string + responses: + '204': + description: '' + tags: + - projects + /token/: + post: + operationId: createTokenObtainPair + description: 'Takes a set of user credentials and returns an access and refresh + JSON web + + token pair to prove the authentication of those credentials.' + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenObtainPair' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenObtainPair' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenObtainPair' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenObtainPair' + description: '' + tags: + - token + /token/refresh/: + post: + operationId: createTokenRefresh + description: 'Takes a refresh type JSON web token and returns an access type + JSON web + + token if the refresh token is valid.' + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRefresh' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRefresh' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenRefresh' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRefresh' + description: '' + tags: + - token + /token/verify/: + post: + operationId: createTokenVerify + description: 'Takes a token and indicates if it is valid. This view provides + no + + information about a token''s fitness for a particular use.' + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenVerify' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenVerify' + description: '' + tags: + - token + /register/: + post: + operationId: createUser + description: '' + parameters: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Register' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Register' + multipart/form-data: + schema: + $ref: '#/components/schemas/Register' + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/Register' + description: '' + tags: + - register +components: + schemas: + Project: + type: object + properties: + id: + type: integer + readOnly: true + name: + type: string + maxLength: 100 + members: + type: string + readOnly: true + required: + - name + TokenObtainPair: + type: object + properties: + email: + type: string + password: + type: string + writeOnly: true + required: + - email + - password + TokenRefresh: + type: object + properties: + refresh: + type: string + access: + type: string + readOnly: true + required: + - refresh + TokenVerify: + type: object + properties: + token: + type: string + required: + - token + Register: + type: object + properties: + password: + type: string + writeOnly: true + email: + type: string + format: email + first_name: + type: string + maxLength: 150 + last_name: + type: string + maxLength: 150 + required: + - password + - email diff --git a/frontend/src/api/auth/useAuthentication.ts b/frontend/src/api/auth/useAuthentication.ts index 37ed8b4..af39e4f 100644 --- a/frontend/src/api/auth/useAuthentication.ts +++ b/frontend/src/api/auth/useAuthentication.ts @@ -37,7 +37,7 @@ export const useAuthentication = () => { setAuthenticating(true); return await tokenApi .createTokenObtainPair({ - username: email, + email: email, password, }) .then(async response => { diff --git a/frontend/src/api/schema/.openapi-generator/VERSION b/frontend/src/api/schema/.openapi-generator/VERSION index 7f4d792..44bad91 100644 --- a/frontend/src/api/schema/.openapi-generator/VERSION +++ b/frontend/src/api/schema/.openapi-generator/VERSION @@ -1 +1 @@ -6.5.0-SNAPSHOT \ No newline at end of file +7.0.1-SNAPSHOT \ No newline at end of file diff --git a/frontend/src/api/schema/api.ts b/frontend/src/api/schema/api.ts index 9af750c..c4e759e 100644 --- a/frontend/src/api/schema/api.ts +++ b/frontend/src/api/schema/api.ts @@ -26,1664 +26,125 @@ import { BASE_PATH, COLLECTION_FORMATS, BaseAPI, RequiredError } from './base'; /** * * @export - * @interface Batch + * @interface Project */ -export interface Batch { +export interface Project { /** * * @type {number} - * @memberof Batch - */ - 'id'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'feedstock_weight'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'feedstock_moisture_content'?: number; - /** - * - * @type {boolean} - * @memberof Batch - */ - 'testing'?: boolean; - /** - * - * @type {string} - * @memberof Batch - */ - 'created_at'?: string; - /** - * - * @type {string} - * @memberof Batch - */ - 'updated_at'?: string; - /** - * - * @type {number} - * @memberof Batch - */ - 'biochar_produced'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'moisture_content'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a1_diesel_consumed'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a2_diesel_consumed'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_diesel_consumed'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_electricity_consumed'?: number | null; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_feedstock_preparation'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_propane_consumption'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_stack_emissions_n20'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_stack_emissions_ch4'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a3_stack_emissions_c02'?: number; - /** - * - * @type {number} - * @memberof Batch - */ - 'a4_diesel_consumed'?: number; - /** - * - * @type {string} - * @memberof Batch - */ - 'start_date': string; - /** - * - * @type {string} - * @memberof Batch - */ - 'end_date': string; - /** - * - * @type {number} - * @memberof Batch - */ - 'feedstock': number; -} -/** - * - * @export - * @interface BatchCSV - */ -export interface BatchCSV { - /** - * - * @type {number} - * @memberof BatchCSV - */ - 'id'?: number; - /** - * - * @type {string} - * @memberof BatchCSV - */ - 'file_name'?: string; - /** - * - * @type {string} - * @memberof BatchCSV - */ - 'created_at'?: string; - /** - * - * @type {string} - * @memberof BatchCSV - */ - 'updated_at'?: string; - /** - * - * @type {File} - * @memberof BatchCSV - */ - 'csv_file': File; -} -/** - * - * @export - * @interface Feedstock - */ -export interface Feedstock { - /** - * - * @type {number} - * @memberof Feedstock - */ - 'id'?: number; - /** - * - * @type {string} - * @memberof Feedstock - */ - 'feedstock_type'?: string; - /** - * - * @type {number} - * @memberof Feedstock - */ - 'lab_results'?: number | null; -} -/** - * - * @export - * @interface Report - */ -export interface Report { - /** - * - * @type {number} - * @memberof Report + * @memberof Project */ 'id'?: number; /** * * @type {string} - * @memberof Report + * @memberof Project */ - 'file_name'?: string; + 'name': string; /** * * @type {string} - * @memberof Report - */ - 'created_at'?: string; - /** - * - * @type {boolean} - * @memberof Report - */ - 'template'?: boolean; - /** - * - * @type {File} - * @memberof Report + * @memberof Project */ - 'file': File; + 'members'?: string; } /** * * @export - * @interface TokenObtainPair + * @interface Register */ -export interface TokenObtainPair { - /** - * - * @type {string} - * @memberof TokenObtainPair - */ - 'username': string; +export interface Register { /** * * @type {string} - * @memberof TokenObtainPair + * @memberof Register */ 'password': string; -} -/** - * - * @export - * @interface TokenRefresh - */ -export interface TokenRefresh { /** * * @type {string} - * @memberof TokenRefresh + * @memberof Register */ - 'refresh': string; + 'email': string; /** * * @type {string} - * @memberof TokenRefresh + * @memberof Register */ - 'access'?: string; -} -/** - * - * @export - * @interface TokenVerify - */ -export interface TokenVerify { + 'first_name'?: string; /** * * @type {string} - * @memberof TokenVerify + * @memberof Register */ - 'token': string; + 'last_name'?: string; } - -/** - * BatchApi - axios parameter creator - * @export - */ -export const BatchApiAxiosParamCreator = function (configuration?: Configuration) { - return { - /** - * - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createBatch: async (batch?: Batch, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/batch/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batch, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyBatch: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('destroyBatch', 'id', id) - const localVarPath = `/batch/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listBatchs: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/batch/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateBatch: async (id: string, batch?: Batch, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('partialUpdateBatch', 'id', id) - const localVarPath = `/batch/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batch, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveBatch: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('retrieveBatch', 'id', id) - const localVarPath = `/batch/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateBatch: async (id: string, batch?: Batch, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('updateBatch', 'id', id) - const localVarPath = `/batch/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batch, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; - -/** - * BatchApi - functional programming interface - * @export - */ -export const BatchApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = BatchApiAxiosParamCreator(configuration) - return { - /** - * - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async createBatch(batch?: Batch, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createBatch(batch, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async destroyBatch(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.destroyBatch(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listBatchs(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listBatchs(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async partialUpdateBatch(id: string, batch?: Batch, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateBatch(id, batch, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async retrieveBatch(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveBatch(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async updateBatch(id: string, batch?: Batch, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateBatch(id, batch, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - } -}; - -/** - * BatchApi - factory interface - * @export - */ -export const BatchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = BatchApiFp(configuration) - return { - /** - * - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createBatch(batch?: Batch, options?: any): AxiosPromise { - return localVarFp.createBatch(batch, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyBatch(id: string, options?: any): AxiosPromise { - return localVarFp.destroyBatch(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listBatchs(options?: any): AxiosPromise> { - return localVarFp.listBatchs(options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateBatch(id: string, batch?: Batch, options?: any): AxiosPromise { - return localVarFp.partialUpdateBatch(id, batch, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveBatch(id: string, options?: any): AxiosPromise { - return localVarFp.retrieveBatch(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateBatch(id: string, batch?: Batch, options?: any): AxiosPromise { - return localVarFp.updateBatch(id, batch, options).then((request) => request(axios, basePath)); - }, - }; -}; - -/** - * BatchApi - object-oriented interface - * @export - * @class BatchApi - * @extends {BaseAPI} - */ -export class BatchApi extends BaseAPI { - /** - * - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public createBatch(batch?: Batch, options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).createBatch(batch, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public destroyBatch(id: string, options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).destroyBatch(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public listBatchs(options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).listBatchs(options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public partialUpdateBatch(id: string, batch?: Batch, options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).partialUpdateBatch(id, batch, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public retrieveBatch(id: string, options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).retrieveBatch(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch. - * @param {Batch} [batch] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchApi - */ - public updateBatch(id: string, batch?: Batch, options?: AxiosRequestConfig) { - return BatchApiFp(this.configuration).updateBatch(id, batch, options).then((request) => request(this.axios, this.basePath)); - } -} - - -/** - * BatchCSVApi - axios parameter creator - * @export - */ -export const BatchCSVApiAxiosParamCreator = function (configuration?: Configuration) { - return { - /** - * - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createBatchCSV: async (batchCSV?: BatchCSV, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/batchCSV/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batchCSV, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyBatchCSV: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('destroyBatchCSV', 'id', id) - const localVarPath = `/batchCSV/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listBatchCSVs: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/batchCSV/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateBatchCSV: async (id: string, batchCSV?: BatchCSV, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('partialUpdateBatchCSV', 'id', id) - const localVarPath = `/batchCSV/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batchCSV, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveBatchCSV: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('retrieveBatchCSV', 'id', id) - const localVarPath = `/batchCSV/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateBatchCSV: async (id: string, batchCSV?: BatchCSV, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('updateBatchCSV', 'id', id) - const localVarPath = `/batchCSV/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(batchCSV, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; - -/** - * BatchCSVApi - functional programming interface - * @export - */ -export const BatchCSVApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = BatchCSVApiAxiosParamCreator(configuration) - return { - /** - * - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async createBatchCSV(batchCSV?: BatchCSV, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createBatchCSV(batchCSV, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async destroyBatchCSV(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.destroyBatchCSV(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listBatchCSVs(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listBatchCSVs(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async partialUpdateBatchCSV(id: string, batchCSV?: BatchCSV, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateBatchCSV(id, batchCSV, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async retrieveBatchCSV(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveBatchCSV(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async updateBatchCSV(id: string, batchCSV?: BatchCSV, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateBatchCSV(id, batchCSV, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - } -}; - -/** - * BatchCSVApi - factory interface - * @export - */ -export const BatchCSVApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = BatchCSVApiFp(configuration) - return { - /** - * - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createBatchCSV(batchCSV?: BatchCSV, options?: any): AxiosPromise { - return localVarFp.createBatchCSV(batchCSV, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyBatchCSV(id: string, options?: any): AxiosPromise { - return localVarFp.destroyBatchCSV(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listBatchCSVs(options?: any): AxiosPromise> { - return localVarFp.listBatchCSVs(options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateBatchCSV(id: string, batchCSV?: BatchCSV, options?: any): AxiosPromise { - return localVarFp.partialUpdateBatchCSV(id, batchCSV, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveBatchCSV(id: string, options?: any): AxiosPromise { - return localVarFp.retrieveBatchCSV(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateBatchCSV(id: string, batchCSV?: BatchCSV, options?: any): AxiosPromise { - return localVarFp.updateBatchCSV(id, batchCSV, options).then((request) => request(axios, basePath)); - }, - }; -}; - -/** - * BatchCSVApi - object-oriented interface - * @export - * @class BatchCSVApi - * @extends {BaseAPI} - */ -export class BatchCSVApi extends BaseAPI { - /** - * - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public createBatchCSV(batchCSV?: BatchCSV, options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).createBatchCSV(batchCSV, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public destroyBatchCSV(id: string, options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).destroyBatchCSV(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public listBatchCSVs(options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).listBatchCSVs(options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public partialUpdateBatchCSV(id: string, batchCSV?: BatchCSV, options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).partialUpdateBatchCSV(id, batchCSV, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public retrieveBatchCSV(id: string, options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).retrieveBatchCSV(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this batch csv. - * @param {BatchCSV} [batchCSV] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof BatchCSVApi - */ - public updateBatchCSV(id: string, batchCSV?: BatchCSV, options?: AxiosRequestConfig) { - return BatchCSVApiFp(this.configuration).updateBatchCSV(id, batchCSV, options).then((request) => request(this.axios, this.basePath)); - } -} - - -/** - * FeedstockApi - axios parameter creator - * @export - */ -export const FeedstockApiAxiosParamCreator = function (configuration?: Configuration) { - return { - /** - * - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createFeedstock: async (feedstock?: Feedstock, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/feedstock/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(feedstock, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyFeedstock: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('destroyFeedstock', 'id', id) - const localVarPath = `/feedstock/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listFeedstocks: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/feedstock/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateFeedstock: async (id: string, feedstock?: Feedstock, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('partialUpdateFeedstock', 'id', id) - const localVarPath = `/feedstock/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(feedstock, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveFeedstock: async (id: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('retrieveFeedstock', 'id', id) - const localVarPath = `/feedstock/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateFeedstock: async (id: string, feedstock?: Feedstock, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('updateFeedstock', 'id', id) - const localVarPath = `/feedstock/{id}/` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(feedstock, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; - -/** - * FeedstockApi - functional programming interface - * @export - */ -export const FeedstockApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = FeedstockApiAxiosParamCreator(configuration) - return { - /** - * - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async createFeedstock(feedstock?: Feedstock, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createFeedstock(feedstock, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async destroyFeedstock(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.destroyFeedstock(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listFeedstocks(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listFeedstocks(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async partialUpdateFeedstock(id: string, feedstock?: Feedstock, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateFeedstock(id, feedstock, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async retrieveFeedstock(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveFeedstock(id, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async updateFeedstock(id: string, feedstock?: Feedstock, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateFeedstock(id, feedstock, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - } -}; - -/** - * FeedstockApi - factory interface - * @export - */ -export const FeedstockApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = FeedstockApiFp(configuration) - return { - /** - * - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - createFeedstock(feedstock?: Feedstock, options?: any): AxiosPromise { - return localVarFp.createFeedstock(feedstock, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - destroyFeedstock(id: string, options?: any): AxiosPromise { - return localVarFp.destroyFeedstock(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listFeedstocks(options?: any): AxiosPromise> { - return localVarFp.listFeedstocks(options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - partialUpdateFeedstock(id: string, feedstock?: Feedstock, options?: any): AxiosPromise { - return localVarFp.partialUpdateFeedstock(id, feedstock, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - retrieveFeedstock(id: string, options?: any): AxiosPromise { - return localVarFp.retrieveFeedstock(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateFeedstock(id: string, feedstock?: Feedstock, options?: any): AxiosPromise { - return localVarFp.updateFeedstock(id, feedstock, options).then((request) => request(axios, basePath)); - }, - }; -}; - -/** - * FeedstockApi - object-oriented interface - * @export - * @class FeedstockApi - * @extends {BaseAPI} - */ -export class FeedstockApi extends BaseAPI { - /** - * - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public createFeedstock(feedstock?: Feedstock, options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).createFeedstock(feedstock, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public destroyFeedstock(id: string, options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).destroyFeedstock(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public listFeedstocks(options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).listFeedstocks(options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public partialUpdateFeedstock(id: string, feedstock?: Feedstock, options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).partialUpdateFeedstock(id, feedstock, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public retrieveFeedstock(id: string, options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).retrieveFeedstock(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {string} id A unique integer value identifying this feedstock. - * @param {Feedstock} [feedstock] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof FeedstockApi - */ - public updateFeedstock(id: string, feedstock?: Feedstock, options?: AxiosRequestConfig) { - return FeedstockApiFp(this.configuration).updateFeedstock(id, feedstock, options).then((request) => request(this.axios, this.basePath)); - } -} - - -/** - * GeneratePuroApi - axios parameter creator - * @export - */ -export const GeneratePuroApiAxiosParamCreator = function (configuration?: Configuration) { - return { - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listgeneratePuros: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/generatePuro/`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; - /** - * GeneratePuroApi - functional programming interface + * * @export + * @interface TokenObtainPair */ -export const GeneratePuroApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = GeneratePuroApiAxiosParamCreator(configuration) - return { - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listgeneratePuros(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listgeneratePuros(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - } -}; - +export interface TokenObtainPair { + /** + * + * @type {string} + * @memberof TokenObtainPair + */ + 'email': string; + /** + * + * @type {string} + * @memberof TokenObtainPair + */ + 'password': string; +} /** - * GeneratePuroApi - factory interface + * * @export + * @interface TokenRefresh */ -export const GeneratePuroApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = GeneratePuroApiFp(configuration) - return { - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listgeneratePuros(options?: any): AxiosPromise> { - return localVarFp.listgeneratePuros(options).then((request) => request(axios, basePath)); - }, - }; -}; - +export interface TokenRefresh { + /** + * + * @type {string} + * @memberof TokenRefresh + */ + 'refresh': string; + /** + * + * @type {string} + * @memberof TokenRefresh + */ + 'access'?: string; +} /** - * GeneratePuroApi - object-oriented interface + * * @export - * @class GeneratePuroApi - * @extends {BaseAPI} + * @interface TokenVerify */ -export class GeneratePuroApi extends BaseAPI { +export interface TokenVerify { /** * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof GeneratePuroApi + * @type {string} + * @memberof TokenVerify */ - public listgeneratePuros(options?: AxiosRequestConfig) { - return GeneratePuroApiFp(this.configuration).listgeneratePuros(options).then((request) => request(this.axios, this.basePath)); - } + 'token': string; } - /** - * ReportApi - axios parameter creator + * ProjectsApi - axios parameter creator * @export */ -export const ReportApiAxiosParamCreator = function (configuration?: Configuration) { +export const ProjectsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** * - * @param {Report} [report] + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createReport: async (report?: Report, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/report/`; + createProject: async (project?: Project, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/projects/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -1702,7 +163,7 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(report, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(project, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -1711,14 +172,14 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - destroyReport: async (id: string, options: AxiosRequestConfig = {}): Promise => { + destroyProject: async (id: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('destroyReport', 'id', id) - const localVarPath = `/report/{id}/` + assertParamExists('destroyProject', 'id', id) + const localVarPath = `/projects/{id}/` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -1747,8 +208,8 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listReports: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/report/`; + listProjects: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/projects/`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -1773,15 +234,15 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - partialUpdateReport: async (id: string, report?: Report, options: AxiosRequestConfig = {}): Promise => { + partialUpdateProject: async (id: string, project?: Project, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('partialUpdateReport', 'id', id) - const localVarPath = `/report/{id}/` + assertParamExists('partialUpdateProject', 'id', id) + const localVarPath = `/projects/{id}/` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -1801,7 +262,7 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(report, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(project, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -1810,14 +271,14 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveReport: async (id: string, options: AxiosRequestConfig = {}): Promise => { + retrieveProject: async (id: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('retrieveReport', 'id', id) - const localVarPath = `/report/{id}/` + assertParamExists('retrieveProject', 'id', id) + const localVarPath = `/projects/{id}/` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -1843,15 +304,15 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateReport: async (id: string, report?: Report, options: AxiosRequestConfig = {}): Promise => { + updateProject: async (id: string, project?: Project, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('updateReport', 'id', id) - const localVarPath = `/report/{id}/` + assertParamExists('updateProject', 'id', id) + const localVarPath = `/projects/{id}/` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -1871,7 +332,7 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(report, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(project, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -1882,30 +343,30 @@ export const ReportApiAxiosParamCreator = function (configuration?: Configuratio }; /** - * ReportApi - functional programming interface + * ProjectsApi - functional programming interface * @export */ -export const ReportApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = ReportApiAxiosParamCreator(configuration) +export const ProjectsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ProjectsApiAxiosParamCreator(configuration) return { /** * - * @param {Report} [report] + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async createReport(report?: Report, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createReport(report, options); + async createProject(project?: Project, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createProject(project, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async destroyReport(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.destroyReport(id, options); + async destroyProject(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.destroyProject(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -1913,186 +374,289 @@ export const ReportApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listReports(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listReports(options); + async listProjects(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listProjects(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async partialUpdateReport(id: string, report?: Report, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateReport(id, report, options); + async partialUpdateProject(id: string, project?: Project, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateProject(id, project, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async retrieveReport(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveReport(id, options); + async retrieveProject(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveProject(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async updateReport(id: string, report?: Report, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateReport(id, report, options); + async updateProject(id: string, project?: Project, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateProject(id, project, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } }; /** - * ReportApi - factory interface + * ProjectsApi - factory interface * @export */ -export const ReportApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = ReportApiFp(configuration) +export const ProjectsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ProjectsApiFp(configuration) return { /** * - * @param {Report} [report] + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createReport(report?: Report, options?: any): AxiosPromise { - return localVarFp.createReport(report, options).then((request) => request(axios, basePath)); + createProject(project?: Project, options?: any): AxiosPromise { + return localVarFp.createProject(project, options).then((request) => request(axios, basePath)); }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - destroyReport(id: string, options?: any): AxiosPromise { - return localVarFp.destroyReport(id, options).then((request) => request(axios, basePath)); + destroyProject(id: string, options?: any): AxiosPromise { + return localVarFp.destroyProject(id, options).then((request) => request(axios, basePath)); }, /** * * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listReports(options?: any): AxiosPromise> { - return localVarFp.listReports(options).then((request) => request(axios, basePath)); + listProjects(options?: any): AxiosPromise> { + return localVarFp.listProjects(options).then((request) => request(axios, basePath)); }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - partialUpdateReport(id: string, report?: Report, options?: any): AxiosPromise { - return localVarFp.partialUpdateReport(id, report, options).then((request) => request(axios, basePath)); + partialUpdateProject(id: string, project?: Project, options?: any): AxiosPromise { + return localVarFp.partialUpdateProject(id, project, options).then((request) => request(axios, basePath)); }, /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - retrieveReport(id: string, options?: any): AxiosPromise { - return localVarFp.retrieveReport(id, options).then((request) => request(axios, basePath)); + retrieveProject(id: string, options?: any): AxiosPromise { + return localVarFp.retrieveProject(id, options).then((request) => request(axios, basePath)); }, /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - updateReport(id: string, report?: Report, options?: any): AxiosPromise { - return localVarFp.updateReport(id, report, options).then((request) => request(axios, basePath)); + updateProject(id: string, project?: Project, options?: any): AxiosPromise { + return localVarFp.updateProject(id, project, options).then((request) => request(axios, basePath)); }, }; }; /** - * ReportApi - object-oriented interface + * ProjectsApi - object-oriented interface * @export - * @class ReportApi + * @class ProjectsApi * @extends {BaseAPI} */ -export class ReportApi extends BaseAPI { +export class ProjectsApi extends BaseAPI { + /** + * + * @param {Project} [project] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ProjectsApi + */ + public createProject(project?: Project, options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).createProject(project, options).then((request) => request(this.axios, this.basePath)); + } + /** * - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof ProjectsApi */ - public createReport(report?: Report, options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).createReport(report, options).then((request) => request(this.axios, this.basePath)); + public destroyProject(id: string, options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).destroyProject(id, options).then((request) => request(this.axios, this.basePath)); } /** * - * @param {string} id A unique integer value identifying this report. * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof ProjectsApi */ - public destroyReport(id: string, options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).destroyReport(id, options).then((request) => request(this.axios, this.basePath)); + public listProjects(options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).listProjects(options).then((request) => request(this.axios, this.basePath)); } /** * + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof ProjectsApi */ - public listReports(options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).listReports(options).then((request) => request(this.axios, this.basePath)); + public partialUpdateProject(id: string, project?: Project, options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).partialUpdateProject(id, project, options).then((request) => request(this.axios, this.basePath)); } /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {string} id A unique integer value identifying this project. * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof ProjectsApi */ - public partialUpdateReport(id: string, report?: Report, options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).partialUpdateReport(id, report, options).then((request) => request(this.axios, this.basePath)); + public retrieveProject(id: string, options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).retrieveProject(id, options).then((request) => request(this.axios, this.basePath)); } /** * - * @param {string} id A unique integer value identifying this report. + * @param {string} id A unique integer value identifying this project. + * @param {Project} [project] * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof ProjectsApi */ - public retrieveReport(id: string, options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).retrieveReport(id, options).then((request) => request(this.axios, this.basePath)); + public updateProject(id: string, project?: Project, options?: AxiosRequestConfig) { + return ProjectsApiFp(this.configuration).updateProject(id, project, options).then((request) => request(this.axios, this.basePath)); + } +} + + + +/** + * RegisterApi - axios parameter creator + * @export + */ +export const RegisterApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {Register} [register] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createUser: async (register?: Register, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/register/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(register, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * RegisterApi - functional programming interface + * @export + */ +export const RegisterApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = RegisterApiAxiosParamCreator(configuration) + return { + /** + * + * @param {Register} [register] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createUser(register?: Register, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(register, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } +}; + +/** + * RegisterApi - factory interface + * @export + */ +export const RegisterApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = RegisterApiFp(configuration) + return { + /** + * + * @param {Register} [register] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createUser(register?: Register, options?: any): AxiosPromise { + return localVarFp.createUser(register, options).then((request) => request(axios, basePath)); + }, + }; +}; +/** + * RegisterApi - object-oriented interface + * @export + * @class RegisterApi + * @extends {BaseAPI} + */ +export class RegisterApi extends BaseAPI { /** * - * @param {string} id A unique integer value identifying this report. - * @param {Report} [report] + * @param {Register} [register] * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof ReportApi + * @memberof RegisterApi */ - public updateReport(id: string, report?: Report, options?: AxiosRequestConfig) { - return ReportApiFp(this.configuration).updateReport(id, report, options).then((request) => request(this.axios, this.basePath)); + public createUser(register?: Register, options?: AxiosRequestConfig) { + return RegisterApiFp(this.configuration).createUser(register, options).then((request) => request(this.axios, this.basePath)); } } + /** * TokenApi - axios parameter creator * @export @@ -2320,3 +884,155 @@ export class TokenApi extends BaseAPI { } + +/** + * UserApi - axios parameter creator + * @export + */ +export const UserApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Delete the current user. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + destroyUserDetail: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/user/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Return the user details. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listUserDetails: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/user/`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * UserApi - functional programming interface + * @export + */ +export const UserApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = UserApiAxiosParamCreator(configuration) + return { + /** + * Delete the current user. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async destroyUserDetail(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.destroyUserDetail(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * Return the user details. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listUserDetails(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listUserDetails(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * UserApi - factory interface + * @export + */ +export const UserApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = UserApiFp(configuration) + return { + /** + * Delete the current user. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + destroyUserDetail(options?: any): AxiosPromise { + return localVarFp.destroyUserDetail(options).then((request) => request(axios, basePath)); + }, + /** + * Return the user details. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listUserDetails(options?: any): AxiosPromise> { + return localVarFp.listUserDetails(options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * UserApi - object-oriented interface + * @export + * @class UserApi + * @extends {BaseAPI} + */ +export class UserApi extends BaseAPI { + /** + * Delete the current user. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserApi + */ + public destroyUserDetail(options?: AxiosRequestConfig) { + return UserApiFp(this.configuration).destroyUserDetail(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Return the user details. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof UserApi + */ + public listUserDetails(options?: AxiosRequestConfig) { + return UserApiFp(this.configuration).listUserDetails(options).then((request) => request(this.axios, this.basePath)); + } +} + + + diff --git a/frontend/src/api/schema/common.ts b/frontend/src/api/schema/common.ts index fea21d2..ce2f405 100644 --- a/frontend/src/api/schema/common.ts +++ b/frontend/src/api/schema/common.ts @@ -144,7 +144,7 @@ export const toPathString = function (url: URL) { */ export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) { return >(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => { - const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url}; + const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || axios.defaults.baseURL || basePath) + axiosArgs.url}; return axios.request(axiosRequestArgs); }; } diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 5b9acf3..3e15778 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -1,4 +1,6 @@ +import { Popover, Transition } from '@headlessui/react'; import Picker from 'components/picker'; +import CustomButton from 'components/button'; import SidebarButtonPrimary from './sidebarButtonPrimary'; import SidebarButtonSecondary from './sidebarButtonSecondary'; import { @@ -8,15 +10,23 @@ import { MdOutlineOpenInNew, MdSettings, MdOutlineBadge, + MdCheck, } from 'react-icons/md'; import { ReactComponent as Flow } from 'assets/icons/Flow Icon.svg'; import { ReactComponent as CrumpetLogo } from 'assets/images/Crumpet Logo Oxford.svg'; +import { Fragment } from 'react'; +import { Outlet } from 'react-router-dom'; const environments = [ { id: 1, name: 'Development' }, { id: 2, name: 'Production' }, ]; +const projects = [ + { id: 1, name: 'Crumpet', selected: true }, + { id: 2, name: 'Muffin', selected: false }, +]; + const SidebarMenu = () => { // icon property is a function to allow for styling const ButtonList = [ @@ -45,6 +55,7 @@ const SidebarMenu = () => { ]; return ( + <>
@@ -83,17 +94,68 @@ const SidebarMenu = () => { widthFill={true} />
-
-
-
- C -
- Crumpet -
- -
+ {/* TODO: Add Popover component from headless UI here */} + + + {({ open }) => ( + <> + +
+
+ C +
+ Crumpet +
+ +
+ + +
+
+ {projects.map((project, index) => ( +
+ { + console.log('clicked'); + }} + /> + {project.name} + {project.selected ? : <>} +
+ ))} + { + console.log('clicked'); + }} + /> +
+
+
+
+ + )} +
+ + ); }; diff --git a/frontend/src/pages/Authentication/useLogin.ts b/frontend/src/pages/Authentication/useLogin.ts index a0f2821..acca35f 100644 --- a/frontend/src/pages/Authentication/useLogin.ts +++ b/frontend/src/pages/Authentication/useLogin.ts @@ -1,13 +1,28 @@ +import useAuthentication from 'api/auth/useAuthentication'; import { useFormik } from 'formik'; +import { useNavigate } from 'react-router'; + +interface FormValues { + email?: string; + password?: string; +} const useLogin = () => { + const { login, authenticating, errorAlert, setErrorAlert } = useAuthentication(); + const navigate = useNavigate(); + const formik = useFormik({ initialValues: { email: '', password: '', }, - onSubmit: () => { - // console.log(values); -- Functionality to be added after backend is complete + onSubmit: ({email, password} : FormValues ) => { + if (email && password) { + login({ email, password }).then(res => { + if (res?.success) navigate('/'); + else setErrorAlert(res?.errors[0]); + }); + } }, }); diff --git a/frontend/src/routes/useRouter.tsx b/frontend/src/routes/useRouter.tsx index 255e7b0..7358f79 100644 --- a/frontend/src/routes/useRouter.tsx +++ b/frontend/src/routes/useRouter.tsx @@ -1,34 +1,38 @@ import React from 'react'; import Welcome from 'pages/welcome/welcome'; -import { createHashRouter } from 'react-router-dom'; +import { createHashRouter, Navigate, RouteObject } from 'react-router-dom'; import { useCookies } from 'react-cookie'; import NotFound from './404'; import Login from 'pages/Authentication'; +import SidebarMenu from 'components/sidebarMenu'; const useRouter = () => { const [cookies] = useCookies(['refreshToken']); - - const routerList = [ - { - path: '/', - element: , - }, - { - path: '*', - element: , - status: 404, - }, - { - path: '/login', - element: , - }, - ]; - const authenticated = cookies.refreshToken; + let routerList: RouteObject[] = []; if (authenticated) { - // Authenticated routes here - // routerList.push({}); + routerList = [ + { + path: '/', + element: , + }, + { + path: '*', + element: , + }, + ]; + } else { + routerList = [ + { + path: '/', + element: , + }, + { + path: '*', + element: , + }, + ]; } const router = createHashRouter(routerList); From b3708ea5b8cf0aa41c6081efe1185b99a236eb56 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 8 Sep 2023 14:27:47 +0100 Subject: [PATCH 07/72] Add custom Table component --- frontend/src/components/table/index.tsx | 76 +++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 frontend/src/components/table/index.tsx diff --git a/frontend/src/components/table/index.tsx b/frontend/src/components/table/index.tsx new file mode 100644 index 0000000..a054c11 --- /dev/null +++ b/frontend/src/components/table/index.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from 'react'; +import { MdMoreHoriz } from 'react-icons/md'; + +/** + * Describes the structure for each header item. + * - `propertyName` is the key to retrieve the value from the data. i.e. needs to map to the name of the property + * on type T of your data array. + * - `displayName` is the text shown as the table header. + */ +interface Header { + propertyName: string; + displayName: string; +} + +/** + * Props structure for the Table component. + * @template T + * - `onMoreClick` function to be executed when the "More" icon is clicked. + * - `headers` an array of header definitions for the table. + * - `data` array of data objects to be displayed in the table. Each object should match the headers' structure. + * - `renderCell` (optional) a custom function to render the cell. Useful if you want a custom rendering for a cell. + */ +interface TableProps { + onMoreClick: () => void; + headers: Header[]; + data: T[]; + /** + * Optional custom function for rendering a table cell. + * @param item - The current data item being rendered. + * @param header - The header definition for the current cell. + * @returns A React node (e.g., JSX element) that will be displayed inside the table cell. + */ + renderCell?: (item: T, header: Header) => ReactNode; +} + +const Table = >({ + onMoreClick, + headers, + data, + renderCell, +}: TableProps) => { + return ( + + + + {headers.map(header => ( + + ))} + + + + + {data.map((row, rowIndex) => ( + + {headers.map(header => ( + + ))} + + + ))} + +
+ {header.displayName} +
+ {renderCell ? renderCell(row, header) : row[header.propertyName]} + +
+ +
+
+ ); +}; + +export default Table; From 2a90ba7d7471c86fe0680d92c871010221d6886b Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 8 Sep 2023 14:28:15 +0100 Subject: [PATCH 08/72] Fix button alignment and sizing --- frontend/src/components/button/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/button/index.tsx b/frontend/src/components/button/index.tsx index 70989c1..5930db9 100644 --- a/frontend/src/components/button/index.tsx +++ b/frontend/src/components/button/index.tsx @@ -2,14 +2,16 @@ interface CustomButtonProps { text: string; onClick: () => void; styles?: string; + icon?: React.ReactElement; } -const CustomButton = ({ text, onClick, styles }: CustomButtonProps) => { +const CustomButton = ({ text, onClick, styles, icon }: CustomButtonProps) => { return ( ); }; From d174b9827100c8e983b0e8586e8a729d48006b83 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 8 Sep 2023 14:28:30 +0100 Subject: [PATCH 09/72] Add 'copy input' component --- frontend/src/components/copyInput/index.tsx | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/src/components/copyInput/index.tsx diff --git a/frontend/src/components/copyInput/index.tsx b/frontend/src/components/copyInput/index.tsx new file mode 100644 index 0000000..930c990 --- /dev/null +++ b/frontend/src/components/copyInput/index.tsx @@ -0,0 +1,41 @@ +import React, { useRef } from 'react'; +import { FiCopy } from 'react-icons/fi'; + +interface CopyInputProps { + value: string; + className?: string; +} + +//TODO: Add Radix tooltip when value is copied +// https://www.radix-ui.com/primitives/docs/components/tooltip +const CopyInput: React.FC = ({ value, className }) => { + const inputRef = useRef(null); + + const handleCopyClick = () => { + if (inputRef.current) { + inputRef.current.select(); + document.execCommand('copy'); + } + }; + + return ( +
+ +
+ +
+
+ ); +}; + +export default CopyInput; + From b2c42e8f50e59209abbecb0d40d768b8d10298bb Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 21:56:14 +0100 Subject: [PATCH 10/72] Add api_key field to project model and create UUID base model --- backend/app/models/project.py | 13 ++++++++++++- backend/app/models/utils.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 backend/app/models/utils.py diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 44cc29a..fe8f3a8 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -1,5 +1,8 @@ -from django.db import models +# from django.utils.crypto import get_random_string +import uuid + from app.models import User +from django.db import models class Project(models.Model): @@ -9,6 +12,14 @@ class Project(models.Model): through="ProjectMembership", related_name="projects", ) + # TODO: Replace this with proper key generation + api_key = models.CharField( + max_length=256, + unique=True, + default=uuid.uuid4, + null=False, + blank=False, + ) def __str__(self): return self.name diff --git a/backend/app/models/utils.py b/backend/app/models/utils.py new file mode 100644 index 0000000..4c1c34d --- /dev/null +++ b/backend/app/models/utils.py @@ -0,0 +1,10 @@ +import uuid + +from django.db import models + + +class UUIDModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + class Meta: + abstract = True From b549c64c13d98c86e6f0e5515d731f5ba8168351 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:00:17 +0100 Subject: [PATCH 11/72] Modify serializers for compatibility with OpenAPI generators Using a non-typed serializer will lead to the an incorrect type generated in the OpenAPI schema definition. I've adjusted so that it will be able to infer the type correctly and still do the nested serialization. --- .dictionary/custom.txt | 1 + backend/app/serializers/project_serializer.py | 46 ++++++++++--------- backend/app/serializers/user_serializer.py | 8 ++++ 3 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 backend/app/serializers/user_serializer.py diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 536f378..37c469a 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -75,3 +75,4 @@ isort pylint tomli tomlkit +projectmembership diff --git a/backend/app/serializers/project_serializer.py b/backend/app/serializers/project_serializer.py index 9007cb6..f8c2348 100644 --- a/backend/app/serializers/project_serializer.py +++ b/backend/app/serializers/project_serializer.py @@ -1,11 +1,14 @@ -from rest_framework import serializers from app.models import Project from app.models.project import ProjectMembership +from .user_serializer import UserSummarySerializer +from rest_framework import serializers class ProjectMembershipSerializer(serializers.ModelSerializer): """Used as a nested serializer by ApplicationSerializer.""" + user = UserSummarySerializer() + class Meta(object): model = ProjectMembership fields = ["id", "user", "type"] @@ -13,27 +16,26 @@ class Meta(object): class ProjectSerializer(serializers.ModelSerializer): - members = serializers.SerializerMethodField() + members = serializers.ListField( + child=ProjectMembershipSerializer(), + source="projectmembership_set.all", # default related name for ProjectMembership. + read_only=True, + ) class Meta: model = Project - fields = ["id", "name", "members"] - - def __init__(self, *args, **kwargs): - # Don't pass the 'fields' arg up to the superclass - fields = kwargs.pop("fields", None) - - # Instantiate the superclass normally - super().__init__(*args, **kwargs) - - if fields is not None: - # Drop any fields that are not specified in the `fields` argument. - allowed = set(fields) - existing = set(self.fields) - for field_name in existing - allowed: - self.fields.pop(field_name) - - def get_members(self, obj: Project): - """obj is an Project instance. Returns list of dicts""" - query_set = ProjectMembership.objects.filter(project=obj) - return [ProjectMembershipSerializer(m).data for m in query_set] + fields = ["id", "name", "api_key", "members"] + + def to_representation(self, instance): + """ + By using to_representation, we can ensure the members field's type is defined + explicitly using ListField(child=ProjectMembershipSerializer()), providing a + clear hint for OpenAPI schema generation. + """ + representation = super().to_representation(instance) + + # Serialize the `members` using `ProjectMembershipSerializer`. + membership_qs = ProjectMembership.objects.filter(project=instance) + representation["members"] = ProjectMembershipSerializer(membership_qs, many=True).data + + return representation diff --git a/backend/app/serializers/user_serializer.py b/backend/app/serializers/user_serializer.py new file mode 100644 index 0000000..bffab2b --- /dev/null +++ b/backend/app/serializers/user_serializer.py @@ -0,0 +1,8 @@ +from app.models import User +from rest_framework import serializers + + +class UserSummarySerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "first_name", "last_name", "email"] From 42a57855026e5d0794a9e46a78fc51d00b88d0c3 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:03:31 +0100 Subject: [PATCH 12/72] Add project key authentication class (untested) --- backend/app/authentication.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/app/authentication.py diff --git a/backend/app/authentication.py b/backend/app/authentication.py new file mode 100644 index 0000000..80a1339 --- /dev/null +++ b/backend/app/authentication.py @@ -0,0 +1,17 @@ +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from .models import Project + + +class ProjectAPIKeyAuthentication(BaseAuthentication): + def authenticate(self, request): + api_key = request.headers.get("X-API-KEY") + if not api_key: + return None + + try: + project = Project.objects.get(api_key=api_key) + return (project, None) # authentication successful + except Project.DoesNotExist: + raise AuthenticationFailed("Invalid API key.") From f0bbd1c37d043c69822fa2dec3f59ef4b4d44e9d Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:05:45 +0100 Subject: [PATCH 13/72] Add migration files --- .dictionary/custom.txt | 2 ++ .../app/migrations/0002_project_api_key.py | 19 +++++++++++++++++++ .../migrations/0003_alter_project_api_key.py | 19 +++++++++++++++++++ .../migrations/0004_alter_project_api_key.py | 19 +++++++++++++++++++ .../migrations/0005_alter_project_api_key.py | 19 +++++++++++++++++++ .../migrations/0006_alter_project_api_key.py | 18 ++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 backend/app/migrations/0002_project_api_key.py create mode 100644 backend/app/migrations/0003_alter_project_api_key.py create mode 100644 backend/app/migrations/0004_alter_project_api_key.py create mode 100644 backend/app/migrations/0005_alter_project_api_key.py create mode 100644 backend/app/migrations/0006_alter_project_api_key.py diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 37c469a..e110682 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -76,3 +76,5 @@ pylint tomli tomlkit projectmembership +Qcet +ZIZW diff --git a/backend/app/migrations/0002_project_api_key.py b/backend/app/migrations/0002_project_api_key.py new file mode 100644 index 0000000..a1bb701 --- /dev/null +++ b/backend/app/migrations/0002_project_api_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.5 on 2023-09-08 17:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="api_key", + field=models.CharField( + default="NIzNPqaU5CpCs7ItQ01x8dcvXozQcetR", max_length=256, unique=True + ), + ), + ] diff --git a/backend/app/migrations/0003_alter_project_api_key.py b/backend/app/migrations/0003_alter_project_api_key.py new file mode 100644 index 0000000..1b311e1 --- /dev/null +++ b/backend/app/migrations/0003_alter_project_api_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.5 on 2023-09-08 17:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0002_project_api_key"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="api_key", + field=models.CharField( + default="PhZIZWGhUv2snEoQ5S4G3O1FQ608FUx8", max_length=256, unique=True + ), + ), + ] diff --git a/backend/app/migrations/0004_alter_project_api_key.py b/backend/app/migrations/0004_alter_project_api_key.py new file mode 100644 index 0000000..9b6fb85 --- /dev/null +++ b/backend/app/migrations/0004_alter_project_api_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.5 on 2023-09-08 20:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0003_alter_project_api_key"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="api_key", + field=models.CharField( + default="", max_length=256, unique=True + ), + ), + ] diff --git a/backend/app/migrations/0005_alter_project_api_key.py b/backend/app/migrations/0005_alter_project_api_key.py new file mode 100644 index 0000000..452dc76 --- /dev/null +++ b/backend/app/migrations/0005_alter_project_api_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.5 on 2023-09-08 20:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0004_alter_project_api_key"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="api_key", + field=models.CharField( + default="", max_length=256, unique=True + ), + ), + ] diff --git a/backend/app/migrations/0006_alter_project_api_key.py b/backend/app/migrations/0006_alter_project_api_key.py new file mode 100644 index 0000000..6cec872 --- /dev/null +++ b/backend/app/migrations/0006_alter_project_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-09-08 20:46 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("app", "0005_alter_project_api_key"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="api_key", + field=models.CharField(default=uuid.uuid4, max_length=256, unique=True), + ), + ] From 83bf15a071ffefdd0a94160f18ea9b177cf343c1 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:06:00 +0100 Subject: [PATCH 14/72] Add database seed file You can pipe this into the Django shell like so: python manage.py shell < db_seed.py --- backend/db_seed.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backend/db_seed.py diff --git a/backend/db_seed.py b/backend/db_seed.py new file mode 100644 index 0000000..a62dd2d --- /dev/null +++ b/backend/db_seed.py @@ -0,0 +1,57 @@ +from app.models import Project, ProjectMembership +from django.contrib.auth import get_user_model + +print("Seeding database...") + +# Clear the database +ProjectMembership.objects.all().delete() +Project.objects.all().delete() +get_user_model().objects.all().delete() + +# Create new User entries +User = get_user_model() + +tom = User.objects.create_user( + email="tom@opencrumpet.com", + first_name="Tom", + last_name="Titherington", + password="Developer123!", +) + +richard = User.objects.create_user( + email="richard@piedpiper.com", + first_name="Richard", + last_name="Hendrix", + password="Developer123!", +) + +jared = User.objects.create_user( + email="jared@piedpiper.com", first_name="Jared", last_name="Dunn", password="Developer123!" +) + +# Create new Project entries +project1 = Project.objects.create(name="Cookie Dough") +project2 = Project.objects.create(name="Marmalade") + + +# Set Tom as admin for both projects and add members +ProjectMembership.objects.create( + user=tom, project=project1, type=ProjectMembership.MembershipType.ADMIN +) +ProjectMembership.objects.create( + user=richard, project=project1, type=ProjectMembership.MembershipType.MEMBER +) +ProjectMembership.objects.create( + user=jared, project=project1, type=ProjectMembership.MembershipType.MEMBER +) +ProjectMembership.objects.create( + user=tom, project=project2, type=ProjectMembership.MembershipType.ADMIN +) +ProjectMembership.objects.create( + user=richard, project=project2, type=ProjectMembership.MembershipType.MEMBER +) +ProjectMembership.objects.create( + user=jared, project=project2, type=ProjectMembership.MembershipType.MEMBER +) + +print("Seed complete!") From de18c8d7432cdb528a5c9984306b20870ae78e5e Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:08:18 +0100 Subject: [PATCH 15/72] Add db service to docker-compose and adjust env vars --- .dictionary/custom.txt | 2 ++ backend/.env | 2 +- backend/Dockerfile | 2 +- backend/docker-compose.yml | 20 ++++++++++++++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index e110682..b94d3a4 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -78,3 +78,5 @@ tomlkit projectmembership Qcet ZIZW +runserver +pgdata diff --git a/backend/.env b/backend/.env index 54131f2..b917f06 100644 --- a/backend/.env +++ b/backend/.env @@ -5,6 +5,6 @@ DJANGO_ALLOWED_HOSTS='localhost,0.0.0.0,127.0.0.1,192.168.1.220' DB_NAME = crumpet_db DB_USERNAME = head_baker DB_PASSWORD = Crumpet2023 -DB_HOST = localhost +DB_HOST = db DB_PORT = 5432 DB_SSL_MODE = require diff --git a/backend/Dockerfile b/backend/Dockerfile index a07ab1e..79184d3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM python:3.11.2 +FROM python:3.10-bullseye ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 9e0e894..45a7a58 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -10,3 +10,23 @@ services: ports: - "8000:8000" tty: true + env_file: + - .env + depends_on: + - db + + db: + image: postgres:15 + container_name: "crumpet_postgres" + ports: + - "5432:5432" + environment: + POSTGRES_DB: crumpet_db + POSTGRES_USER: head_baker + POSTGRES_PASSWORD: Crumpet2023 + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: + From 6a65aec658a55bf2e3d404b4fe24ff976ddc0189 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:08:56 +0100 Subject: [PATCH 16/72] Remove development mode database check in settings.py --- backend/backend/settings.py | 49 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 0c5882f..da6a6a7 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -84,25 +84,11 @@ WSGI_APPLICATION = "backend.wsgi.application" + # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases -if DEVELOPMENT_MODE is True: - print("DEVELOPMENT MODE: ACTIVE") - DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } - } -elif len(sys.argv) > 0 and sys.argv[1] != "collectstatic": - print( - "WARNING: Development mode is not active. Changes to the database will be saved to production." - ) # noqa: E501 - if os.getenv("DB_HOST", None) is None: - raise Exception("DATABASE_URL environment variable not defined") - print("Connecting to database...") - DATABASES = { +DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("DB_NAME"), @@ -112,7 +98,35 @@ "PORT": os.environ.get("DB_PORT"), } } - print("Connected to database") +print("Connected to database") + + +# if DEVELOPMENT_MODE is True: +# print("DEVELOPMENT MODE: ACTIVE") +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.sqlite3", +# "NAME": os.path.join(BASE_DIR, "db.sqlite3"), +# } +# } +# elif len(sys.argv) > 0 and sys.argv[1] != "collectstatic": +# print( +# "WARNING: Development mode is not active. Changes to the database will be saved to production." +# ) # noqa: E501 +# if os.getenv("DB_HOST", None) is None: +# raise Exception("DATABASE_URL environment variable not defined") +# print("Connecting to database...") +# DATABASES = { +# "default": { +# "ENGINE": "django.db.backends.postgresql", +# "NAME": os.environ.get("DB_NAME"), +# "USER": os.environ.get("DB_USERNAME"), +# "PASSWORD": os.environ.get("DB_PASSWORD"), +# "HOST": os.environ.get("DB_HOST"), +# "PORT": os.environ.get("DB_PORT"), +# } +# } +# print("Connected to database") AUTH_USER_MODEL = 'app.User' @@ -170,6 +184,7 @@ "rest_framework_simplejwt.authentication.JWTAuthentication", "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.BasicAuthentication", + "app.authentication.ProjectAPIKeyAuthentication" ), "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticated", From f95531c9fcbb118774f79ce9ac08352a2a3ab65b Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:10:14 +0100 Subject: [PATCH 17/72] Increase psycopg2 version --- backend/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0880323..9ef7584 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -32,6 +32,7 @@ openapi-codec==1.3.2 openpyxl==3.1.1 platformdirs==3.0.0 pre-commit==3.0.4 +psycopg2==2.9.7 psycopg2-binary==2.9.5 pycodestyle==2.10.0 pyflakes==3.0.1 From 8ed26f7733bcc3e3fe31b7dcb413efad44bd43ce Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 10 Sep 2023 22:12:02 +0100 Subject: [PATCH 18/72] Add crypto utils for project key encrypt/decrypt (currently unused) --- .dictionary/custom.txt | 2 ++ backend/app/utils/crypto.py | 28 ++++++++++++++++++++++++++++ backend/requirements.txt | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 backend/app/utils/crypto.py diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index b94d3a4..603ae74 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -80,3 +80,5 @@ Qcet ZIZW runserver pgdata +pyca +pycryptodome diff --git a/backend/app/utils/crypto.py b/backend/app/utils/crypto.py new file mode 100644 index 0000000..0713e35 --- /dev/null +++ b/backend/app/utils/crypto.py @@ -0,0 +1,28 @@ +import os +from base64 import b64decode, b64encode + +from Crypto.Cipher import AES +from django.utils.crypto import get_random_string, pbkdf2 + +BLOCK_SIZE = 16 +KEY = pbkdf2( + os.environ.get("ENCRYPTION_KEY", ""), "salt_here", 100000 +) # derive a key from your encryption key and salt + + +# TODO: Replace this with custom implementation using pyca/cryptography or just store hashes instead +def encrypt(data): + pad = BLOCK_SIZE - len(data) % BLOCK_SIZE + data += pad * chr(pad) + iv = get_random_string(16).encode("utf-8") + cipher = AES.new(KEY, AES.MODE_CBC, iv) + return b64encode(iv + cipher.encrypt(data.encode("utf-8"))) + + +def decrypt(data): + data = b64decode(data) + iv = data[:16] + cipher = AES.new(KEY, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(data[16:]) + pad = ord(decrypted[-1:]) + return decrypted[:-pad].decode("utf-8") diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ef7584..6a02a99 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -35,6 +35,8 @@ pre-commit==3.0.4 psycopg2==2.9.7 psycopg2-binary==2.9.5 pycodestyle==2.10.0 +pycryptodome==3.18.0 +pycryptodome-test-vectors==1.0.13 pyflakes==3.0.1 PyJWT==1.7.1 pylint==2.17.4 From 8ddbbce3f117ab2f13e5798f702cbbfd4988150d Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:35:57 +0100 Subject: [PATCH 19/72] Add auth TODO --- frontend/src/api/auth/useAuthentication.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/api/auth/useAuthentication.ts b/frontend/src/api/auth/useAuthentication.ts index af39e4f..111a79b 100644 --- a/frontend/src/api/auth/useAuthentication.ts +++ b/frontend/src/api/auth/useAuthentication.ts @@ -80,6 +80,7 @@ export const useAuthentication = () => { return true; }) .catch(() => { + //NOTE: Should we not await the response from tokenRefresh before returning false? tokenRefresh(); return false; }); From 99ed9444e71219f420355c56429cbf6c1a65494a Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:39:57 +0100 Subject: [PATCH 20/72] Add api schema and generated models + functions --- frontend/openapi-schema.yml | 36 ++++++++++++++++- frontend/src/api/schema/api.ts | 72 +++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/openapi-schema.yml b/frontend/openapi-schema.yml index 1878190..e462adb 100644 --- a/frontend/openapi-schema.yml +++ b/frontend/openapi-schema.yml @@ -282,8 +282,42 @@ components: name: type: string maxLength: 100 - members: + api_key: type: string + maxLength: 256 + members: + type: array + items: + type: object + properties: + id: + type: integer + readOnly: true + user: + type: object + properties: + id: + type: integer + readOnly: true + first_name: + type: string + maxLength: 150 + last_name: + type: string + maxLength: 150 + email: + type: string + format: email + maxLength: 254 + required: + - email + type: + enum: + - ADM + - MEM + type: string + required: + - user readOnly: true required: - name diff --git a/frontend/src/api/schema/api.ts b/frontend/src/api/schema/api.ts index c4e759e..e06797f 100644 --- a/frontend/src/api/schema/api.ts +++ b/frontend/src/api/schema/api.ts @@ -46,7 +46,77 @@ export interface Project { * @type {string} * @memberof Project */ - 'members'?: string; + 'api_key'?: string; + /** + * + * @type {Array} + * @memberof Project + */ + 'members'?: Array; +} +/** + * + * @export + * @interface ProjectMembersInner + */ +export interface ProjectMembersInner { + /** + * + * @type {number} + * @memberof ProjectMembersInner + */ + 'id'?: number; + /** + * + * @type {ProjectMembersInnerUser} + * @memberof ProjectMembersInner + */ + 'user': ProjectMembersInnerUser; + /** + * + * @type {string} + * @memberof ProjectMembersInner + */ + 'type'?: ProjectMembersInnerTypeEnum; +} + +export const ProjectMembersInnerTypeEnum = { + Adm: 'ADM', + Mem: 'MEM' +} as const; + +export type ProjectMembersInnerTypeEnum = typeof ProjectMembersInnerTypeEnum[keyof typeof ProjectMembersInnerTypeEnum]; + +/** + * + * @export + * @interface ProjectMembersInnerUser + */ +export interface ProjectMembersInnerUser { + /** + * + * @type {number} + * @memberof ProjectMembersInnerUser + */ + 'id'?: number; + /** + * + * @type {string} + * @memberof ProjectMembersInnerUser + */ + 'first_name'?: string; + /** + * + * @type {string} + * @memberof ProjectMembersInnerUser + */ + 'last_name'?: string; + /** + * + * @type {string} + * @memberof ProjectMembersInnerUser + */ + 'email': string; } /** * From 8de04cfc836e5c32c3339774b32a285ff020999c Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:41:28 +0100 Subject: [PATCH 21/72] Add ApiState union type to represent state of external call --- frontend/src/api/utils.ts | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 frontend/src/api/utils.ts diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts new file mode 100644 index 0000000..9e8bd70 --- /dev/null +++ b/frontend/src/api/utils.ts @@ -0,0 +1,67 @@ +/** + * Represents the various states of an API call. + * + * @template T The type of data expected when the API call is successful. + */ +export type ApiState = + | { state: 'initial' } + | { state: 'loading' } + | { state: 'hasData'; data: T } + | { state: 'hasError'; error: Error } + | { state: 'hasDataWithError'; data: T; error: Error }; + +/** + * Constructs the 'initial' state for an API call. + * + * @template T The type of data expected when the API call is successful. + * @returns An ApiState object in the 'initial' state. + */ +const initial = (): ApiState => ({ state: 'initial' }); + +/** + * Constructs the 'loading' state for an API call. + * + * @template T The type of data expected when the API call is successful. + * @returns An ApiState object in the 'loading' state. + */ +const loading = (): ApiState => ({ state: 'loading' }); + +/** + * Constructs the 'hasData' state for an API call. + * + * @template T The type of data expected when the API call is successful. + * @param data The data received from a successful API call. + * @returns An ApiState object in the 'hasData' state. + */ +const hasData = (data: T): ApiState => ({ state: 'hasData', data }); + +/** + * Constructs the 'hasError' state for an API call. + * + * @template T The type of data expected when the API call is successful. + * @param error The error encountered during the API call. + * @returns An ApiState object in the 'hasError' state. + */ +const hasError = (error: Error): ApiState => ({ state: 'hasError', error }); + +/** + * Constructs the 'hasDataWithError' state for an API call. + * + * @template T The type of data expected when the API call is successful. + * @param data The data received from a successful API call. + * @param error The error encountered during the API call. + * @returns An ApiState object in the 'hasError' state. + */ +const hasDataWithError = (data: T, error: Error): ApiState => ({ + state: 'hasDataWithError', + data, + error, +}); + +export const ApiState = { + initial, + loading, + hasData, + hasError, + hasDataWithError, +}; From 590f2d0dc76cfb21f438714e77f281ae1ea4235b Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:43:28 +0100 Subject: [PATCH 22/72] Install Zustand library and add projects store --- .dictionary/custom.txt | 1 + frontend/package.json | 3 ++- frontend/src/stores/useProjectsStore.ts | 32 +++++++++++++++++++++++++ frontend/yarn.lock | 12 ++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 frontend/src/stores/useProjectsStore.ts diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index 603ae74..f2f484b 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -82,3 +82,4 @@ runserver pgdata pyca pycryptodome +zustand diff --git a/frontend/package.json b/frontend/package.json index e47ad5e..a5f9a33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,8 @@ "react-router-dom": "^6.8.1", "serve": "^14.2.0", "typescript": "^4.4.2", - "web-vitals": "^2.1.0" + "web-vitals": "^2.1.0", + "zustand": "^4.4.1" }, "scripts": { "start": "vite", diff --git a/frontend/src/stores/useProjectsStore.ts b/frontend/src/stores/useProjectsStore.ts new file mode 100644 index 0000000..55c12fd --- /dev/null +++ b/frontend/src/stores/useProjectsStore.ts @@ -0,0 +1,32 @@ +import { Configuration, Project, ProjectsApi } from 'api'; +import { ApiState } from 'api/utils'; +import { create } from 'zustand'; + +type ProjectsStore = { + selectedProject: ApiState; + projects: ApiState; + fetchProjects: (config: Configuration) => void; + setSelectedProject: (projectId: number) => void; +}; + +//TODO: Need to handle currently active project vs. selected project (in the settings page) +export const useProjectsStore = create((set, get) => ({ + selectedProject: ApiState.initial(), + projects: ApiState.initial(), + fetchProjects: (config: Configuration) => { + set(state => ({ projects: ApiState.loading() })); + new ProjectsApi(config) + .listProjects() + .then(res => set(state => ({ projects: ApiState.hasData(res.data) }))) + .catch(err => set(state => ({ projects: ApiState.hasError(err) }))); + }, + setSelectedProject: (projectId: number) => { + const currentProjects = get().projects; + if (currentProjects.state == 'hasData' || currentProjects.state == 'hasDataWithError') { + const proj = currentProjects.data.find(project => project.id == projectId); + if (proj) { + set(state => ({ selectedProject: ApiState.hasData(proj) })); + } + } + }, +})); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c9a9e7c..1e499b3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5674,6 +5674,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-sync-external-store@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -6005,3 +6010,10 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zustand@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.1.tgz#0cd3a3e4756f21811bd956418fdc686877e8b3b0" + integrity sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw== + dependencies: + use-sync-external-store "1.2.0" From 18d27c0841e6a05e72147b0042013c3a9dd3b17b Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:47:27 +0100 Subject: [PATCH 23/72] Add initial projects fetch and render them in sidebar --- .dictionary/custom.txt | 1 + frontend/src/components/sidebarMenu/index.tsx | 198 +++++++++--------- frontend/src/pages/root/index.tsx | 54 +++++ 3 files changed, 154 insertions(+), 99 deletions(-) create mode 100644 frontend/src/pages/root/index.tsx diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index f2f484b..d03a606 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -83,3 +83,4 @@ pgdata pyca pycryptodome zustand +probs diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 3e15778..89014a2 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -15,19 +15,24 @@ import { import { ReactComponent as Flow } from 'assets/icons/Flow Icon.svg'; import { ReactComponent as CrumpetLogo } from 'assets/images/Crumpet Logo Oxford.svg'; import { Fragment } from 'react'; -import { Outlet } from 'react-router-dom'; const environments = [ { id: 1, name: 'Development' }, { id: 2, name: 'Production' }, ]; -const projects = [ - { id: 1, name: 'Crumpet', selected: true }, - { id: 2, name: 'Muffin', selected: false }, -]; +interface ProjectEntry { + id: number; + name: string; + selected: boolean; + onSettingsClick?: (project: ProjectEntry) => void; +} + +interface SidebarMenuProps { + projects: ProjectEntry[]; +} -const SidebarMenu = () => { +const SidebarMenu = ({ projects }: SidebarMenuProps) => { // icon property is a function to allow for styling const ButtonList = [ { @@ -55,107 +60,102 @@ const SidebarMenu = () => { ]; return ( - <> -
-
-
- -
Crumpet
+
+
+ +
Crumpet
+
+ +
+ {ButtonList.map((button, index) => ( + + ))} +
- -
- {ButtonList.map((button, index) => ( - +
+ } + label="Support" + onClick={() => console.log('clicked')} widthFill={true} - selected={button.selected} + secondaryIcon={} /> - ))} -
-
-
-
- } - label="Support" - onClick={() => console.log('clicked')} - widthFill={true} - secondaryIcon={} - /> - } - label="Settings" - onClick={() => console.log('clicked')} - widthFill={true} - /> -
- {/* TODO: Add Popover component from headless UI here */} - - - {({ open }) => ( - <> - console.log('clicked')} + widthFill={true} + /> +
+ + {({ open }) => ( + <> + -
-
- C -
- Crumpet -
- -
- - -
-
- {projects.map((project, index) => ( -
- { - console.log('clicked'); - }} - /> - {project.name} - {project.selected ? : <>} -
- ))} - { - console.log('clicked'); - }} - /> +
+
+ C
+ Crumpet
- - - - )} - + + + + +
+
+ {projects.map((project, index) => ( +
+ project.onSettingsClick?.call(null, project)} + /> + {project.name} + {project.selected ? : <>} +
+ ))} + { + console.log('clicked'); + }} + /> +
+
+
+
+ + )} + +
-
- - + ); }; diff --git a/frontend/src/pages/root/index.tsx b/frontend/src/pages/root/index.tsx new file mode 100644 index 0000000..36ba833 --- /dev/null +++ b/frontend/src/pages/root/index.tsx @@ -0,0 +1,54 @@ +import { useApiConfig } from 'api'; +import SidebarMenu from 'components/sidebarMenu'; +import { useEffect } from 'react'; +import { Outlet, useNavigate } from 'react-router-dom'; +import { useProjectsStore } from 'stores/useProjectsStore'; + +const Root = () => { + const { config } = useApiConfig(); + const { projects, fetchProjects, setSelectedProject } = useProjectsStore(); + const navigate = useNavigate(); + + useEffect(() => { + fetchProjects(config); + }, [fetchProjects, config]); + + return ( +
+ {(() => { + switch (projects.state) { + case 'loading': + return
Loading...
; // Just an example + case 'hasData': + return ( + <> + { + return { + id: project.id!, + name: project.name, + selected: project.id == 1 ? true : false, + onSettingsClick: proj => { + setSelectedProject(proj.id); + navigate('/settings'); + }, + }; + })} + /> + + + ); + //TODO: Handle these cases (probs toast and navigate away?) + case 'hasError': + return
Error encountered
; // Render the error + case 'hasDataWithError': + return
Data loaded but with error
; // Handle this case as well + default: + return null; // Handle default case, if needed + } + })()} +
+ ); +}; + +export default Root; From 8a380845fda30e179e5d2bd7621dc7469a6aa2a0 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:49:16 +0100 Subject: [PATCH 24/72] Create settings page, fetch and render data from api --- .dictionary/custom.txt | 1 + frontend/src/pages/settings/index.tsx | 2 + frontend/src/pages/settings/settings.tsx | 117 +++++++++++++++++++++ frontend/src/pages/settings/useSettings.ts | 36 +++++++ 4 files changed, 156 insertions(+) create mode 100644 frontend/src/pages/settings/index.tsx create mode 100644 frontend/src/pages/settings/settings.tsx create mode 100644 frontend/src/pages/settings/useSettings.ts diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index d03a606..e370084 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -84,3 +84,4 @@ pyca pycryptodome zustand probs +Customise diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx new file mode 100644 index 0000000..92e65da --- /dev/null +++ b/frontend/src/pages/settings/index.tsx @@ -0,0 +1,2 @@ +import settings from './settings'; +export default settings; diff --git a/frontend/src/pages/settings/settings.tsx b/frontend/src/pages/settings/settings.tsx new file mode 100644 index 0000000..f78bbcb --- /dev/null +++ b/frontend/src/pages/settings/settings.tsx @@ -0,0 +1,117 @@ +import { ProjectMembersInner, ProjectMembersInnerTypeEnum } from 'api'; +import CustomButton from 'components/button'; +import CopyInput from 'components/copyInput'; +import Table from 'components/table'; +import TextInput from 'components/textInput'; +import { MdAdd } from 'react-icons/md'; +import { useProjectsStore } from 'stores/useProjectsStore'; +import useSettings from './useSettings'; + +const headers = [ + { propertyName: 'email', displayName: 'Email' }, + { propertyName: 'name', displayName: 'Name' }, + { propertyName: 'role', displayName: 'Role' }, +]; + +type UserData = { + email: string; + name: string; + role: string; +}; + +const Settings = () => { + const { selectedProject } = useProjectsStore(); + //TODO: Can we improve this to just pass in selectedProject as is without a check? + const { formik } = useSettings( + selectedProject.state == 'hasData' + ? { projectName: selectedProject?.data.name } + : { projectName: '' }, + ); + + // TODO: If there is no selected project, display an error on the page. + // There needs to be a selected project before page is loaded. + + // Convert selectedProject.data.members to UserData format + const convertMembersToUserData = ( + members: Array | undefined, + ): UserData[] => { + return ( + members?.map(member => ({ + email: member.user.email, + name: `${member.user.first_name || ''} ${member.user.last_name || ''}`.trim(), + role: member.type === ProjectMembersInnerTypeEnum.Adm ? 'Admin' : 'Member', + })) || [] + ); + }; + + const handleMoreClick = () => { + console.log('More icon clicked'); + }; + + return ( +
+ {(() => { + switch (selectedProject.state) { + case 'loading': + //TODO: Better loading experience + return
Loading...
; // Just an example + case 'hasData': { + console.log('members', selectedProject.data.members); + const membersData = convertMembersToUserData(selectedProject.data.members); + + return ( +
+
+

Project Settings

+

Customise your project settings here.

+
+ +
+

Members

+ + } + onClick={() => console.log('button clicked')} + /> + +
+

Project API Key

+ +
+
+

Project ID

+

+ You can use this to reference your project in the API. +

+ +
+ + ); + } + case 'hasError': + //TODO: Toast error message and navigate away (probs to / route) + return
Error encountered
; // Render the error + case 'hasDataWithError': + return
Data loaded but with error
; // Handle this case as well + default: + return null; // Handle default case, if needed + } + })()} + + ); +}; +export default Settings; diff --git a/frontend/src/pages/settings/useSettings.ts b/frontend/src/pages/settings/useSettings.ts new file mode 100644 index 0000000..7388fdb --- /dev/null +++ b/frontend/src/pages/settings/useSettings.ts @@ -0,0 +1,36 @@ +import { useFormik } from 'formik'; +import { useEffect } from 'react'; + +interface FormValues { + projectName?: string; +} + +const useSettings = ({ projectName: initialProjectName }: FormValues) => { + + const formik = useFormik({ + initialValues: { + projectName: initialProjectName || '', + }, + onSubmit: ({ projectName }: FormValues) => { + // if (email && password) { + // login({ email, password }).then(res => { + // if (res?.success) navigate('/'); + // else setErrorAlert(res?.errors[0]); + // }); + // } + }, + }); + + useEffect(() => { + // this is to prevent infinite re-render cycle + if (formik.values.projectName !== initialProjectName) { + formik.setFieldValue('projectName', initialProjectName); + } + }, [initialProjectName, formik]); + + return { + formik, + }; +}; + +export default useSettings; From eeaa2a16a08e093839c5de56f6a6124570fb3726 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:49:23 +0100 Subject: [PATCH 25/72] Add flow page stub --- frontend/src/pages/flows/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 frontend/src/pages/flows/index.tsx diff --git a/frontend/src/pages/flows/index.tsx b/frontend/src/pages/flows/index.tsx new file mode 100644 index 0000000..fbf4fb0 --- /dev/null +++ b/frontend/src/pages/flows/index.tsx @@ -0,0 +1,5 @@ +const Flows = () => { + return
Flows page.
; +}; + +export default Flows; From 55a0d52e0eee6d9e14ab6a8df4e66b4242021938 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:50:33 +0100 Subject: [PATCH 26/72] Add protected route component to use with auth hook --- frontend/src/routes/ProtectedRoute.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 frontend/src/routes/ProtectedRoute.tsx diff --git a/frontend/src/routes/ProtectedRoute.tsx b/frontend/src/routes/ProtectedRoute.tsx new file mode 100644 index 0000000..e69f433 --- /dev/null +++ b/frontend/src/routes/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import React, { useEffect } from 'react'; +import { useAuthentication } from 'api'; + +import { useCookies } from 'react-cookie'; +import { Navigate } from 'react-router-dom'; + + +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { checkAuth } = useAuthentication(); + const [cookies] = useCookies(['refreshToken']); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + if (!cookies.refreshToken) { + return ; + } + + return <>{children} || null; +}; + +export default ProtectedRoute; From bc59ee6395f84bc0a42353aee25d74a3a740680e Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 20:51:05 +0100 Subject: [PATCH 27/72] Refactor useRouter and add auth token context --- frontend/src/App.tsx | 12 ++++- frontend/src/routes/useRouter.tsx | 76 +++++++++++++++++++++---------- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74c3572..4040f59 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,14 +1,22 @@ -import React from 'react'; +import React, { useState } from 'react'; // import { setOpenApiBase } from 'api/configOpenApi'; import Router from './routes'; +import { TokenContext } from 'api'; function App() { + const [accessToken, setAccessToken] = useState(''); + const [refreshToken, setRefreshToken] = useState(''); + // Will not work until openapi is setup // useEffect(() => { // setOpenApiBase(); // }, []); - return ; + return ( + + + + ); } export default App; diff --git a/frontend/src/routes/useRouter.tsx b/frontend/src/routes/useRouter.tsx index 7358f79..3971b3f 100644 --- a/frontend/src/routes/useRouter.tsx +++ b/frontend/src/routes/useRouter.tsx @@ -5,35 +5,65 @@ import { useCookies } from 'react-cookie'; import NotFound from './404'; import Login from 'pages/Authentication'; import SidebarMenu from 'components/sidebarMenu'; +import Root from 'pages/root'; +import Settings from 'pages/settings/settings'; +import Flows from 'pages/flows'; +import { useAuthentication } from 'api'; +import ProtectedRoute from './ProtectedRoute'; const useRouter = () => { const [cookies] = useCookies(['refreshToken']); const authenticated = cookies.refreshToken; + //const { authenticated } = useAuthentication(); let routerList: RouteObject[] = []; - if (authenticated) { - routerList = [ - { - path: '/', - element: , - }, - { - path: '*', - element: , - }, - ]; - } else { - routerList = [ - { - path: '/', - element: , - }, - { - path: '*', - element: , - }, - ]; - } + routerList = [ + { + path: '/', + element: , + children: [ + { path: '/', element: }, + { path: 'flows', element: }, + { path: 'settings', element: }, + ], + }, + { + path: '/login', + element: + }, + { + path: '*', + element: , + }, + ]; + + // if (authenticated) { + // routerList = [ + // { + // path: '/', + // element: , + // children: [ + // { path: '/', element: }, + // { path: 'settings', element: }, + // ], + // }, + // { + // path: '*', + // element: , + // }, + // ]; + // } else { + // routerList = [ + // { + // path: '/', + // element: , + // }, + // { + // path: '*', + // element: , + // }, + // ]; + // } const router = createHashRouter(routerList); From 4a91a553e955618c1df97c7eef7e0e2845d7f008 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Mon, 11 Sep 2023 21:59:28 +0100 Subject: [PATCH 28/72] Add custom modal component --- frontend/src/components/modal/index.tsx | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 frontend/src/components/modal/index.tsx diff --git a/frontend/src/components/modal/index.tsx b/frontend/src/components/modal/index.tsx new file mode 100644 index 0000000..7a9b337 --- /dev/null +++ b/frontend/src/components/modal/index.tsx @@ -0,0 +1,78 @@ +import { Dialog, Transition } from '@headlessui/react'; +import { Fragment, ReactNode } from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + buttonText: string; + panelClassName?: string; + overlayClassName?: string; + children: ReactNode; +} + +const Modal = ({ + isOpen, + onClose, + title, + buttonText, + panelClassName = '', + overlayClassName = '', + children, +}: ModalProps) => { + return ( + <> + + + +
+ + +
+
+ + + + {title} + +
{children}
+
+ +
+
+
+
+
+
+
+ + ); +}; + +export default Modal; From 5c9f0e71e4afcce4924b077d38cf6e3835dc056a Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:31:15 +0100 Subject: [PATCH 29/72] Add shadows, gradients, colours and custom font size to config --- frontend/tailwind.config.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index de5f683..fac21a9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -9,12 +9,28 @@ module.exports = { dropShadow: { lg: '4px 4px 16px rgba(0, 0, 0, 0.15)', }, + boxShadow: { + light: '0 2px 4px 0 rgba(0, 0, 0, 0.04), 0 0px 4px 0 rgba(0, 0, 0, 0.08)', + strong: '0 2px 4px 0 rgba(0, 0, 0, 0.04), 2px 4px 16px 0 rgba(0, 0, 0, 0.12)', + raised: '0 1px 2px 0 rgba(0, 0, 0, 0.24)', + }, + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'radial-light': + 'radial-gradient(rgba(255,255,255,0.24), rgba(5, 5, 44, 0.24)), linear-gradient(#05052C, #05052C)', + 'radial-ultra-light': + 'radial-gradient(rgba(255,255,255,0.12), rgba(5, 5, 44, 0.12)), linear-gradient(#05052C, #05052C)', + }, fontFamily: { sans: ['Inter', ...defaultTheme.fontFamily.sans], heebo: ['Heebo', ...defaultTheme.fontFamily.sans], }, + fontSize: { + regular: '13px', + }, colors: { 'crumpet-light': { + 50: '#FDFBF9', 100: '#FBF8F3', 200: '#F5F0E7', 300: '#E9E2D6', @@ -27,7 +43,11 @@ module.exports = { 300: '#ACA69A', 500: '#989082', }, - 'oxford': '#05052C', + grey: { + 700: '#7D766C', + 900: '#51493E', + }, + oxford: '#05052C', 'hunyadi-yellow': '#FBC571', }, }, From 539c7be376ffd44414456167b2437919b5ca1963 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:32:05 +0100 Subject: [PATCH 30/72] Create emphasis button component and new buttons directory --- .../buttons/EmphasisButton/index.tsx | 44 +++++++++++++++++++ frontend/src/components/buttons/index.ts | 1 + 2 files changed, 45 insertions(+) create mode 100644 frontend/src/components/buttons/EmphasisButton/index.tsx create mode 100644 frontend/src/components/buttons/index.ts diff --git a/frontend/src/components/buttons/EmphasisButton/index.tsx b/frontend/src/components/buttons/EmphasisButton/index.tsx new file mode 100644 index 0000000..5444c94 --- /dev/null +++ b/frontend/src/components/buttons/EmphasisButton/index.tsx @@ -0,0 +1,44 @@ +interface EmphasisButtonProps { + text: string; + variant?: 'primary' | 'secondary'; + enabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + onClick: () => void; + className?: string; + icon?: React.ReactElement; +} + +const EmphasisButton = ({ + text, + variant = 'primary', + enabled = true, + type = 'button', + onClick, + className, + icon, +}: EmphasisButtonProps) => { + const baseStyles = 'flex items-center justify-center rounded-lg px-8 py-2 text-sm font-semibold'; + const primaryStyles = 'bg-radial-light hover:bg-radial-ultra-light shadow-light text-white'; + const secondaryStyles = + 'bg-white border border-crumpet-light-300 text-oxford shadow-light hover:bg-crumpet-light-100'; + const disabledStyles = 'bg-crumpet-light-50 border-crumpet-light-300 text-grey-700'; + + const buttonStyles = enabled + ? variant === 'primary' + ? primaryStyles + : secondaryStyles + : disabledStyles; + + return ( + + ); +}; + +export default EmphasisButton; diff --git a/frontend/src/components/buttons/index.ts b/frontend/src/components/buttons/index.ts new file mode 100644 index 0000000..be31944 --- /dev/null +++ b/frontend/src/components/buttons/index.ts @@ -0,0 +1 @@ +export { default as EmphasisButton } from './EmphasisButton'; From 2f9b465172f533c78f5ac27e0a94c8c550a77021 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:33:28 +0100 Subject: [PATCH 31/72] Create text input component and inputs directory --- frontend/src/components/inputs/TextInput.tsx | 49 ++++++++++++++++++++ frontend/src/components/inputs/index.ts | 1 + 2 files changed, 50 insertions(+) create mode 100644 frontend/src/components/inputs/TextInput.tsx create mode 100644 frontend/src/components/inputs/index.ts diff --git a/frontend/src/components/inputs/TextInput.tsx b/frontend/src/components/inputs/TextInput.tsx new file mode 100644 index 0000000..de2e08e --- /dev/null +++ b/frontend/src/components/inputs/TextInput.tsx @@ -0,0 +1,49 @@ +interface TextInputProps { + label: string; + description: string; + value: string; + error?: string | null; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + className?: string; + inputProps?: React.InputHTMLAttributes; +} + +const TextInput = ({ + label, + description, + value, + error, + onChange, + placeholder, + inputProps, + className, +}: TextInputProps) => { + return ( +
+
+

{label}

+

{description}

+
+
+ +

{error}

+
+
+ ); +}; + +export default TextInput; diff --git a/frontend/src/components/inputs/index.ts b/frontend/src/components/inputs/index.ts new file mode 100644 index 0000000..137a144 --- /dev/null +++ b/frontend/src/components/inputs/index.ts @@ -0,0 +1 @@ +export { default as TextInput } from './TextInput'; From e308a01b5f287b0ee988cee22348e09b8ccd1b1f Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:35:26 +0100 Subject: [PATCH 32/72] Move projects store and add create project action --- frontend/src/features/projects/index.ts | 0 .../src/{ => features/projects}/stores/useProjectsStore.ts | 7 ++++++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/projects/index.ts rename frontend/src/{ => features/projects}/stores/useProjectsStore.ts (78%) diff --git a/frontend/src/features/projects/index.ts b/frontend/src/features/projects/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/stores/useProjectsStore.ts b/frontend/src/features/projects/stores/useProjectsStore.ts similarity index 78% rename from frontend/src/stores/useProjectsStore.ts rename to frontend/src/features/projects/stores/useProjectsStore.ts index 55c12fd..5f200a7 100644 --- a/frontend/src/stores/useProjectsStore.ts +++ b/frontend/src/features/projects/stores/useProjectsStore.ts @@ -7,9 +7,9 @@ type ProjectsStore = { projects: ApiState; fetchProjects: (config: Configuration) => void; setSelectedProject: (projectId: number) => void; + createProject: (name: string, config: Configuration) => void; }; -//TODO: Need to handle currently active project vs. selected project (in the settings page) export const useProjectsStore = create((set, get) => ({ selectedProject: ApiState.initial(), projects: ApiState.initial(), @@ -29,4 +29,9 @@ export const useProjectsStore = create((set, get) => ({ } } }, + createProject: async (name: string, config: Configuration) => { + const res = await new ProjectsApi(config).createProject({ name: name }); + set(state => ({ selectedProject: ApiState.hasData(res.data) })); + get().fetchProjects(config); + }, })); From 4b710d1b24d4204570fd6a0f15d8b7ed437a0f56 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:42:00 +0100 Subject: [PATCH 33/72] Add 'CreateProjectModal' and corresponding hook/logic Using formik for the form validation on the project name which is contained within a custom hook for the component. Tried to keep the component clean and loosely follow the facade pattern. The projects store is used to create and update existing projects. --- .../components/CreateProjectModal.tsx | 92 +++++++++++++++++++ .../projects/hooks/useCreateProject.ts | 58 ++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 frontend/src/features/projects/components/CreateProjectModal.tsx create mode 100644 frontend/src/features/projects/hooks/useCreateProject.ts diff --git a/frontend/src/features/projects/components/CreateProjectModal.tsx b/frontend/src/features/projects/components/CreateProjectModal.tsx new file mode 100644 index 0000000..642ff7e --- /dev/null +++ b/frontend/src/features/projects/components/CreateProjectModal.tsx @@ -0,0 +1,92 @@ +import { Dialog, Transition } from '@headlessui/react'; +import EmphasisButton from 'components/buttons/EmphasisButton'; +import { TextInput } from 'components/inputs'; +import { Fragment } from 'react'; +import { useNavigate } from 'react-router-dom'; +import useCreateProject from '../hooks/useCreateProject'; + +interface CreateProjectModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const CreateProjectModal = ({ isOpen, onClose }: CreateProjectModalProps) => { + const navigate = useNavigate(); + const close = () => { + formik.resetForm({ values: formik.initialValues, errors: {} }); + onClose(); + }; + + const onSubmit = () => { + close(); + }; + + const { formik } = useCreateProject({ onSubmit: onSubmit }); + + return ( + + + +
+ + +
+
+ + + + Create a new project + + + Please provide the following details to continue. + +
+ +
+ +
+ + +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/features/projects/hooks/useCreateProject.ts b/frontend/src/features/projects/hooks/useCreateProject.ts new file mode 100644 index 0000000..5fdc8a5 --- /dev/null +++ b/frontend/src/features/projects/hooks/useCreateProject.ts @@ -0,0 +1,58 @@ +import { useApiConfig } from 'api'; +import { AxiosError } from 'axios'; +import { FormikErrors, useFormik } from 'formik'; +import { useProjectsStore } from '../stores/useProjectsStore'; + +interface FormValues { + name: string; +} + +interface useCreateProjectProps { + onSubmit: () => void; +} + +const useCreateProject = ({onSubmit} : useCreateProjectProps) => { + const { config } = useApiConfig(); + const { createProject } = useProjectsStore(); + const formik = useFormik({ + initialValues: { + name: '', + }, + validate: values => { + const errors: FormikErrors = {}; + if (!values.name) { + errors.name = 'You must provide a name.'; + } + return errors; + }, + onSubmit: async ({ name }: FormValues) => { + try { + // Run onSubmit if successful + await createProject(name, config); + onSubmit(); + } catch (e) { + //TODO: Propagate errors to form using setError etc. + if (e instanceof AxiosError) { + if (e.response) { + const errorData = e.response.data; + if (errorData.detail) { + console.error('General error:', errorData.detail); + } else { + // Handle model field errors. + for (const field in errorData) { + console.error(`Error with ${field}:`, errorData[field].join(', ')); + } + } + } + } else { + // Some other error + console.error(e); + } + } + }, + }); + + return { formik }; +}; + +export default useCreateProject; From c6e816cbb5f8d824ce7475e196ab7062f8db21fd Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:47:11 +0100 Subject: [PATCH 34/72] Fix imports --- frontend/src/pages/root/index.tsx | 2 +- frontend/src/pages/settings/settings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/root/index.tsx b/frontend/src/pages/root/index.tsx index 36ba833..1dc79ce 100644 --- a/frontend/src/pages/root/index.tsx +++ b/frontend/src/pages/root/index.tsx @@ -1,8 +1,8 @@ import { useApiConfig } from 'api'; import SidebarMenu from 'components/sidebarMenu'; +import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; import { useEffect } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; -import { useProjectsStore } from 'stores/useProjectsStore'; const Root = () => { const { config } = useApiConfig(); diff --git a/frontend/src/pages/settings/settings.tsx b/frontend/src/pages/settings/settings.tsx index f78bbcb..c53c9ab 100644 --- a/frontend/src/pages/settings/settings.tsx +++ b/frontend/src/pages/settings/settings.tsx @@ -3,8 +3,8 @@ import CustomButton from 'components/button'; import CopyInput from 'components/copyInput'; import Table from 'components/table'; import TextInput from 'components/textInput'; +import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; import { MdAdd } from 'react-icons/md'; -import { useProjectsStore } from 'stores/useProjectsStore'; import useSettings from './useSettings'; const headers = [ From 0b50f71cd3291ad4ad47ba7866fff7c7d07702ed Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 20 Oct 2023 21:48:35 +0100 Subject: [PATCH 35/72] Add 'CreateProjectsModal' to sidebar (where it is toggled) --- frontend/src/components/sidebarMenu/index.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 89014a2..3ecb8bb 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -1,4 +1,4 @@ -import { Popover, Transition } from '@headlessui/react'; +import { Dialog, Popover, Transition } from '@headlessui/react'; import Picker from 'components/picker'; import CustomButton from 'components/button'; import SidebarButtonPrimary from './sidebarButtonPrimary'; @@ -14,7 +14,9 @@ import { } from 'react-icons/md'; import { ReactComponent as Flow } from 'assets/icons/Flow Icon.svg'; import { ReactComponent as CrumpetLogo } from 'assets/images/Crumpet Logo Oxford.svg'; -import { Fragment } from 'react'; +import { Fragment, useState } from 'react'; +import TextInput from 'components/textInput'; +import { CreateProjectModal } from 'features/projects/components/CreateProjectModal'; const environments = [ { id: 1, name: 'Development' }, @@ -33,6 +35,18 @@ interface SidebarMenuProps { } const SidebarMenu = ({ projects }: SidebarMenuProps) => { + //TODO: Use custom modal and encapsulate logic into its own hook + const [projectName, setProjectName] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + function closeModal() { + setIsOpen(false); + } + + function openModal() { + setIsOpen(true); + } + // icon property is a function to allow for styling const ButtonList = [ { @@ -143,7 +157,7 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { { - console.log('clicked'); + openModal(); }} /> @@ -155,6 +169,7 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { + ); }; From 276b68c50e902d3b7c4557c5f346b6ebf2ac5adc Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 14:24:46 +0100 Subject: [PATCH 36/72] Install react-hot-toast for toasts --- frontend/package.json | 1 + frontend/src/App.tsx | 2 ++ frontend/yarn.lock | 12 ++++++++++++ 3 files changed, 15 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index a5f9a33..8cc2c24 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react": "^18.2.0", "react-cookie": "^4.1.1", "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", "react-icons": "^4.7.1", "react-router-dom": "^6.8.1", "serve": "^14.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4040f59..4ffac16 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; // import { setOpenApiBase } from 'api/configOpenApi'; import Router from './routes'; import { TokenContext } from 'api'; +import { Toaster } from 'react-hot-toast'; function App() { const [accessToken, setAccessToken] = useState(''); @@ -15,6 +16,7 @@ function App() { return ( + ); } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1e499b3..758bec0 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3272,6 +3272,11 @@ globrex@^0.1.2: resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== +goober@^2.1.10: + version "2.1.13" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.13.tgz#e3c06d5578486212a76c9eba860cbc3232ff6d7c" + integrity sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -4772,6 +4777,13 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== +react-hot-toast@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" + integrity sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ== + dependencies: + goober "^2.1.10" + react-icons@^4.7.1: version "4.9.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.9.0.tgz#ba44f436a053393adb1bdcafbc5c158b7b70d2a3" From 12eb21c66b46f7389d274684edf02cc4a9127f79 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 14:24:59 +0100 Subject: [PATCH 37/72] Add simple fade in/out animation --- frontend/src/index.css | 18 ++++++++++++++++++ frontend/tailwind.config.js | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index de79285..9dc219f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -12,3 +12,21 @@ -moz-osx-font-smoothing: grayscale; } } + +@keyframes in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index fac21a9..6386dd7 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -6,6 +6,10 @@ module.exports = { content: ['./src/**/*.{js,jsx,ts,tsx}'], theme: { extend: { + animation: { + 'in': 'in 0.3s forwards', + 'out': 'out 0.3s forwards' + }, dropShadow: { lg: '4px 4px 16px rgba(0, 0, 0, 0.15)', }, From 7e9f93864ceb2e40f6f3b2d37a2273da2b9d7ae5 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 14:25:14 +0100 Subject: [PATCH 38/72] Create Toast component --- frontend/src/components/Toast/index.tsx | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 frontend/src/components/Toast/index.tsx diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx new file mode 100644 index 0000000..53102ff --- /dev/null +++ b/frontend/src/components/Toast/index.tsx @@ -0,0 +1,27 @@ +import { MdInfoOutline } from 'react-icons/md'; +import { resolveValue, Toast as RHToast } from 'react-hot-toast'; + +interface ToastProps { + toast: RHToast; + customMessage?: string | null; +} + +const Toast = ({ toast, customMessage = null }: ToastProps) => { + const message = customMessage != null ? customMessage : resolveValue(toast.message, toast); + return ( +
+
+ +
+
+

{message}

+
+
Close
+
+ ); +}; + +export default Toast; From a5ba77df4a56bcd27cfa3912b43f73c8c2c3c6cb Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 14:25:29 +0100 Subject: [PATCH 39/72] Add call to toast from create projects modal --- .../src/features/projects/components/CreateProjectModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/features/projects/components/CreateProjectModal.tsx b/frontend/src/features/projects/components/CreateProjectModal.tsx index 642ff7e..20d0b35 100644 --- a/frontend/src/features/projects/components/CreateProjectModal.tsx +++ b/frontend/src/features/projects/components/CreateProjectModal.tsx @@ -1,7 +1,9 @@ import { Dialog, Transition } from '@headlessui/react'; import EmphasisButton from 'components/buttons/EmphasisButton'; import { TextInput } from 'components/inputs'; +import Toast from 'components/Toast'; import { Fragment } from 'react'; +import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import useCreateProject from '../hooks/useCreateProject'; @@ -19,6 +21,7 @@ export const CreateProjectModal = ({ isOpen, onClose }: CreateProjectModalProps) const onSubmit = () => { close(); + toast.custom(t => ); }; const { formik } = useCreateProject({ onSubmit: onSubmit }); From 69883333c7a3f1a99d39d53e073646e425ed67a8 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 17:32:04 +0100 Subject: [PATCH 40/72] Refactor sidebar buttons (minor) --- .../src/components/sidebarMenu/sidebarButtonPrimary/index.tsx | 4 ++-- .../components/sidebarMenu/sidebarButtonSecondary/index.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/sidebarMenu/sidebarButtonPrimary/index.tsx b/frontend/src/components/sidebarMenu/sidebarButtonPrimary/index.tsx index 112b415..4aefcfd 100644 --- a/frontend/src/components/sidebarMenu/sidebarButtonPrimary/index.tsx +++ b/frontend/src/components/sidebarMenu/sidebarButtonPrimary/index.tsx @@ -18,10 +18,10 @@ const SidebarButtonPrimary = ({ onClick={onClick} className={`${widthFill ? 'w-full' : ''} ${ selected ? 'bg-crumpet-light-200' : '' - } hover:bg-crumpet-light-200 text-oxford font-bold py-2 px-3 rounded-md justify-start gap-3 + } hover:bg-crumpet-light-200 text-oxford font-bold p-2 rounded-md justify-start gap-3 inline-flex items-center`}> {icon} -
{label}
+
{label}
); }; diff --git a/frontend/src/components/sidebarMenu/sidebarButtonSecondary/index.tsx b/frontend/src/components/sidebarMenu/sidebarButtonSecondary/index.tsx index cc9455a..a92534f 100644 --- a/frontend/src/components/sidebarMenu/sidebarButtonSecondary/index.tsx +++ b/frontend/src/components/sidebarMenu/sidebarButtonSecondary/index.tsx @@ -19,9 +19,9 @@ const SidebarButtonSecondary = ({ className={`${ widthFill ? 'w-full' : '' } bg-transparent hover:text-crumpet-dark-300 text-crumpet-dark-500 - py-2 px-3 rounded-md justify-start gap-3 inline-flex items-center`}> + p-2 rounded-md justify-start gap-3 inline-flex items-center`}> {icon} -
{label}
+
{label}
{secondaryIcon}
); From b713f6ac7cda99b21419a9f111fc11143883aaeb Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 21 Oct 2023 17:33:43 +0100 Subject: [PATCH 41/72] Add projects fetch and set selected project in sidebar on load --- frontend/src/components/sidebarMenu/index.tsx | 45 ++++++++++--------- .../projects/stores/useProjectsStore.ts | 13 ++++++ frontend/src/pages/root/index.tsx | 8 ++-- frontend/src/utils.ts | 9 ++++ 4 files changed, 51 insertions(+), 24 deletions(-) create mode 100644 frontend/src/utils.ts diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 3ecb8bb..4488b0e 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -4,7 +4,6 @@ import CustomButton from 'components/button'; import SidebarButtonPrimary from './sidebarButtonPrimary'; import SidebarButtonSecondary from './sidebarButtonSecondary'; import { - MdSpoke, MdChevronRight, MdSupport, MdOutlineOpenInNew, @@ -15,8 +14,9 @@ import { import { ReactComponent as Flow } from 'assets/icons/Flow Icon.svg'; import { ReactComponent as CrumpetLogo } from 'assets/images/Crumpet Logo Oxford.svg'; import { Fragment, useState } from 'react'; -import TextInput from 'components/textInput'; import { CreateProjectModal } from 'features/projects/components/CreateProjectModal'; +import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; +import { getFirstLetter } from 'utils'; const environments = [ { id: 1, name: 'Development' }, @@ -35,9 +35,9 @@ interface SidebarMenuProps { } const SidebarMenu = ({ projects }: SidebarMenuProps) => { - //TODO: Use custom modal and encapsulate logic into its own hook - const [projectName, setProjectName] = useState(''); const [isOpen, setIsOpen] = useState(false); + const { selectedProject } = useProjectsStore(); + const isLoadingState = ['initial', 'loading', 'hasError'].includes(selectedProject.state); function closeModal() { setIsOpen(false); @@ -64,19 +64,12 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { console.log('Clicked Button'); }, }, - { - label: 'Segments', - icon: () => , - onClick: () => { - console.log('Clicked Button'); - }, - }, ]; return ( <>
@@ -84,7 +77,7 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => {
Crumpet
-
+
{ButtonList.map((button, index) => ( {
-
+
} label="Support" @@ -116,15 +109,27 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { {({ open }) => ( <> - -
-
- C + {isLoadingState ? ( + // Render pulsing grey rectangle for initial, loading, and hasError states +
+
+
+
+ ) : ( +
+
+ {getFirstLetter( + selectedProject.state === 'hasData' ? selectedProject.data.name : null, + )} +
+ + {selectedProject.state === 'hasData' ? selectedProject.data.name : ''} +
- Crumpet -
+ )} ; fetchProjects: (config: Configuration) => void; setSelectedProject: (projectId: number) => void; + fetchAndSelectProject: (config: Configuration) => void; createProject: (name: string, config: Configuration) => void; }; @@ -29,6 +30,18 @@ export const useProjectsStore = create((set, get) => ({ } } }, + fetchAndSelectProject: (config: Configuration) => { + set(state => ({ projects: ApiState.loading() })); + new ProjectsApi(config) + .listProjects() + .then(res => + set(state => ({ + projects: ApiState.hasData(res.data), + selectedProject: ApiState.hasData(res.data[0]), + })), + ) + .catch(err => set(state => ({ projects: ApiState.hasError(err) }))); + }, createProject: async (name: string, config: Configuration) => { const res = await new ProjectsApi(config).createProject({ name: name }); set(state => ({ selectedProject: ApiState.hasData(res.data) })); diff --git a/frontend/src/pages/root/index.tsx b/frontend/src/pages/root/index.tsx index 1dc79ce..8f34430 100644 --- a/frontend/src/pages/root/index.tsx +++ b/frontend/src/pages/root/index.tsx @@ -6,12 +6,12 @@ import { Outlet, useNavigate } from 'react-router-dom'; const Root = () => { const { config } = useApiConfig(); - const { projects, fetchProjects, setSelectedProject } = useProjectsStore(); + const { projects, fetchAndSelectProject, setSelectedProject } = useProjectsStore(); const navigate = useNavigate(); useEffect(() => { - fetchProjects(config); - }, [fetchProjects, config]); + fetchAndSelectProject(config); + }, [fetchAndSelectProject, config]); return (
@@ -38,7 +38,7 @@ const Root = () => { ); - //TODO: Handle these cases (probs toast and navigate away?) + //TODO: Handle these cases (probs toast and navigate away?) case 'hasError': return
Error encountered
; // Render the error case 'hasDataWithError': diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 0000000..640fdf0 --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,9 @@ +/** + * Utility function to get the first letter of a string. + * + * @param str - The input string. + * @returns The first letter of the string, or '' if the string is null or empty. + */ +export const getFirstLetter = (str: string | null | undefined): string => { + return str && str.length > 0 ? str.charAt(0) : ''; +} From 882584d01cf28e7c9477cf5ed284e3a13500c6c9 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 22 Oct 2023 10:55:54 +0100 Subject: [PATCH 42/72] Add type guards for ApiState --- frontend/src/api/utils.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts index 9e8bd70..19b7814 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -65,3 +65,31 @@ export const ApiState = { hasError, hasDataWithError, }; + +/** + * Type guards for ApiState. + * + * See https://www.typescriptlang.org/docs/handbook/advanced-types.html for details. + */ + +export const isHasData = ( + state: ApiState, +): state is + | { state: 'hasData'; data: T } + | { state: 'hasDataWithError'; data: T; error: Error } => { + return state.state === 'hasData' || state.state === 'hasDataWithError'; +}; + +export const isInitial = (state: ApiState): state is { state: 'initial' } => + state.state === 'initial'; + +export const isLoading = (state: ApiState): state is { state: 'loading' } => + state.state === 'loading'; + +export const isHasError = (state: ApiState): state is { state: 'hasError'; error: Error } => + state.state === 'hasError'; + +export const isHasDataWithError = ( + state: ApiState, +): state is { state: 'hasDataWithError'; data: T; error: Error } => + state.state === 'hasDataWithError'; From eb3d9628d5bc4eeea521fba04343921d9404c038 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 22 Oct 2023 10:56:30 +0100 Subject: [PATCH 43/72] Add text button (used in menus) --- .../components/buttons/TextButton/index.tsx | 20 +++++++++++++++++++ frontend/src/components/buttons/index.ts | 1 + frontend/tailwind.config.js | 1 + 3 files changed, 22 insertions(+) create mode 100644 frontend/src/components/buttons/TextButton/index.tsx diff --git a/frontend/src/components/buttons/TextButton/index.tsx b/frontend/src/components/buttons/TextButton/index.tsx new file mode 100644 index 0000000..7d6b0fd --- /dev/null +++ b/frontend/src/components/buttons/TextButton/index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { MdSettings } from 'react-icons/md'; + +interface TextButtonProps { + text: string; + icon?: React.ReactElement; + onClick: () => void; +} +const TextButton = ({ text, icon, onClick }: TextButtonProps) => { + return ( + + ); +}; + +export default TextButton; diff --git a/frontend/src/components/buttons/index.ts b/frontend/src/components/buttons/index.ts index be31944..a0c89bd 100644 --- a/frontend/src/components/buttons/index.ts +++ b/frontend/src/components/buttons/index.ts @@ -1 +1,2 @@ export { default as EmphasisButton } from './EmphasisButton'; +export {default as TextButton} from './TextButton'; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 6386dd7..e847fd5 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -48,6 +48,7 @@ module.exports = { 500: '#989082', }, grey: { + 500: '#A7A198', 700: '#7D766C', 900: '#51493E', }, From ec178ee73e93d20cf07339fce4819efe33d14227 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 22 Oct 2023 10:58:25 +0100 Subject: [PATCH 44/72] Refactor and improve projects selection menu in sidebar --- frontend/src/components/sidebarMenu/index.tsx | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 4488b0e..d34e303 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -1,6 +1,5 @@ -import { Dialog, Popover, Transition } from '@headlessui/react'; +import { Popover, Transition } from '@headlessui/react'; import Picker from 'components/picker'; -import CustomButton from 'components/button'; import SidebarButtonPrimary from './sidebarButtonPrimary'; import SidebarButtonSecondary from './sidebarButtonSecondary'; import { @@ -10,6 +9,7 @@ import { MdSettings, MdOutlineBadge, MdCheck, + MdAdd, } from 'react-icons/md'; import { ReactComponent as Flow } from 'assets/icons/Flow Icon.svg'; import { ReactComponent as CrumpetLogo } from 'assets/images/Crumpet Logo Oxford.svg'; @@ -17,6 +17,9 @@ import { Fragment, useState } from 'react'; import { CreateProjectModal } from 'features/projects/components/CreateProjectModal'; import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; import { getFirstLetter } from 'utils'; +import { TextButton } from 'components/buttons'; +import { useNavigate } from 'react-router'; +import { isHasData } from 'api/utils'; const environments = [ { id: 1, name: 'Development' }, @@ -36,8 +39,9 @@ interface SidebarMenuProps { const SidebarMenu = ({ projects }: SidebarMenuProps) => { const [isOpen, setIsOpen] = useState(false); - const { selectedProject } = useProjectsStore(); + const { selectedProject, setSelectedProject } = useProjectsStore(); const isLoadingState = ['initial', 'loading', 'hasError'].includes(selectedProject.state); + const navigate = useNavigate(); function closeModal() { setIsOpen(false); @@ -109,7 +113,8 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { {({ open }) => ( <> - {isLoadingState ? ( @@ -122,11 +127,11 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => {
{getFirstLetter( - selectedProject.state === 'hasData' ? selectedProject.data.name : null, + isHasData(selectedProject) ? selectedProject.data.name : null, )}
- {selectedProject.state === 'hasData' ? selectedProject.data.name : ''} + {isHasData(selectedProject) ? selectedProject.data.name : ''}
)} @@ -143,28 +148,41 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { leaveTo="opacity-0 translate-y-1"> -
-
+ className="absolute z-10 w-60 px-0 transform translate-x-full bottom-4"> +
+
{projects.map((project, index) => ( -
- project.onSettingsClick?.call(null, project)} - /> - {project.name} - {project.selected ? : <>} -
+ ))} - { - openModal(); - }} +
+ } + onClick={() => navigate('/settings')} /> + } onClick={openModal} />
From 2ccccafb54d8b98909ae56a9802b0333553c88b3 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 22 Oct 2023 13:34:28 +0100 Subject: [PATCH 45/72] Add PopperJS and fix alignment of projects selection popover --- .dictionary/custom.txt | 1 + frontend/package.json | 2 ++ frontend/src/components/sidebarMenu/index.tsx | 13 ++++++++- frontend/yarn.lock | 27 ++++++++++++++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.dictionary/custom.txt b/.dictionary/custom.txt index e370084..2cbe057 100644 --- a/.dictionary/custom.txt +++ b/.dictionary/custom.txt @@ -85,3 +85,4 @@ pycryptodome zustand probs Customise +popperjs diff --git a/frontend/package.json b/frontend/package.json index 8cc2c24..ea3e874 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "dependencies": { "@babel/preset-typescript": "^7.21.0", "@headlessui/react": "latest", + "@popperjs/core": "^2.11.8", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.0.0", @@ -24,6 +25,7 @@ "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-icons": "^4.7.1", + "react-popper": "^2.3.0", "react-router-dom": "^6.8.1", "serve": "^14.2.0", "typescript": "^4.4.2", diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index d34e303..3a7c642 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -20,6 +20,7 @@ import { getFirstLetter } from 'utils'; import { TextButton } from 'components/buttons'; import { useNavigate } from 'react-router'; import { isHasData } from 'api/utils'; +import { usePopper } from 'react-popper'; const environments = [ { id: 1, name: 'Development' }, @@ -43,6 +44,12 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { const isLoadingState = ['initial', 'loading', 'hasError'].includes(selectedProject.state); const navigate = useNavigate(); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'top', + }); + function closeModal() { setIsOpen(false); } @@ -114,6 +121,7 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { {({ open }) => ( <> @@ -148,7 +156,10 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { leaveTo="opacity-0 translate-y-1"> + ref={setPopperElement} + style={styles.popper} + {...attributes.popper} + className="w-full px-2">
{projects.map((project, index) => ( diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 758bec0..0dedfda 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -835,6 +835,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + "@remix-run/router@1.6.3": version "1.6.3" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.6.3.tgz#8205baf6e17ef93be35bf62c37d2d594e9be0dad" @@ -4066,7 +4071,7 @@ lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4777,6 +4782,11 @@ react-fast-compare@^2.0.1: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== +react-fast-compare@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" + integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== + react-hot-toast@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.4.1.tgz#df04295eda8a7b12c4f968e54a61c8d36f4c0994" @@ -4804,6 +4814,14 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-popper@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba" + integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-refresh@^0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" @@ -5810,6 +5828,13 @@ walker@^1.0.8: dependencies: makeerror "1.0.12" +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + web-vitals@^2.1.0: version "2.1.4" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c" From fb0a76b01264a30bc81f984c376c23f8b2203ebf Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:16:43 +0100 Subject: [PATCH 46/72] Create 'Copy' input component --- frontend/src/components/inputs/CopyInput.tsx | 37 ++++++++++++++++++++ frontend/src/components/inputs/index.ts | 1 + 2 files changed, 38 insertions(+) create mode 100644 frontend/src/components/inputs/CopyInput.tsx diff --git a/frontend/src/components/inputs/CopyInput.tsx b/frontend/src/components/inputs/CopyInput.tsx new file mode 100644 index 0000000..d31dbec --- /dev/null +++ b/frontend/src/components/inputs/CopyInput.tsx @@ -0,0 +1,37 @@ +import { useRef } from 'react'; +import { MdContentCopy } from 'react-icons/md'; + +interface CopyInputProps { + label: string; + description: string; + value: string; + className?: string; +} + +const CopyInput = ({ label, description, value, className }: CopyInputProps) => { + const inputRef = useRef(null); + + const handleCopyClick = () => { + if (inputRef.current) { + inputRef.current.select(); + document.execCommand('copy'); + } + }; + + return ( +
+
+

{label}

+

{description}

+
+
+ + +
+
+ ); +}; + +export default CopyInput; diff --git a/frontend/src/components/inputs/index.ts b/frontend/src/components/inputs/index.ts index 137a144..a92d8dc 100644 --- a/frontend/src/components/inputs/index.ts +++ b/frontend/src/components/inputs/index.ts @@ -1 +1,2 @@ export { default as TextInput } from './TextInput'; +export { default as CopyInput } from './CopyInput'; From 09d0acdbb740bfa9752d96d34f66009477826ac6 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:17:13 +0100 Subject: [PATCH 47/72] Create 'Members' table component for use in project settings --- .../components/tables/MembersTable/index.tsx | 46 +++++++++++++++++++ frontend/src/components/tables/index.ts | 1 + 2 files changed, 47 insertions(+) create mode 100644 frontend/src/components/tables/MembersTable/index.tsx create mode 100644 frontend/src/components/tables/index.ts diff --git a/frontend/src/components/tables/MembersTable/index.tsx b/frontend/src/components/tables/MembersTable/index.tsx new file mode 100644 index 0000000..9ea0e44 --- /dev/null +++ b/frontend/src/components/tables/MembersTable/index.tsx @@ -0,0 +1,46 @@ +import { MdOutlineDelete } from 'react-icons/md'; + +interface MemberData { + name: string; + email: string; +} + +interface MembersTableProps { + data: MemberData[]; + onClick?: () => void; +} + +const MembersTable = ({onClick, data} : MembersTableProps) => { + return ( +
+ {data.map((member, index) => { + const isFirst = index === 0; + const isLast = index === data.length - 1; + //Add appropriate borders and rounded edges depending on element index + const conditionalClassNames = isFirst + ? 'rounded-t border-t' + : isLast + ? 'rounded-b border-t border-b' + : 'border-t'; + return ( +
+
+

{member.name}

+

{member.email}

+
+ +
+ ); + })} +
+ ); +}; + +export default MembersTable; diff --git a/frontend/src/components/tables/index.ts b/frontend/src/components/tables/index.ts new file mode 100644 index 0000000..d569550 --- /dev/null +++ b/frontend/src/components/tables/index.ts @@ -0,0 +1 @@ +export { default as MembersTable } from './MembersTable'; From d48c1ce1ec9c721c4b72c8755188782c9101f101 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:17:27 +0100 Subject: [PATCH 48/72] Create 'Outline' button component --- .../components/buttons/OutlineButton/index.tsx | 16 ++++++++++++++++ frontend/src/components/buttons/index.ts | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/buttons/OutlineButton/index.tsx diff --git a/frontend/src/components/buttons/OutlineButton/index.tsx b/frontend/src/components/buttons/OutlineButton/index.tsx new file mode 100644 index 0000000..3b12f63 --- /dev/null +++ b/frontend/src/components/buttons/OutlineButton/index.tsx @@ -0,0 +1,16 @@ +interface OutlineButtonProps { + label: string; + className?: string; +} + +const OutlineButton = ({ label, className }: OutlineButtonProps) => { + return ( + + ); +}; + +export default OutlineButton; diff --git a/frontend/src/components/buttons/index.ts b/frontend/src/components/buttons/index.ts index a0c89bd..f41a592 100644 --- a/frontend/src/components/buttons/index.ts +++ b/frontend/src/components/buttons/index.ts @@ -1,2 +1,3 @@ export { default as EmphasisButton } from './EmphasisButton'; -export {default as TextButton} from './TextButton'; +export { default as TextButton } from './TextButton'; +export { default as OutlineButton } from './OutlineButton'; From 40368ea7a4fac7d8455e2e62613448c3806bc5cd Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:18:08 +0100 Subject: [PATCH 49/72] Add Ubuntu font --- frontend/src/index.css | 1 + frontend/tailwind.config.js | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/index.css b/frontend/src/index.css index 9dc219f..0b702c9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,6 @@ @import url('https://fonts.googleapis.com/css2?family=Inter&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Heebo:wght@700;800;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Ubuntu+Mono&display=swap'); @tailwind base; @tailwind components; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index e847fd5..0de5f70 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -28,6 +28,7 @@ module.exports = { fontFamily: { sans: ['Inter', ...defaultTheme.fontFamily.sans], heebo: ['Heebo', ...defaultTheme.fontFamily.sans], + ubuntu: ['Ubuntu', ...defaultTheme.fontFamily.mono], }, fontSize: { regular: '13px', From 7c36fc441579acbeba966652e37772559874a6e6 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:25:32 +0100 Subject: [PATCH 50/72] Finish project settings page styling and move to /projects folder --- .../projects/pages/ProjectSettings.tsx} | 65 +++++++++---------- 1 file changed, 30 insertions(+), 35 deletions(-) rename frontend/src/{pages/settings/settings.tsx => features/projects/pages/ProjectSettings.tsx} (60%) diff --git a/frontend/src/pages/settings/settings.tsx b/frontend/src/features/projects/pages/ProjectSettings.tsx similarity index 60% rename from frontend/src/pages/settings/settings.tsx rename to frontend/src/features/projects/pages/ProjectSettings.tsx index c53c9ab..ff75ec3 100644 --- a/frontend/src/pages/settings/settings.tsx +++ b/frontend/src/features/projects/pages/ProjectSettings.tsx @@ -1,11 +1,12 @@ import { ProjectMembersInner, ProjectMembersInnerTypeEnum } from 'api'; import CustomButton from 'components/button'; -import CopyInput from 'components/copyInput'; +import { CopyInput, TextInput } from 'components/inputs'; +import { OutlineButton } from 'components/buttons'; import Table from 'components/table'; -import TextInput from 'components/textInput'; +import { MembersTable } from 'components/tables'; import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; import { MdAdd } from 'react-icons/md'; -import useSettings from './useSettings'; +import useSettings from '../hooks/useProjectSettings'; const headers = [ { propertyName: 'email', displayName: 'Email' }, @@ -53,7 +54,7 @@ const Settings = () => { {(() => { switch (selectedProject.state) { case 'loading': - //TODO: Better loading experience + //TODO: Better loading experience return
Loading...
; // Just an example case 'hasData': { console.log('members', selectedProject.data.members); @@ -61,49 +62,43 @@ const Settings = () => { return (
-
-

Project Settings

-

Customise your project settings here.

-
-
-

Members

-
- } - onClick={() => console.log('button clicked')} - /> - -
-

Project API Key

- +
+
+

Members

+

Manage the members of your project.

+
+
-
-

Project ID

-

- You can use this to reference your project in the API. -

- + + +
+
+

Danger Zone

+

Delete your project and all associated data.

+
+
); } case 'hasError': - //TODO: Toast error message and navigate away (probs to / route) + //TODO: Toast error message and navigate away (probs to / route) return
Error encountered
; // Render the error case 'hasDataWithError': return
Data loaded but with error
; // Handle this case as well From 4a7e3fbd2f0ad07e44631da4d7e2622dd3e4229a Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Fri, 27 Oct 2023 13:26:08 +0100 Subject: [PATCH 51/72] Move project settings related files to /projects folder --- .../projects/hooks/useProjectSettings.ts} | 0 frontend/src/pages/settings/index.tsx | 2 -- frontend/src/routes/useRouter.tsx | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) rename frontend/src/{pages/settings/useSettings.ts => features/projects/hooks/useProjectSettings.ts} (100%) delete mode 100644 frontend/src/pages/settings/index.tsx diff --git a/frontend/src/pages/settings/useSettings.ts b/frontend/src/features/projects/hooks/useProjectSettings.ts similarity index 100% rename from frontend/src/pages/settings/useSettings.ts rename to frontend/src/features/projects/hooks/useProjectSettings.ts diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx deleted file mode 100644 index 92e65da..0000000 --- a/frontend/src/pages/settings/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import settings from './settings'; -export default settings; diff --git a/frontend/src/routes/useRouter.tsx b/frontend/src/routes/useRouter.tsx index 3971b3f..7ecb2cd 100644 --- a/frontend/src/routes/useRouter.tsx +++ b/frontend/src/routes/useRouter.tsx @@ -6,7 +6,7 @@ import NotFound from './404'; import Login from 'pages/Authentication'; import SidebarMenu from 'components/sidebarMenu'; import Root from 'pages/root'; -import Settings from 'pages/settings/settings'; +import Settings from 'features/projects/pages/ProjectSettings'; import Flows from 'pages/flows'; import { useAuthentication } from 'api'; import ProtectedRoute from './ProtectedRoute'; From 6f32e613df44df2e2343921fde56d686fe5e5e62 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 13:36:42 +0100 Subject: [PATCH 52/72] Add onClick prop to OutlineButton --- frontend/src/components/buttons/OutlineButton/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/buttons/OutlineButton/index.tsx b/frontend/src/components/buttons/OutlineButton/index.tsx index 3b12f63..16e98a0 100644 --- a/frontend/src/components/buttons/OutlineButton/index.tsx +++ b/frontend/src/components/buttons/OutlineButton/index.tsx @@ -1,11 +1,13 @@ interface OutlineButtonProps { label: string; className?: string; + onClick?: () => void; } -const OutlineButton = ({ label, className }: OutlineButtonProps) => { +const OutlineButton = ({ label, className, onClick }: OutlineButtonProps) => { return (
); diff --git a/frontend/src/features/projects/stores/useProjectsStore.ts b/frontend/src/features/projects/stores/useProjectsStore.ts index d63033b..4a2e59c 100644 --- a/frontend/src/features/projects/stores/useProjectsStore.ts +++ b/frontend/src/features/projects/stores/useProjectsStore.ts @@ -7,7 +7,7 @@ type ProjectsStore = { projects: ApiState; fetchProjects: (config: Configuration) => void; setSelectedProject: (projectId: number) => void; - fetchAndSelectProject: (config: Configuration) => void; + fetchAndSelectProject: (config: Configuration) => void; createProject: (name: string, config: Configuration) => void; }; From f1665c42ca91e9a9bcd14395bcb0258d19350572 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 15:44:16 +0100 Subject: [PATCH 54/72] Create custom container for toasts --- .../toasts/ToastContainer/index.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 frontend/src/components/toasts/ToastContainer/index.tsx diff --git a/frontend/src/components/toasts/ToastContainer/index.tsx b/frontend/src/components/toasts/ToastContainer/index.tsx new file mode 100644 index 0000000..bbb62b0 --- /dev/null +++ b/frontend/src/components/toasts/ToastContainer/index.tsx @@ -0,0 +1,30 @@ +import { useToaster } from 'react-hot-toast'; +import Toast from '../Toast'; + +const ToastContainer = () => { + const { toasts, handlers } = useToaster(); + const { startPause, endPause } = handlers; + + return ( +
+ {toasts + .filter(toast => toast.visible) + .map(toast => { + const message = typeof toast.message === 'string' ? toast.message : ''; + switch (toast.type) { + case 'success': + return ; + case 'error': + return ; + default: + return ; + } + })} +
+ ); +}; + +export default ToastContainer; From 7947fb2a1391030c8623cbab71a233c81e69f590 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 15:48:11 +0100 Subject: [PATCH 55/72] Update Toast component to use custom container and improve styling --- frontend/src/App.tsx | 4 +- frontend/src/components/Toast/index.tsx | 27 -------- .../src/components/toasts/Toast/index.tsx | 65 +++++++++++++++++++ frontend/src/components/toasts/index.ts | 2 + .../components/CreateProjectModal.tsx | 5 +- .../projects/pages/ProjectSettings.tsx | 6 +- 6 files changed, 74 insertions(+), 35 deletions(-) delete mode 100644 frontend/src/components/Toast/index.tsx create mode 100644 frontend/src/components/toasts/Toast/index.tsx create mode 100644 frontend/src/components/toasts/index.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ffac16..761905a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; // import { setOpenApiBase } from 'api/configOpenApi'; import Router from './routes'; import { TokenContext } from 'api'; -import { Toaster } from 'react-hot-toast'; +import { ToastContainer } from 'components/toasts'; function App() { const [accessToken, setAccessToken] = useState(''); @@ -16,7 +16,7 @@ function App() { return ( - + ); } diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx deleted file mode 100644 index 53102ff..0000000 --- a/frontend/src/components/Toast/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { MdInfoOutline } from 'react-icons/md'; -import { resolveValue, Toast as RHToast } from 'react-hot-toast'; - -interface ToastProps { - toast: RHToast; - customMessage?: string | null; -} - -const Toast = ({ toast, customMessage = null }: ToastProps) => { - const message = customMessage != null ? customMessage : resolveValue(toast.message, toast); - return ( -
-
- -
-
-

{message}

-
-
Close
-
- ); -}; - -export default Toast; diff --git a/frontend/src/components/toasts/Toast/index.tsx b/frontend/src/components/toasts/Toast/index.tsx new file mode 100644 index 0000000..4aae404 --- /dev/null +++ b/frontend/src/components/toasts/Toast/index.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; +import toast from 'react-hot-toast'; +import { MdInfoOutline } from 'react-icons/md'; + +interface ToastProps { + id?: string; + message: string; + type?: 'info' | 'success' | 'error'; + duration?: number; +} + +const Toast = ({ id, message, type = 'info', duration = 4000 }: ToastProps) => { + const [progress, setProgress] = useState(100); + const intervalRef = useRef(null); + const color = type == 'info' ? 'bg-radial-ultra-light' : type == 'success' ? 'bg-green-600' : 'bg-red-600'; + const progressColor = + type == 'info' ? 'bg-gray-200' : type == 'success' ? 'bg-green-200' : 'bg-red-200'; + + const startProgress = () => { + if (!intervalRef.current) { + intervalRef.current = window.setInterval(() => { + setProgress(prev => Math.max(prev - 100 / (duration / 10), 0)); + }, 10); + } + }; + + const stopProgress = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + + useEffect(() => { + startProgress(); + return stopProgress; + }, []); + + return ( +
+
+
+
+
+
+ +
+
+

{message}

+
+ +
+
+ ); +}; + +export default Toast; diff --git a/frontend/src/components/toasts/index.ts b/frontend/src/components/toasts/index.ts new file mode 100644 index 0000000..d4f76d5 --- /dev/null +++ b/frontend/src/components/toasts/index.ts @@ -0,0 +1,2 @@ +export {default as ToastContainer } from './ToastContainer'; +export {default as Toast } from './Toast'; \ No newline at end of file diff --git a/frontend/src/features/projects/components/CreateProjectModal.tsx b/frontend/src/features/projects/components/CreateProjectModal.tsx index 20d0b35..266d4b9 100644 --- a/frontend/src/features/projects/components/CreateProjectModal.tsx +++ b/frontend/src/features/projects/components/CreateProjectModal.tsx @@ -1,7 +1,7 @@ import { Dialog, Transition } from '@headlessui/react'; import EmphasisButton from 'components/buttons/EmphasisButton'; import { TextInput } from 'components/inputs'; -import Toast from 'components/Toast'; +import Toast from 'components/toasts/Toast'; import { Fragment } from 'react'; import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; @@ -21,7 +21,8 @@ export const CreateProjectModal = ({ isOpen, onClose }: CreateProjectModalProps) const onSubmit = () => { close(); - toast.custom(t => ); + toast.success("Project created successfully"); + //toast.custom(t => ); }; const { formik } = useCreateProject({ onSubmit: onSubmit }); diff --git a/frontend/src/features/projects/pages/ProjectSettings.tsx b/frontend/src/features/projects/pages/ProjectSettings.tsx index 68732ec..5f440f4 100644 --- a/frontend/src/features/projects/pages/ProjectSettings.tsx +++ b/frontend/src/features/projects/pages/ProjectSettings.tsx @@ -8,7 +8,7 @@ import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; import { MdAdd } from 'react-icons/md'; import useSettings from '../hooks/useProjectSettings'; import toast from 'react-hot-toast'; -import Toast from 'components/Toast'; +import Toast from 'components/toasts/Toast'; const headers = [ { propertyName: 'email', displayName: 'Email' }, @@ -101,9 +101,7 @@ const Settings = () => { className="self-start" onClick={async () => await deleteProject(selectedProject.data.id?.toString() ?? '', () => - toast.custom(t => ( - - )), + toast.success('Project deleted successfully'), ) } /> From 2832488ae1bef8961e996c40560434a226ad422a Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 15:52:15 +0100 Subject: [PATCH 56/72] Display error toast on project deletion failure --- .../src/features/projects/components/CreateProjectModal.tsx | 1 - frontend/src/features/projects/hooks/useProjectSettings.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/projects/components/CreateProjectModal.tsx b/frontend/src/features/projects/components/CreateProjectModal.tsx index 266d4b9..30811d5 100644 --- a/frontend/src/features/projects/components/CreateProjectModal.tsx +++ b/frontend/src/features/projects/components/CreateProjectModal.tsx @@ -22,7 +22,6 @@ export const CreateProjectModal = ({ isOpen, onClose }: CreateProjectModalProps) const onSubmit = () => { close(); toast.success("Project created successfully"); - //toast.custom(t => ); }; const { formik } = useCreateProject({ onSubmit: onSubmit }); diff --git a/frontend/src/features/projects/hooks/useProjectSettings.ts b/frontend/src/features/projects/hooks/useProjectSettings.ts index 8caa288..a2acd54 100644 --- a/frontend/src/features/projects/hooks/useProjectSettings.ts +++ b/frontend/src/features/projects/hooks/useProjectSettings.ts @@ -3,6 +3,7 @@ import { useFormik } from 'formik'; import { useEffect, useState } from 'react'; import { useProjectsStore } from '../stores/useProjectsStore'; import { useNavigate } from 'react-router'; +import toast from 'react-hot-toast'; interface FormValues { @@ -45,7 +46,7 @@ const useSettings = ({ projectName: initialProjectName}: FormValues) => { onDelete?.(); navigate('/flows'); } catch (error) { - //TODO: Toast + toast.error("An error occurred when trying to delete this project"); setErrors(['An error occurred']); } finally { setLoading(false); From de53d8a2b04d027da15f83f0012f635578937029 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 20:50:48 +0100 Subject: [PATCH 57/72] Create main button component --- .../components/buttons/MainButton/index.tsx | 39 +++++++++++++++++++ frontend/src/components/buttons/index.ts | 1 + frontend/tailwind.config.js | 5 +++ 3 files changed, 45 insertions(+) create mode 100644 frontend/src/components/buttons/MainButton/index.tsx diff --git a/frontend/src/components/buttons/MainButton/index.tsx b/frontend/src/components/buttons/MainButton/index.tsx new file mode 100644 index 0000000..2ea3997 --- /dev/null +++ b/frontend/src/components/buttons/MainButton/index.tsx @@ -0,0 +1,39 @@ +import React, { ReactElement } from 'react'; + +interface MainButtonProps { + label: string; + icon?: ReactElement; + className?: string; + enabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + onClick?: () => void; +} + +const MainButton = ({ + label, + icon, + enabled = true, + type = 'button', + className, + onClick, +}: MainButtonProps) => { + const backgroundColor = enabled + ? 'bg-crumpet-yellow-500 hover:bg-crumpet-yellow-400' + : 'bg-crumpet-yellow-200'; + return ( + + ); +}; + +export default MainButton; diff --git a/frontend/src/components/buttons/index.ts b/frontend/src/components/buttons/index.ts index f41a592..c74f75a 100644 --- a/frontend/src/components/buttons/index.ts +++ b/frontend/src/components/buttons/index.ts @@ -1,3 +1,4 @@ export { default as EmphasisButton } from './EmphasisButton'; export { default as TextButton } from './TextButton'; export { default as OutlineButton } from './OutlineButton'; +export {default as MainButton } from './MainButton'; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 0de5f70..3fc334c 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -53,6 +53,11 @@ module.exports = { 700: '#7D766C', 900: '#51493E', }, + 'crumpet-yellow': { + 500: '#F3AE3B', + 400: '#F4B64F', + 200: '#F9D79D', + }, oxford: '#05052C', 'hunyadi-yellow': '#FBC571', }, From 6c195dd001486e1ff56c529c46bbf88fc2e71763 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 20:53:07 +0100 Subject: [PATCH 58/72] Update TextButton to support enabled/disabled state --- .../src/components/buttons/TextButton/index.tsx | 14 ++++++++------ frontend/src/components/sidebarMenu/index.tsx | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/buttons/TextButton/index.tsx b/frontend/src/components/buttons/TextButton/index.tsx index 7d6b0fd..268b620 100644 --- a/frontend/src/components/buttons/TextButton/index.tsx +++ b/frontend/src/components/buttons/TextButton/index.tsx @@ -2,17 +2,19 @@ import React from 'react'; import { MdSettings } from 'react-icons/md'; interface TextButtonProps { - text: string; + label: string; icon?: React.ReactElement; - onClick: () => void; + enabled?: boolean; + onClick?: () => void; } -const TextButton = ({ text, icon, onClick }: TextButtonProps) => { +const TextButton = ({ label, icon, enabled = true, onClick }: TextButtonProps) => { + const color = enabled ? 'text-grey-700 group-hover:text-grey-500' : 'text-grey-500' return ( - ); }; diff --git a/frontend/src/components/sidebarMenu/index.tsx b/frontend/src/components/sidebarMenu/index.tsx index 3a7c642..a1a0d66 100644 --- a/frontend/src/components/sidebarMenu/index.tsx +++ b/frontend/src/components/sidebarMenu/index.tsx @@ -189,11 +189,11 @@ const SidebarMenu = ({ projects }: SidebarMenuProps) => { ))}
} onClick={() => navigate('/settings')} /> - } onClick={openModal} /> + } onClick={openModal} /> From 34ae44b9cb814aa38a19a1eed0e69c4627f7f5e5 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 20:53:20 +0100 Subject: [PATCH 59/72] Update zustand --- frontend/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0dedfda..d36e601 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6049,8 +6049,8 @@ yocto-queue@^1.0.0: integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== zustand@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.1.tgz#0cd3a3e4756f21811bd956418fdc686877e8b3b0" - integrity sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw== + version "4.4.4" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.4.tgz#cc06202219972bd61cef1fd10105e6384ae1d5cf" + integrity sha512-5UTUIAiHMNf5+mFp7/AnzJXS7+XxktULFN0+D1sCiZWyX7ZG+AQpqs2qpYrynRij4QvoDdCD+U+bmg/cG3Ucxw== dependencies: use-sync-external-store "1.2.0" From a9ffa70661ade4c70e0f22674b28f657dd21ec94 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sat, 28 Oct 2023 20:57:08 +0100 Subject: [PATCH 60/72] Refactor projects store and use shallow copy --- .../projects/hooks/useProjectSettings.ts | 17 +++++++---- .../projects/pages/ProjectSettings.tsx | 28 ++++++++----------- .../projects/stores/useProjectsStore.ts | 8 ++++-- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/projects/hooks/useProjectSettings.ts b/frontend/src/features/projects/hooks/useProjectSettings.ts index a2acd54..d8a9d78 100644 --- a/frontend/src/features/projects/hooks/useProjectSettings.ts +++ b/frontend/src/features/projects/hooks/useProjectSettings.ts @@ -1,16 +1,15 @@ import { ProjectsApi, useApiConfig } from 'api'; -import { useFormik } from 'formik'; +import { FormikErrors, useFormik } from 'formik'; import { useEffect, useState } from 'react'; import { useProjectsStore } from '../stores/useProjectsStore'; import { useNavigate } from 'react-router'; import toast from 'react-hot-toast'; - interface FormValues { projectName?: string; } -const useSettings = ({ projectName: initialProjectName}: FormValues) => { +const useSettings = ({ projectName: initialProjectName }: FormValues) => { const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(false); const { fetchAndSelectProject } = useProjectsStore(); @@ -21,6 +20,13 @@ const useSettings = ({ projectName: initialProjectName}: FormValues) => { initialValues: { projectName: initialProjectName || '', }, + validate: values => { + const formErrors: FormikErrors = {}; + if (!values.projectName) { + formErrors.projectName = 'You must provide a name.'; + } + return formErrors; + }, onSubmit: ({ projectName }: FormValues) => { // if (email && password) { // login({ email, password }).then(res => { @@ -34,9 +40,10 @@ const useSettings = ({ projectName: initialProjectName}: FormValues) => { useEffect(() => { // this is to prevent infinite re-render cycle if (formik.values.projectName !== initialProjectName) { + console.log("inside use effect"); formik.setFieldValue('projectName', initialProjectName); } - }, [initialProjectName, formik]); + }, [initialProjectName]); const deleteProject = async (id: string, onDelete?: () => void) => { setLoading(true); @@ -46,7 +53,7 @@ const useSettings = ({ projectName: initialProjectName}: FormValues) => { onDelete?.(); navigate('/flows'); } catch (error) { - toast.error("An error occurred when trying to delete this project"); + toast.error('An error occurred when trying to delete this project'); setErrors(['An error occurred']); } finally { setLoading(false); diff --git a/frontend/src/features/projects/pages/ProjectSettings.tsx b/frontend/src/features/projects/pages/ProjectSettings.tsx index 5f440f4..499e684 100644 --- a/frontend/src/features/projects/pages/ProjectSettings.tsx +++ b/frontend/src/features/projects/pages/ProjectSettings.tsx @@ -1,20 +1,12 @@ import { ProjectMembersInner, ProjectMembersInnerTypeEnum } from 'api'; -import CustomButton from 'components/button'; import { CopyInput, TextInput } from 'components/inputs'; -import { OutlineButton } from 'components/buttons'; -import Table from 'components/table'; +import { MainButton, OutlineButton, TextButton } from 'components/buttons'; import { MembersTable } from 'components/tables'; import { useProjectsStore } from 'features/projects/stores/useProjectsStore'; -import { MdAdd } from 'react-icons/md'; +import { MdAdd, MdSave } from 'react-icons/md'; import useSettings from '../hooks/useProjectSettings'; import toast from 'react-hot-toast'; -import Toast from 'components/toasts/Toast'; - -const headers = [ - { propertyName: 'email', displayName: 'Email' }, - { propertyName: 'name', displayName: 'Name' }, - { propertyName: 'role', displayName: 'Role' }, -]; +import { useShallow } from 'zustand/react/shallow'; type UserData = { email: string; @@ -23,7 +15,8 @@ type UserData = { }; const Settings = () => { - const { selectedProject } = useProjectsStore(); + const selectedProject = useProjectsStore(useShallow(state => state.selectedProject)); + //const { selectedProject } = useProjectsStore(); //TODO: Can we improve this to just pass in selectedProject as is without a check? const { formik, deleteProject } = useSettings( selectedProject.state == 'hasData' @@ -47,10 +40,6 @@ const Settings = () => { ); }; - const handleMoreClick = () => { - console.log('More icon clicked'); - }; - return (
{(() => { @@ -68,9 +57,14 @@ const Settings = () => { label="Project Name" description="The display name of your project." value={formik.values.projectName ?? ''} + error={ + formik.touched.projectName && formik.errors.projectName + ? formik.errors.projectName + : null + } onChange={formik.handleChange} placeholder="Name" - inputProps={{ id: 'projectName' }} + inputProps={{ id: 'projectName', onBlur: formik.handleBlur }} />
diff --git a/frontend/src/features/projects/stores/useProjectsStore.ts b/frontend/src/features/projects/stores/useProjectsStore.ts index 4a2e59c..6846e63 100644 --- a/frontend/src/features/projects/stores/useProjectsStore.ts +++ b/frontend/src/features/projects/stores/useProjectsStore.ts @@ -2,16 +2,18 @@ import { Configuration, Project, ProjectsApi } from 'api'; import { ApiState } from 'api/utils'; import { create } from 'zustand'; -type ProjectsStore = { +interface State { selectedProject: ApiState; projects: ApiState; +} +interface Actions { fetchProjects: (config: Configuration) => void; setSelectedProject: (projectId: number) => void; fetchAndSelectProject: (config: Configuration) => void; createProject: (name: string, config: Configuration) => void; -}; +} -export const useProjectsStore = create((set, get) => ({ +export const useProjectsStore = create((set, get) => ({ selectedProject: ApiState.initial(), projects: ApiState.initial(), fetchProjects: (config: Configuration) => { From 46b1647904fb0f8c82e8f0b47d17cb0e77631b52 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 09:31:37 +0000 Subject: [PATCH 61/72] Remove dead code and imports --- .../src/features/projects/components/CreateProjectModal.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/features/projects/components/CreateProjectModal.tsx b/frontend/src/features/projects/components/CreateProjectModal.tsx index 30811d5..bbfcde4 100644 --- a/frontend/src/features/projects/components/CreateProjectModal.tsx +++ b/frontend/src/features/projects/components/CreateProjectModal.tsx @@ -1,10 +1,8 @@ import { Dialog, Transition } from '@headlessui/react'; import EmphasisButton from 'components/buttons/EmphasisButton'; import { TextInput } from 'components/inputs'; -import Toast from 'components/toasts/Toast'; import { Fragment } from 'react'; import toast from 'react-hot-toast'; -import { useNavigate } from 'react-router-dom'; import useCreateProject from '../hooks/useCreateProject'; interface CreateProjectModalProps { @@ -13,7 +11,6 @@ interface CreateProjectModalProps { } export const CreateProjectModal = ({ isOpen, onClose }: CreateProjectModalProps) => { - const navigate = useNavigate(); const close = () => { formik.resetForm({ values: formik.initialValues, errors: {} }); onClose(); From 1762f6cb925d111820cd3671cab147d4f36d0258 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 09:32:19 +0000 Subject: [PATCH 62/72] Add optional id param to fetchAndSelectProjects (method in the store) --- .../features/projects/stores/useProjectsStore.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/projects/stores/useProjectsStore.ts b/frontend/src/features/projects/stores/useProjectsStore.ts index 6846e63..7cf0bf0 100644 --- a/frontend/src/features/projects/stores/useProjectsStore.ts +++ b/frontend/src/features/projects/stores/useProjectsStore.ts @@ -9,7 +9,7 @@ interface State { interface Actions { fetchProjects: (config: Configuration) => void; setSelectedProject: (projectId: number) => void; - fetchAndSelectProject: (config: Configuration) => void; + fetchAndSelectProject: (config: Configuration, projectId?: number) => void; createProject: (name: string, config: Configuration) => void; } @@ -32,16 +32,18 @@ export const useProjectsStore = create((set, get) => ({ } } }, - fetchAndSelectProject: (config: Configuration) => { + fetchAndSelectProject: (config: Configuration, projectId?: number) => { set(state => ({ projects: ApiState.loading() })); new ProjectsApi(config) .listProjects() - .then(res => - set(state => ({ + .then(res => { + const index = + projectId == undefined ? 0 : res.data.findIndex(project => project.id == projectId); + return set(state => ({ projects: ApiState.hasData(res.data), - selectedProject: ApiState.hasData(res.data[0]), - })), - ) + selectedProject: ApiState.hasData(res.data[index]), + })); + }) .catch(err => set(state => ({ projects: ApiState.hasError(err) }))); }, createProject: async (name: string, config: Configuration) => { From 2c009aa3fcde3e8463fa94018f6830eb14325347 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 09:35:56 +0000 Subject: [PATCH 63/72] Wire up save button to call API and update the project --- .../projects/hooks/useProjectSettings.ts | 48 +++++++++++++++---- .../projects/pages/ProjectSettings.tsx | 22 +++++++-- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/frontend/src/features/projects/hooks/useProjectSettings.ts b/frontend/src/features/projects/hooks/useProjectSettings.ts index d8a9d78..bf685df 100644 --- a/frontend/src/features/projects/hooks/useProjectSettings.ts +++ b/frontend/src/features/projects/hooks/useProjectSettings.ts @@ -4,12 +4,19 @@ import { useEffect, useState } from 'react'; import { useProjectsStore } from '../stores/useProjectsStore'; import { useNavigate } from 'react-router'; import toast from 'react-hot-toast'; +import { AxiosError } from 'axios'; interface FormValues { projectName?: string; } -const useSettings = ({ projectName: initialProjectName }: FormValues) => { +const useSettings = ({ + projectId, + projectName: initialProjectName, +}: { + projectId: number; + projectName: string; +}) => { const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(false); const { fetchAndSelectProject } = useProjectsStore(); @@ -27,20 +34,43 @@ const useSettings = ({ projectName: initialProjectName }: FormValues) => { } return formErrors; }, - onSubmit: ({ projectName }: FormValues) => { - // if (email && password) { - // login({ email, password }).then(res => { - // if (res?.success) navigate('/'); - // else setErrorAlert(res?.errors[0]); - // }); - // } + onSubmit: async ({ projectName }: FormValues, { setSubmitting }) => { + setSubmitting(true); + if (projectName == undefined) { + setSubmitting(false); + return; + } + try { + await new ProjectsApi(config).updateProject(projectId.toString(), { name: projectName }); + fetchAndSelectProject(config, projectId); + toast.success(`Updated project ${projectName} successfully`); + } catch (e) { + //TODO: Propagate errors to form using setError etc. + if (e instanceof AxiosError) { + if (e.response) { + const errorData = e.response.data; + if (errorData.detail) { + console.error('General error:', errorData.detail); + } else { + // Handle model field errors. + for (const field in errorData) { + console.error(`Error with ${field}:`, errorData[field].join(', ')); + } + } + } + } else { + // Some other error + console.error(e); + } + } finally { + setSubmitting(false); + } }, }); useEffect(() => { // this is to prevent infinite re-render cycle if (formik.values.projectName !== initialProjectName) { - console.log("inside use effect"); formik.setFieldValue('projectName', initialProjectName); } }, [initialProjectName]); diff --git a/frontend/src/features/projects/pages/ProjectSettings.tsx b/frontend/src/features/projects/pages/ProjectSettings.tsx index 499e684..a984950 100644 --- a/frontend/src/features/projects/pages/ProjectSettings.tsx +++ b/frontend/src/features/projects/pages/ProjectSettings.tsx @@ -7,6 +7,7 @@ import { MdAdd, MdSave } from 'react-icons/md'; import useSettings from '../hooks/useProjectSettings'; import toast from 'react-hot-toast'; import { useShallow } from 'zustand/react/shallow'; +import { useEffect } from 'react'; type UserData = { email: string; @@ -16,12 +17,14 @@ type UserData = { const Settings = () => { const selectedProject = useProjectsStore(useShallow(state => state.selectedProject)); - //const { selectedProject } = useProjectsStore(); - //TODO: Can we improve this to just pass in selectedProject as is without a check? + // TODO: Refactor this horrendous null check rubbish const { formik, deleteProject } = useSettings( selectedProject.state == 'hasData' - ? { projectName: selectedProject?.data.name } - : { projectName: '' }, + ? { + projectId: selectedProject.data?.id ?? 0, + projectName: selectedProject?.data.name, + } + : { projectId: 0, projectName: '' }, ); // TODO: If there is no selected project, display an error on the page. @@ -42,13 +45,22 @@ const Settings = () => { return (
+
+ + } + enabled={formik.isValid && formik.dirty} + type="submit" + onClick={formik.handleSubmit} + /> +
{(() => { switch (selectedProject.state) { case 'loading': //TODO: Better loading experience return
Loading...
; // Just an example case 'hasData': { - console.log('members', selectedProject.data.members); const membersData = convertMembersToUserData(selectedProject.data.members); return ( From 23165deb55825851ea105ab2c149097e8582d64d Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 11:08:08 +0000 Subject: [PATCH 64/72] Handle potential errors returned from API response (project update) --- .../projects/hooks/useProjectSettings.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/projects/hooks/useProjectSettings.ts b/frontend/src/features/projects/hooks/useProjectSettings.ts index bf685df..cc2219c 100644 --- a/frontend/src/features/projects/hooks/useProjectSettings.ts +++ b/frontend/src/features/projects/hooks/useProjectSettings.ts @@ -17,7 +17,6 @@ const useSettings = ({ projectId: number; projectName: string; }) => { - const [errors, setErrors] = useState([]); const [loading, setLoading] = useState(false); const { fetchAndSelectProject } = useProjectsStore(); const { config } = useApiConfig(); @@ -34,7 +33,7 @@ const useSettings = ({ } return formErrors; }, - onSubmit: async ({ projectName }: FormValues, { setSubmitting }) => { + onSubmit: async ({ projectName }: FormValues, { setSubmitting, setErrors }) => { setSubmitting(true); if (projectName == undefined) { setSubmitting(false); @@ -45,18 +44,28 @@ const useSettings = ({ fetchAndSelectProject(config, projectId); toast.success(`Updated project ${projectName} successfully`); } catch (e) { - //TODO: Propagate errors to form using setError etc. if (e instanceof AxiosError) { if (e.response) { const errorData = e.response.data; - if (errorData.detail) { - console.error('General error:', errorData.detail); - } else { - // Handle model field errors. - for (const field in errorData) { - console.error(`Error with ${field}:`, errorData[field].join(', ')); + + // Handle model field errors + const formikErrors: FormikErrors = {}; + for (const field in errorData) { + if (field in formik.initialValues) { + formikErrors[field as keyof FormValues] = errorData[field].join(', '); + } else { + // If it's a general error or an error for a field not in your form + console.error(`Error: ${errorData[field].join(', ')}`); + toast.error('Something went wrong when updating the project.'); } } + setErrors(formikErrors); + + // For general errors + if (errorData.detail) { + console.error(`General error: ${errorData.detail}`); + toast.error('Something went wrong when updating the project.'); + } } } else { // Some other error @@ -84,7 +93,6 @@ const useSettings = ({ navigate('/flows'); } catch (error) { toast.error('An error occurred when trying to delete this project'); - setErrors(['An error occurred']); } finally { setLoading(false); } @@ -93,7 +101,6 @@ const useSettings = ({ return { formik, loading, - errors, deleteProject, }; }; From fdc4442bc5fe7fc901f232786aa7c72074c98b44 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 12:27:32 +0000 Subject: [PATCH 65/72] Fix members table when rendering a single row --- frontend/src/components/tables/MembersTable/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/MembersTable/index.tsx b/frontend/src/components/tables/MembersTable/index.tsx index 9ea0e44..4f503ca 100644 --- a/frontend/src/components/tables/MembersTable/index.tsx +++ b/frontend/src/components/tables/MembersTable/index.tsx @@ -17,11 +17,14 @@ const MembersTable = ({onClick, data} : MembersTableProps) => { const isFirst = index === 0; const isLast = index === data.length - 1; //Add appropriate borders and rounded edges depending on element index - const conditionalClassNames = isFirst + let conditionalClassNames = isFirst ? 'rounded-t border-t' : isLast ? 'rounded-b border-t border-b' : 'border-t'; + if (isFirst && isLast) { + conditionalClassNames = 'rounded border-t border-b'; + } return (
Date: Sun, 29 Oct 2023 12:27:57 +0000 Subject: [PATCH 66/72] Update 'CopyInput' to use Clipboard API --- frontend/src/components/inputs/CopyInput.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/inputs/CopyInput.tsx b/frontend/src/components/inputs/CopyInput.tsx index d31dbec..60478bb 100644 --- a/frontend/src/components/inputs/CopyInput.tsx +++ b/frontend/src/components/inputs/CopyInput.tsx @@ -11,10 +11,14 @@ interface CopyInputProps { const CopyInput = ({ label, description, value, className }: CopyInputProps) => { const inputRef = useRef(null); - const handleCopyClick = () => { - if (inputRef.current) { - inputRef.current.select(); - document.execCommand('copy'); + const handleCopyClick = async () => { + if (navigator.clipboard && inputRef.current) { + try { + await navigator.clipboard.writeText(inputRef.current.value); + //TODO: Display subtle toast to user + } catch (err) { + console.error('Failed to copy text: ', err); + } } }; @@ -27,8 +31,8 @@ const CopyInput = ({ label, description, value, className }: CopyInputProps) =>
- - + +
); From 30c6384f59b633e15ba3a35071d28583db953e61 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 12:31:24 +0000 Subject: [PATCH 67/72] Remove unused Welcome page and its references --- frontend/src/Routes.test.tsx | 23 ++++++++++------------- frontend/src/pages/welcome/index.ts | 2 -- frontend/src/pages/welcome/welcome.tsx | 8 -------- frontend/src/routes/useRouter.tsx | 1 - 4 files changed, 10 insertions(+), 24 deletions(-) delete mode 100644 frontend/src/pages/welcome/index.ts delete mode 100644 frontend/src/pages/welcome/welcome.tsx diff --git a/frontend/src/Routes.test.tsx b/frontend/src/Routes.test.tsx index aba2f0f..a27f8ae 100644 --- a/frontend/src/Routes.test.tsx +++ b/frontend/src/Routes.test.tsx @@ -1,18 +1,15 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { MemoryRouter } from 'react-router-dom'; -import { Welcome } from 'pages'; describe('Router', () => { - test('renders the correct content when the route is /success', () => { - const mainPath = '/'; - render( - - - , - ); - expect(screen.queryByText(/Welcome/i)).toBeInTheDocument(); - }); + // test('renders the correct content when the route is /success', () => { + // const mainPath = '/'; + + // render( + // + // + // , + // ); + // expect(screen.queryByText(/Welcome/i)).toBeInTheDocument(); + // }); }); diff --git a/frontend/src/pages/welcome/index.ts b/frontend/src/pages/welcome/index.ts deleted file mode 100644 index ebafa2f..0000000 --- a/frontend/src/pages/welcome/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import Welcome from './welcome'; -export default Welcome; diff --git a/frontend/src/pages/welcome/welcome.tsx b/frontend/src/pages/welcome/welcome.tsx deleted file mode 100644 index f1c4db8..0000000 --- a/frontend/src/pages/welcome/welcome.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import SidebarMenu from 'components/sidebarMenu'; - -const Welcome = () => { - return ; -}; - -export default Welcome; diff --git a/frontend/src/routes/useRouter.tsx b/frontend/src/routes/useRouter.tsx index 7ecb2cd..c2ecb0b 100644 --- a/frontend/src/routes/useRouter.tsx +++ b/frontend/src/routes/useRouter.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Welcome from 'pages/welcome/welcome'; import { createHashRouter, Navigate, RouteObject } from 'react-router-dom'; import { useCookies } from 'react-cookie'; import NotFound from './404'; From 3d76710beae9210be15d42780a116f783f32e110 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 12:31:49 +0000 Subject: [PATCH 68/72] Fix typecheck error by making /projects a module --- frontend/src/features/projects/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/features/projects/index.ts b/frontend/src/features/projects/index.ts index e69de29..693da49 100644 --- a/frontend/src/features/projects/index.ts +++ b/frontend/src/features/projects/index.ts @@ -0,0 +1 @@ +export {} \ No newline at end of file From cbd7b18ee18eb2d4f579008a5890f3a5d1d250b8 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 12:39:30 +0000 Subject: [PATCH 69/72] Fix 'yarn typecheck' --- frontend/src/pages/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 30e0bcd..cb0ff5c 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -1,3 +1 @@ -import Welcome from './welcome'; - -export { Welcome }; +export {}; From ddbe0ddedcdac2e91f19f2bf639bfc077a2b7591 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 12:39:47 +0000 Subject: [PATCH 70/72] Fix 'yarn test' --- frontend/src/Routes.test.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/Routes.test.tsx b/frontend/src/Routes.test.tsx index a27f8ae..70ffa59 100644 --- a/frontend/src/Routes.test.tsx +++ b/frontend/src/Routes.test.tsx @@ -2,14 +2,17 @@ import '@testing-library/jest-dom'; describe('Router', () => { - // test('renders the correct content when the route is /success', () => { - // const mainPath = '/'; - // render( - // - // - // , - // ); - // expect(screen.queryByText(/Welcome/i)).toBeInTheDocument(); - // }); + test('renders the correct content when the route is /success', () => { + //TODO + const mainPath = '/'; + console.log("TODO"); + + // render( + // + // + // , + // ); + // expect(screen.queryByText(/Welcome/i)).toBeInTheDocument(); + }); }); From 662316fe9fa7890060dd71b778eb5cb0537a5e56 Mon Sep 17 00:00:00 2001 From: tomtitherington Date: Sun, 29 Oct 2023 13:03:12 +0000 Subject: [PATCH 71/72] Fix eslint hook dependencies error with useCallback --- frontend/src/components/toasts/Toast/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/toasts/Toast/index.tsx b/frontend/src/components/toasts/Toast/index.tsx index 4aae404..f1fe96f 100644 --- a/frontend/src/components/toasts/Toast/index.tsx +++ b/frontend/src/components/toasts/Toast/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import toast from 'react-hot-toast'; import { MdInfoOutline } from 'react-icons/md'; @@ -12,17 +12,18 @@ interface ToastProps { const Toast = ({ id, message, type = 'info', duration = 4000 }: ToastProps) => { const [progress, setProgress] = useState(100); const intervalRef = useRef(null); - const color = type == 'info' ? 'bg-radial-ultra-light' : type == 'success' ? 'bg-green-600' : 'bg-red-600'; + const color = + type == 'info' ? 'bg-radial-ultra-light' : type == 'success' ? 'bg-green-600' : 'bg-red-600'; const progressColor = type == 'info' ? 'bg-gray-200' : type == 'success' ? 'bg-green-200' : 'bg-red-200'; - const startProgress = () => { + const startProgress = useCallback(() => { if (!intervalRef.current) { intervalRef.current = window.setInterval(() => { setProgress(prev => Math.max(prev - 100 / (duration / 10), 0)); }, 10); } - }; + }, [duration]); const stopProgress = () => { if (intervalRef.current) { @@ -34,7 +35,7 @@ const Toast = ({ id, message, type = 'info', duration = 4000 }: ToastProps) => { useEffect(() => { startProgress(); return stopProgress; - }, []); + }, [startProgress]); return (
Date: Sun, 29 Oct 2023 13:03:30 +0000 Subject: [PATCH 72/72] Fix gap between multiple toasts --- frontend/src/components/toasts/ToastContainer/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/toasts/ToastContainer/index.tsx b/frontend/src/components/toasts/ToastContainer/index.tsx index bbb62b0..71c8123 100644 --- a/frontend/src/components/toasts/ToastContainer/index.tsx +++ b/frontend/src/components/toasts/ToastContainer/index.tsx @@ -9,7 +9,7 @@ const ToastContainer = () => {
+ className="fixed bottom-4 right-4 gap-2 flex flex-col-reverse"> {toasts .filter(toast => toast.visible) .map(toast => {