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/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 8d4304f92..411c5b73f 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -601,15 +601,15 @@ class IssueSerializer(DynamicBaseSerializer): # ids cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) module_ids = serializers.ListField( - child=serializers.UUIDField(), required=False, allow_null=True + child=serializers.UUIDField(), required=False, ) # Many to many label_ids = serializers.ListField( - child=serializers.UUIDField(), required=False, allow_null=True + child=serializers.UUIDField(), required=False, ) assignee_ids = serializers.ListField( - child=serializers.UUIDField(), required=False, allow_null=True + child=serializers.UUIDField(), required=False, ) # Count items 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/dashboard.py b/apiserver/plane/app/views/dashboard.py index 21fe422f9..62ce0d910 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -58,6 +58,7 @@ def dashboard_overview_stats(self, request, slug): pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), + target_date__lt=timezone.now().date(), project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, workspace__slug=slug, diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index 85e2f38b2..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 @@ -296,11 +297,7 @@ class InboxIssueViewSet(BaseViewSet): issue_data = request.data.pop("issue", False) if bool(issue_data): - issue = Issue.objects.get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) + issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first() # Only allow guests and viewers to edit name and description if project_member.role <= 10: # viewers and guests since only viewers and guests @@ -430,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 9f95c9b43..14e0b6a9a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -572,12 +572,18 @@ class IssueViewSet(WebhookMixin, BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueCreateSerializer( issue, data=request.data, partial=True @@ -767,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) @@ -790,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) @@ -799,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) @@ -850,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") @@ -1012,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") @@ -1225,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() ) @@ -1627,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, @@ -1650,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): @@ -1686,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() ) @@ -1770,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() ) @@ -1839,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() ) @@ -1909,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") @@ -2296,20 +2358,18 @@ class IssueDraftViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - serializer = IssueSerializer(issue, data=request.data, partial=True) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + 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/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 617bfcfdc..2a98c6b33 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -10,6 +10,7 @@ from django.utils import timezone from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Module imports from plane.db.models import EmailNotificationLog, User, Issue @@ -301,5 +302,7 @@ def send_email_notification( print("Duplicate task recived. Skipping...") return except (Issue.DoesNotExist, User.DoesNotExist) as e: + if settings.DEBUG: + print(e) release_lock(lock_id=lock_id) return 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/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 421521363..7a1dc4fc6 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -60,15 +60,6 @@ def service_importer(service, importer_id): batch_size=100, ) - _ = [ - send_welcome_slack.delay( - str(user.id), - True, - f"{user.email} was imported to Plane from {service}", - ) - for user in new_users - ] - workspace_users = User.objects.filter( email__in=[ user.get("email").strip().lower() 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/db/models/user.py b/apiserver/plane/db/models/user.py index f254a3cb7..0377ccb8b 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -12,15 +12,9 @@ from django.contrib.auth.models import ( PermissionsMixin, ) from django.db.models.signals import post_save -from django.conf import settings from django.dispatch import receiver from django.utils import timezone -# Third party imports -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - def get_default_onboarding(): return { @@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) -@receiver(post_save, sender=User) -def send_welcome_slack(sender, instance, created, **kwargs): - try: - if created and not instance.is_bot: - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=f"New user {instance.email} has signed up and begun the onboarding journey.", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - capture_exception(e) - return - @receiver(post_save, sender=User) def create_user_notification(sender, instance, created, **kwargs): diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index f03209250..5c8947e73 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -1,4 +1,5 @@ """Global Settings""" + # Python imports import os import ssl @@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( traces_sample_rate=1, send_default_pii=True, environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=1.0, + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5) + ), ) 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/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 194bf8d90..eb0f54201 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -30,7 +30,7 @@ openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 -cryptography==42.0.0 +cryptography==42.0.4 lxml==4.9.3 boto3==1.28.40 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/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 4a56f07c2..6524d1ff5 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { } } } - if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run(); + else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); }; export const unsetLinkEditor = (editor: Editor) => { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index b0d2a1021..dbbea671e 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } } -#editor-container { - table { - border-collapse: collapse; - table-layout: fixed; - margin: 0.5em 0 0.5em 0; - - border: 1px solid rgb(var(--color-border-200)); - width: 100%; - - td, - th { - min-width: 1em; - border: 1px solid rgb(var(--color-border-200)); - padding: 10px 15px; - vertical-align: top; - box-sizing: border-box; - position: relative; - transition: background-color 0.3s ease; - - > * { - margin-bottom: 0; - } - } - - th { - font-weight: bold; - text-align: left; - background-color: rgb(var(--color-primary-100)); - } - - td:hover { - background-color: rgba(var(--color-primary-300), 0.1); - } - - .selectedCell:after { - z-index: 2; - position: absolute; - content: ""; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-color: rgba(var(--color-primary-300), 0.1); - pointer-events: none; - } - - .column-resize-handle { - position: absolute; - right: -2px; - top: 0; - bottom: -2px; - width: 2px; - background-color: rgb(var(--color-primary-400)); - pointer-events: none; - } - } -} - -.tableWrapper { - overflow-x: auto; -} - .resize-cursor { cursor: ew-resize; cursor: col-resize; diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 8a47a8c59..ca384d34f 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -9,15 +9,15 @@ border-collapse: collapse; table-layout: fixed; margin: 0; - margin-bottom: 3rem; - border: 1px solid rgba(var(--color-border-200)); + margin-bottom: 1rem; + border: 2px solid rgba(var(--color-border-300)); width: 100%; } .tableWrapper table td, .tableWrapper table th { min-width: 1em; - border: 1px solid rgba(var(--color-border-200)); + border: 1px solid rgba(var(--color-border-300)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -43,7 +43,8 @@ .tableWrapper table th { font-weight: bold; text-align: left; - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; + color: #171717; } .tableWrapper table th * { @@ -62,6 +63,35 @@ pointer-events: none; } +.colorPicker { + display: grid; + padding: 8px 8px; + grid-template-columns: repeat(6, 1fr); + gap: 5px; +} + +.colorPickerLabel { + font-size: 0.85rem; + color: #6b7280; + padding: 8px 8px; + padding-bottom: 0px; +} + +.colorPickerItem { + margin: 2px 0px; + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + cursor: pointer; +} + +.divider { + background-color: #e5e7eb; + height: 1px; + margin: 3px 0; +} + .tableWrapper table .column-resize-handle { position: absolute; right: -2px; @@ -69,7 +99,7 @@ bottom: -2px; width: 4px; z-index: 99; - background-color: rgba(var(--color-primary-400)); + background-color: #d9e4ff; pointer-events: none; } @@ -112,7 +142,7 @@ } .tableWrapper .tableControls .rowsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -127,7 +157,7 @@ } .tableWrapper .tableControls .columnsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -144,10 +174,12 @@ .tableWrapper .tableControls .tableColorPickerToolbox { border: 1px solid rgba(var(--color-border-300)); background-color: rgba(var(--color-background-100)); + border-radius: 5px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); padding: 0.25rem; display: flex; flex-direction: column; - width: 200px; + width: max-content; gap: 0.25rem; } @@ -158,7 +190,7 @@ align-items: center; gap: 0.5rem; border: none; - padding: 0.1rem; + padding: 0.3rem 0.5rem 0.1rem 0.1rem; border-radius: 4px; cursor: pointer; transition: all 0.2s; @@ -173,9 +205,7 @@ .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { - border: 1px solid rgba(var(--color-border-300)); - border-radius: 3px; - padding: 4px; + padding: 4px 0px; display: flex; align-items: center; justify-content: center; @@ -187,8 +217,8 @@ .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { - width: 2rem; - height: 2rem; + width: 1rem; + height: 1rem; } .tableToolbox { diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts index aedb59411..403bd3f02 100644 --- a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts +++ b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts @@ -13,7 +13,7 @@ export const TableCell = Node.create({ }; }, - content: "paragraph+", + content: "block+", addAttributes() { return { @@ -33,7 +33,10 @@ export const TableCell = Node.create({ }, }, background: { - default: "none", + default: null, + }, + textColor: { + default: null, }, }; }, @@ -50,7 +53,7 @@ export const TableCell = Node.create({ return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}`, + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, }), 0, ]; diff --git a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts index c0decdbf8..bd994f467 100644 --- a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts +++ b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts @@ -33,7 +33,7 @@ export const TableHeader = Node.create({ }, }, background: { - default: "rgb(var(--color-primary-100))", + default: "none", }, }; }, diff --git a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts index 28c9a9a48..f961c0582 100644 --- a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts +++ b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts @@ -13,6 +13,17 @@ export const TableRow = Node.create({ }; }, + addAttributes() { + return { + background: { + default: null, + }, + textColor: { + default: null, + }, + }; + }, + content: "(tableCell | tableHeader)*", tableRole: "row", @@ -22,6 +33,12 @@ export const TableRow = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + const style = HTMLAttributes.background + ? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}` + : ""; + + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); + + return ["tr", attributes, 0]; }, }); diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index c08710ec3..f73c55c09 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,7 +1,7 @@ export const icons = { colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` `, + toggleColumnHeader: ``, + toggleRowHeader: ``, insertBottomTableIcon: ` = { placement: "right", }; -function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { +function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { return editor .chain() .focus() .updateAttributes("tableCell", { - background: backgroundColor, - }) - .updateAttributes("tableHeader", { - background: backgroundColor, + background: color.backgroundColor, + textColor: color.textColor, }) .run(); } +function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { + const { state, dispatch } = editor.view; + const { selection } = state; + if (!(selection instanceof CellSelection)) { + return false; + } + + // Get the position of the hovered cell in the selection to determine the row. + const hoveredCell = selection.$headCell || selection.$anchorCell; + + // Find the depth of the table row node + let rowDepth = hoveredCell.depth; + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + rowDepth--; + } + + // If we couldn't find a tableRow node, we can't set the background color + if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + return false; + } + + // Get the position where the table row starts + const rowStartPos = hoveredCell.start(rowDepth); + + // Create a transaction that sets the background color on the tableRow node. + const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, { + ...hoveredCell.node(rowDepth).attrs, + background: color.backgroundColor, + textColor: color.textColor, + }); + + dispatch(tr); + return true; +} + const columnsToolboxItems: ToolboxItem[] = [ { - label: "Add Column Before", + label: "Toggle column header", + icon: icons.toggleColumnHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(), + }, + { + label: "Add column before", icon: icons.insertLeftTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(), }, { - label: "Add Column After", + label: "Add column after", icon: icons.insertRightTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(), }, { - label: "Pick Column Color", - icon: icons.colorPicker, - action: ({ - editor, - triggerButton, - controlsContainer, - }: { - editor: Editor; - triggerButton: HTMLElement; - controlsContainer: Element; - }) => { - createColorPickerToolbox({ - triggerButton, - tippyOptions: { - appendTo: controlsContainer, - }, - onSelectColor: (color) => setCellsBackgroundColor(editor, color), - }); - }, + label: "Pick color", + icon: "", // No icon needed for color picker + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { - label: "Delete Column", + label: "Delete column", icon: icons.deleteColumn, action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(), }, @@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [ const rowsToolboxItems: ToolboxItem[] = [ { - label: "Add Row Above", + label: "Toggle row header", + icon: icons.toggleRowHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(), + }, + { + label: "Add row above", icon: icons.insertTopTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(), }, { - label: "Add Row Below", + label: "Add row below", icon: icons.insertBottomTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(), }, { - label: "Pick Row Color", - icon: icons.colorPicker, - action: ({ - editor, - triggerButton, - controlsContainer, - }: { - editor: Editor; - triggerButton: HTMLButtonElement; - controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined; - }) => { - createColorPickerToolbox({ - triggerButton, - tippyOptions: { - appendTo: controlsContainer, - }, - onSelectColor: (color) => setCellsBackgroundColor(editor, color), - }); - }, + label: "Pick color", + icon: "", + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { label: "Delete Row", @@ -176,37 +187,57 @@ function createToolbox({ triggerButton, items, tippyOptions, + onSelectColor, onClickItem, + colors, }: { triggerButton: Element | null; items: ToolboxItem[]; tippyOptions: any; onClickItem: (item: ToolboxItem) => void; + onSelectColor: (color: { backgroundColor: string; textColor: string }) => void; + colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } }; }): Instance { // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( "div", { className: "tableToolbox" }, - items.map((item) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick() { - onClickItem(item); + items.map((item, index) => { + if (item.label === "Pick color") { + return h("div", { className: "flex flex-col" }, [ + h("div", { className: "divider" }), + h("div", { className: "colorPickerLabel" }, item.label), + h( + "div", + { className: "colorPicker grid" }, + Object.entries(colors).map(([colorName, colorValue]) => + h("div", { + className: "colorPickerItem", + style: `background-color: ${colorValue.backgroundColor}; + color: ${colorValue.textColor || "inherit"};`, + innerHTML: colorValue?.icon || "", + onClick: () => onSelectColor(colorValue), + }) + ) + ), + h("div", { className: "divider" }), + ]); + } else { + return h( + "div", + { + className: "toolboxItem", + itemType: "div", + onClick: () => onClickItem(item), }, - }, - [ - h("div", { - className: "iconContainer", - innerHTML: item.icon, - }), - h("div", { className: "label" }, item.label), - ] - ) - ) + [ + h("div", { className: "iconContainer", innerHTML: item.icon }), + h("div", { className: "label" }, item.label), + ] + ); + } + }) ), ...tippyOptions, }); @@ -214,71 +245,6 @@ function createToolbox({ return Array.isArray(toolbox) ? toolbox[0] : toolbox; } -function createColorPickerToolbox({ - triggerButton, - tippyOptions, - onSelectColor = () => {}, -}: { - triggerButton: HTMLElement; - tippyOptions: Partial; - onSelectColor?: (color: string) => void; -}) { - const items = { - Default: "rgb(var(--color-primary-100))", - Orange: "#FFE5D1", - Grey: "#F1F1F1", - Yellow: "#FEF3C7", - Green: "#DCFCE7", - Red: "#FFDDDD", - Blue: "#D9E4FF", - Pink: "#FFE8FA", - Purple: "#E8DAFB", - }; - - const colorPicker = tippy(triggerButton, { - ...defaultTippyOptions, - content: h( - "div", - { className: "tableColorPickerToolbox" }, - Object.entries(items).map(([key, value]) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick: () => { - onSelectColor(value); - colorPicker.hide(); - }, - }, - [ - h("div", { - className: "colorContainer", - style: { - backgroundColor: value, - }, - }), - h( - "div", - { - className: "label", - }, - key - ), - ] - ) - ) - ), - onHidden: (instance) => { - instance.destroy(); - }, - showOnCreate: true, - ...tippyOptions, - }); - - return colorPicker; -} - export class TableView implements NodeView { node: ProseMirrorNode; cellMinWidth: number; @@ -347,10 +313,27 @@ export class TableView implements NodeView { this.rowsControl, this.columnsControl ); + const columnColors = { + Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" }, + Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" }, + Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" }, + Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" }, + Green: { backgroundColor: "#DCFCE7", textColor: "#171717" }, + Red: { backgroundColor: "#FFDDDD", textColor: "#171717" }, + Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" }, + Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" }, + None: { + backgroundColor: "none", + textColor: "none", + icon: ``, + }, + }; this.columnsToolbox = createToolbox({ triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), items: columnsToolboxItems, + colors: columnColors, + onSelectColor: (color) => setCellsBackgroundColor(this.editor, color), tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, @@ -368,10 +351,12 @@ export class TableView implements NodeView { this.rowsToolbox = createToolbox({ triggerButton: this.rowsControl.firstElementChild, items: rowsToolboxItems, + colors: columnColors, tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, }, + onSelectColor: (color) => setTableRowBackgroundColor(editor, color), onClickItem: (item) => { item.action({ editor: this.editor, @@ -383,8 +368,6 @@ export class TableView implements NodeView { }); } - // Table - this.colgroup = h( "colgroup", null, @@ -437,16 +420,19 @@ export class TableView implements NodeView { } updateControls() { - const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { - if (curr.spec.hoveredCell !== undefined) { - acc["hoveredCell"] = curr.spec.hoveredCell; - } + const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( + (acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } - if (curr.spec.hoveredTable !== undefined) { - acc["hoveredTable"] = curr.spec.hoveredTable; - } - return acc; - }, {} as Record) as any; + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, + {} as Record + ) as any; if (table === undefined || cell === undefined) { return this.root.classList.add("controls--disabled"); @@ -457,12 +443,12 @@ export class TableView implements NodeView { const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; - if (!this.table) { + if (!this.table || !cellDom) { return; } - const tableRect = this.table.getBoundingClientRect(); - const cellRect = cellDom.getBoundingClientRect(); + const tableRect = this.table?.getBoundingClientRect(); + const cellRect = cellDom?.getBoundingClientRect(); if (this.columnsControl) { this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts index 5600fd82a..ef595eee2 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -107,10 +107,9 @@ export const Table = Node.create({ addCommands() { return { insertTable: - ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ rows = 3, cols = 3, withHeaderRow = false } = {}) => ({ tr, dispatch, editor }) => { const node = createTable(editor.schema, rows, cols, withHeaderRow); - if (dispatch) { const offset = tr.selection.anchor + 1; diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 2aaeb4264..1846efe47 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -42,15 +42,6 @@ export function CoreEditorProps( return false; }, handleDrop: (view, event, _slice, moved) => { - if (typeof window !== "undefined") { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return; - } - } - } if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { event.preventDefault(); const file = event.dataTransfer.files[0]; 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/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index be57a4a91..397e8c576 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(editor, uploadFile, setIsSubmitting)); - } - + items.push(ImageItem(editor, uploadFile, setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - return (
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/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index af99fec61..ce4088413 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -35,7 +35,7 @@ export interface DragHandleOptions { } function absoluteRect(node: Element) { - const data = node.getBoundingClientRect(); + const data = node?.getBoundingClientRect(); return { top: data.top, @@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { } function nodePosAtDOM(node: Element, view: EditorView) { - const boundingRect = node.getBoundingClientRect(); + const boundingRect = node?.getBoundingClientRect(); if (node.nodeName === "IMG") { return view.posAtCoords({ 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/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx index 71ad4e0e1..c6786698d 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx @@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(props.editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); - } + items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - const handleAccessChange = (accessKey: string) => { props.commentAccessSpecifier?.onAccessChange(accessKey); }; 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/cycles.d.ts b/packages/types/src/cycles.d.ts index 0e4890b7f..e7ec66ae2 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -30,7 +30,7 @@ export interface ICycle { is_favorite: boolean; issue: string; name: string; - owned_by: string; + owned_by_id: string; progress_snapshot: TProgressSnapshot; project_id: string; status: TCycleGroups; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 754d8df8f..ebe537138 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -203,6 +203,8 @@ export interface ViewFlags { export type GroupByColumnTypes = | "project" + | "cycle" + | "module" | "state" | "state_detail.group" | "priority" 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/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index b6454ae4c..c2c98def3 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -14,6 +14,8 @@ export type TIssueGroupByOptions = | "project" | "assignees" | "mentions" + | "cycle" + | "module" | null; export type TIssueOrderByOptions = @@ -60,6 +62,8 @@ export type TIssueParams = | "created_by" | "subscriber" | "labels" + | "cycle" + | "module" | "start_date" | "target_date" | "project" @@ -79,6 +83,8 @@ export interface IIssueFilterOptions { labels?: string[] | null; priority?: string[] | null; project?: string[] | null; + cycle?: string[] | null; + module?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; 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/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index dbdbaf095..ee4b66d7b 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -5,10 +5,11 @@ export type TControlLink = React.AnchorHTMLAttributes & { onClick: () => void; children: React.ReactNode; target?: string; + disabled?: boolean; }; export const ControlLink: React.FC = (props) => { - const { href, onClick, children, target = "_self", ...rest } = props; + const { href, onClick, children, target = "_self", disabled = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; const _onClick = (event: React.MouseEvent) => { @@ -19,6 +20,8 @@ export const ControlLink: React.FC = (props) => { } }; + if (disabled) return <>{children}; + return ( {children} 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/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index c2644abe0..6a7b3c7b9 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -20,7 +20,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => { const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; return ( 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/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index cb433de05..0212e4980 100644 --- a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -5,17 +5,17 @@ import { observer } from "mobx-react"; type Props = { onClick?: () => void; -} +}; export const SidebarHamburgerToggle: FC = observer((props) => { - const { onClick } = props + const { onClick } = props; const { theme: themeStore } = useApplication(); return (
{ - if (onClick) onClick() - else themeStore.toggleMobileSidebar() + if (onClick) onClick(); + else themeStore.toggleSidebar(); }} > diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 7e885635f..1fae0412f 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -69,7 +69,7 @@ export const ActiveCycleDetails: React.FC = observer((props ); const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined; + const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined; const { data: activeCycleIssues } = useSWR( workspaceSlug && projectId && currentProjectActiveCycleId diff --git a/web/components/cycles/cycle-mobile-header.tsx b/web/components/cycles/cycle-mobile-header.tsx index b893bf993..624334ec4 100644 --- a/web/components/cycles/cycle-mobile-header.tsx +++ b/web/components/cycles/cycle-mobile-header.tsx @@ -152,6 +152,7 @@ export const CycleMobileHeader = () => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} />
diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 20605a90c..646736bd2 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -59,7 +59,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const { getUserDetails } = useMember(); // derived values const cycleDetails = getCycleById(cycleId); - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; // toast alert const { setToastAlert } = useToast(); // form info 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 cf3f32232..16b2b95d9 100644 --- a/web/components/dashboard/widgets/issue-panels/issues-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -14,24 +14,23 @@ import { IssueListItemProps, } from "components/dashboard/widgets"; // ui -import { getButtonStyling } from "@plane/ui"; +import { Loader, getButtonStyling } from "@plane/ui"; // helpers 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,12 +58,19 @@ export const WidgetIssuesList: React.FC = (props) => { }, }; + const issuesList = widgetStats.issues; + return ( <>
{isLoading ? ( - <> - ) : issues.length > 0 ? ( + + + + + + + ) : issuesList.length > 0 ? ( <>
= (props) => { > Issues - {totalIssues} + {widgetStats.count}
{["upcoming", "pending"].includes(tab) &&
Due date
} @@ -84,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; @@ -107,7 +113,7 @@ export const WidgetIssuesList: React.FC = (props) => {
)}
- {issues.length > 0 && ( + {!isLoading && issuesList.length > 0 && ( = observer((props) => { key: "overdue", title: "Issues overdue", count: widgetStats?.pending_issues_count, - link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, + link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`, }, { key: "created", diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx deleted file mode 100644 index 0104c3c1f..000000000 --- a/web/components/dropdowns/cycle.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useApplication, useCycle } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { DropdownButton } from "./buttons"; -// icons -import { ContrastIcon, CycleGroupIcon } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { TDropdownProps } from "./types"; -import { TCycleGroups } from "@plane/types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; - -type Props = TDropdownProps & { - button?: ReactNode; - dropdownArrow?: boolean; - dropdownArrowClassName?: string; - onChange: (val: string | null) => void; - onClose?: () => void; - projectId: string; - value: string | null; -}; - -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - -export const CycleDropdown: React.FC = 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..2c05d9ddf --- /dev/null +++ b/web/components/dropdowns/cycle/index.tsx @@ -0,0 +1,150 @@ +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); + if (isOpen) onClose && onClose(); + }; + + 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..0513ec627 --- /dev/null +++ b/web/components/dropdowns/member/index.tsx @@ -0,0 +1,157 @@ +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); + if (isOpen) onClose && onClose(); + }; + + 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/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index 0c72b986a..57d5d8896 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -53,7 +53,7 @@ const EmojiIconPicker: React.FC = (props) => { setIsOpen((prev) => !prev)} - className="outline-none" + className="outline-none flex items-center justify-center" disabled={disabled} > {label} diff --git a/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx new file mode 100644 index 000000000..1e0882aee --- /dev/null +++ b/web/components/gantt-chart/blocks/block.tsx @@ -0,0 +1,106 @@ +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks"; +import { useIssueDetail } from "hooks/store"; +// components +import { ChartAddBlock, ChartDraggable } from "../helpers"; +// helpers +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +// types +import { IBlockUpdateData, IGanttBlock } from "../types"; +// constants +import { BLOCK_HEIGHT } from "../constants"; + +type Props = { + block: IGanttBlock; + blockToRender: (data: any) => React.ReactNode; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + enableBlockLeftResize: boolean; + enableBlockRightResize: boolean; + enableBlockMove: boolean; + enableAddBlock: boolean; + ganttContainerRef: React.RefObject; +}; + +export const GanttChartBlock: React.FC = observer((props) => { + const { + block, + blockToRender, + blockUpdateHandler, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + enableAddBlock, + ganttContainerRef, + } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const isBlockVisibleOnChart = block.start_date && block.target_date; + + const handleChartBlockPosition = ( + block: IGanttBlock, + totalBlockShifts: number, + dragDirection: "left" | "right" | "move" + ) => { + if (!block.start_date || !block.target_date) return; + + const originalStartDate = new Date(block.start_date); + const updatedStartDate = new Date(originalStartDate); + + const originalTargetDate = new Date(block.target_date); + const updatedTargetDate = new Date(originalTargetDate); + + // update the start date on left resize + if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); + // update the target date on right resize + else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + // update both the dates on x-axis move + else if (dragDirection === "move") { + updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); + updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + } + + // call the block update handler with the updated dates + blockUpdateHandler(block.data, { + start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, + target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, + }); + }; + + return ( +
+
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + > + {isBlockVisibleOnChart ? ( + handleChartBlockPosition(block, ...args)} + enableBlockLeftResize={enableBlockLeftResize} + enableBlockRightResize={enableBlockRightResize} + enableBlockMove={enableBlockMove} + ganttContainerRef={ganttContainerRef} + /> + ) : ( + enableAddBlock && + )} +
+
+ ); +}); diff --git a/web/components/gantt-chart/blocks/blocks-list.tsx b/web/components/gantt-chart/blocks/blocks-list.tsx index 15a3e5295..d98524ecc 100644 --- a/web/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/components/gantt-chart/blocks/blocks-list.tsx @@ -1,16 +1,10 @@ -import { observer } from "mobx-react"; import { FC } from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; -import { useChart } from "../hooks"; -// helpers -import { ChartAddBlock, ChartDraggable } from "components/gantt-chart"; -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; +// components +import { GanttChartBlock } from "./block"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; // constants -import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants"; +import { HEADER_HEIGHT } from "../constants"; export type GanttChartBlocksProps = { itemsContainerWidth: number; @@ -21,10 +15,11 @@ export type GanttChartBlocksProps = { enableBlockRightResize: boolean; enableBlockMove: boolean; enableAddBlock: boolean; + ganttContainerRef: React.RefObject; showAllBlocks: boolean; }; -export const GanttChartBlocksList: FC = observer((props) => { +export const GanttChartBlocksList: FC = (props) => { const { itemsContainerWidth, blocks, @@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC = observer((props) enableBlockRightResize, enableBlockMove, enableAddBlock, + ganttContainerRef, showAllBlocks, } = props; - // store hooks - const { peekIssue } = useIssueDetail(); - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleChartBlockPosition = ( - block: IGanttBlock, - totalBlockShifts: number, - dragDirection: "left" | "right" | "move" - ) => { - if (!block.start_date || !block.target_date) return; - - const originalStartDate = new Date(block.start_date); - const updatedStartDate = new Date(originalStartDate); - - const originalTargetDate = new Date(block.target_date); - const updatedTargetDate = new Date(originalTargetDate); - - // update the start date on left resize - if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); - // update the target date on right resize - else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - // update both the dates on x-axis move - else if (dragDirection === "move") { - updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); - updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - } - - // call the block update handler with the updated dates - blockUpdateHandler(block.data, { - start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, - target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, - }); - }; return (
= observer((props) // hide the block if it doesn't have start and target dates and showAllBlocks is false if (!showAllBlocks && !(block.start_date && block.target_date)) return; - const isBlockVisibleOnChart = block.start_date && block.target_date; - return ( -
-
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - > - {isBlockVisibleOnChart ? ( - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - /> - ) : ( - enableAddBlock && - )} -
-
+ ); })}
); -}); +}; diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index 6dcfdc36f..2ebd0360d 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -1,10 +1,13 @@ import { Expand, Shrink } from "lucide-react"; // hooks -import { useChart } from "../hooks"; // helpers import { cn } from "helpers/common.helper"; // types import { IGanttBlock, TGanttViews } from "../types"; +// constants +import { VIEWS_LIST } from "components/gantt-chart/data"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { blocks: IGanttBlock[] | null; @@ -16,10 +19,10 @@ type Props = { toggleFullScreenMode: () => void; }; -export const GanttChartHeader: React.FC = (props) => { +export const GanttChartHeader: React.FC = observer((props) => { const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props; // chart hook - const { currentView, allViews } = useChart(); + const { currentView } = useGanttChart(); return (
@@ -29,7 +32,7 @@ export const GanttChartHeader: React.FC = (props) => {
- {allViews?.map((chartView: any) => ( + {VIEWS_LIST.map((chartView: any) => (
= (props) => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index 42670b972..8a1c5de26 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -1,3 +1,7 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks/use-gantt-chart"; // components import { BiWeekChartView, @@ -12,7 +16,6 @@ import { TGanttViews, WeekChartView, YearChartView, - useChart, } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; @@ -36,7 +39,7 @@ type Props = { quickAdd?: React.JSX.Element | undefined; }; -export const GanttChartMainContent: React.FC = (props) => { +export const GanttChartMainContent: React.FC = observer((props) => { const { blocks, blockToRender, @@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC = (props) => { updateCurrentViewRenderPayload, quickAdd, } = props; + // refs + const ganttContainerRef = useRef(null); // chart hook - const { currentView, currentViewData, updateScrollLeft } = useChart(); + const { currentView, currentViewData } = useGanttChart(); // handling scroll functionality const onScroll = (e: React.UIEvent) => { const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget; - updateScrollLeft(scrollLeft); + // updateScrollLeft(scrollLeft); const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth; const approxRangeRight = scrollWidth - (scrollLeft + clientWidth); @@ -95,6 +100,7 @@ export const GanttChartMainContent: React.FC = (props) => { "mb-8": bottomSpacing, } )} + ref={ganttContainerRef} onScroll={onScroll} > = (props) => { enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} enableAddBlock={enableAddBlock} + ganttContainerRef={ganttContainerRef} showAllBlocks={showAllBlocks} /> )}
); -}; +}); diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index 877c15901..be6229ce3 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -1,6 +1,9 @@ import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "../hooks/use-gantt-chart"; // components -import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart"; +import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart"; // views import { generateMonthChart, @@ -34,7 +37,7 @@ type ChartViewRootProps = { quickAdd?: React.JSX.Element | undefined; }; -export const ChartViewRoot: FC = (props) => { +export const ChartViewRoot: FC = observer((props) => { const { border, title, @@ -57,7 +60,8 @@ export const ChartViewRoot: FC = (props) => { const [fullScreenMode, setFullScreenMode] = useState(false); const [chartBlocks, setChartBlocks] = useState(null); // hooks - const { currentView, currentViewData, renderView, dispatch } = useChart(); + const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = + useGanttChart(); // rendering the block structure const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => @@ -87,36 +91,20 @@ export const ChartViewRoot: FC = (props) => { // updating the prevData, currentData and nextData if (currentRender.payload.length > 0) { + updateCurrentViewData(currentRender.state); + if (side === "left") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: selectedCurrentView, - currentViewData: currentRender.state, - renderView: [...currentRender.payload, ...renderView], - }, - }); + updateCurrentView(selectedCurrentView); + updateRenderView([...currentRender.payload, ...renderView]); updatingCurrentLeftScrollPosition(currentRender.scrollWidth); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else if (side === "right") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...renderView, ...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView([...renderView, ...currentRender.payload]); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView(currentRender.payload); setItemsContainerWidth(currentRender.scrollWidth); setTimeout(() => { handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate); @@ -206,4 +194,4 @@ export const ChartViewRoot: FC = (props) => { /> ); -}; +}); diff --git a/web/components/gantt-chart/chart/views/bi-week.tsx b/web/components/gantt-chart/chart/views/bi-week.tsx index 6e53d5390..f0ad084e9 100644 --- a/web/components/gantt-chart/chart/views/bi-week.tsx +++ b/web/components/gantt-chart/chart/views/bi-week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const BiWeekChartView: FC = () => { +export const BiWeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const BiWeekChartView: FC = () => { ); -}; +}); diff --git a/web/components/gantt-chart/chart/views/day.tsx b/web/components/gantt-chart/chart/views/day.tsx index a50b7748a..84b2edac4 100644 --- a/web/components/gantt-chart/chart/views/day.tsx +++ b/web/components/gantt-chart/chart/views/day.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const DayChartView: FC = () => { +export const DayChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const DayChartView: FC = () => { ); -}; +}); diff --git a/web/components/gantt-chart/chart/views/hours.tsx b/web/components/gantt-chart/chart/views/hours.tsx index e1fd02e3f..bd1a7b6dd 100644 --- a/web/components/gantt-chart/chart/views/hours.tsx +++ b/web/components/gantt-chart/chart/views/hours.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const HourChartView: FC = () => { +export const HourChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const HourChartView: FC = () => { ); -}; +}); diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index c559e9688..b67b453f1 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -1,6 +1,7 @@ import { FC } from "react"; +import { observer } from "mobx-react"; // hooks -import { useChart } from "components/gantt-chart"; +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // types @@ -8,15 +9,15 @@ import { IMonthBlock } from "../../views"; // constants import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants"; -export const MonthChartView: FC = () => { +export const MonthChartView: FC = observer(() => { // chart hook - const { currentViewData, renderView } = useChart(); + const { currentViewData, renderView } = useGanttChart(); const monthBlocks: IMonthBlock[] = renderView; return ( -
+
{monthBlocks?.map((block, rootIndex) => ( -
+
= () => { ))}
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/quarter.tsx b/web/components/gantt-chart/chart/views/quarter.tsx index ffbc1cbfe..b8adc4b3a 100644 --- a/web/components/gantt-chart/chart/views/quarter.tsx +++ b/web/components/gantt-chart/chart/views/quarter.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const QuarterChartView: FC = () => { +export const QuarterChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const QuarterChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/week.tsx b/web/components/gantt-chart/chart/views/week.tsx index 8170affa4..981fc9236 100644 --- a/web/components/gantt-chart/chart/views/week.tsx +++ b/web/components/gantt-chart/chart/views/week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const WeekChartView: FC = () => { +export const WeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const WeekChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/year.tsx b/web/components/gantt-chart/chart/views/year.tsx index 9dbeedece..659126ac3 100644 --- a/web/components/gantt-chart/chart/views/year.tsx +++ b/web/components/gantt-chart/chart/views/year.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const YearChartView: FC = () => { +export const YearChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const YearChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/contexts/index.tsx b/web/components/gantt-chart/contexts/index.tsx index 84e7a19b5..1d8a19f1a 100644 --- a/web/components/gantt-chart/contexts/index.tsx +++ b/web/components/gantt-chart/contexts/index.tsx @@ -1,57 +1,19 @@ -import React, { createContext, useState } from "react"; -// types -import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types"; -// data -import { allViewsWithData, currentViewDataWithView } from "../data"; +import { createContext } from "react"; +// mobx store +import { GanttStore } from "store/issue/issue_gantt_view.store"; -export const ChartContext = createContext(undefined); +let ganttViewStore = new GanttStore(); -const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => { - switch (action.type) { - case "CURRENT_VIEW": - return { ...state, currentView: action.payload }; - case "CURRENT_VIEW_DATA": - return { ...state, currentViewData: action.payload }; - case "RENDER_VIEW": - return { ...state, currentViewData: action.payload }; - case "PARTIAL_UPDATE": - return { ...state, ...action.payload }; - default: - return state; - } +export const GanttStoreContext = createContext(ganttViewStore); + +const initializeStore = () => { + const _ganttStore = ganttViewStore ?? new GanttStore(); + if (typeof window === "undefined") return _ganttStore; + if (!ganttViewStore) ganttViewStore = _ganttStore; + return _ganttStore; }; -const initialView = "month"; - -export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // states; - const [state, dispatch] = useState({ - currentView: initialView, - currentViewData: currentViewDataWithView(initialView), - renderView: [], - allViews: allViewsWithData, - activeBlock: null, - }); - const [scrollLeft, setScrollLeft] = useState(0); - - const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { - const newState = chartReducer(state, action); - dispatch(() => newState); - return newState; - }; - - const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft); - - return ( - - {children} - - ); +export const GanttStoreProvider = ({ children }: any) => { + const store = initializeStore(); + return {children}; }; diff --git a/web/components/gantt-chart/data/index.ts b/web/components/gantt-chart/data/index.ts index 58ac6e4b2..cc15c5d9e 100644 --- a/web/components/gantt-chart/data/index.ts +++ b/web/components/gantt-chart/data/index.ts @@ -1,5 +1,5 @@ // types -import { WeekMonthDataType, ChartDataType } from "../types"; +import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types"; // constants export const weeks: WeekMonthDataType[] = [ @@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => { }; // context data -export const allViewsWithData: ChartDataType[] = [ +export const VIEWS_LIST: ChartDataType[] = [ // { // key: "hours", // title: "Hours", @@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [ // }, ]; -export const currentViewDataWithView = (view: string = "month") => { - const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view); - return currentView; -}; +export const currentViewDataWithView = (view: TGanttViews = "month") => + VIEWS_LIST.find((_viewData) => _viewData.key === view); diff --git a/web/components/gantt-chart/helpers/add-block.tsx b/web/components/gantt-chart/helpers/add-block.tsx index bfeddffa2..b7497013f 100644 --- a/web/components/gantt-chart/helpers/add-block.tsx +++ b/web/components/gantt-chart/helpers/add-block.tsx @@ -1,21 +1,21 @@ import { useEffect, useRef, useState } from "react"; import { addDays } from "date-fns"; import { Plus } from "lucide-react"; -// hooks -import { useChart } from "../hooks"; // ui import { Tooltip } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { IBlockUpdateData, IGanttBlock } from "../types"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { block: IGanttBlock; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; }; -export const ChartAddBlock: React.FC = (props) => { +export const ChartAddBlock: React.FC = observer((props) => { const { block, blockUpdateHandler } = props; // states const [isButtonVisible, setIsButtonVisible] = useState(false); @@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC = (props) => { // refs const containerRef = useRef(null); // chart hook - const { currentViewData } = useChart(); + const { currentViewData } = useGanttChart(); const handleButtonClick = () => { if (!currentViewData) return; @@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC = (props) => { )}
); -}; +}); diff --git a/web/components/gantt-chart/helpers/block-structure.ts b/web/components/gantt-chart/helpers/block-structure.ts deleted file mode 100644 index 0f18b43cc..000000000 --- a/web/components/gantt-chart/helpers/block-structure.ts +++ /dev/null @@ -1,12 +0,0 @@ -// types -import { TIssue } from "@plane/types"; -import { IGanttBlock } from "components/gantt-chart"; - -export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => - blocks?.map((block) => ({ - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: block.start_date ? new Date(block.start_date) : null, - target_date: block.target_date ? new Date(block.target_date) : null, - })); diff --git a/web/components/gantt-chart/helpers/draggable.tsx b/web/components/gantt-chart/helpers/draggable.tsx index ac1602346..c2b4dc619 100644 --- a/web/components/gantt-chart/helpers/draggable.tsx +++ b/web/components/gantt-chart/helpers/draggable.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; import { ArrowRight } from "lucide-react"; // hooks -import { IGanttBlock, useChart } from "components/gantt-chart"; +import { IGanttBlock } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // constants import { SIDEBAR_WIDTH } from "../constants"; +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { observer } from "mobx-react"; type Props = { block: IGanttBlock; @@ -14,19 +16,29 @@ type Props = { enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; + ganttContainerRef: React.RefObject; }; -export const ChartDraggable: React.FC = (props) => { - const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props; +export const ChartDraggable: React.FC = observer((props) => { + const { + block, + blockToRender, + handleBlock, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + } = props; // states const [isLeftResizing, setIsLeftResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false); const [isMoving, setIsMoving] = useState(false); const [isHidden, setIsHidden] = useState(true); + const [scrollLeft, setScrollLeft] = useState(0); // refs const resizableRef = useRef(null); // chart hook - const { currentViewData, scrollLeft } = useChart(); + const { currentViewData } = useGanttChart(); // check if cursor reaches either end while resizing/dragging const checkScrollEnd = (e: MouseEvent): number => { const SCROLL_THRESHOLD = 70; @@ -212,6 +224,17 @@ export const ChartDraggable: React.FC = (props) => { block.position?.width && scrollLeft > block.position.marginLeft + block.position.width; + useEffect(() => { + const ganttContainer = ganttContainerRef.current; + if (!ganttContainer) return; + + const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft); + ganttContainer.addEventListener("scroll", handleScroll); + return () => { + ganttContainer.removeEventListener("scroll", handleScroll); + }; + }, [ganttContainerRef]); + useEffect(() => { const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; const resizableBlock = resizableRef.current; @@ -234,7 +257,7 @@ export const ChartDraggable: React.FC = (props) => { return () => { observer.unobserve(resizableBlock); }; - }, [block.data.name]); + }, []); return ( <> @@ -312,4 +335,4 @@ export const ChartDraggable: React.FC = (props) => { ); -}; +}); diff --git a/web/components/gantt-chart/helpers/index.ts b/web/components/gantt-chart/helpers/index.ts index 1b51dc374..c96d42eec 100644 --- a/web/components/gantt-chart/helpers/index.ts +++ b/web/components/gantt-chart/helpers/index.ts @@ -1,3 +1,2 @@ export * from "./add-block"; -export * from "./block-structure"; export * from "./draggable"; diff --git a/web/components/gantt-chart/hooks/index.ts b/web/components/gantt-chart/hooks/index.ts new file mode 100644 index 000000000..009650675 --- /dev/null +++ b/web/components/gantt-chart/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-gantt-chart"; diff --git a/web/components/gantt-chart/hooks/index.tsx b/web/components/gantt-chart/hooks/index.tsx deleted file mode 100644 index 5fb9bee3f..000000000 --- a/web/components/gantt-chart/hooks/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; -// types -import { ChartContextReducer } from "../types"; -// context -import { ChartContext } from "../contexts"; - -export const useChart = (): ChartContextReducer => { - const context = useContext(ChartContext); - - if (!context) throw new Error("useChart must be used within a GanttChart"); - - return context; -}; diff --git a/web/components/gantt-chart/hooks/use-gantt-chart.ts b/web/components/gantt-chart/hooks/use-gantt-chart.ts new file mode 100644 index 000000000..23e025e90 --- /dev/null +++ b/web/components/gantt-chart/hooks/use-gantt-chart.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { GanttStoreContext } from "components/gantt-chart/contexts"; +// types +import { IGanttStore } from "store/issue/issue_gantt_view.store"; + +export const useGanttChart = (): IGanttStore => { + const context = useContext(GanttStoreContext); + if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider"); + return context; +}; diff --git a/web/components/gantt-chart/index.ts b/web/components/gantt-chart/index.ts index 54a2cc597..78297ffcd 100644 --- a/web/components/gantt-chart/index.ts +++ b/web/components/gantt-chart/index.ts @@ -3,5 +3,5 @@ export * from "./chart"; export * from "./helpers"; export * from "./hooks"; export * from "./root"; -export * from "./types"; export * from "./sidebar"; +export * from "./types"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index ac132500b..4df5d9931 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // components import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; // context -import { ChartContextProvider } from "./contexts"; +import { GanttStoreProvider } from "components/gantt-chart/contexts"; type GanttChartRootProps = { border?: boolean; @@ -42,7 +42,7 @@ export const GanttChartRoot: FC = (props) => { } = props; return ( - + = (props) => { showAllBlocks={showAllBlocks} quickAdd={quickAdd} /> - + ); }; diff --git a/web/components/gantt-chart/sidebar/cycles.tsx b/web/components/gantt-chart/sidebar/cycles.tsx deleted file mode 100644 index 384869a40..000000000 --- a/web/components/gantt-chart/sidebar/cycles.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { CycleGanttSidebarBlock } from "components/cycles"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const CycleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/cycles/block.tsx b/web/components/gantt-chart/sidebar/cycles/block.tsx new file mode 100644 index 000000000..f1374c753 --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { CycleGanttSidebarBlock } from "components/cycles"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "components/gantt-chart/types"; +// constants +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const CyclesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/cycles/index.ts b/web/components/gantt-chart/sidebar/cycles/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/cycles/sidebar.tsx b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx new file mode 100644 index 000000000..11f67a099 --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { CyclesSidebarBlock } from "./block"; +// types +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const CycleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/issues.tsx b/web/components/gantt-chart/sidebar/issues.tsx deleted file mode 100644 index 52e30ded5..000000000 --- a/web/components/gantt-chart/sidebar/issues.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { observer } from "mobx-react"; -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -import { useIssueDetail } from "hooks/store"; -// ui -import { Loader } from "@plane/ui"; -// components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; - showAllBlocks?: boolean; -}; - -export const IssueGanttSidebar: React.FC = observer((props: Props) => { - const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; - - const { activeBlock, dispatch } = useChart(); - const { peekIssue } = useIssueDetail(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - <> - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const isBlockVisibleOnSidebar = block.start_date && block.target_date; - - // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!showAllBlocks && !isBlockVisibleOnSidebar) return; - - const duration = - !block.start_date || !block.target_date - ? null - : findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- - {duration} day{duration > 1 ? "s" : ""} - -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- - ); -}); diff --git a/web/components/gantt-chart/sidebar/issues/block.tsx b/web/components/gantt-chart/sidebar/issues/block.tsx new file mode 100644 index 000000000..03a17a65b --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/block.tsx @@ -0,0 +1,77 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useIssueDetail } from "hooks/store"; +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { IssueGanttSidebarBlock } from "components/issues"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "../../types"; +// constants +import { BLOCK_HEIGHT } from "../../constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const IssuesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ + {duration} day{duration > 1 ? "s" : ""} + +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/issues/index.ts b/web/components/gantt-chart/sidebar/issues/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/components/gantt-chart/sidebar/issues/sidebar.tsx new file mode 100644 index 000000000..323938eec --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -0,0 +1,107 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// components +import { IssuesSidebarBlock } from "./block"; +// ui +import { Loader } from "@plane/ui"; +// types +import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; + +type Props = { + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; + showAllBlocks?: boolean; +}; + +export const IssueGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => { + const isBlockVisibleOnSidebar = block.start_date && block.target_date; + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!showAllBlocks && !isBlockVisibleOnSidebar) return; + + return ( + + {(provided, snapshot) => ( + + )} + + ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/modules.tsx b/web/components/gantt-chart/sidebar/modules.tsx deleted file mode 100644 index bdf8ca571..000000000 --- a/web/components/gantt-chart/sidebar/modules.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { ModuleGanttSidebarBlock } from "components/modules"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const ModuleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/modules/block.tsx b/web/components/gantt-chart/sidebar/modules/block.tsx new file mode 100644 index 000000000..4b2e47226 --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { ModuleGanttSidebarBlock } from "components/modules"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +import { IGanttBlock } from "components/gantt-chart/types"; +// constants +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const ModulesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration !== undefined && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/modules/index.ts b/web/components/gantt-chart/sidebar/modules/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/modules/sidebar.tsx b/web/components/gantt-chart/sidebar/modules/sidebar.tsx new file mode 100644 index 000000000..dee83fa79 --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { ModulesSidebarBlock } from "./block"; +// types +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const ModuleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/project-views.tsx b/web/components/gantt-chart/sidebar/project-views.tsx index a27c4dded..a7e7c5e35 100644 --- a/web/components/gantt-chart/sidebar/project-views.tsx +++ b/web/components/gantt-chart/sidebar/project-views.tsx @@ -1,17 +1,10 @@ import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; // ui import { Loader } from "@plane/ui"; // components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { IssuesSidebarBlock } from "./issues/block"; // types import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; type Props = { title: string; @@ -23,18 +16,6 @@ type Props = { export const ProjectViewGanttSidebar: React.FC = (props) => { const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; const handleOrderChange = (result: DropResult) => { if (!blocks) return; @@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC = (props) => { > <> {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) ) : ( diff --git a/web/components/gantt-chart/types/index.ts b/web/components/gantt-chart/types/index.ts index 1360f9f45..6268e4363 100644 --- a/web/components/gantt-chart/types/index.ts +++ b/web/components/gantt-chart/types/index.ts @@ -1,10 +1,3 @@ -// context types -export type allViewsType = { - key: string; - title: string; - data: Object | null; -}; - export interface IGanttBlock { data: any; id: string; @@ -29,34 +22,6 @@ export interface IBlockUpdateData { export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; -export interface ChartContextData { - allViews: allViewsType[]; - currentView: TGanttViews; - currentViewData: ChartDataType | undefined; - renderView: any; - activeBlock: IGanttBlock | null; -} - -export type ChartContextActionPayload = - | { - type: "CURRENT_VIEW"; - payload: TGanttViews; - } - | { - type: "CURRENT_VIEW_DATA" | "RENDER_VIEW"; - payload: ChartDataType | undefined; - } - | { - type: "PARTIAL_UPDATE"; - payload: Partial; - }; - -export interface ChartContextReducer extends ChartContextData { - scrollLeft: number; - updateScrollLeft: (scrollLeft: number) => void; - dispatch: (action: ChartContextActionPayload) => void; -} - // chart render types export interface WeekMonthDataType { key: number; diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index a0ac3c9aa..e8236288c 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -308,6 +308,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} /> diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index c1846cdc2..07f779819 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -312,6 +312,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["module"]} /> diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index 3b3e05f1a..9d4596f83 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -71,7 +71,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { 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 93845a41d..7407d3d9c 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: !data.name && !data.description_html ? Object.values(data).join(",") : undefined, @@ -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) => {
diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index e0dcdac47..4b5da3a9b 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, @@ -157,6 +160,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); @@ -320,6 +349,7 @@ export const IssueDetailRoot: FC = observer((props) => { fetchIssue, updateIssue, removeIssue, + archiveIssue, removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, @@ -351,7 +381,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 @@ -65,8 +65,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const { issue: { getIssueById }, } = useIssueDetail(); - // states - const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const { getStateById } = useProjectState(); const issue = getIssueById(issueId); if (!issue) return <>; @@ -82,7 +81,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()); @@ -90,50 +105,74 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); - const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1; - 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 +200,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -198,7 +237,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
- + Start date
= observer((props) => {
- + Due date
= observer((props) => { buttonContainerClassName="w-full text-left" buttonClassName={cn("text-sm", { "text-custom-text-400": !issue.target_date, - "text-red-500": targetDateDistance <= 0, + "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), })} hideIcon clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100" 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/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 4dcaab335..badb849fb 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -73,42 +73,44 @@ export const CalendarChart: React.FC = observer((props) => { <>
- -
- {layout === "month" && ( -
- {allWeeksOfActiveMonth && - Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( - - ))} -
- )} - {layout === "week" && ( - - )} +
+ +
+ {layout === "month" && ( +
+ {allWeeksOfActiveMonth && + Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( + + ))} +
+ )} + {layout === "week" && ( + + )} +
diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 85ab152a7..f92365a58 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -91,7 +91,7 @@ export const CalendarDayTile: React.FC = observer((props) => { snapshot.isDraggingOver || date.date.getDay() === 0 || date.date.getDay() === 6 ? "bg-custom-background-90" : "bg-custom-background-100" - } ${calendarLayout === "month" ? "min-h-[9rem]" : ""}`} + } ${calendarLayout === "month" ? "min-h-[5rem]" : ""}`} {...provided.droppableProps} ref={provided.innerRef} > diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 49ca84eb6..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 @@ -79,6 +79,7 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { target="_blank" onClick={() => handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > <> {issue?.tempId !== undefined && ( @@ -107,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/calendar/week-header.tsx b/web/components/issues/issue-layouts/calendar/week-header.tsx index f5ec41e96..a4b714c94 100644 --- a/web/components/issues/issue-layouts/calendar/week-header.tsx +++ b/web/components/issues/issue-layouts/calendar/week-header.tsx @@ -13,7 +13,7 @@ export const CalendarWeekHeader: React.FC = observer((props) => { return (
diff --git a/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx new file mode 100644 index 000000000..6299bebd7 --- /dev/null +++ b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// hooks +import { useCycle } from "hooks/store"; +// ui +import { CycleGroupIcon } from "@plane/ui"; +// types +import { TCycleGroups } from "@plane/types"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedCycleFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { getCycleById } = useCycle(); + + return ( + <> + {values.map((cycleId) => { + const cycleDetails = getCycleById(cycleId) ?? null; + + if (!cycleDetails) return null; + + const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups; + + return ( +
+ + {cycleDetails.name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 4ca2538e5..03b0c5138 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -1,12 +1,15 @@ import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; +import { useRouter } from "next/router"; // hooks -import { useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // components import { + AppliedCycleFilters, AppliedDateFilters, AppliedLabelsFilters, AppliedMembersFilters, + AppliedModuleFilters, AppliedPriorityFilters, AppliedProjectFilters, AppliedStateFilters, @@ -34,6 +37,9 @@ const dateFilters = ["start_date", "target_date"]; export const AppliedFiltersList: React.FC = observer((props) => { const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; // store hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); const { membership: { currentProjectRole }, } = useUser(); @@ -104,6 +110,20 @@ export const AppliedFiltersList: React.FC = observer((props) => { values={value} /> )} + {filterKey === "cycle" && !cycleId && ( + handleRemoveFilter("cycle", val)} + values={value} + /> + )} + {filterKey === "module" && !moduleId && ( + handleRemoveFilter("module", val)} + values={value} + /> + )} {isEditingAllowed && ( + )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx index 3c94b4f3f..131bea46b 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx @@ -11,7 +11,7 @@ import { FilterSubGroupBy, } from "components/issues"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueGroupByOptions } from "@plane/types"; import { ILayoutDisplayFiltersOptions } from "constants/issue"; type Props = { @@ -20,6 +20,7 @@ type Props = { handleDisplayFiltersUpdate: (updatedDisplayFilter: Partial) => void; handleDisplayPropertiesUpdate: (updatedDisplayProperties: Partial) => void; layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + ignoreGroupedFilters?: Partial[]; }; export const DisplayFiltersSelection: React.FC = observer((props) => { @@ -29,6 +30,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { handleDisplayFiltersUpdate, handleDisplayPropertiesUpdate, layoutDisplayFiltersOptions, + ignoreGroupedFilters = [], } = props; const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) => @@ -54,6 +56,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { group_by: val, }) } + ignoreGroupedFilters={ignoreGroupedFilters} />
)} @@ -71,6 +74,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { }) } subGroupByOptions={layoutDisplayFiltersOptions?.display_filters.sub_group_by ?? []} + ignoreGroupedFilters={ignoreGroupedFilters} />
)} diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx index 659d86d08..a4478e834 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/group-by.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; - // components import { FilterHeader, FilterOption } from "components/issues"; // types @@ -12,10 +11,11 @@ type Props = { displayFilters: IIssueDisplayFilterOptions; groupByOptions: TIssueGroupByOptions[]; handleUpdate: (val: TIssueGroupByOptions) => void; + ignoreGroupedFilters: Partial[]; }; export const FilterGroupBy: React.FC = observer((props) => { - const { displayFilters, groupByOptions, handleUpdate } = props; + const { displayFilters, groupByOptions, handleUpdate, ignoreGroupedFilters } = props; const [previewEnabled, setPreviewEnabled] = useState(true); @@ -34,6 +34,7 @@ export const FilterGroupBy: React.FC = observer((props) => { {ISSUE_GROUP_BY_OPTIONS.filter((option) => groupByOptions.includes(option.key)).map((groupBy) => { if (displayFilters.layout === "kanban" && selectedSubGroupBy !== null && groupBy.key === selectedSubGroupBy) return null; + if (ignoreGroupedFilters.includes(groupBy?.key)) return null; return ( void; subGroupByOptions: TIssueGroupByOptions[]; + ignoreGroupedFilters: Partial[]; }; export const FilterSubGroupBy: React.FC = observer((props) => { - const { displayFilters, handleUpdate, subGroupByOptions } = props; + const { displayFilters, handleUpdate, subGroupByOptions, ignoreGroupedFilters } = props; const [previewEnabled, setPreviewEnabled] = useState(true); @@ -33,6 +33,7 @@ export const FilterSubGroupBy: React.FC = observer((props) => {
{ISSUE_GROUP_BY_OPTIONS.filter((option) => subGroupByOptions.includes(option.key)).map((subGroupBy) => { if (selectedGroupBy !== null && subGroupBy.key === selectedGroupBy) return null; + if (ignoreGroupedFilters.includes(subGroupBy?.key)) return null; return ( void; + searchQuery: string; +}; + +export const FilterCycle: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getCycleById, getProjectCycleIds } = useCycle(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const cycleIds = projectId ? getProjectCycleIds(projectId) : undefined; + const cycles = cycleIds?.map((projectId) => getCycleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + cycles?.filter((cycle) => cycle.name.toLowerCase().includes(searchQuery.toLowerCase())), + (cycle) => cycle.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + const cycleStatus = (status: TCycleGroups) => (status ? status.toLocaleLowerCase() : "draft") as TCycleGroups; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={ + + } + title={cycle.name} + activePulse={cycleStatus(cycle?.status) === "current" ? true : false} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx index 19e3ec841..5a41ccf4d 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; +// hooks +import { useApplication } from "hooks/store"; // components import { FilterAssignees, @@ -13,6 +15,8 @@ import { FilterState, FilterStateGroup, FilterTargetDate, + FilterCycle, + FilterModule, } from "components/issues"; // hooks import useDebounce from "hooks/use-debounce"; @@ -32,8 +36,11 @@ type Props = { }; export const FilterSelection: React.FC = observer((props) => { - const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states, onSearchCapture } = - props; + const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states, onSearchCapture } = props; + // hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -111,6 +118,28 @@ export const FilterSelection: React.FC = observer((props) => {
)} + {/* cycle */} + {isFilterEnabled("cycle") && !cycleId && ( +
+ handleFiltersUpdate("cycle", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* module */} + {isFilterEnabled("module") && !moduleId && ( +
+ handleFiltersUpdate("module", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* assignees */} {isFilterEnabled("mentions") && (
diff --git a/web/components/issues/issue-layouts/filters/header/filters/index.ts b/web/components/issues/issue-layouts/filters/header/filters/index.ts index 2d3a04d0f..ab5756bf4 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/index.ts +++ b/web/components/issues/issue-layouts/filters/header/filters/index.ts @@ -8,4 +8,6 @@ export * from "./project"; export * from "./start-date"; export * from "./state-group"; export * from "./state"; +export * from "./cycle"; +export * from "./module"; export * from "./target-date"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/module.tsx b/web/components/issues/issue-layouts/filters/header/filters/module.tsx new file mode 100644 index 000000000..49e00f84d --- /dev/null +++ b/web/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +import { useApplication, useModule } from "hooks/store"; +// ui +import { Loader, DiceIcon } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterModule: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getModuleById, getProjectModuleIds } = useModule(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const moduleIds = projectId ? getProjectModuleIds(projectId) : undefined; + const modules = moduleIds?.map((projectId) => getModuleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + modules?.filter((module) => module.name.toLowerCase().includes(searchQuery.toLowerCase())), + (module) => module.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={} + title={cycle.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx index f46d962ea..26c7bfaf5 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx @@ -8,10 +8,11 @@ type Props = { title: React.ReactNode; onClick?: () => void; multiple?: boolean; + activePulse?: boolean; }; export const FilterOption: React.FC = (props) => { - const { icon, isChecked, multiple = true, onClick, title } = props; + const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props; return (
+ {activePulse && ( +
+ )} ); }; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index b5f092aba..ec33872eb 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -5,12 +5,9 @@ import { observer } from "mobx-react-lite"; import { useIssues, useUser } from "hooks/store"; // components import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues"; -import { - GanttChartRoot, - IBlockUpdateData, - renderIssueBlocksStructure, - IssueGanttSidebar, -} from "components/gantt-chart"; +import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart"; +// helpers +import { renderIssueBlocksStructure } from "helpers/issue.helper"; // types import { TIssue, TUnGroupedIssues } from "@plane/types"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 18a767455..209d876ac 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -29,6 +29,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { const handleIssuePeekOverview = () => workspaceSlug && issueDetails && + !issueDetails.tempId && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); return ( @@ -65,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(); @@ -75,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 = () => @@ -89,11 +90,12 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { target="_blank" onClick={handleIssuePeekOverview} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issueDetails?.tempId} > -
+
{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 fb83a14e3..8446e7328 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -42,7 +42,7 @@ 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 }, } = useApplication(); @@ -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)}
@@ -82,6 +82,7 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop target="_blank" onClick={() => handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > {issue.name} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index b645afe30..cefa84826 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -1,6 +1,15 @@ import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail, useKanbanView, useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { + useCycle, + useIssueDetail, + useKanbanView, + useLabel, + useMember, + useModule, + useProject, + useProjectState, +} from "hooks/store"; // components import { HeaderGroupByCard } from "./headers/group-by-card"; import { KanbanGroup } from "./kanban-group"; @@ -79,14 +88,16 @@ const GroupByKanBan: React.FC = observer((props) => { const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const _module = useModule(); const projectState = useProjectState(); const { peekIssue } = useIssueDetail(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + const list = getGroupByColumns(group_by as GroupByColumnTypes, project, cycle, _module, label, projectState, member); if (!list) return null; - const groupWithIssues = list.filter((_list) => (issueIds as TGroupedIssues)[_list.id]?.length > 0); + const groupWithIssues = list.filter((_list) => (issueIds as TGroupedIssues)?.[_list.id]?.length > 0); const groupList = showEmptyGroup ? list : groupWithIssues; diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 7cbda05e1..a2f9ec4fb 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -80,6 +80,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: groupValue }; } else if (groupByKey === "priority") { preloadedData = { ...preloadedData, priority: groupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: groupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [groupValue] }; } else if (groupByKey === "labels" && groupValue != "None") { preloadedData = { ...preloadedData, label_ids: [groupValue] }; } else if (groupByKey === "assignees" && groupValue != "None") { @@ -96,6 +100,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: subGroupValue }; } else if (subGroupByKey === "priority") { preloadedData = { ...preloadedData, priority: subGroupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: subGroupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [subGroupValue] }; } else if (subGroupByKey === "labels" && subGroupValue != "None") { preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; } else if (subGroupByKey === "assignees" && subGroupValue != "None") { 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/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 5fdb58ef0..7b57752f2 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -18,7 +18,7 @@ import { } from "@plane/types"; // constants import { EIssueActions } from "../types"; -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; @@ -217,10 +217,28 @@ export const KanBanSwimLanes: React.FC = observer((props) => { const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const _module = useModule(); const projectState = useProjectState(); - const groupByList = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); - const subGroupByList = getGroupByColumns(sub_group_by as GroupByColumnTypes, project, label, projectState, member); + const groupByList = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member + ); + const subGroupByList = getGroupByColumns( + sub_group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member + ); if (!groupByList || !subGroupByList) return null; 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 7a34311cc..cc04ed716 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -26,7 +26,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const { 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}
)} @@ -76,6 +76,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock target="_blank" onClick={() => handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > {issue.name} diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index e148d5131..c6f82c2be 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -3,7 +3,7 @@ import { useRef } from "react"; import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; // types import { GroupByColumnTypes, @@ -65,10 +65,21 @@ const GroupByList: React.FC = (props) => { const project = useProject(); const label = useLabel(); const projectState = useProjectState(); + const cycle = useCycle(); + const _module = useModule(); const containerRef = useRef(null); - const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const groups = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + _module, + label, + projectState, + member, + true + ); if (!groups) return null; 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 840967565..776a1cd46 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,11 +1,10 @@ import { useCallback, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { differenceInCalendarDays } from "date-fns"; -import { Layers, Link, Paperclip } from "lucide-react"; +import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; import xor from "lodash/xor"; // hooks -import { useEventTracker, useEstimate, useLabel, useIssues } from "hooks/store"; +import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store"; // components import { IssuePropertyLabels } from "../properties/labels"; import { Tooltip } from "@plane/ui"; @@ -14,13 +13,15 @@ import { DateDropdown, EstimateDropdown, PriorityDropdown, - ProjectMemberDropdown, + MemberDropdown, ModuleDropdown, CycleDropdown, StateDropdown, } from "components/dropdowns"; // helpers import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; +import { cn } from "helpers/common.helper"; // types import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; // constants @@ -47,11 +48,14 @@ export const IssueProperties: React.FC = observer((props) => { const { issues: { addIssueToCycle, removeIssueFromCycle }, } = useIssues(EIssuesStoreType.CYCLE); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { getStateById } = useProjectState(); // router const router = useRouter(); const { workspaceSlug, cycleId, moduleId } = router.query; - const { areEstimatesEnabledForCurrentProject } = useEstimate(); const currentLayout = `${activeLayout} layout`; + // derived values + const stateDetails = getStateById(issue.state_id); const issueOperations = useMemo( () => ({ @@ -231,8 +235,6 @@ export const IssueProperties: React.FC = observer((props) => { const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); - const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1; - return (
{/* basic properties */} @@ -284,6 +286,7 @@ export const IssueProperties: React.FC = observer((props) => { onChange={handleStartDate} maxDate={maxDate ?? undefined} placeholder="Start date" + icon={} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} disabled={isReadOnly} showTooltip @@ -299,8 +302,9 @@ export const IssueProperties: React.FC = observer((props) => { onChange={handleTargetDate} minDate={minDate ?? undefined} placeholder="Due date" + icon={} buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} - buttonClassName={targetDateDistance <= 0 ? "text-red-500" : ""} + buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""} clearIconClassName="!text-custom-text-100" disabled={isReadOnly} showTooltip @@ -311,7 +315,7 @@ export const IssueProperties: React.FC = observer((props) => { {/* assignee */}
- = observer((props) => { {/* cycles */} {cycleId === undefined && ( -
+
= observer((props) => { !!properties.sub_issue_count} + shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count} >
{}} + className={cn( + "flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1", + { + "hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count, + } + )} >
{issue.sub_issues_count}
@@ -395,7 +404,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.attachment_count} + shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count} >
@@ -409,7 +418,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.link} + shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count} >
diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 7e14ad3da..0c1091d39 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,10 +1,11 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search, Tags } from "lucide-react"; // hooks import { useApplication, useLabel } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { Combobox } from "@headlessui/react"; import { Tooltip } from "@plane/ui"; @@ -48,6 +49,10 @@ export const IssuePropertyLabels: React.FC = 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 &&
)} @@ -531,7 +590,7 @@ export const IssueFormRoot: FC = observer((props) => { }} value={value} buttonVariant="border-with-text" - tabIndex={11} + tabIndex={getTabIndex("cycle_id")} />
)} @@ -551,7 +610,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} buttonVariant="border-with-text" - tabIndex={12} + tabIndex={getTabIndex("module_ids")} multiple showCount /> @@ -573,7 +632,7 @@ export const IssueFormRoot: FC = observer((props) => { }} projectId={projectId} buttonVariant="border-with-text" - tabIndex={13} + tabIndex={getTabIndex("estimate_point")} />
)} @@ -603,7 +662,7 @@ export const IssueFormRoot: FC = observer((props) => { } placement="bottom-start" - tabIndex={14} + tabIndex={getTabIndex("parent_id")} > {watch("parent_id") ? ( <> @@ -653,7 +712,7 @@ export const IssueFormRoot: FC = observer((props) => { onKeyDown={(e) => { if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); }} - tabIndex={15} + tabIndex={getTabIndex("create_more")} >
{}} size="sm" /> @@ -661,7 +720,7 @@ export const IssueFormRoot: FC = observer((props) => { Create more
- @@ -673,7 +732,7 @@ export const IssueFormRoot: FC = observer((props) => { size="sm" loading={isSubmitting} onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))} - tabIndex={17} + tabIndex={getTabIndex("draft_button")} > {isSubmitting ? "Moving" : "Move from draft"} @@ -683,7 +742,7 @@ export const IssueFormRoot: FC = observer((props) => { size="sm" loading={isSubmitting} onClick={handleSubmit((data) => handleFormSubmit(data, true))} - tabIndex={17} + tabIndex={getTabIndex("draft_button")} > {isSubmitting ? "Saving" : "Save as draft"} @@ -691,7 +750,13 @@ export const IssueFormRoot: FC = observer((props) => { )} -
diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 339045b90..dc73bc77a 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -3,7 +3,16 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; // hooks -import { useApplication, useEventTracker, useCycle, useIssues, useModule, useProject } from "hooks/store"; +import { + useApplication, + useEventTracker, + useCycle, + useIssues, + useModule, + useProject, + useWorkspace, + useIssueDetail, +} from "hooks/store"; import useToast from "hooks/use-toast"; import useLocalStorage from "hooks/use-local-storage"; // components @@ -39,6 +48,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const [changesMade, setChangesMade] = useState | null>(null); const [createMore, setCreateMore] = useState(false); const [activeProjectId, setActiveProjectId] = useState(null); + const [description, setDescription] = useState(undefined); // store hooks const { captureIssueEvent } = useEventTracker(); const { @@ -52,7 +62,8 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW); const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE); - const { issues: draftIssueStore } = useIssues(EIssuesStoreType.DRAFT); + const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); + const { fetchIssue } = useIssueDetail(); // store mapping based on current store const issueStores = { [EIssuesStoreType.PROJECT]: { @@ -81,11 +92,26 @@ export const CreateUpdateIssueModal: React.FC = observer((prop // toast alert const { setToastAlert } = useToast(); // local storage - const { setValue: setLocalStorageDraftIssue } = useLocalStorage("draftedIssue", {}); + const { storedValue: localStorageDraftIssues, setValue: setLocalStorageDraftIssue } = useLocalStorage< + Record> + >("draftedIssue", {}); // current store details const { store: currentIssueStore, viewId } = issueStores[storeType]; + const fetchIssueDetail = async (issueId: string | undefined) => { + if (!workspaceSlug) return; + if (!projectId || issueId === undefined) { + setDescription("

"); + return; + } + const response = await fetchIssue(workspaceSlug, projectId, issueId, isDraft ? "DRAFT" : "DEFAULT"); + if (response) setDescription(response?.description_html || "

"); + }; + useEffect(() => { + // fetching issue details + if (isOpen) fetchIssueDetail(data?.id); + // if modal is closed, reset active project to null // and return to avoid activeProjectId being set to some other project if (!isOpen) { @@ -104,6 +130,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop // in the url. This has the least priority. if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId) setActiveProjectId(projectId ?? workspaceProjectIds?.[0]); + + // clearing up the description state when we leave the component + return () => setDescription(undefined); }, [data, projectId, workspaceProjectIds, isOpen, activeProjectId]); const addIssueToCycle = async (issue: TIssue, cycleId: string) => { @@ -126,9 +155,14 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const handleClose = (saveDraftIssueInLocalStorage?: boolean) => { if (changesMade && saveDraftIssueInLocalStorage) { - const draftIssue = JSON.stringify(changesMade); - setLocalStorageDraftIssue(draftIssue); + // updating the current edited issue data in the local storage + let draftIssues = localStorageDraftIssues ? localStorageDraftIssues : {}; + if (workspaceSlug) { + draftIssues = { ...draftIssues, [workspaceSlug]: changesMade }; + setLocalStorageDraftIssue(draftIssues); + } } + setActiveProjectId(null); onClose(); }; @@ -141,7 +175,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop try { const response = is_draft_issue - ? await draftIssueStore.createIssue(workspaceSlug, payload.project_id, payload) + ? await draftIssues.createIssue(workspaceSlug, payload.project_id, payload) : await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId); if (!response) throw new Error(); @@ -182,7 +216,10 @@ export const CreateUpdateIssueModal: React.FC = observer((prop if (!workspaceSlug || !payload.project_id || !data?.id) return; try { - await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId); + isDraft + ? await draftIssues.updateIssue(workspaceSlug, payload.project_id, data.id, payload) + : await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId); + setToastAlert({ type: "success", title: "Success!", @@ -260,6 +297,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop changesMade={changesMade} data={{ ...data, + description_html: description, cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null, module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null, }} @@ -275,6 +313,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop { - const layouts = [ - { key: "list", title: "List", icon: List }, - { key: "kanban", title: "Kanban", icon: Kanban }, - { key: "calendar", title: "Calendar", icon: Calendar }, - ]; - const [analyticsModal, setAnalyticsModal] = useState(false); - const { workspaceSlug, projectId } = router.query as { - workspaceSlug: string; - projectId: string; - }; - const { currentProjectDetails } = useProject(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const [analyticsModal, setAnalyticsModal] = useState(false); + const { workspaceSlug, projectId } = router.query as { + workspaceSlug: string; + projectId: string; + }; + const { currentProjectDetails } = useProject(); + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); - // store hooks - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.PROJECT); - const { - project: { projectMemberIds }, - } = useMember(); - const activeLayout = issueFilters?.displayFilters?.layout; + // store hooks + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.PROJECT); + const { + project: { projectMemberIds }, + } = useMember(); + const activeLayout = issueFilters?.displayFilters?.layout; - const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); - }, - [workspaceSlug, projectId, updateFilters] - ); + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); + }, + [workspaceSlug, projectId, updateFilters] + ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; + const handleFiltersUpdate = useCallback( + (key: keyof IIssueFilterOptions, value: string | string[]) => { + if (!workspaceSlug || !projectId) return; + const newValues = issueFilters?.filters?.[key] ?? []; - if (Array.isArray(value)) { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); + if (Array.isArray(value)) { + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + } else { + if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); + }, + [workspaceSlug, projectId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); + }, + [workspaceSlug, projectId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); + }, + [workspaceSlug, projectId, updateFilters] + ); + + return ( + <> + setAnalyticsModal(false)} + projectDetails={currentProjectDetails ?? undefined} + /> +
+ Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {layouts.map((layout, index) => ( + { + handleLayoutChange(ISSUE_LAYOUTS[index].key); + }} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ + Filters + + } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [workspaceSlug, projectId, issueFilters, updateFilters] - ); - - const handleDisplayFilters = useCallback( - (updatedDisplayFilter: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); - }, - [workspaceSlug, projectId, updateFilters] - ); - - const handleDisplayProperties = useCallback( - (property: Partial) => { - if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); - }, - [workspaceSlug, projectId, updateFilters] - ); - - return ( - <> - setAnalyticsModal(false)} - projectDetails={currentProjectDetails ?? undefined} + > + -
- Layout} - customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" - closeOnSelect - > - {layouts.map((layout, index) => ( - { - handleLayoutChange(ISSUE_LAYOUTS[index].key); - }} - className="flex items-center gap-2" - > - -
{layout.title}
-
- ))} -
-
- - Filters - - - } - > - - -
-
- - Display - - - } - > - - -
+ +
+
+ + Display + + + } + > + + +
- -
- - ); + +
+ + ); }; diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx index 8b51c977e..8db7fd0ac 100644 --- a/web/components/issues/peek-overview/header.tsx +++ b/web/components/issues/peek-overview/header.tsx @@ -1,17 +1,20 @@ import { FC } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react"; -import { MoveRight, MoveDiagonal, Link2, Trash2 } from "lucide-react"; +import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react"; // ui -import { CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon } from "@plane/ui"; +import { ArchiveIcon, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Tooltip } from "@plane/ui"; // helpers import { copyUrlToClipboard } from "helpers/string.helper"; // hooks import useToast from "hooks/use-toast"; // store hooks -import { useUser } from "hooks/store"; +import { useIssueDetail, useProjectState, useUser } from "hooks/store"; +// helpers +import { cn } from "helpers/common.helper"; // components import { IssueSubscription, IssueUpdateStatus } from "components/issues"; +import { STATE_GROUPS } from "constants/state"; export type TPeekModes = "side-peek" | "modal" | "full-screen"; @@ -43,6 +46,8 @@ export type PeekOverviewHeaderProps = { isArchived: boolean; disabled: boolean; toggleDeleteIssueModal: (value: boolean) => void; + toggleArchiveIssueModal: (value: boolean) => void; + handleRestoreIssue: () => void; isSubmitting: "submitting" | "submitted" | "saved"; }; @@ -57,23 +62,31 @@ export const IssuePeekOverviewHeader: FC = observer((pr disabled, removeRoutePeekId, toggleDeleteIssueModal, + toggleArchiveIssueModal, + handleRestoreIssue, isSubmitting, } = props; // router const router = useRouter(); // store hooks const { currentUser } = useUser(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getStateById } = useProjectState(); // hooks const { setToastAlert } = useToast(); // derived values + const issueDetails = getIssueById(issueId); + const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined; const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); + const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`; + const handleCopyText = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - copyUrlToClipboard( - `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}` - ).then(() => { + copyUrlToClipboard(issueLink).then(() => { setToastAlert({ type: "success", title: "Link Copied!", @@ -81,13 +94,15 @@ export const IssuePeekOverviewHeader: FC = observer((pr }); }); }; - const redirectToIssueDetail = () => { - router.push({ - pathname: `/${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`, - }); + router.push({ pathname: `/${issueLink}` }); removeRoutePeekId(); }; + // auth + const isArchivingAllowed = !isArchived && !disabled; + const isInArchivableGroup = + !!stateDetails && [STATE_GROUPS.completed.key, STATE_GROUPS.cancelled.key].includes(stateDetails?.group); + const isRestoringAllowed = isArchived && !disabled; return (
= observer((pr >
{currentMode && (
@@ -110,7 +125,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr onChange={(val: any) => setPeekMode(val)} customButton={ } > @@ -138,13 +153,43 @@ export const IssuePeekOverviewHeader: FC = observer((pr {currentUser && !isArchived && ( )} - - {!disabled && ( - + + {isArchivingAllowed && ( + + + + )} + {isRestoringAllowed && ( + + + + )} + {!disabled && ( + + + )}
diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index c669c0349..2f5a02c11 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -1,9 +1,18 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; -import { differenceInCalendarDays } from "date-fns"; -import { Signal, Tag, Triangle, LayoutPanelTop, CircleDot, CopyPlus, XCircle, CalendarDays } from "lucide-react"; +import { + Signal, + Tag, + Triangle, + LayoutPanelTop, + CircleDot, + CopyPlus, + XCircle, + CalendarClock, + CalendarCheck2, +} from "lucide-react"; // hooks -import { useIssueDetail, useProject } from "hooks/store"; +import { useIssueDetail, useProject, useProjectState } from "hooks/store"; // ui icons import { DiceIcon, DoubleCircleIcon, UserGroupIcon, ContrastIcon, RelatedIcon } from "@plane/ui"; import { @@ -15,17 +24,12 @@ import { TIssueOperations, IssueRelationSelect, } from "components/issues"; -import { - DateDropdown, - EstimateDropdown, - PriorityDropdown, - ProjectMemberDropdown, - StateDropdown, -} from "components/dropdowns"; +import { DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns"; // components import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // helpers import { cn } from "helpers/common.helper"; +import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; interface IPeekOverviewProperties { workspaceSlug: string; @@ -42,11 +46,13 @@ export const PeekOverviewProperties: FC = observer((pro const { issue: { getIssueById }, } = useIssueDetail(); + const { getStateById } = useProjectState(); // derived values const issue = getIssueById(issueId); if (!issue) return <>; const projectDetails = getProjectById(issue.project_id); const isEstimateEnabled = projectDetails?.estimate; + const stateDetails = getStateById(issue.state_id); const minDate = issue.start_date ? new Date(issue.start_date) : null; minDate?.setDate(minDate.getDate()); @@ -54,8 +60,6 @@ export const PeekOverviewProperties: FC = observer((pro const maxDate = issue.target_date ? new Date(issue.target_date) : null; maxDate?.setDate(maxDate.getDate()); - const targetDateDistance = issue.target_date ? differenceInCalendarDays(new Date(issue.target_date), new Date()) : 1; - return (
Properties
@@ -87,7 +91,7 @@ export const PeekOverviewProperties: FC = observer((pro Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={disabled} @@ -124,7 +128,7 @@ export const PeekOverviewProperties: FC = observer((pro {/* start date */}
- + Start date
= observer((pro {/* due date */}
- + Due date
= observer((pro buttonContainerClassName="w-full text-left" buttonClassName={cn("text-sm", { "text-custom-text-400": !issue.target_date, - "text-red-500": targetDateDistance <= 0, + "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), })} hideIcon clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100" diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index eb8ad0aa7..f014b12c5 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -1,4 +1,4 @@ -import { FC, Fragment, useEffect, useState, useMemo } from "react"; +import { FC, useEffect, useState, useMemo } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks @@ -11,10 +11,11 @@ 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, ISSUE_RESTORED } from "constants/event-tracker"; interface IIssuePeekOverview { is_archived?: boolean; + is_draft?: boolean; } export type TIssuePeekOperations = { @@ -27,6 +28,8 @@ export type TIssuePeekOperations = { 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; @@ -45,7 +48,7 @@ export type TIssuePeekOperations = { }; export const IssuePeekOverview: FC = observer((props) => { - const { is_archived = false } = props; + const { is_archived = false, is_draft = false } = props; // hooks const { setToastAlert } = useToast(); // router @@ -54,12 +57,13 @@ export const IssuePeekOverview: FC = observer((props) => { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); const { - issues: { removeIssue: removeArchivedIssue }, + issues: { restoreIssue }, } = useIssues(EIssuesStoreType.ARCHIVED); const { peekIssue, updateIssue, removeIssue, + archiveIssue, issue: { getIssueById, fetchIssue }, } = useIssueDetail(); const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } = @@ -72,7 +76,12 @@ export const IssuePeekOverview: FC = observer((props) => { () => ({ fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { - await fetchIssue(workspaceSlug, projectId, issueId, is_archived); + await fetchIssue( + workspaceSlug, + projectId, + issueId, + is_archived ? "ARCHIVED" : is_draft ? "DRAFT" : "DEFAULT" + ); } catch (error) { console.error("Error fetching the parent issue"); } @@ -116,8 +125,7 @@ export const IssuePeekOverview: FC = observer((props) => { }, remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { - if (is_archived) await removeArchivedIssue(workspaceSlug, projectId, issueId); - else await removeIssue(workspaceSlug, projectId, issueId); + removeIssue(workspaceSlug, projectId, issueId); setToastAlert({ title: "Issue deleted successfully", type: "success", @@ -141,6 +149,58 @@ export const IssuePeekOverview: 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 peek-overview" }, + 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 peek-overview" }, + path: router.asPath, + }); + } + }, + restore: async (workspaceSlug: string, projectId: string, issueId: string) => { + try { + await restoreIssue(workspaceSlug, projectId, issueId); + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue restored successfully.", + }); + captureIssueEvent({ + eventName: ISSUE_RESTORED, + payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, + path: router.asPath, + }); + } catch (error) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be restored. Please try again.", + }); + captureIssueEvent({ + eventName: ISSUE_RESTORED, + payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, + path: router.asPath, + }); + } + }, addIssueToCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => { try { await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); @@ -301,10 +361,12 @@ export const IssuePeekOverview: FC = observer((props) => { }), [ is_archived, + is_draft, fetchIssue, updateIssue, removeIssue, - removeArchivedIssue, + archiveIssue, + restoreIssue, addIssueToCycle, removeIssueFromCycle, addModulesToIssue, @@ -335,16 +397,14 @@ export const IssuePeekOverview: FC = observer((props) => { const isLoading = !issue || loader ? true : false; return ( - - - + ); }); diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index e69692ecc..cb2100ce0 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -5,17 +5,18 @@ import { observer } from "mobx-react-lite"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useKeypress from "hooks/use-keypress"; +import useToast from "hooks/use-toast"; // store hooks import { useIssueDetail } from "hooks/store"; // components import { - DeleteArchivedIssueModal, DeleteIssueModal, IssuePeekOverviewHeader, TPeekModes, PeekOverviewIssueDetails, PeekOverviewProperties, TIssueOperations, + ArchiveIssueModal, } from "components/issues"; import { IssueActivity } from "../issue-detail/issue-activity"; // ui @@ -43,21 +44,53 @@ export const IssueView: FC = observer((props) => { setPeekIssue, isAnyModalOpen, isDeleteIssueModalOpen, + isArchiveIssueModalOpen, toggleDeleteIssueModal, + toggleArchiveIssueModal, issue: { getIssueById }, } = useIssueDetail(); const issue = getIssueById(issueId); + // hooks + const { alerts } = useToast(); // remove peek id const removeRoutePeekId = () => { setPeekIssue(undefined); }; - // hooks - useOutsideClickDetector(issuePeekOverviewRef, () => !isAnyModalOpen && removeRoutePeekId()); - const handleKeyDown = () => !isAnyModalOpen && removeRoutePeekId(); + + useOutsideClickDetector(issuePeekOverviewRef, () => { + if (!isAnyModalOpen && (!alerts || alerts.length === 0)) { + removeRoutePeekId(); + } + }); + const handleKeyDown = () => { + if (!isAnyModalOpen) { + removeRoutePeekId(); + const issueElement = document.getElementById(`issue-${issueId}`); + if (issueElement) issueElement?.focus(); + } + }; useKeypress("Escape", handleKeyDown); + const handleRestore = async () => { + if (!issueOperations.restore) return; + await issueOperations.restore(workspaceSlug, projectId, issueId); + removeRoutePeekId(); + }; + return ( <> + {issue && !is_archived && ( + toggleArchiveIssueModal(false)} + data={issue} + onSubmit={async () => { + if (issueOperations.archive) await issueOperations.archive(workspaceSlug, projectId, issueId); + removeRoutePeekId(); + }} + /> + )} + {issue && !is_archived && ( = observer((props) => { )} {issue && is_archived && ( - toggleDeleteIssueModal(false)} @@ -96,11 +129,11 @@ export const IssueView: FC = observer((props) => { {/* header */} { - setPeekMode(value); - }} + setPeekMode={(value) => setPeekMode(value)} removeRoutePeekId={removeRoutePeekId} toggleDeleteIssueModal={toggleDeleteIssueModal} + toggleArchiveIssueModal={toggleArchiveIssueModal} + handleRestoreIssue={handleRestore} isArchived={is_archived} issueId={issueId} workspaceSlug={workspaceSlug} @@ -124,7 +157,7 @@ export const IssueView: FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} /> @@ -134,7 +167,7 @@ export const IssueView: FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} /> @@ -148,7 +181,7 @@ export const IssueView: FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} /> @@ -166,7 +199,7 @@ export const IssueView: FC = observer((props) => { projectId={projectId} issueId={issueId} issueOperations={issueOperations} - disabled={disabled} + disabled={disabled || is_archived} />
diff --git a/web/components/issues/sub-issues/properties.tsx b/web/components/issues/sub-issues/properties.tsx index 3a205aea2..03c9d8902 100644 --- a/web/components/issues/sub-issues/properties.tsx +++ b/web/components/issues/sub-issues/properties.tsx @@ -2,7 +2,7 @@ import React from "react"; // hooks import { useIssueDetail } from "hooks/store"; // components -import { PriorityDropdown, ProjectMemberDropdown, StateDropdown } from "components/dropdowns"; +import { PriorityDropdown, MemberDropdown, StateDropdown } from "components/dropdowns"; // types import { TSubIssueOperations } from "./root"; @@ -62,7 +62,7 @@ export const IssueProperty: React.FC = (props) => {
- diff --git a/web/components/issues/title-input.tsx b/web/components/issues/title-input.tsx index dbd18aaaa..4a4057a6a 100644 --- a/web/components/issues/title-input.tsx +++ b/web/components/issues/title-input.tsx @@ -19,11 +19,10 @@ export type IssueTitleInputProps = { }; export const IssueTitleInput: FC = observer((props) => { - const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props; + const { disabled, value, workspaceSlug, isSubmitting, setIsSubmitting, issueId, issueOperations, projectId } = props; // states const [title, setTitle] = useState(""); // hooks - const debouncedValue = useDebounce(title, 1500); useEffect(() => { @@ -31,15 +30,41 @@ export const IssueTitleInput: FC = observer((props) => { }, [value]); useEffect(() => { + const textarea = document.querySelector("#title-input"); if (debouncedValue && debouncedValue !== value) { issueOperations.update(workspaceSlug, projectId, issueId, { name: debouncedValue }, false).finally(() => { setIsSubmitting("saved"); + if (textarea && !textarea.matches(":focus")) { + const trimmedTitle = debouncedValue.trim(); + if (trimmedTitle !== title) setTitle(trimmedTitle); + } }); } // DO NOT Add more dependencies here. It will cause multiple requests to be sent. // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue]); + useEffect(() => { + const handleBlur = () => { + const trimmedTitle = title.trim(); + if (trimmedTitle !== title && isSubmitting !== "submitting") { + setTitle(trimmedTitle); + setIsSubmitting("submitting"); + } + }; + + const textarea = document.querySelector("#title-input"); // You might need to change this selector according to your TextArea component + if (textarea) { + textarea.addEventListener("blur", handleBlur); + } + + return () => { + if (textarea) { + textarea.removeEventListener("blur", handleBlur); + } + }; + }, [title, isSubmitting, setIsSubmitting]); + const handleTitleChange = useCallback( (e: React.ChangeEvent) => { setIsSubmitting("submitting"); @@ -53,6 +78,7 @@ export const IssueTitleInput: FC = observer((props) => { return (