Merge branch 'develop' of github.com:makeplane/plane into feat/pagination

This commit is contained in:
pablohashescobar 2024-02-28 17:13:45 +05:30
commit 7060fb712f
155 changed files with 3131 additions and 3421 deletions

View File

@ -2,7 +2,7 @@ name: Bug report
description: Create a bug report to help us improve Plane description: Create a bug report to help us improve Plane
title: "[bug]: " title: "[bug]: "
labels: [🐛bug] labels: [🐛bug]
assignees: [srinivaspendem, pushya-plane] assignees: [srinivaspendem, pushya22]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -45,7 +45,7 @@ body:
- Deploy preview - Deploy preview
validations: validations:
required: true required: true
type: dropdown - type: dropdown
id: browser id: browser
attributes: attributes:
label: Browser label: Browser

View File

@ -2,7 +2,7 @@ name: Feature request
description: Suggest a feature to improve Plane description: Suggest a feature to improve Plane
title: "[feature]: " title: "[feature]: "
labels: [✨feature] labels: [✨feature]
assignees: [srinivaspendem, pushya-plane] assignees: [srinivaspendem, pushya22]
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -2,7 +2,7 @@ name: Create Sync Action
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: branches:
- preview - preview
@ -17,7 +17,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@ -31,14 +31,25 @@ jobs:
sudo apt update sudo apt update
sudo apt install gh -y sudo apt install gh -y
- name: Push Changes to Target Repo - name: Push Changes to Target Repo A
env: env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: | run: |
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" TARGET_REPO="${{ secrets.TARGET_REPO_A }}"
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH git checkout $SOURCE_BRANCH
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH
- name: Push Changes to Target Repo B
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
TARGET_REPO="${{ secrets.TARGET_REPO_B }}"
TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH

View File

@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
return ( return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("owned_by") .select_related("owned_by")
@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id")) .filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView):
return ( return (
Label.objects.filter(workspace__slug=self.kwargs.get("slug")) Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("parent") .select_related("parent")
@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView):
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by(self.kwargs.get("order_by", "-created_at")) .order_by(self.kwargs.get("order_by", "-created_at"))
.distinct() .distinct()
) )
@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
) )
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
.select_related("project") project__project_projectmember__member=self.request.user,
.select_related("workspace") project__project_projectmember__is_active=True,
.select_related("issue") )
.select_related("actor") .select_related("workspace", "project", "issue", "actor")
.annotate( .annotate(
is_member=Exists( is_member=Exists(
ProjectMember.objects.filter( ProjectMember.objects.filter(
@ -784,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
) )
.select_related("actor", "workspace", "issue", "project") .select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at")) ).order_by(request.GET.get("order_by", "created_at"))

View File

@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id")) .filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("module") .select_related("module")

View File

@ -24,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView):
return ( return (
State.objects.filter(workspace__slug=self.kwargs.get("slug")) State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(~Q(name="Triage")) .filter(~Q(name="Triage"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -259,23 +259,15 @@ urlpatterns = [
name="project-issue-archive", name="project-issue-archive",
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/archive/",
IssueArchiveViewSet.as_view( IssueArchiveViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"delete": "destroy", "post": "archive",
"delete": "unarchive",
} }
), ),
name="project-issue-archive", name="project-issue-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/unarchive/<uuid:pk>/",
IssueArchiveViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-issue-archive",
), ),
## End Issue Archives ## End Issue Archives
## Issue Relation ## Issue Relation

View File

@ -85,7 +85,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project", "workspace", "owned_by") .select_related("project", "workspace", "owned_by")
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
@ -689,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(cycle_id=self.kwargs.get("cycle_id")) .filter(cycle_id=self.kwargs.get("cycle_id"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet):
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
) )
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
) )
def create(self, request, slug, project_id, workspace_integration_id): def create(self, request, slug, project_id, workspace_integration_id):

View File

@ -596,7 +596,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView):
def get(self, request, slug): def get(self, request, slug):
issues = ( issues = (
Issue.issue_objects.filter(workspace__slug=slug) Issue.issue_objects.filter(workspace__slug=slug)
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
) )
serializer = IssueSerializer(issues, many=True) serializer = IssueSerializer(issues, many=True)
@ -619,6 +622,7 @@ class IssueActivityEndpoint(BaseAPIView):
.filter( .filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.filter(**filters) .filter(**filters)
@ -628,6 +632,7 @@ class IssueActivityEndpoint(BaseAPIView):
IssueComment.objects.filter(issue_id=issue_id) IssueComment.objects.filter(issue_id=issue_id)
.filter( .filter(
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.filter(**filters) .filter(**filters)
@ -679,7 +684,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")
@ -841,7 +849,10 @@ class LabelViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("parent") .select_related("parent")
@ -1054,7 +1065,10 @@ class IssueLinkViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1397,6 +1411,36 @@ class IssueArchiveViewSet(BaseViewSet):
serializer = IssueDetailSerializer(issue, expand=self.expand) serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, pk=None):
issue = Issue.issue_objects.get(
workspace__slug=slug,
project_id=project_id,
pk=pk,
)
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{"error": "Can only archive completed or cancelled state group issue"},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
issue.save()
return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK)
def unarchive(self, request, slug, project_id, pk=None): def unarchive(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, workspace__slug=slug,
@ -1420,7 +1464,7 @@ class IssueArchiveViewSet(BaseViewSet):
issue.archived_at = None issue.archived_at = None
issue.save() issue.save()
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSubscriberViewSet(BaseViewSet): class IssueSubscriberViewSet(BaseViewSet):
@ -1456,7 +1500,10 @@ class IssueSubscriberViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1540,7 +1587,10 @@ class IssueReactionViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1609,7 +1659,10 @@ class CommentReactionViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(comment_id=self.kwargs.get("comment_id")) .filter(comment_id=self.kwargs.get("comment_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
@ -1679,7 +1732,10 @@ class IssueRelationViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.select_related("issue") .select_related("issue")

View File

@ -683,7 +683,10 @@ class ModuleLinkViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id")) .filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )

View File

@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(parent__isnull=True) .filter(parent__isnull=True)
.filter(Q(owned_by=self.request.user) | Q(access=0)) .filter(Q(owned_by=self.request.user) | Q(access=0))
.select_related("project") .select_related("project")

View File

@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView):
return ( return (
Project.objects.filter( Project.objects.filter(
q, q,
Q(project_projectmember__member=self.request.user) project_projectmember__member=self.request.user,
| Q(network=2), project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
.distinct() .distinct()
@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView):
cycles = Cycle.objects.filter( cycles = Cycle.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView):
modules = Module.objects.filter( modules = Module.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView):
pages = Page.objects.filter( pages = Page.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView):
issue_views = IssueView.objects.filter( issue_views = IssueView.objects.filter(
q, q,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug, workspace__slug=slug,
) )
@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView):
issues = Issue.issue_objects.filter( issues = Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=self.request.user, project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
) )
if workspace_search == "false": if workspace_search == "false":

View File

@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(~Q(name="Triage")) .filter(~Q(name="Triage"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")

View File

@ -87,6 +87,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count") .values("count")
) )
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
@ -148,7 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
issue_queryset = ( issue_queryset = (
self.get_queryset() self.get_queryset()
.filter(**filters) .filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate(cycle_id=F("issue_cycle__cycle_id"))
) )
issue_queryset = order_issue_queryset( issue_queryset = order_issue_queryset(
@ -222,7 +225,10 @@ class IssueViewViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))

View File

@ -1084,6 +1084,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.annotate(state_group=F("state__group")) .annotate(state_group=F("state__group"))
@ -1099,6 +1100,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.values("priority") .values("priority")
@ -1121,6 +1123,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
Issue.issue_objects.filter( Issue.issue_objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
created_by_id=user_id, created_by_id=user_id,
) )
.filter(**filters) .filter(**filters)
@ -1132,6 +1135,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1143,6 +1147,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
assignees__in=[user_id], assignees__in=[user_id],
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1154,6 +1159,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assignees__in=[user_id], assignees__in=[user_id],
state__group="completed", state__group="completed",
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1164,6 +1170,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
workspace__slug=slug, workspace__slug=slug,
subscriber_id=user_id, subscriber_id=user_id,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.count() .count()
@ -1213,6 +1220,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
~Q(field__in=["comment", "vote", "reaction", "draft"]), ~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor=user_id, actor=user_id,
).select_related("actor", "workspace", "issue", "project") ).select_related("actor", "workspace", "issue", "project")
@ -1343,6 +1351,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
| Q(issue_subscribers__subscriber_id=user_id), | Q(issue_subscribers__subscriber_id=user_id),
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
.filter(**filters) .filter(**filters)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
@ -1418,6 +1427,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
labels = Label.objects.filter( labels = Label.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
serializer = LabelSerializer(labels, many=True).data serializer = LabelSerializer(labels, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)
@ -1432,6 +1442,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
states = State.objects.filter( states = State.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
serializer = StateSerializer(states, many=True).data serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)

View File

@ -292,6 +292,7 @@ def issue_export_task(
workspace__id=workspace_id, workspace__id=workspace_id,
project_id__in=project_ids, project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id, project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True
) )
.select_related( .select_related(
"project", "workspace", "state", "parent", "created_by" "project", "workspace", "state", "parent", "created_by"

View File

@ -60,15 +60,6 @@ def service_importer(service, importer_id):
batch_size=100, batch_size=100,
) )
_ = [
send_welcome_slack.delay(
str(user.id),
True,
f"{user.email} was imported to Plane from {service}",
)
for user in new_users
]
workspace_users = User.objects.filter( workspace_users = User.objects.filter(
email__in=[ email__in=[
user.get("email").strip().lower() user.get("email").strip().lower()

View File

@ -483,17 +483,23 @@ def track_archive_at(
) )
) )
else: else:
if requested_data.get("automation"):
comment = "Plane has archived the issue"
new_value = "archive"
else:
comment = "Actor has archived the issue"
new_value = "manual_archive"
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
project_id=project_id, project_id=project_id,
workspace_id=workspace_id, workspace_id=workspace_id,
comment="Plane has archived the issue", comment=comment,
verb="updated", verb="updated",
actor_id=actor_id, actor_id=actor_id,
field="archived_at", field="archived_at",
old_value=None, old_value=None,
new_value="archive", new_value=new_value,
epoch=epoch, epoch=epoch,
) )
) )

View File

