Compare commits

..

3 Commits

Author SHA1 Message Date
NarayanBavisetti 41c6575884 chore: squashed migration 2024-10-14 20:16:15 +05:30
NarayanBavisetti f155875326 chore: user preference migration 2024-10-14 19:20:38 +05:30
gurusainath d9510fa381 migration: added external_id and external_source in issue_type 2024-10-14 13:31:07 +05:30
130 changed files with 2335 additions and 3124 deletions
@@ -8,13 +8,14 @@ on:
env:
CURRENT_BRANCH: ${{ github.ref_name }}
TARGET_BRANCH: "preview" # The target branch that you would like to merge changes like develop
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
ACCOUNT_USER_NAME: ${{ vars.ACCOUNT_USER_NAME }}
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
create_pull_request:
Create_PR:
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -47,6 +48,6 @@ jobs:
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "${{ vars.SYNC_PR_TITLE }}" --body "")
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "")
echo "Pull Request created: $PR_URL"
fi
@@ -35,8 +35,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
RUN_ID="${{ github.run_id }}"
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
TARGET_BRANCH="sync/${RUN_ID}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
+2 -2
View File
@@ -212,7 +212,7 @@ class IssueSerializer(BaseSerializer):
updated_by_id = instance.updated_by_id
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete(soft=False)
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
@@ -229,7 +229,7 @@ class IssueSerializer(BaseSerializer):
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete(soft=False)
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
+5 -1
View File
@@ -404,7 +404,11 @@ class CycleAPIEndpoint(BaseAPIView):
epoch=int(timezone.now().timestamp()),
)
# Delete the cycle
cycle.delete(soft=False)
cycle.delete()
# Delete the cycle issues
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle",
+1 -9
View File
@@ -202,15 +202,7 @@ class IssueAPIEndpoint(BaseAPIView):
issue_queryset = (
self.get_queryset()
.annotate(
cycle_id=Case(
When(
Q(issue_cycle__cycle__deleted_at__isnull=True),
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+2 -6
View File
@@ -169,9 +169,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
updated_by_id = instance.updated_by_id
if assignees is not None:
DraftIssueAssignee.objects.filter(draft_issue=instance).delete(
soft=False
)
DraftIssueAssignee.objects.filter(draft_issue=instance).delete()
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
@@ -188,9 +186,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
)
if labels is not None:
DraftIssueLabel.objects.filter(draft_issue=instance).delete(
soft=False
)
DraftIssueLabel.objects.filter(draft_issue=instance).delete()
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
+2 -2
View File
@@ -201,7 +201,7 @@ class IssueCreateSerializer(BaseSerializer):
updated_by_id = instance.updated_by_id
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete(soft=False)
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
@@ -218,7 +218,7 @@ class IssueCreateSerializer(BaseSerializer):
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete(soft=False)
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
@@ -65,7 +65,6 @@ class WorkSpaceMemberSerializer(DynamicBaseSerializer):
class WorkspaceMemberMeSerializer(BaseSerializer):
draft_issue_count = serializers.IntegerField(read_only=True)
class Meta:
model = WorkspaceMember
fields = "__all__"
+15 -7
View File
@@ -22,7 +22,6 @@ from plane.db.models import (
from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class UserAssetsV2Endpoint(BaseAPIView):
@@ -194,11 +193,14 @@ class UserAssetsV2Endpoint(BaseAPIView):
def patch(self, request, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
storage = S3Storage(request=request)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
if asset.storage_metadata is None:
asset.storage_metadata = storage.get_object_metadata(
object_name=asset.asset.name
)
# get the entity and save the asset id for the request field
self.entity_asset_save(
asset_id=asset_id,
@@ -444,11 +446,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
def patch(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
storage = S3Storage(request=request)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
if asset.storage_metadata is None:
asset.storage_metadata = storage.get_object_metadata(
object_name=asset.asset.name
)
# get the entity and save the asset id for the request field
self.entity_asset_save(
asset_id=asset_id,
@@ -681,11 +686,14 @@ class ProjectAssetEndpoint(BaseAPIView):
asset = FileAsset.objects.get(
id=pk,
)
storage = S3Storage(request=request)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(pk))
if asset.storage_metadata is None:
asset.storage_metadata = storage.get_object_metadata(
object_name=asset.asset.name
)
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
+1 -26
View File
@@ -160,7 +160,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -172,7 +171,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -184,7 +182,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -196,7 +193,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -208,7 +204,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -220,7 +215,6 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -520,26 +514,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.annotate(first_name=F("assignees__first_name"))
.annotate(last_name=F("assignees__last_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.annotate(avatar_url=F("assignees__avatar_url"))
.annotate(display_name=F("assignees__display_name"))
.values(
"first_name",
+6 -11
View File
@@ -102,7 +102,6 @@ class CycleViewSet(BaseViewSet):
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -114,7 +113,6 @@ class CycleViewSet(BaseViewSet):
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -490,9 +488,12 @@ class CycleViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# TODO: Soft delete the cycle break the onetoone relationship with cycle issue
cycle.delete(soft=False)
# Delete the cycle
cycle.delete()
# Delete the cycle issues
CycleIssue.objects.filter(
cycle_id=self.kwargs.get("pk"),
).delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
user=request.user,
@@ -606,7 +607,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -617,7 +617,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -628,7 +627,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -639,7 +637,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -650,7 +647,6 @@ class TransferCycleIssueEndpoint(BaseAPIView):
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -1214,7 +1210,6 @@ class CycleAnalyticsEndpoint(BaseAPIView):
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
+2 -10
View File
@@ -3,7 +3,7 @@ import json
# Django imports
from django.core import serializers
from django.db.models import F, Func, OuterRef, Q, Case, When
from django.db.models import F, Func, OuterRef, Q
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -102,15 +102,7 @@ class CycleIssueViewSet(BaseViewSet):
"issue_cycle__cycle",
)
.filter(**filters)
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+4 -14
View File
@@ -218,10 +218,7 @@ def dashboard_assigned_issues(self, request, slug):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -238,9 +235,7 @@ def dashboard_assigned_issues(self, request, slug):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -387,10 +382,7 @@ def dashboard_created_issues(self, request, slug):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -407,9 +399,7 @@ def dashboard_created_issues(self, request, slug):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+7 -24
View File
@@ -3,7 +3,7 @@ import json
# Django import
from django.utils import timezone
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Case, When
from django.db.models import Q, Count, OuterRef, Func, F, Prefetch
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
@@ -112,15 +112,7 @@ class InboxIssueViewSet(BaseViewSet):
),
)
)
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -149,10 +141,7 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -170,8 +159,7 @@ class InboxIssueViewSet(BaseViewSet):
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -198,8 +186,7 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=~Q(issue__labels__id__isnull=True)
& Q(issue__labels__deleted_at__isnull=True),
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -311,10 +298,7 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=(
~Q(issue__labels__id__isnull=True)
& Q(issue__labels__deleted_at__isnull=True)
),
filter=~Q(issue__labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -322,8 +306,7 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"issue__assignees__id",
distinct=True,
filter=~Q(issue__assignees__id__isnull=True)
& Q(issue__assignees__member_project__is_active=True),
filter=~Q(issue__assignees__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+9 -10
View File
@@ -3,7 +3,14 @@ import json
# Django imports
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Case, When
from django.db.models import (
F,
Func,
OuterRef,
Q,
Prefetch,
Exists,
)
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -64,15 +71,7 @@ class IssueArchiveViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -20,7 +20,6 @@ from plane.db.models import FileAsset, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class IssueAttachmentEndpoint(BaseAPIView):
@@ -255,7 +254,10 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
issue_attachment.is_uploaded = True
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
if issue_attachment.storage_metadata is None:
storage = S3Storage(request=request)
issue_attachment.storage_metadata = storage.get_object_metadata(
issue_attachment.asset.name
)
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
+11 -59
View File
@@ -14,8 +14,6 @@ from django.db.models import (
Q,
UUIDField,
Value,
When,
Case,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -85,15 +83,7 @@ class IssueListEndpoint(BaseAPIView):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -217,15 +207,7 @@ class IssueViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -489,10 +471,7 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -510,8 +489,7 @@ class IssueViewSet(BaseViewSet):
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -590,10 +568,7 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -610,9 +585,7 @@ class IssueViewSet(BaseViewSet):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -775,30 +748,13 @@ class IssuePaginatedViewSet(BaseViewSet):
"workspace", "project", "state", "parent"
)
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
@@ -823,7 +779,7 @@ class IssuePaginatedViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", "false")
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gt", None)
# required fields
@@ -856,7 +812,7 @@ class IssuePaginatedViewSet(BaseViewSet):
"sub_issues_count",
]
if str(is_description_required).lower() == "true":
if is_description_required:
required_fields.append("description_html")
# querying issues
@@ -890,10 +846,7 @@ class IssuePaginatedViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -911,8 +864,7 @@ class IssuePaginatedViewSet(BaseViewSet):
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+3 -24
View File
@@ -3,17 +3,7 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import (
Q,
OuterRef,
F,
Func,
UUIDField,
Value,
CharField,
Case,
When,
)
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
@@ -93,15 +83,7 @@ class IssueRelationViewSet(BaseViewSet):
Issue.issue_objects.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -130,10 +112,7 @@ class IssueRelationViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+3 -18
View File
@@ -10,8 +10,6 @@ from django.db.models import (
Q,
Value,
UUIDField,
Case,
When,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
@@ -50,15 +48,7 @@ class SubIssuesEndpoint(BaseAPIView):
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -87,10 +77,7 @@ class SubIssuesEndpoint(BaseAPIView):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -107,9 +94,7 @@ class SubIssuesEndpoint(BaseAPIView):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+1 -11
View File
@@ -6,8 +6,6 @@ from django.db.models import (
Func,
OuterRef,
Q,
Case,
When,
)
# Django Imports
@@ -67,15 +65,7 @@ class ModuleIssueViewSet(BaseViewSet):
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+4 -26
View File
@@ -9,8 +9,6 @@ from django.db.models import (
Q,
UUIDField,
Value,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
@@ -207,15 +205,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -244,10 +234,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -265,8 +252,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -285,15 +271,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
)
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
@@ -43,7 +43,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -54,7 +53,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -65,7 +63,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -76,7 +73,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
@@ -87,7 +83,6 @@ class WorkspaceCyclesEndpoint(BaseAPIView):
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
+14 -34
View File
@@ -12,8 +12,6 @@ from django.db.models import (
Q,
UUIDField,
Value,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
@@ -39,7 +37,6 @@ from plane.db.models import (
DraftIssueModule,
DraftIssueCycle,
Workspace,
FileAsset,
)
from .. import BaseViewSet
from plane.bgtasks.issue_activities_task import issue_activity
@@ -57,24 +54,12 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
"assignees", "labels", "draft_issue_module__module"
)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
cycle_id=Case(
When(
draft_issue_cycle__cycle__deleted_at__isnull=True,
then=F("draft_issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -94,9 +79,6 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
)
& Q(
draft_issue_module__module__deleted_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
@@ -277,9 +259,9 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
if request.data.get("cycle_id", None):
if draft_issue.cycle_id:
created_records = CycleIssue.objects.create(
cycle_id=request.data.get("cycle_id", None),
cycle_id=draft_issue.cycle_id,
issue_id=serializer.data.get("id", None),
project_id=draft_issue.project_id,
workspace_id=draft_issue.workspace_id,
@@ -297,7 +279,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
{
"updated_cycle_issues": None,
"created_cycle_issues": serializers.serialize(
"json", [created_records]
"json", created_records
),
}
),
@@ -306,7 +288,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
origin=request.META.get("HTTP_ORIGIN"),
)
if request.data.get("module_ids", []):
if draft_issue.module_ids:
# bulk create the module
ModuleIssue.objects.bulk_create(
[
@@ -318,11 +300,11 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
created_by_id=draft_issue.created_by_id,
updated_by_id=draft_issue.updated_by_id,
)
for module in request.data.get("module_ids", [])
for module in draft_issue.module_ids
],
batch_size=10,
)
# Update the activity
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
@@ -335,20 +317,18 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in request.data.get("module_ids", [])
for module in draft_issue.module_ids
]
# Update file assets
file_assets = FileAsset.objects.filter(draft_issue_id=draft_id)
file_assets.update(
issue_id=serializer.data.get("id", None),
entity_type=FileAsset.EntityTypeContext.ISSUE_DESCRIPTION,
draft_issue_id=None,
)
# delete the draft issue
draft_issue.delete()
# delete the draft issue module
DraftIssueModule.objects.filter(draft_issue=draft_issue).delete()
# delete the draft issue cycle
DraftIssueCycle.objects.filter(draft_issue=draft_issue).delete()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+4 -25
View File
@@ -3,11 +3,7 @@ from django.db.models import (
CharField,
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Cast
# Third party modules
@@ -38,7 +34,6 @@ from plane.db.models import (
User,
Workspace,
WorkspaceMember,
DraftIssue,
)
from plane.utils.cache import cache_response, invalidate_cache
@@ -288,26 +283,10 @@ class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
class WorkspaceMemberUserEndpoint(BaseAPIView):
def get(self, request, slug):
draft_issue_count = (
DraftIssue.objects.filter(
created_by=request.user,
workspace_id=OuterRef("workspace_id"),
)
.values("workspace_id")
.annotate(count=Count("id"))
.values("count")
)
workspace_member = (
WorkspaceMember.objects.filter(
member=request.user, workspace__slug=slug, is_active=True
)
.annotate(
draft_issue_count=Coalesce(
Subquery(draft_issue_count, output_field=IntegerField()), 0
)
)
.first()
workspace_member = WorkspaceMember.objects.get(
member=request.user,
workspace__slug=slug,
is_active=True,
)
serializer = WorkspaceMemberMeSerializer(workspace_member)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -9
View File
@@ -120,15 +120,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+2 -2
View File
@@ -21,8 +21,8 @@ def soft_delete_related_objects(
try:
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
hasattr(field.remote_field, "on_delete")
and field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
@@ -1,28 +0,0 @@
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import FileAsset
from plane.settings.storage import S3Storage
from plane.utils.exception_logger import log_exception
@shared_task
def get_asset_object_metadata(asset_id):
try:
# Get the asset
asset = FileAsset.objects.get(pk=asset_id)
# Create an instance of the S3 storage
storage = S3Storage()
# Get the storage
asset.storage_metadata = storage.get_object_metadata(
object_name=asset.asset.name
)
# Save the asset
asset.save()
return
except FileAsset.DoesNotExist:
return
except Exception as e:
log_exception(e)
return
@@ -0,0 +1,179 @@
# Generated by Django 4.2.16 on 2024-10-14 07:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0080_fileasset_draft_issue_alter_fileasset_entity_type"),
]
operations = [
migrations.AddField(
model_name="issuetype",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="issuetype",
name="external_source",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.RenameField(
model_name="usernotificationpreference",
old_name="property_change",
new_name="email_property_change",
),
migrations.AlterField(
model_name="usernotificationpreference",
name="email_property_change",
field=models.BooleanField(default=False),
),
migrations.RenameField(
model_name="usernotificationpreference",
old_name="state_change",
new_name="email_state_change",
),
migrations.AlterField(
model_name="usernotificationpreference",
name="email_state_change",
field=models.BooleanField(default=False),
),
migrations.RenameField(
model_name="usernotificationpreference",
old_name="comment",
new_name="email_comment",
),
migrations.AlterField(
model_name="usernotificationpreference",
name="email_comment",
field=models.BooleanField(default=False),
),
migrations.RenameField(
model_name="usernotificationpreference",
old_name="mention",
new_name="email_mention",
),
migrations.AlterField(
model_name="usernotificationpreference",
name="email_mention",
field=models.BooleanField(default=False),
),
migrations.RenameField(
model_name="usernotificationpreference",
old_name="issue_completed",
new_name="email_issue_completed",
),
migrations.AlterField(
model_name="usernotificationpreference",
name="email_issue_completed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="deployboard",
name="is_disabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_assignee_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_cycle_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_module_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_priority_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_reactions",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="email_start_target_date_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_assignee_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_comment",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_cycle_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_issue_completed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_mention",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_module_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_priority_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_property_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_reactions",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_start_target_date_change",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="usernotificationpreference",
name="in_app_state_change",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="deployboard",
name="entity_name",
field=models.CharField(
choices=[
("project", "Project"),
("issue", "Issue"),
("module", "Module"),
("cycle", "Task"),
("page", "Page"),
("view", "View"),
("inbox", "Inbox"),
],
max_length=30,
),
),
]
@@ -1,187 +0,0 @@
# Generated by Django 4.2.16 on 2024-10-15 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0080_fileasset_draft_issue_alter_fileasset_entity_type"),
]
operations = [
migrations.RemoveField(
model_name="globalview",
name="created_by",
),
migrations.RemoveField(
model_name="globalview",
name="updated_by",
),
migrations.RemoveField(
model_name="globalview",
name="workspace",
),
migrations.AlterUniqueTogether(
name="issueviewfavorite",
unique_together=None,
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="created_by",
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="project",
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="updated_by",
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="user",
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="view",
),
migrations.RemoveField(
model_name="issueviewfavorite",
name="workspace",
),
migrations.AlterUniqueTogether(
name="modulefavorite",
unique_together=None,
),
migrations.RemoveField(
model_name="modulefavorite",
name="created_by",
),
migrations.RemoveField(
model_name="modulefavorite",
name="module",
),
migrations.RemoveField(
model_name="modulefavorite",
name="project",
),
migrations.RemoveField(
model_name="modulefavorite",
name="updated_by",
),
migrations.RemoveField(
model_name="modulefavorite",
name="user",
),
migrations.RemoveField(
model_name="modulefavorite",
name="workspace",
),
migrations.RemoveField(
model_name="pageblock",
name="created_by",
),
migrations.RemoveField(
model_name="pageblock",
name="issue",
),
migrations.RemoveField(
model_name="pageblock",
name="page",
),
migrations.RemoveField(
model_name="pageblock",
name="project",
),
migrations.RemoveField(
model_name="pageblock",
name="updated_by",
),
migrations.RemoveField(
model_name="pageblock",
name="workspace",
),
migrations.AlterUniqueTogether(
name="pagefavorite",
unique_together=None,
),
migrations.RemoveField(
model_name="pagefavorite",
name="created_by",
),
migrations.RemoveField(
model_name="pagefavorite",
name="page",
),
migrations.RemoveField(
model_name="pagefavorite",
name="project",
),
migrations.RemoveField(
model_name="pagefavorite",
name="updated_by",
),
migrations.RemoveField(
model_name="pagefavorite",
name="user",
),
migrations.RemoveField(
model_name="pagefavorite",
name="workspace",
),
migrations.AlterUniqueTogether(
name="projectfavorite",
unique_together=None,
),
migrations.RemoveField(
model_name="projectfavorite",
name="created_by",
),
migrations.RemoveField(
model_name="projectfavorite",
name="project",
),
migrations.RemoveField(
model_name="projectfavorite",
name="updated_by",
),
migrations.RemoveField(
model_name="projectfavorite",
name="user",
),
migrations.RemoveField(
model_name="projectfavorite",
name="workspace",
),
migrations.AddField(
model_name="issuetype",
name="external_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="issuetype",
name="external_source",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.DeleteModel(
name="CycleFavorite",
),
migrations.DeleteModel(
name="GlobalView",
),
migrations.DeleteModel(
name="IssueViewFavorite",
),
migrations.DeleteModel(
name="ModuleFavorite",
),
migrations.DeleteModel(
name="PageBlock",
),
migrations.DeleteModel(
name="PageFavorite",
),
migrations.DeleteModel(
name="ProjectFavorite",
),
]
+1 -11
View File
@@ -43,19 +43,9 @@ class UserAuditModel(models.Model):
abstract = True
class SoftDeletionQuerySet(models.QuerySet):
def delete(self, soft=True):
if soft:
return self.update(deleted_at=timezone.now())
else:
return super().delete()
class SoftDeletionManager(models.Manager):
def get_queryset(self):
return SoftDeletionQuerySet(self.model, using=self._db).filter(
deleted_at__isnull=True
)
return super().get_queryset().filter(deleted_at__isnull=True)
class SoftDeleteModel(models.Model):
+6 -3
View File
@@ -2,7 +2,7 @@ from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle
@@ -39,6 +39,7 @@ from .issue import (
)
from .module import (
Module,
ModuleFavorite,
ModuleIssue,
ModuleLink,
ModuleMember,
@@ -51,6 +52,7 @@ from .notification import (
)
from .page import (
Page,
PageFavorite,
PageLabel,
PageLog,
ProjectPage,
@@ -59,6 +61,7 @@ from .page import (
from .project import (
Project,
ProjectBaseModel,
ProjectFavorite,
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
@@ -69,7 +72,7 @@ from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
from .user import Account, Profile, User
from .view import IssueView
from .view import IssueView, IssueViewFavorite
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
@@ -84,7 +87,7 @@ from .workspace import (
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .page import Page, PageLog, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint
+27
View File
@@ -127,6 +127,33 @@ class CycleIssue(ProjectBaseModel):
return f"{self.cycle}"
# DEPRECATED TODO: - Remove in next release
class CycleFavorite(ProjectBaseModel):
"""_summary_
CycleFavorite (model): To store all the cycle favorite of the user
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cycle_favorites",
)
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites"
)
class Meta:
unique_together = ["cycle", "user"]
verbose_name = "Cycle Favorite"
verbose_name_plural = "Cycle Favorites"
db_table = "cycle_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the cycle"""
return f"{self.user.email} <{self.cycle.name}>"
class CycleUserProperties(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
@@ -20,6 +20,7 @@ class DeployBoard(WorkspaceBaseModel):
("cycle", "Task"),
("page", "Page"),
("view", "View"),
("inbox", "Inbox"),
)
entity_identifier = models.UUIDField(null=True)
@@ -41,6 +42,7 @@ class DeployBoard(WorkspaceBaseModel):
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
is_activity_enabled = models.BooleanField(default=True)
is_disabled = models.BooleanField(default=False)
def __str__(self):
"""Return name of the deploy board"""
+3 -2
View File
@@ -12,7 +12,6 @@ from django.db.models import Q
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from .project import ProjectBaseModel
@@ -80,7 +79,7 @@ def get_default_display_properties():
# TODO: Handle identifiers for Bulk Inserts - nk
class IssueManager(SoftDeletionManager):
class IssueManager(models.Manager):
def get_queryset(self):
return (
super()
@@ -91,6 +90,7 @@ class IssueManager(SoftDeletionManager):
| models.Q(issue_inbox__status=2)
| models.Q(issue_inbox__isnull=True)
)
.filter(deleted_at__isnull=True)
.filter(state__is_triage=False)
.exclude(archived_at__isnull=False)
.exclude(project__archived_at__isnull=False)
@@ -172,6 +172,7 @@ class Issue(ProjectBaseModel):
blank=True,
)
objects = models.Manager()
issue_objects = IssueManager()
class Meta:
+29 -2
View File
@@ -100,9 +100,9 @@ class Module(ProjectBaseModel):
unique_together = ["name", "project", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["name", "project"],
fields=['name', 'project'],
condition=Q(deleted_at__isnull=True),
name="module_unique_name_project_when_deleted_at_null",
name='module_unique_name_project_when_deleted_at_null'
)
]
verbose_name = "Module"
@@ -191,6 +191,33 @@ class ModuleLink(ProjectBaseModel):
return f"{self.module.name} {self.url}"
# DEPRECATED TODO: - Remove in next release
class ModuleFavorite(ProjectBaseModel):
"""_summary_
ModuleFavorite (model): To store all the module favorite of the user
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="module_favorites",
)
module = models.ForeignKey(
"db.Module", on_delete=models.CASCADE, related_name="module_favorites"
)
class Meta:
unique_together = ["module", "user"]
verbose_name = "Module Favorite"
verbose_name_plural = "Module Favorites"
db_table = "module_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the module"""
return f"{self.user.email} <{self.module.name}>"
class ModuleUserProperties(ProjectBaseModel):
module = models.ForeignKey(
"db.Module",
+25 -7
View File
@@ -6,7 +6,6 @@ from django.db import models
from .base import BaseModel
class Notification(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", related_name="notifications", on_delete=models.CASCADE
@@ -90,12 +89,31 @@ class UserNotificationPreference(BaseModel):
null=True,
)
# preference fields
property_change = models.BooleanField(default=True)
state_change = models.BooleanField(default=True)
comment = models.BooleanField(default=True)
mention = models.BooleanField(default=True)
issue_completed = models.BooleanField(default=True)
# email preference fields
email_property_change = models.BooleanField(default=False)
email_state_change = models.BooleanField(default=False)
email_priority_change = models.BooleanField(default=False)
email_assignee_change = models.BooleanField(default=False)
email_start_target_date_change = models.BooleanField(default=False)
email_module_change = models.BooleanField(default=False)
email_cycle_change = models.BooleanField(default=False)
email_reactions = models.BooleanField(default=False)
email_comment = models.BooleanField(default=False)
email_mention = models.BooleanField(default=False)
email_issue_completed = models.BooleanField(default=False)
# in app preference fields
in_app_property_change = models.BooleanField(default=False)
in_app_state_change = models.BooleanField(default=False)
in_app_priority_change = models.BooleanField(default=False)
in_app_assignee_change = models.BooleanField(default=False)
in_app_start_target_date_change = models.BooleanField(default=False)
in_app_module_change = models.BooleanField(default=False)
in_app_cycle_change = models.BooleanField(default=False)
in_app_reactions = models.BooleanField(default=False)
in_app_comment = models.BooleanField(default=False)
in_app_mention = models.BooleanField(default=False)
in_app_issue_completed = models.BooleanField(default=False)
class Meta:
verbose_name = "UserNotificationPreference"
+80
View File
@@ -119,6 +119,86 @@ class PageLog(BaseModel):
return f"{self.page.name} {self.entity_name}"
# DEPRECATED TODO: - Remove in next release
class PageBlock(ProjectBaseModel):
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="blocks"
)
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
issue = models.ForeignKey(
"db.Issue", on_delete=models.SET_NULL, related_name="blocks", null=True
)
completed_at = models.DateTimeField(null=True)
sort_order = models.FloatField(default=65535)
sync = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = PageBlock.objects.filter(
project=self.project, page=self.page
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self.completed_at and self.issue:
try:
from plane.db.models import Issue, State
completed_state = State.objects.filter(
group="completed", project=self.project
).first()
if completed_state is not None:
Issue.objects.update(
pk=self.issue_id, state=completed_state
)
except ImportError:
pass
super(PageBlock, self).save(*args, **kwargs)
class Meta:
verbose_name = "Page Block"
verbose_name_plural = "Page Blocks"
db_table = "page_blocks"
ordering = ("-created_at",)
def __str__(self):
"""Return page and page block"""
return f"{self.page.name} <{self.name}>"
# DEPRECATED TODO: - Remove in next release
class PageFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="page_favorites",
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="page_favorites"
)
class Meta:
unique_together = ["page", "user"]
verbose_name = "Page Favorite"
verbose_name_plural = "Page Favorites"
db_table = "page_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the page"""
return f"{self.user.email} <{self.page.name}>"
class PageLabel(BaseModel):
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="page_labels"
+21 -3
View File
@@ -181,9 +181,7 @@ class ProjectBaseModel(BaseModel):
Project, on_delete=models.CASCADE, related_name="project_%(class)s"
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_%(class)s",
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
)
class Meta:
@@ -287,6 +285,26 @@ class ProjectIdentifier(AuditModel):
ordering = ("-created_at",)
# DEPRECATED TODO: - Remove in next release
class ProjectFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="project_favorites",
)
class Meta:
unique_together = ["project", "user"]
verbose_name = "Project Favorite"
verbose_name_plural = "Project Favorites"
db_table = "project_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user of the project"""
return f"{self.user.email} <{self.project.name}>"
def get_anchor():
return uuid4().hex
-5
View File
@@ -252,9 +252,4 @@ def create_user_notification(sender, instance, created, **kwargs):
UserNotificationPreference.objects.create(
user=instance,
property_change=False,
state_change=False,
comment=False,
mention=False,
issue_completed=False,
)
+59
View File
@@ -52,6 +52,41 @@ def get_default_display_properties():
"updated_on": True,
}
# DEPRECATED TODO: - Remove in next release
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)
sort_order = models.FloatField(default=65535)
logo_props = models.JSONField(default=dict)
class Meta:
verbose_name = "Global View"
verbose_name_plural = "Global Views"
db_table = "global_views"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self._state.adding:
largest_sort_order = GlobalView.objects.filter(
workspace=self.workspace
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(GlobalView, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.workspace.name}>"
class IssueView(WorkspaceBaseModel):
name = models.CharField(max_length=255, verbose_name="View Name")
@@ -74,6 +109,7 @@ class IssueView(WorkspaceBaseModel):
)
is_locked = models.BooleanField(default=False)
class Meta:
verbose_name = "Issue View"
verbose_name_plural = "Issue Views"
@@ -103,3 +139,26 @@ class IssueView(WorkspaceBaseModel):
def __str__(self):
"""Return name of the View"""
return f"{self.name} <{self.project.name}>"
# DEPRECATED TODO: - Remove in next release
class IssueViewFavorite(ProjectBaseModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="user_view_favorites",
)
view = models.ForeignKey(
"db.IssueView", on_delete=models.CASCADE, related_name="view_favorites"
)
class Meta:
unique_together = ["view", "user"]
verbose_name = "View Favorite"
verbose_name_plural = "View Favorites"
db_table = "view_favorites"
ordering = ("-created_at",)
def __str__(self):
"""Return user and the view"""
return f"{self.user.email} <{self.view.name}>"
+1 -5
View File
@@ -39,11 +39,7 @@ class S3Storage(S3Boto3Storage):
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(
f"{request.scheme}://{request.get_host()}"
if request
else self.aws_s3_endpoint_url
),
endpoint_url=f"{request.scheme}://{request.get_host()}",
config=boto3.session.Config(signature_version="s3v4"),
)
else:
+2 -2
View File
@@ -421,7 +421,7 @@ class IssueCreateSerializer(BaseSerializer):
updated_by_id = instance.updated_by_id
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete(soft=False)
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
@@ -438,7 +438,7 @@ class IssueCreateSerializer(BaseSerializer):
)
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete(soft=False)
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
+5 -3
View File
@@ -15,7 +15,6 @@ from rest_framework.response import Response
from .base import BaseAPIView
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class EntityAssetEndpoint(BaseAPIView):
@@ -160,11 +159,14 @@ class EntityAssetEndpoint(BaseAPIView):
# get the asset id
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace)
storage = S3Storage(request=request)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(str(asset.id))
if asset.storage_metadata is None:
asset.storage_metadata = storage.get_object_metadata(
object_name=asset.asset.name
)
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
+4 -25
View File
@@ -106,15 +106,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
queryset=IssueVote.objects.select_related("actor"),
)
)
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -703,24 +695,13 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=(
~Q(labels__id__isnull=True)
& Q(labels__deleted_at__isnull=True)
),
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@@ -737,9 +718,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True),
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
+2 -2
View File
@@ -138,7 +138,7 @@ def burndown_plot(
estimate__type="points",
).exists()
if estimate_type and plot_type == "points" and cycle_id:
issue_estimates = Issue.issue_objects.filter(
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
@@ -149,7 +149,7 @@ def burndown_plot(
total_estimate_points = sum(issue_estimates)
if estimate_type and plot_type == "points" and module_id:
issue_estimates = Issue.issue_objects.filter(
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
+1 -5
View File
@@ -26,16 +26,12 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
annotations_map = {
"assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)),
"label_ids": (
"labels__id",
~Q(labels__id__isnull=True) & (Q(labels__deleted_at__isnull=True)),
),
"label_ids": ("labels__id", ~Q(labels__id__isnull=True)),
"module_ids": (
"issue_module__module_id",
(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__module__deleted_at__isnull=True)
),
),
}
+1 -2
View File
@@ -185,7 +185,6 @@ class OffsetPaginator:
class GroupedOffsetPaginator(OffsetPaginator):
# Field mappers - list m2m fields here
FIELD_MAPPER = {
"labels__id": "label_ids",
@@ -683,7 +682,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
# for multi groups
result[
self.FIELD_MAPPER.get(self.sub_group_by_field_name)
] = ([] if "None" in sub_group_ids else sub_group_ids)
] = [] if "None" in sub_group_ids else sub_group_ids
# If a result belongs to multiple groups, add it to each group
processed_results[str(group_value)]["results"][
str(sub_group_value)
+1 -1
View File
@@ -34,7 +34,7 @@ x-app-env: &app-env
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
+2 -2
View File
@@ -60,12 +60,12 @@ http {
proxy_pass http://space:3002/spaces/;
}
location /${BUCKET_NAME} {
location /${BUCKET_NAME}/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://plane-minio:9000/${BUCKET_NAME};
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
+2 -2
View File
@@ -68,12 +68,12 @@ http {
proxy_pass http://space:3000/spaces/;
}
location /${BUCKET_NAME} {
location /${BUCKET_NAME}/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://plane-minio:9000/${BUCKET_NAME};
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
@@ -16,8 +16,8 @@ type Props = {
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const activeTextColor = COLORS_LIST.find((c) => TextColorItem(editor).isActive(c.key));
const activeBackgroundColor = COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive(c.key));
const activeTextColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").color === c.key);
const activeBackgroundColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").backgroundColor === c.key);
return (
<div className="relative h-full">
@@ -211,7 +211,7 @@ export const ImageItem = (editor: Editor) =>
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
key: "text-color",
name: "Color",
isActive: (color) => editor.isActive("customColor", { color }),
isActive: (color) => editor.getAttributes("textStyle").color === color,
command: (color: string) => toggleTextColor(color, editor),
icon: Palette,
});
@@ -219,7 +219,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem => ({
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
key: "background-color",
name: "Background color",
isActive: (color) => editor.isActive("customColor", { backgroundColor: color }),
isActive: (color) => editor.getAttributes("textStyle").backgroundColor === color,
command: (color: string) => toggleBackgroundColor(color, editor),
icon: Palette,
});
@@ -16,7 +16,8 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomColorExtension } from "./custom-color";
import { CustomTextColorExtension } from "./custom-text-color";
import { CustomBackgroundColorExtension } from "./custom-background-color";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -84,7 +85,8 @@ export const CoreEditorExtensionsWithoutProps = [
TableCell,
TableRow,
CustomMentionWithoutProps(),
CustomColorExtension,
CustomTextColorExtension,
CustomBackgroundColorExtension,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
@@ -0,0 +1,78 @@
import { Extension } from "@tiptap/core";
// constants
import { COLORS_LIST } from "@/constants/common";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
backgroundColor: {
/**
* Set the background color
* @param color The color to set
* @example editor.commands.setBackgroundColor('red')
*/
setBackgroundColor: (color: string) => ReturnType;
/**
* Unset the background color
* @example editor.commands.unsetBackgroundColor()
*/
unsetBackgroundColor: () => ReturnType;
};
}
}
export const CustomBackgroundColorExtension = Extension.create({
name: "customBackgroundColor",
addOptions() {
return {
types: ["textStyle"],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"),
renderHTML: (attributes: { backgroundColor: string }) => {
const { backgroundColor } = attributes;
if (!backgroundColor) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-background-color": backgroundColor,
};
if (!COLORS_LIST.find((c) => c.key === backgroundColor)) {
elementAttributes = {
...elementAttributes,
style: `background-color: ${backgroundColor}`,
};
}
return elementAttributes;
},
},
},
},
];
},
addCommands() {
return {
setBackgroundColor:
(backgroundColor: string) =>
({ chain }) =>
chain().setMark("textStyle", { backgroundColor }).run(),
unsetBackgroundColor:
() =>
({ chain }) =>
chain().setMark("textStyle", { backgroundColor: null }).run(),
};
},
});
@@ -1,133 +0,0 @@
import { Mark, mergeAttributes } from "@tiptap/core";
// constants
import { COLORS_LIST } from "@/constants/common";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
color: {
/**
* Set the text color
* @param {string} color The color to set
* @example editor.commands.setTextColor('red')
*/
setTextColor: (color: string) => ReturnType;
/**
* Unset the text color
* @example editor.commands.unsetTextColor()
*/
unsetTextColor: () => ReturnType;
/**
* Set the background color
* @param {string} backgroundColor The color to set
* @example editor.commands.setBackgroundColor('red')
*/
setBackgroundColor: (backgroundColor: string) => ReturnType;
/**
* Unset the background color
* @example editor.commands.unsetBackgroundColorColor()
*/
unsetBackgroundColor: () => ReturnType;
};
}
}
export const CustomColorExtension = Mark.create({
name: "customColor",
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
color: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"),
renderHTML: (attributes: { color: string }) => {
const { color } = attributes;
if (!color) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-text-color": color,
};
if (!COLORS_LIST.find((c) => c.key === color)) {
elementAttributes = {
...elementAttributes,
style: `color: ${color}`,
};
}
return elementAttributes;
},
},
backgroundColor: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-background-color"),
renderHTML: (attributes: { backgroundColor: string }) => {
const { backgroundColor } = attributes;
if (!backgroundColor) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-background-color": backgroundColor,
};
if (!COLORS_LIST.find((c) => c.key === backgroundColor)) {
elementAttributes = {
...elementAttributes,
style: `background-color: ${backgroundColor}`,
};
}
return elementAttributes;
},
},
};
},
parseHTML() {
return [
{
tag: "span",
getAttrs: (node) => node.getAttribute("data-text-color") && null,
},
{
tag: "span",
getAttrs: (node) => node.getAttribute("data-background-color") && null,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setTextColor:
(color: string) =>
({ chain }) =>
chain().setMark(this.name, { color }).run(),
unsetTextColor:
() =>
({ chain }) =>
chain().setMark(this.name, { color: null }).run(),
setBackgroundColor:
(backgroundColor: string) =>
({ chain }) =>
chain().setMark(this.name, { backgroundColor }).run(),
unsetBackgroundColor:
() =>
({ chain }) =>
chain().setMark(this.name, { backgroundColor: null }).run(),
};
},
});
@@ -59,12 +59,12 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
src: remoteImageSrc,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
const { width, height, aspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%"),
height: ensurePixelString(nodeHeight, "auto"),
aspectRatio: nodeAspectRatio || null,
width: ensurePixelString(width, "35%"),
height: ensurePixelString(height, "auto"),
aspectRatio: aspectRatio || 1,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
@@ -104,17 +104,17 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
setEditorContainer(closestEditorContainer);
const aspectRatioCalculated = img.naturalWidth / img.naturalHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (nodeWidth === "35%") {
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialHeight = initialWidth / aspectRatio;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
aspectRatio: aspectRatio,
};
setSize(initialComputedSize);
@@ -124,10 +124,9 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
// or if aspectRatioCalculated from the image's width and height doesn't match stored aspectRatio then also we'll update the attrs
if (!nodeAspectRatio || nodeAspectRatio !== aspectRatioCalculated) {
if (!aspectRatio) {
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio: aspectRatioCalculated };
const newSize = { ...prevSize, aspectRatio };
updateAttributesSafely(
newSize,
"Failed to update attributes while initializing images with width but no aspect ratio:"
@@ -137,16 +136,16 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
}, [width, updateAttributes, editorContainer, aspectRatio]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(nodeWidth),
height: ensurePixelString(nodeHeight),
width: ensurePixelString(width),
height: ensurePixelString(height),
}));
}, [nodeWidth, nodeHeight]);
}, [width, height]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
@@ -218,7 +217,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
aspectRatio: size.aspectRatio,
}}
>
{showImageLoader && (
@@ -244,7 +243,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
})}
style={{
width: size.width,
...(size.aspectRatio && { aspectRatio: size.aspectRatio }),
aspectRatio: size.aspectRatio,
}}
/>
{showImageUtils && (
@@ -0,0 +1,78 @@
import { Extension } from "@tiptap/core";
// constants
import { COLORS_LIST } from "@/constants/common";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
color: {
/**
* Set the text color
* @param color The color to set
* @example editor.commands.setColor('red')
*/
setTextColor: (color: string) => ReturnType;
/**
* Unset the text color
* @example editor.commands.unsetColor()
*/
unsetTextColor: () => ReturnType;
};
}
}
export const CustomTextColorExtension = Extension.create({
name: "customTextColor",
addOptions() {
return {
types: ["textStyle"],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
color: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("data-text-color"),
renderHTML: (attributes: { color: string }) => {
const { color } = attributes;
if (!color) {
return {};
}
let elementAttributes: Record<string, string> = {
"data-text-color": color,
};
if (!COLORS_LIST.find((c) => c.key === color)) {
elementAttributes = {
...elementAttributes,
style: `color: ${color}`,
};
}
return elementAttributes;
},
},
},
},
];
},
addCommands() {
return {
setTextColor:
(color: string) =>
({ chain }) =>
chain().setMark("textStyle", { color }).run(),
unsetTextColor:
() =>
({ chain }) =>
chain().setMark("textStyle", { color: null }).run(),
};
},
});
@@ -8,17 +8,17 @@ import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomBackgroundColorExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMention,
CustomQuoteExtension,
CustomToggleHeadingExtension,
CustomTextColorExtension,
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
@@ -155,7 +155,7 @@ export const CoreEditorExtensions = (args: TArguments) => {
includeChildren: true,
}),
CharacterCount,
CustomColorExtension,
CustomToggleHeadingExtension,
CustomTextColorExtension,
CustomBackgroundColorExtension,
];
};
+2 -2
View File
@@ -8,11 +8,11 @@ export * from "./issue-embed";
export * from "./mentions";
export * from "./slash-commands";
export * from "./table";
export * from "./toggle-heading";
export * from "./typography";
export * from "./core-without-props";
export * from "./custom-background-color";
export * from "./custom-code-inline";
export * from "./custom-color";
export * from "./custom-text-color";
export * from "./drop";
export * from "./enter-key-extension";
export * from "./extensions";
@@ -21,8 +21,8 @@ import {
CustomMention,
HeadingListExtension,
CustomReadOnlyImageExtension,
CustomColorExtension,
CustomToggleHeadingReadOnlyExtension,
CustomTextColorExtension,
CustomBackgroundColorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -123,8 +123,8 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => {
readonly: true,
}),
CharacterCount,
CustomColorExtension,
CustomTextColorExtension,
CustomBackgroundColorExtension,
HeadingListExtension,
CustomToggleHeadingReadOnlyExtension,
];
};
@@ -10,7 +10,6 @@ import {
Heading6,
ImageIcon,
List,
ListCollapse,
ListOrdered,
ListTodo,
MinusSquare,
@@ -35,7 +34,6 @@ import {
toggleTextColor,
toggleBackgroundColor,
insertImage,
insertToggleHeading,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@@ -51,8 +49,7 @@ export const getSlashCommandFilteredSections =
({ query }: { query: string }): TSlashCommandSection[] => {
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "basic",
title: "Basic blocks",
key: "general",
items: [
{
commandKey: "text",
@@ -196,39 +193,6 @@ export const getSlashCommandFilteredSections =
},
],
},
{
key: "advanced",
title: "Advanced blocks",
items: [
{
commandKey: "toggle-heading",
key: "toggle-heading-1",
title: "Toggle heading 1",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 1",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(1, editor, range),
},
{
commandKey: "toggle-heading",
key: "toggle-heading-2",
title: "Toggle heading 2",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 2",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(2, editor, range),
},
{
commandKey: "toggle-heading",
key: "toggle-heading-3",
title: "Toggle heading 3",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 3",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(3, editor, range),
},
],
},
{
key: "text-color",
title: "Colors",
@@ -1,41 +0,0 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ChevronRight } from "lucide-react";
// types
import { TToggleHeadingBlockAttributes } from "./types";
import { cn } from "@/helpers/common";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TToggleHeadingBlockAttributes;
};
};
export const CustomToggleHeadingBlock: React.FC<Props> = (props) => {
const { node, updateAttributes } = props;
// derived values
const headingLevel = Number(node.attrs["data-heading-level"] ?? 1);
const isToggleOpen = node.attrs["data-toggle-status"] === "open";
return (
<NodeViewWrapper className="editor-toggle-heading-component flex items-start gap-1 my-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateAttributes({
"data-toggle-status": isToggleOpen ? "close" : "open",
});
}}
className="flex-shrink-0 size-5 grid place-items-center rounded hover:bg-custom-background-80 transition-colors"
>
<ChevronRight
className={cn("size-3.5 transition-all", {
"rotate-90": isToggleOpen,
})}
/>
</button>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};
@@ -1,49 +0,0 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
toggleHeadingComponent: {
insertToggleHeading: ({ headingLevel }: { headingLevel: number }) => ReturnType;
};
}
}
export const CustomToggleHeadingExtensionConfig = Node.create({
name: "toggleHeadingComponent",
group: "block",
content: "block+",
addAttributes() {
return {
"data-heading-level": {
default: 1,
},
"data-background-color": {
default: null,
},
"data-toggle-status": {
default: "close",
},
};
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {},
},
};
},
parseHTML() {
return [{ tag: "toggle-heading-component" }];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["toggle-heading-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -1,39 +0,0 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomToggleHeadingBlock } from "./block";
// config
import { CustomToggleHeadingExtensionConfig } from "./extension-config";
// utils
import { DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES } from "./utils";
export const CustomToggleHeadingExtension = CustomToggleHeadingExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertToggleHeading:
({ headingLevel }) =>
({ commands }) =>
commands.insertContent({
type: this.name,
content: [
{
type: "heading",
attrs: {
level: headingLevel,
},
},
],
attrs: {
"data-heading-level": headingLevel ?? 1,
"data-toggle-status": DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES["data-toggle-status"],
},
}),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomToggleHeadingBlock);
},
});
@@ -1,2 +0,0 @@
export * from "./extension";
export * from "./read-only-extension";
@@ -1,3 +0,0 @@
import { CustomToggleHeadingExtensionConfig } from "./extension-config";
export const CustomToggleHeadingReadOnlyExtension = CustomToggleHeadingExtensionConfig;
@@ -1,5 +0,0 @@
export type TToggleHeadingBlockAttributes = {
"data-heading-level": number | undefined;
"data-background-color": string | undefined;
"data-toggle-status": "open" | "close" | undefined;
};
@@ -1,8 +0,0 @@
// types
import { TToggleHeadingBlockAttributes } from "./types";
export const DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES: TToggleHeadingBlockAttributes = {
"data-heading-level": undefined,
"data-background-color": undefined,
"data-toggle-status": "close",
};
@@ -180,8 +180,3 @@ export const toggleBackgroundColor = (color: string | undefined, editor: Editor,
}
}
};
export const insertToggleHeading = (headingLevel: number, editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertToggleHeading({ headingLevel }).run();
else editor.chain().focus().insertToggleHeading({ headingLevel }).run();
};
@@ -43,7 +43,6 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
".issue-embed",
".image-component",
".image-upload-component",
".editor-toggle-heading-component",
].join(", ");
for (const elem of elements) {
@@ -23,8 +23,7 @@ export type TEditorCommands =
| "divider"
| "issue-embed"
| "text-color"
| "background-color"
| "toggle-heading";
| "background-color";
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
+7 -32
View File
@@ -316,10 +316,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 2rem;
}
margin-top: 2rem;
margin-bottom: 4px;
font-size: var(--font-size-h1);
line-height: var(--line-height-h1);
@@ -327,10 +324,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 1.4rem;
}
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: var(--font-size-h2);
line-height: var(--line-height-h2);
@@ -338,10 +332,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 1rem;
}
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h3);
line-height: var(--line-height-h3);
@@ -349,10 +340,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 1rem;
}
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h4);
line-height: var(--line-height-h4);
@@ -360,10 +348,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 1rem;
}
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h5);
line-height: var(--line-height-h5);
@@ -371,10 +356,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 1rem;
}
margin-top: 1rem;
margin-bottom: 1px;
font-size: var(--font-size-h6);
line-height: var(--line-height-h6);
@@ -382,14 +364,7 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
&:not(:first-child) {
margin-top: 0.25rem;
}
&:first-child {
margin-top: 0;
}
margin-top: 0.25rem;
margin-bottom: 1px;
padding: 3px 0;
font-size: var(--font-size-regular);
-1
View File
@@ -54,7 +54,6 @@ export interface IProject {
updated_by: string;
workspace: IWorkspace | string;
workspace_detail: IWorkspaceLite;
timezone: string;
}
export interface IProjectLite {
-1
View File
@@ -91,7 +91,6 @@ export interface IWorkspaceMemberMe {
updated_by: string;
view_props: IWorkspaceViewProps;
workspace: string;
draft_issue_count: number;
}
export interface ILastActiveWorkspaceDetails {
-1
View File
@@ -31,4 +31,3 @@ export * from "./favorite-folder-icon";
export * from "./planned-icon";
export * from "./in-progress-icon";
export * from "./done-icon";
export * from "./pending-icon";
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const PendingState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color = "#455068" }) => (
<svg
width={width}
height={height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3ZM1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12Z"
fill={color}
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 5C12.5523 5 13 5.44772 13 6V11.382L16.4472 13.1056C16.9412 13.3526 17.1414 13.9532 16.8944 14.4472C16.6474 14.9412 16.0468 15.1414 15.5528 14.8944L11.5528 12.8944C11.214 12.725 11 12.3788 11 12V6C11 5.44772 11.4477 5 12 5Z"
fill={color}
/>
</svg>
);
@@ -16,7 +16,7 @@ export const WorkspaceActiveCycleHeader = observer(() => (
type="text"
link={
<BreadcrumbLink
label="Active cycles"
label="Active Cycles"
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
/>
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { PenSquare } from "lucide-react";
// ui
@@ -11,17 +11,16 @@ import { CreateUpdateIssueModal } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store";
import { useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store";
// plane-web
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const WorkspaceDraftHeader = observer(() => {
export const WorkspaceDraftHeader: FC = observer(() => {
// state
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
const { paginationInfo } = useWorkspaceDraftIssues();
const { joinedProjectIds } = useProject();
// check if user is authorized to create draft issue
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -42,7 +41,7 @@ export const WorkspaceDraftHeader = observer(() => {
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label={`Drafts`} icon={<PenSquare className="h-4 w-4 text-custom-text-300" />} />}
link={<BreadcrumbLink label={`Draft`} icon={<PenSquare className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
{paginationInfo?.total_count && paginationInfo?.total_count > 0 ? (
@@ -54,17 +53,15 @@ export const WorkspaceDraftHeader = observer(() => {
</Header.LeftItem>
<Header.RightItem>
{joinedProjectIds && joinedProjectIds.length > 0 && (
<Button
variant="primary"
size="sm"
className="items-center gap-1"
onClick={() => setIsDraftIssueModalOpen(true)}
disabled={!isAuthorizedUser}
>
Draft<span className="hidden sm:inline-block"> an issue</span>
</Button>
)}
<Button
variant="primary"
size="sm"
className="items-center gap-1"
onClick={() => setIsDraftIssueModalOpen(true)}
disabled={!isAuthorizedUser}
>
Draft <span className="hidden sm:inline-block">issue</span>
</Button>
</Header.RightItem>
</Header>
</>
@@ -8,7 +8,7 @@ import { RefreshCcw } from "lucide-react";
import { Breadcrumbs, Button, Intake, Header } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { InboxIssueCreateModalRoot } from "@/components/inbox";
import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
// hooks
import { useProject, useProjectInbox, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
@@ -69,11 +69,12 @@ export const ProjectInboxHeader: FC = observer(() => {
<Header.RightItem>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2">
<InboxIssueCreateModalRoot
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)}
issue={undefined}
/>
<Button variant="primary" size="sm" onClick={() => setCreateIssueModal(true)}>
@@ -1,5 +1,4 @@
import { FC, useEffect, useRef } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
@@ -17,9 +16,8 @@ import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/f
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import useSize from "@/hooks/use-window-size";
// plane web components
import useSize from "@/hooks/use-window-size";
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
@@ -27,7 +25,6 @@ export const AppSidebar: FC = observer(() => {
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { groupedFavorites } = useFavorite();
const windowSize = useSize();
// refs
const ref = useRef<HTMLDivElement>(null);
@@ -51,8 +48,6 @@ export const AppSidebar: FC = observer(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
const isFavoriteEmpty = isEmpty(groupedFavorites);
return (
<div
className={cn(
@@ -96,7 +91,7 @@ export const AppSidebar: FC = observer(() => {
"opacity-0": !sidebarCollapsed,
})}
/>
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{canPerformWorkspaceMemberActions && <SidebarFavoritesMenu />}
<SidebarProjectsList />
</div>
+198 -218
View File
@@ -6,23 +6,14 @@ import { Controller, useForm } from "react-hook-form";
import { ChevronDown, CircleUserRound } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import type { IUser } from "@plane/types";
import {
Button,
CustomSelect,
CustomSearchSelect,
Input,
TOAST_TYPE,
setPromiseToast,
setToast,
Tooltip,
} from "@plane/ui";
import { Button, CustomSelect, CustomSearchSelect, Input, TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// components
import { DeactivateAccountModal } from "@/components/account";
import { LogoSpinner } from "@/components/common";
import { ImagePickerPopover, UserImageUploadModal, PageHead } from "@/components/core";
import { ProfileSettingContentWrapper } from "@/components/profile";
// constants
import { TIME_ZONES, TTimezone } from "@/constants/timezones";
import { TIME_ZONES } from "@/constants/timezones";
import { USER_ROLES } from "@/constants/workspace";
// helpers
import { getFileURL } from "@/helpers/file.helper";
@@ -116,20 +107,10 @@ const ProfileSettingsPage = observer(() => {
});
};
const getTimeZoneLabel = (timezone: TTimezone | undefined) => {
if (!timezone) return undefined;
return (
<div className="flex gap-1.5">
<span className="text-custom-text-400">{timezone.gmtOffset}</span>
<span className="text-custom-text-200">{timezone.name}</span>
</div>
);
};
const timeZoneOptions = TIME_ZONES.map((timeZone) => ({
value: timeZone.value,
query: timeZone.name + " " + timeZone.gmtOffset + " " + timeZone.value,
content: getTimeZoneLabel(timeZone),
query: timeZone.label + " " + timeZone.value,
content: timeZone.label,
}));
if (!currentUser)
@@ -162,14 +143,14 @@ const ProfileSettingsPage = observer(() => {
/>
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full flex-col gap-6">
<div className="flex w-full flex-col gap-8">
<div className="relative h-44 w-full">
<img
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
className="h-44 w-full rounded-lg object-cover"
alt={currentUser?.first_name ?? "Cover image"}
/>
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
<div className="absolute -bottom-6 left-8 flex items-end justify-between">
<div className="flex gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-custom-background-90">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
@@ -192,6 +173,7 @@ const ProfileSettingsPage = observer(() => {
</div>
</div>
</div>
<div className="absolute bottom-3 right-3 flex">
<Controller
control={control}
@@ -208,207 +190,201 @@ const ProfileSettingsPage = observer(() => {
/>
</div>
</div>
<div className="item-center mt-6 flex justify-between">
<div className="item-center mt-4 flex justify-between md:px-8">
<div className="flex flex-col">
<div className="item-center flex text-lg font-medium text-custom-text-200">
<div className="item-center flex text-lg font-semibold text-custom-text-100">
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
</div>
<span className="text-sm text-custom-text-300 tracking-tight">{watch("email")}</span>
<span className="text-sm tracking-tight">{watch("email")}</span>
</div>
{/* <Link href={`/profile/${currentUser.id}`}>
<span className="flex item-center gap-1 text-sm text-custom-primary-100 underline font-medium">
<ExternalLink className="h-4 w-4" />
Activity Overview
</span>
</Link> */}
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
First name<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="first_name"
rules={{
required: "Please enter first name",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="Enter your first name"
className={`w-full rounded-md ${errors.first_name ? "border-red-500" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
/>
{errors.first_name && <span className="text-xs text-red-500">{errors.first_name.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">Last name</h4>
<Controller
control={control}
name="last_name"
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Enter your last name"
className="w-full rounded-md"
maxLength={24}
autoComplete="on"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Display name<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="display_name"
rules={{
required: "Display name is required.",
validate: (value) => {
if (value.trim().length < 1) return "Display name can't be empty.";
if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces.";
if (value.replace(/\s/g, "").length < 1)
return "Display name must be at least 1 character long.";
if (value.replace(/\s/g, "").length > 20)
return "Display name must be less than 20 characters long.";
return true;
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="display_name"
name="display_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors?.display_name)}
placeholder="Enter your display name"
className={`w-full ${errors?.display_name ? "border-red-500" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && (
<span className="text-xs text-red-500">{errors?.display_name?.message}</span>
<div className="grid grid-cols-1 gap-6 md:px-8 lg:grid-cols-2 2xl:grid-cols-3 pb-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">
First name<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="first_name"
rules={{
required: "First name is required.",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="first_name"
name="first_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.first_name)}
placeholder="Enter your first name"
className={`w-full rounded-md ${errors.first_name ? "border-red-500" : ""}`}
maxLength={24}
autoComplete="on"
/>
)}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Email<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="email"
rules={{
required: "Email is required.",
}}
render={({ field: { value, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email"
className={`w-full cursor-not-allowed rounded-md !bg-custom-background-90 ${
errors.email ? "border-red-500" : ""
}`}
autoComplete="on"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Role<span className="text-red-500">*</span>
</h4>
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
/>
{errors.first_name && <span className="text-xs text-red-500">Please enter first name</span>}
</div>
</div>
<div className="flex flex-col gap-2 pt-4">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">
Timezone<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Please select a timezone" }}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={
value
? (getTimeZoneLabel(TIME_ZONES.find((t) => t.value === value)) ?? value)
: "Select a timezone"
}
options={timeZoneOptions}
onChange={onChange}
buttonClassName={errors.user_timezone ? "border-red-500" : ""}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-72"
input
/>
)}
/>
{errors.user_timezone && <span className="text-xs text-red-500">{errors.user_timezone.message}</span>}
</div>
<Tooltip tooltipContent="Coming soon" position="bottom">
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-custom-text-200">Language</h4>
<CustomSearchSelect
value="English (US)"
label="English (US)"
options={[]}
onChange={() => {}}
className="rounded-md bg-custom-background-90"
input
<div className="flex flex-col gap-1">
<h4 className="text-sm">Last name</h4>
<Controller
control={control}
name="last_name"
render={({ field: { value, onChange, ref } }) => (
<Input
id="last_name"
name="last_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.last_name)}
placeholder="Enter your last name"
className="w-full rounded-md"
maxLength={24}
autoComplete="on"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">
Email<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="email"
rules={{
required: "Email is required.",
}}
render={({ field: { value, ref } }) => (
<Input
id="email"
name="email"
type="email"
value={value}
ref={ref}
hasError={Boolean(errors.email)}
placeholder="Enter your email"
className={`w-full cursor-not-allowed rounded-md !bg-custom-background-80 ${
errors.email ? "border-red-500" : ""
}`}
autoComplete="on"
disabled
/>
</div>
</Tooltip>
)}
/>
</div>
<div className="flex items-center justify-between pt-6 pb-8">
<div className="flex flex-col gap-1">
<h4 className="text-sm">
Role<span className="text-red-500">*</span>
</h4>
<Controller
name="role"
control={control}
rules={{ required: "Role is required." }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
optionsClassName="w-full"
input
>
{USER_ROLES.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a role</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">
Display name<span className="text-red-500">*</span>
</h4>
<Controller
control={control}
name="display_name"
rules={{
required: "Display name is required.",
validate: (value) => {
if (value.trim().length < 1) return "Display name can't be empty.";
if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces.";
if (value.replace(/\s/g, "").length < 1)
return "Display name must be at least 1 characters long.";
if (value.replace(/\s/g, "").length > 20)
return "Display name must be less than 20 characters long.";
return true;
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="display_name"
name="display_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors?.display_name)}
placeholder="Enter your display name"
className={`w-full ${errors?.display_name ? "border-red-500" : ""}`}
maxLength={24}
/>
)}
/>
{errors?.display_name && <span className="text-xs text-red-500">{errors?.display_name?.message}</span>}
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">
Timezone<span className="text-red-500">*</span>
</h4>
<Controller
name="user_timezone"
control={control}
rules={{ required: "Time zone is required" }}
render={({ field: { value, onChange } }) => (
<CustomSearchSelect
value={value}
label={value ? (TIME_ZONES.find((t) => t.value === value)?.label ?? value) : "Select a timezone"}
options={timeZoneOptions}
onChange={onChange}
buttonClassName={errors.user_timezone ? "border-red-500" : "border-none"}
className="rounded-md border-[0.5px] !border-custom-border-200"
input
/>
)}
/>
{errors.role && <span className="text-xs text-red-500">Please select a time zone</span>}
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" type="submit" loading={isLoading}>
{isLoading ? "Saving..." : "Save changes"}
</Button>
@@ -416,11 +392,11 @@ const ProfileSettingsPage = observer(() => {
</div>
</div>
</form>
<Disclosure as="div" className="border-t border-custom-border-100">
<Disclosure as="div" className="border-t border-custom-border-100 md:px-8">
{({ open }) => (
<>
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
<span className="text-lg font-medium tracking-tight">Deactivate account</span>
<span className="text-lg tracking-tight">Deactivate account</span>
<ChevronDown className={`h-5 w-5 transition-all ${open ? "rotate-180" : ""}`} />
</Disclosure.Button>
<Transition
@@ -454,4 +430,8 @@ const ProfileSettingsPage = observer(() => {
);
});
// ProfileSettingsPage.getLayout = function getLayout(page: ReactElement) {
// return <ProfileSettingsLayout>{page}</ProfileSettingsLayout>;
// };
export default ProfileSettingsPage;
@@ -2,11 +2,10 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
// plane ui
import { TCycleEstimateType } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EstimateTypeDropdown, validateCycleSnapshot } from "@/components/cycles";
import { validateCycleSnapshot } from "@/components/cycles";
// helpers
import { getDate } from "@/helpers/date-time.helper";
// hooks
@@ -21,8 +20,7 @@ export const SidebarChart: FC<ProgressChartProps> = observer((props) => {
const { workspaceSlug, projectId, cycleId } = props;
// hooks
const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } =
useCycle();
const { getEstimateTypeByCycleId, getCycleById } = useCycle();
// derived data
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
@@ -39,57 +37,33 @@ export const SidebarChart: FC<ProgressChartProps> = observer((props) => {
if (!workspaceSlug || !projectId || !cycleId) return null;
const isArchived = !!cycleDetails?.archived_at;
// handlers
const onChange = async (value: TCycleEstimateType) => {
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
if (isArchived) {
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
} else {
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
}
} catch (err) {
console.error(err);
setEstimateType(cycleId, estimateType);
}
};
return (
<>
<div className="relative flex items-center justify-between gap-2 pt-4">
<EstimateTypeDropdown value={estimateType} onChange={onChange} cycleId={cycleId} projectId={projectId} />
</div>
<div className="py-4">
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={estimateType === "points" ? totalEstimatePoints : totalIssues}
plotTitle={estimateType === "points" ? "points" : "issues"}
/>
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={estimateType === "points" ? totalEstimatePoints : totalIssues}
plotTitle={estimateType === "points" ? "points" : "issues"}
/>
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
);
});
@@ -3,7 +3,6 @@ type TIssueAdditionalPropertiesProps = {
issueTypeId: string | null;
projectId: string;
workspaceSlug: string;
isDraft?: boolean;
};
export const IssueAdditionalProperties: React.FC<TIssueAdditionalPropertiesProps> = () => <></>;
@@ -1,8 +1,8 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ICycle, TCycleEstimateType } from "@plane/types";
import { Loader } from "@plane/ui";
import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types";
import { CustomSelect, Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EmptyState } from "@/components/empty-state";
@@ -11,7 +11,6 @@ import { EmptyStateType } from "@/constants/empty-state";
import { useCycle, useProjectEstimates } from "@/hooks/store";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown";
export type ActiveCycleProductivityProps = {
workspaceSlug: string;
@@ -19,10 +18,16 @@ export type ActiveCycleProductivityProps = {
cycle: ICycle | null;
};
const cycleBurnDownChartOptions = [
{ value: "issues", label: "Issues" },
{ value: "points", label: "Points" },
];
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
// hooks
const { getEstimateTypeByCycleId, setEstimateType } = useCycle();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
@@ -32,6 +37,11 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
setEstimateType(cycle.id, value);
};
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const chartDistributionData =
cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
@@ -42,7 +52,22 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</Link>
<EstimateTypeDropdown value={estimateType} onChange={onChange} cycleId={cycle.id} projectId={projectId} />
{isCurrentEstimateTypeIsPoints && (
<div className="relative flex items-center gap-2">
<CustomSelect
value={estimateType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
{cycleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
)}
</div>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
@@ -7,7 +7,8 @@ import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// components
import { CycleProgressStats } from "@/components/cycles";
// constants
@@ -24,19 +25,6 @@ type TCycleAnalyticsProgress = {
projectId: string;
cycleId: string;
};
type Options = {
value: string;
label: string;
};
export const cycleEstimateOptions: Options[] = [
{ value: "issues", label: "Issues" },
{ value: "points", label: "Points" },
];
export const cycleChartOptions: Options[] = [
{ value: "burndown", label: "Burn-down" },
{ value: "burnup", label: "Burn-up" },
];
export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
if (!cycleDetails || cycleDetails === null) return cycleDetails;
@@ -53,13 +41,32 @@ export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | nul
return updatedCycleDetails;
};
type options = {
value: string;
label: string;
};
export const cycleChartOptions: options[] = [
{ value: "burndown", label: "Burn-down" },
{ value: "burnup", label: "Burn-up" },
];
export const cycleEstimateOptions: options[] = [
{ value: "issues", label: "issues" },
{ value: "points", label: "points" },
];
export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((props) => {
// props
const { workspaceSlug, projectId, cycleId } = props;
// router
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle") || undefined;
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
const {
getPlotTypeByCycleId,
getEstimateTypeByCycleId,
getCycleById,
fetchCycleDetails,
fetchArchivedCycleDetails,
setEstimateType,
} = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
@@ -69,9 +76,21 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const completedIssues = cycleDetails?.completed_issues || 0;
const totalIssues = cycleDetails?.total_issues || 0;
const completedEstimatePoints = cycleDetails?.completed_estimate_points || 0;
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const progressHeaderPercentage = cycleDetails
? estimateType === "points"
? completedEstimatePoints != 0 && totalEstimatePoints != 0
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
: 0
: completedIssues != 0 && completedIssues != 0
? Math.round((completedIssues / totalIssues) * 100)
: 0
: 0;
const chartDistributionData =
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
@@ -96,6 +115,23 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
const isArchived = !!cycleDetails?.archived_at;
// handlers
const onChange = async (value: TCycleEstimateType) => {
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
if (isArchived) {
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
} else {
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
}
} catch (err) {
console.error(err);
setEstimateType(cycleId, estimateType);
}
};
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
@@ -156,7 +192,30 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
<Transition show={open}>
<Disclosure.Panel className="flex flex-col">
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
<div className="relative flex items-center justify-between gap-2 pt-4">
<CustomSelect
value={estimateType}
label={<span>{cycleEstimateOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="border-none rounded text-sm font-medium"
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
<div className="flex items-center justify-center gap-2">
<div className="flex items-center gap-1 text-xs">
<span className="text-custom-text-300">Done</span>
<span className="font-semibold text-custom-text-400">{progressHeaderPercentage}%</span>
</div>
</div>
</div>
<div className="py-4">
<SidebarChartRoot workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
</div>
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 py-4">
@@ -240,7 +240,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycles <br /> can be archived.
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
@@ -1,39 +0,0 @@
import React from "react";
import { TCycleEstimateType } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { useCycle, useProjectEstimates } from "@/hooks/store";
import { cycleEstimateOptions } from "../analytics-sidebar";
type TProps = {
value: TCycleEstimateType;
onChange: (value: TCycleEstimateType) => Promise<void>;
showDefault?: boolean;
projectId: string;
cycleId: string;
};
export const EstimateTypeDropdown = (props: TProps) => {
const { value, onChange, projectId, cycleId, showDefault = false } = props;
const { getIsPointsDataAvailable } = useCycle();
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
return getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled ? (
<div className="relative flex items-center gap-2">
<CustomSelect
value={value}
label={<span>{cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="bg-custom-background-90 border-none rounded text-sm font-medium "
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
) : showDefault ? (
<span className="capitalize">{value}</span>
) : null;
};
@@ -1,2 +1 @@
export * from "./filters";
export * from "./estimate-type-dropdown";
@@ -211,7 +211,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
<>
<button
onClick={openCycleOverview}
className={`z-[1] flex text-custom-primary-200 text-xs gap-1 flex-shrink-0 ${isMobile || (isActive && !searchParams.has("peekCycle")) ? "flex" : "hidden group-hover:flex"}`}
className={`z-[1] flex text-custom-primary-200 text-xs gap-1 flex-shrink-0 ${isMobile || isActive ? "flex" : "hidden group-hover:flex"}`}
>
<Eye className="h-4 w-4 my-auto text-custom-primary-200" />
<span>More details</span>
+1 -1
View File
@@ -115,7 +115,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
key: "archive",
action: handleArchiveCycle,
title: "Archive",
description: isCompleted ? undefined : "Only completed cycles can\nbe archived.",
description: isCompleted ? undefined : "Only completed cycle can\nbe archived.",
icon: ArchiveIcon,
className: "items-start",
iconClassName: "mt-1",
@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import Link from "next/link";
@@ -63,10 +63,6 @@ const WIDGET_KEY = "recent_collaborators";
export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
const { dashboardId, searchQuery = "", workspaceSlug } = props;
// state
const [visibleItems, setVisibleItems] = useState(16);
const [isExpanded, setIsExpanded] = useState(false);
// store hooks
const { fetchWidgetStats } = useDashboard();
const { getUserDetails } = useMember();
@@ -94,10 +90,8 @@ export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]);
const filteredStats = sortedStats.filter((user) => {
if (!user) return false;
const userDetails = getUserDetails(user?.user_id);
if (!userDetails || userDetails.is_bot) return false;
const { display_name, first_name, last_name } = userDetails;
const { display_name, first_name, last_name } = getUserDetails(user?.user_id) || {};
const searchLower = searchQuery.toLowerCase();
return (
display_name?.toLowerCase().includes(searchLower) ||
@@ -106,49 +100,16 @@ export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
);
});
// Update the displayedStats to always use the visibleItems limit
const handleLoadMore = () => {
setVisibleItems((prev) => {
const newValue = prev + 16;
if (newValue >= filteredStats.length) {
setIsExpanded(true);
return filteredStats.length;
}
return newValue;
});
};
const handleHide = () => {
setVisibleItems(16);
setIsExpanded(false);
};
const displayedStats = filteredStats.slice(0, visibleItems);
return (
<>
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
{displayedStats?.map((user) => (
<CollaboratorListItem
key={user?.user_id}
issueCount={user?.active_issue_count}
userId={user?.user_id}
workspaceSlug={workspaceSlug}
/>
))}
</div>
{filteredStats.length > visibleItems && !isExpanded && (
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleLoadMore}>
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">
Load more
</div>
</div>
)}
{isExpanded && (
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleHide}>
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">Hide</div>
</div>
)}
</>
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
{filteredStats?.map((user) => (
<CollaboratorListItem
key={user?.user_id}
issueCount={user?.active_issue_count}
userId={user?.user_id}
workspaceSlug={workspaceSlug}
/>
))}
</div>
);
};
+3 -3
View File
@@ -3,6 +3,8 @@ import { observer } from "mobx-react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react";
// types
import { IProject } from "@plane/types";
// ui
import { ComboDropDown } from "@plane/ui";
// components
@@ -12,8 +14,6 @@ import { cn } from "@/helpers/common.helper";
// hooks
import { useProject } from "@/hooks/store";
import { useDropdown } from "@/hooks/use-dropdown";
// plane web types
import { TProject } from "@/plane-web/types";
// components
import { DropdownButton } from "./buttons";
// constants
@@ -27,7 +27,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string;
onChange: (val: string) => void;
onClose?: () => void;
renderCondition?: (project: TProject) => boolean;
renderCondition?: (project: IProject) => boolean;
value: string | null;
renderByDefault?: boolean;
};
@@ -21,11 +21,12 @@ import {
DeclineIssueModal,
DeleteInboxIssueModal,
InboxIssueActionsMobileHeader,
InboxIssueCreateEditModalRoot,
InboxIssueSnoozeModal,
InboxIssueStatus,
SelectDuplicateInboxIssueModal,
} from "@/components/inbox";
import { CreateUpdateIssueModal, IssueUpdateStatus } from "@/components/issues";
import { IssueUpdateStatus } from "@/components/issues";
// helpers
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { EInboxIssueStatus } from "@/helpers/inbox.helper";
@@ -69,7 +70,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox();
const { data: currentUser } = useUser();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails } = useProject();
const router = useAppRouter();
const { getProjectById } = useProject();
@@ -234,19 +234,16 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
value={inboxIssue?.duplicate_to}
onSubmit={handleInboxIssueDuplicate}
/>
<CreateUpdateIssueModal
data={inboxIssue?.issue}
isOpen={acceptIssueModal}
onClose={() => setAcceptIssueModal(false)}
beforeFormSubmit={handleInboxIssueAccept}
withDraftIssueWrapper={false}
fetchIssueDetails={false}
modalTitle={`Move ${currentProjectDetails?.identifier}-${issue?.sequence_id} to project issues`}
primaryButtonText={{
default: "Add to project",
loading: "Adding",
}}
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={acceptIssueModal}
handleModalClose={() => setAcceptIssueModal(false)}
issue={inboxIssue?.issue}
onSubmit={handleInboxIssueAccept}
/>
<DeclineIssueModal
data={inboxIssue?.issue || {}}
isOpen={declineIssueModal}
@@ -9,7 +9,11 @@ import { EditorRefApi } from "@plane/editor";
import { TIssue } from "@plane/types";
import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { InboxIssueTitle, InboxIssueDescription, InboxIssueProperties } from "@/components/inbox/modals/create-modal";
import {
InboxIssueTitle,
InboxIssueDescription,
InboxIssueProperties,
} from "@/components/inbox/modals/create-edit-modal";
// constants
import { ISSUE_CREATED } from "@/constants/event-tracker";
import { ETabIndices } from "@/constants/tab-indices";
@@ -197,7 +201,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
role="button"
tabIndex={getIndex("create_more")}
>
<ToggleSwitch value={createMore} onChange={() => { }} size="sm" />
<ToggleSwitch value={createMore} onChange={() => {}} size="sm" />
<span className="text-xs">Create more</span>
</div>
<div className="flex items-center gap-3">
@@ -0,0 +1,177 @@
"use client";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// editor
import { EditorRefApi } from "@plane/editor";
// types
import { TIssue } from "@plane/types";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import {
InboxIssueTitle,
InboxIssueDescription,
InboxIssueProperties,
} from "@/components/inbox/modals/create-edit-modal";
// constants
import { ISSUE_UPDATED } from "@/constants/event-tracker";
// helpers
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useInboxIssues, useProject, useWorkspace } from "@/hooks/store";
type TInboxIssueEditRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
issue: Partial<TIssue>;
handleModalClose: () => void;
onSubmit?: () => void;
};
export const InboxIssueEditRoot: FC<TInboxIssueEditRoot> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, handleModalClose, onSubmit } = props;
const pathname = usePathname();
// refs
const descriptionEditorRef = useRef<EditorRefApi>(null);
const submitBtnRef = useRef<HTMLButtonElement | null>(null);
// store hooks
const { captureIssueEvent } = useEventTracker();
const { currentProjectDetails } = useProject();
const { updateProjectIssue } = useInboxIssues(issueId);
const { getWorkspaceBySlug } = useWorkspace();
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
// states
const [formSubmitting, setFormSubmitting] = useState(false);
const [formData, setFormData] = useState<Partial<TIssue> | undefined>(undefined);
const handleFormData = useCallback(
<T extends keyof Partial<TIssue>>(issueKey: T, issueValue: Partial<TIssue>[T]) => {
setFormData({
...formData,
[issueKey]: issueValue,
});
},
[formData]
);
useEffect(() => {
if (formData?.id != issue?.id)
setFormData({
id: issue?.id || undefined,
name: issue?.name ?? "",
description_html: issue?.description_html ?? "<p></p>",
priority: issue?.priority ?? "none",
state_id: issue?.state_id ?? "",
label_ids: issue?.label_ids ?? [],
assignee_ids: issue?.assignee_ids ?? [],
start_date: renderFormattedPayloadDate(issue?.start_date) ?? "",
target_date: renderFormattedPayloadDate(issue?.target_date) ?? "",
});
}, [issue, formData]);
const handleFormSubmit = async () => {
const payload: Partial<TIssue> = {
name: formData?.name || "",
description_html: formData?.description_html || "<p></p>",
priority: formData?.priority || "none",
state_id: formData?.state_id || "",
label_ids: formData?.label_ids || [],
assignee_ids: formData?.assignee_ids || [],
start_date: formData?.start_date || undefined,
target_date: formData?.target_date || undefined,
cycle_id: formData?.cycle_id || "",
module_ids: formData?.module_ids || [],
estimate_point: formData?.estimate_point || undefined,
parent_id: formData?.parent_id || null,
};
setFormSubmitting(true);
onSubmit && (await onSubmit());
await updateProjectIssue(payload)
.then(async () => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: {
...formData,
state: "SUCCESS",
element: "Inbox page",
},
path: pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: `Success!`,
message: "Issue created successfully.",
});
descriptionEditorRef?.current?.clearEditor();
handleModalClose();
})
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: {
...formData,
state: "FAILED",
element: "Inbox page",
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: `Error!`,
message: "Some error occurred. Please try again.",
});
});
setFormSubmitting(false);
};
const isTitleLengthMoreThan255Character = formData?.name ? formData.name.length > 255 : false;
if (!workspaceSlug || !projectId || !workspaceId || !formData) return <></>;
return (
<>
<div className="space-y-5 p-5">
<h3 className="text-xl font-medium text-custom-text-200">
Move {currentProjectDetails?.identifier}-{issue?.sequence_id} to project issues
</h3>
<div className="space-y-3">
<InboxIssueTitle
data={formData}
handleData={handleFormData}
isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character}
/>
<InboxIssueDescription
workspaceSlug={workspaceSlug}
projectId={projectId}
workspaceId={workspaceId}
data={formData}
handleData={handleFormData}
editorRef={descriptionEditorRef}
containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]"
onEnterKeyPress={() => submitBtnRef?.current?.click()}
/>
<InboxIssueProperties projectId={projectId} data={formData} handleData={handleFormData} isVisible />
</div>
</div>
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
type="button"
ref={submitBtnRef}
loading={formSubmitting}
disabled={isTitleLengthMoreThan255Character}
onClick={handleFormSubmit}
>
{formSubmitting ? "Adding" : "Add to project"}
</Button>
</div>
</>
);
});
@@ -1,5 +1,6 @@
export * from "./modal";
export * from "./create-root";
export * from "./edit-root";
export * from "./issue-title";
export * from "./issue-description";
export * from "./issue-properties";
@@ -91,7 +91,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
{/* labels */}
<div className="h-7">
<IssueLabelSelect
setIsOpen={() => { }}
setIsOpen={() => {}}
value={data?.label_ids || []}
onChange={(labelIds) => handleData("label_ids", labelIds)}
projectId={projectId}
@@ -0,0 +1,42 @@
import { FC } from "react";
// types
import { TIssue } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { InboxIssueCreateRoot, InboxIssueEditRoot } from "@/components/inbox/modals/create-edit-modal";
type TInboxIssueCreateEditModalRoot = {
workspaceSlug: string;
projectId: string;
modalState: boolean;
handleModalClose: () => void;
issue: Partial<TIssue> | undefined;
onSubmit?: () => void;
};
export const InboxIssueCreateEditModalRoot: FC<TInboxIssueCreateEditModalRoot> = (props) => {
const { workspaceSlug, projectId, modalState, handleModalClose, issue, onSubmit } = props;
return (
<ModalCore
isOpen={modalState}
handleClose={handleModalClose}
position={EModalPosition.TOP}
width={EModalWidth.XXXXL}
>
{issue && issue?.id ? (
<InboxIssueEditRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issue.id}
issue={issue}
handleModalClose={handleModalClose}
onSubmit={onSubmit}
/>
) : (
<InboxIssueCreateRoot workspaceSlug={workspaceSlug} projectId={projectId} handleModalClose={handleModalClose} />
)}
</ModalCore>
);
};

Some files were not shown because too many files have changed in this diff Show More