forked from github/plane
Compare commits
36 Commits
chore/rela
...
fix/minio-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2964835c | ||
|
|
d4df2c4717 | ||
|
|
6d52e7b5b6 | ||
|
|
51ecfd947e | ||
|
|
daa0b16960 | ||
|
|
e7489e3449 | ||
|
|
7311f6d40d | ||
|
|
0005ff5f99 | ||
|
|
0c7b7c4e94 | ||
|
|
4d835c5b4a | ||
|
|
c7092edb61 | ||
|
|
73afb8f4d8 | ||
|
|
9558d88a40 | ||
|
|
d5555117e5 | ||
|
|
978909c021 | ||
|
|
de9f34cac3 | ||
|
|
e3793f4464 | ||
|
|
1621125f6d | ||
|
|
bd077e6500 | ||
|
|
60ae940d40 | ||
|
|
cdfff12f4f | ||
|
|
e01a0d20fe | ||
|
|
63c4792e70 | ||
|
|
ce562fa3ea | ||
|
|
a6a0eb9774 | ||
|
|
d603c1e8f0 | ||
|
|
405ef9314f | ||
|
|
926d2ae0a0 | ||
|
|
11258686ad | ||
|
|
f6b92fc953 | ||
|
|
79bf7d4c0c | ||
|
|
5d331477ef | ||
|
|
3d72279edb | ||
|
|
c107b36d34 | ||
|
|
ccffbe1b4e | ||
|
|
9bfdcff44d |
@@ -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
|
||||
|
||||
4
.github/workflows/Update_Docker_Images.yml
vendored
4
.github/workflows/Update_Docker_Images.yml
vendored
@@ -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
|
||||
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -101,48 +102,84 @@ class CycleViewSet(BaseViewSet):
|
||||
.select_related("workspace")
|
||||
.select_related("owned_by")
|
||||
.annotate(is_favorite=Exists(subquery))
|
||||
.annotate(total_issues=Count("issue_cycle"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="cancelled"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="unstarted"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||
.annotate(
|
||||
completed_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_estimates=Sum(
|
||||
"issue_cycle__issue__estimate_point",
|
||||
filter=Q(issue_cycle__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
@@ -195,17 +232,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("display_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("display_name")
|
||||
@@ -221,17 +271,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -384,17 +447,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.values(
|
||||
"first_name", "last_name", "assignee_id", "avatar", "display_name"
|
||||
)
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
@@ -411,17 +487,30 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(archived_at__isnull=True, is_draft=False),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -487,6 +576,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 +752,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
# Return all Cycle Issues
|
||||
|
||||
@@ -384,7 +384,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
sort_order=largest_sort_order,
|
||||
start_date=issue_data.get("start_date", None),
|
||||
target_date=issue_data.get("target_date", None),
|
||||
priority=issue_data.get("priority", None),
|
||||
priority=issue_data.get("priority", "none"),
|
||||
created_by=request.user,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -173,12 +173,12 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
"none",
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -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:
|
||||
@@ -478,12 +480,12 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
if not request.data.get("issue", {}).get("priority", "none") in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
"none",
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -326,7 +330,12 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.issue_objects.get(
|
||||
issue = 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")
|
||||
).get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
|
||||
@@ -568,6 +577,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 +596,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -607,6 +618,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
IssueCommentSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -890,6 +902,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 +921,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
@@ -929,6 +943,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
IssueLinkSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -1007,6 +1022,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 +1045,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 +1248,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 +1453,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 +1477,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 +1526,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 +1551,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 +1648,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 +1698,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 +1732,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 +1807,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 +1852,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 +1926,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 +1978,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 +2042,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 +2077,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 +2111,7 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
IssueRelationSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
@@ -2113,6 +2145,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 +2190,8 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
.select_related("issue")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
|
||||
class IssueRetrievePublicEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
@@ -2382,6 +2417,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 +2602,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)
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +40,7 @@ from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
permission_classes = [
|
||||
@@ -77,35 +79,63 @@ class ModuleViewSet(BaseViewSet):
|
||||
queryset=ModuleLink.objects.select_related("module", "created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(total_issues=Count("issue_module"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="completed"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="cancelled"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="cancelled",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="started"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="started",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="unstarted"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="unstarted",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="backlog",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(order_by, "name")
|
||||
@@ -129,6 +159,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)
|
||||
@@ -177,18 +208,36 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.values(
|
||||
"first_name", "last_name", "assignee_id", "avatar", "display_name"
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"assignee_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("first_name", "last_name")
|
||||
@@ -204,17 +253,33 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(color=F("labels__color"))
|
||||
.annotate(label_id=F("labels__id"))
|
||||
.values("label_name", "color", "label_id")
|
||||
.annotate(total_issues=Count("label_id"))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=False),
|
||||
filter=Q(
|
||||
completed_at__isnull=False,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
"label_id",
|
||||
filter=Q(completed_at__isnull=True),
|
||||
filter=Q(
|
||||
completed_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by("label_name")
|
||||
@@ -277,6 +342,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 +510,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch = int(timezone.now().timestamp())
|
||||
)
|
||||
|
||||
return Response(
|
||||
@@ -490,7 +557,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1239,13 +1239,21 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
.annotate(
|
||||
created_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(project_issue__created_by_id=user_id),
|
||||
filter=Q(
|
||||
project_issue__created_by_id=user_id,
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
assigned_issues=Count(
|
||||
"project_issue",
|
||||
filter=Q(project_issue__assignees__in=[user_id]),
|
||||
filter=Q(
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@@ -1254,6 +1262,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
filter=Q(
|
||||
project_issue__completed_at__isnull=False,
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1267,6 +1277,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
"started",
|
||||
],
|
||||
project_issue__assignees__in=[user_id],
|
||||
project_issue__archived_at__isnull=True,
|
||||
project_issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, user_id):
|
||||
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 = (
|
||||
Issue.issue_objects.filter(
|
||||
|
||||
@@ -32,7 +32,7 @@ def delete_old_s3_link():
|
||||
else:
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name="ap-south-1",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
|
||||
@@ -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,36 +118,23 @@ def track_priority(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("priority") != requested_data.get("priority"):
|
||||
if requested_data.get("priority") == None:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=None,
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to None",
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||
)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("priority"),
|
||||
new_value=requested_data.get("priority"),
|
||||
field="priority",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the priority to {requested_data.get('priority')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track chnages in state of the issue
|
||||
@@ -153,6 +145,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 +164,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 +177,7 @@ def track_description(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
@@ -203,6 +198,7 @@ def track_description(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {requested_data.get('description_html')}",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -215,6 +211,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 +226,7 @@ def track_target_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the target date to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -243,6 +241,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 +254,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 +269,7 @@ def track_start_date(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the start date to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -283,6 +284,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 +297,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 +317,7 @@ def track_labels(
|
||||
comment=f"added label {label.name}",
|
||||
new_identifier=label.id,
|
||||
old_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -335,6 +339,7 @@ def track_labels(
|
||||
comment=f"removed label {label.name}",
|
||||
old_identifier=label.id,
|
||||
new_identifier=None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,6 +352,7 @@ def track_assignees(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch
|
||||
):
|
||||
# Assignee Addition
|
||||
if len(requested_data.get("assignees_list")) > len(
|
||||
@@ -367,6 +373,7 @@ def track_assignees(
|
||||
workspace=project.workspace,
|
||||
comment=f"added assignee {assignee.display_name}",
|
||||
new_identifier=assignee.id,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -389,12 +396,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 +412,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 +433,7 @@ def track_estimate_points(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the estimate point to None",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -438,12 +448,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 +468,7 @@ def track_archive_at(
|
||||
field="archived_at",
|
||||
old_value="archive",
|
||||
new_value="restore",
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -471,12 +483,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 +509,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 +532,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 +547,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 +562,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 +587,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 +615,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 +632,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 +670,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 +691,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 +721,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 +759,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 +779,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 +809,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 +833,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 +860,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 +883,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 +908,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 +925,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 +949,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 +974,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 +1000,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 +1027,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 +1050,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 +1075,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 +1121,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 = (
|
||||
@@ -1102,13 +1144,14 @@ def delete_issue_relation_activity(
|
||||
issue_id=current_instance.get("related_issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=relation_type,
|
||||
project=project,
|
||||
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"))
|
||||
@@ -1117,19 +1160,20 @@ def delete_issue_relation_activity(
|
||||
issue_id=current_instance.get("issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=f'{current_instance.get("relation_type")}',
|
||||
project=project,
|
||||
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 +1184,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 +1205,7 @@ def update_draft_issue_activity(
|
||||
comment=f"created the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -1172,13 +1218,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 +1235,7 @@ def delete_draft_issue_activity(
|
||||
field="draft",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1200,6 +1248,7 @@ def issue_activity(
|
||||
issue_id,
|
||||
actor_id,
|
||||
project_id,
|
||||
epoch,
|
||||
subscriber=True,
|
||||
):
|
||||
try:
|
||||
@@ -1276,6 +1325,7 @@ def issue_activity(
|
||||
project,
|
||||
actor,
|
||||
issue_activities,
|
||||
epoch,
|
||||
)
|
||||
|
||||
# Save all the values to database
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
53
apiserver/plane/db/migrations/0046_auto_20230919_1421.py
Normal file
53
apiserver/plane/db/migrations/0046_auto_20230919_1421.py
Normal 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),
|
||||
]
|
||||
27
apiserver/plane/db/migrations/0047_auto_20230921_0758.py
Normal file
27
apiserver/plane/db/migrations/0047_auto_20230921_0758.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.3 on 2023-09-21 07:58
|
||||
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_priority_history(apps, schema_editor):
|
||||
IssueActivity = apps.get_model("db", "IssueActivity")
|
||||
updated_issue_activity = []
|
||||
for obj in IssueActivity.objects.all():
|
||||
if obj.field == "priority":
|
||||
obj.new_value = obj.new_value or "none"
|
||||
obj.old_value = obj.old_value or "none"
|
||||
updated_issue_activity.append(obj)
|
||||
IssueActivity.objects.bulk_update(
|
||||
updated_issue_activity, ["new_value", "old_value"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0046_auto_20230919_1421"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_priority_history),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -26,7 +26,7 @@ ROLE_CHOICES = (
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"priority": "none",
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -17,7 +17,7 @@ ROLE_CHOICES = (
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"priority": "none",
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
128
apiserver/plane/settings/selfhosted.py
Normal file
128
apiserver/plane/settings/selfhosted.py
Normal 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")
|
||||
@@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||
|
||||
sorted_data = grouped_data
|
||||
if temp_axis == "priority":
|
||||
order = ["low", "medium", "high", "urgent", "None"]
|
||||
order = ["low", "medium", "high", "urgent", "none"]
|
||||
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||
else:
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0])))
|
||||
return sorted_data
|
||||
|
||||
|
||||
|
||||
@@ -40,9 +40,6 @@ def filter_priority(params, filter, method):
|
||||
priorities = params.get("priority").split(",")
|
||||
if len(priorities) and "" not in priorities:
|
||||
filter["priority__in"] = priorities
|
||||
else:
|
||||
if params.get("priority", None) and len(params.get("priority")):
|
||||
filter["priority__in"] = params.get("priority")
|
||||
return filter
|
||||
|
||||
|
||||
|
||||
@@ -1,113 +1,61 @@
|
||||
version: "3.8"
|
||||
|
||||
x-api-and-worker-env:
|
||||
&api-and-worker-env
|
||||
DEBUG: ${DEBUG}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
DJANGO_SETTINGS_MODULE: plane.settings.production
|
||||
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
|
||||
REDIS_URL: redis://plane-redis:6379/
|
||||
EMAIL_HOST: ${EMAIL_HOST}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
|
||||
EMAIL_PORT: ${EMAIL_PORT}
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
|
||||
WEB_URL: ${WEB_URL}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
DISABLE_COLLECTSTATIC: 1
|
||||
DOCKERIZED: 1
|
||||
OPENAI_API_BASE: ${OPENAI_API_BASE}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
GPT_ENGINE: ${GPT_ENGINE}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
|
||||
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
|
||||
USE_MINIO: ${USE_MINIO}
|
||||
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
|
||||
|
||||
services:
|
||||
plane-web:
|
||||
container_name: planefrontend
|
||||
web:
|
||||
container_name: web
|
||||
image: makeplane/plane-frontend:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
NEXT_PUBLIC_SENTRY_DSN: "0"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH: "0"
|
||||
NEXT_PUBLIC_ENABLE_SENTRY: "0"
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
|
||||
NEXT_PUBLIC_TRACK_EVENTS: "0"
|
||||
- ./web/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- api
|
||||
- worker
|
||||
|
||||
plane-deploy:
|
||||
container_name: planedeploy
|
||||
image: makeplane/plane-deploy:latest
|
||||
space:
|
||||
container_name: space
|
||||
image: makeplane/plane-space:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
- ./space/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- plane-web
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
plane-api:
|
||||
container_name: planebackend
|
||||
api:
|
||||
container_name: api
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/takeoff
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-worker:
|
||||
container_name: planebgworker
|
||||
worker:
|
||||
container_name: bgworker
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/worker
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-beat-worker:
|
||||
container_name: planebeatworker
|
||||
beat-worker:
|
||||
container_name: beatworker
|
||||
image: makeplane/plane-backend:latest
|
||||
restart: always
|
||||
command: ./bin/beat
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
@@ -157,8 +105,8 @@ services:
|
||||
- plane-minio
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
plane-proxy:
|
||||
container_name: planeproxy
|
||||
proxy:
|
||||
container_name: proxy
|
||||
image: makeplane/plane-proxy:latest
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
@@ -168,8 +116,9 @@ services:
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
depends_on:
|
||||
- plane-web
|
||||
- plane-api
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
plane-web:
|
||||
container_name: planefrontend
|
||||
web:
|
||||
container_name: web
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./web/Dockerfile.web
|
||||
@@ -11,11 +11,11 @@ services:
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- api
|
||||
- worker
|
||||
|
||||
plane-deploy:
|
||||
container_name: planedeploy
|
||||
space:
|
||||
container_name: space
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./space/Dockerfile.space
|
||||
@@ -24,12 +24,12 @@ services:
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
- plane-web
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
plane-api:
|
||||
container_name: planebackend
|
||||
api:
|
||||
container_name: api
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-worker:
|
||||
container_name: planebgworker
|
||||
worker:
|
||||
container_name: bgworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -55,12 +55,12 @@ services:
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
plane-beat-worker:
|
||||
container_name: planebeatworker
|
||||
beat-worker:
|
||||
container_name: beatworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.api
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- api
|
||||
- plane-db
|
||||
- plane-redis
|
||||
|
||||
@@ -108,18 +108,20 @@ services:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
entrypoint: >
|
||||
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
|
||||
configure-minio:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./minio/Dockerfile.minio
|
||||
entrypoint: /usr/local/bin/minio.sh ${AWS_ACCESS_KEY_ID} ${AWS_SECRET_ACCESS_KEY} ${AWS_S3_BUCKET_NAME}
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- plane-minio
|
||||
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
plane-proxy:
|
||||
container_name: planeproxy
|
||||
proxy:
|
||||
container_name: proxy
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
@@ -130,8 +132,9 @@ services:
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
depends_on:
|
||||
- plane-web
|
||||
- plane-api
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
47
minio.sh
Normal file
47
minio.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
AWS_ACCESS_KEY_ID=$1
|
||||
AWS_SECRET_ACCESS_KEY=$2
|
||||
AWS_S3_BUCKET_NAME=$3
|
||||
|
||||
/usr/bin/mc config host add plane-minio http://plane-minio:9000 $AWS_ACCESS_KEY_ID $AWS_SECRET_ACCESS_KEY;
|
||||
|
||||
/usr/bin/mc mb $AWS_S3_BUCKET_NAME;
|
||||
/usr/bin/mc anonymous set download $AWS_S3_BUCKET_NAME;
|
||||
|
||||
# Create the policy JSON file
|
||||
cat <<EOF > policy.json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"s3:ListBucket"
|
||||
],
|
||||
"Effect": "Deny",
|
||||
"Resource": [
|
||||
"arn:aws:s3:::uploads/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Effect": "Allow",
|
||||
"Resource": [
|
||||
"arn:aws:s3:::uploads/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Create and apply the policy
|
||||
/usr/bin/mc admin policy create plane-minio blocking-file-listing policy.json
|
||||
# /usr/bin/mc admin policy attach plane-minio read-only-policy user
|
||||
|
||||
/usr/bin/mc admin service restart plane-minio
|
||||
|
||||
exit 0;
|
||||
7
minio/Dockerfile.minio
Normal file
7
minio/Dockerfile.minio
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM minio/mc
|
||||
|
||||
# Copy the setup script
|
||||
COPY minio.sh /usr/local/bin/
|
||||
|
||||
# Set the execute permission for the setup script
|
||||
RUN chmod +x /usr/local/bin/minio.sh
|
||||
@@ -1,29 +1,36 @@
|
||||
events { }
|
||||
events {
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://planefrontend:3000/;
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://planebackend:8000/api/;
|
||||
proxy_pass http://api:8000/api/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
proxy_pass http://planedeploy:3000/spaces/;
|
||||
rewrite ^/spaces/?$ /spaces/login break;
|
||||
proxy_pass http://space:3000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
setup.sh
13
setup.sh
@@ -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
|
||||
@@ -1,4 +1,2 @@
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# Flag to toggle OAuth
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
@@ -33,7 +33,7 @@ export const SignInView = observer(() => {
|
||||
const onSignInSuccess = (response: any) => {
|
||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/";
|
||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
|
||||
|
||||
userStore.setCurrentUser(response?.user);
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SignInView = observer(() => {
|
||||
router.push(`/onboarding?next_path=${nextPath}`);
|
||||
return;
|
||||
}
|
||||
router.push((nextPath ?? "/").toString());
|
||||
router.push((nextPath ?? "/login").toString());
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./home";
|
||||
export * from "./login";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { SignInView, UserLoggedIn } from "components/accounts";
|
||||
|
||||
export const HomeView = observer(() => {
|
||||
export const LoginView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
if (!userStore.currentUser) return <SignInView />;
|
||||
@@ -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",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
// components
|
||||
import { HomeView } from "components/views";
|
||||
|
||||
const HomePage = () => <HomeView />;
|
||||
|
||||
export default HomePage;
|
||||
8
space/pages/login/index.tsx
Normal file
8
space/pages/login/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
// components
|
||||
import { LoginView } from "components/views";
|
||||
|
||||
const LoginPage = () => <LoginView />;
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,24 +1,4 @@
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# GitHub App ID for GitHub OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# GitHub App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable Sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack Client ID for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# Public boards deploy URL
|
||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"
|
||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
|
||||
@@ -9,7 +9,6 @@ import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||
import { generateBarColor } from "helpers/analytics.helper";
|
||||
// types
|
||||
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
analytics: IAnalyticsResponse;
|
||||
|
||||
@@ -103,8 +103,8 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
|
||||
{projectDetails?.close_in !== 0 && (
|
||||
<div className="ml-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
|
||||
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// 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";
|
||||
// types
|
||||
import { IIssueActivity } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
const router = useRouter();
|
||||
@@ -51,6 +58,26 @@ const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const LabelPill = ({ labelId }: { labelId: string }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: labels } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
|
||||
workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: labels?.find((l) => l.id === labelId)?.color ?? "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (
|
||||
@@ -90,14 +117,14 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="group" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
archived_at: {
|
||||
message: (activity) => {
|
||||
if (activity.new_value === "restore") return "restored the issue.";
|
||||
else return "archived the issue.";
|
||||
},
|
||||
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="archive" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
attachment: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -136,7 +163,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="attach_file" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
blocking: {
|
||||
message: (activity) => {
|
||||
@@ -157,7 +184,7 @@ const activityDetails: {
|
||||
},
|
||||
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
|
||||
},
|
||||
blocks: {
|
||||
blocked_by: {
|
||||
message: (activity) => {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
@@ -176,6 +203,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")
|
||||
@@ -224,7 +289,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="contrast" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
description: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -239,7 +304,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
estimate_point: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -271,14 +336,14 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="change_history" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
issue: {
|
||||
message: (activity) => {
|
||||
if (activity.verb === "created") return "created the issue.";
|
||||
else return "deleted an issue.";
|
||||
},
|
||||
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="stack" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -286,14 +351,8 @@ const activityDetails: {
|
||||
return (
|
||||
<>
|
||||
added a new label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<LabelPill labelId={activity.new_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@@ -309,13 +368,7 @@ const activityDetails: {
|
||||
<>
|
||||
removed the label{" "}
|
||||
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: "#000000",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<LabelPill labelId={activity.old_identifier ?? ""} />
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
</span>
|
||||
{showIssue && (
|
||||
@@ -327,7 +380,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="sell" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
link: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -398,7 +451,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="link" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
modules: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
@@ -448,7 +501,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="dataset" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
name: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -463,7 +516,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="chat" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
parent: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -496,7 +549,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="supervised_user_circle" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
priority: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -514,7 +567,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="signal_cellular_alt" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
start_date: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -548,7 +601,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
state: {
|
||||
message: (activity, showIssue) => (
|
||||
@@ -564,7 +617,7 @@ const activityDetails: {
|
||||
.
|
||||
</>
|
||||
),
|
||||
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
|
||||
icon: <Squares2X2Icon className="h-6 w-6 text-custom-sidebar-200" aria-hidden="true" />,
|
||||
},
|
||||
target_date: {
|
||||
message: (activity, showIssue) => {
|
||||
@@ -598,7 +651,7 @@ const activityDetails: {
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
|
||||
icon: <Icon iconName="calendar_today" className="!text-2xl" aria-hidden="true" />,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-dropzone
|
||||
@@ -12,7 +11,7 @@ import fileServices from "services/file.service";
|
||||
// hooks
|
||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { DangerButton, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { UserCircleIcon } from "components/icons";
|
||||
|
||||
@@ -21,6 +20,8 @@ type Props = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onSuccess: (url: string) => void;
|
||||
isRemoving: boolean;
|
||||
handleDelete: () => void;
|
||||
userImage?: boolean;
|
||||
};
|
||||
|
||||
@@ -29,6 +30,8 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
isOpen,
|
||||
onClose,
|
||||
isRemoving,
|
||||
handleDelete,
|
||||
userImage,
|
||||
}) => {
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
@@ -148,12 +151,10 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<NextImage
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
<img
|
||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||
alt="image"
|
||||
className="rounded-lg"
|
||||
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -182,15 +183,22 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
<p className="my-4 text-custom-text-200 text-sm">
|
||||
File formats supported- .jpeg, .jpg, .png, .webp, .svg
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</PrimaryButton>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<DangerButton onClick={handleDelete} outline disabled={!value}>
|
||||
{isRemoving ? "Removing..." : "Remove"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={!image}
|
||||
loading={isImageUploading}
|
||||
>
|
||||
{isImageUploading ? "Uploading..." : "Upload & Save"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
{groupedIssues ? (
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8 bg-custom-background-90">
|
||||
{Object.keys(groupedIssues).map((singleGroup, index) => {
|
||||
const currentState =
|
||||
displayFilters?.group_by === "state"
|
||||
|
||||
@@ -50,8 +50,6 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
|
||||
const { displayFilters, groupedIssues } = viewProps;
|
||||
|
||||
console.log("dF", displayFilters);
|
||||
|
||||
const { data: issueLabels } = useSWR(
|
||||
workspaceSlug && projectId && displayFilters?.group_by === "labels"
|
||||
? PROJECT_ISSUE_LABELS(projectId.toString())
|
||||
|
||||
@@ -13,19 +13,14 @@ import {
|
||||
} from "react-beautiful-dnd";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { MembersSelect, LabelSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
// ui
|
||||
import { ContextMenu, CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
@@ -44,7 +39,15 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
|
||||
import {
|
||||
ICurrentUserResponse,
|
||||
IIssue,
|
||||
IIssueViewProps,
|
||||
IState,
|
||||
ISubIssueResponse,
|
||||
TIssuePriorities,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
@@ -188,6 +191,86 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue(
|
||||
{
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
},
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data }, issue);
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleLabelChange = (data: any) => {
|
||||
partialUpdateIssue({ labels_list: data }, issue);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (data: TIssuePriorities) => {
|
||||
partialUpdateIssue({ priority: data }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||
}, [snapshot, handleTrashBox]);
|
||||
@@ -343,13 +426,12 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-left break-words line-clamp-2"
|
||||
onClick={() => {
|
||||
if (isDraftIssue && handleDraftIssueEdit) handleDraftIssueEdit();
|
||||
else openPeekOverview();
|
||||
}}
|
||||
>
|
||||
{issue.name}
|
||||
<span className="text-sm text-left break-words line-clamp-2">{issue.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -359,21 +441,19 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
}`}
|
||||
>
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
user={user}
|
||||
selfPositioned
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
user={user}
|
||||
selfPositioned
|
||||
<StateSelect
|
||||
value={issue.state_detail}
|
||||
onChange={handleStateChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.start_date && issue.start_date && (
|
||||
@@ -397,16 +477,22 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewIssueLabel labelDetails={issue.label_details} maxRender={2} />
|
||||
<LabelSelect
|
||||
value={issue.labels}
|
||||
onChange={handleLabelChange}
|
||||
labelsDetails={issue.label_details}
|
||||
hideDropdownArrow
|
||||
user={user}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
customButton
|
||||
user={user}
|
||||
selfPositioned
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
|
||||
@@ -8,28 +8,23 @@ import { mutate } from "swr";
|
||||
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CustomMenu, Tooltip } from "components/ui";
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { LabelSelect, MembersSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
// icons
|
||||
import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// helper
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// type
|
||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types";
|
||||
import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, TIssuePriorities } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
@@ -153,6 +148,86 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue(
|
||||
{
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
},
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data }, issue);
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleLabelChange = (data: any) => {
|
||||
partialUpdateIssue({ labels_list: data }, issue);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (data: TIssuePriorities) => {
|
||||
partialUpdateIssue({ priority: data }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const displayProperties = properties
|
||||
? Object.values(properties).some((value) => value === true)
|
||||
: false;
|
||||
@@ -225,22 +300,19 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
{displayProperties && (
|
||||
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
className="max-w-full"
|
||||
isNotAllowed={isNotAllowed}
|
||||
user={user}
|
||||
<StateSelect
|
||||
value={issue.state_detail}
|
||||
onChange={handleStateChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.start_date && issue.start_date && (
|
||||
@@ -260,21 +332,23 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
<LabelSelect
|
||||
value={issue.labels}
|
||||
onChange={handleLabelChange}
|
||||
labelsDetails={issue.label_details}
|
||||
hideDropdownArrow
|
||||
maxRender={1}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
CreateUpdateIssueModal,
|
||||
DeleteIssueModal,
|
||||
DeleteDraftIssueModal,
|
||||
IssuePeekOverview,
|
||||
CreateUpdateDraftIssueModal,
|
||||
} from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
@@ -88,8 +87,16 @@ export const IssuesView: React.FC<Props> = ({
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { groupedByIssues, mutateIssues, displayFilters, filters, isEmpty, setFilters, params } =
|
||||
useIssuesView();
|
||||
const {
|
||||
groupedByIssues,
|
||||
mutateIssues,
|
||||
displayFilters,
|
||||
filters,
|
||||
isEmpty,
|
||||
setFilters,
|
||||
params,
|
||||
setDisplayFilters,
|
||||
} = useIssuesView();
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
@@ -109,6 +116,17 @@ export const IssuesView: React.FC<Props> = ({
|
||||
|
||||
const { members } = useProjectMembers(workspaceSlug?.toString(), projectId?.toString());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraftIssues) return;
|
||||
|
||||
if (
|
||||
displayFilters.layout === "calendar" ||
|
||||
displayFilters.layout === "gantt_chart" ||
|
||||
displayFilters.layout === "spreadsheet"
|
||||
)
|
||||
setDisplayFilters({ layout: "list" });
|
||||
}, [isDraftIssues, displayFilters, setDisplayFilters]);
|
||||
|
||||
const handleDeleteIssue = useCallback(
|
||||
(issue: IIssue) => {
|
||||
setDeleteIssueModal(true);
|
||||
@@ -484,15 +502,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
}
|
||||
: null
|
||||
}
|
||||
fieldsToShow={[
|
||||
"name",
|
||||
"description",
|
||||
"label",
|
||||
"assignee",
|
||||
"priority",
|
||||
"dueDate",
|
||||
"priority",
|
||||
]}
|
||||
fieldsToShow={["all"]}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||
|
||||
@@ -6,19 +6,13 @@ import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
CreateUpdateDraftIssueModal,
|
||||
} from "components/issues";
|
||||
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { LabelSelect, MembersSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
// ui
|
||||
import { Tooltip, CustomMenu, ContextMenu } from "components/ui";
|
||||
// icons
|
||||
@@ -40,8 +34,10 @@ import {
|
||||
ICurrentUserResponse,
|
||||
IIssue,
|
||||
IIssueViewProps,
|
||||
IState,
|
||||
ISubIssueResponse,
|
||||
IUserProfileProjectSegregation,
|
||||
TIssuePriorities,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
@@ -181,6 +177,86 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue(
|
||||
{
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
},
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data }, issue);
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleLabelChange = (data: any) => {
|
||||
partialUpdateIssue({ labels_list: data }, issue);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (data: TIssuePriorities) => {
|
||||
partialUpdateIssue({ priority: data }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const issuePath = isArchivedIssues
|
||||
? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
|
||||
: isDraftIssues
|
||||
@@ -290,21 +366,19 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
}`}
|
||||
>
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.state && (
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<StateSelect
|
||||
value={issue.state_detail}
|
||||
onChange={handleStateChange}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.start_date && issue.start_date && (
|
||||
@@ -323,14 +397,24 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && <ViewIssueLabel labelDetails={issue.label_details} maxRender={3} />}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
{properties.labels && (
|
||||
<LabelSelect
|
||||
value={issue.labels}
|
||||
onChange={handleLabelChange}
|
||||
labelsDetails={issue.label_details}
|
||||
hideDropdownArrow
|
||||
maxRender={3}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
|
||||
@@ -5,15 +5,9 @@ import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// components
|
||||
import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
import { ViewDueDateSelect, ViewEstimateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { LabelSelect, MembersSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
import { Popover2 } from "@blueprintjs/popover2";
|
||||
// icons
|
||||
import { Icon } from "components/ui";
|
||||
@@ -28,6 +22,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// constant
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
@@ -39,7 +34,15 @@ import {
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
|
||||
import {
|
||||
ICurrentUserResponse,
|
||||
IIssue,
|
||||
IState,
|
||||
ISubIssueResponse,
|
||||
Properties,
|
||||
TIssuePriorities,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// helper
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
|
||||
@@ -180,6 +183,86 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue(
|
||||
{
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
},
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePriorityChange = (data: TIssuePriorities) => {
|
||||
partialUpdateIssue({ priority: data }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
const newData = issue.assignees ?? [];
|
||||
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data }, issue);
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleLabelChange = (data: any) => {
|
||||
partialUpdateIssue({ labels_list: data }, issue);
|
||||
};
|
||||
|
||||
const paddingLeft = `${nestingLevel * 68}px`;
|
||||
|
||||
const tooltipPosition = index === 0 ? "bottom" : "top";
|
||||
@@ -283,47 +366,49 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
{properties.state && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewStateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
className="max-w-full"
|
||||
tooltipPosition={tooltipPosition}
|
||||
customButton
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<StateSelect
|
||||
value={issue.state_detail}
|
||||
onChange={handleStateChange}
|
||||
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.priority && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
tooltipPosition={tooltipPosition}
|
||||
noBorder
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
tooltipPosition={tooltipPosition}
|
||||
customButton
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
buttonClassName="!p-0 !rounded-none !shadow-none !border-0"
|
||||
hideDropdownArrow
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{properties.labels && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewIssueLabel labelDetails={issue.label_details} maxRender={1} />
|
||||
<LabelSelect
|
||||
value={issue.labels}
|
||||
onChange={handleLabelChange}
|
||||
labelsDetails={issue.label_details}
|
||||
hideDropdownArrow
|
||||
maxRender={1}
|
||||
user={user}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export const ActiveCycleDetails: React.FC = () => {
|
||||
cy="34.375"
|
||||
r="22"
|
||||
stroke="rgb(var(--color-text-400))"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
|
||||
|
||||
@@ -221,7 +221,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType })
|
||||
cy="34.375"
|
||||
r="22"
|
||||
stroke="rgb(var(--color-text-400))"
|
||||
stroke-linecap="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M36.4375 20.9919C36.4375 19.2528 37.6796 17.8127 39.1709 18.1419C40.125 18.3526 41.0604 18.6735 41.9625 19.1014C43.7141 19.9322 45.3057 21.1499 46.6464 22.685C47.987 24.2202 49.0505 26.0426 49.776 28.0484C50.5016 30.0541 50.875 32.2038 50.875 34.3748C50.875 36.5458 50.5016 38.6956 49.776 40.7013C49.0505 42.7071 47.987 44.5295 46.6464 46.0647C45.3057 47.5998 43.7141 48.8175 41.9625 49.6483C41.0604 50.0762 40.125 50.3971 39.1709 50.6077C37.6796 50.937 36.4375 49.4969 36.4375 47.7578L36.4375 20.9919Z"
|
||||
|
||||
@@ -46,32 +46,38 @@ const IntegrationGuide = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full space-y-2">
|
||||
<div className="h-full w-full">
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"
|
||||
className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6"
|
||||
>
|
||||
<div className="flex items-center gap-4 whitespace-nowrap">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={service.logo}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${service.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h3>{service.title}</h3>
|
||||
<p className="text-sm text-custom-text-200">{service.description}</p>
|
||||
<div className="flex items-start justify-between gap-4 w-full">
|
||||
<div className="flex item-center gap-2.5">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={service.logo}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${service.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-sm font-medium">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
<span className="capitalize">{service.type}</span> now
|
||||
<span className="capitalize">{service.type}</span>
|
||||
</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
@@ -80,10 +86,11 @@ const IntegrationGuide = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
|
||||
<h3 className="mb-2 flex gap-2 text-lg font-medium justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="">Previous Exports</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between pt-7 pb-3.5 border-b border-custom-border-200">
|
||||
<div className="flex gap-2 items-center">
|
||||
<h3 className="flex gap-2 text-xl font-medium">Previous Exports</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
|
||||
@@ -128,27 +135,31 @@ const IntegrationGuide = () => {
|
||||
<Icon iconName="keyboard_arrow_right" className="!text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</h3>
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{exporterServices?.results.map((service) => (
|
||||
<SingleExport key={service.id} service={service} refreshing={refreshing} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
<div>
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{exporterServices?.results.map((service) => (
|
||||
<SingleExport key={service.id} service={service} refreshing={refreshing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200 px-4 py-6">
|
||||
No previous export available.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="py-2 text-sm text-custom-text-200">No previous export available.</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{provider && (
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 py-3">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm">
|
||||
<span>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ModuleCancelledIcon: React.FC<Props> = ({
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_4052_100277)">
|
||||
<g clipPath="url(#clip0_4052_100277)">
|
||||
<path
|
||||
d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z"
|
||||
fill="#ef4444"
|
||||
|
||||
@@ -16,7 +16,7 @@ export const ModulePausedIcon: React.FC<Props> = ({ width = "20", height = "20",
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_4052_100275)">
|
||||
<g clipPath="url(#clip0_4052_100275)">
|
||||
<path
|
||||
d="M6.4435 10.34C6.6145 10.34 6.75667 10.2825 6.87 10.1675C6.98333 10.0525 7.04 9.91 7.04 9.74V6.24C7.04 6.07 6.98217 5.9275 6.8665 5.8125C6.75082 5.6975 6.60749 5.64 6.4365 5.64C6.2655 5.64 6.12333 5.6975 6.01 5.8125C5.89667 5.9275 5.84 6.07 5.84 6.24V9.74C5.84 9.91 5.89783 10.0525 6.0135 10.1675C6.12918 10.2825 6.27251 10.34 6.4435 10.34ZM9.5635 10.34C9.7345 10.34 9.87667 10.2825 9.99 10.1675C10.1033 10.0525 10.16 9.91 10.16 9.74V6.24C10.16 6.07 10.1022 5.9275 9.9865 5.8125C9.87082 5.6975 9.72749 5.64 9.5565 5.64C9.3855 5.64 9.24333 5.6975 9.13 5.8125C9.01667 5.9275 8.96 6.07 8.96 6.24V9.74C8.96 9.91 9.01783 10.0525 9.1335 10.1675C9.24918 10.2825 9.39251 10.34 9.5635 10.34ZM8 16C6.89333 16 5.85333 15.79 4.88 15.37C3.90667 14.95 3.06 14.38 2.34 13.66C1.62 12.94 1.05 12.0933 0.63 11.12C0.21 10.1467 0 9.10667 0 8C0 7.54667 0.0366667 7.09993 0.11 6.6598C0.183333 6.21965 0.293333 5.78639 0.44 5.36C0.493333 5.21333 0.593333 5.11667 0.74 5.07C0.886667 5.02333 1.02667 5.04199 1.16 5.12596C1.30285 5.20993 1.40523 5.33327 1.46714 5.49596C1.52905 5.65865 1.54 5.82 1.5 5.98C1.42 6.31333 1.35 6.64765 1.29 6.98294C1.23 7.31823 1.2 7.65725 1.2 8C1.2 9.89833 1.85875 11.5063 3.17624 12.8238C4.49375 14.1413 6.10167 14.8 8 14.8C9.89833 14.8 11.5063 14.1413 12.8238 12.8238C14.1413 11.5063 14.8 9.89833 14.8 8C14.8 6.10167 14.1413 4.49375 12.8238 3.17624C11.5063 1.85875 9.89833 1.2 8 1.2C7.63235 1.2 7.26852 1.22667 6.90852 1.28C6.54852 1.33333 6.19235 1.41333 5.84 1.52C5.68 1.57333 5.52 1.56667 5.36 1.5C5.2 1.43333 5.08667 1.32667 5.02 1.18C4.95333 1.03333 4.96 0.886667 5.04 0.74C5.12 0.593333 5.23333 0.493333 5.38 0.44C5.79333 0.306667 6.21333 0.2 6.64 0.12C7.06667 0.04 7.49333 0 7.92 0C9.02667 0 10.07 0.21 11.05 0.63C12.03 1.05 12.8863 1.62 13.6189 2.34C14.3516 3.06 14.9316 3.90667 15.3589 4.88C15.7863 5.85333 16 6.89333 16 8C16 9.10667 15.79 10.1467 15.37 11.12C14.95 12.0933 14.38 12.94 13.66 13.66C12.94 14.38 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM2.65764 3.62C2.37921 3.62 2.14333 3.52255 1.95 3.32764C1.75667 3.13275 1.66 2.89608 1.66 2.61764C1.66 2.33921 1.75745 2.10333 1.95236 1.91C2.14725 1.71667 2.38392 1.62 2.66236 1.62C2.94079 1.62 3.17667 1.71745 3.37 1.91236C3.56333 2.10725 3.66 2.34392 3.66 2.62236C3.66 2.90079 3.56255 3.13667 3.36764 3.33C3.17275 3.52333 2.93608 3.62 2.65764 3.62Z"
|
||||
fill="#525252"
|
||||
|
||||
@@ -19,6 +19,6 @@ export const StateGroupBacklogIcon: React.FC<Props> = ({
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="6" cy="6" r="5.6" stroke={color} stroke-width="0.8" stroke-dasharray="4 4" />
|
||||
<circle cx="6" cy="6" r="5.6" stroke={color} strokeWidth="0.8" strokeDasharray="4 4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export const StateGroupCancelledIcon: React.FC<Props> = ({
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_4052_100277)">
|
||||
<g clipPath="url(#clip0_4052_100277)">
|
||||
<path
|
||||
d="M8 8.84L10.58 11.42C10.7 11.54 10.84 11.6 11 11.6C11.16 11.6 11.3 11.54 11.42 11.42C11.54 11.3 11.6 11.16 11.6 11C11.6 10.84 11.54 10.7 11.42 10.58L8.84 8L11.42 5.42C11.54 5.3 11.6 5.16 11.6 5C11.6 4.84 11.54 4.7 11.42 4.58C11.3 4.46 11.16 4.4 11 4.4C10.84 4.4 10.7 4.46 10.58 4.58L8 7.16L5.42 4.58C5.3 4.46 5.16 4.4 5 4.4C4.84 4.4 4.7 4.46 4.58 4.58C4.46 4.7 4.4 4.84 4.4 5C4.4 5.16 4.46 5.3 4.58 5.42L7.16 8L4.58 10.58C4.46 10.7 4.4 10.84 4.4 11C4.4 11.16 4.46 11.3 4.58 11.42C4.7 11.54 4.84 11.6 5 11.6C5.16 11.6 5.3 11.54 5.42 11.42L8 8.84ZM8 16C6.90667 16 5.87333 15.79 4.9 15.37C3.92667 14.95 3.07667 14.3767 2.35 13.65C1.62333 12.9233 1.05 12.0733 0.63 11.1C0.21 10.1267 0 9.09333 0 8C0 6.89333 0.21 5.85333 0.63 4.88C1.05 3.90667 1.62333 3.06 2.35 2.34C3.07667 1.62 3.92667 1.05 4.9 0.63C5.87333 0.21 6.90667 0 8 0C9.10667 0 10.1467 0.21 11.12 0.63C12.0933 1.05 12.94 1.62 13.66 2.34C14.38 3.06 14.95 3.90667 15.37 4.88C15.79 5.85333 16 6.89333 16 8C16 9.09333 15.79 10.1267 15.37 11.1C14.95 12.0733 14.38 12.9233 13.66 13.65C12.94 14.3767 12.0933 14.95 11.12 15.37C10.1467 15.79 9.10667 16 8 16ZM8 14.8C9.89333 14.8 11.5 14.1367 12.82 12.81C14.14 11.4833 14.8 9.88 14.8 8C14.8 6.10667 14.14 4.5 12.82 3.18C11.5 1.86 9.89333 1.2 8 1.2C6.12 1.2 4.51667 1.86 3.19 3.18C1.86333 4.5 1.2 6.10667 1.2 8C1.2 9.88 1.86333 11.4833 3.19 12.81C4.51667 14.1367 6.12 14.8 8 14.8Z"
|
||||
fill={color}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const StateGroupStartedIcon: React.FC<Props> = ({
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
>
|
||||
<circle cx="6" cy="6" r="5.6" stroke={color} stroke-width="0.8" />
|
||||
<circle cx="6" cy="6" r="3.35" stroke={color} stroke-width="0.8" stroke-dasharray="2.4 2.4" />
|
||||
<circle cx="6" cy="6" r="5.6" stroke={color} strokeWidth="0.8" />
|
||||
<circle cx="6" cy="6" r="3.35" stroke={color} strokeWidth="0.8" strokeDasharray="2.4 2.4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,6 @@ export const StateGroupUnstartedIcon: React.FC<Props> = ({
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="8" cy="8" r="7.4" stroke={color} stroke-width="1.2" />
|
||||
<circle cx="8" cy="8" r="7.4" stroke={color} strokeWidth="1.2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { Loader, PrimaryButton } from "components/ui";
|
||||
// icons
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowRightIcon } from "components/icons";
|
||||
// types
|
||||
import { IImporterService } from "types";
|
||||
// fetch-keys
|
||||
@@ -57,10 +56,10 @@ const IntegrationGuide = () => {
|
||||
data={importToDelete}
|
||||
user={user}
|
||||
/>
|
||||
<div className="h-full space-y-2">
|
||||
<div className="h-full">
|
||||
{(!provider || provider === "csv") && (
|
||||
<>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
{/* <div className="mb-5 flex items-center gap-2">
|
||||
<div className="h-full w-full space-y-1">
|
||||
<div className="text-lg font-medium">Relocation Guide</div>
|
||||
<div className="text-sm">
|
||||
@@ -78,85 +77,87 @@ const IntegrationGuide = () => {
|
||||
<ArrowRightIcon width={"18px"} color={"#3F76FF"} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{IMPORTERS_EXPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 whitespace-nowrap">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={service.logo}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${service.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h3>{service.title}</h3>
|
||||
<p className="text-sm text-custom-text-200">{service.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}
|
||||
>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
<span className="capitalize">{service.type}</span> now
|
||||
</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div> */}
|
||||
{IMPORTERS_EXPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={service.logo}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${service.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-sm font-medium">{service.title}</h3>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
|
||||
<h3 className="mb-2 flex gap-2 text-lg font-medium">
|
||||
Previous Imports
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)).then(() =>
|
||||
setRefreshing(false)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? "Refreshing..." : "Refresh status"}
|
||||
</button>
|
||||
</h3>
|
||||
{importerServices ? (
|
||||
importerServices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{importerServices.map((service) => (
|
||||
<SingleImport
|
||||
key={service.id}
|
||||
service={service}
|
||||
refreshing={refreshing}
|
||||
handleDelete={() => handleDeleteImport(service)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
<span className="capitalize">{service.type}</span>
|
||||
</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center pt-7 pb-3.5 border-b border-custom-border-200">
|
||||
<h3 className="flex gap-2 text-xl font-medium">
|
||||
Previous Imports
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string)).then(() =>
|
||||
setRefreshing(false)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? "Refreshing..." : "Refresh status"}
|
||||
</button>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{importerServices ? (
|
||||
importerServices.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{importerServices.map((service) => (
|
||||
<SingleImport
|
||||
key={service.id}
|
||||
service={service}
|
||||
refreshing={refreshing}
|
||||
handleDelete={() => handleDeleteImport(service)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200 px-4 py-6">
|
||||
No previous imports available.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="py-2 text-sm text-custom-text-200">
|
||||
No previous imports available.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const SingleImport: React.FC<Props> = ({ service, refreshing, handleDelete }) => (
|
||||
<div className="flex items-center justify-between gap-2 py-3">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm">
|
||||
<span>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DangerButton, Loader, PrimaryButton } from "components/ui";
|
||||
// icons
|
||||
import GithubLogo from "public/services/github.png";
|
||||
import SlackLogo from "public/services/slack.png";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
// types
|
||||
import { IAppIntegration, IWorkspaceIntegration } from "types";
|
||||
// fetch-keys
|
||||
@@ -27,13 +28,12 @@ type Props = {
|
||||
const integrationDetails: { [key: string]: any } = {
|
||||
github: {
|
||||
logo: GithubLogo,
|
||||
installed:
|
||||
"Activate GitHub integrations on individual projects to sync with specific repositories.",
|
||||
installed: "Activate GitHub on individual projects to sync with specific repositories.",
|
||||
notInstalled: "Connect with GitHub with your Plane workspace to sync project issues.",
|
||||
},
|
||||
slack: {
|
||||
logo: SlackLogo,
|
||||
installed: "Activate Slack integrations on individual projects to sync with specific channels.",
|
||||
installed: "Activate Slack on individual projects to sync with specific channels.",
|
||||
notInstalled: "Connect with Slack with your Plane workspace to sync project issues.",
|
||||
},
|
||||
};
|
||||
@@ -99,31 +99,22 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-12 w-12 flex-shrink-0">
|
||||
<div className="h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={integrationDetails[integration.provider].logo}
|
||||
alt={`${integration.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-xl font-semibold">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
{integration.title}
|
||||
{workspaceIntegrations ? (
|
||||
isInstalled ? (
|
||||
<span className="flex items-center gap-1 text-sm font-normal text-green-500">
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500" /> Installed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-sm font-normal text-custom-text-200">
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-custom-background-80" />{" "}
|
||||
Not Installed
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
{workspaceIntegrations
|
||||
? isInstalled && <CheckCircle2 className="h-3.5 w-3.5 text-white fill-green-500" />
|
||||
: null}
|
||||
</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
{workspaceIntegrations
|
||||
? isInstalled
|
||||
? integrationDetails[integration.provider].installed
|
||||
@@ -135,12 +126,12 @@ export const SingleIntegrationCard: React.FC<Props> = ({ integration }) => {
|
||||
|
||||
{workspaceIntegrations ? (
|
||||
isInstalled ? (
|
||||
<DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration}>
|
||||
{deletingIntegration ? "Removing..." : "Remove installation"}
|
||||
<DangerButton onClick={handleRemoveIntegration} loading={deletingIntegration} outline>
|
||||
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
|
||||
</DangerButton>
|
||||
) : (
|
||||
<PrimaryButton onClick={startAuth} loading={isInstalling}>
|
||||
{isInstalling ? "Installing..." : "Add installation"}
|
||||
{isInstalling ? "Installing..." : "Install"}
|
||||
</PrimaryButton>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
data: IIssue | null;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
onSubmit?: () => Promise<void>;
|
||||
redirection?: boolean;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = ({
|
||||
@@ -43,6 +44,7 @@ export const DeleteIssueModal: React.FC<Props> = ({
|
||||
data,
|
||||
user,
|
||||
onSubmit,
|
||||
redirection = true,
|
||||
}) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
@@ -132,7 +134,7 @@ export const DeleteIssueModal: React.FC<Props> = ({
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
|
||||
if (issueId) router.back();
|
||||
if (issueId && redirection) router.back();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
||||
@@ -55,7 +55,10 @@ const defaultValues: Partial<IIssue> = {
|
||||
};
|
||||
|
||||
interface IssueFormProps {
|
||||
handleFormSubmit: (formData: Partial<IIssue>) => Promise<void>;
|
||||
handleFormSubmit: (
|
||||
formData: Partial<IIssue>,
|
||||
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
|
||||
) => Promise<void>;
|
||||
data?: Partial<IIssue> | null;
|
||||
prePopulatedData?: Partial<IIssue> | null;
|
||||
projectId: string;
|
||||
@@ -134,12 +137,16 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
|
||||
const handleCreateUpdateIssue = async (
|
||||
formData: Partial<IIssue>,
|
||||
action: "saveDraft" | "createToNewIssue" = "saveDraft"
|
||||
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
|
||||
) => {
|
||||
await handleFormSubmit({
|
||||
...formData,
|
||||
is_draft: action === "saveDraft",
|
||||
});
|
||||
await handleFormSubmit(
|
||||
{
|
||||
...(data ?? {}),
|
||||
...formData,
|
||||
is_draft: action === "createDraft" || action === "updateDraft",
|
||||
},
|
||||
action
|
||||
);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
@@ -263,7 +270,9 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit((formData) => handleCreateUpdateIssue(formData, "createToNewIssue"))}
|
||||
onSubmit={handleSubmit((formData) =>
|
||||
handleCreateUpdateIssue(formData, "convertToNewIssue")
|
||||
)}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
@@ -563,15 +572,20 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
<SecondaryButton onClick={onClose}>Discard</SecondaryButton>
|
||||
<SecondaryButton
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((formData) => handleCreateUpdateIssue(formData, "saveDraft"))}
|
||||
onClick={handleSubmit((formData) =>
|
||||
handleCreateUpdateIssue(formData, data?.id ? "updateDraft" : "createDraft")
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Draft"}
|
||||
</SecondaryButton>
|
||||
{data && (
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Add Issue"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
<PrimaryButton
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((formData) =>
|
||||
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createNewIssue")
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Add Issue"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
VIEW_ISSUES,
|
||||
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
||||
CYCLE_DETAILS,
|
||||
MODULE_DETAILS,
|
||||
} from "constants/fetch-keys";
|
||||
import modulesService from "services/modules.service";
|
||||
|
||||
interface IssuesModalProps {
|
||||
data?: IIssue | null;
|
||||
@@ -55,18 +59,21 @@ interface IssuesModalProps {
|
||||
onSubmit?: (data: Partial<IIssue>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
data,
|
||||
handleClose,
|
||||
isOpen,
|
||||
isUpdatingSingleIssue = false,
|
||||
prePopulateData,
|
||||
fieldsToShow = ["all"],
|
||||
onSubmit,
|
||||
}) => {
|
||||
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = (props) => {
|
||||
const {
|
||||
data,
|
||||
handleClose,
|
||||
isOpen,
|
||||
isUpdatingSingleIssue = false,
|
||||
prePopulateData: prePopulateDataProps,
|
||||
fieldsToShow = ["all"],
|
||||
onSubmit,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue> | undefined>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
@@ -79,23 +86,46 @@ 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();
|
||||
|
||||
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,
|
||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPreloadedData(prePopulateDataProps ?? {});
|
||||
|
||||
if (cycleId && !prePopulateDataProps?.cycle) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
cycle: cycleId.toString(),
|
||||
}));
|
||||
}
|
||||
if (moduleId && !prePopulateDataProps?.module) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
module: moduleId.toString(),
|
||||
}));
|
||||
}
|
||||
if (
|
||||
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
|
||||
!prePopulateDataProps?.assignees
|
||||
) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""],
|
||||
}));
|
||||
}
|
||||
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProject being set to some other project
|
||||
@@ -106,16 +136,19 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project) {
|
||||
setActiveProject(data.project);
|
||||
return;
|
||||
}
|
||||
if (data && data.project) return setActiveProject(data.project);
|
||||
|
||||
if (prePopulateData && prePopulateData.project && !activeProject)
|
||||
return setActiveProject(prePopulateData.project);
|
||||
|
||||
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)
|
||||
@@ -141,7 +174,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
? VIEW_ISSUES(viewId.toString(), viewGanttParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "");
|
||||
|
||||
const createIssue = async (payload: Partial<IIssue>) => {
|
||||
const createDraftIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !activeProject || !user) return;
|
||||
|
||||
await issuesService
|
||||
@@ -181,7 +214,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (!createMore) onClose();
|
||||
};
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
const updateDraftIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!user) return;
|
||||
|
||||
await issuesService
|
||||
@@ -197,6 +230,11 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
}
|
||||
|
||||
if (!payload.is_draft) {
|
||||
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
|
||||
}
|
||||
|
||||
if (!createMore) onClose();
|
||||
|
||||
setToastAlert({
|
||||
@@ -214,7 +252,93 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<IIssue>) => {
|
||||
const addIssueToCycle = async (issueId: string, cycleId: string) => {
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
await issuesService
|
||||
.addIssueToCycle(
|
||||
workspaceSlug as string,
|
||||
activeProject ?? "",
|
||||
cycleId,
|
||||
{
|
||||
issues: [issueId],
|
||||
},
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issueId: string, moduleId: string) => {
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
await modulesService
|
||||
.addIssuesToModule(
|
||||
workspaceSlug as string,
|
||||
activeProject ?? "",
|
||||
moduleId as string,
|
||||
{
|
||||
issues: [issueId],
|
||||
},
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
if (moduleId) {
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
await issuesService
|
||||
.createIssues(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async (res) => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module);
|
||||
|
||||
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
|
||||
if (displayFilters.layout === "gantt_chart")
|
||||
mutate(ganttFetchKey, {
|
||||
start_target_date: true,
|
||||
order_by: "sort_order",
|
||||
});
|
||||
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
if (groupedIssues) mutateMyIssues();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
if (!createMore) onClose();
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id))
|
||||
mutate(USER_ISSUE(workspaceSlug as string));
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (
|
||||
formData: Partial<IIssue>,
|
||||
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
|
||||
) => {
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
@@ -225,8 +349,12 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
|
||||
if (!data) await createIssue(payload);
|
||||
else await updateIssue(payload);
|
||||
if (action === "createDraft") await createDraftIssue(payload);
|
||||
else if (action === "updateDraft" || action === "convertToNewIssue")
|
||||
await updateDraftIssue(payload);
|
||||
else if (action === "createNewIssue") await createIssue(payload);
|
||||
|
||||
clearDraftIssueLocalStorage();
|
||||
|
||||
if (onSubmit) await onSubmit(payload);
|
||||
};
|
||||
|
||||
@@ -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,13 @@ 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"),
|
||||
cycle: getValues("cycle"),
|
||||
module: getValues("module"),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -571,8 +570,6 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
const data = JSON.stringify(getValues());
|
||||
setValueInLocalStorage(data);
|
||||
handleDiscardClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,6 @@ export * from "./main-content";
|
||||
export * from "./modal";
|
||||
export * from "./parent-issues-list-modal";
|
||||
export * from "./sidebar";
|
||||
export * from "./sub-issues-list";
|
||||
export * from "./label";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./peek-overview";
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
IssueAttachmentUpload,
|
||||
IssueAttachments,
|
||||
IssueDescriptionForm,
|
||||
SubIssuesList,
|
||||
IssueReaction,
|
||||
} from "components/issues";
|
||||
import { SubIssuesRoot } from "./sub-issues";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
@@ -43,7 +43,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
uneditable = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -206,7 +206,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
<IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} />
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
<SubIssuesList parentIssue={issueDetails} user={user} disabled={uneditable} />
|
||||
<SubIssuesRoot parentIssue={issueDetails} user={user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
|
||||
@@ -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
|
||||
@@ -68,7 +69,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
handleClose,
|
||||
isOpen,
|
||||
isUpdatingSingleIssue = false,
|
||||
prePopulateData,
|
||||
prePopulateData: prePopulateDataProps,
|
||||
fieldsToShow = ["all"],
|
||||
onSubmit,
|
||||
}) => {
|
||||
@@ -77,6 +78,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
const [formDirtyState, setFormDirtyState] = useState<any>(null);
|
||||
const [showConfirmDiscard, setShowConfirmDiscard] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
const [prePopulateData, setPreloadedData] = useState<Partial<IIssue>>({});
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query;
|
||||
@@ -92,17 +94,70 @@ 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,
|
||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||
};
|
||||
useEffect(() => {
|
||||
setPreloadedData(prePopulateDataProps ?? {});
|
||||
|
||||
if (cycleId && !prePopulateDataProps?.cycle) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
cycle: cycleId.toString(),
|
||||
}));
|
||||
}
|
||||
if (moduleId && !prePopulateDataProps?.module) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
module: moduleId.toString(),
|
||||
}));
|
||||
}
|
||||
if (
|
||||
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
|
||||
!prePopulateDataProps?.assignees
|
||||
) {
|
||||
setPreloadedData((prevData) => ({
|
||||
...(prevData ?? {}),
|
||||
...prePopulateDataProps,
|
||||
assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""],
|
||||
}));
|
||||
}
|
||||
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]);
|
||||
|
||||
/**
|
||||
*
|
||||
* @description This function is used to close the modals. This function will show a confirm discard modal if the form is dirty.
|
||||
* @returns void
|
||||
*/
|
||||
|
||||
const onClose = () => {
|
||||
if (!showConfirmDiscard) handleClose();
|
||||
if (formDirtyState === null) return setActiveProject(null);
|
||||
const data = JSON.stringify(formDirtyState);
|
||||
setValueInLocalStorage(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This function is used to close the modals. This function is to be used when the form is submitted,
|
||||
* meaning we don't need to show the confirm discard modal or store the form data in local storage.
|
||||
*/
|
||||
|
||||
const onFormSubmitClose = () => {
|
||||
setFormDirtyState(null);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This function is used to close the modals. This function is to be used when we click outside the modal,
|
||||
* meaning we don't need to show the confirm discard modal but will store the form data in local storage.
|
||||
* Use this function when you want to store the form data in local storage.
|
||||
*/
|
||||
|
||||
const onDiscardClose = () => {
|
||||
if (formDirtyState !== null) {
|
||||
setShowConfirmDiscard(true);
|
||||
} else {
|
||||
@@ -111,11 +166,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onDiscardClose = () => {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
};
|
||||
|
||||
const handleFormDirty = (data: any) => {
|
||||
setFormDirtyState(data);
|
||||
};
|
||||
@@ -291,7 +341,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
if (!createMore) onDiscardClose();
|
||||
if (!createMore) onFormSubmitClose();
|
||||
};
|
||||
|
||||
const createDraftIssue = async () => {
|
||||
@@ -350,7 +400,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
|
||||
|
||||
if (!createMore) onDiscardClose();
|
||||
if (!createMore) onFormSubmitClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@@ -397,6 +447,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
setActiveProject(null);
|
||||
setFormDirtyState(null);
|
||||
setShowConfirmDiscard(false);
|
||||
clearLocalStorageValue();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -431,9 +482,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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import { FC, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
import { CreateUpdateIssueModal } from "components/issues";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
parentIssue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }) => {
|
||||
// states
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
|
||||
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { data: subIssuesResponse } = useSWR(
|
||||
workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null,
|
||||
workspaceSlug && parentIssue
|
||||
? () => issuesService.subIssues(workspaceSlug as string, parentIssue.project, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const addAsSubIssue = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !parentIssue) return;
|
||||
|
||||
const payload = {
|
||||
sub_issue_ids: data.map((i) => i.id),
|
||||
};
|
||||
|
||||
await issuesService
|
||||
.addSubIssues(workspaceSlug as string, parentIssue.project, parentIssue.id, payload)
|
||||
.finally(() => mutate(SUB_ISSUES(parentIssue.id)));
|
||||
};
|
||||
|
||||
const handleSubIssueRemove = (issue: IIssue) => {
|
||||
if (!workspaceSlug || !parentIssue) return;
|
||||
|
||||
mutate<ISubIssueResponse>(
|
||||
SUB_ISSUES(parentIssue.id),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const stateDistribution = { ...prevData.state_distribution };
|
||||
|
||||
const issueGroup = issue.state_detail.group;
|
||||
stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1;
|
||||
|
||||
return {
|
||||
state_distribution: stateDistribution,
|
||||
sub_issues: prevData.sub_issues.filter((i) => i.id !== issue.id),
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user)
|
||||
.finally(() => mutate(SUB_ISSUES(parentIssue.id)));
|
||||
};
|
||||
|
||||
const handleCreateIssueModal = () => {
|
||||
setCreateIssueModal(true);
|
||||
|
||||
setPreloadedData({
|
||||
parent: parentIssue.id,
|
||||
});
|
||||
};
|
||||
|
||||
const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0;
|
||||
const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0;
|
||||
|
||||
const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue;
|
||||
|
||||
const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0;
|
||||
|
||||
const completionPercentage = (totalCompletedSubIssues / totalSubIssues) * 100;
|
||||
|
||||
const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={createIssueModal}
|
||||
prePopulateData={{ ...preloadedData }}
|
||||
handleClose={() => setCreateIssueModal(false)}
|
||||
/>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={subIssuesListModal}
|
||||
handleClose={() => setSubIssuesListModal(false)}
|
||||
searchParams={{ sub_issue: true, issue_id: parentIssue?.id }}
|
||||
handleOnSubmit={addAsSubIssue}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
{subIssuesResponse && subIssuesResponse.sub_issues.length > 0 ? (
|
||||
<Disclosure defaultOpen={true}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-start gap-3 text-custom-text-200">
|
||||
<Disclosure.Button className="flex items-center gap-1 rounded px-2 py-1 text-xs text-custom-text-100 hover:bg-custom-background-80">
|
||||
<ChevronRightIcon className={`h-3 w-3 ${open ? "rotate-90" : ""}`} />
|
||||
Sub-issues{" "}
|
||||
<span className="ml-1 text-custom-text-200">
|
||||
{subIssuesResponse.sub_issues.length}
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
<div className="flex w-60 items-center gap-2">
|
||||
<div className="bar relative h-1.5 w-full rounded bg-custom-background-80">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-1.5 rounded bg-green-500 duration-300"
|
||||
style={{
|
||||
width: `${
|
||||
isNaN(completionPercentage)
|
||||
? 0
|
||||
: completionPercentage > 100
|
||||
? 100
|
||||
: completionPercentage.toFixed(0)
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{isNaN(completionPercentage)
|
||||
? 0
|
||||
: completionPercentage > 100
|
||||
? 100
|
||||
: completionPercentage.toFixed(0)}
|
||||
% Done
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && !isNotAllowed ? (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80"
|
||||
onClick={handleCreateIssueModal}
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Create new
|
||||
</button>
|
||||
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="mt-3 flex flex-col gap-y-1">
|
||||
{subIssuesResponse.sub_issues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}
|
||||
>
|
||||
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-90">
|
||||
<div className="flex items-center gap-2 rounded text-xs">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
<span className="max-w-sm break-words font-medium">{issue.name}</span>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubIssueRemove(issue);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
) : (
|
||||
!isNotAllowed && (
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add sub-issue
|
||||
</>
|
||||
}
|
||||
buttonClassName="whitespace-nowrap"
|
||||
position="left"
|
||||
noBorder
|
||||
noChevron
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={handleCreateIssueModal}>Create new</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => setSubIssuesListModal(true)}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
web/components/issues/sub-issues/index.ts
Normal file
1
web/components/issues/sub-issues/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
188
web/components/issues/sub-issues/issue.tsx
Normal file
188
web/components/issues/sub-issues/issue.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
// lucide icons
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
X,
|
||||
Pencil,
|
||||
Trash,
|
||||
Link as LinkIcon,
|
||||
Loader,
|
||||
} from "lucide-react";
|
||||
// components
|
||||
import { SubIssuesRootList } from "./issues-list";
|
||||
import { IssueProperty } from "./properties";
|
||||
// ui
|
||||
import { Tooltip, CustomMenu } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
|
||||
export interface ISubIssues {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
issue: any;
|
||||
spacingLeft?: number;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void;
|
||||
issuesLoader: ISubIssuesRootLoaders;
|
||||
handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void;
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
issueId: string,
|
||||
issue?: IIssue | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const SubIssues: React.FC<ISubIssues> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
issue,
|
||||
spacingLeft = 0,
|
||||
user,
|
||||
editable,
|
||||
removeIssueFromSubIssues,
|
||||
issuesLoader,
|
||||
handleIssuesLoader,
|
||||
copyText,
|
||||
handleIssueCrudOperation,
|
||||
}) => (
|
||||
<div>
|
||||
{issue && (
|
||||
<div
|
||||
className="relative flex items-center gap-2 py-1 px-2 w-full h-full hover:bg-custom-background-90 group transition-all border-b border-custom-border-100"
|
||||
style={{ paddingLeft: `${spacingLeft}px` }}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[22px] h-[22px]">
|
||||
{issue?.sub_issues_count > 0 && (
|
||||
<>
|
||||
{issuesLoader.sub_issues.includes(issue?.id) ? (
|
||||
<div className="w-full h-full flex justify-center items-center rounded-sm bg-custom-background-80 transition-all cursor-not-allowed">
|
||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex justify-center items-center rounded-sm hover:bg-custom-background-80 transition-all cursor-pointer"
|
||||
onClick={() => handleIssuesLoader({ key: "visibility", issueId: issue?.id })}
|
||||
>
|
||||
{issuesLoader && issuesLoader.visibility.includes(issue?.id) ? (
|
||||
<ChevronDown width={14} strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight width={14} strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="w-full flex items-center gap-2">
|
||||
<div
|
||||
className="flex-shrink-0 w-[6px] h-[6px] rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state_detail.color,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={`${issue?.name}`}>
|
||||
<div className="line-clamp-1 text-xs text-custom-text-100 pr-2">{issue?.name}</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<IssueProperty
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
issue={issue}
|
||||
user={user}
|
||||
editable={editable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-sm">
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
{editable && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => handleIssueCrudOperation("edit", parentIssue?.id, issue)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Pencil width={14} strokeWidth={2} />
|
||||
<span>Edit issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => handleIssueCrudOperation("delete", parentIssue?.id, issue)}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Trash width={14} strokeWidth={2} />
|
||||
<span>Delete issue</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
copyText(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon width={14} strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<>
|
||||
{issuesLoader.delete.includes(issue?.id) ? (
|
||||
<div className="flex-shrink-0 w-[22px] h-[22px] rounded-sm bg-red-200/10 text-red-500 transition-all cursor-not-allowed overflow-hidden flex justify-center items-center">
|
||||
<Loader width={14} strokeWidth={2} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-shrink-0 invisible group-hover:visible w-[22px] h-[22px] rounded-sm hover:bg-custom-background-80 transition-all cursor-pointer overflow-hidden flex justify-center items-center"
|
||||
onClick={() => {
|
||||
handleIssuesLoader({ key: "delete", issueId: issue?.id });
|
||||
removeIssueFromSubIssues(parentIssue?.id, issue);
|
||||
}}
|
||||
>
|
||||
<X width={14} strokeWidth={2} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && (
|
||||
<SubIssuesRootList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={issue}
|
||||
spacingLeft={spacingLeft + 22}
|
||||
user={user}
|
||||
editable={editable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesLoader={issuesLoader}
|
||||
handleIssuesLoader={handleIssuesLoader}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
95
web/components/issues/sub-issues/issues-list.tsx
Normal file
95
web/components/issues/sub-issues/issues-list.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { SubIssues } from "./issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// fetch keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface ISubIssuesRootList {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
spacingLeft?: number;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void;
|
||||
issuesLoader: ISubIssuesRootLoaders;
|
||||
handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void;
|
||||
copyText: (text: string) => void;
|
||||
handleIssueCrudOperation: (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
issueId: string,
|
||||
issue?: IIssue | null
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const SubIssuesRootList: React.FC<ISubIssuesRootList> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
spacingLeft = 10,
|
||||
user,
|
||||
editable,
|
||||
removeIssueFromSubIssues,
|
||||
issuesLoader,
|
||||
handleIssuesLoader,
|
||||
copyText,
|
||||
handleIssueCrudOperation,
|
||||
}) => {
|
||||
const { data: issues, isLoading } = useSWR(
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? SUB_ISSUES(parentIssue?.id)
|
||||
: null,
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id });
|
||||
} else {
|
||||
if (issuesLoader.sub_issues.includes(parentIssue?.id)) {
|
||||
handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id });
|
||||
}
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{issues &&
|
||||
issues.sub_issues &&
|
||||
issues.sub_issues.length > 0 &&
|
||||
issues.sub_issues.map((issue: IIssue) => (
|
||||
<SubIssues
|
||||
key={`${issue?.id}`}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
issue={issue}
|
||||
spacingLeft={spacingLeft}
|
||||
user={user}
|
||||
editable={editable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesLoader={issuesLoader}
|
||||
handleIssuesLoader={handleIssuesLoader}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 ${
|
||||
spacingLeft > 10 ? `border-l border-custom-border-100` : ``
|
||||
}`}
|
||||
style={{ left: `${spacingLeft - 12}px` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
web/components/issues/sub-issues/progressbar.tsx
Normal file
25
web/components/issues/sub-issues/progressbar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface IProgressBar {
|
||||
total: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => {
|
||||
const calPercentage = (doneValue: number, totalValue: number): string => {
|
||||
if (doneValue === 0 || totalValue === 0) return (0).toFixed(0);
|
||||
return ((100 * doneValue) / totalValue).toFixed(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="w-full">
|
||||
<div className="w-full rounded-full bg-custom-background-80 overflow-hidden shadow">
|
||||
<div
|
||||
className="bg-green-500 h-[6px] rounded-full transition-all"
|
||||
style={{ width: `${calPercentage(done, total)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs font-medium">{calPercentage(done, total)}% Done</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
206
web/components/issues/sub-issues/properties.tsx
Normal file
206
web/components/issues/sub-issues/properties.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from "react";
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
// components
|
||||
import { ViewDueDateSelect, ViewStartDateSelect } from "components/issues";
|
||||
import { MembersSelect, PrioritySelect } from "components/project";
|
||||
import { StateSelect } from "components/states";
|
||||
// hooks
|
||||
import useIssuesProperties from "hooks/use-issue-properties";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IState } from "types";
|
||||
// fetch-keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
|
||||
export interface IIssueProperty {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
parentIssue: IIssue;
|
||||
issue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export const IssueProperty: React.FC<IIssueProperty> = ({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
parentIssue,
|
||||
issue,
|
||||
user,
|
||||
editable,
|
||||
}) => {
|
||||
const [properties] = useIssuesProperties(workspaceSlug, projectId);
|
||||
|
||||
const handlePriorityChange = (data: any) => {
|
||||
partialUpdateIssue({ priority: data });
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_PRIORITY",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const handleStateChange = (data: string, states: IState[] | undefined) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue({
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
});
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssigneeChange = (data: any) => {
|
||||
let newData = issue.assignees ?? [];
|
||||
|
||||
if (newData && newData.length > 0) {
|
||||
if (newData.includes(data)) newData = newData.splice(newData.indexOf(data), 1);
|
||||
else newData = [...newData, data];
|
||||
} else newData = [...newData, data];
|
||||
|
||||
partialUpdateIssue({ assignees_list: data, assignees: data });
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
|
||||
user
|
||||
);
|
||||
};
|
||||
|
||||
const partialUpdateIssue = async (data: Partial<IIssue>) => {
|
||||
mutate(
|
||||
workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null,
|
||||
(elements: any) => {
|
||||
const _elements = { ...elements };
|
||||
const _issues = _elements.sub_issues.map((element: IIssue) =>
|
||||
element.id === issue.id ? { ...element, ...data } : element
|
||||
);
|
||||
_elements["sub_issues"] = [..._issues];
|
||||
return _elements;
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const issueResponse = await issuesService.patchIssue(
|
||||
workspaceSlug as string,
|
||||
issue.project,
|
||||
issue.id,
|
||||
data,
|
||||
user
|
||||
);
|
||||
|
||||
mutate(
|
||||
SUB_ISSUES(parentIssue.id),
|
||||
(elements: any) => {
|
||||
const _elements = elements.sub_issues.map((element: IIssue) =>
|
||||
element.id === issue.id ? issueResponse : element
|
||||
);
|
||||
elements["sub_issues"] = _elements;
|
||||
return elements;
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1">
|
||||
{properties.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={handlePriorityChange}
|
||||
hideDropdownArrow
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.state && (
|
||||
<div className="flex-shrink-0">
|
||||
<StateSelect
|
||||
value={issue.state_detail}
|
||||
onChange={handleStateChange}
|
||||
hideDropdownArrow
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.start_date && issue.start_date && (
|
||||
<div className="flex-shrink-0 w-[104px]">
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.due_date && issue.target_date && (
|
||||
<div className="flex-shrink-0 w-[104px]">
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
user={user}
|
||||
isNotAllowed={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.assignee && (
|
||||
<div className="flex-shrink-0">
|
||||
<MembersSelect
|
||||
value={issue.assignees}
|
||||
onChange={handleAssigneeChange}
|
||||
membersDetails={issue.assignee_details}
|
||||
hideDropdownArrow
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
328
web/components/issues/sub-issues/root.tsx
Normal file
328
web/components/issues/sub-issues/root.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React from "react";
|
||||
// next imports
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR, { mutate } from "swr";
|
||||
// lucide icons
|
||||
import { Plus, ChevronRight, ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { SubIssuesRootList } from "./issues-list";
|
||||
import { ProgressBar } from "./progressbar";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
// hooks
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
import useToast from "hooks/use-toast";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// fetch keys
|
||||
import { SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
export interface ISubIssuesRoot {
|
||||
parentIssue: IIssue;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
}
|
||||
|
||||
export interface ISubIssuesRootLoaders {
|
||||
visibility: string[];
|
||||
delete: string[];
|
||||
sub_issues: string[];
|
||||
}
|
||||
export interface ISubIssuesRootLoadersHandler {
|
||||
key: "visibility" | "delete" | "sub_issues";
|
||||
issueId: string;
|
||||
}
|
||||
|
||||
export const SubIssuesRoot: React.FC<ISubIssuesRoot> = ({ parentIssue, user }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: issues, isLoading } = useSWR(
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? SUB_ISSUES(parentIssue?.id)
|
||||
: null,
|
||||
workspaceSlug && projectId && parentIssue && parentIssue?.id
|
||||
? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id)
|
||||
: null
|
||||
);
|
||||
|
||||
const [issuesLoader, setIssuesLoader] = React.useState<ISubIssuesRootLoaders>({
|
||||
visibility: [parentIssue?.id],
|
||||
delete: [],
|
||||
sub_issues: [],
|
||||
});
|
||||
const handleIssuesLoader = ({ key, issueId }: ISubIssuesRootLoadersHandler) => {
|
||||
setIssuesLoader((previousData: ISubIssuesRootLoaders) => ({
|
||||
...previousData,
|
||||
[key]: previousData[key].includes(issueId)
|
||||
? previousData[key].filter((i: string) => i !== issueId)
|
||||
: [...previousData[key], issueId],
|
||||
}));
|
||||
};
|
||||
|
||||
const [issueCrudOperation, setIssueCrudOperation] = React.useState<{
|
||||
create: { toggle: boolean; issueId: string | null };
|
||||
existing: { toggle: boolean; issueId: string | null };
|
||||
edit: { toggle: boolean; issueId: string | null; issue: IIssue | null };
|
||||
delete: { toggle: boolean; issueId: string | null; issue: IIssue | null };
|
||||
}>({
|
||||
create: {
|
||||
toggle: false,
|
||||
issueId: null,
|
||||
},
|
||||
existing: {
|
||||
toggle: false,
|
||||
issueId: null,
|
||||
},
|
||||
edit: {
|
||||
toggle: false,
|
||||
issueId: null, // parent issue id for mutation
|
||||
issue: null,
|
||||
},
|
||||
delete: {
|
||||
toggle: false,
|
||||
issueId: null, // parent issue id for mutation
|
||||
issue: null,
|
||||
},
|
||||
});
|
||||
const handleIssueCrudOperation = (
|
||||
key: "create" | "existing" | "edit" | "delete",
|
||||
issueId: string | null,
|
||||
issue: IIssue | null = null
|
||||
) => {
|
||||
setIssueCrudOperation({
|
||||
...issueCrudOperation,
|
||||
[key]: {
|
||||
toggle: !issueCrudOperation[key].toggle,
|
||||
issueId: issueId,
|
||||
issue: issue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addAsSubIssueFromExistingIssues = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !parentIssue || issueCrudOperation?.existing?.issueId === null) return;
|
||||
const issueId = issueCrudOperation?.existing?.issueId;
|
||||
const payload = {
|
||||
sub_issue_ids: data.map((i) => i.id),
|
||||
};
|
||||
await issuesService.addSubIssues(workspaceSlug, projectId, issueId, payload).finally(() => {
|
||||
if (issueId) mutate(SUB_ISSUES(issueId));
|
||||
});
|
||||
};
|
||||
|
||||
const removeIssueFromSubIssues = async (parentIssueId: string, issue: IIssue) => {
|
||||
if (!workspaceSlug || !parentIssue || !issue?.id) return;
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user)
|
||||
.then(async () => {
|
||||
if (parentIssueId) await mutate(SUB_ISSUES(parentIssueId));
|
||||
handleIssuesLoader({ key: "delete", issueId: issue?.id });
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: `Issue removed!`,
|
||||
message: `Issue removed successfully.`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
handleIssuesLoader({ key: "delete", issueId: issue?.id });
|
||||
setToastAlert({
|
||||
type: "warning",
|
||||
title: `Error!`,
|
||||
message: `Error, Please try again later.`,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const copyText = (text: string) => {
|
||||
const originURL =
|
||||
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${text}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const isEditable = memberRole?.isGuest || memberRole?.isViewer ? false : true;
|
||||
|
||||
const mutateSubIssues = (parentIssueId: string | null) => {
|
||||
if (parentIssueId) mutate(SUB_ISSUES(parentIssueId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full space-y-2">
|
||||
{!issues && isLoading ? (
|
||||
<div className="py-3 text-center text-sm text-custom-text-300 font-medium">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? (
|
||||
<>
|
||||
{/* header */}
|
||||
<div className="relative flex items-center gap-4 text-xs">
|
||||
<div
|
||||
className="rounded border border-custom-border-100 shadow p-1.5 px-2 flex items-center gap-1 hover:bg-custom-background-80 transition-all cursor-pointer select-none"
|
||||
onClick={() =>
|
||||
handleIssuesLoader({ key: "visibility", issueId: parentIssue?.id })
|
||||
}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[16px] h-[16px] flex justify-center items-center">
|
||||
{issuesLoader.visibility.includes(parentIssue?.id) ? (
|
||||
<ChevronDown width={16} strokeWidth={2} />
|
||||
) : (
|
||||
<ChevronRight width={14} strokeWidth={2} />
|
||||
)}
|
||||
</div>
|
||||
<div>Sub-issues</div>
|
||||
<div>({issues?.sub_issues?.length || 0})</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-[250px] select-none">
|
||||
<ProgressBar
|
||||
total={issues?.sub_issues?.length || 0}
|
||||
done={
|
||||
(issues?.state_distribution?.cancelled || 0) +
|
||||
(issues?.state_distribution?.completed || 0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditable && issuesLoader.visibility.includes(parentIssue?.id) && (
|
||||
<div className="ml-auto flex-shrink-0 flex items-center gap-2 select-none">
|
||||
<div
|
||||
className="hover:bg-custom-background-80 transition-all cursor-pointer p-1.5 px-2 rounded border border-custom-border-100 shadow"
|
||||
onClick={() => handleIssueCrudOperation("create", parentIssue?.id)}
|
||||
>
|
||||
Add sub-issue
|
||||
</div>
|
||||
<div
|
||||
className="hover:bg-custom-background-80 transition-all cursor-pointer p-1.5 px-2 rounded border border-custom-border-100 shadow"
|
||||
onClick={() => handleIssueCrudOperation("existing", parentIssue?.id)}
|
||||
>
|
||||
Add an existing issue
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* issues */}
|
||||
{issuesLoader.visibility.includes(parentIssue?.id) && (
|
||||
<div className="border border-b-0 border-custom-border-100">
|
||||
<SubIssuesRootList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
parentIssue={parentIssue}
|
||||
user={undefined}
|
||||
editable={isEditable}
|
||||
removeIssueFromSubIssues={removeIssueFromSubIssues}
|
||||
issuesLoader={issuesLoader}
|
||||
handleIssuesLoader={handleIssuesLoader}
|
||||
copyText={copyText}
|
||||
handleIssueCrudOperation={handleIssueCrudOperation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
isEditable && (
|
||||
<div className="text-xs py-2 text-custom-text-300 font-medium">
|
||||
<div className="py-3 text-center">No sub issues are available</div>
|
||||
<>
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add sub-issue
|
||||
</>
|
||||
}
|
||||
buttonClassName="whitespace-nowrap"
|
||||
position="left"
|
||||
noBorder
|
||||
noChevron
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("create", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
mutateSubIssues(parentIssue?.id);
|
||||
handleIssueCrudOperation("existing", parentIssue?.id);
|
||||
}}
|
||||
>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{isEditable && issueCrudOperation?.create?.toggle && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.create?.toggle}
|
||||
prePopulateData={{
|
||||
parent: issueCrudOperation?.create?.issueId,
|
||||
}}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.create?.issueId);
|
||||
handleIssueCrudOperation("create", null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEditable &&
|
||||
issueCrudOperation?.existing?.toggle &&
|
||||
issueCrudOperation?.existing?.issueId && (
|
||||
<ExistingIssuesListModal
|
||||
isOpen={issueCrudOperation?.existing?.toggle}
|
||||
handleClose={() => handleIssueCrudOperation("existing", null)}
|
||||
searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }}
|
||||
handleOnSubmit={addAsSubIssueFromExistingIssues}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
{isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudOperation?.edit?.toggle}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.edit?.issueId);
|
||||
handleIssueCrudOperation("edit", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.edit?.issue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isEditable &&
|
||||
issueCrudOperation?.delete?.toggle &&
|
||||
issueCrudOperation?.delete?.issueId && (
|
||||
<DeleteIssueModal
|
||||
isOpen={issueCrudOperation?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
mutateSubIssues(issueCrudOperation?.delete?.issueId);
|
||||
handleIssueCrudOperation("delete", null, null);
|
||||
}}
|
||||
data={issueCrudOperation?.delete?.issue}
|
||||
user={user}
|
||||
redirection={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,5 +3,4 @@ export * from "./due-date";
|
||||
export * from "./estimate";
|
||||
export * from "./label";
|
||||
export * from "./priority";
|
||||
export * from "./start-date";
|
||||
export * from "./state";
|
||||
export * from "./start-date";
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// ui
|
||||
import { CustomSearchSelect, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { StateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// fetch-keys
|
||||
import { STATES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
position?: "left" | "right";
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
className?: string;
|
||||
selfPositioned?: boolean;
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
|
||||
export const ViewStateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
tooltipPosition = "top",
|
||||
className = "",
|
||||
selfPositioned = false,
|
||||
customButton = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
const [fetchStates, setFetchStates] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && issue && fetchStates ? STATES_LIST(issue.project) : null,
|
||||
workspaceSlug && issue && fetchStates
|
||||
? () => stateService.getStates(workspaceSlug as string, issue.project)
|
||||
: null
|
||||
);
|
||||
const states = getStatesList(stateGroups);
|
||||
|
||||
const options = states?.map((state) => ({
|
||||
value: state.id,
|
||||
query: state.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
{state.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const selectedOption = issue.state_detail;
|
||||
|
||||
const stateLabel = (
|
||||
<Tooltip
|
||||
tooltipHeading="State"
|
||||
tooltipContent={selectedOption?.name ?? ""}
|
||||
position={tooltipPosition}
|
||||
>
|
||||
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
|
||||
<span className="h-3.5 w-3.5">
|
||||
{selectedOption && (
|
||||
<StateGroupIcon stateGroup={selectedOption.group} color={selectedOption.color} />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{selectedOption?.name ?? "State"}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
className={className}
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
const oldState = states?.find((s) => s.id === issue.state);
|
||||
const newState = states?.find((s) => s.id === data);
|
||||
|
||||
partialUpdateIssue(
|
||||
{
|
||||
state: data,
|
||||
state_detail: newState,
|
||||
},
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
workspaceId: issue.workspace,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
"ISSUE_PROPERTY_UPDATE_STATE",
|
||||
user
|
||||
);
|
||||
|
||||
if (oldState?.group !== "completed" && newState?.group !== "completed") {
|
||||
trackEventServices.trackIssueMarkedAsDoneEvent(
|
||||
{
|
||||
workspaceSlug: issue.workspace_detail.slug,
|
||||
workspaceId: issue.workspace_detail.id,
|
||||
projectId: issue.project_detail.id,
|
||||
projectIdentifier: issue.project_detail.identifier,
|
||||
projectName: issue.project_detail.name,
|
||||
issueId: issue.id,
|
||||
},
|
||||
user
|
||||
);
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
{...(customButton ? { customButton: stateLabel } : { label: stateLabel })}
|
||||
position={position}
|
||||
disabled={isNotAllowed}
|
||||
onOpen={() => setFetchStates(true)}
|
||||
noChevron
|
||||
selfPositioned={selfPositioned}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -16,8 +16,6 @@ import { Popover, Transition } from "@headlessui/react";
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { Component } from "lucide-react";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
@@ -146,10 +144,10 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<Component
|
||||
className="h-4 w-4 text-custom-text-100 flex-shrink-0"
|
||||
<span
|
||||
className="h-4 w-4 rounded-full"
|
||||
style={{
|
||||
color: watch("color"),
|
||||
backgroundColor: watch("color"),
|
||||
}}
|
||||
/>
|
||||
</Popover.Button>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SingleLabel: React.FC<Props> = ({
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
<Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
|
||||
<span>Convert to group</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
@@ -51,7 +51,6 @@ export const ProfileIssuesView = () => {
|
||||
groupedIssues,
|
||||
mutateProfileIssues,
|
||||
displayFilters,
|
||||
setDisplayFilters,
|
||||
isEmpty,
|
||||
filters,
|
||||
setFilters,
|
||||
|
||||
@@ -79,7 +79,7 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 bg-custom-background-90 p-4 sm:px-6">
|
||||
<div className="flex justify-end gap-2 p-4 sm:px-6">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDeletion} loading={isDeleteLoading}>
|
||||
{isDeleteLoading ? "Removing..." : "Remove"}
|
||||
|
||||
@@ -7,3 +7,6 @@ export * from "./single-project-card";
|
||||
export * from "./single-sidebar-project";
|
||||
export * from "./confirm-project-leave-modal";
|
||||
export * from "./member-select";
|
||||
export * from "./members-select";
|
||||
export * from "./label-select";
|
||||
export * from "./priority-select";
|
||||
|
||||
243
web/components/project/label-select.tsx
Normal file
243
web/components/project/label-select.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
|
||||
// headless ui
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// component
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// types
|
||||
import { Tooltip } from "components/ui";
|
||||
import { ICurrentUserResponse, IIssueLabels } from "types";
|
||||
// constants
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
value: string[];
|
||||
onChange: (data: any) => void;
|
||||
labelsDetails: any[];
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
optionsClassName?: string;
|
||||
maxRender?: number;
|
||||
hideDropdownArrow?: boolean;
|
||||
disabled?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
export const LabelSelect: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
labelsDetails,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
optionsClassName = "",
|
||||
maxRender = 2,
|
||||
hideDropdownArrow = false,
|
||||
disabled = false,
|
||||
user,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [fetchStates, setFetchStates] = useState(false);
|
||||
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const dropdownBtn = useRef<any>(null);
|
||||
const dropdownOptions = useRef<any>(null);
|
||||
|
||||
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||
projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
|
||||
workspaceSlug && projectId && fetchStates
|
||||
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options = issueLabels?.map((label) => ({
|
||||
value: label.id,
|
||||
query: label.name,
|
||||
content: (
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color,
|
||||
}}
|
||||
/>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const filteredOptions =
|
||||
query === ""
|
||||
? options
|
||||
: options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const label = (
|
||||
<div className={`flex items-center gap-2 text-custom-text-200`}>
|
||||
{labelsDetails.length > 0 ? (
|
||||
labelsDetails.length <= maxRender ? (
|
||||
<>
|
||||
{labelsDetails.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center flex-shrink-0 rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip
|
||||
position="top"
|
||||
tooltipHeading="Labels"
|
||||
tooltipContent={labelsDetails.map((l) => l.name).join(", ")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${value.length} Labels`}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions);
|
||||
|
||||
const footerOption = (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
|
||||
onClick={() => setLabelModal(true)}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-1 text-custom-text-200">
|
||||
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Create New Label</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId && (
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId.toString()}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<Combobox
|
||||
as="div"
|
||||
className={`flex-shrink-0 text-left ${className}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
multiple
|
||||
>
|
||||
{({ open }: { open: boolean }) => {
|
||||
if (open) {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
setFetchStates(true);
|
||||
} else if (isOpen) setIsOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Combobox.Button
|
||||
ref={dropdownBtn}
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 w-full text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: value.length <= maxRender
|
||||
? "cursor-pointer"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && (
|
||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
</Combobox.Button>
|
||||
<div className={`${open ? "fixed z-20 top-0 left-0 h-full w-full cursor-auto" : ""}`}>
|
||||
<Combobox.Options
|
||||
ref={dropdownOptions}
|
||||
className={`absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap mt-1 ${optionsClassName}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
<Combobox.Input
|
||||
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search"
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
|
||||
active && !selected ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
{option.content}
|
||||
{selected && <CheckIcon className={`h-3.5 w-3.5`} />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<span className="flex items-center gap-2 p-1">
|
||||
<p className="text-left text-custom-text-200 ">No matching results</p>
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<p className="text-center text-custom-text-200">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
{footerOption}
|
||||
</Combobox.Options>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Combobox>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,8 @@ import useSWR from "swr";
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { Avatar, CustomSearchSelect } from "components/ui";
|
||||
// icon
|
||||
import { Ban } from "lucide-react";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@@ -49,7 +51,10 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
{selectedOption ? (
|
||||
selectedOption?.display_name
|
||||
) : (
|
||||
<span className="text-sm py-0.5 text-custom-text-200">Select</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-3.5 w-3.5 text-custom-sidebar-text-400 rotate-90" />
|
||||
<span className="text-sm py-0.5 text-custom-sidebar-text-400">None</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -61,7 +66,12 @@ export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
{
|
||||
value: "none",
|
||||
query: "none",
|
||||
content: <div className="flex items-center gap-2">None</div>,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban className="h-3.5 w-3.5 text-custom-sidebar-text-400 rotate-90" />
|
||||
<span className="text-sm py-0.5 text-custom-sidebar-text-400">None</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user