@ -79,7 +79,7 @@ def archive_old_issues():
issue_activity.delay( issue_activity.delay(
type="issue.activity.updated", type="issue.activity.updated",
requested_data=json.dumps( requested_data=json.dumps(
{"archived_at": str(archive_at)} {"archived_at": str(archive_at), "automation": True}
), ),
actor_id=str(project.created_by_id), actor_id=str(project.created_by_id),
issue_id=issue.id, issue_id=issue.id,

View File

@ -12,15 +12,9 @@ from django.contrib.auth.models import (
PermissionsMixin, PermissionsMixin,
) )
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
# Third party imports
from sentry_sdk import capture_exception
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
def get_default_onboarding(): def get_default_onboarding():
return { return {
@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin):
super(User, self).save(*args, **kwargs) super(User, self).save(*args, **kwargs)
@receiver(post_save, sender=User)
def send_welcome_slack(sender, instance, created, **kwargs):
try:
if created and not instance.is_bot:
# Send message on slack as well
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)
try:
_ = client.chat_postMessage(
channel="#trackers",
text=f"New user {instance.email} has signed up and begun the onboarding journey.",
)
except SlackApiError as e:
print(f"Got an error: {e.response['error']}")
return
except Exception as e:
capture_exception(e)
return
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_user_notification(sender, instance, created, **kwargs): def create_user_notification(sender, instance, created, **kwargs):

78
deploy/1-click/README.md Normal file
View File

@ -0,0 +1,78 @@
# 1-Click Self-Hosting
In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization.
Let's get started!
## Installing Plane
Installing Plane is a very easy and minimal step process.
### Prerequisite
- Operating System (latest): Debian / Ubuntu / Centos
- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64
### Downloading Latest Stable Release
```
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh -
```
<details>
<summary>Downloading Preview Release</summary>
```
export BRANCH=preview
curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh -
```
NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture
</details>
--
Expect this after a successful install
![Install Output](images/install.png)
Access the application on a browser via http://server-ip-address
---
### Get Control of your Plane Server Setup
Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane
![Plane Help](images/help.png)
<ins>Basic Operations</ins>:
1. Start Server using `plane-app start`
1. Stop Server using `plane-app stop`
1. Restart Server using `plane-app restart`
<ins>Advanced Operations</ins>:
1. Configure Plane using `plane-app --configure`. This will give you options to modify
- NGINX Port (default 80)
- Domain Name (default is the local server public IP address)
- File Upload Size (default 5MB)
- External Postgres DB Url (optional - default empty)
- External Redis URL (optional - default empty)
- AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket)
1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images)
1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility.
1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio.
1. Plane App can be reinstalled using `plane-app --install`.
<ins>Application Data is stored in the mentioned folders</ins>:
1. DB Data: /opt/plane/data/postgres
1. Redis Data: /opt/plane/data/redis
1. Minio Data: /opt/plane/data/minio

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -13,6 +13,23 @@ YELLOW='\033[1;33m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
function print_header() {
clear
cat <<"EOF"
---------------------------------------
____ _
| _ \| | __ _ _ __ ___
| |_) | |/ _` | '_ \ / _ \
| __/| | (_| | | | | __/
|_| |_|\__,_|_| |_|\___|
---------------------------------------
Project management tool from the future
---------------------------------------
EOF
}
function buildLocalImage() { function buildLocalImage() {
if [ "$1" == "--force-build" ]; then if [ "$1" == "--force-build" ]; then
DO_BUILD="1" DO_BUILD="1"
@ -110,7 +127,7 @@ function download() {
exit 0 exit 0
fi fi
else else
docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull
fi fi
echo "" echo ""
@ -121,19 +138,48 @@ function download() {
} }
function startServices() { function startServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull
docker compose up -d --quiet-pull
cd $SCRIPT_DIR local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator")
if [ -n "$migrator_container_id" ]; then
local idx=0
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
local message=">>> Waiting for Data Migration to finish"
local dots=$(printf '%*s' $idx | tr ' ' '.')
echo -ne "\r$message$dots"
((idx++))
sleep 1
done
fi
printf "\r\033[K"
# if migrator exit status is not 0, show error message and exit
if [ -n "$migrator_container_id" ]; then
local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
if [ $migrator_exit_code -ne 0 ]; then
echo "Plane Server failed to start ❌"
stopServices
exit 1
fi
fi
local api_container_id=$(docker container ls -q -f "name=plane-app-api")
local idx2=0
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
do
local message=">>> Waiting for API Service to Start"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
echo -ne "\r$message$dots"
((idx2++))
sleep 1
done
printf "\r\033[K"
} }
function stopServices() { function stopServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down
docker compose down
cd $SCRIPT_DIR
} }
function restartServices() { function restartServices() {
cd $PLANE_INSTALL_DIR docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart
docker compose restart
cd $SCRIPT_DIR
} }
function upgrade() { function upgrade() {
echo "***** STOPPING SERVICES ****" echo "***** STOPPING SERVICES ****"
@ -144,47 +190,137 @@ function upgrade() {
download download
echo "***** PLEASE VALIDATE AND START SERVICES ****" echo "***** PLEASE VALIDATE AND START SERVICES ****"
}
function viewSpecificLogs(){
local SERVICE_NAME=$1
if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then
echo "Service '$SERVICE_NAME' is running."
else
echo "Service '$SERVICE_NAME' is not running."
fi
docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME
}
function viewLogs(){
ARG_SERVICE_NAME=$2
if [ -z "$ARG_SERVICE_NAME" ];
then
echo
echo "Select a Service you want to view the logs for:"
echo " 1) Web"
echo " 2) Space"
echo " 3) API"
echo " 4) Worker"
echo " 5) Beat-Worker"
echo " 6) Migrator"
echo " 7) Proxy"
echo " 8) Redis"
echo " 9) Postgres"
echo " 10) Minio"
echo " 0) Back to Main Menu"
echo
read -p "Service: " DOCKER_SERVICE_NAME
until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do
echo "Invalid selection. Please enter a number between 1 and 11."
read -p "Service: " DOCKER_SERVICE_NAME
done
if [ -z "$DOCKER_SERVICE_NAME" ];
then
echo "INVALID SERVICE NAME SUPPLIED"
else
case $DOCKER_SERVICE_NAME in
1) viewSpecificLogs "web";;
2) viewSpecificLogs "space";;
3) viewSpecificLogs "api";;
4) viewSpecificLogs "worker";;
5) viewSpecificLogs "beat-worker";;
6) viewSpecificLogs "migrator";;
7) viewSpecificLogs "proxy";;
8) viewSpecificLogs "plane-redis";;
9) viewSpecificLogs "plane-db";;
10) viewSpecificLogs "plane-minio";;
0) askForAction;;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
fi
elif [ -n "$ARG_SERVICE_NAME" ];
then
ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]')
case $ARG_SERVICE_NAME in
web) viewSpecificLogs "web";;
space) viewSpecificLogs "space";;
api) viewSpecificLogs "api";;
worker) viewSpecificLogs "worker";;
beat-worker) viewSpecificLogs "beat-worker";;
migrator) viewSpecificLogs "migrator";;
proxy) viewSpecificLogs "proxy";;
redis) viewSpecificLogs "plane-redis";;
postgres) viewSpecificLogs "plane-db";;
minio) viewSpecificLogs "plane-minio";;
*) echo "INVALID SERVICE NAME SUPPLIED";;
esac
else
echo "INVALID SERVICE NAME SUPPLIED"
fi
} }
function askForAction() { function askForAction() {
echo local DEFAULT_ACTION=$1
echo "Select a Action you want to perform:"
echo " 1) Install (${CPU_ARCH})" if [ -z "$DEFAULT_ACTION" ];
echo " 2) Start" then
echo " 3) Stop" echo
echo " 4) Restart" echo "Select a Action you want to perform:"
echo " 5) Upgrade" echo " 1) Install (${CPU_ARCH})"
echo " 6) Exit" echo " 2) Start"
echo echo " 3) Stop"
read -p "Action [2]: " ACTION echo " 4) Restart"
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do echo " 5) Upgrade"
echo "$ACTION: invalid selection." echo " 6) View Logs"
echo " 7) Exit"
echo
read -p "Action [2]: " ACTION read -p "Action [2]: " ACTION
done until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
echo echo "$ACTION: invalid selection."
read -p "Action [2]: " ACTION
done
if [ -z "$ACTION" ];
then
ACTION=2
fi
echo
fi
if [ "$ACTION" == "1" ] if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
then then
install install
askForAction askForAction
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ] elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ]
then then
startServices startServices
askForAction askForAction
elif [ "$ACTION" == "3" ] elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ]
then then
stopServices stopServices
askForAction askForAction
elif [ "$ACTION" == "4" ] elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ]
then then
restartServices restartServices
askForAction askForAction
elif [ "$ACTION" == "5" ] elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ]
then then
upgrade upgrade
askForAction askForAction
elif [ "$ACTION" == "6" ] elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ]
then
viewLogs $@
askForAction
elif [ "$ACTION" == "7" ]
then then
exit 0 exit 0
else else
@ -217,4 +353,8 @@ then
fi fi
mkdir -p $PLANE_INSTALL_DIR/archive mkdir -p $PLANE_INSTALL_DIR/archive
askForAction DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env
print_header
askForAction $@

View File

@ -12,27 +12,27 @@ export interface PaginatedUserNotification {
} }
export interface IUserNotification { export interface IUserNotification {
id: string; archived_at: string | null;
created_at: Date; created_at: string;
updated_at: Date; created_by: null;
data: Data; data: Data;
entity_identifier: string; entity_identifier: string;
entity_name: string; entity_name: string;
title: string; id: string;
message: null; message: null;
message_html: string; message_html: string;
message_stripped: null; message_stripped: null;
sender: string;
read_at: Date | null;
archived_at: Date | null;
snoozed_till: Date | null;
created_by: null;
updated_by: null;
workspace: string;
project: string; project: string;
read_at: Date | null;
receiver: string;
sender: string;
snoozed_till: Date | null;
title: string;
triggered_by: string; triggered_by: string;
triggered_by_details: IUserLite; triggered_by_details: IUserLite;
receiver: string; updated_at: Date;
updated_by: null;
workspace: string;
} }
export interface Data { export interface Data {

View File

@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}; };
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, disabled = false, onClick, className } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div" disabled={disabled}>
{({ active, close }) => ( {({ active, close }) => (
<button <button
type="button" type="button"
className={cn( className={cn(
"w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200", "w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200",
{ {
"bg-custom-background-80": active, "bg-custom-background-80": active && !disabled,
"text-custom-text-400": disabled,
}, },
className className
)} )}
@ -195,6 +196,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
close(); close();
onClick && onClick(e); onClick && onClick(e);
}} }}
disabled={disabled}
> >
{children} {children}
</button> </button>

View File

@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps &
export interface ICustomMenuItemProps { export interface ICustomMenuItemProps {
children: React.ReactNode; children: React.ReactNode;
disabled?: boolean;
onClick?: (args?: any) => void; onClick?: (args?: any) => void;
className?: string; className?: string;
} }

View File

@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-archive closed issues</h4> <h4 className="text-sm font-medium">Auto-archive closed issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will auto archive issues that have been completed or cancelled. Plane will auto archive issues that have been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.archive_in} value={currentProjectDetails?.archive_in}
label={`${currentProjectDetails?.archive_in} ${ label={`${currentProjectDetails?.archive_in} ${
currentProjectDetails?.archive_in === 1 ? "Month" : "Months" currentProjectDetails?.archive_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ archive_in: val }); handleChange({ archive_in: val });
@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customise Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<div className=""> <div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4> <h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm tracking-tight text-custom-text-200"> <p className="text-sm tracking-tight text-custom-text-200">
Plane will automatically close issue that haven{"'"}t been completed or cancelled. Plane will automatically close issue that haven{"'"}t been completed or canceled.
</p> </p>
</div> </div>
</div> </div>
@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
<CustomSelect <CustomSelect
value={currentProjectDetails?.close_in} value={currentProjectDetails?.close_in}
label={`${currentProjectDetails?.close_in} ${ label={`${currentProjectDetails?.close_in} ${
currentProjectDetails?.close_in === 1 ? "Month" : "Months" currentProjectDetails?.close_in === 1 ? "month" : "months"
}`} }`}
onChange={(val: number) => { onChange={(val: number) => {
handleChange({ close_in: val }); handleChange({ close_in: val });
@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)} onClick={() => setmonthModal(true)}
> >
Customize Time Range Customize time range
</button> </button>
</> </>
</CustomSelect> </CustomSelect>

View File

@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div> <div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Customise Time Range Customize time range
</Dialog.Title> </Dialog.Title>
<div className="mt-8 flex items-center gap-2"> <div className="mt-8 flex items-center gap-2">
<div className="flex w-full flex-col justify-center gap-1"> <div className="flex w-full flex-col justify-center gap-1">

View File

@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" /> <div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20"> <div className="fixed inset-0 z-30 overflow-y-auto">
<Transition.Child <div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
as={React.Fragment} <Transition.Child
enter="ease-out duration-300" as={React.Fragment}
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter="ease-out duration-300"
enterTo="opacity-100 translate-y-0 sm:scale-100" enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
leave="ease-in duration-200" enterTo="opacity-100 translate-y-0 sm:scale-100"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
<Dialog.Panel className="relative flex w-full items-center justify-center "> >
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all"> <Dialog.Panel className="relative flex w-full max-w-2xl items-center justify-center transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
<Command <div className="w-full max-w-2xl">
filter={(value, search) => { <Command
if (value.toLowerCase().includes(search.toLowerCase())) return 1; filter={(value, search) => {
return 0; if (value.toLowerCase().includes(search.toLowerCase())) return 1;
}} return 0;
onKeyDown={(e) => { }}
// when search is empty and page is undefined onKeyDown={(e) => {
// when user tries to close the modal with esc // when search is empty and page is undefined
if (e.key === "Escape" && !page && !searchTerm) closePalette(); // when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page // Escape goes to previous page
// Backspace goes to previous page when search is empty // Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
e.preventDefault(); e.preventDefault();
setPages((pages) => pages.slice(0, -1)); setPages((pages) => pages.slice(0, -1));
setPlaceholder("Type a command or search..."); setPlaceholder("Type a command or search...");
} }
}} }}
>
<div
className={`flex gap-4 p-3 pb-0 sm:items-center ${
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
}`}
> >
{issueDetails && ( <div
<div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200"> className={`flex gap-4 p-3 pb-0 sm:items-center ${
{projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name} issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
</div> }`}
)} >
{projectId && ( {issueDetails && (
<Tooltip tooltipContent="Toggle workspace level search"> <div className="overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center"> {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
<button
type="button"
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
className="flex-shrink-0"
>
Workspace Level
</button>
<ToggleSwitch
value={isWorkspaceLevel}
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
/>
</div> </div>
</Tooltip> )}
)} {projectId && (
</div> <Tooltip tooltipContent="Toggle workspace level search">
<div className="relative"> <div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
<Search <button
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200" type="button"
aria-hidden="true" onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
strokeWidth={2} className="flex-shrink-0"
/> >
<Command.Input Workspace Level
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" </button>
placeholder={placeholder} <ToggleSwitch
value={searchTerm} value={isWorkspaceLevel}
onValueChange={(e) => setSearchTerm(e)} onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
autoFocus />
tabIndex={1} </div>
/> </Tooltip>
</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}
/>
<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"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={1}
/>
</div>
<Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm"> <Command.List className="max-h-96 overflow-scroll p-2 vertical-scrollbar scrollbar-sm">
{searchTerm !== "" && ( {searchTerm !== "" && (
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100"> <h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
Search results for{" "} Search results for{" "}
<span className="font-medium"> <span className="font-medium">
{'"'} {'"'}
{searchTerm} {searchTerm}
{'"'} {'"'}
</span>{" "} </span>{" "}
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
</h5> </h5>
)} )}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-sm text-custom-text-200">No results found.</div> <div className="my-4 text-center text-sm text-custom-text-200">No results found.</div>
)} )}
{(isLoading || isSearching) && ( {(isLoading || isSearching) && (
<Command.Loading> <Command.Loading>
<Loader className="space-y-3"> <Loader className="space-y-3">
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
<Loader.Item height="40px" /> <Loader.Item height="40px" />
</Loader> </Loader>
</Command.Loading> </Command.Loading>
)} )}
{debouncedSearchTerm !== "" && ( {debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} /> <CommandPaletteSearchResults closePalette={closePalette} results={results} />
)} )}
{!page && ( {!page && (
<> <>
{/* issue actions */} {/* issue actions */}
{issueId && ( {issueId && (
<CommandPaletteIssueActions <CommandPaletteIssueActions
closePalette={closePalette} closePalette={closePalette}
issueDetails={issueDetails} issueDetails={issueDetails}
pages={pages} pages={pages}
setPages={(newPages) => setPages(newPages)} setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/> />
)} )}
<Command.Group heading="Issue"> <Command.Group heading="Issue">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command Palette");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item <Command.Item
onSelect={() => { onSelect={() => {
closePalette(); closePalette();
setTrackElement("Command palette"); setTrackElement("Command Palette");
toggleCreateProjectModal(true); toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LayersIcon className="h-3.5 w-3.5" />
Create new issue
</div>
<kbd>C</kbd>
</Command.Item>
</Command.Group>
{workspaceSlug && (
<Command.Group heading="Project">
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new project
</div>
<kbd>P</kbd>
</Command.Item>
</Command.Group>
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
onSelect={() => {
setPlaceholder("Search workspace settings...");
setSearchTerm("");
setPages([...pages, "settings"]);
}} }}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-custom-text-200"> <div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" /> <Settings className="h-3.5 w-3.5" />
Create new project Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div> </div>
<kbd>P</kbd>
</Command.Item> </Command.Item>
</Command.Group> </Command.Group>
)}
{/* project actions */} {/* help options */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />} <CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
<Command.Group heading="Workspace Settings"> {/* workspace settings actions */}
<Command.Item {page === "settings" && workspaceSlug && (
onSelect={() => { <CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
setPlaceholder("Search workspace settings..."); )}
setSearchTerm("");
setPages([...pages, "settings"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Search settings...
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Account">
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<FolderPlus className="h-3.5 w-3.5" />
Create new workspace
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change interface theme...");
setSearchTerm("");
setPages([...pages, "change-interface-theme"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Settings className="h-3.5 w-3.5" />
Change interface theme...
</div>
</Command.Item>
</Command.Group>
{/* help options */} {/* issue details page actions */}
<CommandPaletteHelpActions closePalette={closePalette} /> {page === "change-issue-state" && issueDetails && (
</> <ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)} )}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* workspace settings actions */} {/* theme actions */}
{page === "settings" && workspaceSlug && ( {page === "change-interface-theme" && (
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} /> <CommandPaletteThemeActions
)} closePalette={() => {
closePalette();
{/* issue details page actions */} setPages((pages) => pages.slice(0, -1));
{page === "change-issue-state" && issueDetails && ( }}
<ChangeIssueState closePalette={closePalette} issue={issueDetails} /> />
)} )}
{page === "change-issue-priority" && issueDetails && ( </Command.List>
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} /> </Command>
)} </div>
{page === "change-issue-assignee" && issueDetails && ( </Dialog.Panel>
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} /> </Transition.Child>
)} </div>
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
</Command.List>
</Command>
</div>
</Dialog.Panel>
</Transition.Child>
</div> </div>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>

View File

@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
// fetching project issues. // fetching project issues.
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null workspaceSlug && projectId && isOpen
? () => issueService.getIssues(workspaceSlug as string, projectId as string)
: null
); );
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();

View File

@ -1,277 +0,0 @@
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useCycle } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "./types";
import { TCycleGroups } from "@plane/types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: string | null) => void;
onClose?: () => void;
projectId: string;
value: string | null;
};
type DropdownOptions =
| {
value: string | null;
query: string;
content: JSX.Element;
}[]
| undefined;
export const CycleDropdown: React.FC<Props> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
onChange,
onClose,
placeholder = "Cycle",
placement,
projectId,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
});
const options: DropdownOptions = cycleIds?.map((cycleId) => {
const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return {
value: cycleId,
query: `${cycleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-grow truncate">{cycleDetails?.name}</span>
</div>
),
};
});
options?.unshift({
value: null,
query: "No cycle",
content: (
<div className="flex items-center gap-2">
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No cycle</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const selectedCycle = value ? getCycleById(value) : null;
const onOpen = () => {
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string | null) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Cycle"
tooltipContent={selectedCycle?.name ?? placeholder}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate max-w-40">{selectedCycle?.name ?? placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { observer } from "mobx-react";
//components
import { ContrastIcon, CycleGroupIcon } from "@plane/ui";
//store
import { useApplication, useCycle } from "hooks/store";
//hooks
import { usePopper } from "react-popper";
//icon
import { Check, Search } from "lucide-react";
//types
import { Placement } from "@popperjs/core";
import { TCycleGroups } from "@plane/types";
type DropdownOptions =
| {
value: string | null;
query: string;
content: JSX.Element;
}[]
| undefined;
interface Props {
projectId: string;
referenceElement: HTMLButtonElement | null;
placement: Placement | undefined;
isOpen: boolean;
}
export const CycleOptions = observer((props: any) => {
const { projectId, isOpen, referenceElement, placement } = props;
//state hooks
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle();
useEffect(() => {
if (isOpen) {
onOpen();
inputRef.current && inputRef.current.focus();
}
}, [isOpen]);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => {
const cycleDetails = getCycleById(cycleId);
return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true;
});
const onOpen = () => {
if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId);
};
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
const options: DropdownOptions = cycleIds?.map((cycleId) => {
const cycleDetails = getCycleById(cycleId);
const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
return {
value: cycleId,
query: `${cycleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-grow truncate">{cycleDetails?.name}</span>
</div>
),
};
});
options?.unshift({
value: null,
query: "No cycle",
content: (
<div className="flex items-center gap-2">
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No cycle</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
return (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
);
});

View File

@ -0,0 +1,149 @@
import { Fragment, ReactNode, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { ChevronDown } from "lucide-react";
// hooks
import { useCycle } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "../buttons";
// icons
import { ContrastIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "../types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
import { CycleOptions } from "./cycle-options";
type Props = TDropdownProps & {
button?: ReactNode;
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: string | null) => void;
onClose?: () => void;
projectId: string;
value: string | null;
};
export const CycleDropdown: React.FC<Props> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
onChange,
onClose,
placeholder = "Cycle",
placement,
projectId,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
const { getCycleNameById } = useCycle();
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const selectedName = value ? getCycleNameById(value) : null;
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
};
const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string | null) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full w-full outline-none hover:bg-custom-background-80",
buttonContainerClassName
)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none hover:bg-custom-background-80",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Cycle"
tooltipContent={selectedName ?? placeholder}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate max-w-40">{selectedName ?? placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
{isOpen && (
<CycleOptions isOpen={isOpen} projectId={projectId} placement={placement} referenceElement={referenceElement} />
)}
</Combobox>
);
});

View File

@ -86,6 +86,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen(); if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: Date | null) => { const dropdownOnChange = (val: Date | null) => {
@ -146,7 +147,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span> <span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
)} )}
{isClearable && isDateSelected && ( {isClearable && !disabled && isDateSelected && (
<X <X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)} className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => { onClick={(e) => {

View File

@ -122,6 +122,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen(); if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: number | null) => { const dropdownOnChange = (val: number | null) => {

View File

@ -1,2 +0,0 @@
export * from "./project-member";
export * from "./workspace-member";

View File

@ -0,0 +1,156 @@
import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { ChevronDown } from "lucide-react";
// hooks
import { useMember } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ButtonAvatars } from "./avatar";
import { DropdownButton } from "../buttons";
// helpers
import { cn } from "helpers/common.helper";
// types
import { MemberDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
import { MemberOptions } from "./member-options";
type Props = {
projectId?: string;
onClose?: () => void;
} & MemberDropdownProps;
export const MemberDropdown: React.FC<Props> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Members",
placement,
projectId,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const { getUserDetails } = useMember();
const comboboxProps: any = {
value,
onChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
};
const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onChange={dropdownOnChange}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
{isOpen && (
<MemberOptions
isOpen={isOpen}
projectId={projectId}
placement={placement}
referenceElement={referenceElement}
/>
)}
</Combobox>
);
});

View File

@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { observer } from "mobx-react";
//components
import { Avatar } from "@plane/ui";
//store
import { useApplication, useMember, useUser } from "hooks/store";
//hooks
import { usePopper } from "react-popper";
//icon
import { Check, Search } from "lucide-react";
//types
import { Placement } from "@popperjs/core";
interface Props {
projectId?: string;
referenceElement: HTMLButtonElement | null;
placement: Placement | undefined;
isOpen: boolean;
}
export const MemberOptions = observer((props: Props) => {
const { projectId, referenceElement, placement, isOpen } = props;
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const {
getUserDetails,
project: { getProjectMemberIds, fetchProjectMembers },
workspace: { workspaceMemberIds },
} = useMember();
const { currentUser } = useUser();
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
useEffect(() => {
if (isOpen) {
onOpen();
inputRef.current && inputRef.current.focus();
}
}, [isOpen]);
const memberIds = projectId ? getProjectMemberIds(projectId) : workspaceMemberIds;
const onOpen = () => {
if (!memberIds && workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId);
};
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
const options = memberIds?.map((userId) => {
const userDetails = getUserDetails(userId);
return {
value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
</div>
),
};
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
return (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
);
});

View File

@ -1,261 +0,0 @@
import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ButtonAvatars } from "./avatar";
import { DropdownButton } from "../buttons";
// icons
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { MemberDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
type Props = {
projectId: string;
onClose?: () => void;
} & MemberDropdownProps;
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Members",
placement,
projectId,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberIds, fetchProjectMembers },
} = useMember();
const projectMemberIds = getProjectMemberIds(projectId);
const options = projectMemberIds?.map((userId) => {
const userDetails = getUserDetails(userId);
return {
value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
</div>
),
};
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const comboboxProps: any = {
value,
onChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
const onOpen = () => {
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
};
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onChange={dropdownOnChange}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -1,238 +0,0 @@
import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { ButtonAvatars } from "./avatar";
import { DropdownButton } from "../buttons";
// icons
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { MemberDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((props) => {
const {
button,
buttonClassName,
buttonContainerClassName,
buttonVariant,
className = "",
disabled = false,
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
multiple,
onChange,
onClose,
placeholder = "Members",
placement,
showTooltip = false,
tabIndex,
value,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState<boolean>(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// store hooks
const { currentUser } = useUser();
const {
getUserDetails,
workspace: { workspaceMemberIds },
} = useMember();
const options = workspaceMemberIds?.map((userId) => {
const userDetails = getUserDetails(userId);
return {
value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={userDetails?.avatar} />
<span className="flex-grow truncate">{currentUser?.id === userId ? "You" : userDetails?.display_name}</span>
</div>
),
};
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const comboboxProps: any = {
value,
onChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
};
const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
{...comboboxProps}
handleKeyDown={handleKeyDown}
onChange={dropdownOnChange}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-xs leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

@ -1,22 +1,22 @@
import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper"; import { ChevronDown, X } from "lucide-react";
import { Check, ChevronDown, Search, X } from "lucide-react";
// hooks // hooks
import { useApplication, useModule } from "hooks/store"; import { useModule } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { DropdownButton } from "./buttons"; import { DropdownButton } from "../buttons";
// icons // icons
import { DiceIcon, Tooltip } from "@plane/ui"; import { DiceIcon, Tooltip } from "@plane/ui";
// helpers // helpers
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
// types // types
import { TDropdownProps } from "./types"; import { TDropdownProps } from "../types";
// constants // constants
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants";
import { ModuleOptions } from "./module-options";
type Props = TDropdownProps & { type Props = TDropdownProps & {
button?: ReactNode; button?: ReactNode;
@ -38,14 +38,6 @@ type Props = TDropdownProps & {
} }
); );
type DropdownOptions =
| {
value: string | null;
query: string;
content: JSX.Element;
}[]
| undefined;
type ButtonContentProps = { type ButtonContentProps = {
disabled: boolean; disabled: boolean;
dropdownArrow: boolean; dropdownArrow: boolean;
@ -166,64 +158,14 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
value, value,
} = props; } = props;
// states // states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// refs // refs
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
const moduleIds = getProjectModuleIds(projectId);
const options: DropdownOptions = moduleIds?.map((moduleId) => { const { getModuleNameById } = useModule();
const moduleDetails = getModuleById(moduleId);
return {
value: moduleId,
query: `${moduleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{moduleDetails?.name}</span>
</div>
),
};
});
if (!multiple)
options?.unshift({
value: null,
query: "No module",
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No module</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const onOpen = () => {
if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId);
};
const handleClose = () => { const handleClose = () => {
if (!isOpen) return; if (!isOpen) return;
@ -232,8 +174,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: string & string[]) => { const dropdownOnChange = (val: string & string[]) => {
@ -249,13 +191,6 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
toggleDropdown(); toggleDropdown();
}; };
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
useOutsideClickDetector(dropdownRef, handleClose); useOutsideClickDetector(dropdownRef, handleClose);
const comboboxProps: any = { const comboboxProps: any = {
@ -314,7 +249,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
tooltipContent={ tooltipContent={
Array.isArray(value) Array.isArray(value)
? `${value ? `${value
.map((moduleId) => getModuleById(moduleId)?.name) .map((moduleId) => getModuleNameById(moduleId))
.toString() .toString()
.replaceAll(",", ", ")}` .replaceAll(",", ", ")}`
: "" : ""
@ -339,61 +274,13 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
)} )}
</Combobox.Button> </Combobox.Button>
{isOpen && ( {isOpen && (
<Combobox.Options className="fixed z-10" static> <ModuleOptions
<div isOpen={isOpen}
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none" projectId={projectId}
ref={setPopperElement} placement={placement}
style={styles.popper} referenceElement={referenceElement}
{...attributes.popper} multiple={multiple}
> />
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
"text-custom-text-100": selected,
"text-custom-text-200": !selected,
}
)
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)} )}
</Combobox> </Combobox>
); );

View File

@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from "react";
import { Combobox } from "@headlessui/react";
import { observer } from "mobx-react";
//components
import { DiceIcon } from "@plane/ui";
//store
import { useApplication, useModule } from "hooks/store";
//hooks
import { usePopper } from "react-popper";
import { cn } from "helpers/common.helper";
//icon
import { Check, Search } from "lucide-react";
//types
import { Placement } from "@popperjs/core";
type DropdownOptions =
| {
value: string | null;
query: string;
content: JSX.Element;
}[]
| undefined;
interface Props {
projectId: string;
referenceElement: HTMLButtonElement | null;
placement: Placement | undefined;
isOpen: boolean;
multiple: boolean;
}
export const ModuleOptions = observer((props: Props) => {
const { projectId, isOpen, referenceElement, placement, multiple } = props;
const [query, setQuery] = useState("");
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// store hooks
const {
router: { workspaceSlug },
} = useApplication();
const { getProjectModuleIds, fetchModules, getModuleById } = useModule();
useEffect(() => {
if (isOpen) {
onOpen();
inputRef.current && inputRef.current.focus();
}
}, [isOpen]);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const moduleIds = getProjectModuleIds(projectId);
const onOpen = () => {
if (workspaceSlug && !moduleIds) fetchModules(workspaceSlug, projectId);
};
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
const options: DropdownOptions = moduleIds?.map((moduleId) => {
const moduleDetails = getModuleById(moduleId);
return {
value: moduleId,
query: `${moduleDetails?.name}`,
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{moduleDetails?.name}</span>
</div>
),
};
});
if (!multiple)
options?.unshift({
value: null,
query: "No module",
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No module</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
return (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
"text-custom-text-100": selected,
"text-custom-text-200": !selected,
}
)
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
);
});

