Compare commits

..

25 Commits

Author SHA1 Message Date
pablohashescobar 2fbf0d6eda fix: uuid validation error 2025-02-19 16:36:58 +05:30
pablohashescobar 4e381c0943 fix: module activity 2025-02-18 21:16:20 +05:30
pablohashescobar 563ca2ff07 fix: meta endpoint to return correct error message 2025-02-18 21:04:31 +05:30
pablohashescobar 76c80ced14 fix: error handling for db based integrity errors 2025-02-18 18:51:13 +05:30
Akshita Goyal a49d899ea1 Chore: search code splitting (#6628)
* fix: Handled workspace switcher closing on click

* chore: code splitting for search

* fix: refactor

* fix: quick link error validation

* fix: refactor

* fix: refactor
2025-02-18 15:11:44 +05:30
Aaryan Khandelwal 3f6ef56a0f chore: add hslToHex and hexToHsl color helpers (#6629)
* chore: add more color helpers

* chore: added error handling
2025-02-18 13:18:45 +05:30
Akshita Goyal cba27c348d fix: home quick start widget validation (#6626)
* fix: Handled workspace switcher closing on click

* fix: home quickstart widget
2025-02-18 12:37:00 +05:30
Anmol Singh Bhatia ffe87cc3b4 chore: work item url redirection improvement (#6627) 2025-02-18 12:35:57 +05:30
Anmol Singh Bhatia 473932af0a [WEB-3291] dev: app sidebar revamp (#6578)
* chore: workspace constant and types updated

* chore: workspace service, store and app theme store updated

* dev: extended sidebar implementation and code refactor

* chore: ux improvements

* chore: sidebar preference endpoint updated

* chore: sidebar preference endpoint updated

* chore: sidebar preference endpoint updated

* chore: code refactor

* chore: code refactor

* chore: radix-ui react-scroll-area added to plane ui package

* chore: scrollbar color token added to tailwind config

* dev: scroll area component

* chore-scroll-area-component-improvement

* fix: build error

* chore: code refactor

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
2025-02-17 23:46:55 +05:30
Anmol Singh Bhatia a9aeeb6707 [WEB-3410] fix: work item permission and validation (#6621)
* fix: work item permission and validation

* fix: command palette

* chore: code refactor
2025-02-17 18:09:05 +05:30
Anmol Singh Bhatia 075eefe1a5 [WEB-2278] dev: scroll area enhancement (#6612)
* chore: radix-ui react-scroll-area added to plane ui package

* chore: scrollbar color token added to tailwind config

* dev: scroll area component

* chore-scroll-area-component-improvement

* fix: build error

* chore: code refactor
2025-02-17 15:15:45 +05:30
Aaryan Khandelwal 54bdd62d0c chore: add missing translation keys (#6619) 2025-02-17 15:14:25 +05:30
Anmol Singh Bhatia d4ee32cb41 fix: telemetry url (#6620) 2025-02-17 15:13:46 +05:30
Paul Ivanov 31bba2926d fix: provide working telemetry documentation url (#6614)
Closes #6613
2025-02-17 13:41:12 +05:30
Anmol Singh Bhatia d6c25a76f6 [WEB-3370] fix: cmd+k work item actions (#6617)
* fix: cmd+k work item actions

* chore: code refactor
2025-02-17 13:39:58 +05:30
Anmol Singh Bhatia 8a792d381b [WEB-3396] chore: work items parent select improvement (#6608)
* chore: work items parent select improvements

* chore: code refactor
2025-02-15 05:05:37 +05:30
Anmol Singh Bhatia 4353cc0c4a [WEB-3268] feat: url pattern (#6546)
* feat: meta endpoint for issue

* chore: add detail endpoint

* chore: getIssueMetaFromURL and retrieveWithIdentifier endpoint added

* chore: issue store updated

* chore: move issue detail to new route and add redirection for old route

* fix: issue details permission

* fix: work item detail header

* chore: generateWorkItemLink helper function added

* chore: copyTextToClipboard helper function updated

* chore: workItemLink updated

* chore: workItemLink updated

* chore: workItemLink updated

* fix: issues navigation tab active status

* fix: invalid workitem error state

* chore: peek view parent issue redirection improvement

* fix: issue detail endpoint to not return epics and intake issue

* fix: workitem empty state redirection and header

* fix: workitem empty state redirection and header

* chore: code refactor

* chore: project auth wrapper improvement

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-02-15 05:05:00 +05:30
Anmol Singh Bhatia 82eea3e802 [WEB-3357 | WEB-3363 | WEB-3370] chore: command-k enhancement and fixes (#6600)
* fix: command-k work item actions

* chore: command k work item context indicator improvement and default vale for workspace toggle updated

* chore: code refactor
2025-02-14 19:04:08 +05:30
Prateek Shourya bf1f12378e improvement: minor improvements for workspace switcher (#6609) 2025-02-14 19:03:32 +05:30
Anmol Singh Bhatia c4a3e1e8ac chore: whats new modal width updated (#6607) 2025-02-14 17:02:40 +05:30
Prateek Shourya b62b2710f5 fix: ensure empty state group header is visible (#6606) 2025-02-14 13:54:25 +05:30
Anmol Singh Bhatia 71b41fa22b chore: whats new modal width updated (#6605) 2025-02-14 13:51:26 +05:30
Prateek Shourya 3528d2c934 [WEB-3368] feat: enhance workspace invitations with copyable invite links (#6601)
* feat: invitation link url

* feat: copy invite link from workspace invitations list

* invitation reponse cleanup and logo url fix

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-02-13 23:35:25 +05:30
Anmol Singh Bhatia 39ecfbe7e1 [WEB-3375] fix: project cover image (#6602)
* fix: project cover image

* chore: code refactor
2025-02-13 23:34:08 +05:30
Anmol Singh Bhatia a95864ba11 fix: create sub work item operation (#6603) 2025-02-13 23:33:41 +05:30
140 changed files with 3269 additions and 1060 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
in line with{" "}
<a
href="https://docs.plane.so/self-hosting/telemetry"
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
@@ -336,7 +336,7 @@ export const InstanceSetupForm: FC = (props) => {
</label>
<a
tabIndex={-1}
href="https://docs.plane.so/telemetry"
href="https://developers.plane.so/self-hosting/telemetry"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-500 hover:text-blue-600"
+79 -63
View File
@@ -1,6 +1,7 @@
# Django imports
from django.utils import timezone
from lxml import html
from django.db import IntegrityError
# Third party imports
from rest_framework import serializers
@@ -138,47 +139,56 @@ class IssueSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
try:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
)
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
except IntegrityError:
pass
if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
)
except IntegrityError:
pass
return issue
@@ -194,39 +204,45 @@ class IssueSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee_id=assignee_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for assignee_id in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label_id=label_id,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label_id in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
+78 -62
View File
@@ -2,6 +2,7 @@
from django.utils import timezone
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Third Party imports
from rest_framework import serializers
@@ -134,47 +135,56 @@ class IssueCreateSerializer(BaseSerializer):
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
except IntegrityError:
pass
else:
# Then assign it to default assignee
if default_assignee_id is not None:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if labels is not None and len(labels):
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
try:
IssueAssignee.objects.create(
assignee_id=default_assignee_id,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
except IntegrityError:
pass
if labels is not None and len(labels):
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
except IntegrityError:
pass
return issue
@@ -190,39 +200,45 @@ class IssueCreateSerializer(BaseSerializer):
if assignees is not None:
IssueAssignee.objects.filter(issue=instance).delete()
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueAssignee.objects.bulk_create(
[
IssueAssignee(
assignee=user,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
if labels is not None:
IssueLabel.objects.filter(issue=instance).delete()
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
ignore_conflicts=True,
)
try:
IssueLabel.objects.bulk_create(
[
IssueLabel(
label=label,
issue=instance,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
ignore_conflicts=True,
)
except IntegrityError:
pass
# Time updation occues even when other related models are updated
instance.updated_at = timezone.now()
+12 -9
View File
@@ -59,7 +59,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
class WorkspaceLiteSerializer(BaseSerializer):
class Meta:
model = Workspace
fields = ["name", "slug", "id"]
fields = ["name", "slug", "id", "logo_url"]
read_only_fields = fields
@@ -90,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
workspace = WorkspaceLiteSerializer(read_only=True)
invite_link = serializers.SerializerMethodField()
def get_invite_link(self, obj):
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
class Meta:
model = WorkspaceMemberInvite
@@ -106,6 +108,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
"responded_at",
"created_at",
"updated_at",
"invite_link",
]
@@ -148,12 +151,12 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=validated_data.get("workspace_id"),
url=url,
workspace_id=validated_data.get("workspace_id"),
owner_id=validated_data.get("owner_id")
)
@@ -170,8 +173,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter(
url=url,
workspace_id=instance.workspace_id,
url=url,
workspace_id=instance.workspace_id,
owner=instance.owner
)
+12
View File
@@ -26,6 +26,8 @@ from plane.app.views import (
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
urlpatterns = [
@@ -278,4 +280,14 @@ urlpatterns = [
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
IssueMetaEndpoint.as_view(),
name="issue-meta",
),
path(
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
IssueDetailIdentifierEndpoint.as_view(),
name="issue-detail-identifier",
),
]
+2
View File
@@ -116,6 +116,8 @@ from .issue.base import (
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueBulkUpdateDateEndpoint,
IssueMetaEndpoint,
IssueDetailIdentifierEndpoint,
)
from .issue.activity import IssueActivityEndpoint
+19 -3
View File
@@ -5,6 +5,7 @@ import uuid
from django.conf import settings
from django.http import HttpResponseRedirect
from django.utils import timezone
from django.db import IntegrityError
# Third party imports
from rest_framework import status
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
[self.save_project_cover(asset, project_id) for asset in assets]
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
assets.update(issue_id=entity_id)
# For some cases, the bulk api is called after the issue is deleted creating
# an integrity error
try:
assets.update(issue_id=entity_id)
except IntegrityError:
pass
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
assets.update(comment_id=entity_id)
# For some cases, the bulk api is called after the comment is deleted
# creating an integrity error
try:
assets.update(comment_id=entity_id)
except IntegrityError:
pass
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
assets.update(page_id=entity_id)
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
assets.update(draft_issue_id=entity_id)
# For some cases, the bulk api is called after the draft issue is deleted
# creating an integrity error
try:
assets.update(draft_issue_id=entity_id)
except IntegrityError:
pass
return Response(status=status.HTTP_204_NO_CONTENT)
+175
View File
@@ -1096,3 +1096,178 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
return Response(
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
)
class IssueMetaEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, issue_id):
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
id=issue_id, project_id=project_id, workspace__slug=slug
)
return Response(
{
"sequence_id": issue.sequence_id,
"project_identifier": issue.project.identifier,
},
status=status.HTTP_200_OK,
)
class IssueDetailIdentifierEndpoint(BaseAPIView):
def get(self, request, slug, project_identifier, issue_identifier):
# Fetch the project
project = Project.objects.get(
identifier__iexact=project_identifier,
workspace__slug=slug,
)
# Check if the user is a member of the project
if not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
is_active=True,
).exists():
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_403_FORBIDDEN,
)
# Fetch the issue
issue = (
Issue.issue_objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=FileAsset.objects.filter(
issue_id=OuterRef("id"),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(sequence_id=issue_identifier)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("issue", "actor"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project.id,
issue__sequence_id=issue_identifier,
subscriber=request.user,
)
)
)
).first()
# Check if the issue exists
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
"""
if the role is guest and guest_view_all_features is false and owned by is not
the requesting user then dont show the issue
"""
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project.id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
recent_visited_task.delay(
slug=slug,
entity_name="issue",
entity_identifier=str(issue.id),
user_id=str(request.user.id),
project_id=str(project.id),
)
# Serialize the issue
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
+26 -17
View File
@@ -5,6 +5,7 @@ import json
from django.utils import timezone
from django.db.models import Exists
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
# Third Party imports
from rest_framework.response import Response
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, comment_id):
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
try:
serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
project_id=project_id,
actor_id=request.user.id,
comment_id=comment_id,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Reaction already exists for the user"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="comment_reaction.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def destroy(self, request, slug, project_id, comment_id, reaction_code):
+15 -1
View File
@@ -55,6 +55,20 @@ class LabelViewSet(BaseViewSet):
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@allow_permission([ROLE.ADMIN])
def partial_update(self, request, *args, **kwargs):
# Check if the label name is unique within the project
if (
"name" in request.data
and Label.objects.filter(
project_id=kwargs["project_id"], name=request.data["name"]
)
.exclude(pk=kwargs["pk"])
.exists()
):
return Response(
{"error": "Label with the same name already exists in the project"},
status=status.HTTP_400_BAD_REQUEST,
)
# call the parent method to perform the update
return super().partial_update(request, *args, **kwargs)
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
@@ -74,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
Label(
name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"),
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
@@ -4,6 +4,7 @@ from rest_framework.response import Response
# Django modules
from django.db.models import Q
from django.db import IntegrityError
# Module imports
from plane.app.views.base import BaseAPIView
@@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
try:
workspace = Workspace.objects.get(slug=slug)
serializer = UserFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
user_id=request.user.id,
workspace=workspace,
project_id=request.data.get("project_id", None),
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
except IntegrityError:
return Response(
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def patch(self, request, slug, favorite_id):
@@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace", "workspace__owner", "created_by")
.annotate(total_members=Count("workspace__workspace_member"))
.select_related("workspace")
)
@invalidate_cache(path="/api/workspaces/", user=False)
@@ -30,7 +30,6 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
keys = [
key
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
if key not in ["projects"]
]
for preference in keys:
@@ -40,20 +39,28 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
preference = WorkspaceUserPreference.objects.bulk_create(
[
WorkspaceUserPreference(
key=key, user=request.user, workspace=workspace
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
)
for key in create_preference_keys
for i, key in enumerate(create_preference_keys)
],
batch_size=10,
ignore_conflicts=True,
)
preference = WorkspaceUserPreference.objects.filter(
preferences = WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
).order_by("sort_order").values("key", "is_pinned", "sort_order")
user_preferences = {}
for preference in preferences:
user_preferences[(str(preference["key"]))] = {
"is_pinned": preference["is_pinned"],
"sort_order": preference["sort_order"],
}
return Response(
preference.values("key", "is_pinned", "sort_order"),
user_preferences,
status=status.HTTP_200_OK,
)
@@ -9,10 +9,11 @@ from celery import shared_task
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
# Module imports
from plane.utils.valid_uuid import is_valid_uuid
from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.db.models import (
CommentReaction,
Cycle,
@@ -790,14 +791,15 @@ def create_cycle_issue_activity(
issue_id=updated_record.get("issue_id"),
actor_id=actor_id,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
old_value=old_cycle.name if old_cycle else "",
new_value=new_cycle.name if new_cycle else "",
field="cycles",
project_id=project_id,
workspace_id=workspace_id,
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
to {new_cycle.name if new_cycle else ""}""",
old_identifier=old_cycle.id if old_cycle else None,
new_identifier=new_cycle.id if new_cycle else None,
epoch=epoch,
)
)
@@ -893,11 +895,11 @@ def create_module_issue_activity(
actor_id=actor_id,
verb="created",
old_value="",
new_value=module.name,
new_value=module.name if module else "",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name}",
comment=f"added module {module.name if module else ''}",
new_identifier=requested_data.get("module_id"),
epoch=epoch,
)
@@ -1413,7 +1415,7 @@ def delete_issue_relation_activity(
),
project_id=project_id,
workspace_id=workspace_id,
comment=f'deleted {requested_data.get("relation_type")} relation',
comment=f"deleted {requested_data.get('relation_type')} relation",
old_identifier=requested_data.get("related_issue"),
epoch=epoch,
)
@@ -1567,6 +1569,10 @@ def issue_activity(
try:
issue_activities = []
# Validate UUIDs
if not is_valid_uuid(project_id):
return
project = Project.objects.get(pk=project_id)
workspace_id = project.workspace_id
@@ -1,5 +1,6 @@
# Python imports
from django.utils import timezone
from django.db import DatabaseError
# Third party imports
from celery import shared_task
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
).first()
if recent_visited:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
# Check if the database is available
try:
recent_visited.visited_at = timezone.now()
recent_visited.save(update_fields=["visited_at"])
except DatabaseError:
pass
else:
recent_visited_count = UserRecentVisit.objects.filter(
user_id=user_id, workspace_id=workspace.id
+5 -5
View File
@@ -391,13 +391,13 @@ class WorkspaceHomePreference(BaseModel):
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
PROJECTS = "projects", "Projects"
ANALYTICS = "analytics", "Analytics"
CYCLES = "cycles", "Cycles"
class UserPreferenceKeys(models.TextChoices):
VIEWS = "views", "Views"
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
ANALYTICS = "analytics", "Analytics"
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
workspace = models.ForeignKey(
"db.Workspace",
+2 -2
View File
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
).first()
)
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
+9
View File
@@ -0,0 +1,9 @@
import uuid
def is_valid_uuid(uuid_to_test, version=4):
try:
# check for validity of Uuid
uuid_obj = uuid.UUID(uuid_to_test, version=version)
except ValueError:
return False
return True
+87 -12
View File
@@ -84,48 +84,42 @@ export const WORKSPACE_SETTINGS = {
i18n_label: "workspace_settings.settings.general.title",
href: `/settings`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
},
members: {
key: "members",
i18n_label: "workspace_settings.settings.members.title",
href: `/settings/members`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/members/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
},
"billing-and-plans": {
key: "billing-and-plans",
i18n_label: "workspace_settings.settings.billing_and_plans.title",
href: `/settings/billing`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/billing/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`,
},
export: {
key: "export",
i18n_label: "workspace_settings.settings.exports.title",
href: `/settings/exports`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/exports/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
},
webhooks: {
key: "webhooks",
i18n_label: "workspace_settings.settings.webhooks.title",
href: `/settings/webhooks`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/webhooks/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/api-tokens/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
@@ -256,3 +250,84 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: {
i18n_label: "default_global_view.subscribed",
},
];
export interface IWorkspaceSidebarNavigationItem {
key: string;
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
}
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
"your-work": {
key: "your_work",
labelTranslationKey: "your_work",
href: `/profile/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
views: {
key: "views",
labelTranslationKey: "views",
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
"active-cycles": {
key: "active_cycles",
labelTranslationKey: "cycles",
href: `/active-cycles/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
analytics: {
key: "analytics",
labelTranslationKey: "analytics",
href: `/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
archives: {
key: "archives",
labelTranslationKey: "archives",
href: `/projects/archives/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
};
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"],
];
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
home: {
key: "home",
labelTranslationKey: "home.title",
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
notifications: {
key: "notifications",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
projects: {
key: "projects",
labelTranslationKey: "projects",
href: `/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
};
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];
@@ -8,9 +8,9 @@ export const useOutsideClickDetector = (
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as any)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (
event.target as unknown as HTMLElement | undefined
)?.closest("[data-prevent-outside-click]");
const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest(
"[data-prevent-outside-click]"
);
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;
+22 -2
View File
@@ -520,6 +520,7 @@
"customize_time_range": "Customize time range",
"loading": "Loading",
"attachments": "Attachments",
"property": "Property",
"properties": "Properties",
"parent": "Parent",
"remove": "Remove",
@@ -672,7 +673,21 @@
"disconnect": "Disconnect",
"disconnecting": "Disconnecting",
"installing": "Installing",
"install": "Install"
"install": "Install",
"category": "Category",
"categories": "Categories",
"saving": "Saving",
"save_changes": "Save changes",
"delete": "Delete",
"deleting": "Deleting",
"pending": "Pending",
"invite": "Invite"
},
"chart": {
"x_axis": "X-axis",
"y_axis": "Y-axis",
"metric": "Metric"
},
"form": {
@@ -1279,6 +1294,7 @@
"members": {
"title": "Members",
"add_member": "Add member",
"pending_invites": "Pending invites",
"invitations_sent_successfully": "Invitations sent successfully",
"leave_confirmation": "Are you sure you want to leave the workspace? You will no longer have access to this workspace. This action cannot be undone.",
"details": {
@@ -1515,7 +1531,11 @@
}
},
"states": {
"describe_this_state_for_your_members": "Describe this state for your members."
"describe_this_state_for_your_members": "Describe this state for your members.",
"empty_state": {
"title": "No states available for the {groupKey} group",
"description": "Please create a new state"
}
},
"labels": {
"label_title": "Label title",
+22 -2
View File
@@ -690,6 +690,7 @@
"customize_time_range": "Personalizar rango de tiempo",
"loading": "Cargando",
"attachments": "Archivos adjuntos",
"property": "Propiedad",
"properties": "Propiedades",
"parent": "Padre",
"remove": "Eliminar",
@@ -842,7 +843,21 @@
"disconnect": "Desconectar",
"disconnecting": "Desconectando",
"installing": "Instalando",
"install": "Instalar"
"install": "Instalar",
"category": "Categoría",
"categories": "Categorías",
"saving": "Guardando",
"save_changes": "Guardar cambios",
"delete": "Eliminar",
"deleting": "Eliminando",
"pending": "Pendiente",
"invite": "Invitar"
},
"chart": {
"x_axis": "Eje X",
"y_axis": "Eje Y",
"metric": "Métrica"
},
"form": {
@@ -1448,6 +1463,7 @@
"members": {
"title": "Miembros",
"add_member": "Agregar miembro",
"pending_invites": "Invitaciones pendientes",
"invitations_sent_successfully": "Invitaciones enviadas exitosamente",
"leave_confirmation": "¿Estás seguro de que quieres abandonar el espacio de trabajo? Ya no tendrás acceso a este espacio de trabajo. Esta acción no se puede deshacer.",
"details": {
@@ -1684,7 +1700,11 @@
}
},
"states": {
"describe_this_state_for_your_members": "Describe este estado para tus miembros."
"describe_this_state_for_your_members": "Describe este estado para tus miembros.",
"empty_state": {
"title": "No estados disponibles para el grupo {groupKey}",
"description": "Por favor, crea un nuevo estado"
}
},
"labels": {
"label_title": "Título de la etiqueta",
+22 -2
View File
@@ -690,6 +690,7 @@
"customize_time_range": "Personnaliser la plage de temps",
"loading": "Chargement",
"attachments": "Pièces jointes",
"property": "Propriété",
"properties": "Propriétés",
"parent": "Parent",
"remove": "Supprimer",
@@ -842,7 +843,21 @@
"disconnect": "Déconnecter",
"disconnecting": "Déconnexion",
"installing": "Installation",
"install": "Installer"
"install": "Installer",
"category": "Catégorie",
"categories": "Catégories",
"saving": "Enregistrement",
"save_changes": "Enregistrer les modifications",
"delete": "Supprimer",
"deleting": "Suppression",
"pending": "En attente",
"invite": "Inviter"
},
"chart": {
"x_axis": "Axe X",
"y_axis": "Axe Y",
"metric": "Métrique"
},
"form": {
@@ -1448,6 +1463,7 @@
"members": {
"title": "Membres",
"add_member": "Ajouter un membre",
"pending_invites": "Invitations en attente",
"invitations_sent_successfully": "Invitations envoyées avec succès",
"leave_confirmation": "Êtes-vous sûr de vouloir quitter l'espace de travail ? Vous n'aurez plus accès à cet espace de travail. Cette action ne peut pas être annulée.",
"details": {
@@ -1684,7 +1700,11 @@
}
},
"states": {
"describe_this_state_for_your_members": "Décrivez cet état pour vos membres."
"describe_this_state_for_your_members": "Décrivez cet état pour vos membres.",
"empty_state": {
"title": "Aucun état disponible pour le groupe {groupKey}",
"description": "Veuillez créer un nouvel état"
}
},
"labels": {
"label_title": "Titre de l'étiquette",
+22 -2
View File
@@ -690,6 +690,7 @@
"customize_time_range": "期間をカスタマイズ",
"loading": "読み込み中",
"attachments": "添付ファイル",
"property": "プロパティ",
"properties": "プロパティ",
"parent": "親",
"remove": "削除",
@@ -842,7 +843,21 @@
"disconnect": "切断",
"disconnecting": "切断中",
"installing": "インストール中",
"install": "インストール"
"install": "インストール",
"category": "カテゴリー",
"categories": "カテゴリーズ",
"saving": "セービング",
"save_changes": "セーブ チェンジズ",
"delete": "デリート",
"deleting": "デリーティング",
"pending": "保留中",
"invite": "招待"
},
"chart": {
"x_axis": "エックス アクシス",
"y_axis": "ワイ アクシス",
"metric": "メトリック"
},
"form": {
@@ -1448,6 +1463,7 @@
"members": {
"title": "メンバー",
"add_member": "メンバーを追加",
"pending_invites": "保留中の招待",
"invitations_sent_successfully": "招待が正常に送信されました",
"leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。",
"details": {
@@ -1684,7 +1700,11 @@
}
},
"states": {
"describe_this_state_for_your_members": "このステータスについてメンバーに説明してください。"
"describe_this_state_for_your_members": "このステータスについてメンバーに説明してください。",
"empty_state": {
"title": "{groupKey}グループのステータスがありません",
"description": "新しいステータスを作成してください"
}
},
"labels": {
"label_title": "ラベルタイトル",
@@ -690,6 +690,7 @@
"customize_time_range": "自定义时间范围",
"loading": "加载中",
"attachments": "附件",
"property": "属性",
"properties": "属性",
"parent": "父项",
"remove": "移除",
@@ -842,7 +843,21 @@
"disconnect": "断开连接",
"disconnecting": "正在断开连接",
"installing": "正在安装",
"install": "安装"
"install": "安装",
"category": "类别",
"categories": "类别",
"saving": "保存中",
"save_changes": "保存更改",
"delete": "删除",
"deleting": "删除中",
"pending": "待处理",
"invite": "邀请"
},
"chart": {
"x_axis": "X轴",
"y_axis": "Y轴",
"metric": "指标"
},
"form": {
@@ -1448,6 +1463,7 @@
"members": {
"title": "成员",
"add_member": "添加成员",
"pending_invites": "待处理邀请",
"invitations_sent_successfully": "邀请发送成功",
"leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。",
"details": {
@@ -1684,7 +1700,11 @@
}
},
"states": {
"describe_this_state_for_your_members": "为您的成员描述此状态。"
"describe_this_state_for_your_members": "为您的成员描述此状态。",
"empty_state": {
"title": "{groupKey} 组中没有状态",
"description": "请创建一个新状态"
}
},
"labels": {
"label_title": "标签标题",
@@ -203,6 +203,11 @@ module.exports = {
},
},
backdrop: "rgba(0, 0, 0, 0.25)",
scrollbar: {
neutral: "rgba(96, 100, 108, 0.1)",
hover: "rgba(96, 100, 108, 0.25)",
active: "rgba(96, 100, 108, 0.7)",
},
},
onboarding: {
background: {
+1 -1
View File
@@ -26,7 +26,7 @@ export type TIssueActivityProjectDetail = {
export type TIssueActivityIssueDetail = {
id: string;
sequence_id: boolean;
sequence_id: number;
sort_order: boolean;
name: string;
description_html: string;
+13 -9
View File
@@ -1,10 +1,4 @@
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
IWorkspaceViewProps,
} from "@plane/types";
import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency
import { TUserPermissions } from "./enums";
@@ -22,7 +16,6 @@ export interface IWorkspace {
readonly updated_by: string;
organization_size: string;
total_projects?: number;
current_plan?: string;
role: number;
}
@@ -40,9 +33,10 @@ export interface IWorkspaceMemberInvitation {
responded_at: Date;
role: TUserPermissions;
token: string;
invite_link: string;
workspace: {
id: string;
logo: string;
logo_url: string;
name: string;
slug: string;
};
@@ -229,3 +223,13 @@ export interface IWorkspaceAnalyticsResponse {
export type TWorkspacePaginationInfo = TPaginationInfo & {
results: IWorkspace[];
};
export interface IWorkspaceSidebarNavigationItem {
key?: string;
is_pinned: boolean;
sort_order: number;
}
export interface IWorkspaceSidebarNavigation {
[key: string]: IWorkspaceSidebarNavigationItem;
}
+1
View File
@@ -34,6 +34,7 @@
"@plane/hooks": "*",
"@plane/utils": "*",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-scroll-area": "^1.2.3",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lodash": "^4.17.21",
+5 -12
View File
@@ -2,20 +2,13 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const OverviewIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className = "" }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
export const OverviewIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={className} xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 3C2.5 2.86739 2.55268 2.74021 2.64645 2.64645C2.74021 2.55268 2.86739 2.5 3 2.5H3.5C9.02267 2.5 13.5 6.97733 13.5 12.5V13C13.5 13.1326 13.4473 13.2598 13.3536 13.3536C13.2598 13.4473 13.1326 13.5 13 13.5H12.5C12.3674 13.5 12.2402 13.4473 12.1464 13.3536C12.0527 13.2598 12 13.1326 12 13V12.5C12 7.80533 8.19467 4 3.5 4H3C2.86739 4 2.74021 3.94732 2.64645 3.85355C2.55268 3.75979 2.5 3.63261 2.5 3.5V3ZM2.5 7.5C2.5 7.36739 2.55268 7.24022 2.64645 7.14645C2.74021 7.05268 2.86739 7 3 7H3.5C4.22227 7 4.93747 7.14226 5.60476 7.41866C6.27205 7.69506 6.87837 8.10019 7.38909 8.61091C7.89981 9.12164 8.30494 9.72795 8.58134 10.3952C8.85774 11.0625 9 11.7777 9 12.5V13C9 13.1326 8.94732 13.2598 8.85355 13.3536C8.75978 13.4473 8.63261 13.5 8.5 13.5H8C7.86739 13.5 7.74022 13.4473 7.64645 13.3536C7.55268 13.2598 7.5 13.1326 7.5 13V12.5C7.5 11.4391 7.07857 10.4217 6.32843 9.67157C5.57828 8.92143 4.56087 8.5 3.5 8.5H3C2.86739 8.5 2.74021 8.44732 2.64645 8.35355C2.55268 8.25978 2.5 8.13261 2.5 8V7.5ZM2.5 12.5C2.5 12.2348 2.60536 11.9804 2.79289 11.7929C2.98043 11.6054 3.23478 11.5 3.5 11.5C3.76522 11.5 4.01957 11.6054 4.20711 11.7929C4.39464 11.9804 4.5 12.2348 4.5 12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5C3.23478 13.5 2.98043 13.3946 2.79289 13.2071C2.60536 13.0196 2.5 12.7652 2.5 12.5Z"
fill="#455068"
fill="currentColor"
/>
</svg>
);
+1
View File
@@ -25,6 +25,7 @@ export * from "./popovers";
export * from "./tables";
export * from "./header";
export * from "./row";
export * from "./scroll-area";
export * from "./content-wrapper";
export * from "./card";
export * from "./tag";
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as RadixScrollArea from "@radix-ui/react-scroll-area";
import React, { FC } from "react";
import { cn } from "../helpers";
type TScrollAreaProps = {
type?: "auto" | "always" | "scroll" | "hover";
className?: string;
scrollHideDelay?: number;
size?: "sm" | "md" | "lg";
children: React.ReactNode;
};
const sizeStyles = {
sm: "p-[0.112rem] data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:h-2.5",
md: "p-[0.152rem] data-[orientation=vertical]:w-3 data-[orientation=horizontal]:h-3",
lg: "p-[0.225rem] data-[orientation=vertical]:w-4 data-[orientation=horizontal]:h-4",
};
const thumbSizeStyles = {
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:min-w-14 before:-translate-x-1/2 before:-translate-y-1/2",
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:min-w-17 before:-translate-x-1/2 before:-translate-y-1/2",
};
export const ScrollArea: FC<TScrollAreaProps> = (props) => {
const { type = "always", className = "", scrollHideDelay = 600, size = "md", children } = props;
return (
<RadixScrollArea.Root
type={type}
className={cn("group overflow-hidden", className)}
scrollHideDelay={scrollHideDelay}
>
<RadixScrollArea.Viewport className="size-full">{children}</RadixScrollArea.Viewport>
<RadixScrollArea.Scrollbar
className={cn(
"group/track flex touch-none select-none bg-transparent transition-colors duration-[160ms] ease-out",
sizeStyles[size]
)}
orientation="vertical"
>
<RadixScrollArea.Thumb
className={cn(
"relative flex-1 rounded-[10px] bg-custom-scrollbar-neutral group-hover:bg-custom-scrollbar-hover group-active/track:bg-custom-scrollbar-active",
thumbSizeStyles[size]
)}
/>
</RadixScrollArea.Scrollbar>
<RadixScrollArea.Scrollbar
className={cn(
"group/track flex touch-none select-none bg-transparent transition-colors duration-[160ms] ease-out",
sizeStyles[size]
)}
orientation="horizontal"
>
<RadixScrollArea.Thumb
className={cn(
"relative flex-1 rounded-[10px] bg-custom-scrollbar-neutral group-hover:bg-custom-scrollbar-hover group-active/track:bg-custom-scrollbar-active",
thumbSizeStyles[size]
)}
/>
</RadixScrollArea.Scrollbar>
</RadixScrollArea.Root>
);
};
+80
View File
@@ -7,6 +7,8 @@
*/
export type RGB = { r: number; g: number; b: number };
export type HSL = { h: number; s: number; l: number };
/**
* @description Validates and clamps color values to RGB range (0-255)
* @param {number} value - The color value to validate
@@ -62,3 +64,81 @@ export const hexToRgb = (hex: string): RGB => {
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
*/
export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
/**
* Converts Hex values to HSL values
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
* @returns {HSL} An object containing the HSL values
* @example
* hexToHsl("#ff0000") // returns { h: 0, s: 100, l: 50 }
* hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 }
* hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 }
*/
export const hexToHsl = (hex: string): HSL => {
// return default value for invalid hex
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 };
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 360,
s: s * 100,
l: l * 100,
};
};
/**
* Converts HSL values to a hexadecimal color code
* @param {HSL} hsl - An object containing HSL values
* @param {number} hsl.h - Hue component (0-360)
* @param {number} hsl.s - Saturation component (0-100)
* @param {number} hsl.l - Lightness component (0-100)
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
* @example
* hslToHex({ h: 0, s: 100, l: 50 }) // returns "#ff0000"
* hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00"
* hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff"
*/
export const hslToHex = ({ h, s, l }: HSL): string => {
if (h < 0 || h > 360) return "#000000";
if (s < 0 || s > 100) return "#000000";
if (l < 0 || l > 100) return "#000000";
l /= 100;
const a = (s * Math.min(l, 1 - l)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
};
+1
View File
@@ -10,3 +10,4 @@ export * from "./issue";
export * from "./state";
export * from "./string";
export * from "./theme";
export * from "./workspace";
+5
View File
@@ -0,0 +1,5 @@
// plane imports
import { IWorkspace } from "@plane/types";
export const orderWorkspacesList = (workspaces: IWorkspace[]): IWorkspace[] =>
workspaces.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,94 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Briefcase } from "lucide-react";
// ui
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { IssueDetailQuickActions } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
export const ProjectIssueDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// store hooks
const { getProjectById, loader } = useProject();
const {
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const issueId = getIssueIdByIdentifier(workItem?.toString());
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
const projectId = issueDetails ? issueDetails?.project_id : undefined;
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
if (!workspaceSlug || !projectId || !issueId) return null;
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={projectDetails?.name ?? "Project"}
icon={
projectDetails ? (
projectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={projectDetails?.logo_props} size={16} />
</span>
)
) : (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label="Issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
{projectId && issueId && (
<IssueDetailQuickActions
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
issueId={issueId?.toString()}
/>
)}
</Header.RightItem>
</Header>
);
});
@@ -0,0 +1,115 @@
"use client";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssueDetailRoot } from "@/components/issues";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
// assets
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
const IssueDetailsPage = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, workItem } = useParams();
// hooks
const { resolvedTheme } = useTheme();
// store hooks
const { t } = useTranslation();
const {
fetchIssueWithIdentifier,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
// fetching issue details
const { data, isLoading, error } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = data?.id;
const projectId = data?.project_id;
// derived values
const issue = getIssueById(issueId?.toString() || "") || undefined;
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
const issueLoader = !issue || isLoading;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
useEffect(() => {
const handleToggleIssueDetailSidebar = () => {
if (window && window.innerWidth < 768) {
toggleIssueDetailSidebar(true);
}
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
toggleIssueDetailSidebar(false);
}
};
window.addEventListener("resize", handleToggleIssueDetailSidebar);
handleToggleIssueDetailSidebar();
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
return (
<>
<PageHead title={pageTitle} />
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
}}
/>
) : issueLoader ? (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
) : (
workspaceSlug &&
projectId &&
issueId && (
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
</ProjectAuthWrapper>
)
)}
</>
);
});
export default IssueDetailsPage;
@@ -0,0 +1,155 @@
"use client";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// hooks
import { orderJoinedProjects } from "@/helpers/project.helper";
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
import { TProject } from "@/plane-web/types";
export const ExtendedProjectSidebar = observer(() => {
// refs
const extendedProjectSidebarRef = useRef<HTMLDivElement | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
// states
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
// routers
const { workspaceSlug } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
const { allowPermissions } = useUserPermissions();
const handleOnProjectDrop = (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => {
if (!sourceId || !destinationId || !workspaceSlug) return;
if (sourceId === destinationId) return;
const joinedProjectsList: TProject[] = [];
joinedProjects.map((projectId) => {
const projectDetails = getPartialProjectById(projectId);
if (projectDetails) joinedProjectsList.push(projectDetails);
});
const sourceIndex = joinedProjects.indexOf(sourceId);
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
if (joinedProjectsList.length <= 0) return;
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
if (updatedSortOrder != undefined)
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
};
// filter projects based on search query
const filteredProjects = joinedProjects.filter((projectId) => {
const project = getPartialProjectById(projectId);
if (!project) return false;
return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery);
});
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
useExtendedSidebarOutsideClickDetector(
extendedProjectSidebarRef,
() => {
if (!isProjectModalOpen) {
toggleExtendedProjectSidebar(false);
}
},
"extended-project-sidebar-toggle"
);
return (
<>
{workspaceSlug && (
<CreateProjectModal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
setToFavorite={false}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<div
ref={extendedProjectSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
{
"translate-x-0 opacity-100": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0": !extendedProjectSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
>
<div className="flex flex-col gap-1 w-full">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setIsProjectModalOpen(true);
}}
>
<Plus className="size-3" />
</button>
</Tooltip>
)}
</div>
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1 w-full">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={t("search")}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-0.5">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => {}}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
</div>
</div>
</>
);
});
@@ -0,0 +1,126 @@
"use client";
import React, { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
import { cn } from "@plane/utils";
// hooks
import { useAppTheme, useWorkspace } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
// plane-web imports
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar";
export const ExtendedAppSidebar = observer(() => {
// refs
const extendedSidebarRef = useRef<HTMLDivElement | null>(null);
// routers
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
const orderNavigationItem = (
sourceIndex: number,
destinationIndex: number,
navigationList: {
sort_order: number;
key: string;
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
}[]
): number | undefined => {
if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined;
let updatedSortOrder: number | undefined = undefined;
const sortOrderDefaultValue = 10000;
if (destinationIndex === 0) {
// updating project at the top of the project
const currentSortOrder = navigationList[destinationIndex].sort_order || 0;
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
} else if (destinationIndex === navigationList.length) {
// updating project at the bottom of the project
const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
} else {
// updating project in the middle of the project
const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0;
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
updatedSortOrder = updatedValue;
}
return updatedSortOrder;
};
const handleOnNavigationItemDrop = (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => {
if (!sourceId || !destinationId || !workspaceSlug) return;
if (sourceId === destinationId) return;
const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId);
const destinationIndex = shouldDropAtEnd
? sortedNavigationItemsKeys.length
: sortedNavigationItemsKeys.indexOf(destinationId);
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
if (updatedSortOrder != undefined)
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
sort_order: updatedSortOrder,
});
};
useExtendedSidebarOutsideClickDetector(
extendedSidebarRef,
() => toggleExtendedSidebar(false),
"extended-sidebar-toggle"
);
return (
<div
ref={extendedSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
{
"translate-x-0 opacity-100": extendedSidebarCollapsed,
"-translate-x-full opacity-0": !extendedSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
>
{sortedNavigationItems.map((item, index) => (
<ExtendedSidebarItem
key={item.key}
item={item}
isLastChild={index === sortedNavigationItems.length - 1}
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
/>
))}
</div>
);
});
@@ -1,70 +1,44 @@
"use client";
import React, { useEffect } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { redirect, useParams } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/common";
import { PageHead } from "@/components/core";
import { IssueDetailRoot } from "@/components/issues";
import { EmptyState, LogoSpinner } from "@/components/common";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
// assets
import { useAppRouter } from "@/hooks/use-app-router";
// assets
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
// services
import { IssueService } from "@/services/issue/issue.service";
const issueService = new IssueService();
const IssueDetailsPage = observer(() => {
// i18n
const { t } = useTranslation();
// router
const router = useAppRouter();
const { t } = useTranslation();
const { workspaceSlug, projectId, issueId } = useParams();
// hooks
const { resolvedTheme } = useTheme();
// store hooks
const {
fetchIssue,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
// fetching work item details
const { isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
const { data, isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
// derived values
const issue = getIssueById(issueId?.toString() || "") || undefined;
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
const issueLoader = !issue || isLoading;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
useEffect(() => {
const handleToggleIssueDetailSidebar = () => {
if (window && window.innerWidth < 768) {
toggleIssueDetailSidebar(true);
}
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
toggleIssueDetailSidebar(false);
}
};
window.addEventListener("resize", handleToggleIssueDetailSidebar);
handleToggleIssueDetailSidebar();
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
if (data) {
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
}
}, [workspaceSlug, data]);
return (
<>
<PageHead title={pageTitle} />
<div className="flex items-center justify-center size-full">
{error ? (
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
@@ -72,36 +46,17 @@ const IssueDetailsPage = observer(() => {
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
}}
/>
) : issueLoader ? (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
) : isLoading ? (
<>
<LogoSpinner />
</>
) : (
workspaceSlug &&
projectId &&
issueId && (
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
)
<></>
)}
</>
</div>
);
});
@@ -1,73 +0,0 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// i18n
import { useTranslation } from "@plane/i18n";
// ui
import { Breadcrumbs, LayersIcon, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { IssueDetailQuickActions } from "@/components/issues";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
export const ProjectIssueDetailsHeader = observer(() => {
const { t } = useTranslation();
// router
const router = useAppRouter();
const { workspaceSlug, projectId, issueId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/issues`}
label={t("issue.label", { count: 2 })} // count is for pluralization
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={
currentProjectDetails && issueDetails
? `${currentProjectDetails.identifier}-${issueDetails.sequence_id}`
: ""
}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
</Header.RightItem>
</Header>
);
});
@@ -1,11 +1,18 @@
"use client";
import { ReactNode } from "react";
import { useParams } from "next/navigation";
// plane web layouts
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => (
<ProjectAuthWrapper>{children}</ProjectAuthWrapper>
);
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => {
// router
const { workspaceSlug, projectId } = useParams();
return (
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
{children}
</ProjectAuthWrapper>
);
};
export default ProjectDetailLayout;
@@ -107,7 +107,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("w-full overflow-y-auto", {
className={cn("w-full h-full overflow-y-auto", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
+51 -54
View File
@@ -5,16 +5,10 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import {
SidebarDropdown,
SidebarHelpSection,
SidebarProjectsList,
SidebarQuickActions,
SidebarUserMenu,
SidebarWorkspaceMenu,
} from "@/components/workspace";
// helpers
import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
@@ -23,6 +17,8 @@ import useSize from "@/hooks/use-window-size";
// plane web components
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
import { ExtendedAppSidebar } from "./extended-sidebar";
export const AppSidebar: FC = observer(() => {
// store hooks
@@ -55,62 +51,63 @@ export const AppSidebar: FC = observer(() => {
const isFavoriteEmpty = isEmpty(groupedFavorites);
return (
<div
className={cn(
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
{
"w-[70px] -ml-[250px]": sidebarCollapsed,
}
)}
>
<>
<div
ref={ref}
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
"p-2 pt-4": sidebarCollapsed,
})}
className={cn(
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
{
"w-[70px] -ml-[250px]": sidebarCollapsed,
}
)}
>
<div
className={cn("px-2", {
"px-4": !sidebarCollapsed,
ref={ref}
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
"p-2 pt-4": sidebarCollapsed,
})}
>
{/* Workspace switcher and settings */}
<SidebarDropdown />
<div className="flex-shrink-0 h-4" />
{/* App switcher */}
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
{/* Quick actions */}
<SidebarQuickActions />
</div>
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
<div
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
"vertical-scrollbar px-4": !sidebarCollapsed,
})}
>
{/* User Menu */}
<SidebarUserMenu />
{/* Workspace Menu */}
<SidebarWorkspaceMenu />
<div
className={cn("px-2", {
"px-4": !sidebarCollapsed,
})}
>
{/* Workspace switcher and settings */}
<SidebarDropdown />
<div className="flex-shrink-0 h-4" />
{/* App switcher */}
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
{/* Quick actions */}
<SidebarQuickActions />
</div>
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
<div
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
"vertical-scrollbar px-4": !sidebarCollapsed,
})}
>
<SidebarMenuItems />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<SidebarHelpSection />
</div>
{/* Help Section */}
<SidebarHelpSection />
</div>
</div>
<ExtendedAppSidebar />
<ExtendedProjectSidebar />
</>
);
});
+6 -15
View File
@@ -17,6 +17,7 @@ import type { IWorkspaceMemberInvitation } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { EmptyState } from "@/components/common";
import { WorkspaceLogo } from "@/components/workspace/logo";
import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys";
// helpers
import { truncateText } from "@/helpers/string.helper";
@@ -167,21 +168,11 @@ const UserInvitationsPage = observer(() => {
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
>
<div className="flex-shrink-0">
<div className="grid h-9 w-9 place-items-center rounded">
{invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? (
<img
src={invitation.workspace.logo}
height="100%"
width="100%"
className="rounded"
alt={invitation.workspace.name}
/>
) : (
<span className="grid h-9 w-9 place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-white">
{invitation.workspace.name[0]}
</span>
)}
</div>
<WorkspaceLogo
logo={invitation.workspace.logo_url}
name={invitation.workspace.name}
classNames="size-9 flex-shrink-0"
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div>
@@ -5,27 +5,22 @@ import useSWR from "swr";
import { BulkDeleteIssuesModal } from "@/components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// hooks
import { useCommandPalette, useUser } from "@/hooks/store";
import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
// services
import { IssueService } from "@/services/issue";
// services
const issueService = new IssueService();
export const IssueLevelModals = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
const { workspaceSlug, projectId: paramsProjectId, workItem, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issues: { removeIssue },
} = useIssuesStore();
const { fetchIssueWithIdentifier } = useIssueDetail();
const {
isCreateIssueModalOpen,
toggleCreateIssueModal,
@@ -37,13 +32,19 @@ export const IssueLevelModals = observer(() => {
// derived values
const isDraftIssue = pathname?.includes("draft-issues") || false;
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
const issueId = issueDetails?.id;
const projectId = paramsProjectId ?? issueDetails?.project_id;
return (
<>
<CreateUpdateIssueModal
+1
View File
@@ -0,0 +1 @@
export * from "./subscription";
@@ -0,0 +1 @@
export * from "./subscription-pill";
@@ -2,3 +2,4 @@ export * from "./issue-identifier";
export * from "./issue-properties-activity";
export * from "./issue-type-switcher";
export * from "./issue-type-activity";
export * from "./parent-select-root";
@@ -0,0 +1,82 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { IssueParentSelect, TIssueOperations } from "@/components/issues";
// hooks
import { useIssueDetail } from "@/hooks/store";
type TIssueParentSelect = {
className?: string;
disabled?: boolean;
issueId: string;
issueOperations: TIssueOperations;
projectId: string;
workspaceSlug: string;
};
export const IssueParentSelectRoot: React.FC<TIssueParentSelect> = observer((props) => {
const { issueId, issueOperations, projectId, workspaceSlug } = props;
const { t } = useTranslation();
// store hooks
const {
issue: { getIssueById },
} = useIssueDetail();
const {
toggleParentIssueModal,
removeSubIssue,
subIssues: { setSubIssueHelpers, fetchSubIssues },
} = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined;
const handleParentIssue = async (_issueId: string | null = null) => {
try {
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId });
await issueOperations.fetch(workspaceSlug, projectId, issueId, false);
if (_issueId) await fetchSubIssues(workspaceSlug, projectId, _issueId);
toggleParentIssueModal(null);
} catch (error) {
console.error("something went wrong while fetching the issue");
}
};
const handleRemoveSubIssue = async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("common.something_went_wrong"),
});
}
};
const workItemLink = `/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue?.id}`;
if (!issue) return <></>;
return (
<IssueParentSelect
{...props}
handleParentIssue={handleParentIssue}
handleRemoveSubIssue={handleRemoveSubIssue}
workItemLink={workItemLink}
/>
);
});
+1
View File
@@ -2,4 +2,5 @@ export * from "./edition-badge";
export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./sidebar";
export * from "./members";
@@ -0,0 +1,26 @@
import { observer } from "mobx-react";
import { Search } from "lucide-react";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useCommandPalette } from "@/hooks/store";
export const AppSearch = observer(() => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { toggleCommandPaletteModal } = useCommandPalette();
return (
<button
className={cn(
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none",
{
"border-[0.5px] border-custom-sidebar-border-300": !sidebarCollapsed,
}
)}
onClick={() => toggleCommandPaletteModal(true)}
>
<Search className="size-4 text-custom-sidebar-text-300" />
</button>
);
});
@@ -0,0 +1,220 @@
import { FC, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { Eye, EyeClosed } from "lucide-react";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { DragHandle, DropIndicator, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web imports
// local imports
import { UpgradeBadge } from "../upgrade-badge";
import { getSidebarNavigationItemIcon } from "./helper";
type TExtendedSidebarItemProps = {
item: IWorkspaceSidebarNavigationItem;
handleOnNavigationItemDrop?: (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => void;
disableDrag?: boolean;
disableDrop?: boolean;
isLastChild: boolean;
};
export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((props) => {
const { item, handleOnNavigationItemDrop, disableDrag = false, disableDrop = false, isLastChild } = props;
const { t } = useTranslation();
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const navigationIemRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { getNavigationPreferences, updateSidebarPreference } = useWorkspace();
const { toggleExtendedSidebar } = useAppTheme();
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
// derived values
const sidebarPreference = getNavigationPreferences(workspaceSlug.toString());
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
const handleLinkClick = () => toggleExtendedSidebar();
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
const itemHref =
item.key === "your_work"
? `/${workspaceSlug.toString()}${item.href}${data?.id}`
: `/${workspaceSlug.toString()}${item.href}`;
const isActive = itemHref === pathname;
const pinNavigationItem = (workspaceSlug: string, key: string) => {
updateSidebarPreference(workspaceSlug, key, { is_pinned: true });
};
const unPinNavigationItem = (workspaceSlug: string, key: string) => {
updateSidebarPreference(workspaceSlug, key, { is_pinned: false });
};
const icon = getSidebarNavigationItemIcon(item.key);
useEffect(() => {
const element = navigationIemRef.current;
const dragHandleElement = dragHandleRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => !disableDrag,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: item.key, dragInstanceId: "NAVIGATION" }), // var1
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
!disableDrop && source?.data?.id !== item.key && source?.data?.dragInstanceId === "NAVIGATION",
getData: ({ input, element }) => {
const data = { id: item.key };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const extractedInstruction = extractInstruction(self?.data)?.type;
const currentInstruction = extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined;
if (!currentInstruction) return;
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (handleOnNavigationItemDrop)
handleOnNavigationItemDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
},
})
);
}, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]);
return (
<div
id={`sidebar-${item.key}`}
className={cn("relative", { "bg-custom-sidebar-background-80 opacity-60": isDragging })}
ref={navigationIemRef}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
<div
className={cn(
"group/project-item relative w-full flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90"
)}
id={`${item.key}`}
>
{!disableDrag && (
<Tooltip
// isMobile={isMobile}
tooltipContent={t("drag_to_rearrange")}
position="top-right"
disabled={isDragging}
>
<button
type="button"
className={cn(
"hidden group-hover/project-item:flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
// "cursor-not-allowed opacity-60": project.sort_order === null,
"cursor-grabbing": isDragging,
// "!hidden": isSidebarCollapsed,
}
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
)}
<SidebarNavItem isActive={isActive}>
<Link href={itemHref} onClick={() => handleLinkClick()} className="group flex-grow">
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
</Link>
<div className="flex items-center gap-2">
{item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
{isPinned ? (
<Tooltip tooltipContent="Hide tab">
<Eye
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-300 outline-none"
onClick={() => unPinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
) : (
<Tooltip tooltipContent="Show tab">
<EyeClosed
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-400 outline-none"
onClick={() => pinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
)}
</div>
</SidebarNavItem>
</div>
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
);
});
@@ -0,0 +1,26 @@
import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react";
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/ui";
import { cn } from "@plane/utils";
export const getSidebarNavigationItemIcon = (key: string, className: string = "") => {
switch (key) {
case "home":
return <Home className={cn("size-4 flex-shrink-0", className)} />;
case "notifications":
return <Inbox className={cn("size-4 flex-shrink-0", className)} />;
case "projects":
return <Briefcase className={cn("size-4 flex-shrink-0", className)} />;
case "views":
return <Layers className={cn("size-4 flex-shrink-0", className)} />;
case "active_cycles":
return <ContrastIcon className={cn("size-4 flex-shrink-0", className)} />;
case "analytics":
return <BarChart2 className={cn("size-4 flex-shrink-0", className)} />;
case "your_work":
return <UserActivityIcon className={cn("size-4 flex-shrink-0", className)} />;
case "drafts":
return <PenSquare className={cn("size-4 flex-shrink-0", className)} />;
case "archives":
return <ArchiveIcon className={cn("size-4 flex-shrink-0", className)} />;
}
};
@@ -0,0 +1,4 @@
export * from "./app-search";
export * from "./extended-sidebar-item";
export * from "./helper";
export * from "./sidebar-item";
@@ -0,0 +1,91 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web imports
import { UpgradeBadge } from "@/plane-web/components/workspace";
// local imports
import { getSidebarNavigationItemIcon } from "./helper";
type TSidebarItemProps = {
item: IWorkspaceSidebarNavigationItem;
};
export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
const { item } = props;
const { t } = useTranslation();
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
const { getNavigationPreferences } = useWorkspace();
const { data } = useUser();
// store hooks
const { toggleSidebar, sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const handleLinkClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
if (extendedSidebarCollapsed) toggleExtendedSidebar();
};
const staticItems = ["home", "notifications", "pi-chat", "projects"];
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
const itemHref =
item.key === "your_work"
? `/${workspaceSlug.toString()}${item.href}/${data?.id}`
: `/${workspaceSlug.toString()}${item.href}`;
const isActive = itemHref === pathname;
const sidebarPreference = getNavigationPreferences(workspaceSlug.toString());
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
if (!isPinned && !staticItems.includes(item.key)) return null;
const icon = getSidebarNavigationItemIcon(item.key);
return (
<Tooltip
tooltipContent={t(item.labelTranslationKey)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={itemHref} onClick={() => handleLinkClick()}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}
>
{/* <icon className="size-4" /> */}
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
</div>
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
</SidebarNavItem>
</Link>
</Tooltip>
);
});
+8 -2
View File
@@ -4,12 +4,18 @@ import { observer } from "mobx-react";
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout";
export type IProjectAuthWrapper = {
workspaceSlug: string;
projectId: string;
children: React.ReactNode;
};
export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// props
const { children } = props;
const { workspaceSlug, projectId, children } = props;
return <CoreProjectAuthWrapper>{children}</CoreProjectAuthWrapper>;
return (
<CoreProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
{children}
</CoreProjectAuthWrapper>
);
});
@@ -79,7 +79,7 @@ export const AuthHeader: FC<TAuthHeader> = observer((props) => {
header: (
<div className="relative inline-flex items-center gap-2">
{t("common.join")}{" "}
<WorkspaceLogo logo={workspace?.logo} name={workspace?.name} classNames="w-8 h-9 flex-shrink-0" />{" "}
<WorkspaceLogo logo={workspace?.logo_url} name={workspace?.name} classNames="size-9 flex-shrink-0" />{" "}
{workspace.name}
</div>
),
@@ -4,14 +4,13 @@ import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react";
import { EIssuesStoreType } from "@plane/constants";
import { TIssue } from "@plane/types";
// hooks
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useCommandPalette, useIssues, useUser } from "@/hooks/store";
import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store";
type Props = {
closePalette: () => void;
@@ -25,13 +24,14 @@ type Props = {
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
// router
const { workspaceSlug, projectId, issueId } = useParams();
const { workspaceSlug } = useParams();
// hooks
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const { updateIssue } = useIssueDetail();
const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette();
const { data: currentUser } = useUser();
// derived values
const issueId = issueDetails?.id;
const projectId = issueDetails?.project_id;
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
@@ -65,16 +65,10 @@ export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Copied to clipboard",
});
setToast({ type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard" });
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
setToast({ type: TOAST_TYPE.ERROR, title: "Some error occurred" });
});
};
@@ -4,8 +4,6 @@ import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Check } from "lucide-react";
// plane constants
import { EIssuesStoreType } from "@plane/constants";
// plane types
import { TIssue } from "@plane/types";
// plane ui
@@ -13,24 +11,22 @@ import { Avatar } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssues, useMember } from "@/hooks/store";
import { useIssueDetail, useMember } from "@/hooks/store";
type Props = {
closePalette: () => void;
issue: TIssue;
};
type Props = { closePalette: () => void; issue: TIssue };
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug } = useParams();
// store
const { updateIssue } = useIssueDetail();
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const {
project: { projectMemberIds, getProjectMemberDetails },
project: { getProjectMemberIds, getProjectMemberDetails },
} = useMember();
// derived values
const projectId = issue?.project_id ?? "";
const projectMemberIds = getProjectMemberIds(projectId);
const options =
projectMemberIds
@@ -5,28 +5,26 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Check } from "lucide-react";
// plane constants
import { EIssuesStoreType, ISSUE_PRIORITIES } from "@plane/constants";
import { ISSUE_PRIORITIES } from "@plane/constants";
// plane types
import { TIssue, TIssuePriorities } from "@plane/types";
// mobx store
import { PriorityIcon } from "@plane/ui";
import { useIssues } from "@/hooks/store";
import { useIssueDetail } from "@/hooks/store";
// ui
// types
// constants
type Props = {
closePalette: () => void;
issue: TIssue;
};
type Props = { closePalette: () => void; issue: TIssue };
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const { workspaceSlug } = useParams();
// store hooks
const { updateIssue } = useIssueDetail();
// derived values
const projectId = issue?.project_id;
const submitChanges = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
@@ -5,28 +5,25 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import { Check } from "lucide-react";
import { EIssuesStoreType } from "@plane/constants";
import { TIssue } from "@plane/types";
import { Spinner, StateGroupIcon } from "@plane/ui";
import { useProjectState, useIssues } from "@/hooks/store";
import { useProjectState, useIssueDetail } from "@/hooks/store";
// ui
// icons
// types
type Props = {
closePalette: () => void;
issue: TIssue;
};
type Props = { closePalette: () => void; issue: TIssue };
export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router params
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug } = useParams();
// store hooks
const {
issues: { updateIssue },
} = useIssues(EIssuesStoreType.PROJECT);
const { projectStates } = useProjectState();
const { updateIssue } = useIssueDetail();
const { getProjectStates } = useProjectState();
// derived values
const projectId = issue?.project_id;
const projectStates = getProjectStates(projectId);
const submitChanges = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
@@ -5,13 +5,14 @@ import { Command } from "cmdk";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { CommandIcon, FolderPlus, Search, Settings } from "lucide-react";
import { CommandIcon, FolderPlus, Search, Settings, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspaceSearchResults } from "@plane/types";
import { LayersIcon, Loader, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import {
ChangeIssueAssignee,
@@ -25,12 +26,17 @@ import {
CommandPaletteWorkspaceSettingsActions,
} from "@/components/command-palette";
import { SimpleEmptyState } from "@/components/empty-state";
// fetch-keys
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
// helpers
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
import {
useCommandPalette,
useEventTracker,
useIssueDetail,
useProject,
useUser,
useUserPermissions,
} from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import useDebounce from "@/hooks/use-debounce";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -39,16 +45,13 @@ import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IssueIdentifier } from "@/plane-web/components/issues";
// plane web services
import { WorkspaceService } from "@/plane-web/services";
// services
import { IssueService } from "@/services/issue";
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, issueId } = useParams();
const { workspaceSlug, workItem } = useParams();
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@@ -56,21 +59,15 @@ export const CommandModal: React.FC = observer(() => {
const [isSearching, setIsSearching] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<IWorkspaceSearchResults>({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
results: { workspace: [], project: [], issue: [], cycle: [], module: [], issue_view: [], page: [] },
});
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(true);
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const [searchInIssue, setSearchInIssue] = useState(false);
// plane hooks
const { t } = useTranslation();
// hooks
const { fetchIssueWithIdentifier } = useIssueDetail();
const { workspaceProjectIds } = useProject();
const { platform, isMobile } = usePlatformOS();
const { canPerformAnyCreateAction } = useUser();
@@ -78,7 +75,20 @@ export const CommandModal: React.FC = observer(() => {
useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { setTrackElement } = useEventTracker();
const projectIdentifier = workItem?.toString().split("-")[0];
const sequence_id = workItem?.toString().split("-")[1];
const { data: issueDetails } = useSWR(
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
workspaceSlug && workItem
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
: null
);
// derived values
const issueId = issueDetails?.id;
const projectId = issueDetails?.project_id;
const page = pages[pages.length - 1];
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { baseTabIndex } = getTabIndex(undefined, isMobile);
@@ -88,13 +98,19 @@ export const CommandModal: React.FC = observer(() => {
);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
useEffect(() => {
if (issueDetails && isCommandPaletteOpen) {
setSearchInIssue(true);
}
}, [issueDetails, isCommandPaletteOpen]);
useEffect(() => {
if (!projectId && !isWorkspaceLevel) {
setIsWorkspaceLevel(true);
} else {
setIsWorkspaceLevel(false);
}
}, [projectId]);
const closePalette = () => {
toggleCommandPaletteModal(false);
@@ -133,15 +149,7 @@ export const CommandModal: React.FC = observer(() => {
});
} else {
setResults({
results: {
workspace: [],
project: [],
issue: [],
cycle: [],
module: [],
issue_view: [],
page: [],
},
results: { workspace: [], project: [], issue: [], cycle: [], module: [], issue_view: [], page: [] },
});
setIsLoading(false);
setIsSearching(false);
@@ -152,7 +160,16 @@ export const CommandModal: React.FC = observer(() => {
return (
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
<Dialog
as="div"
className="relative z-30"
onClose={() => {
closePalette();
if (searchInIssue) {
setSearchInIssue(true);
}
}}
>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@@ -213,10 +230,7 @@ export const CommandModal: React.FC = observer(() => {
nextItem.setAttribute("aria-selected", "true");
selectedItem?.setAttribute("aria-selected", "false");
nextItem.focus();
nextItem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
nextItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
@@ -237,32 +251,40 @@ export const CommandModal: React.FC = observer(() => {
}
}}
>
<div
className={`flex gap-4 pb-0 sm:items-center ${
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
}`}
>
{issueDetails && (
<div className="flex gap-2 items-center overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-xs font-medium text-custom-text-200"
/>
)}
{issueDetails.name}
</div>
)}
</div>
<div className="relative">
<Search
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
aria-hidden="true"
strokeWidth={2}
/>
<div className="relative flex items-center px-4 border-0 border-b border-custom-border-200">
<div className="flex items-center gap-2 flex-shrink-0">
<Search
className="h-4 w-4 text-custom-text-200 flex-shrink-0"
aria-hidden="true"
strokeWidth={2}
/>
{searchInIssue && issueDetails && (
<>
<span className="flex items-center text-sm">Update in:</span>
<span className="flex items-center gap-1 rounded px-1.5 py-1 text-sm bg-custom-primary-100/10 ">
{issueDetails.project_id && (
<IssueIdentifier
issueId={issueDetails.id}
projectId={issueDetails.project_id}
textContainerClassName="text-sm text-custom-primary-200"
/>
)}
<X
size={12}
strokeWidth={2}
className="flex-shrink-0 cursor-pointer"
onClick={() => {
setSearchInIssue(false);
}}
/>
</span>
</>
)}
</div>
<Command.Input
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
className={cn(
"w-full bg-transparent p-4 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
)}
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
@@ -308,7 +330,7 @@ export const CommandModal: React.FC = observer(() => {
{!page && (
<>
{/* issue actions */}
{issueId && (
{issueId && issueDetails && searchInIssue && (
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
@@ -30,7 +30,7 @@ import {
export const CommandPalette: FC = observer(() => {
// router params
const { workspaceSlug, projectId, issueId } = useParams();
const { workspaceSlug, projectId, workItem } = useParams();
// store hooks
const { toggleSidebar } = useAppTheme();
const { setTrackElement } = useEventTracker();
@@ -51,7 +51,7 @@ export const CommandPalette: FC = observer(() => {
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const copyIssueUrlToClipboard = useCallback(() => {
if (!issueId) return;
if (!workItem) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
@@ -67,7 +67,7 @@ export const CommandPalette: FC = observer(() => {
title: "Some error occurred",
});
});
}, [issueId]);
}, [workItem]);
// auth
const performProjectCreateActions = useCallback(
@@ -11,6 +11,8 @@ import {
} from "@plane/types";
// ui
import { ContrastIcon, DiceIcon } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -48,7 +50,13 @@ export const commandGroups: {
</div>
),
path: (issue: IWorkspaceIssueSearchResult) =>
`/${issue?.workspace__slug}/projects/${issue?.project_id}/issues/${issue?.id}`,
generateWorkItemLink({
workspaceSlug: issue?.workspace__slug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue?.sequence_id,
}),
title: "Work items",
},
issue_view: {
+10 -3
View File
@@ -24,6 +24,7 @@ import { IIssueActivity } from "@plane/types";
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, Intake } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { capitalizeFirstLetter } from "@/helpers/string.helper";
import { useLabel } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -34,6 +35,14 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
const { workspaceSlug } = useParams();
const { isMobile } = usePlatformOS();
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString() ?? activity.workspace_detail?.slug,
projectId: activity?.project,
issueId: activity?.issue,
projectIdentifier: activity?.project_detail?.identifier,
sequenceId: activity?.issue_detail?.sequence_id,
});
return (
<Tooltip
tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
@@ -42,9 +51,7 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
{activity?.issue_detail ? (
<a
aria-disabled={activity.issue === null}
href={`${`/${workspaceSlug ?? activity.workspace_detail?.slug}/projects/${activity.project}/issues/${
activity.issue
}`}`}
href={workItemLink}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline items-center gap-1 font-medium text-custom-text-100 hover:underline"
@@ -10,6 +10,7 @@ import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
// ui
import { Button, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { getTabIndex } from "@/helpers/tab-indices.helper";
// hooks
import useDebounce from "@/hooks/use-debounce";
@@ -274,7 +275,13 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => {
<span className="truncate">{issue.name}</span>
</div>
<a
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: issue.project__identifier,
sequenceId: issue?.sequence_id,
})}
target="_blank"
className="z-1 relative hidden flex-shrink-0 text-custom-text-200 hover:text-custom-text-100 group-hover:block"
rel="noopener noreferrer"
@@ -9,6 +9,7 @@ import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
// helpers
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
// plane web components
@@ -41,9 +42,17 @@ export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = obser
const targetDate = getDate(issueDetails.target_date);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -101,9 +110,17 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
const dueBy = findTotalDaysInRange(getDate(issueDetails.target_date), new Date(), false) ?? 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -154,9 +171,17 @@ export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = obse
const projectDetails = getProjectById(issueDetails.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetails.project_id}/issues/${issueDetails.id}`}
href={workItemLink}
onClick={() => onClick(issueDetails)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -193,9 +218,17 @@ export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observ
const projectDetails = getProjectById(issue.project_id);
const targetDate = getDate(issue.target_date);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -253,9 +286,17 @@ export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observe
const dueBy: number = findTotalDaysInRange(getDate(issue.target_date), new Date(), false) ?? 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -311,9 +352,17 @@ export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = obser
const projectDetails = getProjectById(issue.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={workItemLink}
onClick={() => onClick(issue)}
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
>
@@ -21,7 +21,7 @@ export const ProductUpdatesModal: FC<ProductUpdatesModalProps> = observer((props
const { config } = useInstance();
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXXXL}>
<ProductUpdatesHeader />
<div className="flex flex-col h-[60vh] vertical-scrollbar scrollbar-xs overflow-hidden overflow-y-scroll px-6 mx-0.5">
{config?.instance_changelog_url && config?.instance_changelog_url !== "" ? (
@@ -12,7 +12,14 @@ import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
import {
useCommandPalette,
useEventTracker,
useProject,
useUser,
useUserPermissions,
useWorkspace,
} from "@/hooks/store";
// plane web constants
export const NoProjectsEmptyState = observer(() => {
@@ -24,6 +31,7 @@ export const NoProjectsEmptyState = observer(() => {
const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser();
const { joinedProjectIds } = useProject();
const { currentWorkspace: activeWorkspace } = useWorkspace();
// local storage
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
hide: false,
@@ -37,6 +45,7 @@ export const NoProjectsEmptyState = observer(() => {
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const EMPTY_STATE_DATA = [
{
@@ -54,6 +63,7 @@ export const NoProjectsEmptyState = observer(() => {
setTrackElement("Sidebar");
toggleCreateProjectModal(true);
},
disabled: !canCreateProject,
},
},
{
@@ -65,6 +75,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.invite_team.cta",
link: `/${workspaceSlug}/settings/members`,
disabled: !isWorkspaceAdmin,
},
},
{
@@ -76,6 +87,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.configure_workspace.cta",
link: "settings",
disabled: !isWorkspaceAdmin,
},
},
{
@@ -104,6 +116,7 @@ export const NoProjectsEmptyState = observer(() => {
cta: {
text: "home.empty.personalize_account.cta",
link: "/profile",
disabled: false,
},
},
];
@@ -112,7 +125,7 @@ export const NoProjectsEmptyState = observer(() => {
case "projects":
return joinedProjectIds?.length > 0;
case "visited_members":
return storedValue?.visited_members;
return (activeWorkspace?.total_members || 0) >= 2;
case "visited_workspace":
return storedValue?.visited_workspace;
case "visited_profile":
@@ -120,7 +133,7 @@ export const NoProjectsEmptyState = observer(() => {
}
};
if (storedValue?.hide) return null;
if (storedValue?.hide || (joinedProjectIds?.length > 0 && (activeWorkspace?.total_members || 0) >= 2)) return null;
return (
<div>
@@ -161,28 +174,35 @@ export const NoProjectsEmptyState = observer(() => {
<div className="flex items-center gap-2 bg-[#17a34a] rounded-full p-1">
<Check className="size-3 text-custom-primary-100 text-white" />
</div>
) : item.cta.link ? (
<Link
href={item.cta.link}
onClick={() => {
if (!storedValue) return;
setValue({
...storedValue,
[item.flag]: true,
});
}}
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
>
{t(item.cta.text)}
</Link>
) : (
<button
type="button"
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
onClick={item.cta.onClick}
>
{t(item.cta.text)}
</button>
!item.cta.disabled &&
(item.cta.link ? (
<Link
href={item.cta.link}
onClick={(e) => {
if (!storedValue) {
e.stopPropagation();
e.preventDefault();
return;
}
setValue({
...storedValue,
[item.flag]: true,
});
}}
className={cn("text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium", {})}
>
{t(item.cta.text)}
</Link>
) : (
<button
type="button"
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
onClick={item.cta.onClick}
>
{t(item.cta.text)}
</button>
))
)}
</div>
);
@@ -42,9 +42,9 @@ export const useLinks = (workspaceSlug: string) => {
});
toggleLinkModal(false);
} catch (error: any) {
console.error("error", error?.data?.url?.error);
console.error("error", error?.data?.error);
setToast({
message: error?.data?.url?.error ?? t("links.toasts.not_created.message"),
message: error?.data?.error ?? t("links.toasts.not_created.message"),
type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_created.title"),
});
@@ -7,8 +7,9 @@ import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProjectState } from "@/hooks/store";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -22,13 +23,22 @@ export const RecentIssue = (props: BlockProps) => {
// hooks
const { getStateById } = useProjectState();
const { setPeekIssue } = useIssueDetail();
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
if (!issueDetails) return <></>;
const state = getStateById(issueDetails?.state);
const workItemLink = `/${workspaceSlug}/projects/${issueDetails?.project_id}/issues/${issueDetails.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issueDetails?.project_id,
issueId: issueDetails?.id,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
});
return (
<ListItem
@@ -32,6 +32,7 @@ import { CreateUpdateIssueModal, NameDescriptionUpdateStatus } from "@/component
// helpers
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { EInboxIssueStatus } from "@/helpers/inbox.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useUser, useProjectInbox, useProject, useUserPermissions } from "@/hooks/store";
@@ -104,7 +105,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const currentInboxIssueId = inboxIssue?.issue?.id;
const issueLink = `${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`;
const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/inbox/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`;
const redirectIssue = (): string | undefined => {
@@ -229,6 +229,14 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
if (!inboxIssue) return null;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: currentInboxIssueId,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: issue?.sequence_id,
});
return (
<>
<>
@@ -358,17 +366,11 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
variant="neutral-primary"
prependIcon={<Link className="h-2.5 w-2.5" />}
size="sm"
onClick={() => handleCopyIssueLink(issueLink)}
onClick={() => handleCopyIssueLink(workItemLink)}
>
{t("inbox_issue.actions.copy")}
</Button>
<ControlLink
href={`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`}
onClick={() =>
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
}
target="_self"
>
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
<Button variant="neutral-primary" prependIcon={<ExternalLink className="h-2.5 w-2.5" />} size="sm">
{t("inbox_issue.actions.open")}
</Button>
@@ -438,7 +440,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<InboxIssueActionsMobileHeader
inboxIssue={inboxIssue}
isSubmitting={isSubmitting}
handleCopyIssueLink={() => handleCopyIssueLink(issueLink)}
handleCopyIssueLink={() => handleCopyIssueLink(workItemLink)}
setAcceptIssueModal={setAcceptIssueModal}
setDeclineIssueModal={setDeclineIssueModal}
handleIssueSnoozeAction={handleIssueSnoozeAction}
@@ -23,7 +23,9 @@ import { NameDescriptionUpdateStatus } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// store types
import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store";
@@ -77,6 +79,8 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
handleActionWithPermission,
} = props;
const router = useAppRouter();
const { getProjectIdentifierById } = useProject();
const issue = inboxIssue?.issue;
const currentInboxIssueId = issue?.id;
// days left for snooze
@@ -84,6 +88,16 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
if (!issue || !inboxIssue) return null;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: currentInboxIssueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
return (
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
{isNotificationEmbed && (
@@ -132,11 +146,7 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
</CustomMenu.MenuItem>
)}
{isAcceptedOrDeclined && (
<CustomMenu.MenuItem
onClick={() =>
router.push(`/${workspaceSlug}/projects/${issue?.project_id}/issues/${currentInboxIssueId}`)
}
>
<CustomMenu.MenuItem onClick={() => router.push(workItemLink)}>
<div className="flex items-center gap-2">
<ExternalLink size={14} strokeWidth={2} />
Open work item
@@ -10,6 +10,7 @@ import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "@
import { IssueLabel, TIssueOperations } from "@/components/issues";
// helper
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useProject } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -34,6 +35,14 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
minDate?.setDate(minDate.getDate());
if (!issue || !issue?.id) return <></>;
const duplicateWorkItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId,
issueId: duplicateIssueDetails?.id,
projectIdentifier: currentProjectDetails?.identifier,
sequenceId: duplicateIssueDetails?.sequence_id,
});
return (
<div className="flex w-full flex-col divide-y-2 divide-custom-border-200">
<div className="w-full overflow-y-auto">
@@ -169,9 +178,9 @@ export const InboxIssueContentProperties: React.FC<Props> = observer((props) =>
</div>
<ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${duplicateIssueDetails?.id}`}
href={duplicateWorkItemLink}
onClick={() => {
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${duplicateIssueDetails?.id}`);
router.push(duplicateWorkItemLink);
}}
target="_self"
>
@@ -2,9 +2,10 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { useIssueDetail, useProject } from "@/hooks/store";
type TCreateIssueToastActionItems = {
workspaceSlug: string;
@@ -21,17 +22,26 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
const {
issue: { getIssueById },
} = useIssueDetail();
const { getProjectIdentifierById } = useProject();
// derived values
const issue = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
if (!issue) return null;
const issueLink = `${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}`;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
});
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
await copyUrlToClipboard(issueLink);
await copyUrlToClipboard(workItemLink, false);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (error) {
@@ -44,7 +54,7 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
return (
<div className="flex items-center gap-1 text-xs text-custom-text-200">
<a
href={`/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}/`}
href={workItemLink}
target="_blank"
rel="noopener noreferrer"
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
// types
import { PROJECT_ERROR_MESSAGES ,EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { PROJECT_ERROR_MESSAGES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TDeDupeIssue, TIssue } from "@plane/types";
// ui
@@ -31,7 +31,7 @@ export const useRelationOperations = (
() => ({
copyText: (text: string) => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${text}`).then(() => {
copyTextToClipboard(`${originURL}${text}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -3,6 +3,7 @@
import { FC } from "react";
// hooks
import { Tooltip } from "@plane/ui";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { useIssueDetail } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
@@ -21,6 +22,14 @@ export const IssueLink: FC<TIssueLink> = (props) => {
const activity = getActivityById(activityId);
if (!activity) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug: activity.workspace_detail?.slug,
projectId: activity.project,
issueId: activity.issue,
projectIdentifier: activity.project_detail.identifier,
sequenceId: activity.issue_detail.sequence_id,
});
return (
<Tooltip
tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This work item has been deleted"}
@@ -28,11 +37,7 @@ export const IssueLink: FC<TIssueLink> = (props) => {
>
<a
aria-disabled={activity.issue === null}
href={`${
activity.issue_detail
? `/${activity.workspace_detail?.slug}/projects/${activity.project}/issues/${activity.issue}`
: "#"
}`}
href={`${activity.issue_detail ? workItemLink : "#"}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
@@ -14,9 +14,11 @@ export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
const {
activity: { getActivityById },
} = useIssueDetail();
const { projectLabels } = useLabel();
const { getLabelById } = useLabel();
const activity = getActivityById(activityId);
const oldLabelColor = getLabelById(activity?.old_identifier ?? "")?.color;
const newLabelColor = getLabelById(activity?.new_identifier ?? "")?.color;
if (!activity) return <></>;
return (
@@ -29,11 +31,7 @@ export const IssueLabelActivity: FC<TIssueLabelActivity> = observer((props) => {
{activity.old_value === "" ? `added a new label ` : `removed the label `}
<LabelActivityChip
name={activity.old_value === "" ? activity.new_value : activity.old_value}
color={
activity.old_value === ""
? projectLabels?.find((l) => l.id === activity.new_identifier)?.color
: projectLabels?.find((l) => l.id === activity.old_identifier)?.color
}
color={activity.old_value === "" ? newLabelColor : oldLabelColor}
/>
{showIssue && (activity.old_value === "" ? ` to ` : ` from `)}
{showIssue && <IssueLink activityId={activityId} />}
@@ -18,12 +18,14 @@ import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { ArchiveIssueModal, DeleteIssueModal, IssueSubscription } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import {
useEventTracker,
useIssueDetail,
useIssues,
useProject,
useProjectState,
useUser,
useUserPermissions,
@@ -53,6 +55,7 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const { allowPermissions } = useUserPermissions();
const { isMobile } = usePlatformOS();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
const {
issue: { getIssueById },
removeIssue,
@@ -72,11 +75,20 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
if (!issue) return <></>;
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(projectId);
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug,
projectId,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
// handlers
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`).then(() => {
copyTextToClipboard(`${originURL}${workItemLink}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
@@ -145,7 +157,7 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
title: t("issue.restore.success.title"),
message: t("issue.restore.success.message"),
});
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`);
router.push(workItemLink);
})
.catch(() => {
setToast({
@@ -158,10 +170,17 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
};
// auth
const isEditable = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const canRestoreIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const isArchivingAllowed = !issue?.archived_at && isEditable;
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
@@ -6,7 +6,7 @@ import Link from "next/link";
import { Pencil, X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { Tooltip } from "@plane/ui";
// components
import { ParentIssuesListModal } from "@/components/issues";
// helpers
@@ -17,31 +17,41 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
// types
import { TIssueOperations } from "./root";
type TIssueParentSelect = {
className?: string;
disabled?: boolean;
issueId: string;
issueOperations: TIssueOperations;
projectId: string;
workspaceSlug: string;
handleParentIssue: (_issueId?: string | null) => Promise<void>;
handleRemoveSubIssue: (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string
) => Promise<void>;
workItemLink: string;
};
export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) => {
const { className = "", disabled = false, issueId, issueOperations, projectId, workspaceSlug } = props;
const {
className = "",
disabled = false,
issueId,
projectId,
workspaceSlug,
handleParentIssue,
handleRemoveSubIssue,
workItemLink,
} = props;
const { t } = useTranslation();
// store hooks
const { getProjectById } = useProject();
const {
issue: { getIssueById },
} = useIssueDetail();
const {
isParentIssueModalOpen,
toggleParentIssueModal,
removeSubIssue,
subIssues: { setSubIssueHelpers, fetchSubIssues },
} = useIssueDetail();
const { isParentIssueModalOpen, toggleParentIssueModal } = useIssueDetail();
// derived values
const issue = getIssueById(issueId);
@@ -49,36 +59,6 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
const parentIssueProjectDetails =
parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined;
const { isMobile } = usePlatformOS();
const handleParentIssue = async (_issueId: string | null = null) => {
try {
await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId });
await issueOperations.fetch(workspaceSlug, projectId, issueId, false);
_issueId && (await fetchSubIssues(workspaceSlug, projectId, _issueId));
toggleParentIssueModal(null);
} catch (error) {
console.error("something went wrong while fetching the issue");
}
};
const handleRemoveSubIssue = async (
workspaceSlug: string,
projectId: string,
parentIssueId: string,
issueId: string
) => {
try {
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
await fetchSubIssues(workspaceSlug, projectId, parentIssueId);
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("common.something_went_wrong"),
});
}
};
if (!issue) return <></>;
@@ -109,12 +89,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props)
{issue.parent_id && parentIssue ? (
<div className="flex items-center gap-1 bg-green-500/20 rounded px-1.5 py-1">
<Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}>
<Link
href={`/${workspaceSlug}/projects/${parentIssue.project_id}/issues/${parentIssue?.id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<Link href={workItemLink} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
{parentIssue?.project_id && parentIssueProjectDetails && (
<IssueIdentifier
projectId={parentIssue.project_id}
@@ -2,14 +2,17 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { MinusCircle } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// component
// ui
import { ControlLink, CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssues, useProjectState } from "@/hooks/store";
import { useIssues, useProject, useProjectState } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
@@ -28,14 +31,20 @@ export type TIssueParentDetail = {
export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
const { workspaceSlug, projectId, issueId, issue, issueOperations } = props;
// router
const router = useRouter();
const { t } = useTranslation();
// hooks
const { issueMap } = useIssues();
const { getProjectStates } = useProjectState();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
const { getProjectIdentifierById } = useProject();
// derived values
const parentIssue = issueMap?.[issue.parent_id || ""] || undefined;
const isParentEpic = parentIssue?.is_epic;
const projectIdentifier = getProjectIdentifierById(parentIssue?.project_id);
const issueParentState = getProjectStates(parentIssue?.project_id)?.find(
(state) => state?.id === parentIssue?.state_id
@@ -44,13 +53,24 @@ export const IssueParentDetail: FC<TIssueParentDetail> = observer((props) => {
if (!parentIssue) return <></>;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: parentIssue?.project_id,
issueId: parentIssue.id,
projectIdentifier,
sequenceId: parentIssue.sequence_id,
isEpic: isParentEpic,
});
const handleParentIssueClick = () => {
if (isParentEpic) router.push(workItemLink);
else handleRedirection(workspaceSlug, parentIssue, isMobile);
};
return (
<>
<div className="mb-5 flex w-min items-center gap-3 whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-80 px-2.5 py-1 text-xs">
<ControlLink
href={`/${workspaceSlug}/projects/${parentIssue?.project_id}/issues/${parentIssue.id}`}
onClick={() => handleRedirection(workspaceSlug, parentIssue, isMobile)}
>
<ControlLink href={workItemLink} onClick={handleParentIssueClick}>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<span className="block h-2 w-2 rounded-full" style={{ backgroundColor: stateColor }} />
@@ -5,6 +5,8 @@ import { observer } from "mobx-react";
import Link from "next/link";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useProject } from "@/hooks/store";
// plane web components
@@ -23,19 +25,24 @@ export const IssueParentSiblingItem: FC<TIssueParentSiblingItem> = observer((pro
issue: { getIssueById },
} = useIssueDetail();
// derived values
const issueDetail = (issueId && getIssueById(issueId)) || undefined;
if (!issueDetail) return <></>;
const projectDetails = (issueDetail.project_id && getProjectById(issueDetail.project_id)) || undefined;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetail?.project_id,
issueId: issueDetail?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issueDetail?.sequence_id,
});
return (
<>
<CustomMenu.MenuItem key={issueDetail.id}>
<Link
href={`/${workspaceSlug}/projects/${issueDetail?.project_id as string}/issues/${issueDetail.id}`}
target="_blank"
className="flex items-center gap-2 py-0.5"
>
<Link href={workItemLink} target="_blank" className="flex items-center gap-2 py-0.5">
{issueDetail.project_id && projectDetails?.identifier && (
<IssueIdentifier
projectId={issueDetail.project_id}
@@ -11,6 +11,7 @@ import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import { ExistingIssuesListModal } from "@/components/core";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -115,7 +116,13 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro
>
<Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}>
<Link
href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues/${currentIssue.id}`}
href={generateWorkItemLink({
workspaceSlug,
projectId: projectDetails?.id,
issueId: currentIssue.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: currentIssue?.sequence_id,
})}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium"
@@ -4,7 +4,14 @@ import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// types
import { EIssuesStoreType, ISSUE_UPDATED, ISSUE_DELETED, ISSUE_ARCHIVED,EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import {
EIssuesStoreType,
ISSUE_UPDATED,
ISSUE_DELETED,
ISSUE_ARCHIVED,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// ui
@@ -332,7 +339,12 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
// issue details
const issue = getIssueById(issueId);
// checking if issue is editable, based on user role
const isEditable = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
return (
<>
@@ -16,7 +16,7 @@ import {
StateDropdown,
} from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { IssueCycleSelect, IssueLabel, IssueModuleSelect, IssueParentSelect } from "@/components/issues";
import { IssueCycleSelect, IssueLabel, IssueModuleSelect } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
@@ -25,7 +25,7 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
// plane web components
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
import { IssueWorklogProperty } from "@/plane-web/components/issues";
import { IssueParentSelectRoot, IssueWorklogProperty } from "@/plane-web/components/issues";
// components
import type { TIssueOperations } from "./root";
@@ -261,7 +261,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<LayoutPanelTop className="h-4 w-4 flex-shrink-0" />
<span>{t("common.parent")}</span>
</div>
<IssueParentSelect
<IssueParentSelectRoot
className="h-full w-3/5 flex-grow"
workspaceSlug={workspaceSlug}
projectId={projectId}
@@ -13,8 +13,9 @@ import { TIssue } from "@plane/types";
import { Tooltip, ControlLink } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProjectState } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -40,15 +41,17 @@ export const CalendarIssueBlock = observer(
const blockRef = useRef(null);
const menuActionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug } = useParams();
const { getProjectStates } = useProjectState();
const { getIsIssuePeeked } = useIssueDetail();
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as CalendarStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
const stateColor = getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id)?.color || "";
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile);
@@ -72,10 +75,20 @@ export const CalendarIssueBlock = observer(
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug?.toString()}/projects/${projectId?.toString()}/issues/${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="block w-full text-sm text-custom-text-100 rounded border-b md:border-[1px] border-custom-border-200 hover:border-custom-border-400"
disabled={!!issue?.tempId || isMobile}
@@ -8,8 +8,9 @@ import { Tooltip, ControlLink } from "@plane/ui";
import { SIDEBAR_WIDTH } from "@/components/gantt-chart/constants";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProjectState } from "@/hooks/store";
import { useIssueDetail, useIssues, useProject, useProjectState } from "@/hooks/store";
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -90,12 +91,14 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { isMobile } = usePlatformOS();
const storeType = useIssueStoreType() as GanttStoreType;
const { issuesFilter } = useIssues(storeType);
const { getProjectIdentifierById } = useProject();
// handlers
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
// derived values
const issueDetails = getIssueById(issueId);
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
const handleIssuePeekOverview = (e: any) => {
e.stopPropagation(true);
@@ -103,10 +106,19 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
handleRedirection(workspaceSlug, issueDetails, isMobile);
};
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issueDetails?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
isEpic,
});
return (
<ControlLink
id={`issue-${issueId}`}
href={`/${workspaceSlug}/projects/${issueDetails?.project_id}/${isEpic ? "epics" : "issues"}/${issueDetails?.id}`}
href={workItemLink}
onClick={handleIssuePeekOverview}
className="line-clamp-1 w-full cursor-pointer text-sm text-custom-text-100"
disabled={!!issueDetails?.tempId}
@@ -17,8 +17,9 @@ import RenderIfVisible from "@/components/core/render-if-visible-HOC";
import { HIGHLIGHT_CLASS } from "@/components/issues/issue-layouts/utils";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useKanbanView } from "@/hooks/store";
import { useIssueDetail, useKanbanView, useProject } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
@@ -130,6 +131,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const { workspaceSlug: routerWorkspaceSlug } = useParams();
const workspaceSlug = routerWorkspaceSlug?.toString();
// hooks
const { getProjectIdentifierById } = useProject();
const { getIsIssuePeeked } = useIssueDetail(isEpic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
const { handleRedirection } = useIssuePeekOverviewRedirection(isEpic);
const { isMobile } = usePlatformOS();
@@ -147,6 +149,17 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const canEditIssueProperties = canEditProperties(issue?.project_id ?? undefined);
const isDragAllowed = canDragIssuesInCurrentGrouping && !issue?.tempId && canEditIssueProperties;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
useOutsideClickDetector(cardRef, () => {
cardRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
@@ -215,9 +228,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
>
<ControlLink
id={getIssueBlockId(issueId, groupId, subGroupId)}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${
issue.id
}`}
href={workItemLink}
ref={cardRef}
className={cn(
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
@@ -16,6 +16,7 @@ import { MultipleSelectEntityAction } from "@/components/core";
import { IssueProperties } from "@/components/issues/issue-layouts/properties";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
@@ -149,10 +150,19 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
//TODO: add better logic. This is to have a min width for ID/Key based on the length of project identifier
const keyMinWidth = displayProperties?.key ? (projectIdentifier?.length ?? 0) * 7 : 0;
const workItemLink = generateWorkItemLink({
workspaceSlug,
projectId: issue?.project_id,
issueId,
projectIdentifier,
sequenceId: issue?.sequence_id,
isEpic,
isArchived: !!issue?.archived_at,
});
return (
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${issue.id}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
disabled={!!issue?.tempId || issue?.is_draft}
@@ -27,7 +27,7 @@ import {
// helpers
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { generateWorkItemLink, shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import { useEventTracker, useLabel, useIssues, useProjectState, useProject, useProjectEstimates } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -247,17 +247,17 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
});
};
const redirectToIssueDetail = () => {
router.push(
`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${issue.id}#sub-issues`
);
// router.push({
// pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
// issue.id
// }`,
// hash: "sub-issues",
// });
};
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier: projectDetails?.identifier,
sequenceId: issue?.sequence_id,
isArchived: !!issue?.archived_at,
isEpic,
});
const redirectToIssueDetail = () => router.push(`${workItemLink}#sub-issues`);
if (!displayProperties || !issue.project_id) return null;
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useProjectState } from "@/hooks/store";
import { useEventTracker, useProject, useProjectState } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -42,18 +43,26 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
// store hooks
const { setTrackElement } = useEventTracker();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !readOnly;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -45,8 +46,10 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
const { allowPermissions } = useUserPermissions();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly;
@@ -56,12 +59,18 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -14,9 +14,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssues, useEventTracker, useProjectState, useUserPermissions } from "@/hooks/store";
import { useIssues, useEventTracker, useProjectState, useUserPermissions, useProject } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -45,8 +46,10 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
const { allowPermissions } = useUserPermissions();
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT) && !readOnly;
@@ -56,12 +59,18 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
@@ -15,9 +15,10 @@ import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, set
import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useIssues, useProjectState, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useIssues, useProject, useProjectState, useUserPermissions } from "@/hooks/store";
// types
import { IQuickActionProps } from "../list/list-view-types";
@@ -48,9 +49,11 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { getStateById } = useProjectState();
const { getProjectIdentifierById } = useProject();
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const stateDetails = getStateById(issue.state_id);
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
// auth
const isEditingAllowed =
allowPermissions(
@@ -63,16 +66,23 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
const isInArchivableGroup = !!stateDetails && ARCHIVABLE_STATE_GROUPS.includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug.toString(),
projectId: issue?.project_id,
issueId: issue?.id,
projectIdentifier,
sequenceId: issue?.sequence_id,
});
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
copyUrlToClipboard(workItemLink, false).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link copied",
message: "Work item link copied to clipboard",
})
);
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleOpenInNewTab = () => window.open(workItemLink, "_blank");
const isDraftIssue = pathname?.includes("draft-issues") || false;
@@ -16,6 +16,7 @@ import { MultipleSelectEntityAction } from "@/components/core";
import RenderIfVisible from "@/components/core/render-if-visible-HOC";
// helper
import { cn } from "@/helpers/common.helper";
import { generateWorkItemLink } from "@/helpers/issue.helper";
// hooks
import { useIssueDetail, useIssues, useProject } from "@/hooks/store";
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
@@ -231,6 +232,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
const disableUserActions = !canEditProperties(issueDetail.project_id ?? undefined);
const subIssuesCount = issueDetail?.sub_issues_count ?? 0;
const isIssueSelected = selectionHelpers.getIsEntitySelected(issueDetail.id);
const projectIdentifier = getProjectIdentifierById(issueDetail.project_id);
const canSelectIssues = !disableUserActions && !selectionHelpers.isSelectionDisabled;
@@ -239,6 +241,15 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
? (getProjectIdentifierById(issueDetail.project_id)?.length ?? 0 + 5) * 7
: 0;
const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(),
projectId: issueDetail?.project_id,
issueId,
projectIdentifier,
sequenceId: issueDetail?.sequence_id,
isEpic,
});
return (
<>
<td
@@ -248,7 +259,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
className="relative md:sticky left-0 z-10 group/list-block bg-custom-background-100"
>
<ControlLink
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/${isEpic ? "epics" : "issues"}/${issueId}`}
href={workItemLink}
onClick={() => handleIssuePeekOverview(issueDetail)}
className={cn(
"group clickable cursor-pointer h-11 w-[28rem] flex items-center text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200 bg-transparent group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10",

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