diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 5d19be11c..3adaa4230 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -2,7 +2,7 @@ name: Bug report description: Create a bug report to help us improve Plane title: "[bug]: " labels: [πŸ›bug] -assignees: [srinivaspendem, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: @@ -45,7 +45,7 @@ body: - Deploy preview validations: required: true - type: dropdown +- type: dropdown id: browser attributes: label: Browser diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index 941fbef87..ff9cdd238 100644 --- a/.github/ISSUE_TEMPLATE/--feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/--feature-request.yaml @@ -2,7 +2,7 @@ name: Feature request description: Suggest a feature to improve Plane title: "[feature]: " labels: [✨feature] -assignees: [srinivaspendem, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 0d3f97068..44bae0efa 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -23,6 +23,10 @@ jobs: gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} 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: - id: set_env_variables @@ -41,7 +45,36 @@ jobs: fi 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: + 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 needs: [branch_build_setup] env: @@ -55,9 +88,9 @@ jobs: - name: Set Frontend Docker Tag run: | 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 - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest else TAG=${{ env.FRONTEND_TAG }} fi @@ -77,7 +110,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Frontend to Docker Container Registry uses: docker/build-push-action@v5.1.0 @@ -93,6 +126,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} 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 needs: [branch_build_setup] env: @@ -106,9 +140,9 @@ jobs: - name: Set Space Docker Tag run: | 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 - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest else TAG=${{ env.SPACE_TAG }} fi @@ -128,7 +162,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Space to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -144,6 +178,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} 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 needs: [branch_build_setup] env: @@ -157,9 +192,9 @@ jobs: - name: Set Backend Docker Tag run: | 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 - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest else TAG=${{ env.BACKEND_TAG }} fi @@ -179,7 +214,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Backend to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -194,8 +229,8 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - 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 needs: [branch_build_setup] env: @@ -209,9 +244,9 @@ jobs: - name: Set Proxy Docker Tag run: | 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 - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest else TAG=${{ env.PROXY_TAG }} fi @@ -231,7 +266,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Plane-Proxy to Docker Hub uses: docker/build-push-action@v5.1.0 diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 47a85f3ba..8644f04f0 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -2,7 +2,7 @@ name: Create Sync Action on: workflow_dispatch: - push: + push: branches: - preview @@ -17,7 +17,7 @@ jobs: contents: read steps: - name: Checkout Code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.1 with: persist-credentials: false fetch-depth: 0 @@ -31,14 +31,25 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo + - name: Push Changes to Target Repo A env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | - TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" - TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" + TARGET_REPO="${{ secrets.TARGET_REPO_A }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH - git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + 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 diff --git a/apiserver/package.json b/apiserver/package.json index fb4f8441d..060944406 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.15.1" + "version": "0.16.0" } diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 6f66c373e..84931f46b 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return ( Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) .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("workspace") .select_related("owned_by") @@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): ) .filter(workspace__slug=self.kwargs.get("slug")) .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")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 50269fe07..0905ae1f7 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView): return ( Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .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("workspace") .select_related("parent") @@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView): IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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")) .distinct() ) @@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .select_related("actor") + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("workspace", "project", "issue", "actor") .annotate( is_member=Exists( ProjectMember.objects.filter( @@ -784,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index d509a53c7..2e5bb85e2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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("workspace") .select_related("module") diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index dedc15ccd..ec10f9bab 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -24,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView): return ( State.objects.filter(workspace__slug=self.kwargs.get("slug")) .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")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 7d661b49e..4ee70450b 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -259,23 +259,15 @@ urlpatterns = [ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", + name="project-issue-archive-unarchive", ), ## End Issue Archives ## Issue Relation diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 29b4bbf8b..b2a27252c 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView): }, { "key": "SLACK_CLIENT_ID", - "default": os.environ.get("SLACK_CLIENT_ID", "1"), + "default": os.environ.get("SLACK_CLIENT_ID", None), }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", @@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView): }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 866396655..85e1e9f2e 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -85,7 +85,10 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .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") .prefetch_related( Prefetch( @@ -689,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) .filter(workspace__slug=self.kwargs.get("slug")) .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")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index d70eec4f2..ed32a14fe 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -33,6 +33,7 @@ from plane.app.serializers import ( IssueSerializer, InboxSerializer, InboxIssueSerializer, + IssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters from plane.bgtasks.issue_activites_task import issue_activity @@ -426,11 +427,10 @@ class InboxIssueViewSet(BaseViewSet): ) ) ).first() - if issue is None: 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) def destroy(self, request, slug, project_id, inbox_id, issue_id): diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py index 410e6b332..c22ee3e52 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), 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): diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 25c42dc5b..14e0b6a9a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -773,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): def get(self, request, slug): issues = ( 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") ) serializer = IssueSerializer(issues, many=True) @@ -796,6 +799,7 @@ class IssueActivityEndpoint(BaseAPIView): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -805,6 +809,7 @@ class IssueActivityEndpoint(BaseAPIView): IssueComment.objects.filter(issue_id=issue_id) .filter( project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -856,7 +861,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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("workspace") .select_related("issue") @@ -1018,7 +1026,10 @@ class LabelViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .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("workspace") .select_related("parent") @@ -1231,7 +1242,10 @@ class IssueLinkViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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") .distinct() ) @@ -1633,6 +1647,36 @@ class IssueArchiveViewSet(BaseViewSet): serializer = IssueDetailSerializer(issue, expand=self.expand) 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): issue = Issue.objects.get( workspace__slug=slug, @@ -1656,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet): issue.archived_at = None issue.save() - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) class IssueSubscriberViewSet(BaseViewSet): @@ -1692,7 +1736,10 @@ class IssueSubscriberViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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") .distinct() ) @@ -1776,7 +1823,10 @@ class IssueReactionViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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") .distinct() ) @@ -1845,7 +1895,10 @@ class CommentReactionViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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") .distinct() ) @@ -1915,7 +1968,10 @@ class IssueRelationViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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("workspace") .select_related("issue") @@ -2310,17 +2366,10 @@ class IssueDraftViewSet(BaseViewSet): 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 request.data.get( - "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() + serializer.save() issue_activity.delay( type="issue_draft.activity.updated", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 5ac244dda..3b52db64f 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -673,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_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") .distinct() ) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1d8ff1fbb..7ecf22fa8 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .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(Q(owned_by=self.request.user) | Q(access=0)) .select_related("project") diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 5d2f95673..6f9b2618e 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ] 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( super() .get_queryset() @@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) ) ) + .annotate(sort_order=Subquery(sort_order)) .prefetch_related( Prefetch( "project_projectmember", @@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): for field in request.GET.get("fields", "").split(",") 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 = ( self.get_queryset() - .annotate(sort_order=Subquery(sort_order_query)) .order_by("sort_order", "name") ) if request.GET.get("per_page", False) and request.GET.get( @@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): serializer.save() # Add the user as Administrator to the project - project_member = ProjectMember.objects.create( + _ = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20, diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index ccef3d18f..a2ed1c015 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView): return ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, workspace__slug=slug, ) .distinct() @@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView): issues = Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView): cycles = Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView): modules = Module.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView): pages = Page.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView): issue_views = IssueView.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView): issues = Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) if workspace_search == "false": diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 242061e18..34b3d1dcc 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .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")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 97a0f036f..ade445fae 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -86,6 +86,10 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .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") .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) @@ -163,7 +167,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) ) @@ -284,7 +287,10 @@ class IssueViewViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .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("workspace") .annotate(is_favorite=Exists(subquery)) diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 6677b4c4b..47de86a1c 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -1086,6 +1086,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .annotate(state_group=F("state__group")) @@ -1101,6 +1102,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .values("priority") @@ -1123,6 +1125,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, created_by_id=user_id, ) .filter(**filters) @@ -1134,6 +1137,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1145,6 +1149,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1156,6 +1161,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): assignees__in=[user_id], state__group="completed", project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1166,6 +1172,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, subscriber_id=user_id, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1215,6 +1222,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, actor=user_id, ).select_related("actor", "workspace", "issue", "project") @@ -1355,6 +1363,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): | Q(issue_subscribers__subscriber_id=user_id), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .select_related("workspace", "project", "state", "parent") @@ -1486,6 +1495,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView): labels = Label.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = LabelSerializer(labels, many=True).data return Response(serializer, status=status.HTTP_200_OK) @@ -1500,6 +1510,7 @@ class WorkspaceStatesEndpoint(BaseAPIView): states = State.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index b99e4b1d9..d8522e769 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -292,6 +292,7 @@ def issue_export_task( workspace__id=workspace_id, project_id__in=project_ids, project__project_projectmember__member=exporter_instance.initiated_by_id, + project__project_projectmember__is_active=True ) .select_related( "project", "workspace", "state", "parent", "created_by" diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b86ab5e78..2a16ee911 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -483,17 +483,23 @@ def track_archive_at( ) ) 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( IssueActivity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment="Plane has archived the issue", + comment=comment, verb="updated", actor_id=actor_id, field="archived_at", old_value=None, - new_value="archive", + new_value=new_value, epoch=epoch, ) ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 974a545fc..c6c4d7515 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -79,7 +79,7 @@ def archive_old_issues(): issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps( - {"archived_at": str(archive_at)} + {"archived_at": str(archive_at), "automation": True} ), actor_id=str(project.created_by_id), issue_id=issue.id, diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index d38b1f4c3..3b6dea332 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -9,11 +9,11 @@ from plane.db.models import Issue def search_issues(query, queryset): - fields = ["name", "sequence_id"] + fields = ["name", "sequence_id", "project__identifier"] q = Q() for field in fields: 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: q |= Q(**{"sequence_id": sequence_id}) else: diff --git a/deploy/1-click/README.md b/deploy/1-click/README.md new file mode 100644 index 000000000..88ea66c4c --- /dev/null +++ b/deploy/1-click/README.md @@ -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 - + +``` + +
+ Downloading Preview Release + +``` +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 +
+ +-- + + +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) + +Basic Operations: +1. Start Server using `plane-app start` +1. Stop Server using `plane-app stop` +1. Restart Server using `plane-app restart` + +Advanced Operations: +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`. + +Application Data is stored in the mentioned folders: +1. DB Data: /opt/plane/data/postgres +1. Redis Data: /opt/plane/data/redis +1. Minio Data: /opt/plane/data/minio \ No newline at end of file diff --git a/deploy/1-click/images/help.png b/deploy/1-click/images/help.png new file mode 100644 index 000000000..c14603a4b Binary files /dev/null and b/deploy/1-click/images/help.png differ diff --git a/deploy/1-click/images/install.png b/deploy/1-click/images/install.png new file mode 100644 index 000000000..c8ba1e5f8 Binary files /dev/null and b/deploy/1-click/images/install.png differ diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index 917d08fdf..9a0eac902 100644 --- a/deploy/1-click/install.sh +++ b/deploy/1-click/install.sh @@ -1,17 +1,20 @@ #!/bin/bash +export GIT_REPO=makeplane/plane + # Check if the user has sudo access if command -v curl &> /dev/null; then sudo curl -sSL \ -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 sudo wget -q \ -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 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 diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index 2d6ef0a6f..e6bd24b9e 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -90,9 +90,9 @@ function prepare_environment() { show_message "- Updating OS with required tools βœ‹" >&2 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 if ! command -v $tool &> /dev/null; then @@ -150,11 +150,11 @@ function download_plane() { show_message "Downloading Plane Setup Files βœ‹" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -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' \ -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 [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then @@ -202,7 +202,7 @@ function printUsageInstructions() { } function build_local_image() { show_message "- Downloading Plane Source Code βœ‹" >&2 - REPO=https://github.com/makeplane/plane.git + REPO=https://github.com/$CODE_REPO.git CURR_DIR=$PWD PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null @@ -290,40 +290,40 @@ function configure_plane() { fi - smtp_host=$(read_env "EMAIL_HOST") - smtp_user=$(read_env "EMAIL_HOST_USER") - smtp_password=$(read_env "EMAIL_HOST_PASSWORD") - smtp_port=$(read_env "EMAIL_PORT") - smtp_from=$(read_env "EMAIL_FROM") - smtp_tls=$(read_env "EMAIL_USE_TLS") - smtp_ssl=$(read_env "EMAIL_USE_SSL") + # smtp_host=$(read_env "EMAIL_HOST") + # smtp_user=$(read_env "EMAIL_HOST_USER") + # smtp_password=$(read_env "EMAIL_HOST_PASSWORD") + # smtp_port=$(read_env "EMAIL_PORT") + # smtp_from=$(read_env "EMAIL_FROM") + # smtp_tls=$(read_env "EMAIL_USE_TLS") + # smtp_ssl=$(read_env "EMAIL_USE_SSL") - SMTP_SETTINGS=$(dialog \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --backtitle "Plane Configuration" \ - --title "SMTP Settings" \ - --form "" \ - 0 0 0 \ - "Host:" 1 1 "$smtp_host" 1 10 80 0 \ - "User:" 2 1 "$smtp_user" 2 10 80 0 \ - "Password:" 3 1 "$smtp_password" 3 10 80 0 \ - "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ - "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ - "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ - "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ - 2>&1 1>&3) + # SMTP_SETTINGS=$(dialog \ + # --ok-label "Next" \ + # --cancel-label "Skip" \ + # --backtitle "Plane Configuration" \ + # --title "SMTP Settings" \ + # --form "" \ + # 0 0 0 \ + # "Host:" 1 1 "$smtp_host" 1 10 80 0 \ + # "User:" 2 1 "$smtp_user" 2 10 80 0 \ + # "Password:" 3 1 "$smtp_password" 3 10 80 0 \ + # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ + # "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ + # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ + # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ + # 2>&1 1>&3) - save_smtp_settings=0 - if [ $? -eq 0 ]; then - save_smtp_settings=1 - smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) - smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) - smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) - smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) - smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) - smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) - fi + # save_smtp_settings=0 + # if [ $? -eq 0 ]; then + # save_smtp_settings=1 + # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) + # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) + # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) + # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) + # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) + # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) + # fi external_pgdb_url=$(dialog \ --backtitle "Plane Configuration" \ --title "Using External Postgres Database ?" \ @@ -383,15 +383,6 @@ function configure_plane() { domain_name: $domain_name 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 aws_region: $aws_region aws_access_key: $aws_access_key @@ -413,15 +404,15 @@ function configure_plane() { fi # check enable smpt settings value - if [ $save_smtp_settings == 1 ]; then - update_env "EMAIL_HOST" "$smtp_host" - update_env "EMAIL_HOST_USER" "$smtp_user" - update_env "EMAIL_HOST_PASSWORD" "$smtp_password" - update_env "EMAIL_PORT" "$smtp_port" - update_env "EMAIL_FROM" "$smtp_from" - update_env "EMAIL_USE_TLS" "$smtp_tls" - update_env "EMAIL_USE_SSL" "$smtp_ssl" - fi + # if [ $save_smtp_settings == 1 ]; then + # update_env "EMAIL_HOST" "$smtp_host" + # update_env "EMAIL_HOST_USER" "$smtp_user" + # update_env "EMAIL_HOST_PASSWORD" "$smtp_password" + # update_env "EMAIL_PORT" "$smtp_port" + # update_env "EMAIL_FROM" "$smtp_from" + # update_env "EMAIL_USE_TLS" "$smtp_tls" + # update_env "EMAIL_USE_SSL" "$smtp_ssl" + # fi # check enable aws settings value if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then @@ -493,13 +484,24 @@ function install() { check_for_docker_images last_installed_on=$(read_config "INSTALLATION_DATE") - if [ "$last_installed_on" == "" ]; then - configure_plane - fi - printUsageInstructions - - update_config "INSTALLATION_DATE" "$(date)" + # if [ "$last_installed_on" == "" ]; then + # configure_plane + # fi + 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 "" else @@ -539,12 +541,15 @@ function upgrade() { prepare_environment if [ $? -eq 0 ]; then + stop_server download_plane if [ $? -eq 0 ]; then check_for_docker_images upgrade_configuration update_config "UPGRADE_DATE" "$(date)" - + + start_server + show_message "" show_message "Plane Upgraded Successfully βœ…" show_message "" @@ -601,6 +606,11 @@ function uninstall() { sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /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 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 sleep 1 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 "---------------------------------------------------------------" >&2 + show_message "Access the Plane application at http://$MY_IP" >&2 + show_message "---------------------------------------------------------------" >&2 + else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -694,7 +736,7 @@ function update_installer() { show_message "Updating Plane Installer βœ‹" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -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 show_message "Plane Installer Updated βœ…" "replace_last_line" >&2 @@ -711,12 +753,14 @@ fi PLANE_INSTALL_DIR=/opt/plane 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 CPU_ARCH=$(uname -m) PROGRESS_MSG="" USE_GLOBAL_IMAGES=0 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 USE_GLOBAL_IMAGES=1 @@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then restart_server elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then install + start_server + show_message "" >&2 + show_message "To view help, use plane-app --help " >&2 elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then configure_plane printUsageInstructions diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 60861878c..07e5ea9f6 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -56,8 +56,6 @@ x-app-env : &app-env - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - - services: web: <<: *app-env @@ -138,7 +136,6 @@ services: command: postgres -c 'max_connections=1000' volumes: - pgdata:/var/lib/postgresql/data - plane-redis: <<: *app-env image: redis:6.2.7-alpine diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 30f2d15d7..16b6ea7c3 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -13,6 +13,23 @@ YELLOW='\033[1;33m' GREEN='\033[0;32m' NC='\033[0m' # No Color +function print_header() { +clear + +cat <<"EOF" +--------------------------------------- + ____ _ +| _ \| | __ _ _ __ ___ +| |_) | |/ _` | '_ \ / _ \ +| __/| | (_| | | | | __/ +|_| |_|\__,_|_| |_|\___| + +--------------------------------------- +Project management tool from the future +--------------------------------------- +EOF +} + function buildLocalImage() { if [ "$1" == "--force-build" ]; then DO_BUILD="1" @@ -110,7 +127,7 @@ function download() { exit 0 fi else - docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull fi echo "" @@ -121,19 +138,48 @@ function download() { } function startServices() { - cd $PLANE_INSTALL_DIR - docker compose up -d --quiet-pull - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull + + 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() { - cd $PLANE_INSTALL_DIR - docker compose down - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down } function restartServices() { - cd $PLANE_INSTALL_DIR - docker compose restart - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart } function upgrade() { echo "***** STOPPING SERVICES ****" @@ -144,47 +190,137 @@ function upgrade() { download 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() { - echo - echo "Select a Action you want to perform:" - echo " 1) Install (${CPU_ARCH})" - echo " 2) Start" - echo " 3) Stop" - echo " 4) Restart" - echo " 5) Upgrade" - echo " 6) Exit" - echo - read -p "Action [2]: " ACTION - until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do - echo "$ACTION: invalid selection." + local DEFAULT_ACTION=$1 + + if [ -z "$DEFAULT_ACTION" ]; + then + echo + echo "Select a Action you want to perform:" + echo " 1) Install (${CPU_ARCH})" + echo " 2) Start" + echo " 3) Stop" + echo " 4) Restart" + echo " 5) Upgrade" + echo " 6) View Logs" + echo " 7) Exit" + echo read -p "Action [2]: " ACTION - done - echo + until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do + 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 install askForAction - elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ] + elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ] then startServices askForAction - elif [ "$ACTION" == "3" ] + elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ] then stopServices askForAction - elif [ "$ACTION" == "4" ] + elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ] then restartServices askForAction - elif [ "$ACTION" == "5" ] + elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ] then upgrade askForAction - elif [ "$ACTION" == "6" ] + elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ] + then + viewLogs $@ + askForAction + elif [ "$ACTION" == "7" ] then exit 0 else @@ -217,4 +353,8 @@ then fi 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 $@ diff --git a/package.json b/package.json index 762ce322a..9239a9b41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.15.1", + "version": "0.16.0", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 7f7f4831a..fcb6b57bb 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.15.1", + "version": "0.16.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index b33bc12fb..bd1f2d90f 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 8481abdf3..0bdd70824 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 71d70399d..e033f620a 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index a85a8b998..0f3d0d8f7 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 6bfe67261..2fee408c9 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,7 +1,7 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.15.1", + "version": "0.16.0", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 50ede8674..d7e807b91 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.15.1", + "version": "0.16.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 42ce3fed5..e0829e87b 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.15.1", + "version": "0.16.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 0e5c2eb16..9c9938845 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.15.1", + "version": "0.16.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 8033c19a9..652e2776f 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -12,27 +12,27 @@ export interface PaginatedUserNotification { } export interface IUserNotification { - id: string; - created_at: Date; - updated_at: Date; + archived_at: string | null; + created_at: string; + created_by: null; data: Data; entity_identifier: string; entity_name: string; - title: string; + id: string; message: null; message_html: string; 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; + read_at: Date | null; + receiver: string; + sender: string; + snoozed_till: Date | null; + title: string; triggered_by: string; triggered_by_details: IUserLite; - receiver: string; + updated_at: Date; + updated_by: null; + workspace: string; } export interface Data { diff --git a/packages/ui/package.json b/packages/ui/package.json index 912fcfeb8..756a0f2f1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@plane/ui", "description": "UI components shared across multiple apps internally", "private": true, - "version": "0.15.1", + "version": "0.16.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 37aba932a..cdfccbb4e 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -177,17 +177,18 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { }; const MenuItem: React.FC = (props) => { - const { children, onClick, className = "" } = props; + const { children, disabled = false, onClick, className } = props; return ( - + {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 930f332b9..f600499fe 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -64,6 +64,7 @@ export type ICustomSearchSelectProps = IDropdownProps & export interface ICustomMenuItemProps { children: React.ReactNode; + disabled?: boolean; onClick?: (args?: any) => void; className?: string; } diff --git a/space/package.json b/space/package.json index 9ee7279cd..a1d600a60 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.15.1", + "version": "0.16.0", "private": true, "scripts": { "dev": "turbo run develop", diff --git a/web/components/automation/auto-archive-automation.tsx b/web/components/automation/auto-archive-automation.tsx index 974efff3a..d871b64d0 100644 --- a/web/components/automation/auto-archive-automation.tsx +++ b/web/components/automation/auto-archive-automation.tsx @@ -48,7 +48,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => {

Auto-archive closed issues

- Plane will auto archive issues that have been completed or cancelled. + Plane will auto archive issues that have been completed or canceled.

@@ -73,7 +73,7 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { { handleChange({ archive_in: val }); @@ -93,7 +93,7 @@ export const AutoArchiveAutomation: React.FC = 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" onClick={() => setmonthModal(true)} > - Customise Time Range + Customize time range diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 8d6662c11..2ae4d1f9c 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => {

Auto-close issues

- Plane will automatically close issue that haven{"'"}t been completed or cancelled. + Plane will automatically close issue that haven{"'"}t been completed or canceled.

@@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { { handleChange({ close_in: val }); @@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC = 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" onClick={() => setmonthModal(true)} > - Customize Time Range + Customize time range diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index 1d306bb04..01d07f64a 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen,
- Customise Time Range + Customize time range
diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index bd489f4c4..b52976aa8 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
-
- - -
- { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - onKeyDown={(e) => { - // when search is empty and page is undefined - // when user tries to close the modal with esc - if (e.key === "Escape" && !page && !searchTerm) closePalette(); +
+
+ + +
+ { + if (value.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + onKeyDown={(e) => { + // when search is empty and page is undefined + // when user tries to close the modal with esc + if (e.key === "Escape" && !page && !searchTerm) closePalette(); - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { - e.preventDefault(); - setPages((pages) => pages.slice(0, -1)); - setPlaceholder("Type a command or search..."); - } - }} - > -
pages.slice(0, -1)); + setPlaceholder("Type a command or search..."); + } + }} > - {issueDetails && ( -
- {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name} -
- )} - {projectId && ( - -
- - setIsWorkspaceLevel((prevData) => !prevData)} - /> +
+ {issueDetails && ( +
+ {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
- - )} -
-
-
+ )} + {projectId && ( + +
+ + setIsWorkspaceLevel((prevData) => !prevData)} + /> +
+
+ )} +
+
+
- - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
No results found.
- )} + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
No results found.
+ )} - {(isLoading || isSearching) && ( - - - - - - - - - )} + {(isLoading || isSearching) && ( + + + + + + + + + )} - {debouncedSearchTerm !== "" && ( - - )} + {debouncedSearchTerm !== "" && ( + + )} - {!page && ( - <> - {/* issue actions */} - {issueId && ( - setPages(newPages)} - setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} - setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} - /> - )} - - { - closePalette(); - setTrackElement("Command Palette"); - toggleCreateIssueModal(true); - }} - className="focus:bg-custom-background-80" - > -
- - Create new issue -
- C -
-
- - {workspaceSlug && ( - + {!page && ( + <> + {/* issue actions */} + {issueId && ( + setPages(newPages)} + setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} + setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} + /> + )} + { closePalette(); - setTrackElement("Command palette"); - toggleCreateProjectModal(true); + setTrackElement("Command Palette"); + toggleCreateIssueModal(true); + }} + className="focus:bg-custom-background-80" + > +
+ + Create new issue +
+ C +
+
+ + {workspaceSlug && ( + + { + closePalette(); + setTrackElement("Command palette"); + toggleCreateProjectModal(true); + }} + className="focus:outline-none" + > +
+ + Create new project +
+ P +
+
+ )} + + {/* project actions */} + {projectId && } + + + { + setPlaceholder("Search workspace settings..."); + setSearchTerm(""); + setPages([...pages, "settings"]); }} className="focus:outline-none" >
- - Create new project + + Search settings... +
+
+
+ + +
+ + Create new workspace +
+
+ { + setPlaceholder("Change interface theme..."); + setSearchTerm(""); + setPages([...pages, "change-interface-theme"]); + }} + className="focus:outline-none" + > +
+ + Change interface theme...
- P
- )} - {/* project actions */} - {projectId && } + {/* help options */} + + + )} - - { - setPlaceholder("Search workspace settings..."); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings... -
-
-
- - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme..."); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme... -
-
-
+ {/* workspace settings actions */} + {page === "settings" && workspaceSlug && ( + + )} - {/* help options */} - - - )} + {/* issue details page actions */} + {page === "change-issue-state" && issueDetails && ( + + )} + {page === "change-issue-priority" && issueDetails && ( + + )} + {page === "change-issue-assignee" && issueDetails && ( + + )} - {/* workspace settings actions */} - {page === "settings" && workspaceSlug && ( - - )} - - {/* issue details page actions */} - {page === "change-issue-state" && issueDetails && ( - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} - - {/* theme actions */} - {page === "change-interface-theme" && ( - { - closePalette(); - setPages((pages) => pages.slice(0, -1)); - }} - /> - )} -
- -
- - + {/* theme actions */} + {page === "change-interface-theme" && ( + { + closePalette(); + setPages((pages) => pages.slice(0, -1)); + }} + /> + )} + +
+
+
+
+
diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index f5eab83ef..39be2872b 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -49,8 +49,10 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { const [query, setQuery] = useState(""); // fetching project issues. const { data: issues } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null + workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, + workspaceSlug && projectId && isOpen + ? () => issueService.getIssues(workspaceSlug as string, projectId as string) + : null ); const { setToastAlert } = useToast(); diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index ed6bac324..7c8fbd2a9 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -67,6 +67,7 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -84,30 +85,25 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { onChange={(val) => { if (val === selectedDurationFilter) return; + let newTab = selectedTab; // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } + if (val === "none" && selectedTab !== "completed") newTab = "pending"; // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { - handleUpdateFilters({ - duration: val, - tab: "upcoming", - }); - return; - } + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - handleUpdateFilters({ duration: val }); + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -115,18 +111,21 @@ export const AssignedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index 4ef5708c8..e7832883b 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -64,6 +64,7 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -81,30 +82,25 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { onChange={(val) => { if (val === selectedDurationFilter) return; + let newTab = selectedTab; // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } + if (val === "none" && selectedTab !== "completed") newTab = "pending"; // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { - handleUpdateFilters({ - duration: val, - tab: "upcoming", - }); - return; - } + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - handleUpdateFilters({ duration: val }); + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -112,18 +108,21 @@ export const CreatedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/issue-panels/issues-list.tsx b/web/components/dashboard/widgets/issue-panels/issues-list.tsx index 3f1250d4d..16b2b95d9 100644 --- a/web/components/dashboard/widgets/issue-panels/issues-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -19,19 +19,18 @@ import { Loader, getButtonStyling } from "@plane/ui"; import { cn } from "helpers/common.helper"; import { getRedirectionFilters } from "helpers/dashboard.helper"; // types -import { TIssue, TIssuesListTypes } from "@plane/types"; +import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types"; export type WidgetIssuesListProps = { isLoading: boolean; - issues: TIssue[]; tab: TIssuesListTypes; - totalIssues: number; type: "assigned" | "created"; + widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse; workspaceSlug: string; }; export const WidgetIssuesList: React.FC = (props) => { - const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; + const { isLoading, tab, type, widgetStats, workspaceSlug } = props; // store hooks const { setPeekIssue } = useIssueDetail(); @@ -59,6 +58,8 @@ export const WidgetIssuesList: React.FC = (props) => { }, }; + const issuesList = widgetStats.issues; + return ( <>
@@ -69,7 +70,7 @@ export const WidgetIssuesList: React.FC = (props) => { - ) : issues.length > 0 ? ( + ) : issuesList.length > 0 ? ( <>
= (props) => { > Issues - {totalIssues} + {widgetStats.count}
{["upcoming", "pending"].includes(tab) &&
Due date
} @@ -89,7 +90,7 @@ export const WidgetIssuesList: React.FC = (props) => { {type === "created" &&
Assigned to
}
- {issues.map((issue) => { + {issuesList.map((issue) => { const IssueListItem = ISSUE_LIST_ITEM[type][tab]; if (!IssueListItem) return null; @@ -112,7 +113,7 @@ export const WidgetIssuesList: React.FC = (props) => {
)}
- {issues.length > 0 && ( + {!isLoading && issuesList.length > 0 && ( void; - onClose?: () => void; - projectId: string; - value: string | null; -}; - -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - -export const CycleDropdown: React.FC = 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(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(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: ( -
- - {cycleDetails?.name} -
- ), - }; - }); - options?.unshift({ - value: null, - query: "No cycle", - content: ( -
- - No cycle -
- ), - }); - - 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) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `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 }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matches found

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/cycle/cycle-options.tsx b/web/components/dropdowns/cycle/cycle-options.tsx new file mode 100644 index 000000000..e691569b7 --- /dev/null +++ b/web/components/dropdowns/cycle/cycle-options.tsx @@ -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(null); + const inputRef = useRef(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) => { + 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: ( +
+ + {cycleDetails?.name} +
+ ), + }; + }); + options?.unshift({ + value: null, + query: "No cycle", + content: ( +
+ + No cycle +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `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 }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/cycle/index.tsx b/web/components/dropdowns/cycle/index.tsx new file mode 100644 index 000000000..465eb3e2a --- /dev/null +++ b/web/components/dropdowns/cycle/index.tsx @@ -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 = 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(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(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) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index 04c7d6948..570ea45da 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -86,6 +86,7 @@ export const DateDropdown: React.FC = (props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: Date | null) => { @@ -146,7 +147,7 @@ export const DateDropdown: React.FC = (props) => { {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {value ? renderFormattedDate(value) : placeholder} )} - {isClearable && isDateSelected && ( + {isClearable && !disabled && isDateSelected && ( { diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx index 2674fa902..663ca67ce 100644 --- a/web/components/dropdowns/estimate.tsx +++ b/web/components/dropdowns/estimate.tsx @@ -122,6 +122,7 @@ export const EstimateDropdown: React.FC = observer((props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: number | null) => { @@ -137,6 +138,13 @@ export const EstimateDropdown: React.FC = observer((props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); useEffect(() => { @@ -217,6 +225,7 @@ export const EstimateDropdown: React.FC = observer((props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/dropdowns/member/index.ts b/web/components/dropdowns/member/index.ts deleted file mode 100644 index a9f7e09c8..000000000 --- a/web/components/dropdowns/member/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./project-member"; -export * from "./workspace-member"; diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx new file mode 100644 index 000000000..332f2227a --- /dev/null +++ b/web/components/dropdowns/member/index.tsx @@ -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 = 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(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(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) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/member/member-options.tsx b/web/components/dropdowns/member/member-options.tsx new file mode 100644 index 000000000..46a0b9cba --- /dev/null +++ b/web/components/dropdowns/member/member-options.tsx @@ -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(null); + const inputRef = useRef(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) => { + 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: ( +
+ + {currentUser?.id === userId ? "You" : userDetails?.display_name} +
+ ), + }; + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `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 }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/member/project-member.tsx b/web/components/dropdowns/member/project-member.tsx deleted file mode 100644 index 0e2479849..000000000 --- a/web/components/dropdowns/member/project-member.tsx +++ /dev/null @@ -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 = 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(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(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: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - 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) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `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 }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/member/workspace-member.tsx b/web/components/dropdowns/member/workspace-member.tsx deleted file mode 100644 index e7a679750..000000000 --- a/web/components/dropdowns/member/workspace-member.tsx +++ /dev/null @@ -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 = 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(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(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: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - 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) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `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 }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/module.tsx b/web/components/dropdowns/module/index.tsx similarity index 63% rename from web/components/dropdowns/module.tsx rename to web/components/dropdowns/module/index.tsx index f41ece121..5e0a3977f 100644 --- a/web/components/dropdowns/module.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -1,22 +1,22 @@ 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, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; // hooks -import { useApplication, useModule } from "hooks/store"; +import { useModule } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { DropdownButton } from "./buttons"; +import { DropdownButton } from "../buttons"; // icons import { DiceIcon, Tooltip } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; // types -import { TDropdownProps } from "./types"; +import { TDropdownProps } from "../types"; // constants -import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; +import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants"; +import { ModuleOptions } from "./module-options"; type Props = TDropdownProps & { button?: ReactNode; @@ -38,14 +38,6 @@ type Props = TDropdownProps & { } ); -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - type ButtonContentProps = { disabled: boolean; dropdownArrow: boolean; @@ -166,64 +158,14 @@ export const ModuleDropdown: React.FC = observer((props) => { value, } = props; // states - const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(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 moduleDetails = getModuleById(moduleId); - return { - value: moduleId, - query: `${moduleDetails?.name}`, - content: ( -
- - {moduleDetails?.name} -
- ), - }; - }); - if (!multiple) - options?.unshift({ - value: null, - query: "No module", - content: ( -
- - No module -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const onOpen = () => { - if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId); - }; + const { getModuleNameById } = useModule(); const handleClose = () => { if (!isOpen) return; @@ -232,8 +174,8 @@ export const ModuleDropdown: React.FC = observer((props) => { }; const toggleDropdown = () => { - if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string & string[]) => { @@ -307,7 +249,7 @@ export const ModuleDropdown: React.FC = observer((props) => { tooltipContent={ Array.isArray(value) ? `${value - .map((moduleId) => getModuleById(moduleId)?.name) + .map((moduleId) => getModuleNameById(moduleId)) .toString() .replaceAll(",", ", ")}` : "" @@ -332,60 +274,13 @@ export const ModuleDropdown: React.FC = observer((props) => { )} {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - 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 }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
+ )} ); diff --git a/web/components/dropdowns/module/module-options.tsx b/web/components/dropdowns/module/module-options.tsx new file mode 100644 index 000000000..e7d205b12 --- /dev/null +++ b/web/components/dropdowns/module/module-options.tsx @@ -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(null); + const inputRef = useRef(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) => { + 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: ( +
+ + {moduleDetails?.name} +
+ ), + }; + }); + if (!multiple) + options?.unshift({ + value: null, + query: "No module", + content: ( +
+ + No module +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + 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 }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index d519ad9f1..5cacefb3f 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -314,6 +314,7 @@ export const PriorityDropdown: React.FC = (props) => { const toggleDropdown = () => { setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: TIssuePriorities) => { @@ -329,6 +330,13 @@ export const PriorityDropdown: React.FC = (props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) @@ -417,6 +425,7 @@ export const PriorityDropdown: React.FC = (props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index fa068fdd0..9fa2f38c8 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -104,6 +104,7 @@ export const StateDropdown: React.FC = observer((props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string) => { @@ -119,6 +120,13 @@ export const StateDropdown: React.FC = observer((props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); useEffect(() => { @@ -205,6 +213,7 @@ export const StateDropdown: React.FC = observer((props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index 3bfd077fe..b67b453f1 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -15,9 +15,9 @@ export const MonthChartView: FC = observer(() => { const monthBlocks: IMonthBlock[] = renderView; return ( -
+
{monthBlocks?.map((block, rootIndex) => ( -
+
{ link={ } /> } diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index b7ca78ede..d1da1c859 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -109,7 +109,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { type="text" link={ } /> } diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 691ce36c7..8a3bb4261 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -92,7 +92,7 @@ export const InboxIssueActionsHeader: FC = observer((p id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - } + }, }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, @@ -269,12 +269,17 @@ export const InboxIssueActionsHeader: FC = observer((p { if (!date) return; setDate(date) }} + onSelect={(date) => { + if (!date) return; + setDate(date); + }} mode="single" className="border border-custom-border-200 rounded-md p-3" - disabled={[{ - before: tomorrow, - }]} + disabled={[ + { + before: tomorrow, + }, + ]} /> + +
+
+ + +
+
+ + + ); +}; diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx deleted file mode 100644 index 49d9e19dd..000000000 --- a/web/components/issues/delete-archived-issue-modal.tsx +++ /dev/null @@ -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; -}; - -export const DeleteArchivedIssueModal: React.FC = 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 ( - - - -
- - -
-
- - -
-
- - - -

Delete Archived Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the archived issue will be permanently removed. This action - cannot be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}); diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx deleted file mode 100644 index 6a2caba18..000000000 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ /dev/null @@ -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; -}; - -const issueDraftService = new IssueDraftService(); - -export const DeleteDraftIssueModal: React.FC = (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 ( - - - -
- - -
-
- - -
-
- - - -

Delete Draft Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {data && getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the draft issue will be permanently removed. This action cannot - be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}; diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index a063980c0..3a9c0653e 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -23,14 +23,14 @@ export const DeleteIssueModal: React.FC = (props) => { const { issueMap } = useIssues(); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const { setToastAlert } = useToast(); // hooks const { getProjectById } = useProject(); useEffect(() => { - setIsDeleteLoading(false); + setIsDeleting(false); }, [isOpen]); if (!dataId && !data) return null; @@ -38,12 +38,12 @@ export const DeleteIssueModal: React.FC = (props) => { const issue = data ? data : issueMap[dataId!]; const onClose = () => { - setIsDeleteLoading(false); + setIsDeleting(false); handleClose(); }; const handleIssueDelete = async () => { - setIsDeleteLoading(true); + setIsDeleting(true); if (onSubmit) await onSubmit() .then(() => { @@ -56,7 +56,7 @@ export const DeleteIssueModal: React.FC = (props) => { message: "Failed to delete issue", }); }) - .finally(() => setIsDeleteLoading(false)); + .finally(() => setIsDeleting(false)); }; return ( @@ -109,14 +109,8 @@ export const DeleteIssueModal: React.FC = (props) => { -
diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx deleted file mode 100644 index cfd6370fa..000000000 --- a/web/components/issues/draft-issue-form.tsx +++ /dev/null @@ -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 = { - project_id: "", - name: "", - description_html: "

", - 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, - action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" - ) => Promise; - data?: Partial | null; - isOpen: boolean; - prePopulatedData?: Partial | null; - projectId: string; - setActiveProject: React.Dispatch>; - createMore: boolean; - setCreateMore: React.Dispatch>; - 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 = 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(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(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({ - defaultValues: prePopulatedData ?? defaultValues, - reValidateMode: "onChange", - }); - - const issueName = watch("name"); - - const payload: Partial = { - 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, - 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: "

", - }); - editorRef?.current?.clearEditor(); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - // setValue("description", {}); - setValue("description_html", `${watch("description_html")}

${response}

`); - 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 && ( - <> - setStateModal(false)} projectId={projectId} /> - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])} - /> - - )} - - handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") - )} - > -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( - ( -
- { - onChange(val); - setActiveProject(val); - }} - buttonVariant="border-with-text" - /> -
- )} - /> - )} -

- {status ? "Update" : "Create"} issue -

-
- {watch("parent_id") && - (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && - selectedParentIssue && ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - { - setValue("parent_id", null); - setSelectedParentIssue(null); - }} - /> -
-
- )} -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( -
-
- {issueName && issueName !== "" && ( - - )} - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - button={ - - } - className=" !min-w-[38rem]" - placement="top-end" - /> - )} -
- ( - { - onChange(description_html); - }} - mentionHighlights={mentionHighlights} - mentionSuggestions={mentionSuggestions} - /> - )} - /> -
- )} -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( - ( -
- 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} - placeholder="Assignees" - multiple - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Start date" - maxDate={maxDate ?? undefined} - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Due date" - minDate={minDate ?? undefined} - /> -
- )} - /> - )} - {projectDetails?.cycle_view && ( - ( -
- onChange(cycleId)} - value={value} - buttonVariant="border-with-text" - /> -
- )} - /> - )} - - {projectDetails?.module_view && workspaceSlug && ( - ( -
- -
- )} - /> - )} - - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && - areEstimatesEnabledForProject(projectId) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - projectId={projectId} - /> - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - - {watch("parent_id") ? ( - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - setValue("parent_id", null)}> - Remove parent issue - - - ) : ( - setParentIssueListModalOpen(true)}> - Select Parent Issue - - )} - - )} -
-
-
-
-
-
setCreateMore((prevData) => !prevData)} - > - Create more - {}} size="md" /> -
-
- - - -
-
- - - ); -}); diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx deleted file mode 100644 index 40a79798e..000000000 --- a/web/components/issues/draft-issue-modal.tsx +++ /dev/null @@ -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; - fieldsToShow?: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - )[]; - onSubmit?: (data: Partial) => Promise | void; -} - -// services -const issueService = new IssueService(); -const moduleService = new ModuleService(); - -export const CreateUpdateDraftIssueModal: React.FC = observer((props) => { - const { - data, - handleClose, - isOpen, - isUpdatingSingleIssue = false, - prePopulateData: prePopulateDataProps, - fieldsToShow = ["all"], - onSubmit, - } = props; - - // states - const [createMore, setCreateMore] = useState(false); - const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState | 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) => { - 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) => { - await draftIssues - .updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload) - .then(() => { - if (isUpdatingSingleIssue) { - mutate(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) => { - 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, - action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" - ) => { - if (!workspaceSlug || !activeProject) return; - - const payload: Partial = { - ...formData, - // description: formData.description ?? "", - description_html: formData.description_html ?? "

", - }; - - 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 ( - <> - - - -
- - -
-
- - - - - -
-
-
-
- - ); -}); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 3904049e9..d001a29c2 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -14,10 +14,5 @@ export * from "./issue-detail"; export * from "./peek-overview"; -// draft issue -export * from "./draft-issue-form"; -export * from "./draft-issue-modal"; -export * from "./delete-draft-issue-modal"; - // archived issue -export * from "./delete-archived-issue-modal"; +export * from "./archive-issue-modal"; diff --git a/web/components/issues/issue-detail/inbox/root.tsx b/web/components/issues/issue-detail/inbox/root.tsx index a57820106..d96b36efa 100644 --- a/web/components/issues/issue-detail/inbox/root.tsx +++ b/web/components/issues/issue-detail/inbox/root.tsx @@ -30,6 +30,8 @@ export const InboxIssueDetailRoot: FC = (props) => { } = useInboxIssues(); const { issue: { getIssueById }, + fetchActivities, + fetchComments, } = useIssueDetail(); const { captureIssueEvent } = useEventTracker(); const { setToastAlert } = useToast(); @@ -54,7 +56,7 @@ export const InboxIssueDetailRoot: FC = (props) => { showToast: boolean = true ) => { try { - const response = await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); + await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); if (showToast) { setToastAlert({ title: "Issue updated successfully", @@ -64,7 +66,7 @@ export const InboxIssueDetailRoot: FC = (props) => { } captureIssueEvent({ eventName: "Inbox issue updated", - payload: { ...response, state: "SUCCESS", element: "Inbox" }, + payload: { ...data, state: "SUCCESS", element: "Inbox" }, updates: { changed_property: Object.keys(data).join(","), change_details: Object.values(data).join(","), @@ -125,6 +127,8 @@ export const InboxIssueDetailRoot: FC = (props) => { async () => { if (workspaceSlug && projectId && inboxId && issueId) { await issueOperations.fetch(workspaceSlug, projectId, issueId); + await fetchActivities(workspaceSlug, projectId, issueId); + await fetchComments(workspaceSlug, projectId, issueId); } } ); diff --git a/web/components/issues/issue-detail/inbox/sidebar.tsx b/web/components/issues/issue-detail/inbox/sidebar.tsx index e0b2aca28..592791a85 100644 --- a/web/components/issues/issue-detail/inbox/sidebar.tsx +++ b/web/components/issues/issue-detail/inbox/sidebar.tsx @@ -5,7 +5,7 @@ import { CalendarCheck2, Signal, Tag } from "lucide-react"; import { useIssueDetail, useProject, useProjectState } from "hooks/store"; // components import { IssueLabel, TIssueOperations } from "components/issues"; -import { DateDropdown, PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; +import { DateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns"; // icons import { DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; // helper @@ -80,7 +80,7 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { Assignees - issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -154,6 +154,10 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} disabled={!is_editable} + isInboxIssue + onLabelUpdate={(val: string[]) => + issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val }) + } /> diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx index 55f07870c..2335e4d32 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/archived-at.tsx @@ -1,10 +1,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; -import { MessageSquare } from "lucide-react"; +import { RotateCcw } from "lucide-react"; // hooks import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent } from "./"; +// ui +import { ArchiveIcon } from "@plane/ui"; type TIssueArchivedAtActivity = { activityId: string; ends: "top" | "bottom" | undefined }; @@ -18,13 +20,21 @@ export const IssueArchivedAtActivity: FC = observer((p const activity = getActivityById(activityId); if (!activity) return <>; + return ( ); }); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index c7b75340b..e209b4bbf 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -14,10 +14,11 @@ type TIssueActivityBlockComponent = { activityId: string; ends: "top" | "bottom" | undefined; children: ReactNode; + customUserName?: string; }; export const IssueActivityBlockComponent: FC = (props) => { - const { icon, activityId, ends, children } = props; + const { icon, activityId, ends, children, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -37,7 +38,7 @@ export const IssueActivityBlockComponent: FC = (pr {icon ? icon : }
- + {children} = (props) => { - const { activityId } = props; + const { activityId, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -18,12 +18,19 @@ export const IssueUser: FC = (props) => { const activity = getActivityById(activityId); if (!activity) return <>; + return ( - - {activity.actor_detail?.display_name} - + <> + {customUserName ? ( + {customUserName} + ) : ( + + {activity.actor_detail?.display_name} + + )} + ); }; diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx index 2000721ee..0c2c3cbb5 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-card.tsx @@ -14,6 +14,8 @@ import { FileService } from "services/file.service"; // types import { TIssueComment } from "@plane/types"; import { TActivityOperations } from "../root"; +// helpers +import { isEmptyHtmlString } from "helpers/string.helper"; const fileService = new FileService(); @@ -67,6 +69,12 @@ export const IssueCommentCard: FC = (props) => { isEditing && setFocus("comment_html"); }, [isEditing, setFocus]); + const isEmpty = + watch("comment_html") === "" || + watch("comment_html")?.trim() === "" || + watch("comment_html") === "

" || + isEmptyHtmlString(watch("comment_html") ?? ""); + if (!comment || !currentUser) return <>; return ( = (props) => { > <>
-
+
{ + if (e.key === "Enter" && !e.shiftKey && !isEmpty) { + handleSubmit(onEnter)(e); + } + }} + > = (props) => {
); + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + if (!issue) return <>; return ( @@ -118,6 +125,7 @@ export const IssueLabelSelect: React.FC = observer((props) => onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 6252fc03a..0e343d9a8 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -16,7 +16,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; 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"; export type TIssueOperations = { @@ -29,6 +29,8 @@ export type TIssueOperations = { showToast?: boolean ) => Promise; remove: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + archive?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + restore?: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; removeIssueFromCycle?: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; @@ -63,6 +65,7 @@ export const IssueDetailRoot: FC = observer((props) => { fetchIssue, updateIssue, removeIssue, + archiveIssue, addIssueToCycle, removeIssueFromCycle, addModulesToIssue, @@ -158,6 +161,32 @@ export const IssueDetailRoot: FC = 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[]) => { try { await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); @@ -321,6 +350,7 @@ export const IssueDetailRoot: FC = observer((props) => { fetchIssue, updateIssue, removeIssue, + archiveIssue, removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, @@ -350,7 +380,7 @@ export const IssueDetailRoot: FC = observer((props) => { /> ) : (
-
+
= observer((props) => { />
= observer((props) => { const { workspaceSlug, projectId, issueId, issueOperations, is_archived, is_editable } = props; + // states + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [archiveIssueModal, setArchiveIssueModal] = useState(false); // router const router = useRouter(); // store hooks @@ -66,8 +65,6 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issue: { getIssueById }, } = useIssueDetail(); const { getStateById } = useProjectState(); - // states - const [deleteIssueModal, setDeleteIssueModal] = useState(false); const issue = getIssueById(issueId); if (!issue) return <>; @@ -83,8 +80,23 @@ export const IssueDetailsSidebar: React.FC = 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); + // 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; minDate?.setDate(minDate.getDate()); @@ -94,46 +106,72 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { return ( <> - {workspaceSlug && projectId && issue && ( - setDeleteIssueModal(false)} - isOpen={deleteIssueModal} - data={issue} - onSubmit={async () => { - await issueOperations.remove(workspaceSlug, projectId, issueId); - router.push(`/${workspaceSlug}/projects/${projectId}/issues`); - }} - /> - )} - + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issue} + onSubmit={handleDeleteIssue} + /> + setArchiveIssueModal(false)} + data={issue} + onSubmit={handleArchiveIssue} + />
-
+
{currentUser && !is_archived && ( )} - - - - {is_editable && ( - - )} +
+ + + + {isArchivingAllowed && ( + + + + )} + {is_editable && ( + + + + )} +
-
+
Properties
{/* TODO: render properties using a common component */}
@@ -161,7 +199,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index ab5983960..7321ef27f 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -67,7 +67,7 @@ export const IssueSubscription: FC = observer((props) => { > {loading ? ( - Loading... + Loading... ) : isSubscribed ? (
Unsubscribe
diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 472d68085..43f62e5be 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -26,6 +26,8 @@ interface IBaseCalendarRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; viewId?: string; isCompletedCycle?: boolean; @@ -114,6 +116,16 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { ? async () => handleIssues(issue.target_date ?? "", issue, EIssueActions.REMOVE) : 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} /> )} diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 9f3532302..b5d0c4346 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -26,7 +26,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { const { router: { workspaceSlug, projectId }, } = useApplication(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { getProjectStates } = useProjectState(); const { peekIssue, setPeekIssue } = useIssueDetail(); // states @@ -108,7 +108,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { }} />
- {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} + {getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id}
{issue.name}
diff --git a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx index 1ef08ea61..4daf68b9f 100644 --- a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx @@ -33,6 +33,10 @@ export const CycleCalendarLayout: React.FC = observer(() => { if (!workspaceSlug || !cycleId || !projectId) return; 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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx index d2b23e176..cb474d25e 100644 --- a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx @@ -34,6 +34,10 @@ export const ModuleCalendarLayout: React.FC = observer(() => { if (!workspaceSlug || !moduleId) return; 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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx index 40f72e7b8..d42a8c5d2 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx @@ -28,6 +28,11 @@ export const CalendarLayout: React.FC = observer(() => { 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] ); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx index 573a9cf20..0110aea2b 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx @@ -16,6 +16,7 @@ export interface IViewCalendarLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index d668b8a44..209d876ac 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -66,7 +66,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { const { issueId } = props; // store hooks const { getStateById } = useProjectState(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { router: { workspaceSlug }, } = useApplication(); @@ -76,7 +76,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { } = useIssueDetail(); // derived values const issueDetails = getIssueById(issueId); - const projectDetails = issueDetails && getProjectById(issueDetails?.project_id); + const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id); const stateDetails = issueDetails && getStateById(issueDetails?.state_id); const handleIssuePeekOverview = () => @@ -95,7 +95,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => {
{stateDetails && }
- {projectDetails?.identifier} {issueDetails?.sequence_id} + {projectIdentifier} {issueDetails?.sequence_id}
{issueDetails?.name} diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 3b31f6b67..0d7a984b1 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -41,6 +41,8 @@ export interface IBaseKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; showLoader?: boolean; viewId?: string; @@ -188,6 +190,12 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 0dc9aa908..8446e7328 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -42,9 +42,9 @@ interface IssueDetailsBlockProps { const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; // hooks - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); const { setPeekIssue } = useIssueDetail(); @@ -64,7 +64,7 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop
- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} + {getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
{quickActions(issue)}
diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index ec9742baf..440b379b8 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; +import { CreateUpdateIssueModal } from "components/issues"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; // hooks diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index 2b311f6eb..001169933 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -39,6 +39,11 @@ export const CycleKanBanLayout: React.FC = observer(() => { 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] ); @@ -46,7 +51,15 @@ export const CycleKanBanLayout: React.FC = observer(() => { const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { QuickActions={CycleIssueQuickActions} viewId={cycleId?.toString() ?? ""} storeType={EIssuesStoreType.CYCLE} - addIssuesToView={(issueIds: string[]) => { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index c3af69e6e..07ad7eb83 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index 2e189c9f4..c6c041654 100644 --- a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 89e2ee187..efd86bc8e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id); + }, }), [issues, workspaceSlug] ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 1cdf71d45..8dd33b728 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 6cec6d358..ffe9de661 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -41,6 +41,8 @@ interface IBaseListRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; viewId?: string; storeType: TCreateModalStoreTypes; @@ -109,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1bbd574ed..cc04ed716 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -24,9 +24,9 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; // hooks const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); const updateIssue = async (issueToUpdate: TIssue) => { @@ -45,7 +45,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); - const projectDetails = getProjectById(issue.project_id); + const projectIdentifier = getProjectIdentifierById(issue.project_id); return (
= observer((props: IssueBlock > {displayProperties && displayProperties?.key && (
- {projectDetails?.identifier}-{issue.sequence_id} + {projectIdentifier}-{issue.sequence_id}
)} diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index 5a6b3c462..8d9164b37 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; +import { CreateUpdateIssueModal } from "components/issues"; import { ExistingIssuesListModal } from "components/core"; import { CustomMenu } from "@plane/ui"; // mobx diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index e369410af..f435d0639 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -5,6 +5,8 @@ export interface IQuickActionProps { handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; + handleArchive?: () => Promise; + handleRestore?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 2ba4ea7f5..6e70d00d0 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.RESTORE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.restoreIssue(workspaceSlug, projectId, issue.id); + }, }), [issues, workspaceSlug, projectId] ); diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index e30c207b6..5c15ebe60 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -38,13 +38,26 @@ export const CycleListLayout: React.FC = observer(() => { 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] ); const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { issueActions={issueActions} viewId={cycleId?.toString()} storeType={EIssuesStoreType.CYCLE} - addIssuesToView={(issueIds: string[]) => { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 520a2da32..95c62d34c 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); diff --git a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index 91e80382a..fa4a05bbc 100644 --- a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index f0479b71f..9e1b5830b 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.archiveIssue(workspaceSlug, projectId, issue.id); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [issues] diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index dd384ba93..5ecfd6da2 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -17,6 +17,7 @@ export interface IViewListLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 8c16d3e24..ce97f1afa 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -13,7 +13,7 @@ import { DateDropdown, EstimateDropdown, PriorityDropdown, - ProjectMemberDropdown, + MemberDropdown, ModuleDropdown, CycleDropdown, StateDropdown, @@ -313,7 +313,7 @@ export const IssueProperties: React.FC = observer((props) => { {/* assignee */}
- = observer((pro } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -60,18 +65,45 @@ export const IssuePropertyLabels: React.FC = observer((pro const storeLabels = getProjectLabels(projectId); - const openDropDown = () => { - if (!storeLabels && workspaceSlug && projectId) { - setIsLoading(true); + const onOpen = () => { + if (!storeLabels && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); - } }; const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); onClose && onClose(); }; - const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false); + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", @@ -176,6 +208,7 @@ export const IssuePropertyLabels: React.FC = observer((pro return ( = observer((pro ? "cursor-pointer" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} - onClick={openDropDown} + onClick={handleOnClick} > {label} {!hideDropdownArrow && !disabled &&