View File

@ -314,6 +314,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: TIssuePriorities) => { const dropdownOnChange = (val: TIssuePriorities) => {

View File

@ -104,6 +104,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen(); if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: string) => { const dropdownOnChange = (val: string) => {

View File

@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
link={ link={
<BreadcrumbLink <BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`} href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
label="Archived Issues" label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/> />
} }

View File

@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
type="text" type="text"
link={ link={
<BreadcrumbLink <BreadcrumbLink
label="Archived Issues" label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/> />
} }

View File

@ -0,0 +1,106 @@
import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// hooks
import { useProject } from "hooks/store";
import { useIssues } from "hooks/store/use-issues";
import useToast from "hooks/use-toast";
// ui
import { Button } from "@plane/ui";
// types
import { TIssue } from "@plane/types";
type Props = {
data?: TIssue;
dataId?: string | null | undefined;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveIssueModal: React.FC<Props> = (props) => {
const { dataId, data, isOpen, handleClose, onSubmit } = props;
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getProjectById } = useProject();
const { issueMap } = useIssues();
// toast alert
const { setToastAlert } = useToast();
if (!dataId && !data) return null;
const issue = data ? data : issueMap[dataId!];
const projectDetails = getProjectById(issue.project_id);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
if (!onSubmit) return;
setIsArchiving(true);
await onSubmit()
.then(() => onClose())
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">
Archive issue {projectDetails?.identifier} {issue.sequence_id}
</h3>
<p className="text-sm text-custom-text-200 mt-3">
Are you sure you want to archive the issue? All your archived issues can be restored later.
</p>
<div className="flex justify-end gap-2 mt-3">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -1,139 +0,0 @@
import { useEffect, useState, Fragment } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
import { AlertTriangle } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
import { useIssues, useProject } from "hooks/store";
// ui
import { Button } from "@plane/ui";
// types
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "constants/issue";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: TIssue;
onSubmit?: () => Promise<void>;
};
export const DeleteArchivedIssueModal: React.FC<Props> = observer((props) => {
const { data, isOpen, handleClose, onSubmit } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { getProjectById } = useProject();
const {
issues: { removeIssue },
} = useIssues(EIssuesStoreType.ARCHIVED);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleIssueDelete = async () => {
if (!workspaceSlug) return;
setIsDeleteLoading(true);
await removeIssue(workspaceSlug.toString(), data.project_id, data.id)
.then(() => {
if (onSubmit) onSubmit();
})
.catch((err) => {
const error = err?.detail;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
title: "Error",
type: "error",
message: errorString || "Something went wrong.",
});
})
.finally(() => {
setIsDeleteLoading(false);
onClose();
});
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Archived Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the archived issue will be permanently removed. This action
cannot be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="danger"
size="sm"
tabIndex={1}
onClick={handleIssueDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@ -1,138 +0,0 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// services
import { IssueDraftService } from "services/issue";
// hooks
import useToast from "hooks/use-toast";
// icons
import { AlertTriangle } from "lucide-react";
// ui
import { Button } from "@plane/ui";
// types
import type { TIssue } from "@plane/types";
import { useProject } from "hooks/store";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: TIssue | null;
onSubmit?: () => Promise<void> | void;
};
const issueDraftService = new IssueDraftService();
export const DeleteDraftIssueModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, data, onSubmit } = props;
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// toast alert
const { setToastAlert } = useToast();
// hooks
const { getProjectById } = useProject();
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
const onClose = () => {
setIsDeleteLoading(false);
handleClose();
};
const handleDeletion = async () => {
if (!workspaceSlug || !data) return;
setIsDeleteLoading(true);
await issueDraftService
.deleteDraftIssue(workspaceSlug.toString(), data.project_id, data.id)
.then(() => {
setIsDeleteLoading(false);
handleClose();
setToastAlert({
title: "Success",
message: "Draft Issue deleted successfully",
type: "success",
});
})
.catch((error) => {
console.error(error);
handleClose();
setToastAlert({
title: "Error",
message: "Something went wrong",
type: "error",
});
setIsDeleteLoading(false);
});
if (onSubmit) await onSubmit();
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-red-500/20 p-4">
<AlertTriangle className="h-6 w-6 text-red-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Delete Draft Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-custom-text-200">
Are you sure you want to delete issue{" "}
<span className="break-words font-medium text-custom-text-100">
{data && getProjectById(data?.project_id)?.identifier}-{data?.sequence_id}
</span>
{""}? All of the data related to the draft issue will be permanently removed. This action cannot
be undone.
</p>
</span>
<div className="flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const { issueMap } = useIssues(); const { issueMap } = useIssues();
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
useEffect(() => { useEffect(() => {
setIsDeleteLoading(false); setIsDeleting(false);
}, [isOpen]); }, [isOpen]);
if (!dataId && !data) return null; if (!dataId && !data) return null;
@ -38,12 +38,12 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
const issue = data ? data : issueMap[dataId!]; const issue = data ? data : issueMap[dataId!];
const onClose = () => { const onClose = () => {
setIsDeleteLoading(false); setIsDeleting(false);
handleClose(); handleClose();
}; };
const handleIssueDelete = async () => { const handleIssueDelete = async () => {
setIsDeleteLoading(true); setIsDeleting(true);
if (onSubmit) if (onSubmit)
await onSubmit() await onSubmit()
.then(() => { .then(() => {
@ -56,7 +56,7 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
message: "Failed to delete issue", message: "Failed to delete issue",
}); });
}) })
.finally(() => setIsDeleteLoading(false)); .finally(() => setIsDeleting(false));
}; };
return ( return (
@ -109,14 +109,8 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button variant="danger" size="sm" tabIndex={1} onClick={handleIssueDelete} loading={isDeleting}>
variant="danger" {isDeleting ? "Deleting" : "Delete"}
size="sm"
tabIndex={1}
onClick={handleIssueDelete}
loading={isDeleteLoading}
>
{isDeleteLoading ? "Deleting..." : "Delete Issue"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,668 +0,0 @@
import React, { FC, useState, useEffect, useRef } from "react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { observer } from "mobx-react-lite";
import { Sparkle, X } from "lucide-react";
// hooks
import { useApplication, useEstimate, useMention, useProject, useWorkspace } from "hooks/store";
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
// services
import { AIService } from "services/ai.service";
import { FileService } from "services/file.service";
// components
import { GptAssistantPopover } from "components/core";
import { ParentIssuesListModal } from "components/issues";
import { IssueLabelSelect } from "components/issues/select";
import { CreateStateModal } from "components/states";
import { CreateLabelModal } from "components/labels";
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
import {
CycleDropdown,
DateDropdown,
EstimateDropdown,
ModuleDropdown,
PriorityDropdown,
ProjectDropdown,
ProjectMemberDropdown,
StateDropdown,
} from "components/dropdowns";
// ui
import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types";
const aiService = new AIService();
const fileService = new FileService();
const defaultValues: Partial<TIssue> = {
project_id: "",
name: "",
description_html: "<p></p>",
estimate_point: null,
state_id: "",
parent_id: null,
priority: "none",
assignee_ids: [],
label_ids: [],
start_date: undefined,
target_date: undefined,
};
interface IssueFormProps {
handleFormSubmit: (
formData: Partial<TIssue>,
action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue"
) => Promise<void>;
data?: Partial<TIssue> | null;
isOpen: boolean;
prePopulatedData?: Partial<TIssue> | null;
projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
createMore: boolean;
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void;
handleDiscard: () => void;
status: boolean;
user: IUser | undefined;
fieldsToShow: (
| "project"
| "name"
| "description"
| "state"
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
}
export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
const {
handleFormSubmit,
data,
isOpen,
prePopulatedData,
projectId,
setActiveProject,
createMore,
setCreateMore,
status,
fieldsToShow,
handleDiscard,
} = props;
// states
const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
// store hooks
const { areEstimatesEnabledForProject } = useEstimate();
const { mentionHighlights, mentionSuggestions } = useMention();
// hooks
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
const { setToastAlert } = useToast();
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// store
const {
config: { envConfig },
} = useApplication();
const { getProjectById } = useProject();
// form info
const {
formState: { errors, isSubmitting },
handleSubmit,
reset,
watch,
control,
getValues,
setValue,
setFocus,
} = useForm<TIssue>({
defaultValues: prePopulatedData ?? defaultValues,
reValidateMode: "onChange",
});
const issueName = watch("name");
const payload: Partial<TIssue> = {
name: watch("name"),
description_html: watch("description_html"),
state_id: watch("state_id"),
priority: watch("priority"),
assignee_ids: watch("assignee_ids"),
label_ids: watch("label_ids"),
start_date: watch("start_date"),
target_date: watch("target_date"),
project_id: watch("project_id"),
parent_id: watch("parent_id"),
cycle_id: watch("cycle_id"),
module_ids: watch("module_ids"),
};
useEffect(() => {
if (!isOpen || data) return;
setLocalStorageValue(
JSON.stringify({
...payload,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(payload), isOpen, data]);
// const onClose = () => {
// handleClose();
// };
// const onClose = () => {
// handleClose();
// };
const handleCreateUpdateIssue = async (
formData: Partial<TIssue>,
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
) => {
await handleFormSubmit(
{
...(data ?? {}),
...formData,
// is_draft: action === "createDraft" || action === "updateDraft",
},
action
);
// TODO: check_with_backend
setGptAssistantModal(false);
reset({
...defaultValues,
project_id: projectId,
description_html: "<p></p>",
});
editorRef?.current?.clearEditor();
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
// setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
editorRef.current?.setEditorValue(`${watch("description_html")}`);
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: issueName,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToastAlert({
type: "error",
title: "Error!",
message:
"Issue title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message: error || "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: error || "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
useEffect(() => {
setFocus("name");
}, [setFocus]);
// update projectId in form when projectId changes
useEffect(() => {
reset({
...getValues(),
project_id: projectId,
});
}, [getValues, projectId, reset]);
const startDate = watch("start_date");
const targetDate = watch("target_date");
const minDate = startDate ? new Date(startDate) : null;
minDate?.setDate(minDate.getDate());
const maxDate = targetDate ? new Date(targetDate) : null;
maxDate?.setDate(maxDate.getDate());
const projectDetails = getProjectById(projectId);
return (
<>
{projectId && (
<>
<CreateStateModal isOpen={stateModal} handleClose={() => setStateModal(false)} projectId={projectId} />
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])}
/>
</>
)}
<form
onSubmit={handleSubmit((formData) =>
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft")
)}
>
<div className="space-y-5">
<div className="flex items-center gap-x-2">
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
<Controller
control={control}
name="project_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(val) => {
onChange(val);
setActiveProject(val);
}}
buttonVariant="border-with-text"
/>
</div>
)}
/>
)}
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">
{status ? "Update" : "Create"} issue
</h3>
</div>
{watch("parent_id") &&
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
selectedParentIssue && (
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: selectedParentIssue.state__color,
}}
/>
<span className="flex-shrink-0 text-custom-text-200">
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
</span>
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
setValue("parent_id", null);
setSelectedParentIssue(null);
}}
/>
</div>
</div>
)}
<div className="space-y-3">
<div className="mt-2 space-y-3">
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
<div>
<Controller
control={control}
name="name"
rules={{
required: "Title is required",
maxLength: {
value: 255,
message: "Title should be less than 255 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="w-full resize-none text-xl"
/>
)}
/>
</div>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
<div className="relative">
<div className="border-0.5 absolute bottom-3.5 right-3.5 flex items-center gap-2">
{issueName && issueName !== "" && (
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80 ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
</>
)}
</button>
)}
{envConfig?.has_openai_configured && (
<GptAssistantPopover
isOpen={gptAssistantModal}
projectId={projectId}
handleClose={() => {
setGptAssistantModal((prevData) => !prevData);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
onResponse={(response) => {
handleAiAssistance(response);
}}
button={
<button
type="button"
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-80"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
>
<Sparkle className="h-3.5 w-3.5" />
AI
</button>
}
className=" !min-w-[38rem]"
placement="top-end"
/>
)}
</div>
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
}}
mentionHighlights={mentionHighlights}
mentionSuggestions={mentionSuggestions}
/>
)}
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
<Controller
control={control}
name="state_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<StateDropdown
value={value}
onChange={onChange}
projectId={projectId}
buttonVariant="border-with-text"
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown value={value} onChange={onChange} buttonVariant="border-with-text" />
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
<Controller
control={control}
name="assignee_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ProjectMemberDropdown
projectId={projectId}
value={value}
onChange={onChange}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""}
placeholder="Assignees"
multiple
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
<Controller
control={control}
name="label_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<IssueLabelSelect
setIsOpen={setLabelModal}
value={value}
onChange={onChange}
projectId={projectId}
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="Start date"
maxDate={maxDate ?? undefined}
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<DateDropdown
value={value}
onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)}
buttonVariant="border-with-text"
placeholder="Due date"
minDate={minDate ?? undefined}
/>
</div>
)}
/>
)}
{projectDetails?.cycle_view && (
<Controller
control={control}
name="cycle_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<CycleDropdown
projectId={projectId}
onChange={(cycleId) => onChange(cycleId)}
value={value}
buttonVariant="border-with-text"
/>
</div>
)}
/>
)}
{projectDetails?.module_view && workspaceSlug && (
<Controller
control={control}
name="module_ids"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ModuleDropdown
projectId={projectId}
value={value ?? []}
onChange={onChange}
buttonVariant="border-with-text"
multiple
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) &&
areEstimatesEnabledForProject(projectId) && (
<Controller
control={control}
name="estimate_point"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<EstimateDropdown
value={value}
onChange={onChange}
projectId={projectId}
buttonVariant="border-with-text"
/>
</div>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ParentIssuesListModal
isOpen={parentIssueListModalOpen}
handleClose={() => setParentIssueListModalOpen(false)}
onChange={(issue) => {
onChange(issue.id);
setSelectedParentIssue(issue);
}}
projectId={projectId}
/>
)}
/>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
<CustomMenu ellipsis>
{watch("parent_id") ? (
<>
<CustomMenu.MenuItem onClick={() => setParentIssueListModalOpen(true)}>
Change parent issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setValue("parent_id", null)}>
Remove parent issue
</CustomMenu.MenuItem>
</>
) : (
<CustomMenu.MenuItem onClick={() => setParentIssueListModalOpen(true)}>
Select Parent Issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
</div>
</div>
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
<div
className="flex cursor-pointer items-center gap-1"
onClick={() => setCreateMore((prevData) => !prevData)}
>
<span className="text-xs">Create more</span>
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
</div>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleDiscard}>
Discard
</Button>
<Button
variant="neutral-primary"
size="sm"
loading={isSubmitting}
onClick={handleSubmit((formData) =>
handleCreateUpdateIssue(formData, data?.id ? "updateDraft" : "createDraft")
)}
>
{isSubmitting ? "Saving..." : "Save Draft"}
</Button>
<Button
loading={isSubmitting}
variant="primary"
size="sm"
onClick={handleSubmit((formData) =>
handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createNewIssue")
)}
>
{isSubmitting ? "Saving..." : "Add Issue"}
</Button>
</div>
</div>
</form>
</>
);
});

View File

@ -1,349 +0,0 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { IssueService } from "services/issue";
import { ModuleService } from "services/module.service";
// hooks
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
import { useIssues, useProject, useUser } from "hooks/store";
// components
import { DraftIssueForm } from "components/issues";
// types
import type { TIssue } from "@plane/types";
import { EIssuesStoreType } from "constants/issue";
// fetch-keys
import { PROJECT_ISSUES_DETAILS, USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys";
interface IssuesModalProps {
data?: TIssue | null;
handleClose: () => void;
isOpen: boolean;
isUpdatingSingleIssue?: boolean;
prePopulateData?: Partial<TIssue>;
fieldsToShow?: (
| "project"
| "name"
| "description"
| "state"
| "priority"
| "assignee"
| "label"
| "startDate"
| "dueDate"
| "estimate"
| "parent"
| "all"
)[];
onSubmit?: (data: Partial<TIssue>) => Promise<void> | void;
}
// services
const issueService = new IssueService();
const moduleService = new ModuleService();
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer((props) => {
const {
data,
handleClose,
isOpen,
isUpdatingSingleIssue = false,
prePopulateData: prePopulateDataProps,
fieldsToShow = ["all"],
onSubmit,
} = props;
// states
const [createMore, setCreateMore] = useState(false);
const [activeProject, setActiveProject] = useState<string | null>(null);
const [prePopulateData, setPreloadedData] = useState<Partial<TIssue> | undefined>(undefined);
// router
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
// store
const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT);
const { currentUser } = useUser();
const { workspaceProjectIds: workspaceProjects } = useProject();
// derived values
const projects = workspaceProjects;
const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {});
const { setToastAlert } = useToast();
const onClose = () => {
handleClose();
setActiveProject(null);
};
const onDiscard = () => {
clearDraftIssueLocalStorage();
onClose();
};
useEffect(() => {
setPreloadedData(prePopulateDataProps ?? {});
if (cycleId && !prePopulateDataProps?.cycle_id) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
cycle: cycleId.toString(),
}));
}
if (moduleId && !prePopulateDataProps?.module_ids) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
module: moduleId.toString(),
}));
}
if (
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
!prePopulateDataProps?.assignee_ids
) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""],
}));
}
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]);
useEffect(() => {
setPreloadedData(prePopulateDataProps ?? {});
if (cycleId && !prePopulateDataProps?.cycle_id) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
cycle: cycleId.toString(),
}));
}
if (moduleId && !prePopulateDataProps?.module_ids) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
module: moduleId.toString(),
}));
}
if (
(router.asPath.includes("my-issues") || router.asPath.includes("assigned")) &&
!prePopulateDataProps?.assignee_ids
) {
setPreloadedData((prevData) => ({
...(prevData ?? {}),
...prePopulateDataProps,
assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""],
}));
}
}, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]);
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project_id) return setActiveProject(data.project_id);
if (prePopulateData && prePopulateData.project_id && !activeProject)
return setActiveProject(prePopulateData.project_id);
if (prePopulateData && prePopulateData.project_id && !activeProject)
return setActiveProject(prePopulateData.project_id);
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((id) => id === projectId) ?? projects?.[0] ?? null);
}, [activeProject, data, projectId, projects, isOpen, prePopulateData]);
const createDraftIssue = async (payload: Partial<TIssue>) => {
if (!workspaceSlug || !activeProject || !currentUser) return;
await draftIssues
.createIssue(workspaceSlug as string, activeProject ?? "", payload)
.then(async () => {
await draftIssues.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation");
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id))
mutate(USER_ISSUE(workspaceSlug.toString()));
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be created. Please try again.",
});
});
if (!createMore) onClose();
};
const updateDraftIssue = async (payload: Partial<TIssue>) => {
await draftIssues
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
.then(() => {
if (isUpdatingSingleIssue) {
mutate<TIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...payload } as TIssue), false);
} else {
if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString()));
}
// if (!payload.is_draft) { // TODO: check_with_backend
// if (payload.cycle_id && payload.cycle_id !== "") addIssueToCycle(res.id, payload.cycle_id);
// if (payload.module_id && payload.module_id !== "") addIssueToModule(res.id, payload.module_id);
// }
if (!createMore) onClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Issue updated successfully.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be updated. Please try again.",
});
});
};
const addIssueToCycle = async (issueId: string, cycleId: string) => {
if (!workspaceSlug || !activeProject) return;
await issueService.addIssueToCycle(workspaceSlug as string, activeProject ?? "", cycleId, {
issues: [issueId],
});
};
const addIssueToModule = async (issueId: string, moduleIds: string[]) => {
if (!workspaceSlug || !activeProject) return;
await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, {
modules: moduleIds,
});
};
const createIssue = async (payload: Partial<TIssue>) => {
if (!workspaceSlug || !activeProject) return;
await issueService
.createIssue(workspaceSlug.toString(), activeProject, payload)
.then(async (res) => {
if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id);
if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
if (!createMore) onClose();
if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id));
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be created. Please try again.",
});
});
};
const handleFormSubmit = async (
formData: Partial<TIssue>,
action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft"
) => {
if (!workspaceSlug || !activeProject) return;
const payload: Partial<TIssue> = {
...formData,
// description: formData.description ?? "",
description_html: formData.description_html ?? "<p></p>",
};
if (action === "createDraft") await createDraftIssue(payload);
else if (action === "updateDraft" || action === "convertToNewIssue") await updateDraftIssue(payload);
else if (action === "createNewIssue") await createIssue(payload);
clearDraftIssueLocalStorage();
if (onSubmit) await onSubmit(payload);
};
if (!projects || projects.length === 0) return null;
return (
<>
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-4xl">
<DraftIssueForm
isOpen={isOpen}
handleFormSubmit={handleFormSubmit}
prePopulatedData={prePopulateData}
data={data}
createMore={createMore}
setCreateMore={setCreateMore}
handleClose={onClose}
handleDiscard={onDiscard}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
status={data ? true : false}
user={currentUser ?? undefined}
fieldsToShow={fieldsToShow}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
</>
);
});

View File

@ -14,10 +14,5 @@ export * from "./issue-detail";
export * from "./peek-overview"; export * from "./peek-overview";
// draft issue
export * from "./draft-issue-form";
export * from "./draft-issue-modal";
export * from "./delete-draft-issue-modal";
// archived issue // archived issue
export * from "./delete-archived-issue-modal"; export * from "./archive-issue-modal";

View File

@ -30,6 +30,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
} = useInboxIssues(); } = useInboxIssues();
const { const {
issue: { getIssueById }, issue: { getIssueById },
fetchActivities,
fetchComments,
} = useIssueDetail(); } = useIssueDetail();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -125,6 +127,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
async () => { async () => {
if (workspaceSlug && projectId && inboxId && issueId) { if (workspaceSlug && projectId && inboxId && issueId) {
await issueOperations.fetch(workspaceSlug, projectId, issueId); await issueOperations.fetch(workspaceSlug, projectId, issueId);
await fetchActivities(workspaceSlug, projectId, issueId);
await fetchComments(workspaceSlug, projectId, issueId);
} }
} }
); );

View File

@ -5,7 +5,7 @@ import { CalendarCheck2, Signal, Tag } from "lucide-react";
import { useIssueDetail, useProject, useProjectState } from "hooks/store"; import { useIssueDetail, useProject, useProjectState } from "hooks/store";
// components // components
import { IssueLabel, TIssueOperations } from "components/issues"; import { IssueLabel, TIssueOperations } from "components/issues";
import { DateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
// icons // icons
import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui";
// helper // helper
@ -80,7 +80,7 @@ export const InboxIssueDetailsSidebar: React.FC<Props> = observer((props) => {
<UserGroupIcon className="h-4 w-4 flex-shrink-0" /> <UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span> <span>Assignees</span>
</div> </div>
<ProjectMemberDropdown <MemberDropdown
value={issue?.assignee_ids ?? undefined} value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!is_editable} disabled={!is_editable}

View File

@ -1,10 +1,12 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MessageSquare } from "lucide-react"; import { RotateCcw } from "lucide-react";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// components // components
import { IssueActivityBlockComponent } from "./"; import { IssueActivityBlockComponent } from "./";
// ui
import { ArchiveIcon } from "@plane/ui";
type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined }; type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined };
@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC<TIssueArchivedAtActivity> = observer((p
const activity = getActivityById(activityId); const activity = getActivityById(activityId);
if (!activity) return <></>; if (!activity) return <></>;
return ( return (
<IssueActivityBlockComponent <IssueActivityBlockComponent
icon={<MessageSquare size={14} color="#6b7280" aria-hidden="true" />} icon={
activity.new_value === "restore" ? (
<RotateCcw className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
) : (
<ArchiveIcon className="h-3.5 w-3.5" color="#6b7280" aria-hidden="true" />
)
}
activityId={activityId} activityId={activityId}
ends={ends} ends={ends}
customUserName={activity.new_value === "archive" ? "Plane" : undefined}
> >
{activity.new_value === "restore" ? `restored the issue` : `archived the issue`}. {activity.new_value === "restore" ? "restored the issue" : "archived the issue"}.
</IssueActivityBlockComponent> </IssueActivityBlockComponent>
); );
}); });

View File

@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = {
activityId: string; activityId: string;
ends: "top" | "bottom" | undefined; ends: "top" | "bottom" | undefined;
children: ReactNode; children: ReactNode;
customUserName?: string;
}; };
export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => { export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (props) => {
const { icon, activityId, ends, children } = props; const { icon, activityId, ends, children, customUserName } = props;
// hooks // hooks
const { const {
activity: { getActivityById }, activity: { getActivityById },
@ -37,7 +38,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
{icon ? icon : <Network className="w-3.5 h-3.5" />} {icon ? icon : <Network className="w-3.5 h-3.5" />}
</div> </div>
<div className="w-full text-custom-text-200"> <div className="w-full text-custom-text-200">
<IssueUser activityId={activityId} /> <IssueUser activityId={activityId} customUserName={customUserName} />
<span> {children} </span> <span> {children} </span>
<span> <span>
<Tooltip <Tooltip

View File

@ -1,15 +1,15 @@
import { FC } from "react"; import { FC } from "react";
import Link from "next/link";
// hooks // hooks
import { useIssueDetail } from "hooks/store"; import { useIssueDetail } from "hooks/store";
// ui
type TIssueUser = { type TIssueUser = {
activityId: string; activityId: string;
customUserName?: string;
}; };
export const IssueUser: FC<TIssueUser> = (props) => { export const IssueUser: FC<TIssueUser> = (props) => {
const { activityId } = props; const { activityId, customUserName } = props;
// hooks // hooks
const { const {
activity: { getActivityById }, activity: { getActivityById },
@ -18,12 +18,19 @@ export const IssueUser: FC<TIssueUser> = (props) => {
const activity = getActivityById(activityId); const activity = getActivityById(activityId);
if (!activity) return <></>; if (!activity) return <></>;
return ( return (
<a <>
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`} {customUserName ? (
className="hover:underline text-custom-text-100 font-medium capitalize" <span className="text-custom-text-100 font-medium">{customUserName}</span>
> ) : (
{activity.actor_detail?.display_name} <Link
</a> href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
className="hover:underline text-custom-text-100 font-medium"
>
{activity.actor_detail?.display_name}
</Link>
)}
</>
); );
}; };

