diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 38694a62e..603f08e94 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,11 +2,6 @@ name: Branch Build on: workflow_dispatch: - inputs: - branch_name: - description: "Branch Name" - required: true - default: "preview" push: branches: - master @@ -16,49 +11,71 @@ on: types: [released, prereleased] env: - TARGET_BRANCH: ${{ inputs.branch_name || github.ref_name || github.event.release.target_commitish }} + TARGET_BRANCH: ${{ github.ref_name || github.event.release.target_commitish }} jobs: branch_build_setup: name: Build-Push Web/Space/API/Proxy Docker Image - runs-on: ubuntu-20.04 - steps: - - name: Check out the repo - uses: actions/checkout@v3.3.0 + runs-on: ubuntu-latest outputs: - gh_branch_name: ${{ env.TARGET_BRANCH }} + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} + 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 }} + + steps: + - id: set_env_variables + name: Set Environment Variables + run: | + if [ "${{ env.TARGET_BRANCH }}" == "master" ]; then + echo "BUILDX_DRIVER=cloud" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + else + echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT + echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT + echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT + echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + fi + echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT branch_build_push_frontend: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Frontend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable else TAG=${{ env.FRONTEND_TAG }} fi echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -67,7 +84,7 @@ jobs: with: context: . file: ./web/Dockerfile.web - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.FRONTEND_TAG }} push: true env: @@ -80,33 +97,36 @@ jobs: needs: [branch_build_setup] env: SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Space Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable else TAG=${{ env.SPACE_TAG }} fi echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -115,7 +135,7 @@ jobs: with: context: . file: ./space/Dockerfile.space - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.SPACE_TAG }} push: true env: @@ -128,33 +148,36 @@ jobs: needs: [branch_build_setup] env: BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Backend Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable else TAG=${{ env.BACKEND_TAG }} fi echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -163,7 +186,7 @@ jobs: with: context: ./apiserver file: ./apiserver/Dockerfile.api - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} push: true tags: ${{ env.BACKEND_TAG }} env: @@ -171,38 +194,42 @@ jobs: DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + branch_build_push_proxy: runs-on: ubuntu-20.04 needs: [branch_build_setup] env: PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} + TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }} + BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} steps: - name: Set Proxy Docker Tag run: | - if [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then + if [ "${{ env.TARGET_BRANCH }}" == "master" ] && [ "${{ github.event_name }}" == "release" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} - elif [ "${{ needs.branch_build_setup.outputs.gh_branch_name }}" == "master" ]; then + elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable else TAG=${{ env.PROXY_TAG }} fi echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV - - name: Docker Setup QEMU - uses: docker/setup-qemu-action@v3.0.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.0.0 - with: - platforms: linux/amd64,linux/arm64 - buildkitd-flags: "--allow-insecure-entitlement security.insecure" - - name: Login to Docker Hub - uses: docker/login-action@v3.0.0 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: ${{ env.BUILDX_DRIVER }} + version: ${{ env.BUILDX_VERSION }} + endpoint: ${{ env.BUILDX_ENDPOINT }} + - name: Check out the repo uses: actions/checkout@v4.1.1 @@ -211,10 +238,11 @@ jobs: with: context: ./nginx file: ./nginx/Dockerfile - platforms: linux/amd64 + platforms: ${{ env.BUILDX_PLATFORMS }} tags: ${{ env.PROXY_TAG }} push: true env: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} + diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index b069ef78c..edb89f9b1 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -1,6 +1,8 @@ # Python imports import zoneinfo import json +from urllib.parse import urlparse + # Django imports from django.conf import settings @@ -51,6 +53,11 @@ class WebhookMixin: and self.request.method in ["POST", "PATCH", "DELETE"] and response.status_code in [200, 201, 204] ): + url = request.build_absolute_uri() + parsed_url = urlparse(url) + # Extract the scheme and netloc + scheme = parsed_url.scheme + netloc = parsed_url.netloc # Push the object to delay send_webhook.delay( event=self.webhook_event, @@ -59,6 +66,7 @@ class WebhookMixin: action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=f"{scheme}://{netloc}", ) return response diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index c296bb111..6f66c373e 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -243,6 +243,29 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): ): serializer = CycleSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save( project_id=project_id, owned_by=request.user, @@ -289,6 +312,23 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): serializer = CycleSerializer(cycle, data=request.data, partial=True) if serializer.is_valid(): + if ( + request.data.get("external_id") + and (cycle.external_id != request.data.get("external_id")) + and Cycle.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", cycle.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Cycle with the same external id and external source already exists", + "id": str(cycle.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e91f2a5f6..a759b15f6 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -220,6 +220,30 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue = Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() # Track the issue @@ -256,6 +280,26 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): partial=True, ) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (issue.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue with the same external id and external source already exists", + "id": str(issue.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() issue_activity.delay( type="issue.activity.updated", @@ -263,6 +307,8 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), + external_id__isnull=False, + external_source__isnull=False, current_instance=current_instance, epoch=int(timezone.now().timestamp()), ) @@ -318,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView): try: serializer = LabelSerializer(data=request.data) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Label.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response( serializer.data, status=status.HTTP_201_CREATED @@ -326,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView): serializer.errors, status=status.HTTP_400_BAD_REQUEST ) except IntegrityError: + label = Label.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() return Response( { - "error": "Label with the same name already exists in the project" + "error": "Label with the same name already exists in the project", + "id": str(label.id), }, - status=status.HTTP_400_BAD_REQUEST, + status=status.HTTP_409_CONFLICT, ) def get(self, request, slug, project_id, pk=None): @@ -357,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView): label = self.get_queryset().get(pk=pk) serializer = LabelSerializer(label, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (label.external_id != str(request.data.get("external_id"))) + and Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", label.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Label with the same external id and external source already exists", + "id": str(label.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 1a9a21a3c..d509a53c7 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -132,6 +132,29 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): }, ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + module = Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() module = Module.objects.get(pk=serializer.data["id"]) serializer = ModuleSerializer(module) @@ -149,8 +172,25 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView): partial=True, ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and (module.external_id != request.data.get("external_id")) + and Module.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", module.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Module with the same external id and external source already exists", + "id": str(module.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get(self, request, slug, project_id, pk=None): diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index f931c2ed2..0a262a071 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -38,6 +38,30 @@ class StateAPIEndpoint(BaseAPIView): data=request.data, context={"project_id": project_id} ) if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save(project_id=project_id) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -91,6 +115,23 @@ class StateAPIEndpoint(BaseAPIView): ) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): + if ( + str(request.data.get("external_id")) + and (state.external_id != str(request.data.get("external_id"))) + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", state.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index a041dd227..77c3f16cc 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -33,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer): class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 7bea75fa0..be98bc312 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -304,6 +304,7 @@ class IssueRelationSerializer(BaseSerializer): sequence_id = serializers.IntegerField( source="related_issue.sequence_id", read_only=True ) + name = serializers.CharField(source="related_issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) class Meta: @@ -313,6 +314,7 @@ class IssueRelationSerializer(BaseSerializer): "project_id", "sequence_id", "relation_type", + "name", ] read_only_fields = [ "workspace", @@ -328,6 +330,7 @@ class RelatedIssueSerializer(BaseSerializer): sequence_id = serializers.IntegerField( source="issue.sequence_id", read_only=True ) + name = serializers.CharField(source="issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) class Meta: @@ -337,6 +340,7 @@ class RelatedIssueSerializer(BaseSerializer): "project_id", "sequence_id", "relation_type", + "name", ] read_only_fields = [ "workspace", @@ -558,7 +562,7 @@ class IssueSerializer(DynamicBaseSerializer): state_id = serializers.PrimaryKeyRelatedField(read_only=True) parent_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_id = serializers.PrimaryKeyRelatedField(read_only=True) + module_ids = serializers.SerializerMethodField() # Many to many label_ids = serializers.PrimaryKeyRelatedField( @@ -593,7 +597,7 @@ class IssueSerializer(DynamicBaseSerializer): "project_id", "parent_id", "cycle_id", - "module_id", + "module_ids", "label_ids", "assignee_ids", "sub_issues_count", @@ -609,6 +613,10 @@ class IssueSerializer(DynamicBaseSerializer): ] read_only_fields = fields + def get_module_ids(self, obj): + # Access the prefetched modules and extract module IDs + return [module for module in obj.issue_module.values_list("module_id", flat=True)] + class IssueLiteSerializer(DynamicBaseSerializer): workspace_detail = WorkspaceLiteSerializer( diff --git a/apiserver/plane/app/urls/module.py b/apiserver/plane/app/urls/module.py index d81d32d3a..5e9f4f123 100644 --- a/apiserver/plane/app/urls/module.py +++ b/apiserver/plane/app/urls/module.py @@ -35,17 +35,26 @@ urlpatterns = [ name="project-modules", ), path( - "workspaces//projects//modules//module-issues/", + "workspaces//projects//issues//modules/", ModuleIssueViewSet.as_view( { + "post": "create_issue_modules", + } + ), + name="issue-module", + ), + path( + "workspaces//projects//modules//issues/", + ModuleIssueViewSet.as_view( + { + "post": "create_module_issues", "get": "list", - "post": "create", } ), name="project-module-issues", ), path( - "workspaces//projects//modules//module-issues//", + "workspaces//projects//modules//issues//", ModuleIssueViewSet.as_view( { "get": "retrieve", diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index e07cb811c..fa1e7559b 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -64,6 +64,7 @@ class WebhookMixin: action=self.request.method, slug=self.workspace_slug, bulk=self.bulk, + current_site=request.META.get("HTTP_ORIGIN"), ) return response diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 3c54f7f95..32f593e1e 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -242,13 +242,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .values("display_name", "assignee_id", "avatar") .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -258,7 +258,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -281,13 +281,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ) ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -297,7 +297,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -419,13 +419,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -435,7 +435,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -459,13 +459,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q(archived_at__isnull=True, is_draft=False), ), ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -475,7 +475,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -599,16 +599,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) .filter(project_id=project_id) .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index af476a130..1366a2886 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_relation", @@ -110,7 +110,6 @@ def dashboard_assigned_issues(self, request, slug): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -146,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = assigned_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = assigned_issues.filter( state__group__in=["completed"] @@ -221,9 +237,8 @@ def dashboard_created_issues(self, request, slug): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -259,6 +274,23 @@ def dashboard_created_issues(self, request, slug): ) ).order_by("priority_order") + if issue_type == "pending": + pending_issues_count = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + ).count() + pending_issues = created_issues.filter( + state__group__in=["backlog", "started", "unstarted"] + )[:5] + return Response( + { + "issues": IssueSerializer( + pending_issues, many=True, expand=self.expand + ).data, + "count": pending_issues_count, + }, + status=status.HTTP_200_OK, + ) + if issue_type == "completed": completed_issues_count = created_issues.filter( state__group__in=["completed"] diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index 3bacdae4c..01eee78e3 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -95,7 +95,7 @@ class InboxIssueViewSet(BaseViewSet): issue_inbox__inbox_id=self.kwargs.get("inbox_id") ) .select_related("workspace", "project", "state", "parent") - .prefetch_related("labels", "assignees") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_inbox", @@ -105,7 +105,6 @@ class InboxIssueViewSet(BaseViewSet): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 5ea02e40e..34bce8a0a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -112,12 +112,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): project_id=self.kwargs.get("project_id") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", @@ -125,7 +121,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet): ) ) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1087,12 +1082,31 @@ class IssueArchiveViewSet(BaseViewSet): .filter(archived_at__isnull=False) .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) ) @method_decorator(gzip_page) @@ -1120,22 +1134,6 @@ class IssueArchiveViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering @@ -1670,7 +1668,35 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( - Issue.objects.annotate( + Issue.objects.filter( + project_id=self.kwargs.get("project_id") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(is_draft=True) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related("actor"), + ) + ) + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( sub_issues_count=Issue.issue_objects.filter( parent=OuterRef("id") ) @@ -1678,22 +1704,7 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(is_draft=True) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) - ) + ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): @@ -1719,22 +1730,6 @@ class IssueDraftViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering @@ -1831,7 +1826,10 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + issue = ( + self.get_queryset().filter(pk=serializer.data["id"]).first() + ) + return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): @@ -1867,10 +1865,13 @@ class IssueDraftViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True + issue = self.get_queryset().filter(pk=pk).first() + return Response( + IssueSerializer( + issue, fields=self.fields, expand=self.expand + ).data, + status=status.HTTP_200_OK, ) - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get( diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 969adc2a5..4792a1f79 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.core.serializers.json import DjangoJSONEncoder + # Third party imports from rest_framework.response import Response @@ -195,7 +197,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) .annotate( total_issues=Count( - "assignee_id", + "id", filter=Q( archived_at__isnull=True, is_draft=False, @@ -204,7 +206,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) .annotate( completed_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -214,7 +216,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "assignee_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -237,7 +239,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .values("label_name", "color", "label_id") .annotate( total_issues=Count( - "label_id", + "id", filter=Q( archived_at__isnull=True, is_draft=False, @@ -246,7 +248,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) .annotate( completed_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=False, archived_at__isnull=True, @@ -256,7 +258,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) .annotate( pending_issues=Count( - "label_id", + "id", filter=Q( completed_at__isnull=True, archived_at__isnull=True, @@ -296,23 +298,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "issue", flat=True ) ) - issue_activity.delay( - type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(pk), - "module_name": str(module.name), - "issues": [str(issue_id) for issue_id in module_issues], - } - ), - actor_id=str(request.user.id), - issue_id=str(pk), - project_id=str(project_id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) + _ = [ + issue_activity.delay( + type="module.activity.deleted", + requested_data=json.dumps({"module_id": str(pk)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=json.dumps({"module_name": str(module.name)}), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for issue in module_issues + ] module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -332,62 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("issue") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .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) - .select_related("project") - .select_related("workspace") - .select_related("module") - .select_related("issue", "issue__state", "issue__project") - .prefetch_related("issue__assignees", "issue__labels") - .prefetch_related("module__members") - .distinct() - ) - @method_decorator(gzip_page) - def list(self, request, slug, project_id, module_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - order_by = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issues = ( - Issue.issue_objects.filter(issue_module__module_id=module_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") + def get_queryset(self): + return ( + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + issue_module__module_id=self.kwargs.get("module_id") ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .order_by(order_by) - .filter(**filters) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("labels", "assignees") + .prefetch_related('issue_module__module') .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -403,105 +358,118 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .values("count") ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) - ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id, module_id): + fields = [ + field + for field in request.GET.get("fields", "").split(",") + if field + ] + filters = issue_filters(request.query_params, "GET") + issue_queryset = self.get_queryset().filter(**filters) serializer = IssueSerializer( - issues, many=True, fields=fields if fields else None + issue_queryset, many=True, fields=fields if fields else None ) return Response(serializer.data, status=status.HTTP_200_OK) - def create(self, request, slug, project_id, module_id): + # create multiple issues inside a module + def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not len(issues): return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, ) - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=module_id - ) - - module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) - - update_module_issue_activity = [] - records_to_update = [] - record_to_create = [] - - for issue in issues: - module_issue = [ - module_issue - for module_issue in module_issues - if str(module_issue.issue_id) in issues - ] - - if len(module_issue): - if module_issue[0].module_id != module_id: - update_module_issue_activity.append( - { - "old_module_id": str(module_issue[0].module_id), - "new_module_id": str(module_id), - "issue_id": str(module_issue[0].issue_id), - } - ) - module_issue[0].module_id = module_id - records_to_update.append(module_issue[0]) - else: - record_to_create.append( - ModuleIssue( - module=module, - issue_id=issue, - project_id=project_id, - workspace=module.workspace, - created_by=request.user, - updated_by=request.user, - ) + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=str(issue), + module_id=module_id, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, ) - - ModuleIssue.objects.bulk_create( - record_to_create, + for issue in issues + ], batch_size=10, ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module_id)}), + actor_id=str(request.user.id), + issue_id=str(issue), + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for issue in issues + ] + issues = (self.get_queryset().filter(pk__in=issues)) + serializer = IssueSerializer(issues , many=True) + return Response(serializer.data, status=status.HTTP_201_CREATED) + - ModuleIssue.objects.bulk_update( - records_to_update, - ["module"], + # create multiple module inside an issue + def create_issue_modules(self, request, slug, project_id, issue_id): + modules = request.data.get("modules", []) + if not len(modules): + return Response( + {"error": "Modules are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = Project.objects.get(pk=project_id) + _ = ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project_id, + workspace_id=project.workspace_id, + created_by=request.user, + updated_by=request.user, + ) + for module in modules + ], batch_size=10, + ignore_conflicts=True, ) + # Bulk Update the activity + _ = [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": module}), + actor_id=str(request.user.id), + issue_id=issue_id, + project_id=project_id, + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + for module in modules + ] - # Capture Issue Activity - issue_activity.delay( - type="module.activity.created", - requested_data=json.dumps({"modules_list": issues}), - actor_id=str(self.request.user.id), - issue_id=None, - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - { - "updated_module_issues": update_module_issue_activity, - "created_module_issues": serializers.serialize( - "json", record_to_create - ), - } - ), - epoch=int(timezone.now().timestamp()), - notification=True, - origin=request.META.get("HTTP_ORIGIN"), - ) + issue = (self.get_queryset().filter(pk=issue_id).first()) + serializer = IssueSerializer(issue) + return Response(serializer.data, status=status.HTTP_201_CREATED) - issues = self.get_queryset().values_list("issue_id", flat=True) - - return Response( - IssueSerializer( - Issue.objects.filter(pk__in=issues), many=True - ).data, - status=status.HTTP_200_OK, - ) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( @@ -512,16 +480,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ) issue_activity.delay( type="module.activity.deleted", - requested_data=json.dumps( - { - "module_id": str(module_id), - "issues": [str(issue_id)], - } - ), + requested_data=json.dumps({"module_id": str(module_id)}), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=None, + current_instance=json.dumps({"module_name": module_issue.module.name}), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index 0455541c6..13acabfe8 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView): parent = request.query_params.get("parent", "false") issue_relation = request.query_params.get("issue_relation", "false") cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", "false") + module = request.query_params.get("module", False) sub_issue = request.query_params.get("sub_issue", "false") issue_id = request.query_params.get("issue_id", False) @@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView): if cycle == "true": issues = issues.exclude(issue_cycle__isnull=False) - if module == "true": - issues = issues.exclude(issue_module__isnull=False) + if module: + issues = issues.exclude(issue_module__module=module) return Response( issues.values( diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 07bf1ad03..27f31f7a9 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -87,12 +87,8 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") .prefetch_related( Prefetch( "issue_reactions", @@ -127,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): .filter(**filters) .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -150,13 +145,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) - ) - ) ) # Priority Ordering diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 159fbcb08..f4d3dbbb5 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -1346,9 +1346,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ) .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels") + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index cf7255585..9e9b348e1 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,5 +1,6 @@ -import json from datetime import datetime +from bs4 import BeautifulSoup + # Third party imports from celery import shared_task @@ -9,7 +10,6 @@ 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 @@ -40,7 +40,7 @@ def stack_email_notification(): processed_notifications = [] # Loop through all the issues to create the emails for receiver_id in receivers: - # Notifcation triggered for the receiver + # Notification triggered for the receiver receiver_notifications = [ notification for notification in email_notifications @@ -124,113 +124,153 @@ def create_payload(notification_data): return data +def process_mention(mention_component): + soup = BeautifulSoup(mention_component, 'html.parser') + mentions = soup.find_all('mention-component') + for mention in mentions: + user_id = mention['id'] + user = User.objects.get(pk=user_id) + user_name = user.display_name + highlighted_name = f"@{user_name}" + mention.replace_with(highlighted_name) + return str(soup) + +def process_html_content(content): + processed_content_list = [] + for html_content in content: + processed_content = process_mention(html_content) + processed_content_list.append(processed_content) + return processed_content_list @shared_task def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): - ri = redis_instance() - base_api = (ri.get(str(issue_id)).decode()) - data = create_payload(notification_data=notification_data) - - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, - ) = get_email_configuration() - - receiver = User.objects.get(pk=receiver_id) - issue = Issue.objects.get(pk=issue_id) - template_data = [] - total_changes = 0 - comments = [] - for actor_id, changes in data.items(): - actor = User.objects.get(pk=actor_id) - total_changes = total_changes + len(changes) - comment = changes.pop("comment", False) - if comment: - comments.append( - { - "actor_comments": comment, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } - ) - activity_time = changes.pop("activity_time") - # Parse the input string into a datetime object - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") - - if changes: - template_data.append( - { - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - "changes": changes, - "issue_details": { - "name": issue.name, - "identifier": f"{issue.project.identifier}-{issue.sequence_id}", - }, - "activity_time": str(formatted_time), - } - ) - - summary = "updates were made to the issue by" - - # Send the mail - subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - context = { - "data": template_data, - "summary": summary, - "issue": { - "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", - "name": issue.name, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - }, - "receiver": { - "email": receiver.email, - }, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "user_preference": f"{base_api}/profile/preferences/email", - "comments": comments, - } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) - text_content = strip_tags(html_content) - try: - connection = get_connection( - host=EMAIL_HOST, - port=int(EMAIL_PORT), - username=EMAIL_HOST_USER, - password=EMAIL_HOST_PASSWORD, - use_tls=EMAIL_USE_TLS == "1", - ) + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=EMAIL_FROM, - to=[receiver.email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) - return - except Exception as e: - print(e) + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } + ) + + summary = "Updates were made to the issue by" + + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + html_content = render_to_string( + "emails/notifications/issue-updates.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + return + except Exception as e: + print(e) + return + except Issue.DoesNotExist: return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 4a036ec31..b86ab5e78 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -30,6 +30,7 @@ from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications from plane.settings.redis import redis_instance + # Track Changes in name def track_name( requested_data, @@ -352,13 +353,18 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = set( - [str(asg) for asg in requested_data.get("assignee_ids", [])] + requested_assignees = ( + set([str(asg) for asg in requested_data.get("assignee_ids", [])]) + if requested_data is not None + else set() ) - current_assignees = set( - [str(asg) for asg in current_instance.get("assignee_ids", [])] + current_assignees = ( + set([str(asg) for asg in current_instance.get("assignee_ids", [])]) + if current_instance is not None + else set() ) + added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees @@ -546,6 +552,20 @@ def create_issue_activity( epoch=epoch, ) ) + requested_data = ( + json.loads(requested_data) if requested_data is not None else None + ) + if requested_data.get("assignee_ids") is not None: + track_assignees( + requested_data, + current_instance, + issue_id, + project_id, + workspace_id, + actor_id, + issue_activities, + epoch, + ) def update_issue_activity( @@ -852,70 +872,26 @@ def create_module_issue_activity( requested_data = ( json.loads(requested_data) if requested_data is not None else None ) - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) - - # Updated Records: - updated_records = current_instance.get("updated_module_issues", []) - created_records = json.loads( - current_instance.get("created_module_issues", []) - ) - - for updated_record in updated_records: - old_module = Module.objects.filter( - pk=updated_record.get("old_module_id", None) - ).first() - new_module = Module.objects.filter( - pk=updated_record.get("new_module_id", None) - ).first() - issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - - issue_activities.append( - IssueActivity( - issue_id=updated_record.get("issue_id"), - actor_id=actor_id, - verb="updated", - old_value=old_module.name, - new_value=new_module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"updated module to ", - old_identifier=old_module.id, - new_identifier=new_module.id, - epoch=epoch, - ) - ) - - for created_record in created_records: - module = Module.objects.filter( - pk=created_record.get("fields").get("module") - ).first() - issue = Issue.objects.filter( - pk=created_record.get("fields").get("issue") - ).first() - if issue: - issue.updated_at = timezone.now() - issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=created_record.get("fields").get("issue"), - actor_id=actor_id, - verb="created", - old_value="", - new_value=module.name, - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"added module {module.name}", - new_identifier=module.id, - epoch=epoch, - ) + module = Module.objects.filter(pk=requested_data.get("module_id")).first() + issue = Issue.objects.filter(pk=issue_id).first() + if issue: + issue.updated_at = timezone.now() + issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"added module {module.name}", + new_identifier=requested_data.get("module_id"), + epoch=epoch, ) + ) def delete_module_issue_activity( @@ -934,32 +910,26 @@ def delete_module_issue_activity( current_instance = ( json.loads(current_instance) if current_instance is not None else None ) - - module_id = requested_data.get("module_id", "") - module_name = requested_data.get("module_name", "") - module = Module.objects.filter(pk=module_id).first() - issues = requested_data.get("issues") - - for issue in issues: - current_issue = Issue.objects.filter(pk=issue).first() - if issue: - current_issue.updated_at = timezone.now() - current_issue.save(update_fields=["updated_at"]) - issue_activities.append( - IssueActivity( - issue_id=issue, - actor_id=actor_id, - verb="deleted", - old_value=module.name if module is not None else module_name, - new_value="", - field="modules", - project_id=project_id, - workspace_id=workspace_id, - comment=f"removed this issue from {module.name if module is not None else module_name}", - old_identifier=module_id if module_id is not None else None, - epoch=epoch, - ) + module_name = current_instance.get("module_name") + current_issue = Issue.objects.filter(pk=issue_id).first() + if current_issue: + current_issue.updated_at = timezone.now() + current_issue.save(update_fields=["updated_at"]) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor_id=actor_id, + verb="deleted", + old_value=module_name, + new_value="", + field="modules", + project_id=project_id, + workspace_id=workspace_id, + comment=f"removed this issue from {module_name}", + old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None, + epoch=epoch, ) + ) def create_link_activity( @@ -1648,7 +1618,6 @@ def issue_activity( ) except Exception as e: capture_exception(e) - if notification: notifications.delay( diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 6cfbec72a..0a843e4a6 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -515,7 +515,7 @@ def notifications( bulk_email_logs.append( EmailNotificationLog( triggered_by_id=actor_id, - receiver_id=subscriber, + receiver_id=mention_id, entity_identifier=issue_id, entity_name="issue", data={ @@ -552,6 +552,7 @@ def notifications( "old_value": str( issue_activity.get("old_value") ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -639,6 +640,7 @@ def notifications( "old_value": str( last_activity.old_value ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -695,6 +697,7 @@ def notifications( "old_value" ) ), + "activity_time": issue_activity.get("created_at"), }, }, ) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index 34bba0cf8..605f48dd9 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -7,6 +7,9 @@ import hmac # Django imports from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags # Third party imports from celery import shared_task @@ -22,10 +25,10 @@ from plane.db.models import ( ModuleIssue, CycleIssue, IssueComment, + User, ) from plane.api.serializers import ( ProjectSerializer, - IssueSerializer, CycleSerializer, ModuleSerializer, CycleIssueSerializer, @@ -34,6 +37,9 @@ from plane.api.serializers import ( IssueExpandSerializer, ) +# Module imports +from plane.license.utils.instance_value import get_email_configuration + SERIALIZER_MAPPER = { "project": ProjectSerializer, "issue": IssueExpandSerializer, @@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False): max_retries=5, retry_jitter=True, ) -def webhook_task(self, webhook, slug, event, event_data, action): +def webhook_task(self, webhook, slug, event, event_data, action, current_site): try: webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) @@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action): response_body=str(e), retry_count=str(self.request.retries), ) - + # Retry logic + if self.request.retries >= self.max_retries: + Webhook.objects.filter(pk=webhook.id).update(is_active=False) + if webhook: + # send email for the deactivation of the webhook + send_webhook_deactivation_email( + webhook_id=webhook.id, + receiver_id=webhook.created_by_id, + reason=str(e), + current_site=current_site, + ) + return raise requests.RequestException() except Exception as e: @@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): @shared_task() -def send_webhook(event, payload, kw, action, slug, bulk): +def send_webhook(event, payload, kw, action, slug, bulk, current_site): try: webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) @@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk): event=event, event_data=data, action=action, + current_site=current_site, ) except Exception as e: @@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk): print(e) capture_exception(e) return + + +@shared_task +def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason): + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + webhook = Webhook.objects.get(pk=webhook_id) + subject="Webhook Deactivated" + message=f"Webhook {webhook.url} has been deactivated due to failed requests." + + # Send the mail + context = { + "email": receiver.email, + "message": message, + "webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", + } + html_content = render_to_string( + "emails/notifications/webhook-deactivate.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py new file mode 100644 index 000000000..6238ef825 --- /dev/null +++ b/apiserver/plane/db/migrations/0058_alter_moduleissue_issue_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-24 18:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0057_auto_20240122_0901'), + ] + + operations = [ + migrations.AlterField( + model_name='moduleissue', + name='issue', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'), + ), + migrations.AlterUniqueTogether( + name='moduleissue', + unique_together={('issue', 'module')}, + ), + ] diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index 131af5e1c..9af4e120e 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -134,11 +134,12 @@ class ModuleIssue(ProjectBaseModel): module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) - issue = models.OneToOneField( + issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_module" ) class Meta: + unique_together = ["issue", "module"] verbose_name = "Module Issue" verbose_name_plural = "Module Issues" db_table = "module_issues" diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 6f8a82e56..f254a3cb7 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -172,4 +172,9 @@ def create_user_notification(sender, instance, created, **kwargs): from plane.db.models import UserNotificationPreference UserNotificationPreference.objects.create( user=instance, + property_change=False, + state_change=False, + comment=False, + mention=False, + issue_completed=False, ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 444248382..f03209250 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -282,10 +282,8 @@ if REDIS_SSL: redis_url = os.environ.get("REDIS_URL") broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" CELERY_BROKER_URL = broker_url - CELERY_RESULT_BACKEND = broker_url else: CELERY_BROKER_URL = REDIS_URL - CELERY_RESULT_BACKEND = REDIS_URL CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 6832297e9..0e7a18fa8 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==41.0.5 +cryptography==41.0.6 lxml==4.9.3 boto3==1.28.40 diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index 4374846df..3c561f37a 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -1,978 +1,1139 @@ - - - - Updates on issue - - - -
- -
- - - - -
-
- -
-
-
- -
+ + + + Updates on issue + + + + + -
- - - - -
-

