conflicts

This commit is contained in:
gurusainath 2023-09-20 12:23:38 +05:30
commit 50c330db65
49 changed files with 1072 additions and 359 deletions

View File

@ -33,14 +33,9 @@ jobs:
deploy:
- space/**
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
mv ./.npmrc ./web
cd web
yarn
yarn build

View File

@ -22,10 +22,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup .npmrc for repository
run: |
echo -e "@tiptap-pro:registry=https://registry.tiptap.dev/\n//registry.tiptap.dev/:_authToken=${{ secrets.TIPTAP_TOKEN }}" > .npmrc
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
id: metaFrontend
uses: docker/metadata-action@v4.3.0

View File

@ -59,17 +59,6 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM
- Setup Tiptap Pro
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
```
@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
```
- Run Docker compose up
```bash

View File

@ -1,6 +1,7 @@
# Backend
# Debug value for api server use it as 0 for production use
DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
# Error logs
SENTRY_DSN=""

View File

@ -23,7 +23,7 @@ from .project import (
ProjectPublicMemberSerializer
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
from .view import GlobalViewSerializer, IssueViewSerializer, IssueViewFavoriteSerializer
from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer, CycleWriteSerializer
from .asset import FileAssetSerializer
from .issue import (

View File

@ -5,10 +5,39 @@ from rest_framework import serializers
from .base import BaseSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import IssueView, IssueViewFavorite
from plane.db.models import GlobalView, IssueView, IssueViewFavorite
from plane.utils.issue_filters import issue_filters
class GlobalViewSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(source="workspace", read_only=True)
class Meta:
model = GlobalView
fields = "__all__"
read_only_fields = [
"workspace",
"query",
]
def create(self, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
return GlobalView.objects.create(**validated_data)
def update(self, instance, validated_data):
query_params = validated_data.get("query_data", {})
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)
class IssueViewSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
project_detail = ProjectLiteSerializer(source="project", read_only=True)

View File

@ -102,6 +102,8 @@ from plane.api.views import (
BulkEstimatePointEndpoint,
## End Estimates
# Views
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
ViewIssuesEndpoint,
IssueViewFavoriteViewSet,
@ -184,7 +186,6 @@ from plane.api.views import (
## Exporter
ExportIssuesEndpoint,
## End Exporter
)
@ -241,7 +242,11 @@ urlpatterns = [
UpdateUserTourCompletedEndpoint.as_view(),
name="user-tour",
),
path("users/workspaces/<str:slug>/activities/", UserActivityEndpoint.as_view(), name="user-activities"),
path(
"users/workspaces/<str:slug>/activities/",
UserActivityEndpoint.as_view(),
name="user-activities",
),
# user workspaces
path(
"users/me/workspaces/",
@ -649,6 +654,37 @@ urlpatterns = [
ViewIssuesEndpoint.as_view(),
name="project-view-issues",
),
path(
"workspaces/<str:slug>/views/",
GlobalViewViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/views/<uuid:pk>/",
GlobalViewViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="global-view",
),
path(
"workspaces/<str:slug>/issues/",
GlobalViewIssuesViewSet.as_view(
{
"get": "list",
}
),
name="global-view-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-views/",
IssueViewFavoriteViewSet.as_view(
@ -767,11 +803,6 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/issues/",
WorkSpaceIssuesEndpoint.as_view(),
name="workspace-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(

View File

@ -56,7 +56,7 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet
from .cycle import (
CycleViewSet,
CycleIssueViewSet,

View File

@ -80,6 +80,7 @@ class CycleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -487,6 +488,7 @@ class CycleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -662,6 +664,7 @@ class CycleIssueViewSet(BaseViewSet):
),
}
),
epoch = int(timezone.now().timestamp())
)
# Return all Cycle Issues

View File

@ -213,6 +213,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@ -277,6 +278,7 @@ class InboxIssueViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
issue_serializer.save()
else:
@ -518,6 +520,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
# create an inbox issue
InboxIssue.objects.create(
@ -582,6 +585,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
issue_serializer.save()
return Response(issue_serializer.data, status=status.HTTP_200_OK)

View File

@ -4,6 +4,7 @@ import random
from itertools import chain
# Django imports
from django.utils import timezone
from django.db.models import (
Prefetch,
OuterRef,
@ -129,6 +130,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch = int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@ -149,6 +151,7 @@ class IssueViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -315,6 +318,7 @@ class IssueViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -568,6 +572,7 @@ class IssueCommentViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@ -586,6 +591,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@ -607,6 +613,7 @@ class IssueCommentViewSet(BaseViewSet):
IssueCommentSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -890,6 +897,7 @@ class IssueLinkViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id")),
project_id=str(self.kwargs.get("project_id")),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
def perform_update(self, serializer):
@ -908,6 +916,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@ -929,6 +938,7 @@ class IssueLinkViewSet(BaseViewSet):
IssueLinkSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -1007,6 +1017,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer.data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1029,6 +1040,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1231,6 +1243,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@ -1435,6 +1448,7 @@ class IssueReactionViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, issue_id, reaction_code):
@ -1458,6 +1472,7 @@ class IssueReactionViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch = int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1506,6 +1521,7 @@ class CommentReactionViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
@ -1530,6 +1546,7 @@ class CommentReactionViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch = int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1626,6 +1643,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
if not ProjectMember.objects.filter(
project_id=project_id,
@ -1675,6 +1693,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1708,6 +1727,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
comment.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1782,6 +1802,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1826,6 +1847,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
"identifier": str(issue_reaction.id),
}
),
epoch = int(timezone.now().timestamp())
)
issue_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -1899,6 +1921,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -1950,6 +1973,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
"comment_id": str(comment_id),
}
),
epoch = int(timezone.now().timestamp())
)
comment_reaction.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -2013,6 +2037,7 @@ class IssueVotePublicViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -2047,6 +2072,7 @@ class IssueVotePublicViewSet(BaseViewSet):
"identifier": str(issue_vote.id),
}
),
epoch = int(timezone.now().timestamp())
)
issue_vote.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -2080,6 +2106,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(current_instance).data,
cls=DjangoJSONEncoder,
),
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -2113,6 +2140,7 @@ class IssueRelationViewSet(BaseViewSet):
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
if relation == "blocking":
@ -2157,6 +2185,8 @@ class IssueRelationViewSet(BaseViewSet):
.select_related("issue")
.distinct()
)
class IssueRetrievePublicEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
@ -2382,6 +2412,7 @@ class IssueDraftViewSet(BaseViewSet):
current_instance=json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
),
epoch = int(timezone.now().timestamp())
)
return super().perform_update(serializer)
@ -2566,6 +2597,7 @@ class IssueDraftViewSet(BaseViewSet):
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -2,6 +2,7 @@
import json
# Django Imports
from django.utils import timezone
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
@ -129,6 +130,7 @@ class ModuleViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -277,6 +279,7 @@ class ModuleIssueViewSet(BaseViewSet):
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch = int(timezone.now().timestamp())
)
return super().perform_destroy(instance)
@ -444,6 +447,7 @@ class ModuleIssueViewSet(BaseViewSet):
),
}
),
epoch = int(timezone.now().timestamp())
)
return Response(

View File

@ -1,4 +1,18 @@
# Django imports
from django.db.models import (
Prefetch,
OuterRef,
Func,
F,
Case,
Value,
CharField,
When,
Exists,
Max,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import IntegrityError
from django.db.models import Prefetch, OuterRef, Exists
@ -10,18 +24,192 @@ from sentry_sdk import capture_exception
# Module imports
from . import BaseViewSet, BaseAPIView
from plane.api.serializers import (
GlobalViewSerializer,
IssueViewSerializer,
IssueLiteSerializer,
IssueViewFavoriteSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.api.permissions import WorkspaceEntityPermission, ProjectEntityPermission
from plane.db.models import (
Workspace,
GlobalView,
IssueView,
Issue,
IssueViewFavorite,
IssueReaction,
IssueLink,
IssueAttachment,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.grouper import group_results
class GlobalViewViewSet(BaseViewSet):
serializer_class = GlobalViewSerializer
model = GlobalView
permission_classes = [
WorkspaceEntityPermission,
]
def perform_create(self, serializer):
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
serializer.save(workspace_id=workspace.id)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.order_by("-created_at")
.distinct()
)
class GlobalViewIssuesViewSet(BaseViewSet):
permission_classes = [
WorkspaceEntityPermission,
]
def get_queryset(self):
return (
Issue.issue_objects.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
try:
filters = issue_filters(request.query_params, "GET")
# Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", "none"]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
*[
When(priority=p, then=Value(i))
for i, p in enumerate(priority_order)
],
output_field=CharField(),
)
).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
if sub_group_by and sub_group_by == group_by:
return Response(
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
return Response(
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class IssueViewViewSet(BaseViewSet):

View File

@ -39,6 +39,7 @@ def track_name(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
@ -52,6 +53,7 @@ def track_name(
project=project,
workspace=project.workspace,
comment=f"updated the name to {requested_data.get('name')}",
epoch=epoch,
)
)
@ -64,6 +66,7 @@ def track_parent(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
@ -81,6 +84,7 @@ def track_parent(
comment=f"updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
epoch=epoch,
)
)
else:
@ -101,6 +105,7 @@ def track_parent(
comment=f"updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
epoch=epoch,
)
)
@ -113,6 +118,7 @@ def track_priority(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
@ -127,6 +133,7 @@ def track_priority(
project=project,
workspace=project.workspace,
comment=f"updated the priority to None",
epoch=epoch,
)
)
else:
@ -141,6 +148,7 @@ def track_priority(
project=project,
workspace=project.workspace,
comment=f"updated the priority to {requested_data.get('priority')}",
epoch=epoch,
)
)
@ -153,6 +161,7 @@ def track_state(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
@ -171,6 +180,7 @@ def track_state(
comment=f"updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
epoch=epoch,
)
)
@ -183,6 +193,7 @@ def track_description(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
@ -203,6 +214,7 @@ def track_description(
project=project,
workspace=project.workspace,
comment=f"updated the description to {requested_data.get('description_html')}",
epoch=epoch,
)
)
@ -215,6 +227,7 @@ def track_target_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
@ -229,6 +242,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to None",
epoch=epoch,
)
)
else:
@ -243,6 +257,7 @@ def track_target_date(
project=project,
workspace=project.workspace,
comment=f"updated the target date to {requested_data.get('target_date')}",
epoch=epoch,
)
)
@ -255,6 +270,7 @@ def track_start_date(
project,
actor,
issue_activities,
epoch
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
@ -269,6 +285,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to None",
epoch=epoch,
)
)
else:
@ -283,6 +300,7 @@ def track_start_date(
project=project,
workspace=project.workspace,
comment=f"updated the start date to {requested_data.get('start_date')}",
epoch=epoch,
)
)
@ -295,6 +313,7 @@ def track_labels(
project,
actor,
issue_activities,
epoch
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
@ -314,6 +333,7 @@ def track_labels(
comment=f"added label {label.name}",
new_identifier=label.id,
old_identifier=None,
epoch=epoch,
)
)
@ -335,6 +355,7 @@ def track_labels(
comment=f"removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
epoch=epoch,
)
)
@ -347,6 +368,7 @@ def track_assignees(
project,
actor,
issue_activities,
epoch
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
@ -367,6 +389,7 @@ def track_assignees(
workspace=project.workspace,
comment=f"added assignee {assignee.display_name}",
new_identifier=assignee.id,
epoch=epoch,
)
)
@ -389,12 +412,13 @@ def track_assignees(
workspace=project.workspace,
comment=f"removed assignee {assignee.display_name}",
old_identifier=assignee.id,
epoch=epoch,
)
)
def create_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -404,12 +428,13 @@ def create_issue_activity(
comment=f"created the issue",
verb="created",
actor=actor,
epoch=epoch,
)
)
def track_estimate_points(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if current_instance.get("estimate_point") != requested_data.get("estimate_point"):
if requested_data.get("estimate_point") == None:
@ -424,6 +449,7 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to None",
epoch=epoch,
)
)
else:
@ -438,12 +464,13 @@ def track_estimate_points(
project=project,
workspace=project.workspace,
comment=f"updated the estimate point to {requested_data.get('estimate_point')}",
epoch=epoch,
)
)
def track_archive_at(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("archived_at") is None:
issue_activities.append(
@ -457,6 +484,7 @@ def track_archive_at(
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
)
)
else:
@ -471,12 +499,13 @@ def track_archive_at(
field="archived_at",
old_value=None,
new_value="archive",
epoch=epoch,
)
)
def track_closed_to(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
if requested_data.get("closed_to") is not None:
updated_state = State.objects.get(
@ -496,12 +525,13 @@ def track_closed_to(
comment=f"Plane updated the state to {updated_state.name}",
old_identifier=None,
new_identifier=updated_state.id,
epoch=epoch,
)
)
def update_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
@ -518,6 +548,11 @@ def update_issue_activity(
"closed_to": track_closed_to,
}
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
@ -528,11 +563,12 @@ def update_issue_activity(
project,
actor,
issue_activities,
epoch
)
def delete_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -542,12 +578,13 @@ def delete_issue_activity(
verb="deleted",
actor=actor,
field="issue",
epoch=epoch,
)
)
def create_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -566,12 +603,13 @@ def create_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=requested_data.get("id", None),
issue_comment_id=requested_data.get("id", None),
epoch=epoch,
)
)
def update_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -593,12 +631,13 @@ def update_comment_activity(
new_value=requested_data.get("comment_html", ""),
new_identifier=current_instance.get("id", None),
issue_comment_id=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_comment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -609,12 +648,13 @@ def delete_comment_activity(
verb="deleted",
actor=actor,
field="comment",
epoch=epoch,
)
)
def create_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -646,6 +686,7 @@ def create_cycle_issue_activity(
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
epoch=epoch,
)
)
@ -666,12 +707,13 @@ def create_cycle_issue_activity(
workspace=project.workspace,
comment=f"added cycle {cycle.name}",
new_identifier=cycle.id,
epoch=epoch,
)
)
def delete_cycle_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -695,12 +737,13 @@ def delete_cycle_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
epoch=epoch,
)
)
def create_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -732,6 +775,7 @@ def create_module_issue_activity(
comment=f"updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
epoch=epoch,
)
)
@ -751,12 +795,13 @@ def create_module_issue_activity(
workspace=project.workspace,
comment=f"added module {module.name}",
new_identifier=module.id,
epoch=epoch,
)
)
def delete_module_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -780,12 +825,13 @@ def delete_module_issue_activity(
workspace=project.workspace,
comment=f"removed this issue from {module.name if module is not None else None}",
old_identifier=module.id if module is not None else None,
epoch=epoch,
)
)
def create_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -803,12 +849,13 @@ def create_link_activity(
field="link",
new_value=requested_data.get("url", ""),
new_identifier=requested_data.get("id", None),
epoch=epoch,
)
)
def update_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -829,12 +876,13 @@ def update_link_activity(
old_identifier=current_instance.get("id"),
new_value=requested_data.get("url", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_link_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
@ -851,13 +899,14 @@ def delete_link_activity(
actor=actor,
field="link",
old_value=current_instance.get("url", ""),
new_value=""
new_value="",
epoch=epoch,
)
)
def create_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -875,12 +924,13 @@ def create_attachment_activity(
field="attachment",
new_value=current_instance.get("asset", ""),
new_identifier=current_instance.get("id", None),
epoch=epoch,
)
)
def delete_attachment_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -891,11 +941,12 @@ def delete_attachment_activity(
verb="deleted",
actor=actor,
field="attachment",
epoch=epoch,
)
)
def create_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@ -914,12 +965,13 @@ def create_issue_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=issue_reaction,
epoch=epoch,
)
)
def delete_issue_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@ -938,12 +990,13 @@ def delete_issue_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("reaction") is not None:
@ -963,12 +1016,13 @@ def create_comment_reaction_activity(
comment="added the reaction",
old_identifier=None,
new_identifier=comment_reaction_id,
epoch=epoch,
)
)
def delete_comment_reaction_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@ -989,12 +1043,13 @@ def delete_comment_reaction_activity(
comment="removed the reaction",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
if requested_data and requested_data.get("vote") is not None:
@ -1011,12 +1066,13 @@ def create_issue_vote_activity(
comment="added the vote",
old_identifier=None,
new_identifier=None,
epoch=epoch,
)
)
def delete_issue_vote_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
current_instance = (
json.loads(current_instance) if current_instance is not None else None
@ -1035,12 +1091,13 @@ def delete_issue_vote_activity(
comment="removed the vote",
old_identifier=current_instance.get("identifier"),
new_identifier=None,
epoch=epoch,
)
)
def create_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -1080,12 +1137,13 @@ def create_issue_relation_activity(
workspace=project.workspace,
comment=f'added {issue_relation.get("relation_type")} relation',
old_identifier=issue_relation.get("related_issue"),
epoch=epoch,
)
)
def delete_issue_relation_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -1109,6 +1167,7 @@ def delete_issue_relation_activity(
workspace=project.workspace,
comment=f'deleted {relation_type} relation',
old_identifier=current_instance.get("issue"),
epoch=epoch,
)
)
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
@ -1124,12 +1183,13 @@ def delete_issue_relation_activity(
workspace=project.workspace,
comment=f'deleted {current_instance.get("relation_type")} relation',
old_identifier=current_instance.get("related_issue"),
epoch=epoch,
)
)
def create_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -1140,12 +1200,13 @@ def create_draft_issue_activity(
field="draft",
verb="created",
actor=actor,
epoch=epoch,
)
)
def update_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
requested_data = json.loads(requested_data) if requested_data is not None else None
current_instance = (
@ -1160,6 +1221,7 @@ def update_draft_issue_activity(
comment=f"created the issue",
verb="updated",
actor=actor,
epoch=epoch,
)
)
else:
@ -1172,13 +1234,14 @@ def update_draft_issue_activity(
field="draft",
verb="updated",
actor=actor,
epoch=epoch,
)
)
def delete_draft_issue_activity(
requested_data, current_instance, issue_id, project, actor, issue_activities
requested_data, current_instance, issue_id, project, actor, issue_activities, epoch
):
issue_activities.append(
IssueActivity(
@ -1188,6 +1251,7 @@ def delete_draft_issue_activity(
field="draft",
verb="deleted",
actor=actor,
epoch=epoch,
)
)
@ -1200,6 +1264,7 @@ def issue_activity(
issue_id,
actor_id,
project_id,
epoch,
subscriber=True,
):
try:
@ -1276,6 +1341,7 @@ def issue_activity(
project,
actor,
issue_activities,
epoch,
)
# Save all the values to database

View File

@ -77,6 +77,7 @@ def archive_old_issues():
project_id=project_id,
current_instance=None,
subscriber=False,
epoch = int(timezone.now().timestamp())
)
for issue in updated_issues
]
@ -148,6 +149,7 @@ def close_old_issues():
project_id=project_id,
current_instance=None,
subscriber=False,
epoch = int(timezone.now().timestamp())
)
for issue in updated_issues
]

View File

@ -2,6 +2,7 @@
from django.db import migrations
def update_issue_activity(apps, schema_editor):
IssueActivityModel = apps.get_model("db", "IssueActivity")
updated_issue_activity = []

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.3 on 2023-09-19 14:21
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def update_epoch(apps, schema_editor):
IssueActivity = apps.get_model('db', 'IssueActivity')
updated_issue_activity = []
for obj in IssueActivity.objects.all():
obj.epoch = int(obj.created_at.timestamp())
updated_issue_activity.append(obj)
IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100)
class Migration(migrations.Migration):
dependencies = [
('db', '0045_auto_20230915_0655'),
]
operations = [
migrations.CreateModel(
name='GlobalView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=255, verbose_name='View Name')),
('description', models.TextField(blank=True, verbose_name='View Description')),
('query', models.JSONField(verbose_name='View Query')),
('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)),
('query_data', models.JSONField(default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')),
],
options={
'verbose_name': 'Global View',
'verbose_name_plural': 'Global Views',
'db_table': 'global_views',
'ordering': ('-created_at',),
},
),
migrations.AddField(
model_name='issueactivity',
name='epoch',
field=models.FloatField(null=True),
),
migrations.RunPython(update_epoch),
]

View File

@ -50,7 +50,7 @@ from .state import State
from .cycle import Cycle, CycleIssue, CycleFavorite
from .view import IssueView, IssueViewFavorite
from .view import GlobalView, IssueView, IssueViewFavorite
from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite

View File

@ -309,6 +309,7 @@ class IssueActivity(ProjectBaseModel):
)
old_identifier = models.UUIDField(null=True)
new_identifier = models.UUIDField(null=True)
epoch = models.FloatField(null=True)
class Meta:
verbose_name = "Issue Activity"

View File

@ -3,7 +3,30 @@ from django.db import models
from django.conf import settings
# Module import
from . import ProjectBaseModel
from . import ProjectBaseModel, BaseModel
class GlobalView(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="global_views"
)
name = models.CharField(max_length=255, verbose_name="View Name")
description = models.TextField(verbose_name="View Description", blank=True)
query = models.JSONField(verbose_name="View Query")
access = models.PositiveSmallIntegerField(
default=1, choices=((0, "Private"), (1, "Public"))
)
query_data = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(ProjectBaseModel):

View File

@ -1,10 +1,8 @@
"""Production settings and globals."""
from urllib.parse import urlparse
import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
@ -91,112 +89,89 @@ if bool(os.environ.get("SENTRY_DSN", False)):
profiles_sample_rate=1.0,
)
if DOCKERIZED and USE_MINIO:
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
else:
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
@ -218,27 +193,16 @@ CSRF_COOKIE_SECURE = True
REDIS_URL = os.environ.get("REDIS_URL")
if DOCKERIZED:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
else:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
}
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
},
}
}
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
@ -261,19 +225,16 @@ broker_url = (
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
)
if DOCKERIZED:
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
else:
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
CELERY_BROKER_URL = broker_url
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Scout Settings
SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
SCOUT_NAME = "Plane"

View File

@ -0,0 +1,128 @@
"""Self hosted settings and globals."""
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
# Docker configurations
DOCKERIZED = 1
USE_MINIO = 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# File size limit
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Allow all host headers
ALLOWED_HOSTS = [
"*",
]
# Security settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Redis URL
REDIS_URL = os.environ.get("REDIS_URL")
# Caches
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# URL used for email redirects
WEB_URL = os.environ.get("WEB_URL", "http://localhost")
# Celery settings
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
# Enable or Disable signups
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Analytics
ANALYTICS_BASE_API = False
# OPEN AI Settings
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")

View File

@ -4,7 +4,7 @@ x-api-and-worker-env:
&api-and-worker-env
DEBUG: ${DEBUG}
SENTRY_DSN: ${SENTRY_DSN}
DJANGO_SETTINGS_MODULE: plane.settings.production
DJANGO_SETTINGS_MODULE: plane.settings.selfhosted
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
REDIS_URL: redis://plane-redis:6379/
EMAIL_HOST: ${EMAIL_HOST}

View File

@ -10,15 +10,4 @@ cp ./space/.env.example ./space/.env
cp ./apiserver/.env.example ./apiserver/.env
# Generate the SECRET_KEY that will be used by django
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
# Generate Prompt for taking tiptap auth key
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env

View File

@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
@ -121,9 +120,6 @@ export const TiptapExtensions = (
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,

View File

@ -17,7 +17,6 @@
"@heroicons/react": "^2.0.12",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",

View File

@ -41,7 +41,7 @@ export const CommandPalette: React.FC = observer(() => {
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
const { workspaceSlug, projectId, issueId, inboxId, cycleId, moduleId } = router.query;
const { user } = useUser();
@ -183,6 +183,13 @@ export const CommandPalette: React.FC = observer(() => {
isOpen={isIssueModalOpen}
handleClose={() => setIsIssueModalOpen(false)}
fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]}
prePopulateData={
cycleId
? { cycle: cycleId.toString() }
: moduleId
? { module: moduleId.toString() }
: undefined
}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}

View File

@ -2,8 +2,9 @@ import { useRouter } from "next/router";
// icons
import { Icon, Tooltip } from "components/ui";
import { CopyPlus } from "lucide-react";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
@ -157,7 +158,7 @@ const activityDetails: {
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
blocked_by: {
message: (activity) => {
if (activity.old_value === "")
return (
@ -176,6 +177,44 @@ const activityDetails: {
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
duplicate: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue as duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue as a duplicate of{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked that this issue relates to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the relation from{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity, showIssue, workspaceSlug) => {
if (activity.verb === "created")

View File

@ -23,7 +23,6 @@ import {
CreateUpdateIssueModal,
DeleteIssueModal,
DeleteDraftIssueModal,
IssuePeekOverview,
CreateUpdateDraftIssueModal,
} from "components/issues";
import { CreateUpdateViewModal } from "components/views";
@ -484,15 +483,7 @@ export const IssuesView: React.FC<Props> = ({
}
: null
}
fieldsToShow={[
"name",
"description",
"label",
"assignee",
"priority",
"dueDate",
"priority",
]}
fieldsToShow={["all"]}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}

View File

@ -50,12 +50,12 @@ export const DeleteDraftIssueModal: React.FC<Props> = (props) => {
};
const handleDeletion = async () => {
if (!workspaceSlug || !data) return;
if (!workspaceSlug || !data || !user) return;
setIsDeleteLoading(true);
await issueServices
.deleteDraftIssue(workspaceSlug as string, data.project, data.id)
.deleteDraftIssue(workspaceSlug as string, data.project, data.id, user)
.then(() => {
setIsDeleteLoading(false);
handleClose();

View File

@ -14,6 +14,7 @@ import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useToast from "hooks/use-toast";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useLocalStorage from "hooks/use-local-storage";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
// components
@ -79,6 +80,8 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
const { user } = useUser();
const { projects } = useProjects();
const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {});
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setToastAlert } = useToast();
@ -111,11 +114,14 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
return;
}
if (prePopulateData && prePopulateData.project)
return setActiveProject(prePopulateData.project);
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, data, projectId, projects, isOpen]);
}, [activeProject, data, projectId, projects, isOpen, prePopulateData]);
const calendarFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
@ -228,6 +234,8 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
if (!data) await createIssue(payload);
else await updateIssue(payload);
clearDraftIssueLocalStorage();
if (onSubmit) await onSubmit(payload);
};

View File

@ -8,7 +8,6 @@ import { Controller, useForm } from "react-hook-form";
import aiService from "services/ai.service";
// hooks
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
// components
import { GptAssistantModal } from "components/core";
import { ParentIssuesListModal } from "components/issues";
@ -62,11 +61,9 @@ export interface IssueFormProps {
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
createMore: boolean;
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void;
handleDiscardClose: () => void;
status: boolean;
user: ICurrentUserResponse | undefined;
setIsConfirmDiscardOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleFormDirty: (payload: Partial<IIssue> | null) => void;
fieldsToShow: (
| "project"
@ -107,8 +104,6 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const { setValue: setValueInLocalStorage } = useLocalStorage<any>("draftedIssue", null);
const editorRef = useRef<any>(null);
const router = useRouter();
@ -139,9 +134,11 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
state: getValues("state"),
priority: getValues("priority"),
assignees: getValues("assignees"),
target_date: getValues("target_date"),
labels: getValues("labels"),
start_date: getValues("start_date"),
target_date: getValues("target_date"),
project: getValues("project"),
parent: getValues("parent"),
};
useEffect(() => {
@ -571,8 +568,6 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
<div className="flex items-center gap-2">
<SecondaryButton
onClick={() => {
const data = JSON.stringify(getValues());
setValueInLocalStorage(data);
handleDiscardClose();
}}
>

View File

@ -19,6 +19,7 @@ import useInboxView from "hooks/use-inbox-view";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useProjects from "hooks/use-projects";
import useMyIssues from "hooks/my-issues/use-my-issues";
import useLocalStorage from "hooks/use-local-storage";
// components
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
// types
@ -92,10 +93,11 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } =
useLocalStorage<any>("draftedIssue", {});
const { setToastAlert } = useToast();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
if (router.asPath.includes("my-issues") || router.asPath.includes("assigned"))
prePopulateData = {
...prePopulateData,
@ -103,6 +105,13 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
};
const onClose = () => {
if (!showConfirmDiscard) handleClose();
if (formDirtyState === null) return setActiveProject(null);
const data = JSON.stringify(formDirtyState);
setValueInLocalStorage(data);
};
const onDiscardClose = () => {
if (formDirtyState !== null) {
setShowConfirmDiscard(true);
} else {
@ -111,11 +120,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}
};
const onDiscardClose = () => {
handleClose();
setActiveProject(null);
};
const handleFormDirty = (data: any) => {
setFormDirtyState(data);
};
@ -397,6 +401,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
setActiveProject(null);
setFormDirtyState(null);
setShowConfirmDiscard(false);
clearLocalStorageValue();
}}
/>
@ -431,9 +436,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
initialData={data ?? prePopulateData}
createMore={createMore}
setCreateMore={setCreateMore}
handleClose={onClose}
handleDiscardClose={onDiscardClose}
setIsConfirmDiscardOpen={setShowConfirmDiscard}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}

View File

@ -73,7 +73,7 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
...selectedIssues.map((issue) => ({
issue: issueId as string,
relation_type: "blocked_by" as const,
related_issue_detail: issue.blocked_issue_detail,
issue_detail: issue.blocked_issue_detail,
related_issue: issue.blocked_issue_detail.id,
})),
],
@ -111,17 +111,17 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
{blockedByIssue && blockedByIssue.length > 0
? blockedByIssue.map((relation) => (
<div
key={relation.related_issue_detail?.id}
key={relation?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockedIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a>
<button
type="button"

View File

@ -81,6 +81,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
related_issue_detail: issue.blocker_issue_detail,
})),
],
relation: "blocking",
})
.then((response) => {
submitChanges({
@ -89,7 +90,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
...(response ?? []).map((i: any) => ({
id: i.id,
relation_type: i.relation_type,
issue_detail: i.related_issue_detail,
issue_detail: i.issue_detail,
issue: i.related_issue,
})),
],
@ -118,7 +119,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
{blockerIssue && blockerIssue.length > 0
? blockerIssue.map((relation) => (
<div
key={relation.issue_detail?.id}
key={relation.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
@ -134,9 +135,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = blockerIssue.filter(
(i) => i.issue_detail?.id !== relation.issue_detail?.id
);
const updatedBlockers = blockerIssue.filter((i) => i.id !== relation.id);
submitChanges({
issue_relations: updatedBlockers,

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
@ -69,7 +69,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "duplicate" as const,
})),
@ -90,7 +90,7 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "duplicate")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
@ -114,39 +114,35 @@ export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
{duplicateIssuesRelation && duplicateIssuesRelation.length > 0
? duplicateIssuesRelation.map((relation) => (
<div
key={relation.related_issue_detail?.id}
key={relation.issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = duplicateIssuesRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
submitChanges();
});
}}
>
<X className="h-2 w-2" />

View File

@ -18,7 +18,7 @@ import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void;
submitChanges: (formData?: Partial<IIssue>) => void;
watch: UseFormWatch<IIssue>;
disabled?: boolean;
};
@ -69,7 +69,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
related_list: [
...selectedIssues.map((issue) => ({
issue: issueId as string,
related_issue_detail: issue.blocker_issue_detail,
issue_detail: issue.blocker_issue_detail,
related_issue: issue.blocker_issue_detail.id,
relation_type: "relates_to" as const,
})),
@ -90,7 +90,7 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
?.filter((i) => i.relation_type === "relates_to")
.map((i) => ({
...i,
related_issue_detail: i.issue_detail,
issue_detail: i.issue_detail,
related_issue: i.issue_detail?.id,
})),
];
@ -114,39 +114,35 @@ export const SidebarRelatesSelect: React.FC<Props> = (props) => {
{relatedToIssueRelation && relatedToIssueRelation.length > 0
? relatedToIssueRelation.map((relation) => (
<div
key={relation.related_issue_detail?.id}
key={relation.issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
>
<a
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockerIcon height={10} width={10} />
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => {
const updatedBlockers = relatedToIssueRelation.filter(
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
);
submitChanges({
related_issues: updatedBlockers,
});
if (!user) return;
issuesService.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
);
issuesService
.deleteIssueRelation(
workspaceSlug as string,
projectId as string,
issueId as string,
relation.id,
user
)
.then(() => {
submitChanges();
});
}}
>
<X className="h-2 w-2" />

View File

@ -509,17 +509,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<SidebarDuplicateSelect
issueId={issueId as string}
submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
if (!data) return mutate(ISSUE_DETAILS(issueId as string));
mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
});
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
@ -529,17 +526,14 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<SidebarRelatesSelect
issueId={issueId as string}
submitChanges={(data: any) => {
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
},
false
);
if (!data) return mutate(ISSUE_DETAILS(issueId as string));
mutate<IIssue>(ISSUE_DETAILS(issueId as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...data,
};
});
}}
watch={watchIssue}
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}

View File

@ -18,7 +18,6 @@ import Gapcursor from "@tiptap/extension-gapcursor";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
import { CustomTableCell } from "./table/table-cell";
@ -121,9 +120,6 @@ export const TiptapExtensions = (
},
includeChildren: true,
}),
UniqueID.configure({
types: ["image"],
}),
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,

View File

@ -1,7 +1,7 @@
export type ButtonProps = {
children: React.ReactNode;
className?: string;
onClick?: () => void;
onClick?: (e: any) => void;
type?: "button" | "submit" | "reset";
disabled?: boolean;
loading?: boolean;

View File

@ -1,9 +1,10 @@
import { useRouter } from "next/router";
// icons
import { CopyPlus } from "lucide-react";
import { Icon, Tooltip } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
import { BlockedIcon, BlockerIcon, RelatedIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
@ -117,7 +118,7 @@ const activityDetails: {
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
blocked_by: {
message: (activity) => (
<>
{activity.old_value === ""
@ -132,6 +133,36 @@ const activityDetails: {
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
duplicate: {
message: (activity) => (
<>
{activity.old_value === ""
? "marked this issue as duplicate of "
: "removed this issue as a duplicate of "}
<span className="font-medium text-custom-text-100">
{activity.verb === "created" ? activity.new_value : activity.old_value}
</span>
.
</>
),
icon: <CopyPlus size={12} color="#6b7280" />,
},
relates_to: {
message: (activity) => (
<>
{activity.old_value === ""
? "marked that this issue relates to "
: "removed the relation from "}
<span className="font-medium text-custom-text-100">
{activity.old_value === "" ? activity.new_value : activity.old_value}
</span>
.
</>
),
icon: <RelatedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity) => (
<>

View File

@ -351,25 +351,23 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
{blockedIssue &&
blockedIssue.map((issue) => (
<div
key={issue.related_issue_detail?.id}
key={issue.issue_detail?.id}
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
>
<a
href={`/${workspaceSlug}/projects/${issue.related_issue_detail?.project_detail.id}/issues/${issue.related_issue_detail?.id}`}
href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
>
<BlockedIcon height={10} width={10} />
{`${issue?.related_issue_detail?.project_detail?.identifier}-${issue?.related_issue_detail?.sequence_id}`}
{`${issue?.issue_detail?.project_detail?.identifier}-${issue?.issue_detail?.sequence_id}`}
</a>
<button
type="button"
className="duration-300"
onClick={() => {
const updatedBlocked = blockedIssue.filter(
(i) => i.related_issue_detail?.id !== issue.related_issue_detail?.id
);
const updatedBlocked = blockedIssue.filter((i) => i?.id !== issue?.id);
if (!user) return;

View File

@ -17,7 +17,10 @@ export const WorkspaceSidebarQuickAction = () => {
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
const { storedValue, clearValue } = useLocalStorage<any>("draftedIssue", null);
const { storedValue, clearValue } = useLocalStorage<any>(
"draftedIssue",
JSON.stringify(undefined)
);
return (
<>
@ -30,18 +33,7 @@ export const WorkspaceSidebarQuickAction = () => {
clearValue();
setIsDraftIssueModalOpen(false);
}}
fieldsToShow={[
"name",
"description",
"label",
"assignee",
"priority",
"dueDate",
"priority",
"state",
"startDate",
"project",
]}
fieldsToShow={["all"]}
/>
<div
@ -50,7 +42,7 @@ export const WorkspaceSidebarQuickAction = () => {
}`}
>
<div
className={`flex items-center justify-between w-full rounded cursor-pointer px-2 gap-1 ${
className={`flex items-center justify-between w-full rounded cursor-pointer px-2 gap-1 group ${
store?.theme?.sidebarCollapsed
? "px-2 hover:bg-custom-sidebar-background-80"
: "px-3 shadow border-[0.5px] border-custom-border-300"

View File

@ -26,7 +26,6 @@
"@nivo/pie": "0.80.0",
"@nivo/scatterplot": "0.80.0",
"@sentry/nextjs": "^7.36.0",
"@tiptap-pro/extension-unique-id": "^2.1.0",
"@tiptap/extension-code-block-lowlight": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-gapcursor": "^2.1.7",

View File

@ -138,6 +138,7 @@ export class ProjectIssuesServices extends APIService {
relation_type: "duplicate" | "relates_to" | "blocked_by";
related_issue: string;
}>;
relation?: "blocking" | null;
}
) {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-relation/`, data)
@ -578,8 +579,15 @@ export class ProjectIssuesServices extends APIService {
});
}
async deleteDraftIssue(workspaceSlug: string, projectId: string, issueId: string): Promise<any> {
return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`)
async deleteDraftIssue(
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse
): Promise<any> {
return this.delete(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;

189
web/store/draft-issue.ts Normal file
View File

@ -0,0 +1,189 @@
// mobx
import { action, observable, runInAction, makeAutoObservable } from "mobx";
// services
import issueService from "services/issues.service";
// types
import type { ICurrentUserResponse, IIssue } from "types";
class DraftIssuesStore {
issues: { [key: string]: IIssue } = {};
isIssuesLoading: boolean = false;
rootStore: any | null = null;
constructor(_rootStore: any | null = null) {
makeAutoObservable(this, {
issues: observable.ref,
isIssuesLoading: observable.ref,
rootStore: observable.ref,
loadDraftIssues: action,
getIssueById: action,
createDraftIssue: action,
updateDraftIssue: action,
deleteDraftIssue: action,
});
this.rootStore = _rootStore;
}
/**
* @description Fetch all draft issues of a project and hydrate issues field
*/
loadDraftIssues = async (workspaceSlug: string, projectId: string, params?: any) => {
this.isIssuesLoading = true;
try {
const issuesResponse = await issueService.getDraftIssues(workspaceSlug, projectId, params);
const issues = Array.isArray(issuesResponse) ? { allIssues: issuesResponse } : issuesResponse;
runInAction(() => {
this.issues = issues;
this.isIssuesLoading = false;
});
} catch (error) {
this.isIssuesLoading = false;
console.error("Fetching issues error", error);
}
};
/**
* @description Fetch a single draft issue by id and hydrate issues field
* @param workspaceSlug
* @param projectId
* @param issueId
* @returns {IIssue}
*/
getIssueById = async (
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<IIssue> => {
if (this.issues[issueId]) return this.issues[issueId];
try {
const issueResponse: IIssue = await issueService.getDraftIssueById(
workspaceSlug,
projectId,
issueId
);
const issues = {
...this.issues,
[issueId]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
throw error;
}
};
/**
* @description Create a new draft issue and hydrate issues field
* @param workspaceSlug
* @param projectId
* @param issueForm
* @param user
* @returns {IIssue}
*/
createDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueForm: IIssue,
user: ICurrentUserResponse
): Promise<IIssue> => {
try {
const issueResponse = await issueService.createDraftIssue(
workspaceSlug,
projectId,
issueForm,
user
);
const issues = {
...this.issues,
[issueResponse.id]: { ...issueResponse },
};
runInAction(() => {
this.issues = issues;
});
return issueResponse;
} catch (error) {
console.error("Creating issue error", error);
throw error;
}
};
updateDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
issueForm: Partial<IIssue>,
user: ICurrentUserResponse
) => {
// keep a copy of the issue in the store
const originalIssue = { ...this.issues[issueId] };
// immediately update the issue in the store
const updatedIssue = { ...this.issues[issueId], ...issueForm };
if (updatedIssue.assignees_list) updatedIssue.assignees = updatedIssue.assignees_list;
try {
runInAction(() => {
this.issues[issueId] = { ...updatedIssue };
});
// make a patch request to update the issue
const issueResponse: IIssue = await issueService.updateDraftIssue(
workspaceSlug,
projectId,
issueId,
issueForm,
user
);
const updatedIssues = { ...this.issues };
updatedIssues[issueId] = { ...issueResponse };
runInAction(() => {
this.issues = updatedIssues;
});
} catch (error) {
// if there is an error, revert the changes
runInAction(() => {
this.issues[issueId] = originalIssue;
});
return error;
}
};
deleteDraftIssue = async (
workspaceSlug: string,
projectId: string,
issueId: string,
user: ICurrentUserResponse
) => {
const issues = { ...this.issues };
delete issues[issueId];
try {
runInAction(() => {
this.issues = issues;
});
issueService.deleteDraftIssue(workspaceSlug, projectId, issueId, user);
} catch (error) {
console.error("Deleting issue error", error);
}
};
}
export default DraftIssuesStore;