View File

@ -14,6 +14,8 @@ import { FileService } from "services/file.service";
// types // types
import { TIssueComment } from "@plane/types"; import { TIssueComment } from "@plane/types";
import { TActivityOperations } from "../root"; import { TActivityOperations } from "../root";
// helpers
import { isEmptyHtmlString } from "helpers/string.helper";
const fileService = new FileService(); const fileService = new FileService();
@ -67,6 +69,12 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
isEditing && setFocus("comment_html"); isEditing && setFocus("comment_html");
}, [isEditing, setFocus]); }, [isEditing, setFocus]);
const isEmpty =
watch("comment_html") === "" ||
watch("comment_html")?.trim() === "" ||
watch("comment_html") === "<p></p>" ||
isEmptyHtmlString(watch("comment_html") ?? "");
if (!comment || !currentUser) return <></>; if (!comment || !currentUser) return <></>;
return ( return (
<IssueCommentBlock <IssueCommentBlock
@ -115,9 +123,14 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
> >
<> <>
<form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}> <form className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}>
<div> <div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !isEmpty) {
handleSubmit(onEnter)(e);
}
}}
>
<LiteTextEditorWithRef <LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(onEnter)}
cancelUploadImage={fileService.cancelUpload} cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(comment?.workspace_detail?.slug as string)} uploadFile={fileService.getUploadFileFunction(comment?.workspace_detail?.slug as string)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)} deleteFile={fileService.getDeleteImageFunction(workspaceId)}
@ -135,10 +148,14 @@ export const IssueCommentCard: FC<TIssueCommentCard> = (props) => {
<button <button
type="button" type="button"
onClick={handleSubmit(onEnter)} onClick={handleSubmit(onEnter)}
disabled={isSubmitting} disabled={isSubmitting || isEmpty}
className="group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 hover:bg-green-500" className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
isEmpty ? "bg-gray-200 cursor-not-allowed" : "hover:bg-green-500"
}`}
> >
<Check className="h-3 w-3 text-green-500 duration-300 group-hover:text-white" /> <Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button> </button>
<button <button
type="button" type="button"

View File

@ -20,7 +20,7 @@ type TActivityTabs = "all" | "activity" | "comments";
const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [ const activityTabs: { key: TActivityTabs; title: string; icon: LucideIcon }[] = [
{ {
key: "all", key: "all",
title: "All Activity", title: "All activity",
icon: History, icon: History,
}, },
{ {

View File

@ -16,7 +16,7 @@ import { TIssue } from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; import { ISSUE_UPDATED, ISSUE_DELETED, ISSUE_ARCHIVED } from "constants/event-tracker";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
export type TIssueOperations = { export type TIssueOperations = {
@ -29,6 +29,8 @@ export type TIssueOperations = {
showToast?: boolean showToast?: boolean
) => Promise<void>; ) => Promise<void>;
remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>; addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise<void>;
removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>; removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise<void>;
addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise<void>;
@ -63,6 +65,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
fetchIssue, fetchIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
archiveIssue,
addIssueToCycle, addIssueToCycle,
removeIssueFromCycle, removeIssueFromCycle,
addModulesToIssue, addModulesToIssue,
@ -158,6 +161,32 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
}); });
} }
}, },
archive: async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue archived successfully.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: router.asPath,
});
} catch (error) {
setToastAlert({
type: "error",
title: "Error!",
message: "Issue could not be archived. Please try again.",
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: router.asPath,
});
}
},
addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => {
try { try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
@ -321,6 +350,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
fetchIssue, fetchIssue,
updateIssue, updateIssue,
removeIssue, removeIssue,
archiveIssue,
removeArchivedIssue, removeArchivedIssue,
addIssueToCycle, addIssueToCycle,
removeIssueFromCycle, removeIssueFromCycle,
@ -350,7 +380,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/> />
) : ( ) : (
<div className="flex w-full h-full overflow-hidden"> <div className="flex w-full h-full overflow-hidden">
<div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-300 overflow-y-auto p-5"> <div className="h-full w-full max-w-2/3 space-y-5 divide-y-2 divide-custom-border-200 overflow-y-auto p-5">
<IssueMainContent <IssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
@ -360,7 +390,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
/> />
</div> </div>
<div <div
className="h-full w-full min-w-[300px] lg:min-w-80 xl:min-w-96 sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-300 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]" className="h-full w-full min-w-[300px] lg:min-w-80 xl:min-w-96 sm:w-1/2 md:w-1/3 space-y-5 overflow-hidden border-l border-custom-border-200 py-5 fixed md:relative bg-custom-sidebar-background-100 right-0 z-[5]"
style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}} style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
> >
<IssueDetailsSidebar <IssueDetailsSidebar

View File

@ -25,17 +25,12 @@ import {
IssueModuleSelect, IssueModuleSelect,
IssueParentSelect, IssueParentSelect,
IssueLabel, IssueLabel,
ArchiveIssueModal,
} from "components/issues"; } from "components/issues";
import { IssueSubscription } from "./subscription"; import { IssueSubscription } from "./subscription";
import { import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns";
DateDropdown,
EstimateDropdown,
PriorityDropdown,
ProjectMemberDropdown,
StateDropdown,
} from "components/dropdowns";
// icons // icons
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, UserGroupIcon } from "@plane/ui"; import { ArchiveIcon, ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip, UserGroupIcon } from "@plane/ui";
// helpers // helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
@ -43,6 +38,7 @@ import { cn } from "helpers/common.helper";
import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; import { shouldHighlightIssueDueDate } from "helpers/issue.helper";
// types // types
import type { TIssueOperations } from "./root"; import type { TIssueOperations } from "./root";
import { STATE_GROUPS } from "constants/state";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -55,6 +51,9 @@ type Props = {
export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props; const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
@ -66,8 +65,6 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const issue = getIssueById(issueId); const issue = getIssueById(issueId);
if (!issue) return <></>; if (!issue) return <></>;
@ -83,8 +80,23 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
}; };
const projectDetails = issue ? getProjectById(issue.project_id) : null; const handleDeleteIssue = async () => {
await issueOperations.remove(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
};
const handleArchiveIssue = async () => {
if (!issueOperations.archive) return;
await issueOperations.archive(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`);
};
// derived values
const projectDetails = getProjectById(issue.project_id);
const stateDetails = getStateById(issue.state_id); const stateDetails = getStateById(issue.state_id);
// auth
const isArchivingAllowed = !is_archived && issueOperations.archive && is_editable;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const minDate = issue.start_date ? new Date(issue.start_date) : null; const minDate = issue.start_date ? new Date(issue.start_date) : null;
minDate?.setDate(minDate.getDate()); minDate?.setDate(minDate.getDate());
@ -94,42 +106,68 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
return ( return (
<> <>
{workspaceSlug && projectId && issue && ( <DeleteIssueModal
<DeleteIssueModal handleClose={() => setDeleteIssueModal(false)}
handleClose={() => setDeleteIssueModal(false)} isOpen={deleteIssueModal}
isOpen={deleteIssueModal} data={issue}
data={issue} onSubmit={handleDeleteIssue}
onSubmit={async () => { />
await issueOperations.remove(workspaceSlug, projectId, issueId); <ArchiveIssueModal
router.push(`/${workspaceSlug}/projects/${projectId}/issues`); isOpen={archiveIssueModal}
}} handleClose={() => setArchiveIssueModal(false)}
/> data={issue}
)} onSubmit={handleArchiveIssue}
/>
<div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden"> <div className="flex h-full w-full flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-end px-5 pb-3"> <div className="flex items-center justify-end px-5 pb-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-4">
{currentUser && !is_archived && ( {currentUser && !is_archived && (
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)} )}
<div className="flex items-center flex-wrap gap-2.5 text-custom-text-300">
<button <Tooltip tooltipContent="Copy link">
type="button" <button
className="rounded-md border border-custom-border-200 p-2 shadow-sm duration-300 hover:bg-custom-background-90 focus:border-custom-primary focus:outline-none focus:ring-1 focus:ring-custom-primary" type="button"
onClick={handleCopyText} className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
> onClick={handleCopyText}
<LinkIcon className="h-3.5 w-3.5" /> >
</button> <LinkIcon className="h-4 w-4" />
</button>
{is_editable && ( </Tooltip>
<button {isArchivingAllowed && (
type="button" <Tooltip
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none" tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
onClick={() => setDeleteIssueModal(true)} >
> <button
<Trash2 className="h-3.5 w-3.5" /> type="button"
</button> className={cn(
)} "h-5 w-5 grid place-items-center rounded focus:outline-none focus:ring-2 focus:ring-custom-primary",
{
"hover:text-custom-text-200": isInArchivableGroup,
"cursor-not-allowed text-custom-text-400": !isInArchivableGroup,
}
)}
onClick={() => {
if (!isInArchivableGroup) return;
setArchiveIssueModal(true);
}}
>
<ArchiveIcon className="h-4 w-4" />
</button>
</Tooltip>
)}
{is_editable && (
<Tooltip tooltipContent="Delete">
<button
type="button"
className="h-5 w-5 grid place-items-center hover:text-custom-text-200 rounded focus:outline-none focus:ring-2 focus:ring-custom-primary"
onClick={() => setDeleteIssueModal(true)}
>
<Trash2 className="h-4 w-4" />
</button>
</Tooltip>
)}
</div>
</div> </div>
</div> </div>
@ -161,7 +199,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<UserGroupIcon className="h-4 w-4 flex-shrink-0" /> <UserGroupIcon className="h-4 w-4 flex-shrink-0" />
<span>Assignees</span> <span>Assignees</span>
</div> </div>
<ProjectMemberDropdown <MemberDropdown
value={issue?.assignee_ids ?? undefined} value={issue?.assignee_ids ?? undefined}
onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} onChange={(val) => issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })}
disabled={!is_editable} disabled={!is_editable}