- {{ issue.issue_identifier }} updates -

-

- {{ issue.name }}: {{ issue.issue_identifier }} -

-
-
- -

- {% if data.1 %}{{ data|length }}{% endif %} {{ summary }} - - {{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - -

- {% if comments.0 %} -

- {{ comments|length }} {% if comments|length == 1 %}comment was{% else %}comments were{% endif %} left by - - {% if comments|length == 1 %} - {{ data.0.actor_detail.first_name }} - {{ data.0.actor_detail.last_name }} - {% else %} - {{ data.0.actor_detail.first_name }} - {{ data.0.actor_detail.last_name }} and others - {% endif %} - -

- {% endif %} - {% if mentions and comments.0 and data.0 %} -

- There are 3 new updates, added 1 new comment and, you were - - @{{ data.0.actor_detail.first_name}} - {{data.0.actor_detail.last_name }} - - mentioned a comment of this issue. -

- {% endif %} - {% for update in data %} {% if update.changes.name %} - -

- The issue title has been updated from “{{update.changes.user.old_value.0}}“ to "{{update.changes.user.new_value|last}}" -

- {% endif %} - - {% if data %} -
- -
-

- Updates -

-
- -
- - - - - -
- {% if update.actor_detail.avatar_url %} - - {% else %} - - - - - - -
+ +
+ + + - -
+
- - {{ update.actor_detail.first_name.0 }} - -
- {% endif %} + +
-

- {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} -

-
-

- {{ update.activity_time }} -

-
- {% if update.changes.target_date %} - - - - - - - -
- - -
-

- Due Date: -

-
-
-

- {{ update.changes.target_date.new_value.0 }} -

-
- {% endif %} {% if update.changes.duplicate %} - - - - - - - - -
- - - Duplicate: - - - {% for duplicate in update.changes.duplicate.new_value %} - - {{ duplicate }} - - {% endfor %} -
- {% endif %} - - {% if update.changes.assignees %} - - - - - {% if update.changes.assignees.new_value.0 %} - - {% endif %} {% if update.changes.assignees.new_value.1 %} - - {% endif %} {% if update.changes.assignees.old_value.0 %} - - {% endif %} {% if update.changes.assignees.old_value.1 %} - - {% endif %} - -
- - -

- Assignees: -

-
-

- {{ update.changes.assignees.new_value.0 }} -

-
-

- +{{ update.changes.assignees.new_value|length|add:"-1"}} - more -

-
-

- {{update.changes.assignees.old_value.0}} -

-
-

- +{{ update.changes.assignees.old_value|length|add:"-1"}} - more -

-
- {% endif %} {% if update.changes.labels %} - - - - - - {% if update.changes.labels.new_value.0 %} - - {% endif %} - {% if update.changes.labels.new_value.1 %} - - {% endif %} - {% if update.changes.labels.old_value.0 %} - - {% endif %} - {% if update.changes.labels.old_value.1 %} - - {% endif %} - -
- - -

- Labels: -

-
-

- {{update.changes.labels.new_value.0}} -

-
-

- +{{ update.changes.labels.new_value|length|add:"-1"}} more -

-
-

- {{update.changes.labels.old_value.0}} -

-
-

- +{{ update.changes.labels.old_value|length|add:"-1"}} more -

-
- {% endif %} - - {% if update.changes.state %} - - - - - - - - - - -
- - -

- State: -

-
- - -

- {{ update.changes.state.old_value.0 }} -

-
- - - - -

- {{update.changes.state.new_value|last }} -

-
- {% endif %} {% if update.changes.link %} - - - - - - +
- - -

- Links: -

-
- {% for link in update.changes.link.new_value %} - - {{ link }} - - {% endfor %} - {% if update.changes.link.old_value|length > 0 %} - {% if update.changes.link.old_value.0 != "None" %} -

- 2 Links were removed +

+ + +
+
+ + + + +
+

+ {{ issue.issue_identifier }} updates +

+

+ {{workspace}}/{{project}}/{{issue.issue_identifier}}: {{ issue.name }} +

+
+
+ {% if actors_involved == 1 %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + . +

+ {% else %} +

+ {{summary}} + + {% if data|length > 0 %} + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name}} + {% else %} + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name}} + {% endif %} + and others.

{% endif %} - {% endif %} -
- {% endif %} - {% if update.changes.priority %} - - - - - - - - - - -
- - -

- Priority: -

-
-

- {{ update.changes.priority.old_value.0 }} -

-
- - -

- {{ update.changes.priority.new_value|last }} -

-
- {% endif %} - {% if update.changes.blocking.new_value %} - - - - + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index a9300830a..70a082b8d 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -4,14 +4,17 @@ import { observer } from "mobx-react-lite"; // icons import { ChevronRight, MoreHorizontal } from "lucide-react"; // constants -import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; +import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { IssueColumn } from "./issue-column"; // ui import { ControlLink, Tooltip } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { useIssueDetail, useProject } from "hooks/store"; +// helper +import { cn } from "helpers/common.helper"; // types import { IIssueDisplayProperties, TIssue } from "@plane/types"; import { EIssueActions } from "../types"; @@ -48,7 +51,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { workspaceSlug } = router.query; //hooks const { getProjectById } = useProject(); - const { setPeekIssue } = useIssueDetail(); + const { peekIssue, setPeekIssue } = useIssueDetail(); // states const [isMenuActive, setIsMenuActive] = useState(false); const [isExpanded, setExpanded] = useState(false); @@ -95,9 +98,21 @@ export const SpreadsheetIssueRow = observer((props: Props) => { return ( <> - + {/* first column/ issue name and key column */} - {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; - - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} {isExpanded && diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index e5b61133a..2deb74cf1 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -1,9 +1,10 @@ import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // hooks -import { useProject, useWorkspace } from "hooks/store"; +import { useEventTracker, useProject, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; @@ -58,6 +59,9 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => // store hooks const { currentWorkspace } = useWorkspace(); const { currentProjectDetails } = useProject(); + const { captureIssueEvent } = useEventTracker(); + // router + const router = useRouter(); // form info const { reset, @@ -155,13 +159,26 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => try { quickAddCallback && - (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId)); + (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then( + (res) => { + captureIssueEvent({ + eventName: "Issue created", + payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" }, + path: router.asPath, + }); + } + )); setToastAlert({ type: "success", title: "Success!", message: "Issue created successfully.", }); } catch (err: any) { + captureIssueEvent({ + eventName: "Issue created", + payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" }, + path: router.asPath, + }); console.error(err); setToastAlert({ type: "error", diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx index 40b933557..7f92bd74c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // mobx store -import { useIssues } from "hooks/store"; +import { useCycle, useIssues } from "hooks/store"; // components import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; import { EIssueActions } from "../../types"; @@ -15,6 +15,7 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => { const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string }; const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { currentProjectCompletedCycleIds } = useCycle(); const issueActions = useMemo( () => ({ @@ -35,6 +36,11 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => { [issues, workspaceSlug, cycleId] ); + const isCompletedCycle = + cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; + + const canEditIssueProperties = () => !isCompletedCycle; + return ( { viewId={cycleId} issueActions={issueActions} QuickActions={CycleIssueQuickActions} + canEditPropertiesBasedOnProject={canEditIssueProperties} + isCompletedCycle={isCompletedCycle} /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx new file mode 100644 index 000000000..588c7be9e --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header-column.tsx @@ -0,0 +1,46 @@ +import { useRef } from "react"; +//types +import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +//components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { HeaderColumn } from "./columns/header-column"; +import { observer } from "mobx-react"; + +interface Props { + displayProperties: IIssueDisplayProperties; + property: keyof IIssueDisplayProperties; + isEstimateEnabled: boolean; + displayFilters: IIssueDisplayFilterOptions; + handleDisplayFilterUpdate: (data: Partial) => void; +} +export const SpreadsheetHeaderColumn = observer((props: Props) => { + const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props; + + //hooks + const tableHeaderCellRef = useRef(null); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + return ( + + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 91cfcbdf8..828977e64 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -6,7 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/type import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet"; // components import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -import { SpreadsheetHeaderColumn } from "./columns/header-column"; +import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column"; interface Props { displayProperties: IIssueDisplayProperties; @@ -21,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => { return ( - - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - - ); - })} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + + ))} ); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index e9ccf179d..e63b01dfb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -5,6 +5,7 @@ import { EIssueActions } from "../types"; //components import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; +import { useTableKeyboardNavigation } from "hooks/use-table-keyboard-navigation"; type Props = { displayProperties: IIssueDisplayProperties; @@ -35,8 +36,10 @@ export const SpreadsheetTable = observer((props: Props) => { canEditProperties, } = props; + const handleKeyBoardNavigation = useTableKeyboardNavigation(); + return ( -
- - - Blocking: + - {% endfor %} {% if comments.0 %} - -
- -

- Comments -

- - {% for comment in comments %} - - - - - -
- {% if comment.actor_detail.avatar_url %} - - {% else %} - - - + made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. +

+ {% elif data|length == 0 and comments|length > 0 %} +

+ + {{ comments.0.actor_detail.first_name}} + {{comments.0.actor_detail.last_name }} + + added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. +

+ {% elif data|length > 0 and comments|length > 0 %} +

+ + {{ data.0.actor_detail.first_name}} + {{data.0.actor_detail.last_name }} + + made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. +

+ {% endif %} + {% else %} +

+ There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. +

+ {% endif %} --> + {% for update in data %} {% if update.changes.name %} + +

+ The issue title has been updated to {{ issue.name}} +

+ {% endif %} + + {% if data %} +
+ +
+

+ Updates +

+
+ +
+ +
- - {{ comment.actor_detail.first_name.0 }} - -
+ + + + + +
+ {% if update.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ update.actor_detail.first_name.0 }} + +
+ {% endif %} +
+

+ {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} +

+
+

+ {{ update.activity_time }} +

+
+ {% if update.changes.target_date %} + + + + + + + +
+ + +
+

+ Due Date: +

+
+
+ {% if update.changes.target_date.new_value.0 %} +

+ {{ update.changes.target_date.new_value.0 }} +

+ {% else %} +

+ {{ update.changes.target_date.old_value.0 }} +

