Merge branch 'develop' of github.com:makeplane/plane into dev/external-pings

This commit is contained in:
pablohashescobar 2024-02-28 16:55:29 +05:30
commit 37fb3cd4e2
195 changed files with 3854 additions and 3817 deletions

View File

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

View File

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

View File

@ -23,6 +23,10 @@ jobs:
gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }}
gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }}
gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }}
build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }}
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_backend: ${{ steps.changed_files.outputs.backend_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
steps: steps:
- id: set_env_variables - id: set_env_variables
@ -41,7 +45,36 @@ jobs:
fi fi
echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT
- id: checkout_files
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
frontend:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
backend:
- apiserver/**
proxy:
- nginx/**
branch_build_push_frontend: branch_build_push_frontend:
if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -55,9 +88,9 @@ jobs:
- name: Set Frontend Docker Tag - name: Set Frontend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest
else else
TAG=${{ env.FRONTEND_TAG }} TAG=${{ env.FRONTEND_TAG }}
fi fi
@ -77,7 +110,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Frontend to Docker Container Registry - name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -93,6 +126,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space: branch_build_push_space:
if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -106,9 +140,9 @@ jobs:
- name: Set Space Docker Tag - name: Set Space Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest
else else
TAG=${{ env.SPACE_TAG }} TAG=${{ env.SPACE_TAG }}
fi fi
@ -128,7 +162,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Space to Docker Hub - name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -144,6 +178,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend: branch_build_push_backend:
if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -157,9 +192,9 @@ jobs:
- name: Set Backend Docker Tag - name: Set Backend Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest
else else
TAG=${{ env.BACKEND_TAG }} TAG=${{ env.BACKEND_TAG }}
fi fi
@ -179,7 +214,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Backend to Docker Hub - name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0
@ -194,8 +229,8 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy: branch_build_push_proxy:
if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
@ -209,9 +244,9 @@ jobs:
- name: Set Proxy Docker Tag - name: Set Proxy Docker Tag
run: | run: |
if [ "${{ github.event_name }}" == "release" ]; then if [ "${{ github.event_name }}" == "release" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest
else else
TAG=${{ env.PROXY_TAG }} TAG=${{ env.PROXY_TAG }}
fi fi
@ -231,7 +266,7 @@ jobs:
endpoint: ${{ env.BUILDX_ENDPOINT }} endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4
- name: Build and Push Plane-Proxy to Docker Hub - name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v5.1.0 uses: docker/build-push-action@v5.1.0

View File

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

View File

@ -1,4 +1,4 @@
{ {
"name": "plane-api", "name": "plane-api",
"version": "0.15.1" "version": "0.16.0"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView):
}, },
{ {
"key": "SLACK_CLIENT_ID", "key": "SLACK_CLIENT_ID",
"default": os.environ.get("SLACK_CLIENT_ID", "1"), "default": os.environ.get("SLACK_CLIENT_ID", None),
}, },
{ {
"key": "POSTHOG_API_KEY", "key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"), "default": os.environ.get("POSTHOG_API_KEY", None),
}, },
{ {
"key": "POSTHOG_HOST", "key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"), "default": os.environ.get("POSTHOG_HOST", None),
}, },
{ {
"key": "UNSPLASH_ACCESS_KEY", "key": "UNSPLASH_ACCESS_KEY",
@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView):
}, },
{ {
"key": "POSTHOG_API_KEY", "key": "POSTHOG_API_KEY",
"default": os.environ.get("POSTHOG_API_KEY", "1"), "default": os.environ.get("POSTHOG_API_KEY", None),
}, },
{ {
"key": "POSTHOG_HOST", "key": "POSTHOG_HOST",
"default": os.environ.get("POSTHOG_HOST", "1"), "default": os.environ.get("POSTHOG_HOST", None),
}, },
{ {
"key": "UNSPLASH_ACCESS_KEY", "key": "UNSPLASH_ACCESS_KEY",

View File

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

View File

@ -33,6 +33,7 @@ from plane.app.serializers import (
IssueSerializer, IssueSerializer,
InboxSerializer, InboxSerializer,
InboxIssueSerializer, InboxIssueSerializer,
IssueDetailSerializer,
) )
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity from plane.bgtasks.issue_activites_task import issue_activity
@ -426,11 +427,10 @@ class InboxIssueViewSet(BaseViewSet):
) )
) )
).first() ).first()
if issue is None: if issue is None:
return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND)
serializer = IssueSerializer(issue) serializer = IssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, issue_id): def destroy(self, request, slug, project_id, inbox_id, issue_id):

View File

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

View File

@ -773,7 +773,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)
@ -796,6 +799,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)
@ -805,6 +809,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)
@ -856,7 +861,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")
@ -1018,7 +1026,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")
@ -1231,7 +1242,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()
) )
@ -1633,6 +1647,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,
@ -1656,7 +1700,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):
@ -1692,7 +1736,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()
) )
@ -1776,7 +1823,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()
) )
@ -1845,7 +1895,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()
) )
@ -1915,7 +1968,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")
@ -2310,17 +2366,10 @@ class IssueDraftViewSet(BaseViewSet):
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
serializer = IssueSerializer(issue, data=request.data, partial=True) serializer = IssueCreateSerializer(issue, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
if request.data.get( serializer.save()
"is_draft"
) is not None and not request.data.get("is_draft"):
serializer.save(
created_at=timezone.now(), updated_at=timezone.now()
)
else:
serializer.save()
issue_activity.delay( issue_activity.delay(
type="issue_draft.activity.updated", type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),

View File

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

View File

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

View File

@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
] ]
def get_queryset(self): def get_queryset(self):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
) )
) )
) )
.annotate(sort_order=Subquery(sort_order))
.prefetch_related( .prefetch_related(
Prefetch( Prefetch(
"project_projectmember", "project_projectmember",
@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
for field in request.GET.get("fields", "").split(",") for field in request.GET.get("fields", "").split(",")
if field if field
] ]
sort_order_query = ProjectMember.objects.filter(
member=request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = ( projects = (
self.get_queryset() self.get_queryset()
.annotate(sort_order=Subquery(sort_order_query))
.order_by("sort_order", "name") .order_by("sort_order", "name")
) )
if request.GET.get("per_page", False) and request.GET.get( if request.GET.get("per_page", False) and request.GET.get(
@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer.save() serializer.save()
# Add the user as Administrator to the project # Add the user as Administrator to the project
project_member = ProjectMember.objects.create( _ = ProjectMember.objects.create(
project_id=serializer.data["id"], project_id=serializer.data["id"],
member=request.user, member=request.user,
role=20, role=20,

View File

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

View File

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

View File

@ -86,6 +86,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"))
@ -163,7 +167,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"))
) )
@ -284,7 +287,10 @@ class IssueViewViewSet(BaseViewSet):
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.annotate(is_favorite=Exists(subquery)) .annotate(is_favorite=Exists(subquery))

View File

@ -1086,6 +1086,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"))
@ -1101,6 +1102,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")
@ -1123,6 +1125,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)
@ -1134,6 +1137,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()
@ -1145,6 +1149,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()
@ -1156,6 +1161,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()
@ -1166,6 +1172,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()
@ -1215,6 +1222,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")
@ -1355,6 +1363,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")
@ -1486,6 +1495,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)
@ -1500,6 +1510,7 @@ class WorkspaceStatesEndpoint(BaseAPIView):
states = State.objects.filter( states = State.objects.filter(
workspace__slug=slug, workspace__slug=slug,
project__project_projectmember__member=request.user, project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True
) )
serializer = StateSerializer(states, many=True).data serializer = StateSerializer(states, many=True).data
return Response(serializer, status=status.HTTP_200_OK) return Response(serializer, status=status.HTTP_200_OK)

View File

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

View File

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

View File

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

View File

@ -9,11 +9,11 @@ from plane.db.models import Issue
def search_issues(query, queryset): def search_issues(query, queryset):
fields = ["name", "sequence_id"] fields = ["name", "sequence_id", "project__identifier"]
q = Q() q = Q()
for field in fields: for field in fields:
if field == "sequence_id" and len(query) <= 20: if field == "sequence_id" and len(query) <= 20:
sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences: for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id}) q |= Q(**{"sequence_id": sequence_id})
else: else:

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -1,17 +1,20 @@
#!/bin/bash #!/bin/bash
export GIT_REPO=makeplane/plane
# Check if the user has sudo access # Check if the user has sudo access
if command -v curl &> /dev/null; then if command -v curl &> /dev/null; then
sudo curl -sSL \ sudo curl -sSL \
-o /usr/local/bin/plane-app \ -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
else else
sudo wget -q \ sudo wget -q \
-O /usr/local/bin/plane-app \ -O /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s)
fi fi
sudo chmod +x /usr/local/bin/plane-app sudo chmod +x /usr/local/bin/plane-app
sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app
sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app
plane-app --help plane-app -i #--help

View File

@ -90,9 +90,9 @@ function prepare_environment() {
show_message "- Updating OS with required tools ✋" >&2 show_message "- Updating OS with required tools ✋" >&2
sudo "$PACKAGE_MANAGER" update -y sudo "$PACKAGE_MANAGER" update -y
sudo "$PACKAGE_MANAGER" upgrade -y # sudo "$PACKAGE_MANAGER" upgrade -y
local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq")
for tool in "${required_tools[@]}"; do for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then if ! command -v $tool &> /dev/null; then
@ -150,11 +150,11 @@ function download_plane() {
show_message "Downloading Plane Setup Files ✋" >&2 show_message "Downloading Plane Setup Files ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s)
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s)
# if .env does not exists rename variables-upgrade.env to .env # if .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then
@ -202,7 +202,7 @@ function printUsageInstructions() {
} }
function build_local_image() { function build_local_image() {
show_message "- Downloading Plane Source Code ✋" >&2 show_message "- Downloading Plane Source Code ✋" >&2
REPO=https://github.com/makeplane/plane.git REPO=https://github.com/$CODE_REPO.git
CURR_DIR=$PWD CURR_DIR=$PWD
PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
@ -290,40 +290,40 @@ function configure_plane() {
fi fi
smtp_host=$(read_env "EMAIL_HOST") # smtp_host=$(read_env "EMAIL_HOST")
smtp_user=$(read_env "EMAIL_HOST_USER") # smtp_user=$(read_env "EMAIL_HOST_USER")
smtp_password=$(read_env "EMAIL_HOST_PASSWORD") # smtp_password=$(read_env "EMAIL_HOST_PASSWORD")
smtp_port=$(read_env "EMAIL_PORT") # smtp_port=$(read_env "EMAIL_PORT")
smtp_from=$(read_env "EMAIL_FROM") # smtp_from=$(read_env "EMAIL_FROM")
smtp_tls=$(read_env "EMAIL_USE_TLS") # smtp_tls=$(read_env "EMAIL_USE_TLS")
smtp_ssl=$(read_env "EMAIL_USE_SSL") # smtp_ssl=$(read_env "EMAIL_USE_SSL")
SMTP_SETTINGS=$(dialog \ # SMTP_SETTINGS=$(dialog \
--ok-label "Next" \ # --ok-label "Next" \
--cancel-label "Skip" \ # --cancel-label "Skip" \
--backtitle "Plane Configuration" \ # --backtitle "Plane Configuration" \
--title "SMTP Settings" \ # --title "SMTP Settings" \
--form "" \ # --form "" \
0 0 0 \ # 0 0 0 \
"Host:" 1 1 "$smtp_host" 1 10 80 0 \ # "Host:" 1 1 "$smtp_host" 1 10 80 0 \
"User:" 2 1 "$smtp_user" 2 10 80 0 \ # "User:" 2 1 "$smtp_user" 2 10 80 0 \
"Password:" 3 1 "$smtp_password" 3 10 80 0 \ # "Password:" 3 1 "$smtp_password" 3 10 80 0 \
"Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \
"From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \ # "From:" 5 1 "${smtp_from:-Mailer <mailer@example.com>}" 5 10 80 0 \
"TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \
"SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \
2>&1 1>&3) # 2>&1 1>&3)
save_smtp_settings=0 # save_smtp_settings=0
if [ $? -eq 0 ]; then # if [ $? -eq 0 ]; then
save_smtp_settings=1 # save_smtp_settings=1
smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p)
smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p)
smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p)
smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p)
smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p)
smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p)
fi # fi
external_pgdb_url=$(dialog \ external_pgdb_url=$(dialog \
--backtitle "Plane Configuration" \ --backtitle "Plane Configuration" \
--title "Using External Postgres Database ?" \ --title "Using External Postgres Database ?" \
@ -383,15 +383,6 @@ function configure_plane() {
domain_name: $domain_name domain_name: $domain_name
upload_limit: $upload_limit upload_limit: $upload_limit
save_smtp_settings: $save_smtp_settings
smtp_host: $smtp_host
smtp_user: $smtp_user
smtp_password: $smtp_password
smtp_port: $smtp_port
smtp_from: $smtp_from
smtp_tls: $smtp_tls
smtp_ssl: $smtp_ssl
save_aws_settings: $save_aws_settings save_aws_settings: $save_aws_settings
aws_region: $aws_region aws_region: $aws_region
aws_access_key: $aws_access_key aws_access_key: $aws_access_key
@ -413,15 +404,15 @@ function configure_plane() {
fi fi
# check enable smpt settings value # check enable smpt settings value
if [ $save_smtp_settings == 1 ]; then # if [ $save_smtp_settings == 1 ]; then
update_env "EMAIL_HOST" "$smtp_host" # update_env "EMAIL_HOST" "$smtp_host"
update_env "EMAIL_HOST_USER" "$smtp_user" # update_env "EMAIL_HOST_USER" "$smtp_user"
update_env "EMAIL_HOST_PASSWORD" "$smtp_password" # update_env "EMAIL_HOST_PASSWORD" "$smtp_password"
update_env "EMAIL_PORT" "$smtp_port" # update_env "EMAIL_PORT" "$smtp_port"
update_env "EMAIL_FROM" "$smtp_from" # update_env "EMAIL_FROM" "$smtp_from"
update_env "EMAIL_USE_TLS" "$smtp_tls" # update_env "EMAIL_USE_TLS" "$smtp_tls"
update_env "EMAIL_USE_SSL" "$smtp_ssl" # update_env "EMAIL_USE_SSL" "$smtp_ssl"
fi # fi
# check enable aws settings value # check enable aws settings value
if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then
@ -493,13 +484,24 @@ function install() {
check_for_docker_images check_for_docker_images
last_installed_on=$(read_config "INSTALLATION_DATE") last_installed_on=$(read_config "INSTALLATION_DATE")
if [ "$last_installed_on" == "" ]; then # if [ "$last_installed_on" == "" ]; then
configure_plane # configure_plane
fi # fi
printUsageInstructions
update_config "INSTALLATION_DATE" "$(date)"
update_env "NGINX_PORT" "80"
update_env "DOMAIN_NAME" "$MY_IP"
update_env "WEB_URL" "http://$MY_IP"
update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP"
update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')"
if command -v crontab &> /dev/null; then
sudo touch /etc/cron.daily/makeplane
sudo chmod +x /etc/cron.daily/makeplane
sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane
sudo crontab /etc/cron.daily/makeplane
fi
show_message "Plane Installed Successfully ✅" show_message "Plane Installed Successfully ✅"
show_message "" show_message ""
else else
@ -539,12 +541,15 @@ function upgrade() {
prepare_environment prepare_environment
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
stop_server
download_plane download_plane
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
check_for_docker_images check_for_docker_images
upgrade_configuration upgrade_configuration
update_config "UPGRADE_DATE" "$(date)" update_config "UPGRADE_DATE" "$(date)"
start_server
show_message "" show_message ""
show_message "Plane Upgraded Successfully ✅" show_message "Plane Upgraded Successfully ✅"
show_message "" show_message ""
@ -601,6 +606,11 @@ function uninstall() {
sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
if command -v crontab &> /dev/null; then
sudo crontab -r &> /dev/null
sudo rm /etc/cron.daily/makeplane &> /dev/null
fi
# rm -rf $PLANE_INSTALL_DIR &> /dev/null # rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅" show_message "- Configuration Cleaned ✅"
@ -642,7 +652,39 @@ function start_server() {
while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do
sleep 1 sleep 1
done done
# wait for migrator container to exit with status 0 before starting the application
migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator")
# if migrator container is running, wait for it to exit
if [ -n "$migrator_container_id" ]; then
while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2
sleep 1
done
fi
# if migrator exit status is not 0, show error message and exit
if [ -n "$migrator_container_id" ]; then
migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id)
if [ $migrator_exit_code -ne 0 ]; then
# show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2
show_message "Plane Server failed to start ❌" "replace_last_line" >&2
stop_server
exit 1
fi
fi
api_container_id=$(sudo docker container ls -q -f "name=plane-api")
while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete";
do
show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2
sleep 1
done
show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
show_message "---------------------------------------------------------------" >&2
show_message "Access the Plane application at http://$MY_IP" >&2
show_message "---------------------------------------------------------------" >&2
else else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi fi
@ -694,7 +736,7 @@ function update_installer() {
show_message "Updating Plane Installer ✋" >&2 show_message "Updating Plane Installer ✋" >&2
sudo curl -H 'Cache-Control: no-cache, no-store' \ sudo curl -H 'Cache-Control: no-cache, no-store' \
-s -o /usr/local/bin/plane-app \ -s -o /usr/local/bin/plane-app \
https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s)
sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null
show_message "Plane Installer Updated ✅" "replace_last_line" >&2 show_message "Plane Installer Updated ✅" "replace_last_line" >&2
@ -711,12 +753,14 @@ fi
PLANE_INSTALL_DIR=/opt/plane PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log LOG_DIR=$PLANE_INSTALL_DIR/logs
CODE_REPO=${GIT_REPO:-makeplane/plane}
OS_SUPPORTED=false OS_SUPPORTED=false
CPU_ARCH=$(uname -m) CPU_ARCH=$(uname -m)
PROGRESS_MSG="" PROGRESS_MSG=""
USE_GLOBAL_IMAGES=0 USE_GLOBAL_IMAGES=0
PACKAGE_MANAGER="" PACKAGE_MANAGER=""
MY_IP=$(curl -s ifconfig.me)
if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then
USE_GLOBAL_IMAGES=1 USE_GLOBAL_IMAGES=1
@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then
restart_server restart_server
elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then
install install
start_server
show_message "" >&2
show_message "To view help, use plane-app --help " >&2
elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then
configure_plane configure_plane
printUsageInstructions printUsageInstructions

View File

@ -56,8 +56,6 @@ x-app-env : &app-env
- BUCKET_NAME=${BUCKET_NAME:-uploads} - BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services: services:
web: web:
<<: *app-env <<: *app-env
@ -138,7 +136,6 @@ services:
command: postgres -c 'max_connections=1000' command: postgres -c 'max_connections=1000'
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
plane-redis: plane-redis:
<<: *app-env <<: *app-env
image: redis:6.2.7-alpine image: redis:6.2.7-alpine

View File

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

View File

@ -1,6 +1,6 @@
{ {
"repository": "https://github.com/makeplane/plane.git", "repository": "https://github.com/makeplane/plane.git",
"version": "0.15.1", "version": "0.16.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-core", "name": "@plane/editor-core",
"version": "0.15.1", "version": "0.16.0",
"description": "Core Editor that powers Plane", "description": "Core Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/document-editor", "name": "@plane/document-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Pages Editor", "description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs", "main": "./dist/index.mjs",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/editor-extensions", "name": "@plane/editor-extensions",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Editor with extensions", "description": "Package that powers Plane's Editor with extensions",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/lite-text-editor", "name": "@plane/lite-text-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Package that powers Plane's Comment Editor", "description": "Package that powers Plane's Comment Editor",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/rich-text-editor", "name": "@plane/rich-text-editor",
"version": "0.15.1", "version": "0.16.0",
"description": "Rich Text Editor that powers Plane", "description": "Rich Text Editor that powers Plane",
"private": true, "private": true,
"main": "./dist/index.mjs", "main": "./dist/index.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "eslint-config-custom", "name": "eslint-config-custom",
"private": true, "private": true,
"version": "0.15.1", "version": "0.16.0",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "tailwind-config-custom", "name": "tailwind-config-custom",
"version": "0.15.1", "version": "0.16.0",
"description": "common tailwind configuration across monorepo", "description": "common tailwind configuration across monorepo",
"main": "index.js", "main": "index.js",
"private": true, "private": true,

View File

@ -1,6 +1,6 @@
{ {
"name": "tsconfig", "name": "tsconfig",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"files": [ "files": [
"base.json", "base.json",

View File

@ -1,6 +1,6 @@
{ {
"name": "@plane/types", "name": "@plane/types",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"main": "./src/index.d.ts" "main": "./src/index.d.ts"
} }

View File

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

View File

@ -2,7 +2,7 @@
"name": "@plane/ui", "name": "@plane/ui",
"description": "UI components shared across multiple apps internally", "description": "UI components shared across multiple apps internally",
"private": true, "private": true,
"version": "0.15.1", "version": "0.16.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "space", "name": "space",
"version": "0.15.1", "version": "0.16.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "turbo run develop", "dev": "turbo run develop",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const filterParams = getRedirectionFilters(selectedTab); const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
onChange={(val) => { onChange={(val) => {
if (val === selectedDurationFilter) return; if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") newTab = "pending";
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ duration: val }); handleUpdateFilters({
duration: val,
tab: newTab,
});
}} }}
/> />
</div> </div>
<Tab.Group <Tab.Group
as="div" as="div"
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} selectedIndex={selectedTabIndex}
onChange={(i) => { onChange={(i) => {
const selectedTab = tabsList[i]; const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
}} }}
className="h-full flex flex-col" className="h-full flex flex-col"
> >
@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div> </div>
<Tab.Panels as="div" className="h-full"> <Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => ( {tabsList.map((tab) => {
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col"> if (tab.key !== selectedTab) return null;
<WidgetIssuesList
issues={widgetStats.issues} return (
tab={tab.key} <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
totalIssues={widgetStats.count} <WidgetIssuesList
type="assigned" tab={tab.key}
workspaceSlug={workspaceSlug} type="assigned"
isLoading={fetching} workspaceSlug={workspaceSlug}
/> widgetStats={widgetStats}
</Tab.Panel> isLoading={fetching}
))} />
</Tab.Panel>
);
})}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
const filterParams = getRedirectionFilters(selectedTab); const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
onChange={(val) => { onChange={(val) => {
if (val === selectedDurationFilter) return; if (val === selectedDurationFilter) return;
let newTab = selectedTab;
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") newTab = "pending";
handleUpdateFilters({ duration: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming";
handleUpdateFilters({
duration: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ duration: val }); handleUpdateFilters({
duration: val,
tab: newTab,
});
}} }}
/> />
</div> </div>
<Tab.Group <Tab.Group
as="div" as="div"
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)} selectedIndex={selectedTabIndex}
onChange={(i) => { onChange={(i) => {
const selectedTab = tabsList[i]; const newSelectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
}} }}
className="h-full flex flex-col" className="h-full flex flex-col"
> >
@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div> </div>
<Tab.Panels as="div" className="h-full"> <Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => ( {tabsList.map((tab) => {
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col"> if (tab.key !== selectedTab) return null;
<WidgetIssuesList
issues={widgetStats.issues} return (
tab={tab.key} <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
totalIssues={widgetStats.count} <WidgetIssuesList
type="created" tab={tab.key}
workspaceSlug={workspaceSlug} type="created"
isLoading={fetching} workspaceSlug={workspaceSlug}
/> widgetStats={widgetStats}
</Tab.Panel> isLoading={fetching}
))} />
</Tab.Panel>
);
})}
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>
</div> </div>

View File

@ -19,19 +19,18 @@ import { Loader, getButtonStyling } from "@plane/ui";
import { cn } from "helpers/common.helper"; import { cn } from "helpers/common.helper";
import { getRedirectionFilters } from "helpers/dashboard.helper"; import { getRedirectionFilters } from "helpers/dashboard.helper";
// types // types
import { TIssue, TIssuesListTypes } from "@plane/types"; import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
export type WidgetIssuesListProps = { export type WidgetIssuesListProps = {
isLoading: boolean; isLoading: boolean;
issues: TIssue[];
tab: TIssuesListTypes; tab: TIssuesListTypes;
totalIssues: number;
type: "assigned" | "created"; type: "assigned" | "created";
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
workspaceSlug: string; workspaceSlug: string;
}; };
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => { export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
// store hooks // store hooks
const { setPeekIssue } = useIssueDetail(); const { setPeekIssue } = useIssueDetail();
@ -59,6 +58,8 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
}, },
}; };
const issuesList = widgetStats.issues;
return ( return (
<> <>
<div className="h-full"> <div className="h-full">
@ -69,7 +70,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
<Loader.Item height="25px" /> <Loader.Item height="25px" />
<Loader.Item height="25px" /> <Loader.Item height="25px" />
</Loader> </Loader>
) : issues.length > 0 ? ( ) : issuesList.length > 0 ? (
<> <>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1"> <div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
<h6 <h6
@ -80,7 +81,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
> >
Issues Issues
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center"> <span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center">
{totalIssues} {widgetStats.count}
</span> </span>
</h6> </h6>
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>} {["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
@ -89,7 +90,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
{type === "created" && <h6 className="text-center">Assigned to</h6>} {type === "created" && <h6 className="text-center">Assigned to</h6>}
</div> </div>
<div className="px-4 pb-3 mt-2"> <div className="px-4 pb-3 mt-2">
{issues.map((issue) => { {issuesList.map((issue) => {
const IssueListItem = ISSUE_LIST_ITEM[type][tab]; const IssueListItem = ISSUE_LIST_ITEM[type][tab];
if (!IssueListItem) return null; if (!IssueListItem) return null;
@ -112,7 +113,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
</div> </div>
)} )}
</div> </div>
{issues.length > 0 && ( {!isLoading && issuesList.length > 0 && (
<Link <Link
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`} href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
className={cn( className={cn(

View File

@ -1,269 +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();
};
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}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matches found</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

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

View File

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

View File

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

View File

@ -122,6 +122,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen(); if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: number | null) => { const dropdownOnChange = (val: number | null) => {
@ -137,6 +138,13 @@ export const EstimateDropdown: 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);
useEffect(() => { useEffect(() => {
@ -217,6 +225,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
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">

View File

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

View File

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

View File

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

View File

@ -1,253 +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();
};
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}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
});

View File

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

View File

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

View File

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

View File

@ -314,6 +314,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: TIssuePriorities) => { const dropdownOnChange = (val: TIssuePriorities) => {
@ -329,6 +330,13 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
toggleDropdown(); toggleDropdown();
}; };
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
useOutsideClickDetector(dropdownRef, handleClose); useOutsideClickDetector(dropdownRef, handleClose);
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
@ -417,6 +425,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
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">

View File

@ -104,6 +104,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!isOpen) onOpen(); if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen); setIsOpen((prevIsOpen) => !prevIsOpen);
if (isOpen) onClose && onClose();
}; };
const dropdownOnChange = (val: string) => { const dropdownOnChange = (val: string) => {
@ -119,6 +120,13 @@ export const StateDropdown: 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);
useEffect(() => { useEffect(() => {
@ -205,6 +213,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
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">

View File

@ -15,9 +15,9 @@ export const MonthChartView: FC<any> = observer(() => {
const monthBlocks: IMonthBlock[] = renderView; const monthBlocks: IMonthBlock[] = renderView;
return ( return (
<div className="absolute top-0 left-0 h-full w-max flex divide-x divide-custom-border-100/50"> <div className="absolute top-0 left-0 min-h-full h-max w-max flex divide-x divide-custom-border-100/50">
{monthBlocks?.map((block, rootIndex) => ( {monthBlocks?.map((block, rootIndex) => (
<div key={`month-${block?.month}-${block?.year}`} className="relative"> <div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
<div <div
className="w-full sticky top-0 z-[5] bg-custom-background-100" className="w-full sticky top-0 z-[5] bg-custom-background-100"
style={{ style={{

View File

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

View File

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

View File

@ -92,7 +92,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
id: inboxIssueId, id: inboxIssueId,
state: "SUCCESS", state: "SUCCESS",
element: "Inbox page", element: "Inbox page",
} },
}); });
router.push({ router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
@ -269,12 +269,17 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
<DayPicker <DayPicker
selected={date ? new Date(date) : undefined} selected={date ? new Date(date) : undefined}
defaultMonth={date ? new Date(date) : undefined} defaultMonth={date ? new Date(date) : undefined}
onSelect={(date) => { if (!date) return; setDate(date) }} onSelect={(date) => {
if (!date) return;
setDate(date);
}}
mode="single" mode="single"
className="border border-custom-border-200 rounded-md p-3" className="border border-custom-border-200 rounded-md p-3"
disabled={[{ disabled={[
before: tomorrow, {
}]} before: tomorrow,
},
]}
/> />
<Button <Button
variant="primary" variant="primary"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
} = useInboxIssues(); } = useInboxIssues();
const { const {
issue: { getIssueById }, issue: { getIssueById },
fetchActivities,
fetchComments,
} = useIssueDetail(); } = useIssueDetail();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -54,7 +56,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
showToast: boolean = true showToast: boolean = true
) => { ) => {
try { try {
const response = await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data);
if (showToast) { if (showToast) {
setToastAlert({ setToastAlert({
title: "Issue updated successfully", title: "Issue updated successfully",
@ -64,7 +66,7 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
} }
captureIssueEvent({ captureIssueEvent({
eventName: "Inbox issue updated", eventName: "Inbox issue updated",
payload: { ...response, state: "SUCCESS", element: "Inbox" }, payload: { ...data, state: "SUCCESS", element: "Inbox" },
updates: { updates: {
changed_property: Object.keys(data).join(","), changed_property: Object.keys(data).join(","),
change_details: Object.values(data).join(","), change_details: Object.values(data).join(","),
@ -125,6 +127,8 @@ export const InboxIssueDetailRoot: FC<TInboxIssueDetailRoot> = (props) => {
async () => { async () => {
if (workspaceSlug && projectId && inboxId && issueId) { if (workspaceSlug && projectId && inboxId && issueId) {
await issueOperations.fetch(workspaceSlug, projectId, issueId); await issueOperations.fetch(workspaceSlug, projectId, issueId);
await fetchActivities(workspaceSlug, projectId, issueId);
await fetchComments(workspaceSlug, projectId, issueId);
} }
} }
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,8 @@ import { TIssueComment } from "@plane/types";
// icons // icons
import { Globe2, Lock } from "lucide-react"; import { Globe2, Lock } from "lucide-react";
import { useMention, useWorkspace } from "hooks/store"; import { useMention, useWorkspace } from "hooks/store";
// helpers
import { isEmptyHtmlString } from "helpers/string.helper";
const fileService = new FileService(); const fileService = new FileService();
@ -51,10 +53,10 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const { const {
handleSubmit, handleSubmit,
control, control,
watch,
formState: { isSubmitting }, formState: { isSubmitting },
reset, reset,
watch, } = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "<p></p>" } });
} = useForm<Partial<TIssueComment>>({ defaultValues: { comment_html: "" } });
const onSubmit = async (formData: Partial<TIssueComment>) => { const onSubmit = async (formData: Partial<TIssueComment>) => {
await activityOperations.createComment(formData).finally(() => { await activityOperations.createComment(formData).finally(() => {
@ -63,14 +65,19 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
}); });
}; };
const isEmpty =
watch("comment_html") === "" ||
watch("comment_html")?.trim() === "" ||
watch("comment_html") === "<p></p>" ||
isEmptyHtmlString(watch("comment_html") ?? "");
return ( return (
<div <div
// onKeyDown={(e) => { onKeyDown={(e) => {
// if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey && !isEmpty) {
// e.preventDefault(); handleSubmit(onSubmit)(e);
// // handleSubmit(onSubmit)(e); }
// } }}
// }}
> >
<Controller <Controller
name="access" name="access"
@ -81,15 +88,12 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
control={control} control={control}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<LiteTextEditorWithRef <LiteTextEditorWithRef
onEnterKeyPress={(e) => {
handleSubmit(onSubmit)(e);
}}
cancelUploadImage={fileService.cancelUpload} cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.getDeleteImageFunction(workspaceId)} deleteFile={fileService.getDeleteImageFunction(workspaceId)}
restoreFile={fileService.getRestoreImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)}
ref={editorRef} ref={editorRef}
value={value ?? ""} value={!value ? "<p></p>" : value}
customClassName="p-2" customClassName="p-2"
editorContentCustomClassNames="min-h-[35px]" editorContentCustomClassNames="min-h-[35px]"
debouncedUpdatesEnabled={false} debouncedUpdatesEnabled={false}
@ -105,7 +109,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
} }
submitButton={ submitButton={
<Button <Button
disabled={isSubmitting || watch("comment_html") === ""} disabled={isSubmitting || isEmpty}
variant="primary" variant="primary"
type="submit" type="submit"
className="!px-2.5 !py-1.5 !text-xs" className="!px-2.5 !py-1.5 !text-xs"

View File

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

View File

@ -13,6 +13,8 @@ export type TIssueLabel = {
projectId: string; projectId: string;
issueId: string; issueId: string;
disabled: boolean; disabled: boolean;
isInboxIssue?: boolean;
onLabelUpdate?: (labelIds: string[]) => void;
}; };
export type TLabelOperations = { export type TLabelOperations = {
@ -21,7 +23,7 @@ export type TLabelOperations = {
}; };
export const IssueLabel: FC<TIssueLabel> = observer((props) => { export const IssueLabel: FC<TIssueLabel> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false } = props; const { workspaceSlug, projectId, issueId, disabled = false, isInboxIssue = false, onLabelUpdate } = props;
// hooks // hooks
const { updateIssue } = useIssueDetail(); const { updateIssue } = useIssueDetail();
const { createLabel } = useLabel(); const { createLabel } = useLabel();
@ -31,12 +33,14 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
() => ({ () => ({
updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => { updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial<TIssue>) => {
try { try {
await updateIssue(workspaceSlug, projectId, issueId, data); if (onLabelUpdate) onLabelUpdate(data.label_ids || []);
setToastAlert({ else await updateIssue(workspaceSlug, projectId, issueId, data);
title: "Issue updated successfully", if (!isInboxIssue)
type: "success", setToastAlert({
message: "Issue updated successfully", title: "Issue updated successfully",
}); type: "success",
message: "Issue updated successfully",
});
} catch (error) { } catch (error) {
setToastAlert({ setToastAlert({
title: "Issue update failed", title: "Issue update failed",
@ -48,11 +52,12 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => { createLabel: async (workspaceSlug: string, projectId: string, data: Partial<IIssueLabel>) => {
try { try {
const labelResponse = await createLabel(workspaceSlug, projectId, data); const labelResponse = await createLabel(workspaceSlug, projectId, data);
setToastAlert({ if (!isInboxIssue)
title: "Label created successfully", setToastAlert({
type: "success", title: "Label created successfully",
message: "Label created successfully", type: "success",
}); message: "Label created successfully",
});
return labelResponse; return labelResponse;
} catch (error) { } catch (error) {
setToastAlert({ setToastAlert({
@ -64,7 +69,7 @@ export const IssueLabel: FC<TIssueLabel> = observer((props) => {
} }
}, },
}), }),
[updateIssue, createLabel, setToastAlert] [updateIssue, createLabel, setToastAlert, onLabelUpdate]
); );
return ( return (

View File

@ -80,6 +80,13 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
</div> </div>
); );
const searchInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (query !== "" && e.key === "Escape") {
e.stopPropagation();
setQuery("");
}
};
if (!issue) return <></>; if (!issue) return <></>;
return ( return (
@ -118,6 +125,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
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> </div>

View File

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

View File

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

View File

@ -67,7 +67,7 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
> >
{loading ? ( {loading ? (
<span> <span>
<span className="hidden sm:block">Loading</span>... <span className="hidden sm:block">Loading...</span>
</span> </span>
) : isSubscribed ? ( ) : isSubscribed ? (
<div className="hidden sm:block">Unsubscribe</div> <div className="hidden sm:block">Unsubscribe</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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