View File

@ -26,6 +26,8 @@ interface IBaseCalendarRoot {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
viewId?: string; viewId?: string;
isCompletedCycle?: boolean; isCompletedCycle?: boolean;
@ -114,6 +116,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE) ? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE)
: undefined : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE]
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.ARCHIVE)
: undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE]
? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.RESTORE)
: undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
)} )}

View File

@ -26,7 +26,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
const { const {
router: { workspaceSlug, projectId }, router: { workspaceSlug, projectId },
} = useApplication(); } = useApplication();
const { getProjectById } = useProject(); const { getProjectIdentifierById } = useProject();
const { getProjectStates } = useProjectState(); const { getProjectStates } = useProjectState();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { peekIssue, setPeekIssue } = useIssueDetail();
// states // states
@ -108,7 +108,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
}} }}
/> />
<div className="flex-shrink-0 text-xs text-custom-text-300"> <div className="flex-shrink-0 text-xs text-custom-text-300">
{getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} {getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id}
</div> </div>
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}> <Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<div className="truncate text-xs">{issue.name}</div> <div className="truncate text-xs">{issue.name}</div>

View File

@ -33,6 +33,10 @@ export const CycleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId || !projectId) return; if (!workspaceSlug || !cycleId || !projectId) return;
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId, projectId] [issues, workspaceSlug, cycleId, projectId]
); );