View File

@ -5,6 +5,7 @@ import UserStore from "./user";
import ThemeStore from "./theme";
import ProjectPublishStore, { IProjectPublishStore } from "./project-publish";
import IssuesStore from "./issues";
import DraftIssuesStore from "./draft-issue";
import WorkspaceStore, { IWorkspaceStore } from "./workspaces";
import ProjectStore, { IProjectStore } from "./projects";
@ -24,6 +25,7 @@ export class RootStore {
theme;
projectPublish: IProjectPublishStore;
issues: IssuesStore;
draftIssuesStore: DraftIssuesStore;
workspace: IWorkspaceStore;
project: IProjectStore;
@ -51,5 +53,6 @@ export class RootStore {
this.issueDetail = new IssueViewDetailStore(this);
this.issueKanBanView = new IssueKanBanViewStore(this);
this.draftIssuesStore = new DraftIssuesStore(this);
}
}

20
web/types/issues.d.ts vendored
View File

@ -76,8 +76,10 @@ export type IssueRelationType = "duplicate" | "relates_to" | "blocked_by";
export interface IssueRelation {
id: string;
issue: string;
related_issue: string;
issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
relation: "blocking" | null;
}
export interface IIssue {
@ -87,20 +89,8 @@ export interface IIssue {
assignees_list: string[];
attachment_count: number;
attachments: any[];
issue_relations: {
id: string;
issue: string;
issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
related_issues: {
id: string;
issue: string;
related_issue_detail: BlockeIssueDetail;
relation_type: IssueRelationType;
related_issue: string;
}[];
issue_relations: IssueRelation[];
related_issues: IssueRelation[];
bridge_id?: string | null;
completed_at: Date;
created_at: string;

View File

@ -2174,13 +2174,6 @@
lodash.merge "^4.6.2"
postcss-selector-parser "6.0.10"
"@tiptap-pro/extension-unique-id@^2.1.0":
version "2.2.3"
resolved "https://registry.tiptap.dev/@tiptap-pro%2fextension-unique-id/-/extension-unique-id-2.2.3.tgz#151a570ef8363bf460bf5b08dc0581fb182ebabc"
integrity sha512-Y1jM+6hebNltFZ+0fbC+NcOCU647KjRtqJ7jEZVFoP12ZMocZNqTTabsSIys0UXOkSaJy3/H6Z/ybygAHY/dBg==
dependencies:
uuid "^8.3.2"
"@tiptap/core@^2.1.8":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.1.8.tgz#4555dc7d86580dee790d4aded1ce7fb79319da70"
@ -8188,11 +8181,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"