+ {% endif %} +
+ {% endif %} {% if update.changes.duplicate %} + + + + + {% if update.changes.duplicate.new_value.0 %} + + {% endif %} + {% if update.changes.duplicate.new_value.2 %} + + {% endif %} + {% if update.changes.duplicate.old_value.0 %} + + {% endif %} + {% if update.changes.duplicate.old_value.2 %} + + {% endif %} + +
+ + + Duplicate: + + + {% for duplicate in update.changes.duplicate.new_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.new_value|length|add:"-2" }} + more + + + {% for duplicate in update.changes.duplicate.old_value|slice:":2" %} + + {{ duplicate }} + + {% endfor %} + + + +{{ update.changes.duplicate.old_value|length|add:"-2" }} + more + +
+ {% endif %} + + {% if update.changes.assignees %} + + + + + +
+ + + Assignee: + + + {% if update.changes.assignees.new_value.0 %} + + {{update.changes.assignees.new_value.0}} + + {% endif %} + {% if update.changes.assignees.new_value.1 %} + + +{{ update.changes.assignees.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.assignees.old_value.0 %} + + {{update.changes.assignees.old_value.0}} + + {% endif %} + {% if update.changes.assignees.old_value.1 %} + + +{{ update.changes.assignees.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} {% if update.changes.labels %} + + + + + + +
+ + + Labels: + + + {% if update.changes.labels.new_value.0 %} + + {{update.changes.labels.new_value.0}} + + {% endif %} + {% if update.changes.labels.new_value.1 %} + + +{{ update.changes.labels.new_value|length|add:"-1"}} more + + {% endif %} + {% if update.changes.labels.old_value.0 %} + + {{update.changes.labels.old_value.0}} + + {% endif %} + {% if update.changes.labels.old_value.1 %} + + +{{ update.changes.labels.old_value|length|add:"-1"}} more + + {% endif %} +
+ {% endif %} + + {% if update.changes.state %} + + + + + {% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %} + + {% endif %} + + + {% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %} + + {% endif %} + + +
+ + +

+ State: +

+
+ + +

+ {{ update.changes.state.old_value.0 }} +

+
+ + + + +

+ {{update.changes.state.new_value|last }} +

+
+ {% endif %} {% if update.changes.link %} + + + + + + + +
+ + +

+ Links: +

+
+ {% for link in update.changes.link.new_value %} + + {{ link }} + + {% endfor %} + {% if update.changes.link.old_value|length > 0 %} + {% if update.changes.link.old_value.0 != "None" %} +

+ 2 Links were removed +

+ {% endif %} + {% endif %} +
+ {% endif %} + {% if update.changes.priority %} + + + + + + + + + +
+ + +

+ Priority: +

+
+

+ {{ update.changes.priority.old_value.0 }} +

+
+ + +

+ {{ update.changes.priority.new_value|last }} +

+
+ {% endif %} + {% if update.changes.blocking.new_value %} + + + + + {% if update.changes.blocking.new_value.0 %} + + {% endif %} + {% if update.changes.blocking.new_value.2 %} + + {% endif %} + {% if update.changes.blocking.old_value.0 %} + + {% endif %} + {% if update.changes.blocking.old_value.2 %} + + {% endif %} + +
+ + + Blocking: + + + {% for blocking in update.changes.blocking.new_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.new_value|length|add:"-2" }} + more + + + {% for blocking in update.changes.blocking.old_value|slice:":2" %} + + {{ blocking }} + + {% endfor %} + + + +{{ update.changes.blocking.old_value|length|add:"-2" }} + more + +
+ {% endif %} + + + {% endif %} + + {% endfor %} {% if comments.0 %} + +
+ +

+ Comments +

+ + {% for comment in comments %} + + + + -
+ {% if comment.actor_detail.avatar_url %} + + {% else %} + + + + +
+ + {{ comment.actor_detail.first_name.0 }} + +
+ {% endif %} +
+ + + + + {% for actor_comment in comment.actor_comments.new_value %} + + + + {% endfor %} +
+

+ {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} +

+
+
+

+ {{ actor_comment|safe }} +

+
+
+
- {% endif %} -
- - - - - {% for actor_comment in comment.actor_comments.new_value %} - - - - {% endfor %}
-

- {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} -

-
-
-

- {{ actor_comment|safe }} -

-
-
-
- {% endfor %} -
- {% endif %} - - -
- View issue -
-
- - - - - - -
-
- This email was sent to - {{ receiver.email }}. - If you'd rather not receive this kind of email, - you can unsubscribe to the issue - or - manage your email preferences. - - + {% endfor %} +
+ {% endif %} -
- - - + +
+ View issue +
+
+ + + + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the issue + or + manage your email preferences. + + +
+
+ + + \ No newline at end of file diff --git a/apiserver/templates/emails/notifications/webhook-deactivate.html b/apiserver/templates/emails/notifications/webhook-deactivate.html new file mode 100644 index 000000000..054395235 --- /dev/null +++ b/apiserver/templates/emails/notifications/webhook-deactivate.html @@ -0,0 +1,1544 @@ + + + + + + + + {{ message }} + + + + + + + + + + + + + + + diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index f32be504d..917d08fdf 100644 --- a/deploy/1-click/install.sh +++ b/deploy/1-click/install.sh @@ -1,5 +1,6 @@ #!/bin/bash +# Check if the user has sudo access if command -v curl &> /dev/null; then sudo curl -sSL \ -o /usr/local/bin/plane-app \ @@ -11,6 +12,6 @@ else fi sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's/export BRANCH=${BRANCH:-master}/export 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 plane-app --help \ No newline at end of file +plane-app --help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index 445f39d69..2d6ef0a6f 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -17,7 +17,7 @@ Project management tool from the future EOF } -function update_env_files() { +function update_env_file() { config_file=$1 key=$2 value=$3 @@ -25,14 +25,16 @@ function update_env_files() { # Check if the config file exists if [ ! -f "$config_file" ]; then echo "Config file not found. Creating a new one..." >&2 - touch "$config_file" + sudo touch "$config_file" fi # Check if the key already exists in the config file - if grep -q "^$key=" "$config_file"; then - awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" + if sudo grep "^$key=" "$config_file"; then + sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null + sudo mv "$config_file.tmp" "$config_file" &> /dev/null else - echo "$key=$value" >> "$config_file" + # sudo echo "$key=$value" >> "$config_file" + echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null fi } function read_env_file() { @@ -42,12 +44,12 @@ function read_env_file() { # Check if the config file exists if [ ! -f "$config_file" ]; then echo "Config file not found. Creating a new one..." >&2 - touch "$config_file" + sudo touch "$config_file" fi # Check if the key already exists in the config file - if grep -q "^$key=" "$config_file"; then - value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") + if sudo grep -q "^$key=" "$config_file"; then + value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") echo "$value" else echo "" @@ -55,19 +57,19 @@ function read_env_file() { } function update_config() { config_file="$PLANE_INSTALL_DIR/config.env" - update_env_files "$config_file" "$1" "$2" + update_env_file $config_file $1 $2 } function read_config() { config_file="$PLANE_INSTALL_DIR/config.env" - read_env_file "$config_file" "$1" + read_env_file $config_file $1 } function update_env() { config_file="$PLANE_INSTALL_DIR/.env" - update_env_files "$config_file" "$1" "$2" + update_env_file $config_file $1 $2 } function read_env() { config_file="$PLANE_INSTALL_DIR/.env" - read_env_file "$config_file" "$1" + read_env_file $config_file $1 } function show_message() { print_header @@ -87,14 +89,14 @@ function prepare_environment() { show_message "Prepare Environment..." >&2 show_message "- Updating OS with required tools ✋" >&2 - sudo apt-get update -y &> /dev/null - sudo apt-get upgrade -y &> /dev/null + sudo "$PACKAGE_MANAGER" update -y + sudo "$PACKAGE_MANAGER" upgrade -y - required_tools=("curl" "awk" "wget" "nano" "dialog" "git") + local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") for tool in "${required_tools[@]}"; do if ! command -v $tool &> /dev/null; then - sudo apt install -y $tool &> /dev/null + sudo "$PACKAGE_MANAGER" install -y $tool fi done @@ -103,11 +105,30 @@ function prepare_environment() { # Install Docker if not installed if ! command -v docker &> /dev/null; then show_message "- Installing Docker ✋" >&2 - sudo curl -o- https://get.docker.com | bash - + # curl -o- https://get.docker.com | bash - - if [ "$EUID" -ne 0 ]; then - dockerd-rootless-setuptool.sh install &> /dev/null + if [ "$PACKAGE_MANAGER" == "yum" ]; then + sudo $PACKAGE_MANAGER install -y yum-utils + sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null + elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then + # Add Docker's official GPG key: + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null + sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null + sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null + sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null + + # Add the repository to Apt sources: + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + sudo $PACKAGE_MANAGER update fi + + sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y + show_message "- Docker Installed ✅" "replace_last_line" >&2 else show_message "- Docker is already installed ✅" >&2 @@ -127,17 +148,17 @@ function prepare_environment() { function download_plane() { # Download Docker Compose File from github url show_message "Downloading Plane Setup Files ✋" >&2 - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$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 - mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env + sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env fi show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2 @@ -186,7 +207,7 @@ function build_local_image() { PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null - sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $BRANCH --single-branch -q > /dev/null + sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml @@ -199,25 +220,26 @@ function check_for_docker_images() { show_message "" >&2 # show_message "Building Plane Images" >&2 - update_env "DOCKERHUB_USER" "makeplane" - update_env "PULL_POLICY" "always" CURR_DIR=$(pwd) - if [ "$BRANCH" == "master" ]; then + if [ "$DEPLOY_BRANCH" == "master" ]; then update_env "APP_RELEASE" "latest" export APP_RELEASE=latest else - update_env "APP_RELEASE" "$BRANCH" - export APP_RELEASE=$BRANCH + update_env "APP_RELEASE" "$DEPLOY_BRANCH" + export APP_RELEASE=$DEPLOY_BRANCH fi - if [ $CPU_ARCH == "amd64" ] || [ $CPU_ARCH == "x86_64" ]; then + if [ $USE_GLOBAL_IMAGES == 1 ]; then # show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2 + export DOCKERHUB_USER=makeplane + update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" + update_env "PULL_POLICY" "always" echo "Building Plane Images for $CPU_ARCH is not required. Skipping..." else export DOCKERHUB_USER=myplane show_message "Building Plane Images for $CPU_ARCH " >&2 - update_env "DOCKERHUB_USER" "myplane" + update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" update_env "PULL_POLICY" "never" build_local_image @@ -233,7 +255,7 @@ function check_for_docker_images() { sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2 - docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull + sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull show_message "Plane Images Downloaded ✅" "replace_last_line" >&2 } function configure_plane() { @@ -453,9 +475,11 @@ function install() { show_message "" if [ "$(uname)" == "Linux" ]; then OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + print_header + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then OS_SUPPORTED=true show_message "******** Installing Plane ********" show_message "" @@ -488,7 +512,8 @@ function install() { fi else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌" + OS_SUPPORTED=false + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi @@ -499,12 +524,17 @@ function install() { fi } function upgrade() { + print_header if [ "$(uname)" == "Linux" ]; then OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then + OS_SUPPORTED=true + show_message "******** Upgrading Plane ********" + show_message "" prepare_environment @@ -528,53 +558,49 @@ function upgrade() { exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected" + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected : $(uname)" + PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" show_message "" exit 1 fi } function uninstall() { + print_header if [ "$(uname)" == "Linux" ]; then OS="linux" OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - # check the OS - if [ "$OS_NAME" == "ubuntu" ]; then + OS_NAME=$(echo "$OS_NAME" | tr -d '"') + if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || + [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then + OS_SUPPORTED=true show_message "******** Uninstalling Plane ********" show_message "" stop_server - # CHECK IF PLANE SERVICE EXISTS - # if [ -f "/etc/systemd/system/plane.service" ]; then - # sudo systemctl stop plane.service &> /dev/null - # sudo systemctl disable plane.service &> /dev/null - # sudo rm /etc/systemd/system/plane.service &> /dev/null - # sudo systemctl daemon-reload &> /dev/null - # fi - # show_message "- Plane Service removed ✅" if ! [ -x "$(command -v docker)" ]; then echo "DOCKER_NOT_INSTALLED" &> /dev/null else # Ask of user input to confirm uninstall docker ? - CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) + CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) if [ $? -eq 0 ]; then show_message "- Uninstalling Docker ✋" - sudo apt-get purge -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null - sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null + sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null + sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null + sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null show_message "- Docker Uninstalled ✅" "replace_last_line" >&2 fi fi - rm $PLANE_INSTALL_DIR/.env &> /dev/null - rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null - rm $PLANE_INSTALL_DIR/config.env &> /dev/null - rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null + sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null + sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null # rm -rf $PLANE_INSTALL_DIR &> /dev/null show_message "- Configuration Cleaned ✅" @@ -593,12 +619,12 @@ function uninstall() { show_message "" show_message "" else - PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" show_message "" exit 1 fi else - PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" + PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" show_message "" exit 1 fi @@ -608,15 +634,15 @@ function start_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Starting Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file up -d + show_message "Starting Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file up -d # Wait for containers to be running echo "Waiting for containers to start..." - while ! docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do + while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do sleep 1 done - show_message "Plane Server Started ✅" "replace_last_line" >&2 + show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -626,11 +652,11 @@ function stop_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Stopping Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file down - show_message "Plane Server Stopped ✅" "replace_last_line" >&2 + show_message "Stopping Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file down + show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2 else - show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 + show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2 fi } function restart_server() { @@ -638,9 +664,9 @@ function restart_server() { env_file="$PLANE_INSTALL_DIR/.env" # check if both the files exits if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Restarting Plane Server ✋" - docker compose -f $docker_compose_file --env-file=$env_file restart - show_message "Plane Server Restarted ✅" "replace_last_line" >&2 + show_message "Restarting Plane Server ($APP_RELEASE) ✋" + sudo docker compose -f $docker_compose_file --env-file=$env_file restart + show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2 else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -666,28 +692,45 @@ function show_help() { } function update_installer() { show_message "Updating Plane Installer ✋" >&2 - curl -H 'Cache-Control: no-cache, no-store' \ + sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/1-click/install.sh?token=$(date +%s) + https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) - chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null + sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null show_message "Plane Installer Updated ✅" "replace_last_line" >&2 } -export BRANCH=${BRANCH:-master} -export APP_RELEASE=$BRANCH +export DEPLOY_BRANCH=${BRANCH:-master} +export APP_RELEASE=$DEPLOY_BRANCH export DOCKERHUB_USER=makeplane export PULL_POLICY=always +if [ "$DEPLOY_BRANCH" == "master" ]; then + export APP_RELEASE=latest +fi + PLANE_INSTALL_DIR=/opt/plane DATA_DIR=$PLANE_INSTALL_DIR/data LOG_DIR=$PLANE_INSTALL_DIR/log OS_SUPPORTED=false CPU_ARCH=$(uname -m) PROGRESS_MSG="" -USE_GLOBAL_IMAGES=1 +USE_GLOBAL_IMAGES=0 +PACKAGE_MANAGER="" -mkdir -p $PLANE_INSTALL_DIR/{data,log} +if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then + USE_GLOBAL_IMAGES=1 +fi + +sudo mkdir -p $PLANE_INSTALL_DIR/{data,log} + +if command -v apt-get &> /dev/null; then + PACKAGE_MANAGER="apt-get" +elif command -v yum &> /dev/null; then + PACKAGE_MANAGER="yum" +elif command -v apk &> /dev/null; then + PACKAGE_MANAGER="apk" +fi if [ "$1" == "start" ]; then start_server @@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then upgrade elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then uninstall -elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then +elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then update_installer elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then show_help diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index b223e722a..60861878c 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -38,10 +38,6 @@ x-app-env : &app-env - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # OPENAI SETTINGS - Deprecated can be configured through admin panel - - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - - OPENAI_API_KEY=${OPENAI_API_KEY:-""} - - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 3f306c559..30f2d15d7 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -20,8 +20,8 @@ function buildLocalImage() { DO_BUILD="2" else printf "\n" >&2 - printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2 - printf "${YELLOW}Since the prebuilt ${ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2 + printf "${YELLOW}You are on ${CPU_ARCH} cpu architecture. ${NC}\n" >&2 + printf "${YELLOW}Since the prebuilt ${CPU_ARCH} compatible docker images are not available for, we will be running the docker build on this system. ${NC} \n" >&2 printf "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2 printf "\n" >&2 printf "${GREEN}Select an option to proceed: ${NC}\n" >&2 @@ -49,7 +49,7 @@ function buildLocalImage() { cd $PLANE_TEMP_CODE_DIR if [ "$BRANCH" == "master" ]; then - APP_RELEASE=latest + export APP_RELEASE=latest fi docker compose -f build.yml build --no-cache >&2 @@ -149,7 +149,7 @@ function upgrade() { function askForAction() { echo echo "Select a Action you want to perform:" - echo " 1) Install (${ARCH})" + echo " 1) Install (${CPU_ARCH})" echo " 2) Start" echo " 3) Stop" echo " 4) Restart" @@ -193,8 +193,8 @@ function askForAction() { } # CPU ARCHITECHTURE BASED SETTINGS -ARCH=$(uname -m) -if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ]; +CPU_ARCH=$(uname -m) +if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then USE_GLOBAL_IMAGES=1 DOCKERHUB_USER=makeplane @@ -205,6 +205,11 @@ else PULL_POLICY=never fi +if [ "$BRANCH" == "master" ]; +then + export APP_RELEASE=latest +fi + # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME if [ "$BRANCH" != "master" ]; then diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 4a3781811..6d2cde0ff 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -8,13 +8,13 @@ NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces -SENTRY_DSN="" -SENTRY_ENVIRONMENT="production" -GOOGLE_CLIENT_ID="" -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" +SENTRY_DSN= +SENTRY_ENVIRONMENT=production +GOOGLE_CLIENT_ID= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= DOCKERIZED=1 # deprecated -CORS_ALLOWED_ORIGINS="http://localhost" +CORS_ALLOWED_ORIGINS=http://localhost #DB SETTINGS PGHOST=plane-db @@ -31,19 +31,14 @@ REDIS_PORT=6379 REDIS_URL=redis://${REDIS_HOST}:6379/ # EMAIL SETTINGS -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" +EMAIL_HOST= +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= EMAIL_PORT=587 -EMAIL_FROM="Team Plane " +EMAIL_FROM=Team Plane EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 -# OPENAI SETTINGS -OPENAI_API_BASE=https://api.openai.com/v1 # deprecated -OPENAI_API_KEY="sk-" # deprecated -GPT_ENGINE="gpt-3.5-turbo" # deprecated - # LOGIN/SIGNUP SETTINGS ENABLE_SIGNUP=1 ENABLE_EMAIL_PASSWORD=1 @@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 # DATA STORE SETTINGS USE_MINIO=1 -AWS_REGION="" -AWS_ACCESS_KEY_ID="access-key" -AWS_SECRET_ACCESS_KEY="secret-key" +AWS_REGION= +AWS_ACCESS_KEY_ID=access-key +AWS_SECRET_ACCESS_KEY=secret-key AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_BUCKET_NAME=uploads -MINIO_ROOT_USER="access-key" -MINIO_ROOT_PASSWORD="secret-key" +MINIO_ROOT_USER=access-key +MINIO_ROOT_PASSWORD=secret-key BUCKET_NAME=uploads FILE_SIZE_LIMIT=5242880 diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx b/packages/editor/core/src/ui/extensions/horizontal-rule.tsx deleted file mode 100644 index cee0ded83..000000000 --- a/packages/editor/core/src/ui/extensions/horizontal-rule.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { TextSelection } from "prosemirror-state"; - -import { InputRule, mergeAttributes, Node, nodeInputRule, wrappingInputRule } from "@tiptap/core"; - -/** - * Extension based on: - * - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule) - */ - -export interface HorizontalRuleOptions { - HTMLAttributes: Record; -} - -declare module "@tiptap/core" { - interface Commands { - horizontalRule: { - /** - * Add a horizontal rule - */ - setHorizontalRule: () => ReturnType; - }; - } -} - -export const HorizontalRule = Node.create({ - name: "horizontalRule", - - addOptions() { - return { - HTMLAttributes: {}, - }; - }, - - group: "block", - - addAttributes() { - return { - color: { - default: "#dddddd", - }, - }; - }, - - parseHTML() { - return [ - { - tag: `div[data-type="${this.name}"]`, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - "div", - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - "data-type": this.name, - }), - ["div", {}], - ]; - }, - - addCommands() { - return { - setHorizontalRule: - () => - ({ chain }) => { - return ( - chain() - .insertContent({ type: this.name }) - // set cursor after horizontal rule - .command(({ tr, dispatch }) => { - if (dispatch) { - const { $to } = tr.selection; - const posAfter = $to.end(); - - if ($to.nodeAfter) { - tr.setSelection(TextSelection.create(tr.doc, $to.pos)); - } else { - // add node after horizontal rule if it’s the end of the document - const node = $to.parent.type.contentMatch.defaultType?.create(); - - if (node) { - tr.insert(posAfter, node); - tr.setSelection(TextSelection.create(tr.doc, posAfter)); - } - } - - tr.scrollIntoView(); - } - - return true; - }) - .run() - ); - }, - }; - }, - - addInputRules() { - return [ - new InputRule({ - find: /^(?:---|—-|___\s|\*\*\*\s)$/, - handler: ({ state, range, match }) => { - state.tr.replaceRangeWith(range.from, range.to, this.type.create()); - }, - }), - ]; - }, -}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index a40f97c88..bdca28398 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -1,26 +1,25 @@ -import StarterKit from "@tiptap/starter-kit"; -import TiptapUnderline from "@tiptap/extension-underline"; -import TextStyle from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import StarterKit from "@tiptap/starter-kit"; import { Markdown } from "tiptap-markdown"; -import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { Table } from "src/ui/extensions/table/table"; import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { TableRow } from "src/ui/extensions/table/table-row/table-row"; -import { HorizontalRule } from "src/ui/extensions/horizontal-rule"; import { ImageExtension } from "src/ui/extensions/image"; import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; -import { CustomKeymap } from "src/ui/extensions/keymap"; import { CustomCodeBlockExtension } from "src/ui/extensions/code"; -import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; +import { CustomKeymap } from "src/ui/extensions/keymap"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { DeleteImage } from "src/types/delete-image"; import { IMentionSuggestion } from "src/types/mention-suggestion"; @@ -55,7 +54,9 @@ export const CoreEditorExtensions = ( }, code: false, codeBlock: false, - horizontalRule: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, blockquote: false, dropcursor: { color: "rgba(var(--color-text-100))", @@ -104,7 +105,6 @@ export const CoreEditorExtensions = ( transformCopiedText: true, transformPastedText: true, }), - HorizontalRule, Table, TableHeader, TableCell, diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index 6a47d79f0..e723ca0d7 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -10,6 +10,11 @@ export interface CustomMentionOptions extends MentionOptions { } export const CustomMention = Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, addAttributes() { return { id: { diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index 6d706cb79..40e75a1e3 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -14,6 +14,7 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ return { onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + props.editor.storage.mentionsOpen = true; reactRenderer = new ReactRenderer(MentionList, { props, editor: props.editor, @@ -45,10 +46,18 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({ return true; } - // @ts-ignore - return reactRenderer?.ref?.onKeyDown(props); + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + // @ts-ignore + reactRenderer?.ref?.onKeyDown(props); + event?.stopPropagation(); + return true; + } + return false; }, - onExit: () => { + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; popup?.[0].destroy(); reactRenderer?.destroy(); }, diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 708fefd7f..05716243e 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -11,7 +11,6 @@ import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { Table } from "src/ui/extensions/table/table"; import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; import { TableRow } from "src/ui/extensions/table/table-row/table-row"; -import { HorizontalRule } from "src/ui/extensions/horizontal-rule"; import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"; import { isValidHttpUrl } from "src/lib/utils"; @@ -51,7 +50,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { }, }, codeBlock: false, - horizontalRule: false, + horizontalRule: { + HTMLAttributes: { class: "mt-4 mb-4" }, + }, dropcursor: { color: "rgba(var(--color-text-100))", width: 2, @@ -72,7 +73,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "rounded-lg border border-neutral-border-medium", }, }), - HorizontalRule, TiptapUnderline, TextStyle, Color, diff --git a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx index 129efa4ee..7d93bf36f 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/enter-key-extension.tsx @@ -4,13 +4,16 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ name: "enterKey", - addKeyboardShortcuts() { + addKeyboardShortcuts(this) { return { Enter: () => { - if (onEnterKeyPress) { - onEnterKeyPress(); + if (!this.editor.storage.mentionsOpen) { + if (onEnterKeyPress) { + onEnterKeyPress(); + } + return true; } - return true; + return false; }, "Shift-Enter": ({ editor }) => editor.commands.first(({ commands }) => [ diff --git a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx index 527fd5674..c4b24d166 100644 --- a/packages/editor/lite-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/extensions/index.tsx @@ -1,5 +1,3 @@ import { EnterKeyExtension } from "src/ui/extensions/enter-key-extension"; -export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [ - // EnterKeyExtension(onEnterKeyPress), -]; +export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [EnterKeyExtension(onEnterKeyPress)]; diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 91c6ef1d5..12cbab4c6 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: IUser; + owned_by: string; project: string; project_detail: IProjectLite; status: TCycleGroups; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.d.ts index 31751c0d0..7cfa6aa85 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.d.ts @@ -13,9 +13,10 @@ export type TWidgetKeys = | "recent_projects" | "recent_collaborators"; -export type TIssuesListTypes = "upcoming" | "overdue" | "completed"; +export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed"; export type TDurationFilterOptions = + | "none" | "today" | "this_week" | "this_month" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 9734f85c2..527abe630 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -21,7 +21,7 @@ export type TIssue = { project_id: string; parent_id: string | null; cycle_id: string | null; - module_id: string | null; + module_ids: string[] | null; created_at: string; updated_at: string; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/projects.d.ts index a412180b8..b54e3f0f9 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -117,7 +117,7 @@ export type TProjectIssuesSearchParams = { parent?: boolean; issue_relation?: boolean; cycle?: boolean; - module?: boolean; + module?: string[]; sub_issue?: boolean; issue_id?: string; workspace_search: boolean; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 7f1d49632..61cc7081b 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -64,8 +64,7 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups" - | "start_target_date"; + | "show_empty_groups"; export type TCalendarLayouts = "month" | "week"; @@ -93,7 +92,6 @@ export interface IIssueDisplayFilterOptions { layout?: TIssueLayouts; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; - start_target_date?: boolean; sub_issue?: boolean; type?: TIssueTypeFilters; } diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index e82944c03..0f09764ac 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -2,8 +2,6 @@ import * as React from "react"; // icons import { ChevronRight } from "lucide-react"; -// components -import { Tooltip } from "../tooltip"; type BreadcrumbsProps = { children: any; @@ -25,42 +23,11 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => ( type Props = { type?: "text" | "component"; component?: React.ReactNode; - label?: string; - icon?: React.ReactNode; - link?: string; + link?: JSX.Element; }; const BreadcrumbItem: React.FC = (props) => { - const { type = "text", component, label, icon, link } = props; - return ( - <> - {type != "text" ? ( -
{component}
- ) : ( - -
  • -
    - {link ? ( - - {icon && ( -
    {icon}
    - )} -
    {label}
    -
    - ) : ( -
    - {icon &&
    {icon}
    } -
    {label}
    -
    - )} -
    -
  • -
    - )} - - ); + const { type = "text", component, link } = props; + return <>{type != "text" ?
    {component}
    : link}; }; Breadcrumbs.BreadcrumbItem = BreadcrumbItem; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 45ecdb578..604dbe0b1 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const { buttonClassName = "", customButtonClassName = "", + customButtonTabIndex = 0, placement, children, className = "", @@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { verticalEllipsis = false, portalElement, menuButtonOnClick, + onMenuClose, tabIndex, closeOnSelect, } = props; @@ -47,18 +49,27 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => setIsOpen(false); - const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + const closeDropdown = () => { + isOpen && onMenuClose && onMenuClose(); + setIsOpen(false); + }; + + const handleOnChange = () => { + if (closeOnSelect) closeDropdown(); + }; + + const selectActiveItem = () => { + const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( + `[data-headlessui-state="active"] button` + ); + activeItem?.click(); + }; + + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem); useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( - { - if (closeOnSelect) closeDropdown(); - }} - static - > +
    { ref={dropdownRef} tabIndex={tabIndex} className={cn("relative w-min text-left", className)} - onKeyDown={handleKeyDown} + onKeyDownCapture={handleKeyDown} + onChange={handleOnChange} > {({ open }) => ( <> @@ -103,6 +115,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (menuButtonOnClick) menuButtonOnClick(); }} className={customButtonClassName} + tabIndex={customButtonTabIndex} > {customButton} @@ -122,6 +135,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none hover:text-custom-text-100 ${ disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-neutral-component-surface-dark" } ${buttonClassName}`} + tabIndex={customButtonTabIndex} > @@ -142,6 +156,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); }} + tabIndex={customButtonTabIndex} > {label} {!noChevron && } @@ -159,6 +174,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { const MenuItem: React.FC = (props) => { const { children, onClick, className = "" } = props; + return ( {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 06f1c44c0..930f332b9 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2"; export interface IDropdownProps { customButtonClassName?: string; + customButtonTabIndex?: number; buttonClassName?: string; className?: string; customButton?: JSX.Element; @@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; } diff --git a/packages/ui/src/hooks/use-dropdown-key-down.tsx b/packages/ui/src/hooks/use-dropdown-key-down.tsx index 1bb861477..b93a4d551 100644 --- a/packages/ui/src/hooks/use-dropdown-key-down.tsx +++ b/packages/ui/src/hooks/use-dropdown-key-down.tsx @@ -1,16 +1,23 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent) => void; + ( + onOpen: () => void, + onClose: () => void, + isOpen: boolean, + selectActiveItem?: () => void + ): (event: React.KeyboardEvent) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => { const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") { - event.stopPropagation(); if (!isOpen) { + event.stopPropagation(); onOpen(); + } else { + selectActiveItem && selectActiveItem(); } } else if (event.key === "Escape" && isOpen) { event.stopPropagation(); diff --git a/packages/ui/src/progress/linear-progress-indicator.tsx b/packages/ui/src/progress/linear-progress-indicator.tsx index 467285024..7cf9717a0 100644 --- a/packages/ui/src/progress/linear-progress-indicator.tsx +++ b/packages/ui/src/progress/linear-progress-indicator.tsx @@ -1,13 +1,20 @@ import React from "react"; import { Tooltip } from "../tooltip"; +import { cn } from "../../helpers"; type Props = { data: any; noTooltip?: boolean; inPercentage?: boolean; + size?: "sm" | "md" | "lg"; }; -export const LinearProgressIndicator: React.FC = ({ data, noTooltip = false, inPercentage = false }) => { +export const LinearProgressIndicator: React.FC = ({ + data, + noTooltip = false, + inPercentage = false, + size = "sm", +}) => { const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0); // eslint-disable-next-line @typescript-eslint/no-unused-vars let progress = 0; @@ -23,18 +30,24 @@ export const LinearProgressIndicator: React.FC = ({ data, noTooltip = fal if (noTooltip) return
    ; else return ( - -
    + +
    ); }); return ( -
    +
    {total === 0 ? ( -
    {bars}
    +
    {bars}
    ) : ( -
    {bars}
    +
    {bars}
    )}
    ); diff --git a/web/components/account/deactivate-account-modal.tsx b/web/components/account/deactivate-account-modal.tsx index b60374891..b15d2dc77 100644 --- a/web/components/account/deactivate-account-modal.tsx +++ b/web/components/account/deactivate-account-modal.tsx @@ -89,8 +89,8 @@ export const DeactivateAccountModal: React.FC = (props) => {
    -
    -
    + , updates: any) => + handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => { + captureIssueEvent({ + eventName: "Issue updated", + payload: { + ...issue, + ...data, + element: "Spreadsheet layout", + }, + updates: updates, + path: router.asPath, + }); + }) + } + disabled={disableUserActions} + onClose={() => { + tableCellRef?.current?.focus(); + }} + /> +
    +
    { href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`} target="_blank" onClick={() => handleIssuePeekOverview(issueDetail)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" >
    -
    +
    {issueDetail.name}
    @@ -147,29 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
    - ) => - handleIssues({ ...issue, ...data }, EIssueActions.UPDATE) - } - disabled={disableUserActions} - /> -
    + { + tableHeaderCellRef?.current?.focus(); + }} + /> +
    + #ID @@ -33,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => { - -
    +
    void; onSubmit: (formData: Partial) => Promise; projectId: string; + isDraft: boolean; } const issueDraftService = new IssueDraftService(); @@ -34,6 +36,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { projectId, isCreateMoreToggleEnabled, onCreateMoreToggleChange, + isDraft, } = props; // states const [issueDiscardModal, setIssueDiscardModal] = useState(false); @@ -42,6 +45,8 @@ export const DraftIssueLayout: React.FC = observer((props) => { const { workspaceSlug } = router.query; // toast alert const { setToastAlert } = useToast(); + // store hooks + const { captureIssueEvent } = useEventTracker(); const handleClose = () => { if (changesMade) setIssueDiscardModal(true); @@ -55,24 +60,33 @@ export const DraftIssueLayout: React.FC = observer((props) => { await issueDraftService .createDraftIssue(workspaceSlug.toString(), projectId.toString(), payload) - .then(() => { + .then((res) => { setToastAlert({ type: "success", title: "Success!", message: "Draft Issue created successfully.", }); - + captureIssueEvent({ + eventName: "Draft issue created", + payload: { ...res, state: "SUCCESS" }, + path: router.asPath, + }); onChange(null); setIssueDiscardModal(false); onClose(false); }) - .catch(() => + .catch(() => { setToastAlert({ type: "error", title: "Error!", message: "Issue could not be created. Please try again.", - }) - ); + }); + captureIssueEvent({ + eventName: "Draft issue created", + payload: { ...payload, state: "FAILED" }, + path: router.asPath, + }); + }); }; return ( @@ -95,6 +109,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { onClose={handleClose} onSubmit={onSubmit} projectId={projectId} + isDraft={isDraft} /> ); diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 96ce648ee..f843e4aa7 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState, useRef, useEffect } from "react"; +import React, { FC, useState, useRef, useEffect, Fragment } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; @@ -44,7 +44,7 @@ const defaultValues: Partial = { assignee_ids: [], label_ids: [], cycle_id: null, - module_id: null, + module_ids: null, start_date: null, target_date: null, }; @@ -55,8 +55,9 @@ export interface IssueFormProps { onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; - onSubmit: (values: Partial) => Promise; + onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; projectId: string; + isDraft: boolean; } // services @@ -72,6 +73,7 @@ export const IssueFormRoot: FC = observer((props) => { projectId: defaultProjectId, isCreateMoreToggleEnabled, onCreateMoreToggleChange, + isDraft, } = props; // states const [labelModal, setLabelModal] = useState(false); @@ -132,12 +134,13 @@ export const IssueFormRoot: FC = observer((props) => { parent_id: formData.parent_id, }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); const issueName = watch("name"); - const handleFormSubmit = async (formData: Partial) => { - await onSubmit(formData); + const handleFormSubmit = async (formData: Partial, is_draft_issue = false) => { + await onSubmit(formData, is_draft_issue); setGptAssistantModal(false); @@ -247,7 +250,7 @@ export const IssueFormRoot: FC = observer((props) => { }} /> )} -
    + handleFormSubmit(data))}>
    {/* Don't show project selection if editing an issue */} @@ -267,6 +270,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); }} buttonVariant="border-with-text" + // TODO: update tabIndex logic tabIndex={19} />
    @@ -541,21 +545,23 @@ export const IssueFormRoot: FC = observer((props) => { )} /> )} - {projectDetails?.module_view && ( + {projectDetails?.module_view && workspaceSlug && ( (
    { - onChange(moduleId); + value={value ?? []} + onChange={(moduleIds) => { + onChange(moduleIds); handleFormChange(); }} buttonVariant="border-with-text" tabIndex={13} + multiple + showCount />
    )} @@ -666,7 +672,34 @@ 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 0ade2b67b..744ebf675 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from "react"; +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; // hooks -import { useApplication, useCycle, useIssues, useModule, useProject, useUser, useWorkspace } from "hooks/store"; +import { useApplication, useEventTracker, useCycle, useIssues, useModule, useProject, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; import useLocalStorage from "hooks/use-local-storage"; // components @@ -12,7 +13,6 @@ import { IssueFormRoot } from "./form"; import type { TIssue } from "@plane/types"; // constants import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; - export interface IssuesModalProps { data?: Partial; isOpen: boolean; @@ -20,19 +20,25 @@ export interface IssuesModalProps { onSubmit?: (res: TIssue) => Promise; withDraftIssueWrapper?: boolean; storeType?: TCreateModalStoreTypes; + isDraft?: boolean; } export const CreateUpdateIssueModal: React.FC = observer((props) => { - const { data, isOpen, onClose, onSubmit, withDraftIssueWrapper = true, storeType = EIssuesStoreType.PROJECT } = props; + const { + data, + isOpen, + onClose, + onSubmit, + withDraftIssueWrapper = true, + storeType = EIssuesStoreType.PROJECT, + isDraft = false, + } = props; // states const [changesMade, setChangesMade] = useState | null>(null); const [createMore, setCreateMore] = useState(false); const [activeProjectId, setActiveProjectId] = useState(null); // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); - const { currentUser } = useUser(); + const { captureIssueEvent } = useEventTracker(); const { router: { workspaceSlug, projectId, cycleId, moduleId, viewId: projectViewId }, } = useApplication(); @@ -45,40 +51,38 @@ 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); // store mapping based on current store const issueStores = { [EIssuesStoreType.PROJECT]: { store: projectIssues, - dataIdToUpdate: activeProjectId, viewId: undefined, }, [EIssuesStoreType.PROJECT_VIEW]: { store: viewIssues, - dataIdToUpdate: activeProjectId, viewId: projectViewId, }, [EIssuesStoreType.PROFILE]: { store: profileIssues, - dataIdToUpdate: currentUser?.id || undefined, viewId: undefined, }, [EIssuesStoreType.CYCLE]: { store: cycleIssues, - dataIdToUpdate: activeProjectId, viewId: cycleId, }, [EIssuesStoreType.MODULE]: { store: moduleIssues, - dataIdToUpdate: activeProjectId, viewId: moduleId, }, }; + // router + const router = useRouter(); // toast alert const { setToastAlert } = useToast(); // local storage const { setValue: setLocalStorageDraftIssue } = useLocalStorage("draftedIssue", {}); // current store details - const { store: currentIssueStore, viewId, dataIdToUpdate } = issueStores[storeType]; + const { store: currentIssueStore, viewId } = issueStores[storeType]; useEffect(() => { // if modal is closed, reset active project to null @@ -108,11 +112,11 @@ export const CreateUpdateIssueModal: React.FC = observer((prop fetchCycleDetails(workspaceSlug, activeProjectId, cycleId); }; - const addIssueToModule = async (issue: TIssue, moduleId: string) => { + const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { if (!workspaceSlug || !activeProjectId) return; - await moduleIssues.addIssueToModule(workspaceSlug, activeProjectId, moduleId, [issue.id]); - fetchModuleDetails(workspaceSlug, activeProjectId, moduleId); + await moduleIssues.addModulesToIssue(workspaceSlug, activeProjectId, issue.id, moduleIds); + moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId)); }; const handleCreateMoreToggleChange = (value: boolean) => { @@ -128,37 +132,40 @@ export const CreateUpdateIssueModal: React.FC = observer((prop onClose(); }; - const handleCreateIssue = async (payload: Partial): Promise => { - if (!workspaceSlug || !dataIdToUpdate) return; + const handleCreateIssue = async ( + payload: Partial, + is_draft_issue: boolean = false + ): Promise => { + if (!workspaceSlug || !payload.project_id) return; try { - const response = await currentIssueStore.createIssue(workspaceSlug, dataIdToUpdate, payload, viewId); + const response = is_draft_issue + ? await draftIssueStore.createIssue(workspaceSlug, payload.project_id, payload) + : await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId); if (!response) throw new Error(); - currentIssueStore.fetchIssues(workspaceSlug, dataIdToUpdate, "mutation", viewId); + currentIssueStore.fetchIssues(workspaceSlug, payload.project_id, "mutation", viewId); if (payload.cycle_id && payload.cycle_id !== "" && storeType !== EIssuesStoreType.CYCLE) await addIssueToCycle(response, payload.cycle_id); - if (payload.module_id && payload.module_id !== "" && storeType !== EIssuesStoreType.MODULE) - await addIssueToModule(response, payload.module_id); + if (payload.module_ids && payload.module_ids.length > 0 && storeType !== EIssuesStoreType.MODULE) + await addIssueToModule(response, payload.module_ids); setToastAlert({ type: "success", title: "Success!", message: "Issue created successfully.", }); - postHogEventTracker( - "ISSUE_CREATED", - { - ...response, - state: "SUCCESS", - }, - { + captureIssueEvent({ + eventName: "Issue created", + payload: { ...response, state: "SUCCESS" }, + path: router.asPath, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); !createMore && handleClose(); return response; } catch (error) { @@ -167,42 +174,39 @@ export const CreateUpdateIssueModal: React.FC = observer((prop title: "Error!", message: "Issue could not be created. Please try again.", }); - postHogEventTracker( - "ISSUE_CREATED", - { - state: "FAILED", - }, - { + captureIssueEvent({ + eventName: "Issue created", + payload: { ...payload, state: "FAILED" }, + path: router.asPath, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); } }; const handleUpdateIssue = async (payload: Partial): Promise => { - if (!workspaceSlug || !dataIdToUpdate || !data?.id) return; + if (!workspaceSlug || !payload.project_id || !data?.id) return; try { - const response = await currentIssueStore.updateIssue(workspaceSlug, dataIdToUpdate, data.id, payload, viewId); + const response = await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId); setToastAlert({ type: "success", title: "Success!", message: "Issue updated successfully.", }); - postHogEventTracker( - "ISSUE_UPDATED", - { - ...response, - state: "SUCCESS", - }, - { + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...response, state: "SUCCESS" }, + path: router.asPath, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); handleClose(); return response; } catch (error) { @@ -211,22 +215,21 @@ export const CreateUpdateIssueModal: React.FC = observer((prop title: "Error!", message: "Issue could not be created. Please try again.", }); - postHogEventTracker( - "ISSUE_UPDATED", - { - state: "FAILED", - }, - { + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...payload, state: "FAILED" }, + path: router.asPath, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); } }; - const handleFormSubmit = async (formData: Partial) => { - if (!workspaceSlug || !dataIdToUpdate || !storeType) return; + const handleFormSubmit = async (formData: Partial, is_draft_issue: boolean = false) => { + if (!workspaceSlug || !formData.project_id || !storeType) return; const payload: Partial = { ...formData, @@ -234,7 +237,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop }; let response: TIssue | undefined = undefined; - if (!data?.id) response = await handleCreateIssue(payload); + if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue); else response = await handleUpdateIssue(payload); if (response != undefined && onSubmit) await onSubmit(response); @@ -277,8 +280,8 @@ export const CreateUpdateIssueModal: React.FC = observer((prop changesMade={changesMade} data={{ ...data, - cycle_id: cycleId ?? null, - module_id: moduleId ?? null, + cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null, + module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null, }} onChange={handleFormChange} onClose={handleClose} @@ -286,19 +289,21 @@ export const CreateUpdateIssueModal: React.FC = observer((prop projectId={activeProjectId} isCreateMoreToggleEnabled={createMore} onCreateMoreToggleChange={handleCreateMoreToggleChange} + isDraft={isDraft} /> ) : ( handleClose(false)} isCreateMoreToggleEnabled={createMore} onCreateMoreToggleChange={handleCreateMoreToggleChange} onSubmit={handleFormSubmit} projectId={activeProjectId} + isDraft={isDraft} /> )} diff --git a/web/components/issues/issues-mobile-header.tsx b/web/components/issues/issues-mobile-header.tsx new file mode 100644 index 000000000..2338e1848 --- /dev/null +++ b/web/components/issues/issues-mobile-header.tsx @@ -0,0 +1,166 @@ +import { useCallback, useState } from "react"; +import router from "next/router"; +// components +import { CustomMenu } from "@plane/ui"; +// icons +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +// constants +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +// hooks +import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; +// layouts +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "./issue-layouts"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectAnalyticsModal } from "components/analytics"; + +export const IssuesMobileHeader = () => { + 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; + + 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] ?? []; + + 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 + + + } + > + + +
    +
    + + Display + + + } + > + + +
    + + +
    + + ); +}; diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index e90c831f6..6f0f0ef21 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -99,7 +99,7 @@ export const PeekOverviewProperties: FC = observer((pro projectId={projectId} placeholder="Add assignees" multiple - buttonVariant={issue?.assignee_ids?.length > 0 ? "transparent-without-text" : "transparent-with-text"} + buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"} className="w-3/4 flex-grow group" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm justify-between ${issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400"}`} @@ -148,6 +148,8 @@ export const PeekOverviewProperties: FC = observer((pro buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`} hideIcon clearIconClassName="h-3 w-3 hidden group-hover:inline" + // TODO: add this logic + // showPlaceholderIcon /> @@ -173,6 +175,8 @@ export const PeekOverviewProperties: FC = observer((pro buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`} hideIcon clearIconClassName="h-3 w-3 hidden group-hover:inline" + // TODO: add this logic + // showPlaceholderIcon /> @@ -201,7 +205,7 @@ export const PeekOverviewProperties: FC = observer((pro )} {projectDetails?.module_view && ( -
    +
    Module @@ -251,8 +255,8 @@ export const PeekOverviewProperties: FC = observer((pro
    {/* relates to */} -
    -
    +
    +
    Relates to
    @@ -267,8 +271,8 @@ export const PeekOverviewProperties: FC = observer((pro
    {/* blocking */} -
    -
    +
    +
    Blocking
    @@ -283,8 +287,8 @@ export const PeekOverviewProperties: FC = observer((pro
    {/* blocked by */} -
    -
    +
    +
    Blocked by
    @@ -299,8 +303,8 @@ export const PeekOverviewProperties: FC = observer((pro
    {/* duplicate of */} -
    -
    +
    +
    Duplicate of
    diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index 89a659fb3..f14018ed4 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -1,8 +1,9 @@ import { FC, Fragment, useEffect, useState, useMemo } from "react"; +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; // hooks import useToast from "hooks/use-toast"; -import { useIssueDetail, useIssues, useUser } from "hooks/store"; +import { useEventTracker, useIssueDetail, useIssues, useUser } from "hooks/store"; // components import { IssueView } from "components/issues"; // types @@ -28,14 +29,27 @@ export type TIssuePeekOperations = { remove: (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; - addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; - removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; + addModulesToIssue?: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeIssueFromModule?: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueId: string + ) => Promise; + removeModulesFromIssue?: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; }; export const IssuePeekOverview: FC = observer((props) => { const { is_archived = false, onIssueUpdate } = props; // hooks const { setToastAlert } = useToast(); + // router + const router = useRouter(); const { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); @@ -48,7 +62,9 @@ export const IssuePeekOverview: FC = observer((props) => { removeIssue, issue: { getIssueById, fetchIssue }, } = useIssueDetail(); - const { addIssueToCycle, removeIssueFromCycle, addIssueToModule, removeIssueFromModule } = useIssueDetail(); + const { addIssueToCycle, removeIssueFromCycle, addModulesToIssue, removeIssueFromModule, removeModulesFromIssue } = + useIssueDetail(); + const { captureIssueEvent } = useEventTracker(); // state const [loader, setLoader] = useState(false); @@ -86,7 +102,21 @@ export const IssuePeekOverview: FC = observer((props) => { type: "success", message: "Issue updated successfully", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, + updates: { + changed_property: Object.keys(data).join(","), + change_details: Object.values(data).join(","), + }, + path: router.asPath, + }); } catch (error) { + captureIssueEvent({ + eventName: "Issue updated", + payload: { state: "FAILED", element: "Issue peek-overview" }, + path: router.asPath, + }); setToastAlert({ title: "Issue update failed", type: "error", @@ -96,30 +126,59 @@ 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); + let response; + if (is_archived) response = await removeArchivedIssue(workspaceSlug, projectId, issueId); + else response = await removeIssue(workspaceSlug, projectId, issueId); setToastAlert({ title: "Issue deleted successfully", type: "success", message: "Issue deleted successfully", }); + captureIssueEvent({ + eventName: "Issue deleted", + payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, + path: router.asPath, + }); } catch (error) { setToastAlert({ title: "Issue delete failed", type: "error", message: "Issue delete failed", }); + captureIssueEvent({ + eventName: "Issue deleted", + 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); + const response = await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); setToastAlert({ title: "Cycle added to issue successfully", type: "success", message: "Issue added to issue successfully", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, + updates: { + changed_property: "cycle_id", + change_details: cycleId, + }, + path: router.asPath, + }); } catch (error) { + captureIssueEvent({ + eventName: "Issue updated", + payload: { state: "FAILED", element: "Issue peek-overview" }, + updates: { + changed_property: "cycle_id", + change_details: cycleId, + }, + path: router.asPath, + }); setToastAlert({ title: "Cycle add to issue failed", type: "error", @@ -129,29 +188,65 @@ export const IssuePeekOverview: FC = observer((props) => { }, removeIssueFromCycle: async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { try { - await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + const response = await removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); setToastAlert({ title: "Cycle removed from issue successfully", type: "success", message: "Cycle removed from issue successfully", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, + updates: { + changed_property: "cycle_id", + change_details: "", + }, + path: router.asPath, + }); } catch (error) { setToastAlert({ title: "Cycle remove from issue failed", type: "error", message: "Cycle remove from issue failed", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { state: "FAILED", element: "Issue peek-overview" }, + updates: { + changed_property: "cycle_id", + change_details: "", + }, + path: router.asPath, + }); } }, - addIssueToModule: async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addModulesToIssue: async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { try { - await addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + const response = await addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); setToastAlert({ title: "Module added to issue successfully", type: "success", message: "Module added to issue successfully", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, + updates: { + changed_property: "module_id", + change_details: moduleIds, + }, + path: router.asPath, + }); } catch (error) { + captureIssueEvent({ + eventName: "Issue updated", + payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, + updates: { + changed_property: "module_id", + change_details: moduleIds, + }, + path: router.asPath, + }); setToastAlert({ title: "Module add to issue failed", type: "error", @@ -167,6 +262,45 @@ export const IssuePeekOverview: FC = observer((props) => { type: "success", message: "Module removed from issue successfully", }); + captureIssueEvent({ + eventName: "Issue updated", + payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, + updates: { + changed_property: "module_id", + change_details: "", + }, + path: router.asPath, + }); + } catch (error) { + captureIssueEvent({ + eventName: "Issue updated", + payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, + updates: { + changed_property: "module_id", + change_details: "", + }, + path: router.asPath, + }); + setToastAlert({ + title: "Module remove from issue failed", + type: "error", + message: "Module remove from issue failed", + }); + } + }, + removeModulesFromIssue: async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => { + try { + await removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); + setToastAlert({ + title: "Module removed from issue successfully", + type: "success", + message: "Module removed from issue successfully", + }); } catch (error) { setToastAlert({ title: "Module remove from issue failed", @@ -184,8 +318,9 @@ export const IssuePeekOverview: FC = observer((props) => { removeArchivedIssue, addIssueToCycle, removeIssueFromCycle, - addIssueToModule, + addModulesToIssue, removeIssueFromModule, + removeModulesFromIssue, setToastAlert, onIssueUpdate, ] diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 293395410..25a997036 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -234,7 +234,6 @@ export const IssueView: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={disabled} />
    ) : ( @@ -255,7 +254,6 @@ export const IssueView: FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} - disabled={disabled} />
    diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index 88b5e476c..bcb95af1a 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -1,6 +1,5 @@ import React, { Fragment, useRef, useState } from "react"; import { useRouter } from "next/router"; -import useSWR from "swr"; import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; import { observer } from "mobx-react-lite"; @@ -48,14 +47,34 @@ export const IssueLabelSelect: React.FC = observer((props) => { const filteredOptions = query === "" ? projectLabels : projectLabels?.filter((l) => l.name.toLowerCase().includes(query.toLowerCase())); - const openDropdown = () => { - setIsDropdownOpen(true); + const onOpen = () => { if (!projectLabels && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug.toString(), projectId); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => setIsDropdownOpen(false); - const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isDropdownOpen); - useOutsideClickDetector(dropdownRef, closeDropdown); + + const handleClose = () => { + if (isDropdownOpen) setIsDropdownOpen(false); + if (referenceElement) referenceElement.blur(); + }; + + const toggleDropdown = () => { + if (!isDropdownOpen) onOpen(); + setIsDropdownOpen((prevIsOpen) => !prevIsOpen); + }; + + const dropdownOnChange = (val: string[]) => { + onChange(val); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); return ( = observer((props) => { ref={dropdownRef} tabIndex={tabIndex} value={value} - onChange={(val) => onChange(val)} + onChange={dropdownOnChange} className="relative flex-shrink-0 h-full" multiple disabled={disabled} @@ -74,7 +93,7 @@ export const IssueLabelSelect: React.FC = observer((props) => { type="button" ref={setReferenceElement} className="h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200" - onClick={openDropdown} + onClick={handleOnClick} > {label ? ( label diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index 026c505b4..99ff6255e 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -1,8 +1,9 @@ import { FC, useMemo, useState } from "react"; +import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Plus, ChevronRight, ChevronDown, Loader } from "lucide-react"; // hooks -import { useIssueDetail } from "hooks/store"; +import { useEventTracker, useIssueDetail } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ExistingIssuesListModal } from "components/core"; @@ -43,6 +44,8 @@ export type TSubIssueOperations = { export const SubIssuesRoot: FC = observer((props) => { const { workspaceSlug, projectId, parentIssueId, disabled = false } = props; + // router + const router = useRouter(); // store hooks const { setToastAlert } = useToast(); const { @@ -54,6 +57,7 @@ export const SubIssuesRoot: FC = observer((props) => { removeSubIssue, deleteSubIssue, } = useIssueDetail(); + const { setTrackElement, captureIssueEvent } = useEventTracker(); // state type TIssueCrudState = { toggle: boolean; parentIssueId: string | undefined; issue: TIssue | undefined }; @@ -151,6 +155,15 @@ export const SubIssuesRoot: FC = observer((props) => { try { setSubIssueHelpers(parentIssueId, "issue_loader", issueId); await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal); + captureIssueEvent({ + eventName: "Sub-issue updated", + payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" }, + updates: { + changed_property: Object.keys(issueData).join(","), + change_details: Object.values(issueData).join(","), + }, + path: router.asPath, + }); setToastAlert({ type: "success", title: "Sub-issue updated successfully", @@ -158,6 +171,15 @@ export const SubIssuesRoot: FC = observer((props) => { }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { + captureIssueEvent({ + eventName: "Sub-issue updated", + payload: { ...oldIssue, ...issueData, state: "FAILED", element: "Issue detail page" }, + updates: { + changed_property: Object.keys(issueData).join(","), + change_details: Object.values(issueData).join(","), + }, + path: router.asPath, + }); setToastAlert({ type: "error", title: "Error updating sub-issue", @@ -174,8 +196,26 @@ export const SubIssuesRoot: FC = observer((props) => { title: "Sub-issue removed successfully", message: "Sub-issue removed successfully", }); + captureIssueEvent({ + eventName: "Sub-issue removed", + payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, + updates: { + changed_property: "parent_id", + change_details: parentIssueId, + }, + path: router.asPath, + }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { + captureIssueEvent({ + eventName: "Sub-issue removed", + payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, + updates: { + changed_property: "parent_id", + change_details: parentIssueId, + }, + path: router.asPath, + }); setToastAlert({ type: "error", title: "Error removing sub-issue", @@ -192,8 +232,18 @@ export const SubIssuesRoot: FC = observer((props) => { title: "Issue deleted successfully", message: "Issue deleted successfully", }); + captureIssueEvent({ + eventName: "Sub-issue deleted", + payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, + path: router.asPath, + }); setSubIssueHelpers(parentIssueId, "issue_loader", issueId); } catch (error) { + captureIssueEvent({ + eventName: "Sub-issue removed", + payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, + path: router.asPath, + }); setToastAlert({ type: "error", title: "Error deleting issue", @@ -257,14 +307,20 @@ export const SubIssuesRoot: FC = observer((props) => { {!disabled && (
    handleIssueCrudState("create", parentIssueId, null)} + className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80" + onClick={() => { + setTrackElement("Issue detail add sub-issue"); + handleIssueCrudState("create", parentIssueId, null); + }} > Add sub-issue
    handleIssueCrudState("existing", parentIssueId, null)} + className="cursor-pointer rounded border border-custom-border-100 p-1.5 px-2 shadow transition-all hover:bg-custom-background-80" + onClick={() => { + setTrackElement("Issue detail add sub-issue"); + handleIssueCrudState("existing", parentIssueId, null); + }} > Add an existing issue
    @@ -301,6 +357,7 @@ export const SubIssuesRoot: FC = observer((props) => { > { + setTrackElement("Issue detail add sub-issue"); handleIssueCrudState("create", parentIssueId, null); }} > @@ -308,6 +365,7 @@ export const SubIssuesRoot: FC = observer((props) => { { + setTrackElement("Issue detail add sub-issue"); handleIssueCrudState("existing", parentIssueId, null); }} > @@ -335,6 +393,7 @@ export const SubIssuesRoot: FC = observer((props) => { > { + setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("create", parentIssueId, null); }} > @@ -342,6 +401,7 @@ export const SubIssuesRoot: FC = observer((props) => { { + setTrackElement("Issue detail nested sub-issue"); handleIssueCrudState("existing", parentIssueId, null); }} > diff --git a/web/components/issues/view-select/due-date.tsx b/web/components/issues/view-select/due-date.tsx deleted file mode 100644 index a1209afa2..000000000 --- a/web/components/issues/view-select/due-date.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// ui -import { CustomDatePicker } from "components/ui"; -import { Tooltip } from "@plane/ui"; -import { CalendarCheck } from "lucide-react"; -// helpers -import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (date: string | null) => void; - handleOnOpen?: () => void; - handleOnClose?: () => void; - tooltipPosition?: "top" | "bottom"; - className?: string; - noBorder?: boolean; - disabled: boolean; -}; - -export const ViewDueDateSelect: React.FC = ({ - issue, - onChange, - handleOnOpen, - handleOnClose, - tooltipPosition = "top", - className = "", - noBorder = false, - disabled, -}) => { - const minDate = issue.start_date ? new Date(issue.start_date) : null; - minDate?.setDate(minDate.getDate()); - - return ( - -
    - - {issue.target_date ? ( - <> - - {renderFormattedDate(issue.target_date) ?? "_ _"} - - ) : ( - <> - - Due Date - - )} -
    - } - minDate={minDate ?? undefined} - noBorder={noBorder} - handleOnOpen={handleOnOpen} - handleOnClose={handleOnClose} - disabled={disabled} - /> -
    - - ); -}; diff --git a/web/components/issues/view-select/estimate.tsx b/web/components/issues/view-select/estimate.tsx deleted file mode 100644 index 1739f3aaa..000000000 --- a/web/components/issues/view-select/estimate.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -import { Triangle } from "lucide-react"; -import sortBy from "lodash/sortBy"; -// store hooks -import { useEstimate } from "hooks/store"; -// ui -import { CustomSelect, Tooltip } from "@plane/ui"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (data: number) => void; - tooltipPosition?: "top" | "bottom"; - customButton?: boolean; - disabled: boolean; -}; - -export const ViewEstimateSelect: React.FC = observer((props) => { - const { issue, onChange, tooltipPosition = "top", customButton = false, disabled } = props; - const { areEstimatesEnabledForCurrentProject, activeEstimateDetails, getEstimatePointValue } = useEstimate(); - - const estimateValue = getEstimatePointValue(issue.estimate_point, issue.project_id); - - const estimateLabels = ( - -
    - - {estimateValue ?? "None"} -
    -
    - ); - - if (!areEstimatesEnabledForCurrentProject) return null; - - return ( - - - <> - - - - None - - - {sortBy(activeEstimateDetails?.points, "key")?.map((estimate) => ( - - <> - - {estimate.value} - - - ))} - - ); -}); diff --git a/web/components/issues/view-select/index.ts b/web/components/issues/view-select/index.ts deleted file mode 100644 index 8eb88cb0d..000000000 --- a/web/components/issues/view-select/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./due-date"; -export * from "./estimate"; -export * from "./start-date"; diff --git a/web/components/issues/view-select/start-date.tsx b/web/components/issues/view-select/start-date.tsx deleted file mode 100644 index b42fe06c7..000000000 --- a/web/components/issues/view-select/start-date.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// ui -import { CustomDatePicker } from "components/ui"; -import { Tooltip } from "@plane/ui"; -import { CalendarClock } from "lucide-react"; -// helpers -import { renderFormattedDate } from "helpers/date-time.helper"; -// types -import { TIssue } from "@plane/types"; - -type Props = { - issue: TIssue; - onChange: (date: string | null) => void; - handleOnOpen?: () => void; - handleOnClose?: () => void; - tooltipPosition?: "top" | "bottom"; - className?: string; - noBorder?: boolean; - disabled: boolean; -}; - -export const ViewStartDateSelect: React.FC = ({ - issue, - onChange, - handleOnOpen, - handleOnClose, - tooltipPosition = "top", - className = "", - noBorder = false, - disabled, -}) => { - const maxDate = issue.target_date ? new Date(issue.target_date) : null; - maxDate?.setDate(maxDate.getDate()); - - return ( - -
    - - {issue?.start_date ? ( - <> - - {renderFormattedDate(issue?.start_date ?? "_ _")} - - ) : ( - <> - - Start Date - - )} -
    - } - handleOnClose={handleOnClose} - disabled={disabled} - /> -
    - - ); -}; diff --git a/web/components/modules/delete-module-modal.tsx b/web/components/modules/delete-module-modal.tsx index 70d59ff0c..a6682e73f 100644 --- a/web/components/modules/delete-module-modal.tsx +++ b/web/components/modules/delete-module-modal.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useModule } from "hooks/store"; +import { useEventTracker, useModule } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Button } from "@plane/ui"; @@ -26,9 +26,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, moduleId, peekModule } = router.query; // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureModuleEvent } = useEventTracker(); const { deleteModule } = useModule(); // toast alert const { setToastAlert } = useToast(); @@ -52,8 +50,9 @@ export const DeleteModuleModal: React.FC = observer((props) => { title: "Success!", message: "Module deleted successfully.", }); - postHogEventTracker("MODULE_DELETED", { - state: "SUCCESS", + captureModuleEvent({ + eventName: "Module deleted", + payload: { ...data, state: "SUCCESS" }, }); }) .catch(() => { @@ -62,8 +61,9 @@ export const DeleteModuleModal: React.FC = observer((props) => { title: "Error!", message: "Module could not be deleted. Please try again.", }); - postHogEventTracker("MODULE_DELETED", { - state: "FAILED", + captureModuleEvent({ + eventName: "Module deleted", + payload: { ...data, state: "FAILED" }, }); }) .finally(() => { diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index 4b9f35665..24fa379c9 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -70,7 +70,7 @@ export const ModuleForm: React.FC = ({ const startDate = watch("start_date"); const targetDate = watch("target_date"); - const minDate = startDate ? new Date(startDate) : new Date(); + const minDate = startDate ? new Date(startDate) : null; minDate?.setDate(minDate.getDate()); const maxDate = targetDate ? new Date(targetDate) : null; @@ -159,7 +159,6 @@ export const ModuleForm: React.FC = ({ onChange={(date) => onChange(date ? renderFormattedPayloadDate(date) : null)} buttonVariant="border-with-text" placeholder="Start date" - minDate={new Date()} maxDate={maxDate ?? undefined} tabIndex={3} /> diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index d1cbd0dfa..53948f71d 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -13,37 +13,32 @@ export const ModulesListGanttChartView: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; // store - const { projectModuleIds, moduleMap } = useModule(); const { currentProjectDetails } = useProject(); + const { projectModuleIds, moduleMap, updateModuleDetails } = useModule(); - const handleModuleUpdate = (module: IModule, payload: IBlockUpdateData) => { - if (!workspaceSlug) return; - // FIXME - //updateModuleGanttStructure(workspaceSlug.toString(), module.project, module, payload); + const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => { + if (!workspaceSlug || !module) return; + + const payload: any = { ...data }; + if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; + + await updateModuleDetails(workspaceSlug.toString(), module.project, module.id, payload); }; const blockFormat = (blocks: string[]) => - blocks && blocks.length > 0 - ? blocks - .filter((blockId) => { - const block = moduleMap[blockId]; - return block.start_date && block.target_date && new Date(block.start_date) <= new Date(block.target_date); - }) - .map((blockId) => { - const block = moduleMap[blockId]; - return { - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: new Date(block.start_date ?? ""), - target_date: new Date(block.target_date ?? ""), - }; - }) - : []; + blocks?.map((blockId) => { + const block = moduleMap[blockId]; + return { + 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, + }; + }); const isAllowed = currentProjectDetails?.member_role === 20 || currentProjectDetails?.member_role === 15; - const modules = projectModuleIds; return (
    { enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={isAllowed} + showAllBlocks />
    ); diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index f2eba0653..4c5fde3ab 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useForm } from "react-hook-form"; import { Dialog, Transition } from "@headlessui/react"; // hooks -import { useApplication, useModule, useProject } from "hooks/store"; +import { useEventTracker, useModule, useProject } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ModuleForm } from "components/modules"; @@ -31,9 +31,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { // states const [activeProject, setActiveProject] = useState(null); // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureModuleEvent } = useEventTracker(); const { workspaceProjectIds } = useProject(); const { createModule, updateModuleDetails } = useModule(); // toast alert @@ -55,15 +53,14 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { await createModule(workspaceSlug.toString(), selectedProjectId, payload) .then((res) => { handleClose(); - setToastAlert({ type: "success", title: "Success!", message: "Module created successfully.", }); - postHogEventTracker("MODULE_CREATED", { - ...res, - state: "SUCCESS", + captureModuleEvent({ + eventName: "Module created", + payload: { ...res, state: "SUCCESS" }, }); }) .catch((err) => { @@ -72,8 +69,9 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { title: "Error!", message: err.detail ?? "Module could not be created. Please try again.", }); - postHogEventTracker("MODULE_CREATED", { - state: "FAILED", + captureModuleEvent({ + eventName: "Module created", + payload: { ...data, state: "FAILED" }, }); }); }; @@ -91,9 +89,9 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { title: "Success!", message: "Module updated successfully.", }); - postHogEventTracker("MODULE_UPDATED", { - ...res, - state: "SUCCESS", + captureModuleEvent({ + eventName: "Module updated", + payload: { ...res, state: "SUCCESS" }, }); }) .catch((err) => { @@ -102,8 +100,9 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { title: "Error!", message: err.detail ?? "Module could not be updated. Please try again.", }); - postHogEventTracker("MODULE_UPDATED", { - state: "FAILED", + captureModuleEvent({ + eventName: "Module updated", + payload: { ...data, state: "FAILED" }, }); }); }; diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 205b36b68..76ce2ec60 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // hooks -import { useModule, useUser } from "hooks/store"; +import { useEventTracker, useModule, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; @@ -36,6 +36,7 @@ export const ModuleCardItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); + const { setTrackElement } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -83,12 +84,14 @@ export const ModuleCardItem: React.FC = observer((props) => { const handleEditModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("Modules page board layout"); setEditModal(true); }; const handleDeleteModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("Modules page board layout"); setDeleteModal(true); }; diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 46db18796..e5402b806 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; // hooks -import { useModule, useUser } from "hooks/store"; +import { useModule, useUser, useEventTracker } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { CreateUpdateModuleModal, DeleteModuleModal } from "components/modules"; @@ -36,6 +36,7 @@ export const ModuleListItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); + const { setTrackElement } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -83,12 +84,14 @@ export const ModuleListItem: React.FC = observer((props) => { const handleEditModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("Modules page list layout"); setEditModal(true); }; const handleDeleteModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("Modules page list layout"); setDeleteModal(true); }; diff --git a/web/components/modules/module-mobile-header.tsx b/web/components/modules/module-mobile-header.tsx new file mode 100644 index 000000000..e9ed56a8d --- /dev/null +++ b/web/components/modules/module-mobile-header.tsx @@ -0,0 +1,162 @@ +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue"; +import { useIssues, useLabel, useMember, useModule, useProjectState } from "hooks/store"; +import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; +import router from "next/router"; +import { useCallback, useState } from "react"; + +export const ModuleMobileHeader = () => { + const [analyticsModal, setAnalyticsModal] = useState(false); + const { getModuleById } = useModule(); + const layouts = [ + { key: "list", title: "List", icon: List }, + { key: "kanban", title: "Kanban", icon: Kanban }, + { key: "calendar", title: "Calendar", icon: Calendar }, + ]; + const { workspaceSlug, projectId, moduleId } = router.query as { + workspaceSlug: string; + projectId: string; + moduleId: string; + }; + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; + + const { + issuesFilter: { issueFilters, updateFilters }, + } = useIssues(EIssuesStoreType.MODULE); + const activeLayout = issueFilters?.displayFilters?.layout; + const { projectStates } = useProjectState(); + const { projectLabels } = useLabel(); + const { + project: { projectMemberIds }, + } = useMember(); + + const handleLayoutChange = useCallback( + (layout: TIssueLayouts) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + 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); + } + + updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); + }, + [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] + ); + + const handleDisplayFilters = useCallback( + (updatedDisplayFilter: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + const handleDisplayProperties = useCallback( + (property: Partial) => { + if (!workspaceSlug || !projectId) return; + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + }, + [workspaceSlug, projectId, moduleId, updateFilters] + ); + + return ( +
    + setAnalyticsModal(false)} + moduleDetails={moduleDetails ?? 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 + + + } + > + + +
    + + +
    +
    + ); +}; diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 0708187c1..b10908060 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,7 +1,8 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useTheme } from "next-themes"; // hooks -import { useApplication, useModule, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useModule, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // components import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; @@ -15,8 +16,11 @@ export const ModulesListView: React.FC = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId, peekModule } = router.query; + // theme + const { resolvedTheme } = useTheme(); // store hooks const { commandPalette: commandPaletteStore } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { currentProjectRole }, currentUser, @@ -25,7 +29,8 @@ export const ModulesListView: React.FC = observer(() => { const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "modules", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "modules", isLightMode); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -102,7 +107,10 @@ export const ModulesListView: React.FC = observer(() => { }} primaryButton={{ text: "Build your first module", - onClick: () => commandPaletteStore.toggleCreateModuleModal(true), + onClick: () => { + setTrackElement("Module empty state"); + commandPaletteStore.toggleCreateModuleModal(true); + }, }} size="lg" disabled={!isEditingAllowed} diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index e74d2ea29..ba3a13a31 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -1,20 +1,8 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { Disclosure, Popover, Transition } from "@headlessui/react"; -// hooks -import { useModule, useUser } from "hooks/store"; -// hooks -import useToast from "hooks/use-toast"; -// components -import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; -import { DeleteModuleModal } from "components/modules"; -import ProgressChart from "components/core/sidebar/progress-chart"; -// ui -import { CustomRangeDatePicker } from "components/ui"; -import { CustomMenu, Loader, LayersIcon, CustomSelect, ModuleStatusIcon, UserGroupIcon } from "@plane/ui"; -// icon import { AlertCircle, CalendarCheck2, @@ -27,6 +15,17 @@ import { Trash2, UserCircle2, } from "lucide-react"; +// hooks +import { useModule, useUser, useEventTracker } from "hooks/store"; +import useToast from "hooks/use-toast"; +// components +import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; +import { DeleteModuleModal } from "components/modules"; +import ProgressChart from "components/core/sidebar/progress-chart"; +import { ProjectMemberDropdown } from "components/dropdowns"; +// ui +import { CustomRangeDatePicker } from "components/ui"; +import { CustomMenu, Loader, LayersIcon, CustomSelect, ModuleStatusIcon, UserGroupIcon } from "@plane/ui"; // helpers import { isDateGreaterThanToday, renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; @@ -35,7 +34,6 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // constant import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; -import { ProjectMemberDropdown } from "components/dropdowns"; const defaultValues: Partial = { lead: "", @@ -57,6 +55,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); + // refs + const startDateButtonRef = useRef(null); + const endDateButtonRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId, peekModule } = router.query; @@ -65,7 +66,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); - + const { setTrackElement } = useEventTracker(); const moduleDetails = getModuleById(moduleId); const { setToastAlert } = useToast(); @@ -164,16 +165,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const handleStartDateChange = async (date: string) => { setValue("start_date", date); - if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("target_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create module in past date. Please enter a valid date.", - }); - return; - } + if (!watch("target_date") || watch("target_date") === "") endDateButtonRef.current?.click(); + if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { submitChanges({ start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), @@ -189,16 +183,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const handleEndDateChange = async (date: string) => { setValue("target_date", date); - if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("target_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create module in past date. Please enter a valid date.", - }); - return; - } + if (!watch("start_date") || watch("start_date") === "") endDateButtonRef.current?.click(); + if (watch("start_date") && watch("target_date") && watch("start_date") !== "" && watch("start_date") !== "") { submitChanges({ start_date: renderFormattedPayloadDate(`${watch("start_date")}`), target_date: renderFormattedPayloadDate(`${watch("target_date")}`), @@ -251,13 +238,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); const issueCount = - moduleDetails.total_issues === 0 - ? "0 Issue" - : moduleDetails.total_issues === moduleDetails.completed_issues - ? moduleDetails.total_issues > 1 - ? `${moduleDetails.total_issues}` - : `${moduleDetails.total_issues}` - : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; + moduleDetails.total_issues === 0 ? "0 Issue" : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -292,7 +273,12 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { {isEditingAllowed && ( - setModuleDeleteModal(true)}> + { + setTrackElement("Module peek-overview"); + setModuleDeleteModal(true); + }} + > Delete module @@ -355,49 +341,55 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
    - Start Date + Start date
    - - - {renderFormattedDate(startDate) ?? "No date selected"} - - + {({ close }) => ( + <> + + + {renderFormattedDate(startDate) ?? "No date selected"} + + - - - { - if (val) { - handleStartDateChange(val); - } - }} - startDate={watch("start_date") ?? watch("target_date") ?? null} - endDate={watch("target_date") ?? watch("start_date") ?? null} - maxDate={new Date(`${watch("target_date")}`)} - selectsStart={watch("target_date") ? true : false} - /> - - + + + { + if (val) { + handleStartDateChange(val); + close(); + } + }} + startDate={watch("start_date") ?? watch("target_date") ?? null} + endDate={watch("target_date") ?? watch("start_date") ?? null} + maxDate={new Date(`${watch("target_date")}`)} + selectsStart={watch("target_date") ? true : false} + /> + + + + )}
    @@ -405,51 +397,55 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
    - Target Date + Target date
    - <> - - ( + <> + - {renderFormattedDate(endDate) ?? "No date selected"} - - + + {renderFormattedDate(endDate) ?? "No date selected"} + + - - - { - if (val) { - handleEndDateChange(val); - } - }} - startDate={watch("start_date") ?? watch("target_date") ?? null} - endDate={watch("target_date") ?? watch("start_date") ?? null} - minDate={new Date(`${watch("start_date")}`)} - selectsEnd={watch("start_date") ? true : false} - /> - - - + + + { + if (val) { + handleEndDateChange(val); + close(); + } + }} + startDate={watch("start_date") ?? watch("target_date") ?? null} + endDate={watch("target_date") ?? watch("start_date") ?? null} + minDate={new Date(`${watch("start_date")}`)} + selectsEnd={watch("start_date") ? true : false} + /> + + + + )}
    @@ -561,7 +557,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
    - {isStartValid && isEndValid ? ( + {moduleDetails.start_date && moduleDetails.target_date ? (
    @@ -577,9 +573,9 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => {
    diff --git a/web/components/notifications/notification-header.tsx b/web/components/notifications/notification-header.tsx index 4355452f1..0fdeb8432 100644 --- a/web/components/notifications/notification-header.tsx +++ b/web/components/notifications/notification-header.tsx @@ -95,6 +95,7 @@ export const NotificationHeader: React.FC = (props) =>
    } + closeOnSelect >
    diff --git a/web/components/onboarding/invitations.tsx b/web/components/onboarding/invitations.tsx index 0c29aa4b3..b9d844f88 100644 --- a/web/components/onboarding/invitations.tsx +++ b/web/components/onboarding/invitations.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import useSWR, { mutate } from "swr"; // hooks -import { useApplication, useUser, useWorkspace } from "hooks/store"; +import { useEventTracker, useUser, useWorkspace } from "hooks/store"; // components import { Button } from "@plane/ui"; // helpers @@ -15,6 +15,7 @@ import { ROLE } from "constants/workspace"; import { IWorkspaceMemberInvitation } from "@plane/types"; // icons import { CheckCircle2, Search } from "lucide-react"; +import {} from "hooks/store/use-event-tracker"; type Props = { handleNextStep: () => void; @@ -28,9 +29,7 @@ export const Invitations: React.FC = (props) => { const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); const [invitationsRespond, setInvitationsRespond] = useState([]); // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureEvent } = useEventTracker(); const { currentUser, updateCurrentUser } = useUser(); const { workspaces, fetchWorkspaces } = useWorkspace(); @@ -63,7 +62,7 @@ export const Invitations: React.FC = (props) => { await workspaceService .joinWorkspaces({ invitations: invitationsRespond }) .then(async (res) => { - postHogEventTracker("MEMBER_ACCEPTED", { ...res, state: "SUCCESS", accepted_from: "App" }); + captureEvent("Member accepted", { ...res, state: "SUCCESS", accepted_from: "App" }); await fetchWorkspaces(); await mutate(USER_WORKSPACES); await updateLastWorkspace(); @@ -72,7 +71,7 @@ export const Invitations: React.FC = (props) => { }) .catch((error) => { console.error(error); - postHogEventTracker("MEMBER_ACCEPTED", { state: "FAILED", accepted_from: "App" }); + captureEvent("Member accepted", { state: "FAILED", accepted_from: "App" }); }) .finally(() => setIsJoiningWorkspaces(false)); }; diff --git a/web/components/onboarding/tour/root.tsx b/web/components/onboarding/tour/root.tsx index e4261d435..415e6607d 100644 --- a/web/components/onboarding/tour/root.tsx +++ b/web/components/onboarding/tour/root.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; // hooks -import { useApplication, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useUser } from "hooks/store"; // components import { TourSidebar } from "components/onboarding"; // ui @@ -78,10 +78,8 @@ export const TourRoot: React.FC = observer((props) => { // states const [step, setStep] = useState("welcome"); // store hooks - const { - commandPalette: commandPaletteStore, - eventTracker: { setTrackElement }, - } = useApplication(); + const { commandPalette: commandPaletteStore } = useApplication(); + const { setTrackElement } = useEventTracker(); const { currentUser } = useUser(); const currentStepIndex = TOUR_STEPS.findIndex((tourStep) => tourStep.key === step); @@ -159,7 +157,7 @@ export const TourRoot: React.FC = observer((props) => { variant="primary" onClick={() => { onComplete(); - setTrackElement("ONBOARDING_TOUR"); + setTrackElement("Onboarding tour"); commandPaletteStore.toggleCreateProjectModal(true); }} > diff --git a/web/components/page-views/workspace-dashboard.tsx b/web/components/page-views/workspace-dashboard.tsx index aa0ba8e41..93756d22a 100644 --- a/web/components/page-views/workspace-dashboard.tsx +++ b/web/components/page-views/workspace-dashboard.tsx @@ -1,7 +1,8 @@ import { useEffect } from "react"; +import { useTheme } from "next-themes"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useDashboard, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useDashboard, useProject, useUser } from "hooks/store"; // components import { TourRoot } from "components/onboarding"; import { UserGreetingsView } from "components/user"; @@ -14,10 +15,12 @@ import { Spinner } from "@plane/ui"; import { EUserWorkspaceRoles } from "constants/workspace"; export const WorkspaceDashboardView = observer(() => { + // theme + const { resolvedTheme } = useTheme(); // store hooks + const { captureEvent, setTrackElement } = useEventTracker(); const { commandPalette: { toggleCreateProjectModal }, - eventTracker: { postHogEventTracker }, router: { workspaceSlug }, } = useApplication(); const { @@ -28,12 +31,13 @@ export const WorkspaceDashboardView = observer(() => { const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard(); const { joinedProjectIds } = useProject(); - const emptyStateImage = getEmptyStateImagePath("onboarding", "dashboard", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("onboarding", "dashboard", isLightMode); const handleTourCompleted = () => { updateTourCompleted() .then(() => { - postHogEventTracker("USER_TOUR_COMPLETE", { + captureEvent("User tour complete", { user_id: currentUser?.id, email: currentUser?.email, state: "SUCCESS", @@ -58,16 +62,18 @@ export const WorkspaceDashboardView = observer(() => { {homeDashboardId && joinedProjectIds ? ( <> {joinedProjectIds.length > 0 ? ( -
    + <> - {currentUser && } - {currentUser && !currentUser.is_tour_completed && ( -
    - -
    - )} - -
    +
    + {currentUser && } + {currentUser && !currentUser.is_tour_completed && ( +
    + +
    + )} + +
    + ) : ( { progress." primaryButton={{ text: "Build your first project", - onClick: () => toggleCreateProjectModal(true), + onClick: () => { + setTrackElement("Dashboard"); + toggleCreateProjectModal(true); + }, }} comicBox={{ title: "Everything starts with a project in Plane", @@ -89,7 +98,7 @@ export const WorkspaceDashboardView = observer(() => { )} ) : ( -
    +
    )} diff --git a/web/components/pages/create-update-page-modal.tsx b/web/components/pages/create-update-page-modal.tsx index 1b3780422..5f82c95a1 100644 --- a/web/components/pages/create-update-page-modal.tsx +++ b/web/components/pages/create-update-page-modal.tsx @@ -1,8 +1,6 @@ import React, { FC } from "react"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; -// hooks -import { useApplication } from "hooks/store"; // components import { PageForm } from "./page-form"; // types @@ -25,10 +23,6 @@ export const CreateUpdatePageModal: FC = (props) => { const { workspaceSlug } = router.query; const { createPage } = useProjectPages(); - // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); const createProjectPage = async (payload: IPage) => { if (!workspaceSlug) return; diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index 743b48fd4..59305bc51 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -229,7 +229,7 @@ export const PagesListItem: FC = observer(({ pageId, projectId } )} diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 732fdb78f..6b6459731 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -1,5 +1,6 @@ import { FC } from "react"; import { useRouter } from "next/router"; +import { useTheme } from "next-themes"; // hooks import { useApplication, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; @@ -18,7 +19,9 @@ type IPagesListView = { export const PagesListView: FC = (props) => { const { pageIds: projectPageIds } = props; - + // theme + const { resolvedTheme } = useTheme(); + // store hooks const { commandPalette: { toggleCreatePageModal }, } = useApplication(); @@ -36,11 +39,8 @@ export const PagesListView: FC = (props) => { ? PAGE_EMPTY_STATE_DETAILS[pageTab as keyof typeof PAGE_EMPTY_STATE_DETAILS] : PAGE_EMPTY_STATE_DETAILS["All"]; - const emptyStateImage = getEmptyStateImagePath( - "pages", - currentPageTabDetails.key, - currentUser?.theme.theme === "light" - ); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("pages", currentPageTabDetails.key, isLightMode); const isButtonVisible = currentPageTabDetails.key !== "archived" && currentPageTabDetails.key !== "favorites"; diff --git a/web/components/pages/pages-list/recent-pages-list.tsx b/web/components/pages/pages-list/recent-pages-list.tsx index 05fe26e4e..b1327c407 100644 --- a/web/components/pages/pages-list/recent-pages-list.tsx +++ b/web/components/pages/pages-list/recent-pages-list.tsx @@ -1,5 +1,6 @@ import React, { FC } from "react"; import { observer } from "mobx-react-lite"; +import { useTheme } from "next-themes"; // hooks import { useApplication, useUser } from "hooks/store"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; @@ -14,6 +15,8 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { EUserProjectRoles } from "constants/project"; export const RecentPagesList: FC = observer(() => { + // theme + const { resolvedTheme } = useTheme(); // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { @@ -22,7 +25,8 @@ export const RecentPagesList: FC = observer(() => { } = useUser(); const { recentProjectPages } = useProjectPages(); - const EmptyStateImagePath = getEmptyStateImagePath("pages", "recent", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("pages", "recent", isLightMode); // FIXME: replace any with proper type const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value: any) => value.length === 0); diff --git a/web/components/profile/index.ts b/web/components/profile/index.ts index ffd05c5a5..f6d2a3775 100644 --- a/web/components/profile/index.ts +++ b/web/components/profile/index.ts @@ -1,5 +1,5 @@ export * from "./overview"; export * from "./navbar"; -export * from "./sidebar"; - export * from "./profile-issues-filter"; +export * from "./sidebar"; +export * from "./time"; diff --git a/web/components/profile/profile-issues.tsx b/web/components/profile/profile-issues.tsx index 4b3721103..81c9b141c 100644 --- a/web/components/profile/profile-issues.tsx +++ b/web/components/profile/profile-issues.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import { useTheme } from "next-themes"; // components import { ProfileIssuesListLayout } from "components/issues/issue-layouts/list/roots/profile-issues-root"; import { ProfileIssuesKanBanLayout } from "components/issues/issue-layouts/kanban/roots/profile-issues-root"; @@ -27,6 +28,9 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { workspaceSlug: string; userId: string; }; + // theme + const { resolvedTheme } = useTheme(); + // store hooks const { membership: { currentWorkspaceRole }, currentUser, @@ -46,7 +50,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { } ); - const emptyStateImage = getEmptyStateImagePath("profile", type, currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("profile", type, isLightMode); const activeLayout = issueFilters?.displayFilters?.layout || undefined; diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index c0bbd7dd7..a1a0671cd 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -7,6 +7,8 @@ import { observer } from "mobx-react-lite"; import { useUser } from "hooks/store"; // services import { UserService } from "services/user.service"; +// components +import { ProfileSidebarTime } from "./time"; // ui import { Loader, Tooltip } from "@plane/ui"; // icons @@ -34,16 +36,6 @@ export const ProfileSidebar = observer(() => { : null ); - // Create a date object for the current time in the specified timezone - const currentTime = new Date(); - const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: userProjectsData?.user_data.user_timezone, - hour12: false, // Use 24-hour format - hour: "2-digit", - minute: "2-digit", - }); - const timeString = formatter.format(currentTime); - const userDetails = [ { label: "Joined on", @@ -51,11 +43,7 @@ export const ProfileSidebar = observer(() => { }, { label: "Timezone", - value: ( - - {timeString} {userProjectsData?.user_data.user_timezone} - - ), + value: , }, ]; diff --git a/web/components/profile/time.tsx b/web/components/profile/time.tsx new file mode 100644 index 000000000..a1b740410 --- /dev/null +++ b/web/components/profile/time.tsx @@ -0,0 +1,27 @@ +// hooks +import { useCurrentTime } from "hooks/use-current-time"; + +type Props = { + timeZone: string | undefined; +}; + +export const ProfileSidebarTime: React.FC = (props) => { + const { timeZone } = props; + // current time hook + const { currentTime } = useCurrentTime(); + + // Create a date object for the current time in the specified timezone + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timeZone, + hour12: false, // Use 24-hour format + hour: "2-digit", + minute: "2-digit", + }); + const timeString = formatter.format(currentTime); + + return ( + + {timeString} {timeZone} + + ); +}; diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index ebb166f49..e7601ce35 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -1,28 +1,28 @@ import { observer } from "mobx-react-lite"; +import { useTheme } from "next-themes"; // hooks -import { useApplication, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // components import { ProjectCard } from "components/project"; import { Loader } from "@plane/ui"; import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; -// icons -import { Plus } from "lucide-react"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; export const ProjectCardList = observer(() => { + // theme + const { resolvedTheme } = useTheme(); // store hooks - const { - commandPalette: commandPaletteStore, - eventTracker: { setTrackElement }, - } = useApplication(); + const { commandPalette: commandPaletteStore } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, currentUser, } = useUser(); const { workspaceProjectIds, searchedProjects, getProjectById } = useProject(); - const emptyStateImage = getEmptyStateImagePath("onboarding", "projects", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const emptyStateImage = getEmptyStateImagePath("onboarding", "projects", isLightMode); const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; @@ -64,7 +64,7 @@ export const ProjectCardList = observer(() => { primaryButton={{ text: "Start your first project", onClick: () => { - setTrackElement("PROJECTS_EMPTY_STATE"); + setTrackElement("Project empty state"); commandPaletteStore.toggleCreateProjectModal(true); }, }} diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 864806676..98ccfaf23 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -4,7 +4,7 @@ import { Dialog, Transition } from "@headlessui/react"; import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; // hooks -import { useApplication, useProject, useUser, useWorkspace } from "hooks/store"; +import { useEventTracker, useProject, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Button, CustomSelect, Input, TextArea } from "@plane/ui"; @@ -61,9 +61,7 @@ export interface ICreateProjectForm { export const CreateProjectModal: FC = observer((props) => { const { isOpen, onClose, setToFavorite = false, workspaceSlug } = props; // store - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureProjectEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -135,10 +133,14 @@ export const CreateProjectModal: FC = observer((props) => { ...res, state: "SUCCESS", }; - postHogEventTracker("PROJECT_CREATED", newPayload, { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: res.workspace, + captureProjectEvent({ + eventName: "Project created", + payload: newPayload, + group: { + isGrouping: true, + groupType: "Workspace_metrics", + groupId: res.workspace, + }, }); setToastAlert({ type: "success", @@ -157,17 +159,18 @@ export const CreateProjectModal: FC = observer((props) => { title: "Error!", message: err.data[key], }); - postHogEventTracker( - "PROJECT_CREATED", - { + captureProjectEvent({ + eventName: "Project created", + payload: { + ...payload, state: "FAILED", }, - { + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); }); }); }; @@ -205,7 +208,7 @@ export const CreateProjectModal: FC = observer((props) => {
    -
    +
    = (props) => { const { isOpen, project, onClose } = props; // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureProjectEvent } = useEventTracker(); const { currentWorkspace } = useWorkspace(); const { deleteProject } = useProject(); // router @@ -63,17 +61,15 @@ export const DeleteProjectModal: React.FC = (props) => { if (projectId && projectId.toString() === project.id) router.push(`/${workspaceSlug}/projects`); handleClose(); - postHogEventTracker( - "PROJECT_DELETED", - { - state: "SUCCESS", - }, - { + captureProjectEvent({ + eventName: "Project deleted", + payload: { ...project, state: "SUCCESS", element: "Project general settings" }, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); setToastAlert({ type: "success", title: "Success!", @@ -81,17 +77,15 @@ export const DeleteProjectModal: React.FC = (props) => { }); }) .catch(() => { - postHogEventTracker( - "PROJECT_DELETED", - { - state: "FAILED", - }, - { + captureProjectEvent({ + eventName: "Project deleted", + payload: { ...project, state: "FAILED", element: "Project general settings" }, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: currentWorkspace?.id!, - } - ); + }, + }); setToastAlert({ type: "error", title: "Error!", diff --git a/web/components/project/form.tsx b/web/components/project/form.tsx index cb019c9d0..99e30c92b 100644 --- a/web/components/project/form.tsx +++ b/web/components/project/form.tsx @@ -1,7 +1,7 @@ -import { FC, useEffect } from "react"; +import { FC, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; // hooks -import { useApplication, useProject, useWorkspace } from "hooks/store"; +import { useEventTracker, useProject, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // components import EmojiIconPicker from "components/emoji-icon-picker"; @@ -29,10 +29,10 @@ const projectService = new ProjectService(); export const ProjectDetailsForm: FC = (props) => { const { project, workspaceSlug, isAdmin } = props; + // states + const [isLoading, setIsLoading] = useState(false); // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureProjectEvent } = useEventTracker(); const { currentWorkspace } = useWorkspace(); const { updateProject } = useProject(); // toast alert @@ -45,7 +45,7 @@ export const ProjectDetailsForm: FC = (props) => { setValue, setError, reset, - formState: { errors, isSubmitting }, + formState: { errors }, } = useForm({ defaultValues: { ...project, @@ -77,15 +77,15 @@ export const ProjectDetailsForm: FC = (props) => { return updateProject(workspaceSlug.toString(), project.id, payload) .then((res) => { - postHogEventTracker( - "PROJECT_UPDATED", - { ...res, state: "SUCCESS" }, - { + captureProjectEvent({ + eventName: "Project updated", + payload: { ...res, state: "SUCCESS", element: "Project general settings" }, + group: { isGrouping: true, groupType: "Workspace_metrics", groupId: res.workspace, - } - ); + }, + }); setToastAlert({ type: "success", title: "Success!", @@ -93,17 +93,15 @@ export const ProjectDetailsForm: FC = (props) => { }); }) .catch((error) => { - postHogEventTracker( - "PROJECT_UPDATED", - { - state: "FAILED", - }, - { + captureProjectEvent({ + eventName: "Project updated", + payload: { ...payload, state: "FAILED", element: "Project general settings" }, + group: { isGrouping: true, groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); + groupId: currentWorkspace?.id, + }, + }); setToastAlert({ type: "error", title: "Error!", @@ -114,6 +112,7 @@ export const ProjectDetailsForm: FC = (props) => { const onSubmit = async (formData: IProject) => { if (!workspaceSlug) return; + setIsLoading(true); const payload: Partial = { name: formData.name, @@ -139,6 +138,10 @@ export const ProjectDetailsForm: FC = (props) => { else await handleUpdateChange(payload); }); else await handleUpdateChange(payload); + + setTimeout(() => { + setIsLoading(false); + }, 300); }; const currentNetwork = NETWORK_CHOICES.find((n) => n.key === project?.network); @@ -147,10 +150,10 @@ export const ProjectDetailsForm: FC = (props) => { return (
    -
    +
    {watch("cover_image")!} -
    +
    @@ -308,8 +311,8 @@ export const ProjectDetailsForm: FC = (props) => {
    <> - Created on {renderFormattedDate(project?.created_at)} diff --git a/web/components/project/leave-project-modal.tsx b/web/components/project/leave-project-modal.tsx index ae9c8bb30..7302a31d9 100644 --- a/web/components/project/leave-project-modal.tsx +++ b/web/components/project/leave-project-modal.tsx @@ -5,7 +5,7 @@ import { Dialog, Transition } from "@headlessui/react"; import { AlertTriangleIcon } from "lucide-react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useUser } from "hooks/store"; +import { useEventTracker, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Button, Input } from "@plane/ui"; @@ -34,9 +34,7 @@ export const LeaveProjectModal: FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureEvent } = useEventTracker(); const { membership: { leaveProject }, } = useUser(); @@ -65,7 +63,7 @@ export const LeaveProjectModal: FC = observer((props) => { .then(() => { handleClose(); router.push(`/${workspaceSlug}/projects`); - postHogEventTracker("PROJECT_MEMBER_LEAVE", { + captureEvent("Project member leave", { state: "SUCCESS", }); }) @@ -75,7 +73,7 @@ export const LeaveProjectModal: FC = observer((props) => { title: "Error!", message: "Something went wrong please try again later.", }); - postHogEventTracker("PROJECT_MEMBER_LEAVE", { + captureEvent("Project member leave", { state: "FAILED", }); }); diff --git a/web/components/project/member-list.tsx b/web/components/project/member-list.tsx index eab4f36b2..8245cd5c1 100644 --- a/web/components/project/member-list.tsx +++ b/web/components/project/member-list.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search } from "lucide-react"; // hooks -import { useApplication, useMember } from "hooks/store"; +import { useEventTracker, useMember } from "hooks/store"; // components import { ProjectMemberListItem, SendProjectInvitationModal } from "components/project"; // ui @@ -13,9 +13,7 @@ export const ProjectMemberList: React.FC = observer(() => { const [inviteModal, setInviteModal] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // store hooks - const { - eventTracker: { setTrackElement }, - } = useApplication(); + const { setTrackElement } = useEventTracker(); const { project: { projectMemberIds, getProjectMemberDetails }, } = useMember(); diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 23244fedb..1b950135f 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -5,7 +5,7 @@ import { useForm, Controller, useFieldArray } from "react-hook-form"; import { Dialog, Transition } from "@headlessui/react"; import { ChevronDown, Plus, X } from "lucide-react"; // hooks -import { useApplication, useMember, useUser, useWorkspace } from "hooks/store"; +import { useEventTracker, useMember, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Avatar, Button, CustomSelect, CustomSearchSelect } from "@plane/ui"; @@ -45,9 +45,7 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { // toast alert const { setToastAlert } = useToast(); // store hooks - const { - eventTracker: { postHogEventTracker }, - } = useApplication(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -89,8 +87,8 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { type: "success", message: "Members added successfully.", }); - postHogEventTracker( - "MEMBER_ADDED", + captureEvent( + "Member added", { ...res, state: "SUCCESS", @@ -104,8 +102,8 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { }) .catch((error) => { console.error(error); - postHogEventTracker( - "MEMBER_ADDED", + captureEvent( + "Member added", { state: "FAILED", }, diff --git a/web/components/project/settings/features-list.tsx b/web/components/project/settings/features-list.tsx index 964567e39..256e74c6b 100644 --- a/web/components/project/settings/features-list.tsx +++ b/web/components/project/settings/features-list.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite"; import { ContrastIcon, FileText, Inbox, Layers } from "lucide-react"; import { DiceIcon, ToggleSwitch } from "@plane/ui"; // hooks -import { useApplication, useProject, useUser, useWorkspace } from "hooks/store"; +import { useEventTracker, useProject, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // types import { IProject } from "@plane/types"; @@ -51,9 +51,7 @@ export const ProjectFeaturesList: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { - eventTracker: { setTrackElement, postHogEventTracker }, - } = useApplication(); + const { setTrackElement, captureEvent } = useEventTracker(); const { currentUser, membership: { currentProjectRole }, @@ -96,7 +94,7 @@ export const ProjectFeaturesList: FC = observer(() => { value={Boolean(currentProjectDetails?.[feature.property as keyof IProject])} onChange={() => { setTrackElement("PROJECT_SETTINGS_FEATURES_PAGE"); - postHogEventTracker(`TOGGLE_${feature.title.toUpperCase()}`, { + captureEvent(`Toggle ${feature.title.toLowerCase()}`, { workspace_id: currentWorkspace?.id, workspace_slug: currentWorkspace?.slug, project_id: currentProjectDetails?.id, diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index edb8b66a7..91f2264d2 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -18,7 +18,7 @@ import { MoreHorizontal, } from "lucide-react"; // hooks -import { useApplication, useProject } from "hooks/store"; +import { useApplication,useEventTracker, useProject } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useToast from "hooks/use-toast"; // helpers @@ -73,10 +73,8 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { projectId, provided, snapshot, handleCopyText, shortContextMenu = false } = props; // store hooks - const { - theme: themeStore, - eventTracker: { setTrackElement }, - } = useApplication(); + const { theme: themeStore } = useApplication(); + const { setTrackElement } = useEventTracker(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); @@ -131,6 +129,12 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { setLeaveProjectModal(false); }; + const handleProjectClick = () => { + if (window.innerWidth < 768) { + themeStore.toggleSidebar(); + } + }; + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); if (!project) return null; @@ -313,7 +317,7 @@ export const ProjectSidebarListItem: React.FC = observer((props) => { return; return ( - + { const { theme: { sidebarCollapsed }, commandPalette: { toggleCreateProjectModal }, - eventTracker: { setTrackElement }, } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -206,7 +206,7 @@ export const ProjectSidebarList: FC = observer(() => { type="button" className="group flex w-full items-center gap-1 whitespace-nowrap rounded px-1.5 text-left text-sm font-semibold text-custom-sidebar-text-400 hover:bg-sidebar-neutral-component-surface-dark" > - Projects + Your projects {open ? ( ) : ( @@ -217,6 +217,7 @@ export const ProjectSidebarList: FC = observer(() => {
    )} diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index ac90a2e87..afbc5a9b2 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, useState } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import Link from "next/link"; @@ -6,15 +6,15 @@ import { useTheme } from "next-themes"; import { Menu, Transition } from "@headlessui/react"; import { mutate } from "swr"; import { Check, ChevronDown, CircleUserRound, LogOut, Mails, PlusSquare, Settings, UserCircle2 } from "lucide-react"; +import { usePopper } from "react-popper"; // hooks -import { useApplication, useUser, useWorkspace } from "hooks/store"; +import { useApplication, useEventTracker, useUser, useWorkspace } from "hooks/store"; // hooks import useToast from "hooks/use-toast"; // ui import { Avatar, Loader } from "@plane/ui"; // types import { IWorkspace } from "@plane/types"; - // Static Data const userLinks = (workspaceSlug: string, userId: string) => [ { @@ -36,7 +36,6 @@ const userLinks = (workspaceSlug: string, userId: string) => [ icon: Settings, }, ]; - const profileLinks = (workspaceSlug: string, userId: string) => [ { name: "View profile", @@ -49,27 +48,39 @@ const profileLinks = (workspaceSlug: string, userId: string) => [ link: "/profile", }, ]; - export const WorkspaceSidebarDropdown = observer(() => { // router const router = useRouter(); const { workspaceSlug } = router.query; // store hooks const { - theme: { sidebarCollapsed }, - eventTracker: { setTrackElement }, + theme: { sidebarCollapsed, toggleSidebar }, } = useApplication(); + const { setTrackElement } = useEventTracker(); const { currentUser, updateCurrentUser, isUserInstanceAdmin, signOut } = useUser(); const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace(); // hooks const { setToastAlert } = useToast(); const { setTheme } = useTheme(); - + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: "right", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); const handleWorkspaceNavigation = (workspace: IWorkspace) => updateCurrentUser({ last_workspace_id: workspace?.id, }); - const handleSignOut = async () => { await signOut() .then(() => { @@ -85,9 +96,12 @@ export const WorkspaceSidebarDropdown = observer(() => { }) ); }; - + const handleItemClick = () => { + if (window.innerWidth < 768) { + toggleSidebar(); + } + }; const workspacesList = Object.values(workspaces ?? {}); - // TODO: fix workspaces list scroll return (
    @@ -116,14 +130,12 @@ export const WorkspaceSidebarDropdown = observer(() => { activeWorkspace?.name?.charAt(0) ?? "..." )}
    - {!sidebarCollapsed && (

    {activeWorkspace?.name ? activeWorkspace.name : "Loading..."}

    )}
    - {!sidebarCollapsed && (
    - { handleWorkspaceNavigation(workspace)} + onClick={() => { + handleWorkspaceNavigation(workspace); + handleItemClick(); + }} className="w-full" > { workspace?.name?.charAt(0) ?? "..." )} -
    { Create workspace - {userLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link) => ( - + {userLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( + { + if (index > 0) handleItemClick(); + }} + > { )} - {!sidebarCollapsed && ( - + { className="!text-base" /> - { >
    {currentUser?.email} {profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( - + { + if (index == 0) handleItemClick(); + }} + > diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index 9d8f73dcb..04591c3c4 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -27,12 +27,18 @@ export const WorkspaceSidebarMenu = observer(() => { // computed const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; + const handleLinkClick = () => { + if (window.innerWidth < 768) { + themeStore.toggleSidebar(); + } + }; + return (
    {SIDEBAR_MENU_ITEMS.map( (link) => workspaceMemberInfo >= link.access && ( - + {
    { diff --git a/web/components/workspace/sidebar-quick-action.tsx b/web/components/workspace/sidebar-quick-action.tsx index 583163a53..52f26c877 100644 --- a/web/components/workspace/sidebar-quick-action.tsx +++ b/web/components/workspace/sidebar-quick-action.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { ChevronUp, PenSquare, Search } from "lucide-react"; // hooks -import { useApplication, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // components import { CreateUpdateDraftIssueModal } from "components/issues"; @@ -14,11 +14,8 @@ export const WorkspaceSidebarQuickAction = observer(() => { // states const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false); - const { - theme: themeStore, - commandPalette: commandPaletteStore, - eventTracker: { setTrackElement }, - } = useApplication(); + const { theme: themeStore, commandPalette: commandPaletteStore } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); diff --git a/web/components/workspace/workspace-active-cycles-upgrade.tsx b/web/components/workspace/workspace-active-cycles-upgrade.tsx index dc0d0fb9e..d1a607edb 100644 --- a/web/components/workspace/workspace-active-cycles-upgrade.tsx +++ b/web/components/workspace/workspace-active-cycles-upgrade.tsx @@ -19,7 +19,7 @@ export const WorkspaceActiveCyclesUpgrade = observer(() => { const isDarkMode = currentUser?.theme.theme === "dark"; return ( - -
    +
    {WORKSPACE_ACTIVE_CYCLES_DETAILS.map((item) => (
    diff --git a/web/constants/dashboard.ts b/web/constants/dashboard.ts index d23480cfe..3f251ca78 100644 --- a/web/constants/dashboard.ts +++ b/web/constants/dashboard.ts @@ -121,21 +121,25 @@ export const DURATION_FILTER_OPTIONS: { key: TDurationFilterOptions; label: string; }[] = [ + { + key: "none", + label: "None", + }, { key: "today", - label: "Today", + label: "Due today", }, { key: "this_week", - label: "This week", + label: " Due this week", }, { key: "this_month", - label: "This month", + label: "Due this month", }, { key: "this_year", - label: "This year", + label: "Due this year", }, ]; @@ -152,7 +156,7 @@ export const PROJECT_BACKGROUND_COLORS = [ ]; // assigned and created issues widgets tabs list -export const ISSUES_TABS_LIST: { +export const FILTERED_ISSUES_TABS_LIST: { key: TIssuesListTypes; label: string; }[] = [ @@ -170,7 +174,27 @@ export const ISSUES_TABS_LIST: { }, ]; +// assigned and created issues widgets tabs list +export const UNFILTERED_ISSUES_TABS_LIST: { + key: TIssuesListTypes; + label: string; +}[] = [ + { + key: "pending", + label: "Pending", + }, + { + key: "completed", + label: "Marked completed", + }, +]; + export const ASSIGNED_ISSUES_EMPTY_STATES = { + pending: { + title: "Issues assigned to you that are pending\nwill show up here.", + darkImage: UpcomingIssuesDark, + lightImage: UpcomingIssuesLight, + }, upcoming: { title: "Upcoming issues assigned to\nyou will show up here.", darkImage: UpcomingIssuesDark, @@ -189,6 +213,11 @@ export const ASSIGNED_ISSUES_EMPTY_STATES = { }; export const CREATED_ISSUES_EMPTY_STATES = { + pending: { + title: "Issues created by you that are pending\nwill show up here.", + darkImage: UpcomingIssuesDark, + lightImage: UpcomingIssuesLight, + }, upcoming: { title: "Upcoming issues you created\nwill show up here.", darkImage: UpcomingIssuesDark, diff --git a/web/constants/event-tracker.ts b/web/constants/event-tracker.ts new file mode 100644 index 000000000..67f1b1034 --- /dev/null +++ b/web/constants/event-tracker.ts @@ -0,0 +1,105 @@ +export type IssueEventProps = { + eventName: string; + payload: any; + updates?: any; + group?: EventGroupProps; + path?: string; +}; + +export type EventProps = { + eventName: string; + payload: any; + group?: EventGroupProps; +}; + +export type EventGroupProps = { + isGrouping?: boolean; + groupType?: string; + groupId?: string; +}; + +export const getProjectEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.id, + identifier: payload.identifier, + created_at: payload.created_at, + updated_at: payload.updated_at, + state: payload.state, + element: payload.element, +}); + +export const getCycleEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.id, + cycle_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + start_date: payload.start_date, + target_date: payload.target_date, + cycle_status: payload.status, + state: payload.state, + element: payload.element, +}); + +export const getModuleEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.id, + module_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + start_date: payload.start_date, + target_date: payload.target_date, + module_status: payload.status, + state: payload.state, + element: payload.element, +}); + +export const getIssueEventPayload = (props: IssueEventProps) => { + const { eventName, payload, updates, path } = props; + let eventPayload: any = { + issue_id: payload.id, + estimate_point: payload.estimate_point, + link_count: payload.link_count, + target_date: payload.target_date, + is_draft: payload.is_draft, + label_ids: payload.label_ids, + assignee_ids: payload.assignee_ids, + created_at: payload.created_at, + updated_at: payload.updated_at, + sequence_id: payload.sequence_id, + module_ids: payload.module_ids, + sub_issues_count: payload.sub_issues_count, + parent_id: payload.parent_id, + project_id: payload.project_id, + priority: payload.priority, + state_id: payload.state_id, + start_date: payload.start_date, + attachment_count: payload.attachment_count, + cycle_id: payload.cycle_id, + module_id: payload.module_id, + archived_at: payload.archived_at, + state: payload.state, + view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "", + }; + + if (eventName === "Issue updated") { + eventPayload = { + ...eventPayload, + ...updates, + updated_from: props.path?.includes("workspace-views") + ? "All views" + : props.path?.includes("cycles") + ? "Cycle" + : props.path?.includes("modules") + ? "Module" + : props.path?.includes("views") + ? "Project view" + : props.path?.includes("inbox") + ? "Inbox" + : props.path?.includes("draft") + ? "Draft" + : "Project", + }; + } + return eventPayload; +}; diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index ec88c8c87..86386e968 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -13,7 +13,6 @@ const paramsToKey = (params: any) => { start_date, target_date, sub_issue, - start_target_date, project, layout, subscriber, @@ -28,7 +27,6 @@ const paramsToKey = (params: any) => { let createdByKey = created_by ? created_by.split(",") : []; let labelsKey = labels ? labels.split(",") : []; let subscriberKey = subscriber ? subscriber.split(",") : []; - const startTargetDate = start_target_date ? `${start_target_date}`.toUpperCase() : "FALSE"; const startDateKey = start_date ?? ""; const targetDateKey = target_date ?? ""; const type = params.type ? params.type.toUpperCase() : "NULL"; @@ -47,7 +45,7 @@ const paramsToKey = (params: any) => { labelsKey = labelsKey.sort().join("_"); subscriberKey = subscriberKey.sort().join("_"); - return `${layoutKey}_${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${mentionsKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${startTargetDate}_${subscriberKey}`; + return `${layoutKey}_${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${mentionsKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${subscriberKey}`; }; const myIssuesParamsToKey = (params: any) => { diff --git a/web/constants/issue.ts b/web/constants/issue.ts index f351b25f1..57dff280e 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -304,7 +304,38 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, my_issues: { spreadsheet: { - filters: ["priority", "state_group", "labels", "assignees", "created_by", "project", "start_date", "target_date"], + filters: [ + "priority", + "state_group", + "labels", + "assignees", + "created_by", + "subscriber", + "project", + "start_date", + "target_date", + ], + display_properties: true, + display_filters: { + type: [null, "active", "backlog"], + }, + extra_options: { + access: false, + values: [], + }, + }, + list: { + filters: [ + "priority", + "state_group", + "labels", + "assignees", + "created_by", + "subscriber", + "project", + "start_date", + "target_date", + ], display_properties: true, display_filters: { type: [null, "active", "backlog"], diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 6a5e55a62..1a0097eb8 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -26,7 +26,12 @@ export const SPREADSHEET_PROPERTY_DETAILS: { descendingOrderKey: TIssueOrderByOptions; descendingOrderTitle: string; icon: FC; - Column: React.FC<{ issue: TIssue; onChange: (issue: TIssue, data: Partial) => void; disabled: boolean }>; + Column: React.FC<{ + issue: TIssue; + onClose: () => void; + onChange: (issue: TIssue, data: Partial, updates: any) => void; + disabled: boolean; + }>; }; } = { assignee: { diff --git a/web/helpers/dashboard.helper.ts b/web/helpers/dashboard.helper.ts index 2a8ade4fa..8003f15e3 100644 --- a/web/helpers/dashboard.helper.ts +++ b/web/helpers/dashboard.helper.ts @@ -9,6 +9,8 @@ export const getCustomDates = (duration: TDurationFilterOptions): string => { let firstDay, lastDay; switch (duration) { + case "none": + return ""; case "today": firstDay = renderFormattedPayloadDate(today); lastDay = renderFormattedPayloadDate(today); @@ -32,7 +34,9 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => { const today = renderFormattedPayloadDate(new Date()); const filterParams = - type === "upcoming" + type === "pending" + ? "?state_group=backlog,unstarted,started" + : type === "upcoming" ? `?target_date=${today};after` : type === "overdue" ? `?target_date=${today};before` diff --git a/web/helpers/date-time.helper.ts b/web/helpers/date-time.helper.ts index bc5daa2a3..b629e60ec 100644 --- a/web/helpers/date-time.helper.ts +++ b/web/helpers/date-time.helper.ts @@ -87,11 +87,11 @@ export const renderFormattedTime = (date: string | Date, timeFormat: "12-hour" | * @example checkIfStringIsDate("2021-01-01", "2021-01-08") // 8 */ export const findTotalDaysInRange = ( - startDate: Date | string, - endDate: Date | string, + startDate: Date | string | undefined | null, + endDate: Date | string | undefined | null, inclusive: boolean = true -): number => { - if (!startDate || !endDate) return 0; +): number | undefined => { + if (!startDate || !endDate) return undefined; // Parse the dates to check if they are valid const parsedStartDate = new Date(startDate); const parsedEndDate = new Date(endDate); @@ -110,8 +110,11 @@ export const findTotalDaysInRange = ( * @param {boolean} inclusive (optional) // default true * @example findHowManyDaysLeft("2024-01-01") // 3 */ -export const findHowManyDaysLeft = (date: string | Date, inclusive: boolean = true): number => { - if (!date) return 0; +export const findHowManyDaysLeft = ( + date: Date | string | undefined | null, + inclusive: boolean = true +): number | undefined => { + if (!date) return undefined; // Pass the date to findTotalDaysInRange function to find the total number of days in range from today return findTotalDaysInRange(new Date(), date, inclusive); }; diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index cdaa85883..b0121320e 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -105,9 +105,6 @@ export const handleIssueQueryParamsByLayout = ( }); } - // add start_target_date query param for the gantt_chart layout - if (layout === "gantt_chart") queryParams.push("start_target_date"); - return queryParams; }; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index a0ce6b0e2..2349b1585 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -1,4 +1,5 @@ export * from "./use-application"; +export * from "./use-event-tracker" export * from "./use-calendar-view"; export * from "./use-cycle"; export * from "./use-dashboard"; diff --git a/web/hooks/store/use-event-tracker.ts b/web/hooks/store/use-event-tracker.ts new file mode 100644 index 000000000..bcff65ae0 --- /dev/null +++ b/web/hooks/store/use-event-tracker.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IEventTrackerStore } from "store/event-tracker.store"; + +export const useEventTracker = (): IEventTrackerStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useEventTracker must be used within StoreProvider"); + return context.eventTracker; +}; diff --git a/web/hooks/use-current-time.tsx b/web/hooks/use-current-time.tsx new file mode 100644 index 000000000..fc37129c6 --- /dev/null +++ b/web/hooks/use-current-time.tsx @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export const useCurrentTime = () => { + const [currentTime, setCurrentTime] = useState(new Date()); + // update the current time every second + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + return { + currentTime, + }; +}; diff --git a/web/hooks/use-dropdown-key-down.tsx b/web/hooks/use-dropdown-key-down.tsx index 1bb861477..228e35575 100644 --- a/web/hooks/use-dropdown-key-down.tsx +++ b/web/hooks/use-dropdown-key-down.tsx @@ -1,23 +1,31 @@ import { useCallback } from "react"; type TUseDropdownKeyDown = { - (onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent) => void; + (onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): ( + event: React.KeyboardEvent + ) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { +export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown, stopPropagation = true) => { + const stopEventPropagation = (event: React.KeyboardEvent) => { + if (stopPropagation) { + event.stopPropagation(); + event.preventDefault(); + } + }; + const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === "Enter") { - event.stopPropagation(); - if (!isOpen) { - onOpen(); - } - } else if (event.key === "Escape" && isOpen) { - event.stopPropagation(); - onClose(); + stopEventPropagation(event); + + onEnterKeyDown(); + } else if (event.key === "Escape") { + stopEventPropagation(event); + onEscKeyDown(); } }, - [isOpen, onOpen, onClose] + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] ); return handleKeyDown; diff --git a/web/hooks/use-reload-confirmation.tsx b/web/hooks/use-reload-confirmation.tsx index cdaff7365..8343ea78d 100644 --- a/web/hooks/use-reload-confirmation.tsx +++ b/web/hooks/use-reload-confirmation.tsx @@ -1,26 +1,41 @@ import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/router"; -const useReloadConfirmations = (message?: string) => { +//TODO: remove temp flag isActive later and use showAlert as the source of truth +const useReloadConfirmations = (isActive = true) => { const [showAlert, setShowAlert] = useState(false); + const router = useRouter(); const handleBeforeUnload = useCallback( (event: BeforeUnloadEvent) => { + if (!isActive || !showAlert) return; event.preventDefault(); event.returnValue = ""; - return message ?? "Are you sure you want to leave?"; }, - [message] + [isActive, showAlert] + ); + + const handleRouteChangeStart = useCallback( + (url: string) => { + if (!isActive || !showAlert) return; + const leave = confirm("Are you sure you want to leave? Changes you made may not be saved."); + if (!leave) { + router.events.emit("routeChangeError"); + throw `Route change to "${url}" was aborted (this error can be safely ignored).`; + } + }, + [isActive, showAlert, router.events] ); useEffect(() => { - if (!showAlert) { - window.removeEventListener("beforeunload", handleBeforeUnload); - return; - } - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [handleBeforeUnload, showAlert]); + router.events.on("routeChangeStart", handleRouteChangeStart); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChangeStart); + }; + }, [handleBeforeUnload, handleRouteChangeStart, router.events]); return { setShowAlert }; }; diff --git a/web/hooks/use-table-keyboard-navigation.tsx b/web/hooks/use-table-keyboard-navigation.tsx new file mode 100644 index 000000000..0d1c26f3c --- /dev/null +++ b/web/hooks/use-table-keyboard-navigation.tsx @@ -0,0 +1,56 @@ +export const useTableKeyboardNavigation = () => { + const getPreviousRow = (element: HTMLElement) => { + const previousRow = element.closest("tr")?.previousSibling; + + if (previousRow) return previousRow; + //if previous row does not exist in the parent check the row with the header of the table + return element.closest("tbody")?.previousSibling?.childNodes?.[0]; + }; + + const getNextRow = (element: HTMLElement) => { + const nextRow = element.closest("tr")?.nextSibling; + + if (nextRow) return nextRow; + //if next row does not exist in the parent check the row with the body of the table + return element.closest("thead")?.nextSibling?.childNodes?.[0]; + }; + + const handleKeyBoardNavigation = function (e: React.KeyboardEvent) { + const element = e.target as HTMLElement; + + if (!(element?.tagName === "TD" || element?.tagName === "TH")) return; + + let c: HTMLElement | null = null; + if (e.key == "ArrowRight") { + // Right Arrow + c = element.nextSibling as HTMLElement; + } else if (e.key == "ArrowLeft") { + // Left Arrow + c = element.previousSibling as HTMLElement; + } else if (e.key == "ArrowUp") { + // Up Arrow + const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element); + const prevRow = getPreviousRow(element); + + c = prevRow?.childNodes?.[index] as HTMLElement; + } else if (e.key == "ArrowDown") { + // Down Arrow + const index = Array.prototype.indexOf.call(element?.parentNode?.childNodes || [], element); + const nextRow = getNextRow(element); + + c = nextRow?.childNodes[index] as HTMLElement; + } else if (e.key == "Enter" || e.key == "Space") { + e.preventDefault(); + (element?.querySelector(".clickable") as HTMLElement)?.click(); + return; + } + + if (!c) return; + + e.preventDefault(); + c?.focus(); + c?.scrollIntoView({ behavior: "smooth", block: "center", inline: "end" }); + }; + + return handleKeyBoardNavigation; +}; diff --git a/web/hooks/use-user-notifications.tsx b/web/hooks/use-user-notifications.tsx index e8a0c1d34..17a2c63dc 100644 --- a/web/hooks/use-user-notifications.tsx +++ b/web/hooks/use-user-notifications.tsx @@ -264,6 +264,13 @@ const useUserNotification = () => { await userNotificationServices .markAllNotificationsAsRead(workspaceSlug.toString(), markAsReadParams) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "All Notifications marked as read.", + }); + }) .catch(() => { setToastAlert({ type: "error", diff --git a/web/layouts/admin-layout/header.tsx b/web/layouts/admin-layout/header.tsx index eae5498bf..5f492dd03 100644 --- a/web/layouts/admin-layout/header.tsx +++ b/web/layouts/admin-layout/header.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite"; import { Breadcrumbs } from "@plane/ui"; // icons import { Settings } from "lucide-react"; +import { BreadcrumbLink } from "components/common"; export interface IInstanceAdminHeader { title?: string; @@ -21,11 +22,15 @@ export const InstanceAdminHeader: FC = observer((props) => } - label="Settings" - link="/god-mode" + link={ + } + /> + } /> - + } />
    )} diff --git a/web/layouts/app-layout/sidebar.tsx b/web/layouts/app-layout/sidebar.tsx index 81dddcf6a..e211e7884 100644 --- a/web/layouts/app-layout/sidebar.tsx +++ b/web/layouts/app-layout/sidebar.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; // components import { @@ -10,20 +10,47 @@ import { import { ProjectSidebarList } from "components/project"; // hooks import { useApplication } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; export interface IAppSidebar {} export const AppSidebar: FC = observer(() => { // store hooks const { theme: themStore } = useApplication(); + const ref = useRef(null); + + useOutsideClickDetector(ref, () => { + if (themStore.sidebarCollapsed === false) { + if (window.innerWidth < 768) { + themStore.toggleSidebar(); + } + } + }); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 768) { + themStore.toggleSidebar(true); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [themStore]); return (
    -
    +
    diff --git a/web/layouts/auth-layout/project-wrapper.tsx b/web/layouts/auth-layout/project-wrapper.tsx index 831301198..775c5a8b3 100644 --- a/web/layouts/auth-layout/project-wrapper.tsx +++ b/web/layouts/auth-layout/project-wrapper.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; // hooks import { useApplication, + useEventTracker, useCycle, useEstimate, useLabel, @@ -34,6 +35,7 @@ export const ProjectAuthWrapper: FC = observer((props) => { const { commandPalette: { toggleCreateProjectModal }, } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { fetchUserProjectInfo, projectMemberInfo, hasPermissionToProject }, } = useUser(); @@ -135,7 +137,10 @@ export const ProjectAuthWrapper: FC = observer((props) => { image={emptyProject} primaryButton={{ text: "Create Project", - onClick: () => toggleCreateProjectModal(true), + onClick: () => { + setTrackElement("Projects page empty state"); + toggleCreateProjectModal(true); + }, }} />
    diff --git a/web/layouts/settings-layout/project/layout.tsx b/web/layouts/settings-layout/project/layout.tsx index c029643bf..f5f131269 100644 --- a/web/layouts/settings-layout/project/layout.tsx +++ b/web/layouts/settings-layout/project/layout.tsx @@ -41,11 +41,13 @@ export const ProjectSettingLayout: FC = observer((props) } /> ) : ( -
    -
    +
    +
    - {children} +
    + {children} +
    ); }); diff --git a/web/layouts/settings-layout/workspace/layout.tsx b/web/layouts/settings-layout/workspace/layout.tsx index fac64bf2e..4ee0f1e33 100644 --- a/web/layouts/settings-layout/workspace/layout.tsx +++ b/web/layouts/settings-layout/workspace/layout.tsx @@ -10,11 +10,13 @@ export const WorkspaceSettingLayout: FC = (props) => { const { children } = props; return ( -
    -
    +
    +
    - {children} +
    + {children} +
    ); }; diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index dad6253c9..864c87f27 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -17,7 +17,7 @@ import { SWRConfig } from "swr"; import { SWR_CONFIG } from "constants/swr-config"; // dynamic imports const StoreWrapper = dynamic(() => import("lib/wrappers/store-wrapper"), { ssr: false }); -const PosthogWrapper = dynamic(() => import("lib/wrappers/posthog-wrapper"), { ssr: false }); +const PostHogProvider = dynamic(() => import("lib/posthog-provider"), { ssr: false }); const CrispWrapper = dynamic(() => import("lib/wrappers/crisp-wrapper"), { ssr: false }); // nprogress @@ -47,7 +47,7 @@ export const AppProvider: FC = observer((props) => { - = observer((props) => { posthogHost={envConfig?.posthog_host || null} > {children} - + diff --git a/web/lib/wrappers/posthog-wrapper.tsx b/web/lib/posthog-provider.tsx similarity index 83% rename from web/lib/wrappers/posthog-wrapper.tsx rename to web/lib/posthog-provider.tsx index 6ce830517..f42bf5194 100644 --- a/web/lib/wrappers/posthog-wrapper.tsx +++ b/web/lib/posthog-provider.tsx @@ -1,7 +1,7 @@ import { FC, ReactNode, useEffect } from "react"; import { useRouter } from "next/router"; import posthog from "posthog-js"; -import { PostHogProvider } from "posthog-js/react"; +import { PostHogProvider as PHProvider } from "posthog-js/react"; // mobx store provider import { IUser } from "@plane/types"; // helpers @@ -16,7 +16,7 @@ export interface IPosthogWrapper { posthogHost: string | null; } -const PosthogWrapper: FC = (props) => { +const PostHogProvider: FC = (props) => { const { children, user, workspaceRole, projectRole, posthogAPIKey, posthogHost } = props; // router const router = useRouter(); @@ -39,10 +39,6 @@ const PosthogWrapper: FC = (props) => { if (posthogAPIKey && posthogHost) { posthog.init(posthogAPIKey, { api_host: posthogHost || "https://app.posthog.com", - // Enable debug mode in development - loaded: (posthog) => { - if (process.env.NODE_ENV === "development") posthog.debug(); - }, autocapture: false, capture_pageview: false, // Disable automatic pageview capture, as we capture manually }); @@ -63,9 +59,9 @@ const PosthogWrapper: FC = (props) => { }, []); if (posthogAPIKey) { - return {children}; + return {children}; } return <>{children}; }; -export default PosthogWrapper; +export default PostHogProvider; diff --git a/web/lib/redirect.ts b/web/lib/redirect.ts deleted file mode 100644 index 8c179fc10..000000000 --- a/web/lib/redirect.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Router from "next/router"; -import type { NextPageContext } from "next"; - -const redirect = (context: NextPageContext, target: any) => { - if (context.res) { - // server - // 303: "See other" - context.res.writeHead(301, { Location: target }); - context.res.end(); - } else { - // In the browser, we just pretend like this never even happened ;) - Router.push(target); - } -}; - -export default redirect; diff --git a/web/pages/[workspaceSlug]/analytics.tsx b/web/pages/[workspaceSlug]/analytics.tsx index 93e218d8b..57d732416 100644 --- a/web/pages/[workspaceSlug]/analytics.tsx +++ b/web/pages/[workspaceSlug]/analytics.tsx @@ -1,8 +1,9 @@ import React, { Fragment, ReactElement } from "react"; import { observer } from "mobx-react-lite"; import { Tab } from "@headlessui/react"; +import { useTheme } from "next-themes"; // hooks -import { useApplication, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // layouts import { AppLayout } from "layouts/app-layout"; // components @@ -16,18 +17,21 @@ import { EUserWorkspaceRoles } from "constants/workspace"; import { NextPageWithLayout } from "lib/types"; const AnalyticsPage: NextPageWithLayout = observer(() => { + // theme + const { resolvedTheme } = useTheme(); // store hooks const { commandPalette: { toggleCreateProjectModal }, - eventTracker: { setTrackElement }, } = useApplication(); + const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, currentUser, } = useUser(); const { workspaceProjectIds } = useProject(); - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "analytics", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "analytics", isLightMode); const isEditingAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; return ( @@ -68,7 +72,7 @@ const AnalyticsPage: NextPageWithLayout = observer(() => { primaryButton={{ text: "Create Cycles and Modules first", onClick: () => { - setTrackElement("ANALYTICS_EMPTY_STATE"); + setTrackElement("Analytics empty state"); toggleCreateProjectModal(true); }, }} diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 99cc80565..035afbce8 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -2,8 +2,9 @@ import { Fragment, useCallback, useState, ReactElement } from "react"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Tab } from "@headlessui/react"; +import { useTheme } from "next-themes"; // hooks -import { useCycle, useUser } from "hooks/store"; +import { useEventTracker, useCycle, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // layouts import { AppLayout } from "layouts/app-layout"; @@ -22,7 +23,10 @@ import { EUserWorkspaceRoles } from "constants/workspace"; const ProjectCyclesPage: NextPageWithLayout = observer(() => { const [createModal, setCreateModal] = useState(false); + // theme + const { resolvedTheme } = useTheme(); // store hooks + const { setTrackElement } = useEventTracker(); const { membership: { currentProjectRole }, currentUser, @@ -49,7 +53,9 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { }, [handleCurrentLayout, setCycleTab] ); - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "cycles", currentUser?.theme.theme === "light"); + + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "cycles", isLightMode); const totalCycles = currentProjectCycleIds?.length ?? 0; @@ -86,6 +92,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { primaryButton={{ text: "Set your first cycle", onClick: () => { + setTrackElement("Cycle empty state"); setCreateModal(true); }, }} @@ -101,7 +108,7 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} > -
    +
    {CYCLE_TAB_LIST.map((tab) => ( { ))} - {cycleTab !== "active" && ( -
    - {CYCLE_VIEW_LAYOUTS.map((layout) => { - if (layout.key === "gantt" && cycleTab === "draft") return null; +
    + {cycleTab !== "active" && ( +
    + {CYCLE_VIEW_LAYOUTS.map((layout) => { + if (layout.key === "gantt" && cycleTab === "draft") return null; - return ( - - - - ); - })} -
    - )} + onClick={() => handleCurrentLayout(layout.key as TCycleLayout)} + > + + + + ); + })} +
    + )} +
    diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx index fa893a9a8..64956ee01 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/[pageId].tsx @@ -56,9 +56,6 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { // toast alert const { setToastAlert } = useToast(); - //TODO:fix reload confirmations, with mobx - const { setShowAlert } = useReloadConfirmations(); - const { handleSubmit, setValue, watch, getValues, control, reset } = useForm({ defaultValues: { name: "", description_html: "" }, }); @@ -89,6 +86,8 @@ const PageDetailsPage: NextPageWithLayout = observer(() => { const pageStore = usePage(pageId as string); + const { setShowAlert } = useReloadConfirmations(pageStore?.isSubmitting === "submitting"); + useEffect( () => () => { if (pageStore) { diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index fe53c7393..7130ff0ba 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic"; import { Tab } from "@headlessui/react"; import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import { useTheme } from "next-themes"; // hooks import { useApplication, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; @@ -48,7 +49,9 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { const { workspaceSlug, projectId } = router.query; // states const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); - // store + // theme + const { resolvedTheme } = useTheme(); + // store hooks const { currentUser, currentUserLoader, @@ -94,7 +97,8 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { } }; - const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", currentUser?.theme.theme === "light"); + const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light"; + const EmptyStateImagePath = getEmptyStateImagePath("onboarding", "pages", isLightMode); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; diff --git a/web/pages/[workspaceSlug]/settings/members.tsx b/web/pages/[workspaceSlug]/settings/members.tsx index 1c49362d7..c9e8341b8 100644 --- a/web/pages/[workspaceSlug]/settings/members.tsx +++ b/web/pages/[workspaceSlug]/settings/members.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Search } from "lucide-react"; // hooks -import { useApplication, useMember, useUser } from "hooks/store"; +import { useEventTracker, useMember, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // layouts import { AppLayout } from "layouts/app-layout"; @@ -27,9 +27,7 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { - eventTracker: { postHogEventTracker, setTrackElement }, - } = useApplication(); + const { captureEvent, setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -45,7 +43,7 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { return inviteMembersToWorkspace(workspaceSlug.toString(), data) .then(() => { setInviteModal(false); - postHogEventTracker("MEMBER_INVITED", { state: "SUCCESS" }); + captureEvent("Member invited", { state: "SUCCESS" }); setToastAlert({ type: "success", title: "Success!", @@ -53,7 +51,7 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { }); }) .catch((err) => { - postHogEventTracker("MEMBER_INVITED", { state: "FAILED" }); + captureEvent("Member invited", { state: "FAILED" }); setToastAlert({ type: "error", title: "Error!", diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index 425862839..cc0411068 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -50,12 +50,6 @@ class MyDocument extends Document { src="https://plausible.io/js/script.js" /> )} - {process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST && ( - - )} ); diff --git a/web/pages/accounts/reset-password.tsx b/web/pages/accounts/reset-password.tsx index df3c99679..018d72cdc 100644 --- a/web/pages/accounts/reset-password.tsx +++ b/web/pages/accounts/reset-password.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from "react"; +import { ReactElement, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; @@ -19,6 +19,8 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "lib/types"; +// icons +import { Eye, EyeOff } from "lucide-react"; type TResetPasswordFormValues = { email: string; @@ -37,6 +39,8 @@ const ResetPasswordPage: NextPageWithLayout = () => { // router const router = useRouter(); const { uidb64, token, email } = router.query; + // states + const [showPassword, setShowPassword] = useState(false); // toast const { setToastAlert } = useToast(); // sign in redirection hook @@ -117,15 +121,28 @@ const ResetPasswordPage: NextPageWithLayout = () => { required: "Password is required", }} render={({ field: { value, onChange } }) => ( - +
    + + {showPassword ? ( + setShowPassword(false)} + /> + ) : ( + setShowPassword(true)} + /> + )} +
    )} />
    -
    diff --git a/web/services/module.service.ts b/web/services/module.service.ts index 4638f6ab2..ebddfb055 100644 --- a/web/services/module.service.ts +++ b/web/services/module.service.ts @@ -1,7 +1,7 @@ // services import { APIService } from "services/api.service"; // types -import type { IModule, TIssue, ILinkDetails, ModuleLink, TIssueMap } from "@plane/types"; +import type { IModule, TIssue, ILinkDetails, ModuleLink } from "@plane/types"; import { API_BASE_URL } from "helpers/common.helper"; export class ModuleService extends APIService { @@ -63,22 +63,7 @@ export class ModuleService extends APIService { } async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string, queries?: any): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, { - params: queries, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getModuleIssuesWithParams( - workspaceSlug: string, - projectId: string, - moduleId: string, - queries?: any - ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, { params: queries, }) .then((response) => response?.data) @@ -92,15 +77,21 @@ export class ModuleService extends APIService { projectId: string, moduleId: string, data: { issues: string[] } - ): Promise< - { - issue: string; - issue_detail: TIssue; - module: string; - module_detail: IModule; - }[] - > { - return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, data) + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addModulesToIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: { modules: string[] } + ): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/modules/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -111,17 +102,53 @@ export class ModuleService extends APIService { workspaceSlug: string, projectId: string, moduleId: string, - bridgeId: string + issueId: string ): Promise { - return this.delete( - `/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/${bridgeId}/` - ) + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; }); } + async removeIssuesFromModuleBulk( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[] + ): Promise { + const promiseDataUrls: any = []; + issueIds.forEach((issueId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + return await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + + async removeModulesFromIssueBulk( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ): Promise { + const promiseDataUrls: any = []; + moduleIds.forEach((moduleId) => { + promiseDataUrls.push( + this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) + ); + }); + return await Promise.all(promiseDataUrls) + .then((response) => response) + .catch((error) => { + throw error?.response?.data; + }); + } + async createModuleLink( workspaceSlug: string, projectId: string, diff --git a/web/store/application/event-tracker.store.ts b/web/store/application/event-tracker.store.ts deleted file mode 100644 index cc0ac22b2..000000000 --- a/web/store/application/event-tracker.store.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { action, makeObservable, observable } from "mobx"; -import posthog from "posthog-js"; -// stores -import { RootStore } from "../root.store"; - -export interface IEventTrackerStore { - trackElement: string; - setTrackElement: (element: string) => void; - postHogEventTracker: ( - eventName: string, - payload: object | [] | null, - group?: { isGrouping: boolean | null; groupType: string | null; groupId: string | null } | null - ) => void; -} - -export class EventTrackerStore implements IEventTrackerStore { - trackElement: string = ""; - rootStore; - constructor(_rootStore: RootStore) { - makeObservable(this, { - trackElement: observable, - setTrackElement: action.bound, - postHogEventTracker: action, - }); - this.rootStore = _rootStore; - } - - setTrackElement = (element: string) => { - this.trackElement = element; - }; - - postHogEventTracker = ( - eventName: string, - payload: object | [] | null, - group?: { isGrouping: boolean | null; groupType: string | null; groupId: string | null } | null - ) => { - try { - const currentWorkspaceDetails = this.rootStore.workspaceRoot.workspaces.currentWorkspace; - const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails; - let extras: any = { - workspace_name: currentWorkspaceDetails?.name ?? "", - workspace_id: currentWorkspaceDetails?.id ?? "", - workspace_slug: currentWorkspaceDetails?.slug ?? "", - project_name: currentProjectDetails?.name ?? "", - project_id: currentProjectDetails?.id ?? "", - project_identifier: currentProjectDetails?.identifier ?? "", - }; - if (["PROJECT_CREATED", "PROJECT_UPDATED"].includes(eventName)) { - const project_details: any = payload as object; - extras = { - ...extras, - project_name: project_details?.name ?? "", - project_id: project_details?.id ?? "", - project_identifier: project_details?.identifier ?? "", - }; - } - if (group && group!.isGrouping === true) { - posthog?.group(group!.groupType!, group!.groupId!, { - date: new Date(), - workspace_id: group!.groupId, - }); - posthog?.capture(eventName, { - ...payload, - extras: extras, - element: this.trackElement ?? "", - }); - } else { - posthog?.capture(eventName, { - ...payload, - extras: extras, - element: this.trackElement ?? "", - }); - } - } catch (error) { - throw error; - } - this.setTrackElement(""); - }; -} diff --git a/web/store/application/index.ts b/web/store/application/index.ts index 672cf4fbe..30333535a 100644 --- a/web/store/application/index.ts +++ b/web/store/application/index.ts @@ -1,7 +1,7 @@ import { RootStore } from "store/root.store"; import { AppConfigStore, IAppConfigStore } from "./app-config.store"; import { CommandPaletteStore, ICommandPaletteStore } from "./command-palette.store"; -import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; +import { EventTrackerStore, IEventTrackerStore } from "../event-tracker.store"; // import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; import { InstanceStore, IInstanceStore } from "./instance.store"; import { RouterStore, IRouterStore } from "./router.store"; @@ -10,7 +10,6 @@ import { ThemeStore, IThemeStore } from "./theme.store"; export interface IAppRootStore { config: IAppConfigStore; commandPalette: ICommandPaletteStore; - eventTracker: IEventTrackerStore; instance: IInstanceStore; theme: IThemeStore; router: IRouterStore; @@ -19,7 +18,6 @@ export interface IAppRootStore { export class AppRootStore implements IAppRootStore { config: IAppConfigStore; commandPalette: ICommandPaletteStore; - eventTracker: IEventTrackerStore; instance: IInstanceStore; theme: IThemeStore; router: IRouterStore; @@ -28,7 +26,6 @@ export class AppRootStore implements IAppRootStore { this.router = new RouterStore(); this.config = new AppConfigStore(); this.commandPalette = new CommandPaletteStore(); - this.eventTracker = new EventTrackerStore(_rootStore); this.instance = new InstanceStore(); this.theme = new ThemeStore(); } diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index ed5077385..51340d740 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -103,7 +103,7 @@ export class CycleStore implements ICycleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let allCycles = Object.values(this.cycleMap ?? {}).filter((c) => c?.project === projectId); - allCycles = sortBy(allCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + allCycles = sortBy(allCycles, [(c) => c.sort_order]); const allCycleIds = allCycles.map((c) => c.id); return allCycleIds; } @@ -118,7 +118,7 @@ export class CycleStore implements ICycleStore { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); return c.project === projectId && hasEndDatePassed; }); - completedCycles = sortBy(completedCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + completedCycles = sortBy(completedCycles, [(c) => c.sort_order]); const completedCycleIds = completedCycles.map((c) => c.id); return completedCycleIds; } @@ -133,7 +133,7 @@ export class CycleStore implements ICycleStore { const isStartDateUpcoming = isFuture(new Date(c.start_date ?? "")); return c.project === projectId && isStartDateUpcoming; }); - upcomingCycles = sortBy(upcomingCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + upcomingCycles = sortBy(upcomingCycles, [(c) => c.sort_order]); const upcomingCycleIds = upcomingCycles.map((c) => c.id); return upcomingCycleIds; } @@ -148,7 +148,7 @@ export class CycleStore implements ICycleStore { const hasEndDatePassed = isPast(new Date(c.end_date ?? "")); return c.project === projectId && !hasEndDatePassed; }); - incompleteCycles = sortBy(incompleteCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + incompleteCycles = sortBy(incompleteCycles, [(c) => c.sort_order]); const incompleteCycleIds = incompleteCycles.map((c) => c.id); return incompleteCycleIds; } @@ -162,7 +162,7 @@ export class CycleStore implements ICycleStore { let draftCycles = Object.values(this.cycleMap ?? {}).filter( (c) => c.project === projectId && !c.start_date && !c.end_date ); - draftCycles = sortBy(draftCycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + draftCycles = sortBy(draftCycles, [(c) => c.sort_order]); const draftCycleIds = draftCycles.map((c) => c.id); return draftCycleIds; } @@ -203,7 +203,7 @@ export class CycleStore implements ICycleStore { if (!this.fetchedMap[projectId]) return null; let cycles = Object.values(this.cycleMap ?? {}).filter((c) => c.project === projectId); - cycles = sortBy(cycles, [(c) => !c.is_favorite, (c) => c.name.toLowerCase()]); + cycles = sortBy(cycles, [(c) => c.sort_order]); const cycleIds = cycles.map((c) => c.id); return cycleIds || null; }); @@ -304,6 +304,7 @@ export class CycleStore implements ICycleStore { set(this.cycleMap, [cycleId], { ...this.cycleMap?.[cycleId], ...data }); }); const response = await this.cycleService.patchCycle(workspaceSlug, projectId, cycleId, data); + this.fetchCycleDetails(workspaceSlug, projectId, cycleId); return response; } catch (error) { console.log("Failed to patch cycle from cycle store"); diff --git a/web/store/event-tracker.store.ts b/web/store/event-tracker.store.ts new file mode 100644 index 000000000..89e279c40 --- /dev/null +++ b/web/store/event-tracker.store.ts @@ -0,0 +1,155 @@ +import { action, computed, makeObservable, observable } from "mobx"; +import posthog from "posthog-js"; +// stores +import { RootStore } from "./root.store"; +import { + EventGroupProps, + EventProps, + IssueEventProps, + getCycleEventPayload, + getIssueEventPayload, + getModuleEventPayload, + getProjectEventPayload, +} from "constants/event-tracker"; + +export interface IEventTrackerStore { + // properties + trackElement: string; + // computed + getRequiredPayload: any; + // actions + setTrackElement: (element: string) => void; + captureEvent: (eventName: string, payload: object | [] | null, group?: EventGroupProps) => void; + captureProjectEvent: (props: EventProps) => void; + captureCycleEvent: (props: EventProps) => void; + captureModuleEvent: (props: EventProps) => void; + captureIssueEvent: (props: IssueEventProps) => void; +} + +export class EventTrackerStore implements IEventTrackerStore { + trackElement: string = ""; + rootStore; + constructor(_rootStore: RootStore) { + makeObservable(this, { + // properties + trackElement: observable, + // computed + getRequiredPayload: computed, + // actions + setTrackElement: action, + captureEvent: action, + captureProjectEvent: action, + captureCycleEvent: action, + }); + // store + this.rootStore = _rootStore; + } + + /** + * @description: Returns the necessary property for the event tracking + */ + get getRequiredPayload() { + const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; + const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails; + return { + workspace_id: currentWorkspaceDetails?.id ?? "", + project_id: currentProjectDetails?.id ?? "", + }; + } + + /** + * @description: Set the trigger point of event. + * @param {string} element + */ + setTrackElement = (element: string) => { + this.trackElement = element; + }; + + postHogGroup = (group: EventGroupProps) => { + if (group && group!.isGrouping === true) { + posthog?.group(group!.groupType!, group!.groupId!, { + date: new Date(), + workspace_id: group!.groupId, + }); + } + }; + + captureEvent = (eventName: string, payload: object | [] | null) => { + posthog?.capture(eventName, { + ...payload, + element: this.trackElement ?? "", + }); + }; + + /** + * @description: Captures the project related events. + * @param {EventProps} props + */ + captureProjectEvent = (props: EventProps) => { + const { eventName, payload, group } = props; + if (group) { + this.postHogGroup(group); + } + const eventPayload: any = getProjectEventPayload({ + ...this.getRequiredPayload, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(""); + }; + + /** + * @description: Captures the cycle related events. + * @param {EventProps} props + */ + captureCycleEvent = (props: EventProps) => { + const { eventName, payload, group } = props; + if (group) { + this.postHogGroup(group); + } + const eventPayload: any = getCycleEventPayload({ + ...this.getRequiredPayload, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(""); + }; + + /** + * @description: Captures the module related events. + * @param {EventProps} props + */ + captureModuleEvent = (props: EventProps) => { + const { eventName, payload, group } = props; + if (group) { + this.postHogGroup(group); + } + const eventPayload: any = getModuleEventPayload({ + ...this.getRequiredPayload, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(""); + }; + + /** + * @description: Captures the issue related events. + * @param {IssueEventProps} props + */ + captureIssueEvent = (props: IssueEventProps) => { + const { eventName, payload, group } = props; + if (group) { + this.postHogGroup(group); + } + const eventPayload: any = { + ...getIssueEventPayload(props), + ...this.getRequiredPayload, + state_group: this.rootStore.state.getStateById(payload.state_id)?.group ?? "", + element: payload.element ?? this.trackElement, + }; + posthog?.capture(eventName, eventPayload); + }; +} diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index 9a9c91a37..d92453a30 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -89,7 +89,6 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index 27347536b..dd81cfc0e 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -90,7 +90,6 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 33cd06d4d..71618d51c 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -152,6 +152,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { const params = this.rootIssueStore?.cycleIssuesFilter?.appliedFilters; const response = await this.cycleService.getCycleIssuesWithParams(workspaceSlug, projectId, cycleId, params); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); runInAction(() => { set( @@ -182,6 +183,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id]); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); return response; } catch (error) { @@ -200,6 +202,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { if (!cycleId) throw new Error("Cycle Id is required"); const response = await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); return response; } catch (error) { this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); @@ -217,6 +220,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { if (!cycleId) throw new Error("Cycle Id is required"); const response = await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); const issueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === issueId); if (issueIndex >= 0) @@ -245,6 +249,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { }); const response = await this.createIssue(workspaceSlug, projectId, data, cycleId); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); const quickAddIssueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === data.id); if (quickAddIssueIndex >= 0) @@ -267,13 +272,12 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { }); runInAction(() => { - update(this.issues, cycleId, (cycleIssueIds = []) => { - uniq(concat(cycleIssueIds, issueIds)); - }); + update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds))); }); issueIds.forEach((issueId) => { this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }); }); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); return issueToCycle; } catch (error) { @@ -290,6 +294,7 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { this.rootStore.issues.updateIssue(issueId, { cycle_id: null }); const response = await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); return response; } catch (error) { diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index 7096040d5..8295c263d 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -89,7 +89,6 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index f45c6fe20..dc0f601eb 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -1,5 +1,9 @@ import { action, observable, makeObservable, computed, runInAction } from "mobx"; import set from "lodash/set"; +import update from "lodash/update"; +import uniq from "lodash/uniq"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; // base class import { IssueHelperStore } from "../helpers/issue-helper.store"; // services @@ -29,7 +33,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { viewFlags = { enableQuickAdd: false, enableIssueCreation: true, - enableInlineEditing: false, + enableInlineEditing: true, }; // root store rootIssueStore: IIssueRootStore; @@ -123,7 +127,7 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data); runInAction(() => { - this.issues[projectId].push(response.id); + update(this.issues, [projectId], (issueIds = []) => uniq(concat(issueIds, response.id))); }); this.rootStore.issues.addIssue([response]); @@ -136,8 +140,17 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - this.rootStore.issues.updateIssue(issueId, data); - const response = await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); + const response = await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); + + if (data.hasOwnProperty("is_draft") && data?.is_draft === false) { + runInAction(() => { + update(this.issues, [projectId], (issueIds = []) => { + if (issueIds.includes(issueId)) pull(issueIds, issueId); + return issueIds; + }); + }); + } + return response; } catch (error) { this.fetchIssues(workspaceSlug, projectId, "mutation"); @@ -147,15 +160,14 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const response = await this.issueDraftService.deleteDraftIssue(workspaceSlug, projectId, issueId); + const response = await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - const issueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[projectId].splice(issueIndex, 1); + runInAction(() => { + update(this.issues, [projectId], (issueIds = []) => { + if (issueIds.includes(issueId)) pull(issueIds, issueId); + return issueIds; }); - - this.rootStore.issues.removeIssue(issueId); + }); return response; } catch (error) { diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index 03bb1bc30..6516b28fd 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -31,7 +31,10 @@ export interface IIssueFilterHelperStore { filteredParams: TIssueParams[] ): Partial>; computedFilters(filters: IIssueFilterOptions): IIssueFilterOptions; - computedDisplayFilters(displayFilters: IIssueDisplayFilterOptions): IIssueDisplayFilterOptions; + computedDisplayFilters( + displayFilters: IIssueDisplayFilterOptions, + defaultValues?: IIssueDisplayFilterOptions + ): IIssueDisplayFilterOptions; computedDisplayProperties(filters: IIssueDisplayProperties): IIssueDisplayProperties; } @@ -73,10 +76,11 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { labels: filters?.labels || undefined, start_date: filters?.start_date || undefined, target_date: filters?.target_date || undefined, + project: filters.project || undefined, + subscriber: filters.subscriber || undefined, // display filters type: displayFilters?.type || undefined, sub_issue: displayFilters?.sub_issue ?? true, - start_target_date: displayFilters?.start_target_date ?? true, }; const issueFiltersParams: Partial> = {}; @@ -147,20 +151,26 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { * @param {IIssueDisplayFilterOptions} displayFilters * @returns {IIssueDisplayFilterOptions} */ - computedDisplayFilters = (displayFilters: IIssueDisplayFilterOptions): IIssueDisplayFilterOptions => ({ - calendar: { - show_weekends: displayFilters?.calendar?.show_weekends || false, - layout: displayFilters?.calendar?.layout || "month", - }, - layout: displayFilters?.layout || "list", - order_by: displayFilters?.order_by || "sort_order", - group_by: displayFilters?.group_by || null, - sub_group_by: displayFilters?.sub_group_by || null, - type: displayFilters?.type || null, - sub_issue: displayFilters?.sub_issue || false, - show_empty_groups: displayFilters?.show_empty_groups || false, - start_target_date: displayFilters?.start_target_date || false, - }); + computedDisplayFilters = ( + displayFilters: IIssueDisplayFilterOptions, + defaultValues?: IIssueDisplayFilterOptions + ): IIssueDisplayFilterOptions => { + const filters = displayFilters || defaultValues; + + return { + calendar: { + show_weekends: filters?.calendar?.show_weekends || false, + layout: filters?.calendar?.layout || "month", + }, + layout: filters?.layout || "list", + order_by: filters?.order_by || "sort_order", + group_by: filters?.group_by || null, + sub_group_by: filters?.sub_group_by || null, + type: filters?.type || null, + sub_issue: filters?.sub_issue || false, + show_empty_groups: filters?.show_empty_groups || false, + }; + }; /** * @description This method is used to apply the display properties on the issues diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index be687eab8..46605c771 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -12,13 +12,14 @@ export interface IIssueStoreActions { removeIssue: (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; - addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; - removeIssueFromModule: ( + addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeModulesFromIssue: ( workspaceSlug: string, projectId: string, - moduleId: string, - issueId: string - ) => Promise; + issueId: string, + moduleIds: string[] + ) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; } export interface IIssueStore extends IIssueStoreActions { @@ -143,15 +144,26 @@ export class IssueStore implements IIssueStore { return cycle; }; - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { - const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addIssueToModule( + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.addModulesToIssue( workspaceSlug, projectId, - moduleId, - issueIds + issueId, + moduleIds ); - if (issueIds && issueIds.length > 0) - await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueIds[0]); + if (moduleIds && moduleIds.length > 0) + await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); + return _module; + }; + + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + const _module = await this.rootIssueDetailStore.rootIssueStore.moduleIssues.removeModulesFromIssue( + workspaceSlug, + projectId, + issueId, + moduleIds + ); + await this.rootIssueDetailStore.activity.fetchActivities(workspaceSlug, projectId, issueId); return _module; }; diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index 9feb728c7..d78add446 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -143,8 +143,10 @@ export class IssueDetail implements IIssueDetail { this.issue.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds); removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => this.issue.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => - this.issue.addIssueToModule(workspaceSlug, projectId, moduleId, issueIds); + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => + this.issue.addModulesToIssue(workspaceSlug, projectId, issueId, moduleIds); + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => + this.issue.removeModulesFromIssue(workspaceSlug, projectId, issueId, moduleIds); removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => this.issue.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index 3c309cecd..e92027235 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -90,7 +90,6 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index e24f03fb6..c9ed459a1 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -52,13 +52,21 @@ export interface IModuleIssues { data: TIssue, moduleId?: string | undefined ) => Promise; - addIssueToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; - removeIssueFromModule: ( + addIssuesToModule: (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => Promise; + removeIssuesFromModule: ( workspaceSlug: string, projectId: string, moduleId: string, - issueId: string - ) => Promise; + issueIds: string[] + ) => Promise; + addModulesToIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => Promise; + removeModulesFromIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleIds: string[] + ) => Promise; + removeIssueFromModule: (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => Promise; } export class ModuleIssues extends IssueHelperStore implements IModuleIssues { @@ -90,7 +98,10 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { updateIssue: action, removeIssue: action, quickAddIssue: action, - addIssueToModule: action, + addIssuesToModule: action, + removeIssuesFromModule: action, + addModulesToIssue: action, + removeModulesFromIssue: action, removeIssueFromModule: action, }); @@ -145,6 +156,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { const params = this.rootIssueStore?.moduleIssuesFilter?.appliedFilters; const response = await this.moduleService.getModuleIssues(workspaceSlug, projectId, moduleId, params); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); runInAction(() => { set( @@ -175,7 +187,8 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!moduleId) throw new Error("Module Id is required"); const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssueToModule(workspaceSlug, projectId, moduleId, [response.id]); + await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id]); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); return response; } catch (error) { @@ -194,6 +207,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!moduleId) throw new Error("Module Id is required"); const response = await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); return response; } catch (error) { this.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); @@ -211,6 +225,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { if (!moduleId) throw new Error("Module Id is required"); const response = await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); const issueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === issueId); if (issueIndex >= 0) @@ -239,6 +254,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { }); const response = await this.createIssue(workspaceSlug, projectId, data, moduleId); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); const quickAddIssueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === data.id); if (quickAddIssueIndex >= 0) @@ -253,7 +269,7 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; - addIssueToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + addIssuesToModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { try { const issueToModule = await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { issues: issueIds, @@ -261,11 +277,72 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { runInAction(() => { update(this.issues, moduleId, (moduleIssueIds = []) => { - uniq(concat(moduleIssueIds, issueIds)); + if (!moduleIssueIds) return [...issueIds]; + else return uniq(concat(moduleIssueIds, issueIds)); }); }); + issueIds.forEach((issueId) => { - this.rootStore.issues.updateIssue(issueId, { module_id: moduleId }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { + if (issueModuleIds.includes(moduleId)) return issueModuleIds; + else return uniq(concat(issueModuleIds, [moduleId])); + }); + }); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); + + return issueToModule; + } catch (error) { + throw error; + } + }; + + removeIssuesFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + try { + runInAction(() => { + issueIds.forEach((issueId) => { + pull(this.issues[moduleId], issueId); + }); + }); + + runInAction(() => { + issueIds.forEach((issueId) => { + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { + if (issueModuleIds.includes(moduleId)) return pull(issueModuleIds, moduleId); + else return uniq(concat(issueModuleIds, [moduleId])); + }); + }); + }); + + const response = await this.moduleService.removeIssuesFromModuleBulk( + workspaceSlug, + projectId, + moduleId, + issueIds + ); + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); + + return response; + } catch (error) { + throw error; + } + }; + + addModulesToIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + try { + const issueToModule = await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { + modules: moduleIds, + }); + + runInAction(() => { + moduleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + else return uniq(concat(moduleIssueIds, [issueId])); + }); + }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + uniq(concat(issueModuleIds, moduleIds)) + ); }); return issueToModule; @@ -274,14 +351,42 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { } }; + removeModulesFromIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleIds: string[]) => { + try { + runInAction(() => { + moduleIds.forEach((moduleId) => { + update(this.issues, moduleId, (moduleIssueIds = []) => { + if (moduleIssueIds.includes(issueId)) return moduleIssueIds; + else return uniq(concat(moduleIssueIds, [issueId])); + }); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + pull(issueModuleIds, moduleId) + ); + }); + }); + + const response = await this.moduleService.removeModulesFromIssueBulk( + workspaceSlug, + projectId, + issueId, + moduleIds + ); + + return response; + } catch (error) { + throw error; + } + }; + removeIssueFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueId: string) => { try { runInAction(() => { pull(this.issues[moduleId], issueId); + update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => + pull(issueModuleIds, moduleId) + ); }); - this.rootStore.issues.updateIssue(issueId, { module_id: null }); - const response = await this.moduleService.removeIssueFromModule(workspaceSlug, projectId, moduleId, issueId); return response; diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index a0f8028f8..563af5b01 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -93,7 +93,6 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index e0dae761c..b3df3903b 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -90,7 +90,6 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 392b7203f..69393a320 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -89,7 +89,6 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index 04f46c280..b2425757c 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -38,6 +38,8 @@ export interface IIssueRootStore { members: string[] | undefined; projects: string[] | undefined; + rootStore: RootStore; + issues: IIssueStore; state: IStateStore; @@ -87,6 +89,8 @@ export class IssueRootStore implements IIssueRootStore { members: string[] | undefined = undefined; projects: string[] | undefined = undefined; + rootStore: RootStore; + issues: IIssueStore; state: IStateStore; @@ -136,6 +140,8 @@ export class IssueRootStore implements IIssueRootStore { projects: observable, }); + this.rootStore = rootStore; + autorun(() => { if (rootStore.user.currentUser?.id) this.currentUserId = rootStore.user.currentUser?.id; if (rootStore.app.router.workspaceSlug) this.workspaceSlug = rootStore.app.router.workspaceSlug; diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index 34907bd9b..92cc33a64 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -90,7 +90,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo const userFilters = this.getIssueFilters(viewId); if (!userFilters) return undefined; - const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); + const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "my_issues"); if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( @@ -99,7 +99,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo filteredParams ); - if (userFilters?.displayFilters?.layout === "gantt_chart") filteredRouteParams.start_target_date = true; if (userFilters?.displayFilters?.layout === "spreadsheet") filteredRouteParams.sub_issue = false; return filteredRouteParams; @@ -126,7 +125,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }; const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); - displayFilters = this.computedDisplayFilters(_filters?.display_filters); + displayFilters = this.computedDisplayFilters(_filters?.display_filters, { layout: "spreadsheet" }); displayProperties = this.computedDisplayProperties(_filters?.display_properties); kanbanFilters = { group_by: _filters?.kanban_filters?.group_by || [], diff --git a/web/store/module.store.ts b/web/store/module.store.ts index f0f576cbc..5c80e39d0 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -100,7 +100,7 @@ export class ModulesStore implements IModuleStore { const projectId = this.rootStore.app.router.projectId; if (!projectId || !this.fetchedMap[projectId]) return null; let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); - projectModules = sortBy(projectModules, [(m) => !m.is_favorite, (m) => m.name.toLowerCase()]); + projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds || null; } @@ -120,7 +120,7 @@ export class ModulesStore implements IModuleStore { if (!this.fetchedMap[projectId]) return null; let projectModules = Object.values(this.moduleMap).filter((m) => m.project === projectId); - projectModules = sortBy(projectModules, [(m) => !m.is_favorite, (m) => m.name.toLowerCase()]); + projectModules = sortBy(projectModules, [(m) => m.sort_order]); const projectModuleIds = projectModules.map((m) => m.id); return projectModuleIds; }); @@ -196,6 +196,7 @@ export class ModulesStore implements IModuleStore { set(this.moduleMap, [moduleId], { ...originalModuleDetails, ...data }); }); const response = await this.moduleService.patchModule(workspaceSlug, projectId, moduleId, data); + this.fetchModuleDetails(workspaceSlug, projectId, moduleId); return response; } catch (error) { console.error("Failed to update module in module store", error); diff --git a/web/store/root.store.ts b/web/store/root.store.ts index b3aeeea04..3e0733249 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -1,6 +1,7 @@ import { enableStaticRendering } from "mobx-react-lite"; // root stores import { AppRootStore, IAppRootStore } from "./application"; +import { EventTrackerStore, IEventTrackerStore } from "./event-tracker.store"; import { IProjectRootStore, ProjectRootStore } from "./project"; import { CycleStore, ICycleStore } from "./cycle.store"; import { IProjectViewStore, ProjectViewStore } from "./project-view.store"; @@ -22,6 +23,7 @@ enableStaticRendering(typeof window === "undefined"); export class RootStore { app: IAppRootStore; + eventTracker: IEventTrackerStore; user: IUserRootStore; workspaceRoot: IWorkspaceRootStore; projectRoot: IProjectRootStore; @@ -41,6 +43,7 @@ export class RootStore { constructor() { this.app = new AppRootStore(this); + this.eventTracker = new EventTrackerStore(this); this.user = new UserRootStore(this); this.workspaceRoot = new WorkspaceRootStore(this); this.projectRoot = new ProjectRootStore(this);