View File

@ -34,6 +34,10 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
if (!workspaceSlug || !moduleId) return; if (!workspaceSlug || !moduleId) return;
await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id); await issues.removeIssueFromModule(workspaceSlug, issue.project_id, moduleId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, moduleId);
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -28,6 +28,11 @@ export const CalendarLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id); await issues.removeIssue(workspaceSlug.toString(), issue.project_id, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id);
},
}), }),
[issues, workspaceSlug] [issues, workspaceSlug]
); );

View File

@ -16,6 +16,7 @@ export interface IViewCalendarLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -66,7 +66,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
const { issueId } = props; const { issueId } = props;
// store hooks // store hooks
const { getStateById } = useProjectState(); const { getStateById } = useProjectState();
const { getProjectById } = useProject(); const { getProjectIdentifierById } = useProject();
const { const {
router: { workspaceSlug }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
@ -76,7 +76,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
} = useIssueDetail(); } = useIssueDetail();
// derived values // derived values
const issueDetails = getIssueById(issueId); const issueDetails = getIssueById(issueId);
const projectDetails = issueDetails && getProjectById(issueDetails?.project_id); const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id);
const stateDetails = issueDetails && getStateById(issueDetails?.state_id); const stateDetails = issueDetails && getStateById(issueDetails?.state_id);
const handleIssuePeekOverview = () => const handleIssuePeekOverview = () =>
@ -95,7 +95,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => {
<div className="relative flex h-full w-full cursor-pointer items-center gap-2"> <div className="relative flex h-full w-full cursor-pointer items-center gap-2">
{stateDetails && <StateGroupIcon stateGroup={stateDetails?.group} color={stateDetails?.color} />} {stateDetails && <StateGroupIcon stateGroup={stateDetails?.group} color={stateDetails?.color} />}
<div className="flex-shrink-0 text-xs text-custom-text-300"> <div className="flex-shrink-0 text-xs text-custom-text-300">
{projectDetails?.identifier} {issueDetails?.sequence_id} {projectIdentifier} {issueDetails?.sequence_id}
</div> </div>
<Tooltip tooltipHeading="Title" tooltipContent={issueDetails?.name}> <Tooltip tooltipHeading="Title" tooltipContent={issueDetails?.name}>
<span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span> <span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span>

View File

@ -41,6 +41,8 @@ export interface IBaseKanBanLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
showLoader?: boolean; showLoader?: boolean;
viewId?: string; viewId?: string;
@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
handleRemoveFromView={ handleRemoveFromView={
issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
), ),

View File

@ -42,9 +42,9 @@ interface IssueDetailsBlockProps {
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => { const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => {
const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props;
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectIdentifierById } = useProject();
const { const {
router: { workspaceSlug, projectId }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
const { setPeekIssue } = useIssueDetail(); const { setPeekIssue } = useIssueDetail();
@ -64,7 +64,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key"> <WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
<div className="relative"> <div className="relative">
<div className="line-clamp-1 text-xs text-custom-text-300"> <div className="line-clamp-1 text-xs text-custom-text-300">
{getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} {getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
</div> </div>
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">{quickActions(issue)}</div> <div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">{quickActions(issue)}</div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
// components // components
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; import { CreateUpdateIssueModal } from "components/issues";
// lucide icons // lucide icons
import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; import { Minimize2, Maximize2, Circle, Plus } from "lucide-react";
// hooks // hooks

View File

@ -1,4 +1,4 @@
import React, { useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
@ -39,6 +39,11 @@ export const CycleKanBanLayout: React.FC = observer(() => {
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId] [issues, workspaceSlug, cycleId]
); );
@ -46,7 +51,15 @@ export const CycleKanBanLayout: React.FC = observer(() => {
const isCompletedCycle = const isCompletedCycle =
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
const canEditIssueProperties = () => !isCompletedCycle; const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]);
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
},
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
);
return ( return (
<BaseKanBanRoot <BaseKanBanRoot
@ -57,10 +70,7 @@ export const CycleKanBanLayout: React.FC = observer(() => {
QuickActions={CycleIssueQuickActions} QuickActions={CycleIssueQuickActions}
viewId={cycleId?.toString() ?? ""} viewId={cycleId?.toString() ?? ""}
storeType={EIssuesStoreType.CYCLE} storeType={EIssuesStoreType.CYCLE}
addIssuesToView={(issueIds: string[]) => { addIssuesToView={addIssuesToView}
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
}}
canEditPropertiesBasedOnProject={canEditIssueProperties} canEditPropertiesBasedOnProject={canEditIssueProperties}
isCompletedCycle={isCompletedCycle} isCompletedCycle={isCompletedCycle}
/> />

View File

@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString());
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !userId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId);
},
}), }),
[issues, workspaceSlug, userId] [issues, workspaceSlug, userId]
); );

