Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707570ca7a | ||
|
|
c76af7d7d6 | ||
|
|
1dcea9bcc8 | ||
|
|
da957e06b6 | ||
|
|
a0b9596cb4 | ||
|
|
f71e8a3a0f | ||
|
|
002fb4547b | ||
|
|
c1b1ba35c1 | ||
|
|
4566d6e80c | ||
|
|
e8d359e625 | ||
|
|
351eba8d61 | ||
|
|
1e27e37b51 | ||
|
|
7df2e9cf11 | ||
|
|
c6e3f1b932 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,4 +3,3 @@ export * from "./embed";
|
||||
export * from "./image";
|
||||
export * from "./mention-suggestion";
|
||||
export * from "./slash-commands-suggestion";
|
||||
export * from "@/plane-editor/types";
|
||||
|
||||
Vendored
-2
@@ -46,5 +46,3 @@ export type TPageFilters = {
|
||||
sortBy: TPageFiltersSortBy;
|
||||
filters?: TPageFilterProps;
|
||||
};
|
||||
|
||||
export type TPageEmbedType = "mention" | "issue";
|
||||
|
||||
+1
-3
@@ -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,2 +1 @@
|
||||
export * from "./collapsible";
|
||||
export * from "./collapsible-button";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -21,4 +21,3 @@ export * from "./drop-indicator";
|
||||
export * from "./favorite-star";
|
||||
export * from "./loader";
|
||||
export * from "./collapsible";
|
||||
export * from "./popovers";
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+3
-8
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
+4
-3
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
export const ENABLE_BULK_OPERATIONS = false;
|
||||
@@ -1 +0,0 @@
|
||||
export const useBulkOperationStatus = () => false;
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user