mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into feat/pagination
This commit is contained in:
commit
7060fb712f
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
4
.github/ISSUE_TEMPLATE/--bug-report.yaml
vendored
@ -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
|
||||||
|
@ -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:
|
||||||
|
21
.github/workflows/create-sync-pr.yml
vendored
21
.github/workflows/create-sync-pr.yml
vendored
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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"))
|
||||||
|
@ -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")
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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):
|
||||||
|
@ -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")
|
||||||
|
@ -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()
|
||||||
)
|
)
|
||||||
|
@ -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")
|
||||||
|
@ -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":
|
||||||
|
@ -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")
|
||||||
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
@ -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"
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
|
@ -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
78
deploy/1-click/README.md
Normal 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
|
BIN
deploy/1-click/images/help.png
Normal file
BIN
deploy/1-click/images/help.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 109 KiB |
BIN
deploy/1-click/images/install.png
Normal file
BIN
deploy/1-click/images/install.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 173 KiB |
@ -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,9 +190,89 @@ 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() {
|
||||||
|
local DEFAULT_ACTION=$1
|
||||||
|
|
||||||
|
if [ -z "$DEFAULT_ACTION" ];
|
||||||
|
then
|
||||||
echo
|
echo
|
||||||
echo "Select a Action you want to perform:"
|
echo "Select a Action you want to perform:"
|
||||||
echo " 1) Install (${CPU_ARCH})"
|
echo " 1) Install (${CPU_ARCH})"
|
||||||
@ -154,37 +280,47 @@ function askForAction() {
|
|||||||
echo " 3) Stop"
|
echo " 3) Stop"
|
||||||
echo " 4) Restart"
|
echo " 4) Restart"
|
||||||
echo " 5) Upgrade"
|
echo " 5) Upgrade"
|
||||||
echo " 6) Exit"
|
echo " 6) View Logs"
|
||||||
|
echo " 7) Exit"
|
||||||
echo
|
echo
|
||||||
read -p "Action [2]: " ACTION
|
read -p "Action [2]: " ACTION
|
||||||
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
|
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do
|
||||||
echo "$ACTION: invalid selection."
|
echo "$ACTION: invalid selection."
|
||||||
read -p "Action [2]: " ACTION
|
read -p "Action [2]: " ACTION
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -z "$ACTION" ];
|
||||||
|
then
|
||||||
|
ACTION=2
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ]
|
||||||
if [ "$ACTION" == "1" ]
|
|
||||||
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 $@
|
||||||
|
24
packages/types/src/notifications.d.ts
vendored
24
packages/types/src/notifications.d.ts
vendored
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -154,7 +154,8 @@ 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">
|
||||||
|
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={React.Fragment}
|
as={React.Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@ -164,8 +165,8 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative flex w-full items-center justify-center ">
|
<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">
|
||||||
<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">
|
<div className="w-full max-w-2xl">
|
||||||
<Command
|
<Command
|
||||||
filter={(value, search) => {
|
filter={(value, search) => {
|
||||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||||
@ -386,6 +387,7 @@ export const CommandModal: React.FC = observer(() => {
|
|||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
);
|
);
|
||||||
|
@ -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();
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal file
162
web/components/dropdowns/cycle/cycle-options.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
149
web/components/dropdowns/cycle/index.tsx
Normal file
149
web/components/dropdowns/cycle/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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) => {
|
||||||
|
@ -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) => {
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
export * from "./project-member";
|
|
||||||
export * from "./workspace-member";
|
|
156
web/components/dropdowns/member/index.tsx
Normal file
156
web/components/dropdowns/member/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
142
web/components/dropdowns/member/member-options.tsx
Normal file
142
web/components/dropdowns/member/member-options.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
||||||
);
|
);
|
163
web/components/dropdowns/module/module-options.tsx
Normal file
163
web/components/dropdowns/module/module-options.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -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) => {
|
||||||
|
@ -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) => {
|
||||||
|
@ -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" />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@ -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" />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
106
web/components/issues/archive-issue-modal.tsx
Normal file
106
web/components/issues/archive-issue-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -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";
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
@ -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
|
<>
|
||||||
|
{customUserName ? (
|
||||||
|
<span className="text-custom-text-100 font-medium">{customUserName}</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
href={`/${activity?.workspace_detail?.slug}/profile/${activity?.actor_detail?.id}`}
|
||||||
className="hover:underline text-custom-text-100 font-medium capitalize"
|
className="hover:underline text-custom-text-100 font-medium"
|
||||||
>
|
>
|
||||||
{activity.actor_detail?.display_name}
|
{activity.actor_detail?.display_name}
|
||||||
</a>
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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"
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
@ -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,44 +106,70 @@ 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={async () => {
|
onSubmit={handleDeleteIssue}
|
||||||
await issueOperations.remove(workspaceSlug, projectId, issueId);
|
/>
|
||||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
|
<ArchiveIssueModal
|
||||||
}}
|
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">
|
||||||
|
<Tooltip tooltipContent="Copy link">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="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"
|
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}
|
onClick={handleCopyText}
|
||||||
>
|
>
|
||||||
<LinkIcon className="h-3.5 w-3.5" />
|
<LinkIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
{is_editable && (
|
{isArchivingAllowed && (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
|
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)}
|
onClick={() => setDeleteIssueModal(true)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-full w-full overflow-y-auto px-6">
|
<div className="h-full w-full overflow-y-auto px-6">
|
||||||
<h5 className="text-sm font-medium mt-6">Properties</h5>
|
<h5 className="text-sm font-medium mt-6">Properties</h5>
|
||||||
@ -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}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
);
|
);
|
||||||
|
@ -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]
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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,14 +227,15 @@ 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 && (
|
||||||
|
<Combobox.Options className="fixed z-10" static>
|
||||||
<div
|
<div
|
||||||
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}`}
|
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}`}
|
||||||
ref={setPopperElement}
|
ref={setPopperElement}
|
||||||
@ -211,11 +245,13 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
|
<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" />
|
<Search className="h-3.5 w-3.5 text-custom-text-300" />
|
||||||
<Combobox.Input
|
<Combobox.Input
|
||||||
|
ref={inputRef}
|
||||||
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
className="w-full bg-transparent px-2 py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
displayValue={(assigned: any) => assigned?.name || ""}
|
displayValue={(assigned: any) => assigned?.name || ""}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
|
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
|
||||||
@ -252,6 +288,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Combobox.Options>
|
</Combobox.Options>
|
||||||
|
)}
|
||||||
</Combobox>
|
</Combobox>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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,18 +102,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
closeOnSelect
|
closeOnSelect
|
||||||
ellipsis
|
ellipsis
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
{isEditingAllowed && (
|
||||||
onClick={() => {
|
|
||||||
handleCopyIssueLink();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link className="h-3 w-3" />
|
|
||||||
Copy link
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{!readOnly && (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement("Global issues");
|
setTrackElement("Global issues");
|
||||||
@ -96,9 +112,23 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
Edit issue
|
Edit
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</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">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{isEditingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement("Global issues");
|
setTrackElement("Global issues");
|
||||||
@ -110,6 +140,30 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
Make a copy
|
Make a copy
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isArchivingAllowed && (
|
||||||
|
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||||
|
{isInArchivableGroup ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
Archive
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
<p>Archive</p>
|
||||||
|
<p className="text-xs text-custom-text-400">
|
||||||
|
Only completed or canceled
|
||||||
|
<br />
|
||||||
|
issues can be archived
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isEditingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement("Global issues");
|
setTrackElement("Global issues");
|
||||||
@ -118,12 +172,11 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Delete issue
|
Delete
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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);
|
||||||
|
@ -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,18 +112,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
closeOnSelect
|
closeOnSelect
|
||||||
ellipsis
|
ellipsis
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
{isEditingAllowed && (
|
||||||
onClick={() => {
|
|
||||||
handleCopyIssueLink();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link className="h-3 w-3" />
|
|
||||||
Copy link
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{isEditingAllowed && !readOnly && (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIssueToEdit({
|
setIssueToEdit({
|
||||||
@ -118,19 +125,23 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
Edit issue
|
Edit
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
<CustomMenu.MenuItem
|
)}
|
||||||
onClick={() => {
|
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||||
handleRemoveFromView && handleRemoveFromView();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<XCircle className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
Remove from cycle
|
Open in new tab
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{isEditingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -142,6 +153,42 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
Make a copy
|
Make a copy
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</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">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
Archive
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
<p>Archive</p>
|
||||||
|
<p className="text-xs text-custom-text-400">
|
||||||
|
Only completed or canceled
|
||||||
|
<br />
|
||||||
|
issues can be archived
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isDeletingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -150,12 +197,11 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Delete issue
|
Delete
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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,18 +112,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
closeOnSelect
|
closeOnSelect
|
||||||
ellipsis
|
ellipsis
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
{isEditingAllowed && (
|
||||||
onClick={() => {
|
|
||||||
handleCopyIssueLink();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link className="h-3 w-3" />
|
|
||||||
Copy link
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{isEditingAllowed && !readOnly && (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
setIssueToEdit({ ...issue, module_ids: moduleId ? [moduleId.toString()] : [] });
|
||||||
@ -115,19 +122,23 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
Edit issue
|
Edit
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
<CustomMenu.MenuItem
|
)}
|
||||||
onClick={() => {
|
<CustomMenu.MenuItem onClick={handleOpenInNewTab}>
|
||||||
handleRemoveFromView && handleRemoveFromView();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<XCircle className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
Remove from module
|
Open in new tab
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{isEditingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -139,6 +150,42 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
Make a copy
|
Make a copy
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</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">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
Archive
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
<p>Archive</p>
|
||||||
|
<p className="text-xs text-custom-text-400">
|
||||||
|
Only completed or canceled
|
||||||
|
<br />
|
||||||
|
issues can be archived
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isDeletingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -149,12 +196,11 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Delete issue
|
Delete
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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,18 +114,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
|||||||
closeOnSelect
|
closeOnSelect
|
||||||
ellipsis
|
ellipsis
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem
|
{isEditingAllowed && (
|
||||||
onClick={() => {
|
|
||||||
handleCopyIssueLink();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link className="h-3 w-3" />
|
|
||||||
Copy link
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
{isEditingAllowed && !readOnly && (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -111,9 +124,23 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
Edit issue
|
Edit
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</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">
|
||||||
|
<Link className="h-3 w-3" />
|
||||||
|
Copy link
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
{isEditingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -125,6 +152,30 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
|||||||
Make a copy
|
Make a copy
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isArchivingAllowed && (
|
||||||
|
<CustomMenu.MenuItem onClick={() => setArchiveIssueModal(true)} disabled={!isInArchivableGroup}>
|
||||||
|
{isInArchivableGroup ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
Archive
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ArchiveIcon className="h-3 w-3" />
|
||||||
|
<div className="-mt-1">
|
||||||
|
<p>Archive</p>
|
||||||
|
<p className="text-xs text-custom-text-400">
|
||||||
|
Only completed or canceled
|
||||||
|
<br />
|
||||||
|
issues can be archived
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)}
|
||||||
|
{isDeletingAllowed && (
|
||||||
<CustomMenu.MenuItem
|
<CustomMenu.MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTrackElement(activeLayout);
|
setTrackElement(activeLayout);
|
||||||
@ -133,12 +184,11 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Delete issue
|
Delete
|
||||||
</div>
|
</div>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -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(
|
||||||
|
workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null,
|
||||||
|
async () => {
|
||||||
if (workspaceSlug) {
|
if (workspaceSlug) {
|
||||||
await fetchAllGlobalViews(workspaceSlug.toString());
|
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;
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user