View File

@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id);
},
}), }),
[issues, workspaceSlug] [issues, workspaceSlug]
); );

View File

@ -17,6 +17,7 @@ export interface IViewKanBanLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -41,6 +41,8 @@ interface IBaseListRoot {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.RESTORE]?: (issue: TIssue) => Promise<void>;
}; };
viewId?: string; viewId?: string;
storeType: TCreateModalStoreTypes; storeType: TCreateModalStoreTypes;
@ -109,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
handleRemoveFromView={ handleRemoveFromView={
issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined
} }
handleArchive={
issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined
}
handleRestore={
issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined
}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
/> />
), ),

View File

@ -24,9 +24,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props;
// hooks // hooks
const { const {
router: { workspaceSlug, projectId }, router: { workspaceSlug },
} = useApplication(); } = useApplication();
const { getProjectById } = useProject(); const { getProjectIdentifierById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail(); const { peekIssue, setPeekIssue } = useIssueDetail();
const updateIssue = async (issueToUpdate: TIssue) => { const updateIssue = async (issueToUpdate: TIssue) => {
@ -45,7 +45,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
if (!issue) return null; if (!issue) return null;
const canEditIssueProperties = canEditProperties(issue.project_id); const canEditIssueProperties = canEditProperties(issue.project_id);
const projectDetails = getProjectById(issue.project_id); const projectIdentifier = getProjectIdentifierById(issue.project_id);
return ( return (
<div <div
@ -56,7 +56,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
> >
{displayProperties && displayProperties?.key && ( {displayProperties && displayProperties?.key && (
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300"> <div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{projectDetails?.identifier}-{issue.sequence_id} {projectIdentifier}-{issue.sequence_id}
</div> </div>
)} )}

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
// lucide icons // lucide icons
import { CircleDashed, Plus } from "lucide-react"; import { CircleDashed, Plus } from "lucide-react";
// components // components
import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; import { CreateUpdateIssueModal } from "components/issues";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
// mobx // mobx

View File

@ -5,6 +5,8 @@ export interface IQuickActionProps {
handleDelete: () => Promise<void>; handleDelete: () => Promise<void>;
handleUpdate?: (data: TIssue) => Promise<void>; handleUpdate?: (data: TIssue) => Promise<void>;
handleRemoveFromView?: () => Promise<void>; handleRemoveFromView?: () => Promise<void>;
handleArchive?: () => Promise<void>;
handleRestore?: () => Promise<void>;
customActionButton?: React.ReactElement; customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null; portalElement?: HTMLDivElement | null;
readOnly?: boolean; readOnly?: boolean;

View File

@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, projectId, issue.id); await issues.removeIssue(workspaceSlug, projectId, issue.id);
}, },
[EIssueActions.RESTORE]: async (issue: TIssue) => {
if (!workspaceSlug || !projectId) return;
await issues.restoreIssue(workspaceSlug, projectId, issue.id);
},
}), }),
[issues, workspaceSlug, projectId] [issues, workspaceSlug, projectId]
); );

View File

@ -1,4 +1,4 @@
import React, { useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
@ -38,13 +38,26 @@ export const CycleListLayout: React.FC = observer(() => {
await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !cycleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString());
},
}), }),
[issues, workspaceSlug, cycleId] [issues, workspaceSlug, cycleId]
); );
const isCompletedCycle = const isCompletedCycle =
cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false;
const canEditIssueProperties = () => !isCompletedCycle; const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]);
const addIssuesToView = useCallback(
(issueIds: string[]) => {
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
},
[issues?.addIssueToCycle, workspaceSlug, projectId, cycleId]
);
return ( return (
<BaseListRoot <BaseListRoot
@ -54,10 +67,7 @@ export const CycleListLayout: React.FC = observer(() => {
issueActions={issueActions} issueActions={issueActions}
viewId={cycleId?.toString()} viewId={cycleId?.toString()}
storeType={EIssuesStoreType.CYCLE} storeType={EIssuesStoreType.CYCLE}
addIssuesToView={(issueIds: string[]) => { addIssuesToView={addIssuesToView}
if (!workspaceSlug || !projectId || !cycleId) throw new Error();
return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds);
}}
canEditPropertiesBasedOnProject={canEditIssueProperties} canEditPropertiesBasedOnProject={canEditIssueProperties}
isCompletedCycle={isCompletedCycle} isCompletedCycle={isCompletedCycle}
/> />

View File

@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => {
await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !moduleId) return;
await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString());
},
}), }),
[issues, workspaceSlug, moduleId] [issues, workspaceSlug, moduleId]
); );

View File

@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !userId) return;
await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId);
},
}), }),
[issues, workspaceSlug, userId] [issues, workspaceSlug, userId]
); );

View File

@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => {
await issues.removeIssue(workspaceSlug, projectId, issue.id); await issues.removeIssue(workspaceSlug, projectId, issue.id);
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
if (!workspaceSlug || !projectId) return;
await issues.archiveIssue(workspaceSlug, projectId, issue.id);
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[issues] [issues]

View File

@ -17,6 +17,7 @@ export interface IViewListLayout {
[EIssueActions.DELETE]: (issue: TIssue) => Promise<void>; [EIssueActions.DELETE]: (issue: TIssue) => Promise<void>;
[EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise<void>;
[EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise<void>;
}; };
} }

View File

