Compare commits

..
Author SHA1 Message Date
sriram veeraghantaandGitHub 707570ca7a Merge pull request #5041 from makeplane/preview
release: v0.22-dev
2024-07-05 13:28:45 +05:30
sriram veeraghantaandGitHub c76af7d7d6 Merge pull request #4688 from makeplane/preview
release: v0.21-dev
2024-06-03 18:54:06 +05:30
sriram veeraghantaandGitHub 1dcea9bcc8 Merge pull request #4569 from makeplane/preview
release: v0.20-dev
2024-05-23 19:55:06 +05:30
sriram veeraghantaandGitHub da957e06b6 Merge pull request #4349 from makeplane/preview
release: v0.19-dev
2024-05-03 20:36:07 +05:30
sriram veeraghantaandGitHub a0b9596cb4 Merge pull request #4239 from makeplane/preview
chore:version update
2024-04-19 12:01:15 +05:30
sriram veeraghantaandGitHub f71e8a3a0f Merge pull request #4238 from makeplane/preview
release: v0.18-dev
2024-04-19 11:56:03 +05:30
sriram veeraghantaandGitHub 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghantaandGitHub c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghantaandGitHub 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghantaandGitHub e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghantaandGitHub 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghantaandGitHub 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghantaandGitHub 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghantaandGitHub c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
161 changed files with 1564 additions and 4765 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Bug report
description: Create a bug report to help us improve Plane
title: "[bug]: "
labels: [🐛bug]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
@@ -2,7 +2,7 @@ name: Feature request
description: Suggest a feature to improve Plane
title: "[feature]: "
labels: [✨feature]
assignees: [vihar, pushya22]
assignees: [srinivaspendem, pushya22]
body:
- type: markdown
attributes:
+5 -9
View File
@@ -31,7 +31,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }}
flat_branch_name: ${{ steps.set_env_variables.outputs.FLAT_BRANCH_NAME }}
gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }}
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
@@ -57,7 +56,7 @@ jobs:
echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT
echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT
if [ "${{ github.event_name}}" == "workflow_dispatch" ] && [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
if [ "${{ github.event.inputs.base_tag_name }}" != "" ]; then
echo "AIO_BASE_TAG=${{ github.event.inputs.base_tag_name }}" >> $GITHUB_OUTPUT
elif [ "${{ env.TARGET_BRANCH }}" == "preview" ]; then
echo "AIO_BASE_TAG=preview" >> $GITHUB_OUTPUT
@@ -79,9 +78,6 @@ jobs:
echo "DO_SLIM_BUILD=false" >> $GITHUB_OUTPUT
fi
FLAT_BRANCH_NAME=$(echo "${{ env.TARGET_BRANCH }}" | sed 's/[^a-zA-Z0-9]/-/g')
echo "FLAT_BRANCH_NAME=$FLAT_BRANCH_NAME" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
@@ -93,7 +89,7 @@ jobs:
env:
BUILD_TYPE: full
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:full-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -136,7 +132,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -153,7 +149,7 @@ jobs:
env:
BUILD_TYPE: slim
AIO_BASE_TAG: ${{ needs.branch_build_setup.outputs.aio_base_tag }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.flat_branch_name }}
AIO_IMAGE_TAGS: makeplane/plane-aio:slim-${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
@@ -196,7 +192,7 @@ jobs:
tags: ${{ env.AIO_IMAGE_TAGS }}
push: true
build-args: |
BASE_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TAG=${{ env.AIO_BASE_TAG }}
BUILD_TYPE=${{env.BUILD_TYPE}}
cache-from: type=gha
cache-to: type=gha,mode=max
+3 -3
View File
@@ -13,9 +13,9 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True)
total_estimates = serializers.FloatField(read_only=True)
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
total_estimates = serializers.IntegerField(read_only=True)
completed_estimates = serializers.IntegerField(read_only=True)
started_estimates = serializers.IntegerField(read_only=True)
def validate(self, data):
if (
+5 -15
View File
@@ -11,7 +11,6 @@ from rest_framework import serializers
# Module imports
from plane.db.models import (
Issue,
IssueType,
IssueActivity,
IssueAssignee,
IssueAttachment,
@@ -132,12 +131,7 @@ class IssueSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Get the issue type from the project
issue_type = IssueType.objects.filter(project_id=project_id).first()
issue = Issue.objects.create(
**validated_data, project_id=project_id, type=issue_type
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -318,14 +312,10 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
-26
View File
@@ -1140,38 +1140,12 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
-4
View File
@@ -16,7 +16,6 @@ from plane.app.permissions import ProjectLitePermission
from plane.bgtasks.issue_activites_task import issue_activity
from plane.db.models import (
Inbox,
IssueType,
InboxIssue,
Issue,
Project,
@@ -142,8 +141,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
color="#ff7700",
is_triage=True,
)
# Get the issue type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# create an issue
issue = Issue.objects.create(
@@ -155,7 +152,6 @@ class InboxIssueAPIEndpoint(BaseAPIView):
priority=request.data.get("issue", {}).get("priority", "none"),
project_id=project_id,
state=state,
type=issue_type,
)
# create an inbox issue
-9
View File
@@ -26,7 +26,6 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.bgtasks.webhook_task import model_activity
from .base import BaseAPIView
@@ -241,14 +240,6 @@ class ProjectAPIEndpoint(BaseAPIView):
.filter(pk=serializer.data["id"])
.first()
)
# Create the Issue Types
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
# Model activity
model_activity.delay(
model_name="project",
@@ -91,7 +91,6 @@ from .page import (
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
)
from .estimate import (
+6 -16
View File
@@ -33,7 +33,6 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueType,
)
@@ -136,12 +135,7 @@ class IssueCreateSerializer(BaseSerializer):
workspace_id = self.context["workspace_id"]
default_assignee_id = self.context["default_assignee_id"]
# Get Issue Type
issue_type = IssueType.objects.filter(project_id=project_id).first()
# Create Issue
issue = Issue.objects.create(
**validated_data, project_id=project_id, type=issue_type
)
issue = Issue.objects.create(**validated_data, project_id=project_id)
# Issue Audit Users
created_by_id = issue.created_by_id
@@ -465,14 +459,10 @@ class IssueLinkSerializer(BaseSerializer):
return IssueLink.objects.create(**validated_data)
def update(self, instance, validated_data):
if (
IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
)
.exclude(pk=instance.id)
.exists()
):
if IssueLink.objects.filter(
url=validated_data.get("url"),
issue_id=instance.issue_id,
).exclude(pk=instance.id).exists():
raise serializers.ValidationError(
{"error": "URL already exists for this Issue"}
)
@@ -519,7 +509,7 @@ class IssueAttachmentLiteSerializer(DynamicBaseSerializer):
"attributes",
"issue_id",
"updated_at",
"updated_by",
"updated_by_id",
]
read_only_fields = fields
-11
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
Label,
ProjectPage,
Project,
PageVersion,
)
@@ -162,13 +161,3 @@ class PageLogSerializer(BaseSerializer):
"workspace",
"page",
]
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
read_only_fields = [
"workspace",
"page",
]
-11
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
PageLogEndpoint,
SubPagesEndpoint,
PagesDescriptionViewSet,
PageVersionEndpoint,
)
@@ -91,14 +90,4 @@ urlpatterns = [
),
name="page-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/versions/<uuid:pk>/",
PageVersionEndpoint.as_view(),
name="page-versions",
),
]
-1
View File
@@ -179,7 +179,6 @@ from .page.base import (
SubPagesEndpoint,
PagesDescriptionViewSet,
)
from .page.version import PageVersionEndpoint
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
+8 -34
View File
@@ -465,14 +465,14 @@ class CycleViewSet(BaseViewSet):
}
if data[0]["start_date"] and data[0]["end_date"]:
data[0]["estimate_distribution"][
"completion_chart"
] = burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=data[0]["id"],
data[0]["estimate_distribution"]["completion_chart"] = (
burndown_plot(
queryset=queryset.first(),
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=data[0]["id"],
)
)
assignee_distribution = (
@@ -1507,40 +1507,14 @@ class TransferCycleIssueEndpoint(BaseAPIView):
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -542,7 +542,6 @@ def dashboard_recent_collaborators(self, request, slug):
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
is_active=True,
)
.annotate(
num_activities=Coalesce(
+35 -134
View File
@@ -3,11 +3,8 @@ import json
# Django imports
from django.utils import timezone
from django.db.models import Q, OuterRef, F, Func, UUIDField, Value, CharField
from django.db.models import Q
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.functions import Coalesce
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
# Third Party imports
from rest_framework.response import Response
@@ -23,9 +20,6 @@ from plane.app.permissions import ProjectEntityPermission
from plane.db.models import (
Project,
IssueRelation,
Issue,
IssueAttachment,
IssueLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
@@ -67,149 +61,56 @@ class IssueRelationViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
# get all blocking issues
blocking_issues = issue_relations.filter(
relation_type="blocked_by", related_issue_id=issue_id
).values_list("issue_id", flat=True)
# get all blocked by issues
)
blocked_by_issues = issue_relations.filter(
relation_type="blocked_by", issue_id=issue_id
).values_list("related_issue_id", flat=True)
# get all duplicate issues
)
duplicate_issues = issue_relations.filter(
issue_id=issue_id, relation_type="duplicate"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
duplicate_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="duplicate"
).values_list("issue_id", flat=True)
# get all relates to issues
)
relates_to_issues = issue_relations.filter(
issue_id=issue_id, relation_type="relates_to"
).values_list("related_issue_id", flat=True)
# get all relates to issues
)
relates_to_issues_related = issue_relations.filter(
related_issue_id=issue_id, relation_type="relates_to"
).values_list("issue_id", flat=True)
)
queryset = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.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=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).distinct()
blocked_by_issues_serialized = IssueRelationSerializer(
blocked_by_issues, many=True
).data
duplicate_issues_serialized = IssueRelationSerializer(
duplicate_issues, many=True
).data
relates_to_issues_serialized = IssueRelationSerializer(
relates_to_issues, many=True
).data
# Fields
fields = [
"id",
"name",
"state_id",
"sort_order",
"priority",
"sequence_id",
"project_id",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
"relation_type",
]
# revere relation for blocked by issues
blocking_issues_serialized = RelatedIssueSerializer(
blocking_issues, many=True
).data
# reverse relation for duplicate issues
duplicate_issues_related_serialized = RelatedIssueSerializer(
duplicate_issues_related, many=True
).data
# reverse relation for related issues
relates_to_issues_related_serialized = RelatedIssueSerializer(
relates_to_issues_related, many=True
).data
response_data = {
"blocking": queryset.filter(pk__in=blocking_issues)
.annotate(
relation_type=Value("blocking", output_field=CharField())
)
.values(*fields),
"blocked_by": queryset.filter(pk__in=blocked_by_issues)
.annotate(
relation_type=Value("blocked_by", output_field=CharField())
)
.values(*fields),
"duplicate": queryset.filter(pk__in=duplicate_issues)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=duplicate_issues_related)
.annotate(
relation_type=Value(
"duplicate",
output_field=CharField(),
)
)
.values(*fields),
"relates_to": queryset.filter(pk__in=relates_to_issues)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields)
| queryset.filter(pk__in=relates_to_issues_related)
.annotate(
relation_type=Value(
"relates_to",
output_field=CharField(),
)
)
.values(*fields),
"blocking": blocking_issues_serialized,
"blocked_by": blocked_by_issues_serialized,
"duplicate": duplicate_issues_serialized
+ duplicate_issues_related_serialized,
"relates_to": relates_to_issues_serialized
+ relates_to_issues_related_serialized,
}
return Response(response_data, status=status.HTTP_200_OK)
+2 -26
View File
@@ -45,7 +45,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
archived = request.GET.get("archived", "false")
read = request.GET.get("read", None)
type = request.GET.get("type", "all")
mentioned = request.GET.get("mentioned", False)
q_filters = Q()
inbox_issue = Issue.objects.filter(
@@ -87,13 +86,6 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
if read == "true":
notifications = notifications.filter(read_at__isnull=False)
if mentioned:
notifications = notifications.filter(sender__icontains="mentioned")
else:
notifications = notifications.exclude(
sender__icontains="mentioned"
)
type = type.split(",")
# Subscribed issues
if "subscribed" in type:
@@ -218,35 +210,19 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
class UnreadNotificationEndpoint(BaseAPIView):
def get(self, request, slug):
# Watching Issues Count
unread_notifications_count = (
Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
)
.exclude(sender__icontains="mentioned")
.count()
)
mention_notifications_count = Notification.objects.filter(
unread_notifications_count = Notification.objects.filter(
workspace__slug=slug,
receiver_id=request.user.id,
read_at__isnull=True,
archived_at__isnull=True,
snoozed_till__isnull=True,
sender__icontains="mentioned",
).count()
return Response(
{
"total_unread_notifications_count": int(
unread_notifications_count
),
"mention_unread_notifications_count": int(
mention_notifications_count
),
)
},
status=status.HTTP_200_OK,
)
+2 -31
View File
@@ -38,7 +38,6 @@ from plane.db.models import (
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -216,14 +215,8 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND,
)
else:
issue_ids = PageLog.objects.filter(
page_id=pk, entity_name="issue"
).values_list("entity_identifier", flat=True)
data = PageDetailSerializer(page).data
data["issue_ids"] = issue_ids
return Response(
data,
status=status.HTTP_200_OK,
PageDetailSerializer(page).data, status=status.HTTP_200_OK
)
def lock(self, request, slug, project_id, pk):
@@ -482,38 +475,16 @@ class PagesDescriptionViewSet(BaseViewSet):
status=472,
)
# Serialize the existing instance
existing_instance = json.dumps(
{
"description_html": page.description_html,
},
cls=DjangoJSONEncoder,
)
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=existing_instance,
page_id=pk,
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
else:
return Response({"error": "No binary data provided"})
-37
View File
@@ -1,37 +0,0 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
class PageVersionEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
# Return a single page version
page_version = PageVersion.objects.get(
workspace__slug=slug,
page_id=page_id,
pk=pk,
)
# Serialize the page version
serializer = PageVersionSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
workspace__slug=slug,
page_id=page_id,
)
# Serialize the page versions
serializer = PageVersionSerializer(page_versions, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -47,7 +47,6 @@ from plane.db.models import (
ProjectMember,
State,
Workspace,
IssueType,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity
@@ -343,13 +342,6 @@ class ProjectViewSet(BaseViewSet):
.first()
)
# Create the issue type
IssueType.objects.create(
name="Task",
description="A task that needs to be done",
project_id=project.id,
)
model_activity.delay(
model_name="project",
model_id=str(project.id),
@@ -1,4 +1,5 @@
# Python imports
import re
# Django imports
from django.db.models import Q
@@ -10,7 +11,13 @@ from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.db.models import (
Workspace,
Project,
Issue,
Cycle,
Module,
Page,
IssueView,
)
from plane.utils.issue_search import search_issues
-12
View File
@@ -37,9 +37,6 @@ from plane.utils.paginator import BasePaginator
from plane.authentication.utils.host import user_ip
from plane.bgtasks.user_deactivation_email_task import user_deactivation_email
from plane.utils.host import base_host
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
class UserEndpoint(BaseViewSet):
@@ -50,8 +47,6 @@ class UserEndpoint(BaseViewSet):
return self.request.user
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve(self, request):
serialized_data = UserMeSerializer(request.user).data
return Response(
@@ -60,8 +55,6 @@ class UserEndpoint(BaseViewSet):
)
@cache_response(60 * 60)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def retrieve_user_settings(self, request):
serialized_data = UserMeSettingsSerializer(request.user).data
return Response(serialized_data, status=status.HTTP_200_OK)
@@ -86,9 +79,6 @@ class UserEndpoint(BaseViewSet):
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/users/me/")
@invalidate_cache(
path="/api/users/me/workspaces/", multiple=True, user=False
)
def deactivate(self, request):
# Check all workspace user is active
user = self.get_object()
@@ -298,8 +288,6 @@ class AccountEndpoint(BaseAPIView):
class ProfileEndpoint(BaseAPIView):
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
profile = Profile.objects.get(user=request.user)
serializer = ProfileSerializer(profile)
@@ -44,9 +44,6 @@ from plane.db.models import (
WorkspaceTheme,
)
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
@@ -175,8 +172,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
]
@cache_response(60 * 60 * 2)
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [
field
@@ -21,7 +21,6 @@ from plane.db.models import (
Cycle,
Module,
Issue,
IssueType,
IssueSequence,
IssueAssignee,
IssueLabel,
@@ -337,12 +336,6 @@ def create_issues(workspace, project, user_id, issue_count):
65535 if largest_sort_order is None else largest_sort_order + 10000
)
issue_type = IssueType.objects.create(
name="Task",
description="A task that needs to be completed.",
project=project,
)
for _ in range(0, issue_count):
start_date = [None, fake.date_this_year()][random.randint(0, 1)]
end_date = (
@@ -371,7 +364,6 @@ def create_issues(workspace, project, user_id, issue_count):
random.randint(0, 4)
],
created_by_id=creators[random.randint(0, len(creators) - 1)],
type=issue_type,
)
)
@@ -1,44 +0,0 @@
# Python imports
import json
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageVersion
@shared_task
def page_version(
page_id,
existing_instance,
user_id,
):
# Get the page
page = Page.objects.get(id=page_id)
# Get the current instance
current_instance = (
json.loads(existing_instance) if existing_instance is not None else {}
)
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
# Create a new page version
PageVersion.objects.create(
page_id=page_id,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
ownned_by_id=user_id,
last_saved_at=page.updated_at,
)
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
PageVersion.objects.filter(page_id=page_id).order_by(
"last_saved_at"
).first().delete()
return
@@ -1,275 +0,0 @@
# Generated by Django 4.2.11 on 2024-07-01 06:10
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
def create_issue_types(apps, schema_editor):
Project = apps.get_model("db", "Project")
Issue = apps.get_model("db", "Issue")
IssueType = apps.get_model("db", "IssueType")
# Create the issue types for all projects
IssueType.objects.bulk_create(
[
IssueType(
name="Task",
description="A task that needs to be completed.",
project_id=project["id"],
workspace_id=project["workspace_id"],
)
for project in Project.objects.values("id", "workspace_id")
],
batch_size=1000,
)
# Update the issue type for all existing issues
issue_types = {
str(issue_type["project_id"]): str(issue_type["id"])
for issue_type in IssueType.objects.values("id", "project_id")
}
# Update the issue type for all existing issues
bulk_issues = []
for issue in Issue.objects.all():
issue.type_id = issue_types[str(issue.project_id)]
bulk_issues.append(issue)
# Update the issue type for all existing issues
Issue.objects.bulk_update(bulk_issues, ["type_id"], batch_size=1000)
def create_page_versions(apps, schema_editor):
Page = apps.get_model("db", "Page")
PageVersion = apps.get_model("db", "PageVersion")
# Create the page versions for all pages
PageVersion.objects.bulk_create(
[
PageVersion(
page_id=page["id"],
workspace_id=page["workspace_id"],
description_html=page["description_html"],
description_binary=page["description_binary"],
description_stripped=page["description_stripped"],
ownned_by_id=page["owned_by_id"],
last_saved_at=page["updated_at"],
)
for page in Page.objects.values(
"id",
"workspace_id",
"description_html",
"description_binary",
"description_stripped",
"owned_by_id",
"updated_at",
)
],
batch_size=1000,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0069_alter_account_provider_and_more"),
]
operations = [
migrations.CreateModel(
name="IssueType",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
("logo_props", models.JSONField(default=dict)),
("sort_order", models.FloatField(default=65535)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
(
"is_default",
models.BooleanField(default=True),
),
],
options={
"verbose_name": "Issue Type",
"verbose_name_plural": "Issue Types",
"db_table": "issue_types",
"ordering": ("sort_order",),
},
),
migrations.AddField(
model_name="issue",
name="type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_type",
to="db.issuetype",
),
),
migrations.AddField(
model_name="apitoken",
name="is_service",
field=models.BooleanField(default=False),
),
migrations.RunPython(create_issue_types),
migrations.CreateModel(
name="PageVersion",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("description_binary", models.BinaryField(null=True)),
(
"description_html",
models.TextField(blank=True, default="<p></p>"),
),
(
"description_stripped",
models.TextField(blank=True, null=True),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"ownned_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to=settings.AUTH_USER_MODEL,
),
),
(
"page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to="db.page",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="page_versions",
to="db.workspace",
),
),
],
options={
"verbose_name": "Page Version",
"verbose_name_plural": "Page Versions",
"db_table": "page_versions",
"ordering": ("-created_at",),
},
),
migrations.AddField(
model_name="project",
name="start_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="project",
name="target_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunPython(create_page_versions),
]
+1 -10
View File
@@ -50,14 +50,7 @@ from .notification import (
Notification,
UserNotificationPreference,
)
from .page import (
Page,
PageFavorite,
PageLabel,
PageLog,
ProjectPage,
PageVersion,
)
from .page import Page, PageFavorite, PageLabel, PageLog, ProjectPage
from .project import (
Project,
ProjectBaseModel,
@@ -108,5 +101,3 @@ from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
from .issue_type import IssueType
-1
View File
@@ -44,7 +44,6 @@ class APIToken(BaseModel):
null=True,
)
expired_at = models.DateTimeField(blank=True, null=True)
is_service = models.BooleanField(default=False)
class Meta:
verbose_name = "API Token"
-7
View File
@@ -164,13 +164,6 @@ class Issue(ProjectBaseModel):
is_draft = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.ForeignKey(
"db.IssueType",
on_delete=models.SET_NULL,
related_name="issue_type",
null=True,
blank=True,
)
objects = models.Manager()
issue_objects = IssueManager()
-34
View File
@@ -1,34 +0,0 @@
# Django imports
from django.db import models
# Module imports
from .workspace import WorkspaceBaseModel
class IssueType(WorkspaceBaseModel):
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
logo_props = models.JSONField(default=dict)
sort_order = models.FloatField(default=65535)
is_default = models.BooleanField(default=True)
class Meta:
verbose_name = "Issue Type"
verbose_name_plural = "Issue Types"
db_table = "issue_types"
ordering = ("sort_order",)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
# If we are adding a new issue type, we need to set the sort order
if self._state.adding:
# Get the largest sort order for the project
largest_sort_order = IssueType.objects.filter(
project=self.project
).aggregate(largest=models.Max("sort_order"))["largest"]
# If there are issue types, set the sort order to the largest + 10000
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(IssueType, self).save(*args, **kwargs)
+1 -50
View File
@@ -1,7 +1,6 @@
import uuid
from django.conf import settings
from django.utils import timezone
# Django imports
from django.db import models
@@ -67,15 +66,6 @@ class Page(BaseModel):
"""Return owner email and page name"""
return f"{self.owned_by.email} <{self.name}>"
def save(self, *args, **kwargs):
# 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)
)
super(Page, self).save(*args, **kwargs)
class PageLog(BaseModel):
TYPE_CHOICES = (
@@ -103,9 +93,7 @@ class PageLog(BaseModel):
verbose_name="Transaction Type",
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_page_log",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
)
class Meta:
@@ -259,40 +247,3 @@ class TeamPage(BaseModel):
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="page_versions",
)
page = models.ForeignKey(
"db.Page",
on_delete=models.CASCADE,
related_name="page_versions",
)
last_saved_at = models.DateTimeField(default=timezone.now)
ownned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="page_versions",
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
class Meta:
verbose_name = "Page Version"
verbose_name_plural = "Page Versions"
db_table = "page_versions"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# 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)
)
super(PageVersion, self).save(*args, **kwargs)
-3
View File
@@ -115,9 +115,6 @@ class Project(BaseModel):
related_name="default_state",
)
archived_at = models.DateTimeField(null=True)
# Project start and target date
start_date = models.DateTimeField(null=True, blank=True)
target_date = models.DateTimeField(null=True, blank=True)
def __str__(self):
"""Return name of the project"""
@@ -23,8 +23,6 @@ from plane.license.utils.instance_value import (
get_configuration_value,
)
from plane.utils.cache import cache_response, invalidate_cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
class InstanceEndpoint(BaseAPIView):
@@ -38,7 +36,6 @@ class InstanceEndpoint(BaseAPIView):
]
@cache_response(60 * 60 * 2, user=False)
@method_decorator(cache_control(private=True, max_age=12))
def get(self, request):
instance = Instance.objects.first()
+2 -2
View File
@@ -12,7 +12,7 @@ from django.db.models import (
Sum,
Value,
When,
FloatField,
IntegerField,
)
from django.db.models.functions import (
Coalesce,
@@ -98,7 +98,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate
else:
queryset = queryset.annotate(
estimate=Sum(Cast("estimate_point__value", FloatField()))
estimate=Sum(Cast("estimate_point__value", IntegerField()))
).order_by(x_axis)
queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset
-1
View File
@@ -3,4 +3,3 @@ export * from "./embed";
export * from "./image";
export * from "./mention-suggestion";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
-2
View File
@@ -46,5 +46,3 @@ export type TPageFilters = {
sortBy: TPageFiltersSortBy;
filters?: TPageFilterProps;
};
export type TPageEmbedType = "mention" | "issue";
+1 -3
View File
@@ -64,7 +64,6 @@ export type TNotificationPaginatedInfoQueryParams = {
type?: string | undefined;
snoozed?: boolean;
archived?: boolean;
mentioned?: boolean;
read?: boolean;
per_page?: number;
cursor?: string;
@@ -87,10 +86,9 @@ export type TNotificationPaginatedInfo = {
// notification count
export type TUnreadNotificationsCount = {
total_unread_notifications_count: number;
mention_unread_notifications_count: number;
};
export type TNotificationLite = {
export type TCurrentSelectedNotification = {
workspace_slug: string | undefined;
project_id: string | undefined;
notification_id: string | undefined;
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Avatar } from "./avatar";
const meta: Meta<typeof Avatar> = {
@@ -1,33 +0,0 @@
import React, { FC } from "react";
import { DropdownIcon } from "../icons";
import { cn } from "../../helpers";
type Props = {
isOpen: boolean;
title: string;
hideChevron?: boolean;
indicatorElement?: React.ReactNode;
actionItemElement?: React.ReactNode;
};
export const CollapsibleButton: FC<Props> = (props) => {
const { isOpen, title, hideChevron = false, indicatorElement, actionItemElement } = props;
return (
<div className="flex items-center justify-between gap-3 h-12 px-2.5 py-3 border-b border-custom-border-100">
<div className="flex items-center gap-3.5">
<div className="flex items-center gap-3">
{!hideChevron && (
<DropdownIcon
className={cn("size-2 text-custom-text-300 hover:text-custom-text-200 duration-300", {
"-rotate-90": !isOpen,
})}
/>
)}
<span className="text-base text-custom-text-100 font-medium">{title}</span>
</div>
{indicatorElement && indicatorElement}
</div>
{actionItemElement && isOpen && actionItemElement}
</div>
);
};
-1
View File
@@ -1,2 +1 @@
export * from "./collapsible";
export * from "./collapsible-button";
+1
View File
@@ -21,4 +21,5 @@ export * from "./related-icon";
export * from "./side-panel-icon";
export * from "./transfer-icon";
export * from "./info-icon";
export * from "./relations-icon";
export * from "./dropdown-icon";
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const RelationsIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg
viewBox="0 0 16 16"
className={`${className}`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{...rest}
>
<path d="M7.99998 4.66536C8.92045 4.66536 9.66665 3.91917 9.66665 2.9987C9.66665 2.07822 8.92045 1.33203 7.99998 1.33203C7.07951 1.33203 6.33331 2.07822 6.33331 2.9987C6.33331 3.91917 7.07951 4.66536 7.99998 4.66536Z" />
<path d="M6.80001 4.19922L4.20001 6.79922" />
<path d="M2.99998 9.66536C3.92045 9.66536 4.66665 8.91917 4.66665 7.9987C4.66665 7.07822 3.92045 6.33203 2.99998 6.33203C2.07951 6.33203 1.33331 7.07822 1.33331 7.9987C1.33331 8.91917 2.07951 9.66536 2.99998 9.66536Z" />
<path d="M4.66669 8H11.3334" />
<path d="M13 9.66536C13.9205 9.66536 14.6666 8.91917 14.6666 7.9987C14.6666 7.07822 13.9205 6.33203 13 6.33203C12.0795 6.33203 11.3333 7.07822 11.3333 7.9987C11.3333 8.91917 12.0795 9.66536 13 9.66536Z" />
<path d="M9.20001 11.7992L11.8 9.19922" />
<path d="M7.99998 14.6654C8.92045 14.6654 9.66665 13.9192 9.66665 12.9987C9.66665 12.0782 8.92045 11.332 7.99998 11.332C7.07951 11.332 6.33331 12.0782 6.33331 12.9987C6.33331 13.9192 7.07951 14.6654 7.99998 14.6654Z" />
</svg>
);
-1
View File
@@ -21,4 +21,3 @@ export * from "./drop-indicator";
export * from "./favorite-star";
export * from "./loader";
export * from "./collapsible";
export * from "./popovers";
-2
View File
@@ -1,2 +0,0 @@
export * from "./popover";
export * from "./popover-menu";
@@ -1,44 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { PopoverMenu } from "./popover-menu";
const meta: Meta<typeof PopoverMenu> = {
title: "PopoverMenu",
component: PopoverMenu,
};
export default meta;
// types
type TPopoverMenu = {
id: number;
name: string;
};
type Story = StoryObj<typeof PopoverMenu<TPopoverMenu>>;
// data
const data: TPopoverMenu[] = [
{ id: 1, name: "John Doe" },
{ id: 2, name: "Jane Doe" },
{ id: 3, name: "John Smith" },
{ id: 4, name: "Jane Smith" },
];
// components
const PopoverMenuItemRender = (item: TPopoverMenu) => (
<div className="text-sm text-gray-600 hover:text-gray-700 rounded-sm cursor-pointer hover:bg-gray-200 transition-all px-1.5 py-0.5 capitalize">
{item.name}
</div>
);
// stories
export const Default: Story = {
args: {
popperPosition: "bottom-start",
panelClassName: "rounded bg-gray-100 p-2",
data: data,
keyExtractor: (item, index: number) => `${item.id}-${index}`,
render: (item) => PopoverMenuItemRender(item),
},
};
-39
View File
@@ -1,39 +0,0 @@
import React, { Fragment } from "react";
// components
import { Popover } from "./popover";
// helpers
import { cn } from "../../helpers";
// types
import { TPopoverMenu } from "./types";
export const PopoverMenu = <T,>(props: TPopoverMenu<T>) => {
const {
popperPosition = "bottom-end",
popperPadding = 0,
buttonClassName = "",
button,
panelClassName = "",
data,
keyExtractor,
render,
} = props;
return (
<Popover
popperPosition={popperPosition}
popperPadding={popperPadding}
buttonClassName={buttonClassName}
button={button}
panelClassName={cn(
"my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2 text-xs shadow-custom-shadow-rg focus:outline-none",
panelClassName
)}
>
<Fragment>
{data.map((item, index) => (
<Fragment key={keyExtractor(item, index)}>{render(item, index)}</Fragment>
))}
</Fragment>
</Popover>
);
};
@@ -1,54 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { Popover } from "./popover";
const meta: Meta<typeof Popover> = {
title: "Popover",
component: Popover,
};
export default meta;
// types
type Story = StoryObj<typeof Popover>;
// data
// components
const RenderCustomPopoverComponent = (
<div className="space-y-2">
<div className="text-sm font-medium text-gray-500">Your custom component</div>
<div>
{["option1", "option2", "option3"].map((option) => (
<div
key={option}
className="text-sm text-gray-600 hover:text-gray-700 rounded-sm cursor-pointer hover:bg-gray-200 transition-all px-1.5 py-0.5 capitalize"
>
{option}
</div>
))}
</div>
</div>
);
// stories
export const Default: Story = {
args: {
popperPosition: "bottom-start",
panelClassName: "rounded bg-gray-100 p-2",
children: RenderCustomPopoverComponent,
},
};
export const CustomMenuButton: Story = {
args: {
popperPosition: "bottom-start",
button: (
<div className="p-2 text-sm font-medium rounded bg-gray-100 hover:bg-gray-200 transition-all">
Custom Menu Button
</div>
),
panelClassName: "rounded bg-gray-100 p-2",
children: RenderCustomPopoverComponent,
},
};
-73
View File
@@ -1,73 +0,0 @@
import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover as HeadlessReactPopover, Transition } from "@headlessui/react";
// helpers
import { cn } from "../../helpers";
// types
import { TPopover } from "./types";
import { EllipsisVertical } from "lucide-react";
export const Popover = (props: TPopover) => {
const {
popperPosition = "bottom-end",
popperPadding = 0,
buttonClassName = "",
button,
panelClassName = "",
children,
} = props;
// states
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// react-popper derived values
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: popperPosition,
modifiers: [
{
name: "preventOverflow",
options: {
padding: popperPadding,
},
},
],
});
return (
<HeadlessReactPopover className="relative flex h-full w-full items-center justify-center">
<HeadlessReactPopover.Button ref={setReferenceElement} className="flex justify-center items-center">
{button ? (
button
) : (
<div
className={cn(
"flex justify-center items-center text-base h-6 w-6 rounded transition-all bg-custom-background-90 hover:bg-custom-background-80",
buttonClassName
)}
>
<EllipsisVertical className="h-3 w-3" />
</div>
)}
</HeadlessReactPopover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<HeadlessReactPopover.Panel
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className={cn("absolute left-0 top-full z-20 w-screen max-w-xs mt-2", panelClassName)}
>
{children}
</HeadlessReactPopover.Panel>
</Transition>
</HeadlessReactPopover>
);
};
-27
View File
@@ -1,27 +0,0 @@
import { ReactNode } from "react";
import { Placement } from "@popperjs/core";
export type TPopoverButtonDefaultOptions = {
// button and button styling
button?: ReactNode;
buttonClassName?: string;
};
export type TPopoverDefaultOptions = TPopoverButtonDefaultOptions & {
// popper styling
popperPosition?: Placement | undefined;
popperPadding?: number | undefined;
// panel styling
panelClassName?: string;
};
export type TPopover = TPopoverDefaultOptions & {
// children
children: ReactNode;
};
export type TPopoverMenu<T> = TPopoverDefaultOptions & {
data: T[];
keyExtractor: (item: T, index: number) => string;
render: (item: T, index: number) => ReactNode;
};
@@ -6,7 +6,9 @@ import { NotificationsSidebar } from "@/components/workspace-notifications";
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
return (
<div className="relative w-full h-full overflow-hidden flex items-center">
<NotificationsSidebar />
<div className="relative w-full lg:w-2/6 border-0 lg:border-r border-custom-border-200 z-[10] flex-shrink-0 bg-custom-background-100 h-full transition-all overflow-hidden">
<NotificationsSidebar />
</div>
<div className="w-full h-full overflow-hidden overflow-y-auto">{children}</div>
</div>
);
@@ -15,20 +15,13 @@ import { useUser, useWorkspace, useWorkspaceNotifications } from "@/hooks/store"
const WorkspaceDashboardPage = observer(() => {
// hooks
const { currentWorkspace } = useWorkspace();
const {
currentSelectedNotificationId,
setCurrentSelectedNotificationId,
notificationLiteByNotificationId,
notificationIdsByWorkspaceId,
getNotifications,
} = useWorkspaceNotifications();
const { currentSelectedNotification, notificationIdsByWorkspaceId, getNotifications } = useWorkspaceNotifications();
const {
membership: { fetchUserProjectInfo },
} = useUser();
// derived values
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Notifications` : undefined;
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
notificationLiteByNotificationId(currentSelectedNotificationId);
const { workspace_slug, project_id, issue_id, is_inbox_issue } = currentSelectedNotification;
// fetch workspace notifications
const notificationMutation =
@@ -72,15 +65,11 @@ const WorkspaceDashboardPage = observer(() => {
projectId={project_id}
inboxIssueId={issue_id}
isNotificationEmbed
embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)}
/>
)}
</>
) : (
<IssuePeekOverview
embedIssue
embedRemoveCurrentNotification={() => setCurrentSelectedNotificationId(undefined)}
/>
<IssuePeekOverview embedIssue />
)}
</div>
</>
@@ -56,7 +56,7 @@ const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((props) => {
<div className="w-80 flex-shrink-0 overflow-y-hidden pt-8 sm:hidden hidden md:block lg:block">
<ProjectSettingsSidebar />
</div>
<div className="w-full pl-10 sm:pl-10 md:pl-3 lg:pl-3 overflow-y-scroll vertical-scrollbar scrollbar-md">
<div className="w-full pl-10 sm:pl-10 md:pl-0 lg:pl-0 overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">
{children}
</div>
</div>
@@ -1,30 +1,25 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { PageHead } from "@/components/core";
import { ProjectStateRoot } from "@/components/project-states";
import { ProjectSettingStateList } from "@/components/states";
// hook
import { useProject } from "@/hooks/store";
const StatesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store
const { currentProjectDetails } = useProject();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
return (
<>
<PageHead title={pageTitle} />
<div className="py-8 pr-9">
<div className="w-full gap-10 overflow-y-auto py-8 pr-9">
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">States</h3>
</div>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<ProjectSettingStateList />
</div>
</>
);
-13
View File
@@ -5,8 +5,6 @@ import "@/styles/globals.css";
import "@/styles/command-pallette.css";
import "@/styles/emoji.css";
import "@/styles/react-day-picker.css";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// local
import { AppProvider } from "./provider";
@@ -38,17 +36,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest.json" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
{/* preloading */}
<link rel="preload" href={`${API_BASE_URL}/api/instances/`} as="fetch" crossOrigin="use-credentials" />
<link rel="preload" href={`${API_BASE_URL}/api/users/me/ `} as="fetch" crossOrigin="use-credentials" />
<link rel="preload" href={`${API_BASE_URL}/api/users/me/profile/ `} as="fetch" crossOrigin="use-credentials" />
<link rel="preload" href={`${API_BASE_URL}/api/users/me/settings/ `} as="fetch" crossOrigin="use-credentials" />
<link
rel="preload"
href={`${API_BASE_URL}/api/users/me/workspaces/`}
as="fetch"
crossOrigin="use-credentials"
/>
</head>
<body>
<div id="context-menu-portal" />
@@ -1 +1 @@
export * from "./issue-embed-upgrade-card";
export * from "./issue-embed";
@@ -2,10 +2,11 @@ import { Crown } from "lucide-react";
// ui
import { Button } from "@plane/ui";
export const IssueEmbedUpgradeCard: React.FC<any> = (props) => (
export const IssueEmbedCard: React.FC<any> = (props) => (
<div
className={`${props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
className={`${
props.selected ? "border-custom-primary-200 border-[2px]" : ""
} w-full h-[100px] cursor-pointer space-y-2 rounded-md border-[0.5px] border-custom-border-200 shadow-custom-shadow-2xs`}
>
<h5 className="h-[20%] text-xs text-custom-text-300 p-2">
{props.node?.attrs?.project_identifier}-{props?.node?.attrs?.sequence_id}
+1
View File
@@ -0,0 +1 @@
export const ENABLE_BULK_OPERATIONS = false;
@@ -1 +0,0 @@
export const useBulkOperationStatus = () => false;
-24
View File
@@ -1,24 +0,0 @@
// editor
import { TEmbedConfig, TReadOnlyEmbedConfig } from "@plane/editor";
// types
import { TPageEmbedType } from "@plane/types";
// plane web components
import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => {
const widgetCallback = () => <IssueEmbedUpgradeCard />;
const issueEmbedProps: TEmbedConfig["issue"] = {
widgetCallback,
};
const issueEmbedReadOnlyProps: TReadOnlyEmbedConfig["issue"] = {
widgetCallback,
};
return {
issueEmbedProps,
issueEmbedReadOnlyProps,
};
};
@@ -17,14 +17,14 @@ import { CreateProjectModal } from "@/components/project";
import { CreateUpdateProjectViewModal } from "@/components/views";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useUser, useAppTheme, useCommandPalette } from "@/hooks/store";
import { useEventTracker, useIssues, useUser, useAppTheme, useCommandPalette } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { IssueService } from "@/services/issue";
@@ -49,7 +49,7 @@ export const CommandPalette: FC = observer(() => {
} = useUser();
const {
issues: { removeIssue },
} = useIssuesStore();
} = useIssues(EIssuesStoreType.PROJECT);
const {
toggleCommandPaletteModal,
isCreateIssueModalOpen,
@@ -71,6 +71,7 @@ export const CommandPalette: FC = observer(() => {
isDeleteIssueModalOpen,
toggleDeleteIssueModal,
isAnyModalOpen,
createIssueStoreType,
} = useCommandPalette();
const { data: issueDetails } = useSWR(
@@ -316,6 +317,7 @@ export const CommandPalette: FC = observer(() => {
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType}
isDraft={isDraftIssue}
/>
@@ -22,8 +22,8 @@ import {
import { cn } from "@/helpers/common.helper";
// plane web components
import { IssueBulkOperationsRoot } from "@/plane-web/components/issues";
// plane web hooks
import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status";
// plane web constants
import { ENABLE_BULK_OPERATIONS } from "@/plane-web/constants/issue";
// helpers
// constants
import { GANTT_SELECT_GROUP } from "../constants";
@@ -78,8 +78,6 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
const ganttContainerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentView, currentViewData } = useGanttChart();
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
// Enable Auto Scroll for Ganttlist
useEffect(() => {
@@ -128,7 +126,7 @@ export const GanttChartMainContent: React.FC<Props> = observer((props) => {
entities={{
[GANTT_SELECT_GROUP]: blockIds ?? [],
}}
disabled={!isBulkOperationsEnabled}
disabled={!ENABLE_BULK_OPERATIONS}
>
{(helpers) => (
<>
@@ -12,7 +12,6 @@ import {
FileStack,
Link,
Trash2,
MoveRight,
} from "lucide-react";
import { Button, ControlLink, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
@@ -46,7 +45,6 @@ type TInboxIssueActionsHeader = {
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
isNotificationEmbed: boolean;
embedRemoveCurrentNotification?: () => void;
};
export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((props) => {
@@ -58,7 +56,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
isMobileSidebar,
setIsMobileSidebar,
isNotificationEmbed = false,
embedRemoveCurrentNotification,
} = props;
// states
const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false);
@@ -243,11 +240,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<div className="hidden relative lg:flex h-full w-full items-center justify-between gap-2 px-4">
<div className="flex items-center gap-4">
{isNotificationEmbed && (
<button onClick={embedRemoveCurrentNotification}>
<MoveRight className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
)}
{issue?.project_id && issue.sequence_id && (
<h3 className="text-base font-medium text-custom-text-300 flex-shrink-0">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id}
@@ -16,7 +16,6 @@ type TInboxContentRoot = {
isMobileSidebar: boolean;
setIsMobileSidebar: (value: boolean) => void;
isNotificationEmbed?: boolean;
embedRemoveCurrentNotification?: () => void;
};
export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
@@ -27,7 +26,6 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
isMobileSidebar,
setIsMobileSidebar,
isNotificationEmbed = false,
embedRemoveCurrentNotification,
} = props;
/// router
const router = useAppRouter();
@@ -80,7 +78,6 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
isNotificationEmbed={isNotificationEmbed || false}
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/>
</div>
<div className="h-full w-full space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto px-6 py-5 vertical-scrollbar scrollbar-md">
@@ -1,6 +1,6 @@
"use client";
import { FC } from "react";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Trash } from "lucide-react";
// ui
@@ -33,9 +33,9 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
isDeleteAttachmentModalOpen,
toggleDeleteAttachmentModal,
} = useIssueDetail();
// state
const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
@@ -46,10 +46,10 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
return (
<>
{isDeleteAttachmentModalOpen && (
{isDeleteIssueAttachmentModalOpen && (
<IssueAttachmentDeleteModal
isOpen={isDeleteAttachmentModalOpen}
onClose={() => toggleDeleteAttachmentModal(false)}
isOpen={isDeleteIssueAttachmentModalOpen}
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
handleAttachmentOperations={handleAttachmentOperations}
data={attachment}
/>
@@ -90,12 +90,12 @@ export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer(
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
<CustomMenu ellipsis closeOnSelect placement="bottom-end" openOnHover disabled={disabled}>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDeleteAttachmentModal(true);
setIsDeleteIssueAttachmentModalOpen(true);
}}
>
<div className="flex items-center gap-2">
-2
View File
@@ -10,8 +10,6 @@ export * from "./label";
export * from "./confirm-issue-discard";
export * from "./issue-update-status";
export * from "./create-issue-toast-action-items";
export * from "./relations";
export * from "./issue-detail-widgets";
// issue details
export * from "./issue-detail";
@@ -1,71 +0,0 @@
"use client";
import React, { FC } from "react";
import { Layers, Link, Paperclip, Waypoints } from "lucide-react";
// components
import {
IssueAttachmentActionButton,
IssueLinksActionButton,
RelationActionButton,
SubIssuesActionButton,
IssueDetailWidgetButton,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
return (
<div className="flex items-center flex-wrap gap-2">
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
customButton={
<IssueDetailWidgetButton
title="Add sub-issues"
icon={<Layers className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Add Relation"
icon={<Waypoints className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Add Links"
icon={<Link className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
customButton={
<IssueDetailWidgetButton
title="Attach"
icon={<Paperclip className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-300" strokeWidth={2} />}
/>
}
/>
</div>
);
};
@@ -1,27 +0,0 @@
"use client";
import React, { FC } from "react";
// components
import { IssueAttachmentItemList } from "@/components/issues/attachment";
// helper
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// helper
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<IssueAttachmentItemList
workspaceSlug={workspaceSlug}
issueId={issueId}
disabled={disabled}
handleAttachmentOperations={handleAttachmentOperations}
/>
);
};
@@ -1,90 +0,0 @@
"use client";
import { useMemo } from "react";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui";
// type
import { TAttachmentOperations } from "@/components/issues/attachment";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
issueId: string
): TAttachmentOperations => {
const { createAttachment, removeAttachment } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const handleAttachmentOperations: TAttachmentOperations = useMemo(
() => ({
create: async (data: FormData) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
},
});
const res = await attachmentUploadPromise;
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: res.id,
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
});
}
},
remove: async (attachmentId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeAttachment(workspaceSlug, projectId, issueId, attachmentId);
setToast({
message: "The attachment has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Attachment removed",
});
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
} catch (error) {
captureIssueEvent({
eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "attachment",
change_details: "",
},
});
setToast({
message: "The Attachment could not be removed",
type: TOAST_TYPE.ERROR,
title: "Attachment not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createAttachment, removeAttachment]
);
return handleAttachmentOperations;
};
@@ -1,4 +0,0 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -1,66 +0,0 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
// helper
import { generateFileName } from "@/helpers/attachment.helper";
// hooks
import { useInstance } from "@/hooks/store";
import { useAttachmentOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
// helper
const [isLoading, setIsLoading] = useState(false);
const { config } = useInstance();
const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
multiple: false,
disabled: isLoading || disabled,
});
return (
<button {...getRootProps()} type="button" disabled={disabled}>
<input {...getInputProps()} />
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
);
});
@@ -1,43 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import {
IssueAttachmentsCollapsibleContent,
IssueAttachmentsCollapsibleTitle,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const AttachmentsCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueAttachmentsCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -1,54 +0,0 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { IssueAttachmentActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueAttachmentsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const attachmentCount = issue?.attachment_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{attachmentCount}</p>
</span>
),
[attachmentCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Attachments"
indicatorElement={indicatorElement}
actionItemElement={
<IssueAttachmentActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -1,8 +0,0 @@
export * from "./attachments";
export * from "./links";
export * from "./relations";
export * from "./root";
export * from "./sub-issues";
export * from "./widget-button";
export * from "./issue-detail-widget-collapsibles";
export * from "./action-buttons";
@@ -1,72 +0,0 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// components
import {
AttachmentsCollapsible,
LinksCollapsible,
RelationsCollapsible,
SubIssuesCollapsible,
} from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
subIssues: { subIssuesByIssueId },
relation: { getRelationsByIssueId },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const subIssues = subIssuesByIssueId(issueId);
const issueRelations = getRelationsByIssueId(issueId);
// render conditions
const shouldRenderSubIssues = !!subIssues && subIssues.length > 0;
const shouldRenderRelations = Object.values(issueRelations ?? {}).some((relation) => relation.length > 0);
const shouldRenderLinks = !!issue?.link_count && issue?.link_count > 0;
const shouldRenderAttachments = !!issue?.attachment_count && issue?.attachment_count > 0;
return (
<div className="flex flex-col">
{shouldRenderSubIssues && (
<SubIssuesCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
{shouldRenderRelations && (
<RelationsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
{shouldRenderLinks && (
<LinksCollapsible workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
)}
{shouldRenderAttachments && (
<AttachmentsCollapsible
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
)}
</div>
);
});
@@ -1,22 +0,0 @@
"use client";
import React, { FC } from "react";
// components
import { LinkList } from "../../issue-detail/links";
// helper
import { useLinkOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksCollapsibleContent: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
return <LinkList issueId={issueId} linkOperations={handleLinkOperations} disabled={disabled} />;
};
@@ -1,71 +0,0 @@
"use client";
import { useMemo } from "react";
import { TIssueLink } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useIssueDetail } from "@/hooks/store";
// types
import { TLinkOperations } from "../../issue-detail/links";
export const useLinkOperations = (workspaceSlug: string, projectId: string, issueId: string): TLinkOperations => {
const { createLink, updateLink, removeLink } = useIssueDetail();
const handleLinkOperations: TLinkOperations = useMemo(
() => ({
create: async (data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await createLink(workspaceSlug, projectId, issueId, data);
setToast({
message: "The link has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Link created",
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? "The link could not be created",
type: TOAST_TYPE.ERROR,
title: "Link not created",
});
}
},
update: async (linkId: string, data: Partial<TIssueLink>) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await updateLink(workspaceSlug, projectId, issueId, linkId, data);
setToast({
message: "The link has been successfully updated",
type: TOAST_TYPE.SUCCESS,
title: "Link updated",
});
} catch (error) {
setToast({
message: "The link could not be updated",
type: TOAST_TYPE.ERROR,
title: "Link not updated",
});
}
},
remove: async (linkId: string) => {
try {
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
await removeLink(workspaceSlug, projectId, issueId, linkId);
setToast({
message: "The link has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Link removed",
});
} catch (error) {
setToast({
message: "The link could not be removed",
type: TOAST_TYPE.ERROR,
title: "Link not removed",
});
}
},
}),
[workspaceSlug, projectId, issueId, createLink, updateLink, removeLink]
);
return handleLinkOperations;
};
@@ -1,4 +0,0 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -1,58 +0,0 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// hooks
import { useIssueDetail } from "@/hooks/store";
// components
import { IssueLinkCreateUpdateModal } from "../../issue-detail/links/create-update-link-modal";
// helper
import { useLinkOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const IssueLinksActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
// state
const [isIssueLinkModal, setIsIssueLinkModal] = useState(false);
// store hooks
const { toggleIssueLinkModal: toggleIssueLinkModalStore } = useIssueDetail();
// helper
const handleLinkOperations = useLinkOperations(workspaceSlug, projectId, issueId);
// handler
const toggleIssueLinkModal = useCallback(
(modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle);
setIsIssueLinkModal(modalToggle);
},
[toggleIssueLinkModalStore]
);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
toggleIssueLinkModal(true);
};
return (
<>
<IssueLinkCreateUpdateModal
isModalOpen={isIssueLinkModal}
handleModal={toggleIssueLinkModal}
linkOperations={handleLinkOperations}
/>
<button type="button" onClick={handleOnClick} disabled={disabled}>
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
</>
);
});
@@ -1,40 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { IssueLinksCollapsibleContent, IssueLinksCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const LinksCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<IssueLinksCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<IssueLinksCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -1,55 +0,0 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { IssueLinksActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueLinksCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const linksCount = issue?.link_count ?? 0;
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{linksCount}</p>
</span>
),
[linksCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Links"
indicatorElement={indicatorElement}
actionItemElement={
<IssueLinksActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -1,181 +0,0 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue, TIssueRelationIdMap } from "@plane/types";
import { Collapsible, RelatedIcon } from "@plane/ui";
// components
import { RelationIssueList } from "@/components/issues";
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { useRelationOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
const ISSUE_RELATION_OPTIONS = [
{
key: "blocked_by",
label: "Blocked by",
icon: (size: number) => <CircleDot size={size} />,
className: "bg-red-500/20 text-red-700",
},
{
key: "blocking",
label: "Blocking",
icon: (size: number) => <XCircle size={size} />,
className: "bg-yellow-500/20 text-yellow-700",
},
{
key: "relates_to",
label: "Relates to",
icon: (size: number) => <RelatedIcon height={size} width={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
{
key: "duplicate",
label: "Duplicate of",
icon: (size: number) => <CopyPlus size={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
];
type TIssueCrudState = { toggle: boolean; issueId: string | undefined; issue: TIssue | undefined };
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
update: {
toggle: false,
issueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
issueId: undefined,
issue: undefined,
},
});
// store hooks
const {
relation: { getRelationsByIssueId },
toggleDeleteIssueModal,
toggleCreateIssueModal,
} = useIssueDetail();
// helper
const issueOperations = useRelationOperations();
// derived values
const relations = getRelationsByIssueId(issueId);
const handleIssueCrudState = (key: "update" | "delete", _issueId: string | null, issue: TIssue | null = null) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
issueId: _issueId,
issue: issue,
},
});
};
// if relations are not available, return null
if (!relations) return null;
// map relations to array
const relationsArray = Object.keys(relations).map((relationKey) => {
const issueIds = relations[relationKey as keyof TIssueRelationIdMap];
const issueRelationOption = ISSUE_RELATION_OPTIONS.find((option) => option.key === relationKey);
return {
relationKey: relationKey as keyof TIssueRelationIdMap,
issueIds: issueIds,
icon: issueRelationOption?.icon,
label: issueRelationOption?.label,
className: issueRelationOption?.className,
};
});
// filter out relations with no issues
const filteredRelationsArray = relationsArray.filter((relation) => relation.issueIds.length > 0);
const shouldRenderIssueDeleteModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.issueId &&
issueCrudState.delete.issue.id;
const shouldRenderIssueUpdateModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
<div className="flex flex-col gap-">
{filteredRelationsArray.map((relation) => (
<div key={relation.relationKey}>
<Collapsible
buttonClassName="w-full"
title={
<div className={`flex items-center gap-1 px-3 py-1 h-9 w-full pl-9 ${relation.className}`}>
<span>{relation.icon ? relation.icon(14) : null}</span>
<span className="text-sm font-medium leading-5">{relation.label}</span>
</div>
}
defaultOpen
>
<RelationIssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
relationKey={relation.relationKey}
issueIds={relation.issueIds}
disabled={disabled}
issueOperations={issueOperations}
handleIssueCrudState={handleIssueCrudState}
/>
</Collapsible>
</div>
))}
</div>
{shouldRenderIssueDeleteModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await issueOperations.remove(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string)
}
/>
)}
{shouldRenderIssueUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
}}
/>
)}
</>
);
});
@@ -1,119 +0,0 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { TIssue } from "@plane/types";
import { RelatedIcon, TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { ISSUE_DELETED, ISSUE_UPDATED } from "@/constants/event-tracker";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useRelationOperations = (): TRelationIssueOperations => {
const { updateIssue, removeIssue } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const pathname = usePathname();
const issueOperations: TRelationIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
},
update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: pathname,
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Issue updated successfully",
});
} catch (error) {
captureIssueEvent({
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","),
},
path: pathname,
});
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Issue update failed",
});
}
},
remove: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await removeIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
} catch (error) {
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
}
},
}),
[pathname, removeIssue, updateIssue]
);
return issueOperations;
};
export const ISSUE_RELATION_OPTIONS = [
{
key: "blocked_by",
label: "Blocked by",
icon: (size: number) => <CircleDot size={size} />,
className: "bg-red-500/20 text-red-700",
},
{
key: "blocking",
label: "Blocking",
icon: (size: number) => <XCircle size={size} />,
className: "bg-yellow-500/20 text-yellow-700",
},
{
key: "relates_to",
label: "Relates to",
icon: (size: number) => <RelatedIcon height={size} width={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
{
key: "duplicate",
label: "Duplicate of",
icon: (size: number) => <CopyPlus size={size} />,
className: "bg-custom-background-80 text-custom-text-200",
},
];
@@ -1,4 +0,0 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -1,96 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
import { ISearchIssueResponse, TIssueRelationTypes } from "@plane/types";
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { ISSUE_RELATION_OPTIONS } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
export const RelationActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, customButton, issueId, disabled = false } = props;
// state
const [relationKey, setRelationKey] = useState<TIssueRelationTypes | null>(null);
const { createRelation, isRelationModalOpen, toggleRelationModal } = useIssueDetail();
// handlers
const handleOnClick = (relationKey: TIssueRelationTypes) => {
setRelationKey(relationKey);
toggleRelationModal(issueId, relationKey);
};
// submit handler
const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!relationKey) return;
if (data.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
await createRelation(
workspaceSlug,
projectId,
issueId,
relationKey,
data.map((i) => i.id)
);
toggleRelationModal(null, null);
};
const handleOnClose = () => {
setRelationKey(null);
toggleRelationModal(null, null);
};
// button element
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<>
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{ISSUE_RELATION_OPTIONS.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOnClick(item.key as TIssueRelationTypes);
}}
>
<div className="flex items-center gap-2">
{item.icon(12)}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={isRelationModalOpen?.issueId === issueId && isRelationModalOpen?.relationType === relationKey}
handleClose={handleOnClose}
searchParams={{ issue_relation: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
workspaceLevelToggle
/>
</>
);
});
@@ -1,40 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { RelationsCollapsibleContent, RelationsCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const RelationsCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<RelationsCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
>
<RelationsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -1,54 +0,0 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CollapsibleButton } from "@plane/ui";
// components
import { RelationActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const RelationsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, issueId, disabled } = props;
// store hook
const {
relation: { getRelationsByIssueId },
} = useIssueDetail();
// derived values
const issueRelations = getRelationsByIssueId(issueId);
const relationsCount = Object.values(issueRelations ?? {}).reduce((acc, curr) => acc + curr.length, 0);
// indicator element
const indicatorElement = useMemo(
() => (
<span className="flex items-center justify-center ">
<p className="text-base text-custom-text-300 !leading-3">{relationsCount}</p>
</span>
),
[relationsCount]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Relations"
indicatorElement={indicatorElement}
actionItemElement={
<RelationActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
}
/>
);
});
@@ -1,34 +0,0 @@
"use client";
import React, { FC } from "react";
// components
import {
IssueDetailWidgetActionButtons,
IssueDetailWidgetCollapsibles,
} from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled: boolean;
};
export const IssueDetailWidgets: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled } = props;
return (
<div className="flex flex-col gap-5">
<IssueDetailWidgetActionButtons
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
<IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
/>
</div>
);
};
@@ -1,172 +0,0 @@
"use client";
import React, { FC, useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { TIssue } from "@plane/types";
// components
import { DeleteIssueModal } from "@/components/issues/delete-issue-modal";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
import { IssueList } from "@/components/issues/sub-issues/issues-list";
// hooks
import { useIssueDetail } from "@/hooks/store";
// helper
import { useSubIssueOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, parentIssueId, disabled } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
update: TIssueCrudState;
delete: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
update: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
delete: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
toggleCreateIssueModal,
toggleDeleteIssueModal,
} = useIssueDetail();
// helpers
const subIssueOperations = useSubIssueOperations();
const subIssueHelpers = subIssueHelpersByIssueId(`${parentIssueId}_root`);
// handler
const handleIssueCrudState = (
key: "create" | "existing" | "update" | "delete",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
const handleFetchSubIssues = useCallback(async () => {
if (!subIssueHelpers.issue_visibility.includes(parentIssueId)) {
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
await subIssueOperations.fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(`${parentIssueId}_root`, "preview_loader", parentIssueId);
}
setSubIssueHelpers(`${parentIssueId}_root`, "issue_visibility", parentIssueId);
}, [
parentIssueId,
projectId,
setSubIssueHelpers,
subIssueHelpers.issue_visibility,
subIssueOperations,
workspaceSlug,
]);
useEffect(() => {
handleFetchSubIssues();
return () => {
handleFetchSubIssues();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentIssueId]);
// render conditions
const shouldRenderDeleteIssueModal =
issueCrudState?.delete?.toggle &&
issueCrudState?.delete?.issue &&
issueCrudState.delete.parentIssueId &&
issueCrudState.delete.issue.id;
const shouldRenderUpdateIssueModal = issueCrudState?.update?.toggle && issueCrudState?.update?.issue;
return (
<>
{subIssueHelpers.issue_visibility.includes(parentIssueId) && (
<IssueList
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={parentIssueId}
rootIssueId={parentIssueId}
spacingLeft={6}
disabled={!disabled}
handleIssueCrudState={handleIssueCrudState}
subIssueOperations={subIssueOperations}
/>
)}
{shouldRenderDeleteIssueModal && (
<DeleteIssueModal
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(null);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>
await subIssueOperations.deleteSubIssue(
workspaceSlug,
projectId,
issueCrudState?.delete?.parentIssueId as string,
issueCrudState?.delete?.issue?.id as string
)
}
isSubIssue
/>
)}
{shouldRenderUpdateIssueModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
await subIssueOperations.updateSubIssue(
workspaceSlug,
projectId,
parentIssueId,
_issue.id,
_issue,
issueCrudState?.update?.issue,
true
);
}}
/>
)}
</>
);
});
@@ -1,178 +0,0 @@
"use client";
import { useMemo } from "react";
import { usePathname } from "next/navigation";
import { TIssue } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
// helper
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// type
import { TSubIssueOperations } from "../../sub-issues";
export type TRelationIssueOperations = {
copyText: (text: string) => void;
update: (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
};
export const useSubIssueOperations = (): TSubIssueOperations => {
const {
subIssues: { setSubIssueHelpers },
fetchSubIssues,
createSubIssues,
updateSubIssue,
removeSubIssue,
deleteSubIssue,
} = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
const pathname = usePathname();
const subIssueOperations: TSubIssueOperations = useMemo(
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
},
fetchSubIssues: async (workspaceSlug: string, projectId: string, parentIssueId: string) => {
try {
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error fetching sub-issues",
});
}
},
addSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueIds: string[]) => {
try {
await createSubIssues(workspaceSlug, projectId, parentIssueId, issueIds);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issues added successfully",
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error adding sub-issue",
});
}
},
updateSubIssue: async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string,
issueData: Partial<TIssue>,
oldIssue: Partial<TIssue> = {},
fromModal: boolean = false
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issue updated successfully",
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue updated",
payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(issueData).join(","),
change_details: Object.values(issueData).join(","),
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error updating sub-issue",
});
}
},
removeSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Sub-issue removed successfully",
});
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "parent_id",
change_details: parentIssueId,
},
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error removing sub-issue",
});
}
},
deleteSubIssue: async (workspaceSlug: string, projectId: string, parentIssueId: string, issueId: string) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await deleteSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
captureIssueEvent({
eventName: "Sub-issue deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
captureIssueEvent({
eventName: "Sub-issue removed",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Error deleting issue",
});
}
},
}),
[fetchSubIssues, createSubIssues, updateSubIssue, removeSubIssue, deleteSubIssue, setSubIssueHelpers]
);
return subIssueOperations;
};
@@ -1,4 +0,0 @@
export * from "./content";
export * from "./title";
export * from "./root";
export * from "./quick-action-button";
@@ -1,182 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
import { LayersIcon, Plus } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import { ExistingIssuesListModal } from "@/components/core";
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
// hooks
import { useEventTracker, useIssueDetail } from "@/hooks/store";
// helper
import { useSubIssueOperations } from "./helper";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
customButton?: React.ReactNode;
disabled?: boolean;
};
type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined };
export const SubIssuesActionButton: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, customButton, disabled = false } = props;
// state
const [issueCrudState, setIssueCrudState] = useState<{
create: TIssueCrudState;
existing: TIssueCrudState;
}>({
create: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
existing: {
toggle: false,
parentIssueId: undefined,
issue: undefined,
},
});
// store hooks
const {
issue: { getIssueById },
isCreateIssueModalOpen,
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
} = useIssueDetail();
const { setTrackElement } = useEventTracker();
// helper
const subIssueOperations = useSubIssueOperations();
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
_parentIssueId: string | null,
issue: TIssue | null = null
) => {
setIssueCrudState({
...issueCrudState,
[key]: {
toggle: !issueCrudState[key].toggle,
parentIssueId: _parentIssueId,
issue: issue,
},
});
};
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
const handleCreateNew = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("create", issueId, null);
toggleCreateIssueModal(true);
};
const handleAddExisting = () => {
setTrackElement("Issue detail nested sub-issue");
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
};
const handleExistingIssuesModalClose = () => {
handleIssueCrudState("existing", null, null);
toggleSubIssuesModal(null);
};
const handleExistingIssuesModalOnSubmit = async (_issue: ISearchIssueResponse[]) =>
subIssueOperations.addSubIssue(
workspaceSlug,
projectId,
issueId,
_issue.map((issue) => issue.id)
);
const handleCreateUpdateModalClose = () => {
handleIssueCrudState("create", null, null);
toggleCreateIssueModal(false);
};
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
if (_issue.parent_id) {
await subIssueOperations.addSubIssue(workspaceSlug, projectId, issueId, [_issue.id]);
}
};
// options
const optionItems = [
{
label: "Create new",
icon: <Plus className="h-3 w-3" />,
onClick: handleCreateNew,
},
{
label: "Add existing",
icon: <LayersIcon className="h-3 w-3" />,
onClick: handleAddExisting,
},
];
// create update modal
const shouldRenderCreateUpdateModal =
issueCrudState?.create?.toggle && issueCrudState?.create?.parentIssueId && isCreateIssueModalOpen;
const createUpdateModalData = { parent_id: issueCrudState?.create?.parentIssueId };
// existing issues modal
const shouldRenderExistingIssuesModal =
issueCrudState?.existing?.toggle && issueCrudState?.existing?.parentIssueId && isSubIssuesModalOpen;
const existingIssuesModalSearchParams = { sub_issue: true, issue_id: issueCrudState?.existing?.parentIssueId };
const customButtonElement = customButton ? <>{customButton}</> : <Plus className="h-4 w-4" />;
return (
<>
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect>
{optionItems.map((item, index) => (
<CustomMenu.MenuItem
key={index}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.onClick();
}}
>
<div className="flex items-center gap-2">
{item.icon}
<span>{item.label}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
{shouldRenderCreateUpdateModal && (
<CreateUpdateIssueModal
isOpen={issueCrudState?.create?.toggle}
data={createUpdateModalData}
onClose={handleCreateUpdateModalClose}
onSubmit={handleCreateUpdateModalOnSubmit}
/>
)}
{shouldRenderExistingIssuesModal && (
<ExistingIssuesListModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={issueCrudState?.existing?.toggle}
handleClose={handleExistingIssuesModalClose}
searchParams={existingIssuesModalSearchParams}
handleOnSubmit={handleExistingIssuesModalOnSubmit}
workspaceLevelToggle
/>
)}
</>
);
});
@@ -1,40 +0,0 @@
"use client";
import React, { FC, useState } from "react";
import { Collapsible } from "@plane/ui";
// components
import { SubIssuesCollapsibleContent, SubIssuesCollapsibleTitle } from "@/components/issues/issue-detail-widgets";
type Props = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const SubIssuesCollapsible: FC<Props> = (props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// state
const [isOpen, setIsOpen] = useState<boolean>(false);
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prev) => !prev)}
title={
<SubIssuesCollapsibleTitle
isOpen={isOpen}
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
}
>
<SubIssuesCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
parentIssueId={issueId}
disabled={disabled}
/>
</Collapsible>
);
};
@@ -1,65 +0,0 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui";
// components
import { SubIssuesActionButton } from "@/components/issues/issue-detail-widgets";
// hooks
import { useIssueDetail } from "@/hooks/store";
type Props = {
isOpen: boolean;
workspaceSlug: string;
projectId: string;
parentIssueId: string;
disabled: boolean;
};
export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, workspaceSlug, projectId, parentIssueId, disabled } = props;
// store hooks
const {
subIssues: { subIssuesByIssueId, stateDistributionByIssueId },
} = useIssueDetail();
// derived data
const subIssuesDistribution = stateDistributionByIssueId(parentIssueId);
const subIssues = subIssuesByIssueId(parentIssueId);
// if there are no sub-issues, return null
if (!subIssues) return null;
// calculate percentage of completed sub-issues
const completedCount = subIssuesDistribution?.completed?.length ?? 0;
const totalCount = subIssues.length;
const percentage = completedCount && totalCount ? (completedCount / totalCount) * 100 : 0;
// indicator element
const indicatorElement = useMemo(
() => (
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
<span>
{completedCount}/{totalCount} Done
</span>
</div>
),
[completedCount, totalCount, percentage]
);
return (
<CollapsibleButton
isOpen={isOpen}
title="Sub-issues"
indicatorElement={indicatorElement}
actionItemElement={
<SubIssuesActionButton
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={parentIssueId}
disabled={disabled}
/>
}
/>
);
});
@@ -1,17 +0,0 @@
"use client";
import React, { FC } from "react";
type Props = {
icon: JSX.Element;
title: string;
};
export const IssueDetailWidgetButton: FC<Props> = (props) => {
const { icon, title } = props;
return (
<div className="h-full w-min whitespace-nowrap flex items-center gap-2 border border-custom-border-200 hover:bg-custom-background-80 rounded px-3 py-1.5">
{icon && icon}
<span className="text-sm font-medium text-custom-text-300">{title}</span>
</div>
);
};
@@ -1,10 +1,9 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { EActivityFilterType } from "@/constants/issue";
import { useIssueDetail } from "@/hooks/store";
// components
import { IssueActivityItem } from "./activity/activity-list";
import { IssueActivityList } from "./activity/activity-list";
import { IssueCommentCard } from "./comments/comment-card";
// types
import { TActivityOperations } from "./root";
@@ -13,15 +12,13 @@ type TIssueActivityCommentRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
selectedFilters: EActivityFilterType[];
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, selectedFilters, activityOperations, showAccessSpecifier, projectId, disabled } =
props;
const { workspaceSlug, issueId, activityOperations, showAccessSpecifier, projectId, disabled } = props;
// hooks
const {
activity: { getActivityCommentByIssueId },
@@ -31,19 +28,9 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
const activityComments = getActivityCommentByIssueId(issueId);
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;
const isCommentFilterSelected = selectedFilters.includes(EActivityFilterType.COMMENT);
const isActivityFilterSelected = selectedFilters.includes(EActivityFilterType.ACTIVITY);
const filteredActivityComments = activityComments.filter(
(activityComment) =>
(activityComment.activity_type === "COMMENT" && isCommentFilterSelected) ||
(activityComment.activity_type === "ACTIVITY" && isActivityFilterSelected)
);
return (
<div>
{filteredActivityComments.map((activityComment, index) =>
{activityComments.map((activityComment, index) =>
activityComment.activity_type === "COMMENT" ? (
<IssueCommentCard
projectId={projectId}
@@ -51,14 +38,14 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
workspaceSlug={workspaceSlug}
commentId={activityComment.id}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
ends={index === 0 ? "top" : index === activityComments.length - 1 ? "bottom" : undefined}
showAccessSpecifier={showAccessSpecifier}
disabled={disabled}
/>
) : activityComment.activity_type === "ACTIVITY" ? (
<IssueActivityItem
<IssueActivityList
activityId={activityComment.id}
ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined}
ends={index === 0 ? "top" : index === activityComments.length - 1 ? "bottom" : undefined}
/>
) : (
<></>
@@ -1,83 +0,0 @@
import React, { FC, Fragment } from "react";
import { observer } from "mobx-react";
import { Check, ListFilter } from "lucide-react";
import { Popover, Transition } from "@headlessui/react";
// ui
import { Button } from "@plane/ui";
// constants
import { ACTIVITY_FILTER_TYPE_OPTIONS, EActivityFilterType } from "@/constants/issue";
// helper
import { cn } from "@/helpers/common.helper";
type Props = {
selectedFilters: EActivityFilterType[];
toggleFilter: (filter: EActivityFilterType) => void;
};
export const ActivityFilter: FC<Props> = observer((props) => {
const { selectedFilters, toggleFilter } = props;
return (
<Popover as="div" className="relative">
{({ open }) => (
<>
<Popover.Button as={React.Fragment}>
<Button
variant="neutral-primary"
size="sm"
prependIcon={<ListFilter className="h-3 w-3" />}
className="relative"
>
<span className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>Filters</span>
</Button>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute mt-2 right-0 z-10 min-w-40">
<div className="p-2 rounded-md border border-custom-border-200 bg-custom-background-100">
{ACTIVITY_FILTER_TYPE_OPTIONS.map((filter) => {
const isSelected = selectedFilters.includes(filter.value);
return (
<div
key={filter.value}
className="flex items-center gap-2 text-sm cursor-pointer px-2 p-1 transition-all hover:bg-custom-background-80 rounded-sm"
onClick={() => toggleFilter(filter.value)}
>
<div
className={cn(
"flex-shrink-0 w-3 h-3 flex justify-center items-center rounded-sm transition-all bg-custom-background-90",
{
"bg-custom-primary text-white": isSelected,
"bg-custom-background-80 text-custom-text-400": isSelected && selectedFilters.length === 1,
"bg-custom-background-90": !isSelected,
}
)}
>
{isSelected && <Check className="h-2.5 w-2.5" />}
</div>
<div
className={cn(
"whitespace-nowrap",
isSelected ? "text-custom-text-100" : "text-custom-text-200"
)}
>
{filter.label}
</div>
</div>
);
})}
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
});
@@ -27,7 +27,7 @@ export const IssueLinkActivity: FC<TIssueLinkActivity> = observer((props) => {
<>
{activity.verb === "created" ? (
<>
<span>added </span>
<span>added this </span>
<a
href={`${activity.new_value}`}
target="_blank"
@@ -24,12 +24,12 @@ import {
IssueInboxActivity,
} from "./actions";
type TIssueActivityItem = {
type TIssueActivityList = {
activityId: string;
ends: "top" | "bottom" | undefined;
};
export const IssueActivityItem: FC<TIssueActivityItem> = observer((props) => {
export const IssueActivityList: FC<TIssueActivityList> = observer((props) => {
const { activityId, ends } = props;
// hooks
const {
@@ -4,7 +4,6 @@ export * from "./activity-comment-root";
// activity
export * from "./activity/activity-list";
export * from "./activity-filter";
// issue comment
export * from "./comments";
@@ -1,15 +1,14 @@
"use client";
import { FC, Fragment, useMemo, useState } from "react";
import { FC, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { History, LucideIcon, MessageCircle } from "lucide-react";
// types
import { TIssueComment } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ActivityFilter, IssueActivityCommentRoot, IssueCommentCreate } from "@/components/issues";
// constants
import { EActivityFilterType } from "@/constants/issue";
import { IssueActivityCommentRoot, IssueCommentRoot, IssueCommentCreate } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
@@ -20,6 +19,21 @@ type TIssueActivity = {
disabled?: boolean;
};
type TActivityTabs = "all" | "comments";
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
{
key: "comments",
title: "Comments",
icon: MessageCircle,
},
{
key: "all",
title: "All activity",
icon: History,
},
];
export type TActivityOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
@@ -32,18 +46,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { createComment, updateComment, removeComment } = useIssueDetail();
const { getProjectById } = useProject();
// state
const [selectedFilters, setSelectedFilters] = useState([EActivityFilterType.COMMENT, EActivityFilterType.ACTIVITY]);
// toggle filter
const toggleFilter = (filter: EActivityFilterType) => {
setSelectedFilters((prevFilters) => {
if (prevFilters.includes(filter)) {
if (prevFilters.length === 1) return prevFilters; // Ensure at least one filter is applied
return prevFilters.filter((f) => f !== filter);
} else {
return [...prevFilters, filter];
}
});
};
const [activityTab, setActivityTab] = useState<TActivityTabs>("comments");
const activityOperations: TActivityOperations = useMemo(
() => ({
@@ -106,36 +109,74 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
if (!project) return <></>;
return (
<div className="space-y-4 pt-3">
<div className="space-y-3 pt-3">
{/* header */}
<div className="flex items-center justify-between">
<div className="text-lg text-custom-text-100">Activity</div>
<ActivityFilter selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
</div>
<div className="text-lg text-custom-text-100">Activity</div>
{/* rendering activity */}
<div className="space-y-3">
<div className="relative flex items-center gap-1">
{activityTabs.map((tab) => (
<div
key={tab.key}
className={`relative flex items-center px-2 py-1.5 gap-1 cursor-pointer transition-all rounded
${
tab.key === activityTab
? `text-custom-text-100 bg-custom-background-80`
: `text-custom-text-200 hover:bg-custom-background-80`
}`}
onClick={() => setActivityTab(tab.key)}
>
<div className="flex-shrink-0 w-4 h-4 flex justify-center items-center">
<tab.icon className="w-3 h-3" />
</div>
<div className="text-sm">{tab.title}</div>
</div>
))}
</div>
<div className="min-h-[200px]">
<div className="space-y-3">
<IssueActivityCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
selectedFilters={selectedFilters}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
/>
{!disabled && (
<IssueCommentCreate
issueId={issueId}
{activityTab === "all" ? (
<div className="space-y-3">
<IssueActivityCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
/>
)}
</div>
{!disabled && (
<IssueCommentCreate
issueId={issueId}
projectId={projectId}
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
/>
)}
</div>
) : (
<div className="space-y-3">
<IssueCommentRoot
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
/>
{!disabled && (
<IssueCommentCreate
issueId={issueId}
projectId={projectId}
workspaceSlug={workspaceSlug}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
/>
)}
</div>
)}
</div>
</div>
</div>

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