@ -13,7 +13,7 @@ import {
DateDropdown, DateDropdown,
EstimateDropdown, EstimateDropdown,
PriorityDropdown, PriorityDropdown,
ProjectMemberDropdown, MemberDropdown,
ModuleDropdown, ModuleDropdown,
CycleDropdown, CycleDropdown,
StateDropdown, StateDropdown,
@ -313,7 +313,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
{/* assignee */} {/* assignee */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5"> <div className="h-5">
<ProjectMemberDropdown <MemberDropdown
projectId={issue?.project_id} projectId={issue?.project_id}
value={issue?.assignee_ids} value={issue?.assignee_ids}
onChange={handleAssignee} onChange={handleAssignee}

View File

@ -1,10 +1,11 @@
import { Fragment, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Check, ChevronDown, Search, Tags } from "lucide-react"; import { Check, ChevronDown, Search, Tags } from "lucide-react";
// hooks // hooks
import { useApplication, useLabel } from "hooks/store"; import { useApplication, useLabel } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components // components
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
@ -48,6 +49,10 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
} = props; } = props;
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs // popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -60,18 +65,45 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
const storeLabels = getProjectLabels(projectId); const storeLabels = getProjectLabels(projectId);
const openDropDown = () => { const onOpen = () => {
if (!storeLabels && workspaceSlug && projectId) { if (!storeLabels && workspaceSlug && projectId)
setIsLoading(true);
fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false));
}
}; };
const handleClose = () => { const handleClose = () => {
if (!isOpen) return;
setIsOpen(false);
onClose && onClose(); onClose && onClose();
}; };
const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false); const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start", placement: placement ?? "bottom-start",
@ -176,6 +208,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
return ( return (
<Combobox <Combobox
as="div" as="div"
ref={dropdownRef}
className={`w-auto max-w-full flex-shrink-0 text-left ${className}`} className={`w-auto max-w-full flex-shrink-0 text-left ${className}`}
value={value} value={value}
onChange={onChange} onChange={onChange}
@ -194,64 +227,68 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
? "cursor-pointer" ? "cursor-pointer"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={openDropDown} onClick={handleOnClick}
> >
{label} {label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />} {!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button> </button>
</Combobox.Button> </Combobox.Button>
<Combobox.Options className="fixed z-10"> {isOpen && (
<div <Combobox.Options className="fixed z-10" static>
className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none ${optionsClassName}`} <div
ref={setPopperElement} className={`z-10 my-1 w-48 whitespace-nowrap rounded border border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none ${optionsClassName}`}
style={styles.popper} ref={setPopperElement}
{...attributes.popper} style={styles.popper}
> {...attributes.popper}
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2"> >
<Search className="h-3.5 w-3.5 text-custom-text-300" /> <div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Combobox.Input <Search className="h-3.5 w-3.5 text-custom-text-300" />
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none" <Combobox.Input
value={query} ref={inputRef}
onChange={(e) => setQuery(e.target.value)} className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search" value={query}
displayValue={(assigned: any) => assigned?.name || ""} onChange={(e) => setQuery(e.target.value)}
/> placeholder="Search"
displayValue={(assigned: any) => assigned?.name || ""}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
{isLoading ? (
<p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && (
<div className="flex-shrink-0">
<Check className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)}
</div>
</div> </div>
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}> </Combobox.Options>
{isLoading ? ( )}
<p className="text-center text-custom-text-200">Loading...</p>
) : filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && (
<div className="flex-shrink-0">
<Check className={`h-3.5 w-3.5`} />
</div>
)}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)}
</div>
</div>
</Combobox.Options>
</Combobox> </Combobox>
); );
}); });

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store"; import { useEventTracker, useProjectState } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -15,30 +16,50 @@ import { TIssue } from "@plane/types";
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { STATE_GROUPS } from "constants/state";
export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; const {
issue,
handleDelete,
handleUpdate,
handleArchive,
customActionButton,
portalElement,
readOnly = false,
} = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { getStateById } = useProjectState();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !readOnly;
// auth
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -50,6 +71,12 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -75,55 +102,81 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setTrackElement("Global issues");
> setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{!readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement("Global issues");
setTrackElement("Global issues"); setCreateUpdateIssueModal(true);
setIssueToEdit(issue); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
setTrackElement("Global issues"); <div className="-mt-1">
setCreateUpdateIssueModal(true); <p>Archive</p>
}} <p className="text-xs text-custom-text-400">
> Only completed or canceled
<div className="flex items-center gap-2"> <br />
<Copy className="h-3 w-3" /> issues can be archived
Make a copy </p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement("Global issues"); {isEditingAllowed && (
setDeleteIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement("Global issues");
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Trash2 className="h-3 w-3" /> }}
Delete issue >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
</> Delete
</div>
</CustomMenu.MenuItem>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,12 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { Link, Trash2 } from "lucide-react"; import { ExternalLink, Link, RotateCcw, Trash2 } from "lucide-react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useUser } from "hooks/store";
// components // components
import { DeleteArchivedIssueModal } from "components/issues"; import { DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -15,40 +15,41 @@ import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const { issue, handleDelete, customActionButton, portalElement, readOnly = false } = props; const { issue, handleDelete, handleRestore, customActionButton, portalElement, readOnly = false } = props;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// states
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// store hooks // store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
// store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED);
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isRestoringAllowed = handleRestore && isEditingAllowed;
// toast alert
const { setToastAlert } = useToast();
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
return ( return (
<> <>
<DeleteArchivedIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
@ -61,17 +62,27 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isRestoringAllowed && (
onClick={() => { <CustomMenu.MenuItem onClick={handleRestore}>
handleCopyIssueLink(); <div className="flex items-center gap-2">
}} <RotateCcw className="h-3 w-3" />
> Restore
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
setTrackElement(activeLayout); setTrackElement(activeLayout);

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useProjectState, useUser } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,13 +17,15 @@ import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { STATE_GROUPS } from "constants/state";
export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
issue, issue,
handleDelete, handleDelete,
handleUpdate, handleUpdate,
handleRemoveFromView, handleRemoveFromView,
handleArchive,
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
@ -31,33 +34,42 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, cycleId } = router.query; const { workspaceSlug, cycleId } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getStateById } = useProjectState();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // toast alert
const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -69,6 +81,12 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -94,68 +112,96 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setIssueToEdit({
> ...issue,
cycle_id: cycleId?.toString() ?? null,
});
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setIssueToEdit({ setCreateUpdateIssueModal(true);
...issue, }}
cycle_id: cycleId?.toString() ?? null, >
}); <div className="flex items-center gap-2">
setTrackElement(activeLayout); <Copy className="h-3 w-3" />
setCreateUpdateIssueModal(true); Make a copy
}} </div>
> </CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
>
<div className="flex items-center gap-2">
<XCircle className="h-3 w-3" />
Remove from cycle
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
handleRemoveFromView && handleRemoveFromView(); <div className="-mt-1">
}} <p>Archive</p>
> <p className="text-xs text-custom-text-400">
<div className="flex items-center gap-2"> Only completed or canceled
<XCircle className="h-3 w-3" /> <br />
Remove from cycle issues can be archived
</p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setCreateUpdateIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement(activeLayout);
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Copy className="h-3 w-3" /> }}
Make a copy >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
<CustomMenu.MenuItem Delete
onClick={() => { </div>
setTrackElement(activeLayout); </CustomMenu.MenuItem>
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete issue
</div>
</CustomMenu.MenuItem>
</>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2, XCircle } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2, XCircle } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useIssues, useEventTracker, useUser } from "hooks/store"; import { useIssues, useEventTracker, useUser, useProjectState } from "hooks/store";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,13 +17,15 @@ import { IQuickActionProps } from "../list/list-view-types";
// constants // constants
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { STATE_GROUPS } from "constants/state";
export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { const {
issue, issue,
handleDelete, handleDelete,
handleUpdate, handleUpdate,
handleRemoveFromView, handleRemoveFromView,
handleArchive,
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
@ -31,33 +34,42 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, moduleId } = router.query; const { workspaceSlug, moduleId } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
// toast alert
const { setToastAlert } = useToast();
// store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getStateById } = useProjectState();
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // toast alert
const { setToastAlert } = useToast();
// derived values
const stateDetails = getStateById(issue.state_id);
// auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const duplicateIssuePayload = omit( const duplicateIssuePayload = omit(
{ {
@ -69,6 +81,12 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
@ -94,67 +112,95 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
> setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] }); setCreateUpdateIssueModal(true);
setTrackElement(activeLayout); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
handleRemoveFromView && handleRemoveFromView();
}}
>
<div className="flex items-center gap-2">
<XCircle className="h-3 w-3" />
Remove from module
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
handleRemoveFromView && handleRemoveFromView(); <div className="-mt-1">
}} <p>Archive</p>
> <p className="text-xs text-custom-text-400">
<div className="flex items-center gap-2"> Only completed or canceled
<XCircle className="h-3 w-3" /> <br />
Remove from module issues can be archived
</p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setCreateUpdateIssueModal(true); <CustomMenu.MenuItem
}} onClick={(e) => {
> e.preventDefault();
<div className="flex items-center gap-2"> e.stopPropagation();
<Copy className="h-3 w-3" /> setTrackElement(activeLayout);
Make a copy setDeleteIssueModal(true);
</div> }}
</CustomMenu.MenuItem> >
<CustomMenu.MenuItem <div className="flex items-center gap-2">
onClick={(e) => { <Trash2 className="h-3 w-3" />
e.preventDefault(); Delete
e.stopPropagation(); </div>
setTrackElement(activeLayout); </CustomMenu.MenuItem>
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete issue
</div>
</CustomMenu.MenuItem>
</>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -1,13 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CustomMenu } from "@plane/ui"; import { ArchiveIcon, CustomMenu } from "@plane/ui";
import { Copy, Link, Pencil, Trash2 } from "lucide-react"; import { observer } from "mobx-react";
import { Copy, ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
import omit from "lodash/omit"; import omit from "lodash/omit";
// hooks // hooks
import { useEventTracker, useIssues, useUser } from "hooks/store"; import { useEventTracker, useIssues, useProjectState, useUser } from "hooks/store";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { ArchiveIssueModal, CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers // helpers
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
@ -16,9 +17,18 @@ import { IQuickActionProps } from "../list/list-view-types";
// constant // constant
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { STATE_GROUPS } from "constants/state";
export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) => { export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; const {
issue,
handleDelete,
handleUpdate,
handleArchive,
customActionButton,
portalElement,
readOnly = false,
} = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -26,28 +36,38 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined); const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [archiveIssueModal, setArchiveIssueModal] = useState(false);
// store hooks // store hooks
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT); const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT);
const { getStateById } = useProjectState();
// derived values
const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`;
const stateDetails = getStateById(issue.state_id);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; // auth
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isArchivingAllowed = handleArchive && isEditingAllowed;
const isInArchivableGroup =
!!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group);
const isDeletingAllowed = isEditingAllowed;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleCopyIssueLink = () => { const issueLink = `${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`;
copyUrlToClipboard(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`).then(() =>
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
copyUrlToClipboard(issueLink).then(() =>
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Link copied", title: "Link copied",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard",
}) })
); );
};
const isDraftIssue = router?.asPath?.includes("draft-issues") || false; const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
@ -62,13 +82,18 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
return ( return (
<> <>
<ArchiveIssueModal
data={issue}
isOpen={archiveIssueModal}
handleClose={() => setArchiveIssueModal(false)}
onSubmit={handleArchive}
/>
<DeleteIssueModal <DeleteIssueModal
data={issue} data={issue}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete} onSubmit={handleDelete}
/> />
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={createUpdateIssueModal} isOpen={createUpdateIssueModal}
onClose={() => { onClose={() => {
@ -82,7 +107,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
storeType={EIssuesStoreType.PROJECT} storeType={EIssuesStoreType.PROJECT}
isDraft={isDraftIssue} isDraft={isDraftIssue}
/> />
<CustomMenu <CustomMenu
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
@ -90,55 +114,81 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
closeOnSelect closeOnSelect
ellipsis ellipsis
> >
<CustomMenu.MenuItem {isEditingAllowed && (
onClick={() => { <CustomMenu.MenuItem
handleCopyIssueLink(); onClick={() => {
}} setTrackElement(activeLayout);
> setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
<div className="flex items-center gap-2">
<ExternalLink className="h-3 w-3" />
Open in new tab
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link className="h-3 w-3" /> <Link className="h-3 w-3" />
Copy link Copy link
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
{isEditingAllowed && !readOnly && ( {isEditingAllowed && (
<> <CustomMenu.MenuItem
<CustomMenu.MenuItem onClick={() => {
onClick={() => { setTrackElement(activeLayout);
setTrackElement(activeLayout); setCreateUpdateIssueModal(true);
setIssueToEdit(issue); }}
setCreateUpdateIssueModal(true); >
}} <div className="flex items-center gap-2">
> <Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isArchivingAllowed && (
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pencil className="h-3 w-3" /> <ArchiveIcon className="h-3 w-3" />
Edit issue Archive
</div> </div>
</CustomMenu.MenuItem> ) : (
<CustomMenu.MenuItem <div className="flex items-start gap-2">
onClick={() => { <ArchiveIcon className="h-3 w-3" />
setTrackElement(activeLayout); <div className="-mt-1">
setCreateUpdateIssueModal(true); <p>Archive</p>
}} <p className="text-xs text-custom-text-400">
> Only completed or canceled
<div className="flex items-center gap-2"> <br />
<Copy className="h-3 w-3" /> issues can be archived
Make a copy </p>
</div>
</div> </div>
</CustomMenu.MenuItem> )}
<CustomMenu.MenuItem </CustomMenu.MenuItem>
onClick={() => { )}
setTrackElement(activeLayout); {isDeletingAllowed && (
setDeleteIssueModal(true); <CustomMenu.MenuItem
}} onClick={() => {
> setTrackElement(activeLayout);
<div className="flex items-center gap-2"> setDeleteIssueModal(true);
<Trash2 className="h-3 w-3" /> }}
Delete issue >
</div> <div className="flex items-center gap-2">
</CustomMenu.MenuItem> <Trash2 className="h-3 w-3" />
</> Delete
</div>
</CustomMenu.MenuItem>
)} )}
</CustomMenu> </CustomMenu>
</> </>
); );
}; });

View File

@ -34,7 +34,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { const {
issuesFilter: { filters, fetchFilters, updateFilters }, issuesFilter: { filters, fetchFilters, updateFilters },
issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue }, issues: { loader, groupedIssueIds, fetchIssues, updateIssue, removeIssue, archiveIssue },
} = useIssues(EIssuesStoreType.GLOBAL); } = useIssues(EIssuesStoreType.GLOBAL);
const { dataViewId, issueIds } = groupedIssueIds; const { dataViewId, issueIds } = groupedIssueIds;
@ -88,11 +88,15 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
} }
}; };
useSWR(workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS${workspaceSlug}` : null, async () => { useSWR(
if (workspaceSlug) { workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
await fetchAllGlobalViews(workspaceSlug.toString()); async () => {
} if (workspaceSlug) {
}); await fetchAllGlobalViews(workspaceSlug.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useSWR( useSWR(
workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null, workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null,
@ -103,7 +107,8 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader"); await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader");
routerFilterParams(); routerFilterParams();
} }
} },
{ revalidateIfStale: false, revalidateOnFocus: false }
); );
const canEditProperties = useCallback( const canEditProperties = useCallback(
@ -133,6 +138,12 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
await removeIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString()); await removeIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString());
}, },
[EIssueActions.ARCHIVE]: async (issue: TIssue) => {
const projectId = issue.project_id;
if (!workspaceSlug || !projectId || !globalViewId) return;
await archiveIssue(workspaceSlug.toString(), projectId, issue.id, globalViewId.toString());
},
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[updateIssue, removeIssue, workspaceSlug] [updateIssue, removeIssue, workspaceSlug]
@ -142,6 +153,7 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
async (issue: TIssue, action: EIssueActions) => { async (issue: TIssue, action: EIssueActions) => {
if (action === EIssueActions.UPDATE) await issueActions[action]!(issue); if (action === EIssueActions.UPDATE) await issueActions[action]!(issue);
if (action === EIssueActions.DELETE) await issueActions[action]!(issue); if (action === EIssueActions.DELETE) await issueActions[action]!(issue);
if (action === EIssueActions.ARCHIVE) await issueActions[action]!(issue);
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[] []
@ -169,10 +181,12 @@ export const AllIssueLayoutRoot: React.FC = observer(() => {
issue={issue} issue={issue}
handleUpdate={async () => handleIssues({ ...issue }, EIssueActions.UPDATE)} handleUpdate={async () => handleIssues({ ...issue }, EIssueActions.UPDATE)}
handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)} handleDelete={async () => handleIssues(issue, EIssueActions.DELETE)}
handleArchive={async () => handleIssues(issue, EIssueActions.ARCHIVE)}
portalElement={portalElement} portalElement={portalElement}
readOnly={!canEditProperties(issue.project_id)}
/> />
), ),
[handleIssues] [canEditProperties, handleIssues]
); );
const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;

View File

@ -33,7 +33,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
issues?.groupedIssueIds ? "mutation" : "init-loader" issues?.groupedIssueIds ? "mutation" : "init-loader"
); );
} }
} },
{ revalidateIfStale: false, revalidateOnFocus: false }
); );
if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) {

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