diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 38694a62ea..603f08e942 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/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 0d72f91924..28e8810603 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -68,6 +68,7 @@ from .issue import ( IssueRelationSerializer, RelatedIssueSerializer, IssuePublicSerializer, + IssueDetailSerializer, ) from .module import ( diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index be98bc312e..90069bd41b 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -586,7 +586,6 @@ class IssueSerializer(DynamicBaseSerializer): "id", "name", "state_id", - "description_html", "sort_order", "completed_at", "estimate_point", @@ -618,6 +617,13 @@ class IssueSerializer(DynamicBaseSerializer): return [module for module in obj.issue_module.values_list("module_id", flat=True)] +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + ['description_html'] + + class IssueLiteSerializer(DynamicBaseSerializer): workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 501f476578..29cb43e386 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign up", + medium="Magic link", first_time=True, ) key, token, current_attempt = generate_magic_token(email=email) @@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) @@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index a41200d61a..c2b3e0b7e4 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", + event_name="Sign in", + medium="Email", first_time=False, ) @@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", + event_name="Sign in", + medium="Magic link", first_time=False, ) diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index af799c4270..6bd3fc6ce3 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,6 +20,7 @@ from django.core import serializers from django.utils import timezone 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 @@ -313,6 +314,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "labels": label_distribution, "completion_chart": {}, } + if data[0]["start_date"] and data[0]["end_date"]: data[0]["distribution"][ "completion_chart" @@ -841,10 +843,230 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.get( + new_cycle = Cycle.objects.filter( workspace__slug=slug, project_id=project_id, pk=new_cycle_id + ).first() + + old_cycle = ( + Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) ) + # Pass the new_cycle queryset to burndown_plot + completion_chart = burndown_plot( + queryset=old_cycle.first(), + slug=slug, + project_id=project_id, + cycle_id=cycle_id, + ) + + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(display_name=F("assignees__display_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("display_name", "assignee_id", "avatar") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("display_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=cycle_id, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate( + total_issues=Count( + "id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) + .annotate( + completed_issues=Count( + "id", + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .annotate( + pending_issues=Count( + "id", + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), + ) + ) + .order_by("label_name") + ) + + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + + label_distribution_data = [ + { + "label_name": item["label_name"], + "color": item["color"], + "label_id": str(item["label_id"]) if item["label_id"] else None, + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in label_distribution + ] + + current_cycle = Cycle.objects.filter( + workspace__slug=slug, project_id=project_id, pk=cycle_id + ).first() + + current_cycle.progress_snapshot = { + "total_issues": old_cycle.first().total_issues, + "completed_issues": old_cycle.first().completed_issues, + "cancelled_issues": old_cycle.first().cancelled_issues, + "started_issues": old_cycle.first().started_issues, + "unstarted_issues": old_cycle.first().unstarted_issues, + "backlog_issues": old_cycle.first().backlog_issues, + "total_estimates": old_cycle.first().total_estimates, + "completed_estimates": old_cycle.first().completed_estimates, + "started_estimates": old_cycle.first().started_estimates, + "distribution":{ + "labels": label_distribution_data, + "assignees": assignee_distribution_data, + "completion_chart": completion_chart, + }, + } + current_cycle.save(update_fields=["progress_snapshot"]) + if ( new_cycle.end_date is not None and new_cycle.end_date < timezone.now().date() diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 34bce8a0a9..c8845150a5 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -50,6 +50,7 @@ from plane.app.serializers import ( CommentReactionSerializer, IssueRelationSerializer, RelatedIssueSerializer, + IssueDetailSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -267,7 +268,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): def retrieve(self, request, slug, project_id, pk=None): issue = self.get_queryset().filter(pk=pk).first() return Response( - IssueSerializer( + IssueDetailSerializer( issue, fields=self.fields, expand=self.expand ).data, status=status.HTTP_200_OK, diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index de90e43374..8152fb0eee 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign in", medium=medium.upper(), first_time=False, ) @@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView): email=email, user_agent=request.META.get("HTTP_USER_AGENT"), ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", + event_name="Sign up", medium=medium.upper(), first_time=True, ) diff --git a/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py new file mode 100644 index 0000000000..074e20a16b --- /dev/null +++ b/apiserver/plane/db/migrations/0060_cycle_progress_snapshot.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-02-08 09:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0059_auto_20240208_0957'), + ] + + operations = [ + migrations.AddField( + model_name='cycle', + name='progress_snapshot', + field=models.JSONField(default=dict), + ), + ] diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index 5251c68ec9..d802dbc1e0 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel): sort_order = models.FloatField(default=65535) external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) + progress_snapshot = models.JSONField(default=dict) class Meta: verbose_name = "Cycle" diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index f32be504d0..917d08fdf8 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 445f39d697..2d6ef0a6f1 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 b223e722ab..60861878cb 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 4e505cff9c..30f2d15d72 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 @@ -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 diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 4a37818115..6d2cde0ffb 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/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx index 18a50a5a84..97231ea966 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -6,10 +6,16 @@ import { scrollSummary } from "src/utils/editor-summary-utils"; interface ContentBrowserProps { editor: Editor; markings: IMarking[]; + setSidePeekVisible?: (sidePeekState: boolean) => void; } export const ContentBrowser = (props: ContentBrowserProps) => { - const { editor, markings } = props; + const { editor, markings, setSidePeekVisible } = props; + + const handleOnClick = (marking: IMarking) => { + scrollSummary(editor, marking); + if (setSidePeekVisible) setSidePeekVisible(false); + } return (
@@ -18,11 +24,11 @@ export const ContentBrowser = (props: ContentBrowserProps) => { {markings.length !== 0 ? ( markings.map((marking) => marking.level === 1 ? ( - scrollSummary(editor, marking)} heading={marking.text} /> + handleOnClick(marking)} heading={marking.text} /> ) : marking.level === 2 ? ( - scrollSummary(editor, marking)} subHeading={marking.text} /> + handleOnClick(marking)} subHeading={marking.text} /> ) : ( - scrollSummary(editor, marking)} /> + handleOnClick(marking)} /> ) ) ) : ( diff --git a/packages/editor/document-editor/src/ui/components/summary-popover.tsx b/packages/editor/document-editor/src/ui/components/summary-popover.tsx index 12903bb3d8..6ad7cad835 100644 --- a/packages/editor/document-editor/src/ui/components/summary-popover.tsx +++ b/packages/editor/document-editor/src/ui/components/summary-popover.tsx @@ -33,9 +33,8 @@ export const SummaryPopover: React.FC = (props) => {
)} diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index e30c2271bd..e7636eacad 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -31,6 +31,7 @@ export interface ICycle { issue: string; name: string; owned_by: string; + progress_snapshot: TProgressSnapshot; project: string; project_detail: IProjectLite; status: TCycleGroups; @@ -50,6 +51,23 @@ export interface ICycle { issues?: TIssue[]; } +export type TProgressSnapshot = { + backlog_issues: number; + cancelled_issues: number; + completed_estimates: number | null; + completed_issues: number; + distribution?: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; + started_estimates: number | null; + started_issues: number; + total_estimates: number | null; + total_issues: number; + unstarted_issues: number; +}; + export type TAssigneesDistribution = { assignee_id: string | null; avatar: string | null; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index c54943f901..1f4a35dd47 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -221,3 +221,12 @@ export interface IGroupByColumn { export interface IIssueMap { [key: string]: TIssue; } + +export interface IIssueListRow { + id: string; + groupId: string; + type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE"; + name?: string; + icon?: ReactElement | undefined; + payload?: Partial; +} diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index a2b5ebe3db..37aba932a5 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,9 +49,19 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { setIsOpen(true); if (referenceElement) referenceElement.focus(); }; - const closeDropdown = () => setIsOpen(false); + const closeDropdown = () => { + isOpen && onMenuClose && onMenuClose(); + setIsOpen(false); + }; - const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + const selectActiveItem = () => { + const activeItem: HTMLElement | undefined | null = dropdownRef.current?.querySelector( + `[data-headlessui-state="active"] button` + ); + activeItem?.click(); + }; + + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen, selectActiveItem); const handleOnClick = () => { if (closeOnSelect) closeDropdown(); @@ -58,13 +70,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { 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} onClick={handleOnClick} > {({ open }) => ( @@ -111,6 +117,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { if (menuButtonOnClick) menuButtonOnClick(); }} className={customButtonClassName} + tabIndex={customButtonTabIndex} > {customButton} @@ -131,6 +138,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-custom-background-80" } ${buttonClassName}`} + tabIndex={customButtonTabIndex} > @@ -152,6 +160,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); }} + tabIndex={customButtonTabIndex} > {label} {!noChevron && } @@ -169,6 +178,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 06f1c44c0a..930f332b9c 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/form-fields/checkbox.tsx b/packages/ui/src/form-fields/checkbox.tsx new file mode 100644 index 0000000000..09b90b03be --- /dev/null +++ b/packages/ui/src/form-fields/checkbox.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; + +export interface CheckboxProps extends React.InputHTMLAttributes { + intermediate?: boolean; + className?: string; +} + +const Checkbox = React.forwardRef((props, ref) => { + const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props; + + return ( +
+ + + + + + + +
+ ); +}); +Checkbox.displayName = "form-checkbox-field"; + +export { Checkbox }; diff --git a/packages/ui/src/form-fields/index.ts b/packages/ui/src/form-fields/index.ts index 9cac734283..f19adcdc5c 100644 --- a/packages/ui/src/form-fields/index.ts +++ b/packages/ui/src/form-fields/index.ts @@ -1,3 +1,4 @@ export * from "./input"; export * from "./textarea"; export * from "./input-color-picker"; +export * from "./checkbox"; diff --git a/packages/ui/src/hooks/use-dropdown-key-down.tsx b/packages/ui/src/hooks/use-dropdown-key-down.tsx index 1bb861477f..b93a4d551c 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/web/components/account/sign-in-forms/optional-set-password.tsx b/web/components/account/sign-in-forms/optional-set-password.tsx index d7a5952984..1ea5ca7921 100644 --- a/web/components/account/sign-in-forms/optional-set-password.tsx +++ b/web/components/account/sign-in-forms/optional-set-password.tsx @@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // icons import { Eye, EyeOff } from "lucide-react"; +import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker"; type Props = { email: string; @@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // form info @@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC = (props) => { title: "Success!", message: "Password created successfully.", }); + captureEvent(PASSWORD_CREATE_SELECTED, { + state: "SUCCESS", + first_time: false, + }); await handleSignInRedirection(); }) - .catch((err) => + .catch((err) => { + captureEvent(PASSWORD_CREATE_SELECTED, { + state: "FAILED", + first_time: false, + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); - - await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + await handleSignInRedirection().finally(() => { + captureEvent(PASSWORD_CREATE_SKIPPED, { + state: "SUCCESS", + first_time: false, + }); + setIsGoingToWorkspace(false); + }); }; return ( diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index fe20d5b107..98719df63a 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; // components import { ESignInSteps, ForgotPasswordPopover } from "components/account"; // ui @@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; // types import { IPasswordSignInData } from "@plane/types"; +// constants +import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker"; type Props = { email: string; @@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC = observer((props) => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // derived values const isSmtpConfigured = envConfig?.is_smtp_configured; // form info @@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC = observer((props) => { await authService .passwordSignIn(payload) - .then(async () => await onSubmit()) + .then(async () => { + captureEvent(SIGN_IN_WITH_PASSWORD, { + state: "SUCCESS", + first_time: false, + }); + await onSubmit(); + }) .catch((err) => setToastAlert({ type: "error", @@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC = observer((props) => {
)} /> -
+
{isSmtpConfigured ? ( captureEvent(FORGOT_PASSWORD)} href={`/accounts/forgot-password?email=${email}`} className="text-xs font-medium text-custom-primary-100" > diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index c92cd4bd45..62f63caea6 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { LatestFeatureBlock } from "components/common"; @@ -13,6 +13,8 @@ import { OAuthOptions, SignInOptionalSetPasswordForm, } from "components/account"; +// constants +import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker"; export enum ESignInSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignInRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // derived values const isSmtpConfigured = envConfig?.is_smtp_configured; @@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {

Don{"'"}t have an account?{" "} - + captureEvent(NAVIGATE_TO_SIGNUP, {})} + className="text-custom-primary-100 font-medium underline" + > Sign up

diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 6e0ae37452..55dbe86e24 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -7,12 +7,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // toast alert const { setToastAlert } = useToast(); + // store hooks + const { captureEvent } = useEventTracker(); // timer const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); // form info @@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx index db14f0ccb5..b49adabbb5 100644 --- a/web/components/account/sign-up-forms/optional-set-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // constants import { ESignUpSteps } from "components/account"; +import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker"; // icons import { Eye, EyeOff } from "lucide-react"; @@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { // states const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // form info @@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { title: "Success!", message: "Password created successfully.", }); + captureEvent(SETUP_PASSWORD, { + state: "SUCCESS", + first_time: true, + }); await handleSignInRedirection(); }) - .catch((err) => + .catch((err) => { + captureEvent(SETUP_PASSWORD, { + state: "FAILED", + first_time: true, + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleGoToWorkspace = async () => { setIsGoingToWorkspace(true); - - await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); + await handleSignInRedirection().finally(() => { + captureEvent(PASSWORD_CREATE_SKIPPED, { + state: "SUCCESS", + first_time: true, + }); + setIsGoingToWorkspace(false); + }); }; return ( diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx index da9d7d79a9..8eeb5e99f9 100644 --- a/web/components/account/sign-up-forms/root.tsx +++ b/web/components/account/sign-up-forms/root.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication } from "hooks/store"; +import { useApplication, useEventTracker } from "hooks/store"; import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { @@ -12,6 +12,8 @@ import { SignUpUniqueCodeForm, } from "components/account"; import Link from "next/link"; +// constants +import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker"; export enum ESignUpSteps { EMAIL = "EMAIL", @@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => { const { config: { envConfig }, } = useApplication(); + const { captureEvent } = useEventTracker(); // step 1 submit handler- email verification const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE); @@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {

Already using Plane?{" "} - + captureEvent(NAVIGATE_TO_SIGNIN, {})} + className="text-custom-primary-100 font-medium underline" + > Sign in

diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx index 7764b627ed..1b54ef9ebc 100644 --- a/web/components/account/sign-up-forms/unique-code.tsx +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -8,12 +8,15 @@ import { UserService } from "services/user.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // ui import { Button, Input } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; +// constants +import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { const { email, handleEmailClear, onSubmit } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // timer @@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { await authService .magicSignIn(payload) .then(async () => { + captureEvent(CODE_VERIFIED, { + state: "SUCCESS", + }); const currentUser = await userService.currentUser(); - await onSubmit(currentUser.is_password_autoset); }) - .catch((err) => + .catch((err) => { + captureEvent(CODE_VERIFIED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { @@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { title: "Success!", message: "A new unique code has been sent to your email.", }); - reset({ email: formData.email, token: "", diff --git a/web/components/common/breadcrumb-link.tsx b/web/components/common/breadcrumb-link.tsx index aebd7fc02a..e5f1dbce6c 100644 --- a/web/components/common/breadcrumb-link.tsx +++ b/web/components/common/breadcrumb-link.tsx @@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC = (props) => { const { href, label, icon } = props; return ( -
  • +
  • {href ? ( ; + children: ReactNode; + as?: keyof JSX.IntrinsicElements; + classNames?: string; + alwaysRender?: boolean; + placeholderChildren?: ReactNode; + pauseHeightUpdateWhileRendering?: boolean; + changingReference?: any; +}; + +const RenderIfVisible: React.FC = (props) => { + const { + defaultHeight = "300px", + root, + verticalOffset = 50, + horizonatlOffset = 0, + as = "div", + children, + classNames = "", + alwaysRender = false, //render the children even if it is not visble in root + placeholderChildren = null, //placeholder children + pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained + changingReference, //This is to force render when this reference is changed + } = props; + const [shouldVisible, setShouldVisible] = useState(alwaysRender); + const placeholderHeight = useRef(defaultHeight); + const intersectionRef = useRef(null); + + const isVisible = alwaysRender || shouldVisible; + + // Set visibility with intersection observer + useEffect(() => { + if (intersectionRef.current) { + const observer = new IntersectionObserver( + (entries) => { + if (typeof window !== undefined && window.requestIdleCallback) { + window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), { + timeout: 300, + }); + } else { + setShouldVisible(entries[0].isIntersecting); + } + }, + { + root: root?.current, + rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`, + } + ); + observer.observe(intersectionRef.current); + return () => { + if (intersectionRef.current) { + observer.unobserve(intersectionRef.current); + } + }; + } + }, [root?.current, intersectionRef, children, changingReference]); + + //Set height after render + useEffect(() => { + if (intersectionRef.current && isVisible) { + placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; + } + }, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]); + + const child = isVisible ? <>{children} : placeholderChildren; + const style = + isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" }; + const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80"); + + return React.createElement(as, { ref: intersectionRef, style, className }, child); +}; + +export default RenderIfVisible; diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index 366688dacd..07e946c80d 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; // constants import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; //.types import { TCycleGroups } from "@plane/types"; @@ -33,7 +34,7 @@ export const CyclesBoardCard: FC = (props) => { // router const router = useRouter(); // store - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -90,39 +91,55 @@ export const CyclesBoardCard: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setUpdateModal(true); }; const handleDeleteCycle = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Cycles page board layout"); + setTrackElement("Cycles page grid layout"); setDeleteModal(true); }; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index 43a6323ad7..da2654aaf8 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle"; import { EUserWorkspaceRoles } from "constants/workspace"; // types import { TCycleGroups } from "@plane/types"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; type TCyclesListItem = { cycleId: string; @@ -37,7 +38,7 @@ export const CyclesListItem: FC = (props) => { // router const router = useRouter(); // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -63,26 +64,42 @@ export const CyclesListItem: FC = (props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", + removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) + .then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); }); - }); }; const handleEditCycle = (e: MouseEvent) => { @@ -159,9 +176,9 @@ export const CyclesListItem: FC = (props) => { projectId={projectId} /> -
    -
    -
    +
    +
    +
    {isCompleted ? ( @@ -181,7 +198,7 @@ export const CyclesListItem: FC = (props) => {
    - + {cycleDetails.name} @@ -194,7 +211,7 @@ export const CyclesListItem: FC = (props) => { {currentCycle && (
    = (props) => {
    )}
    -
    +
    {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
    -
    +
    {cycleDetails.assignees.length > 0 ? ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 32e067833e..5dc0306ab4 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button } from "@plane/ui"; // types import { ICycle } from "@plane/types"; +// constants +import { CYCLE_DELETED } from "constants/event-tracker"; interface ICycleDelete { cycle: ICycle; @@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC = observer((props) => { message: "Cycle deleted successfully.", }); captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "SUCCESS" }, }); }) .catch(() => { captureCycleEvent({ - eventName: "Cycle deleted", + eventName: CYCLE_DELETED, payload: { ...cycle, state: "FAILED" }, }); }); diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 865cc68a1a..dfe2a878e6 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { ICycle } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -29,7 +29,7 @@ export const CycleForm: React.FC = (props) => { const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; // form data const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, control, watch, @@ -61,7 +61,7 @@ export const CycleForm: React.FC = (props) => { maxDate?.setDate(maxDate.getDate() - 1); return ( -
    + handleFormSubmit(formData,dirtyFields))}>
    {!status && ( diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index 46bc04039d..beb239d87e 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -1,16 +1,30 @@ import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +// hooks +import { useApplication, useCycle } from "hooks/store"; // ui import { Tooltip, ContrastIcon } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; -// types -import { ICycle } from "@plane/types"; -export const CycleGanttBlock = ({ data }: { data: ICycle }) => { +type Props = { + cycleId: string; +}; + +export const CycleGanttBlock: React.FC = observer((props) => { + const { cycleId } = props; + // router const router = useRouter(); - const { workspaceSlug } = router.query; + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getCycleById } = useCycle(); + // derived values + const cycleDetails = getCycleById(cycleId); + + const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); - const cycleStatus = data.status.toLocaleLowerCase(); return (
    { ? "rgb(var(--color-text-200))" : "", }} - onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} >
    -
    {data?.name}
    +
    {cycleDetails?.name}
    - {renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.end_date ?? "")} + {renderFormattedDate(cycleDetails?.start_date ?? "")} to{" "} + {renderFormattedDate(cycleDetails?.end_date ?? "")}
    } position="top-left" > -
    {data?.name}
    +
    {cycleDetails?.name}
    ); -}; +}); -export const CycleGanttSidebarBlock = ({ data }: { data: ICycle }) => { +export const CycleGanttSidebarBlock: React.FC = observer((props) => { + const { cycleId } = props; + // router const router = useRouter(); - const { workspaceSlug } = router.query; + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getCycleById } = useCycle(); + // derived values + const cycleDetails = getCycleById(cycleId); - const cycleStatus = data.status.toLocaleLowerCase(); + const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); return (
    router.push(`/${workspaceSlug}/projects/${data?.project}/cycles/${data?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} > { : "" }`} /> -
    {data?.name}
    +
    {cycleDetails?.name}
    ); -}; +}); diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 797fc9e393..421a73a4a5 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -63,7 +63,7 @@ export const CyclesListGanttChartView: FC = observer((props) => { blocks={cycleIds ? blockFormat(cycleIds.map((c) => getCycleById(c))) : null} blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)} sidebarToRender={(props) => } - blockToRender={(data: ICycle) => } + blockToRender={(data: ICycle) => } enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 7e17e55f1e..e8f19d6a18 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage"; import { CycleForm } from "components/cycles"; // types import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +// constants +import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker"; type CycleModalProps = { isOpen: boolean; @@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: "Cycle created successfully.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC = (props) => { message: err.detail ?? "Error in creating cycle. Please try again.", }); captureCycleEvent({ - eventName: "Cycle created", + eventName: CYCLE_CREATED, payload: { ...payload, state: "FAILED" }, }); }); }; - const handleUpdateCycle = async (cycleId: string, payload: Partial) => { + const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const selectedProjectId = payload.project ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) - .then(() => { + .then((res) => { + const changed_properties = Object.keys(dirtyFields); + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" }, + }); setToastAlert({ type: "success", title: "Success!", @@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }) .catch((err) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { ...payload, state: "FAILED" }, + }); setToastAlert({ type: "error", title: "Error!", @@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { return status; }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { @@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { } if (isDateValid) { - if (data) await handleUpdateCycle(data.id, payload); + if (data) await handleUpdateCycle(data.id, payload, dirtyFields); else { await handleCreateCycle(payload).then(() => { setCycleTab("all"); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 1a7bf9c58b..8335baf06b 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { useForm } from "react-hook-form"; import { Disclosure, Popover, Transition } from "@headlessui/react"; +import isEmpty from "lodash/isEmpty"; // services import { CycleService } from "services/cycle.service"; // hooks @@ -38,6 +39,7 @@ import { import { ICycle } from "@plane/types"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { CYCLE_UPDATED } from "constants/event-tracker"; // fetch-keys import { CYCLE_STATUS } from "constants/cycle"; @@ -66,7 +68,7 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; // store hooks - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureCycleEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { defaultValues, }); - const submitChanges = (data: Partial) => { + const submitChanges = (data: Partial, changedProperty: string) => { if (!workspaceSlug || !projectId || !cycleId) return; - updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data); + updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data) + .then((res) => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...res, + changed_properties: [changedProperty], + element: "Right side-peek", + state: "SUCCESS", + }, + }); + }) + + .catch(() => { + captureCycleEvent({ + eventName: CYCLE_UPDATED, + payload: { + ...data, + element: "Right side-peek", + state: "FAILED", + }, + }); + }); }; const handleCopyText = () => { @@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "start_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValidForExistingCycle) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }); if (isDateValid) { - submitChanges({ - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }); + submitChanges( + { + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), + end_date: renderFormattedPayloadDate(`${watch("end_date")}`), + }, + "end_date" + ); setToastAlert({ type: "success", title: "Success!", @@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`); const progressPercentage = cycleDetails - ? Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) + ? isCompleted + ? Math.round( + (cycleDetails.progress_snapshot.completed_issues / cycleDetails.progress_snapshot.total_issues) * 100 + ) + : Math.round((cycleDetails.completed_issues / cycleDetails.total_issues) * 100) : null; if (!cycleDetails) @@ -317,7 +357,14 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = - cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + isCompleted && !isEmpty(cycleDetails.progress_snapshot) + ? cycleDetails.progress_snapshot.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` + : cycleDetails.total_issues === 0 + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; @@ -403,13 +450,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { <> {renderFormattedDate(startDate) ?? "No date selected"} @@ -458,13 +507,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { <> {renderFormattedDate(endDate) ?? "No date selected"} @@ -564,49 +615,105 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
    - {cycleDetails.distribution?.completion_chart && - cycleDetails.start_date && - cycleDetails.end_date ? ( -
    -
    -
    -
    - - Ideal + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
    +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    -
    - - Current -
    -
    -
    -
    - -
    -
    + )} + ) : ( - "" + <> + {cycleDetails.distribution?.completion_chart && + cycleDetails.start_date && + cycleDetails.end_date && ( +
    +
    +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    +
    + )} + )} - {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( -
    - -
    + {/* stats */} + {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? ( + <> + {cycleDetails.progress_snapshot.total_issues > 0 && + cycleDetails.progress_snapshot.distribution && ( +
    + +
    + )} + + ) : ( + <> + {cycleDetails.total_issues > 0 && cycleDetails.distribution && ( +
    + +
    + )} + )}
    diff --git a/web/components/dropdowns/cycle.tsx b/web/components/dropdowns/cycle.tsx index d6d4da432e..e3aa6df11d 100644 --- a/web/components/dropdowns/cycle.tsx +++ b/web/components/dropdowns/cycle.tsx @@ -23,6 +23,7 @@ type Props = TDropdownProps & { dropdownArrow?: boolean; dropdownArrowClassName?: string; onChange: (val: string | null) => void; + onClose?: () => void; projectId: string; value: string | null; }; @@ -47,6 +48,7 @@ export const CycleDropdown: React.FC = observer((props) => { dropdownArrowClassName = "", hideIcon = false, onChange, + onClose, placeholder = "Cycle", placement, projectId, @@ -123,8 +125,10 @@ export const CycleDropdown: React.FC = observer((props) => { }; const handleClose = () => { - if (isOpen) setIsOpen(false); + if (!isOpen) return; + setIsOpen(false); if (referenceElement) referenceElement.blur(); + onClose && onClose(); }; const toggleDropdown = () => { @@ -163,7 +167,7 @@ export const CycleDropdown: React.FC = observer((props) => { + + +
    + ); +}; diff --git a/web/components/gantt-chart/chart/index.ts b/web/components/gantt-chart/chart/index.ts new file mode 100644 index 0000000000..68b20b89a1 --- /dev/null +++ b/web/components/gantt-chart/chart/index.ts @@ -0,0 +1,4 @@ +export * from "./views"; +export * from "./header"; +export * from "./main-content"; +export * from "./root"; diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx deleted file mode 100644 index 4592bfb5b1..0000000000 --- a/web/components/gantt-chart/chart/index.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import { FC, useEffect, useState } from "react"; -// icons -// components -import { GanttChartBlocks } from "components/gantt-chart"; -// import { GanttSidebar } from "../sidebar"; -// import { HourChartView } from "./hours"; -// import { DayChartView } from "./day"; -// import { WeekChartView } from "./week"; -// import { BiWeekChartView } from "./bi-week"; -import { MonthChartView } from "./month"; -// import { QuarterChartView } from "./quarter"; -// import { YearChartView } from "./year"; -// icons -import { Expand, Shrink } from "lucide-react"; -// views -import { - // generateHourChart, - // generateDayChart, - // generateWeekChart, - // generateBiWeekChart, - generateMonthChart, - // generateQuarterChart, - // generateYearChart, - getNumberOfDaysBetweenTwoDatesInMonth, - // getNumberOfDaysBetweenTwoDatesInQuarter, - // getNumberOfDaysBetweenTwoDatesInYear, - getMonthChartItemPositionWidthInMonth, -} from "../views"; -// types -import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; -// data -import { currentViewDataWithView } from "../data"; -// context -import { useChart } from "../hooks"; - -type ChartViewRootProps = { - border: boolean; - title: string; - loaderTitle: string; - blocks: IGanttBlock[] | null; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blockToRender: (data: any) => React.ReactNode; - sidebarToRender: (props: any) => React.ReactNode; - enableBlockLeftResize: boolean; - enableBlockRightResize: boolean; - enableBlockMove: boolean; - enableReorder: boolean; - bottomSpacing: boolean; - showAllBlocks: boolean; -}; - -export const ChartViewRoot: FC = (props) => { - const { - border, - title, - blocks = null, - loaderTitle, - blockUpdateHandler, - sidebarToRender, - blockToRender, - enableBlockLeftResize, - enableBlockRightResize, - enableBlockMove, - enableReorder, - bottomSpacing, - showAllBlocks, - } = props; - // states - const [itemsContainerWidth, setItemsContainerWidth] = useState(0); - const [fullScreenMode, setFullScreenMode] = useState(false); - const [chartBlocks, setChartBlocks] = useState(null); // blocks state management starts - // hooks - const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart(); - - const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => - blocks && blocks.length > 0 - ? blocks.map((block: any) => ({ - ...block, - position: getMonthChartItemPositionWidthInMonth(view, block), - })) - : []; - - useEffect(() => { - if (currentViewData && blocks) setChartBlocks(() => renderBlockStructure(currentViewData, blocks)); - }, [currentViewData, blocks]); - - // blocks state management ends - - const handleChartView = (key: TGanttViews) => updateCurrentViewRenderPayload(null, key); - - const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => { - const selectedCurrentView: TGanttViews = view; - const selectedCurrentViewData: ChartDataType | undefined = - selectedCurrentView && selectedCurrentView === currentViewData?.key - ? currentViewData - : currentViewDataWithView(view); - - if (selectedCurrentViewData === undefined) return; - - let currentRender: any; - - // if (view === "hours") currentRender = generateHourChart(selectedCurrentViewData, side); - // if (view === "day") currentRender = generateDayChart(selectedCurrentViewData, side); - // if (view === "week") currentRender = generateWeekChart(selectedCurrentViewData, side); - // if (view === "bi_week") currentRender = generateBiWeekChart(selectedCurrentViewData, side); - if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side); - // if (view === "quarter") currentRender = generateQuarterChart(selectedCurrentViewData, side); - // if (selectedCurrentView === "year") - // currentRender = generateYearChart(selectedCurrentViewData, side); - - // updating the prevData, currentData and nextData - if (currentRender.payload.length > 0) { - if (side === "left") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: selectedCurrentView, - currentViewData: currentRender.state, - renderView: [...currentRender.payload, ...renderView], - }, - }); - updatingCurrentLeftScrollPosition(currentRender.scrollWidth); - setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); - } else if (side === "right") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...renderView, ...currentRender.payload], - }, - }); - setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); - } else { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...currentRender.payload], - }, - }); - setItemsContainerWidth(currentRender.scrollWidth); - setTimeout(() => { - handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate); - }, 50); - } - } - }; - - const handleToday = () => updateCurrentViewRenderPayload(null, currentView); - - // handling the scroll positioning from left and right - useEffect(() => { - handleToday(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const updatingCurrentLeftScrollPosition = (width: number) => { - const scrollContainer = document.getElementById("scroll-container") as HTMLElement; - - if (!scrollContainer) return; - - scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft; - setItemsContainerWidth(width + scrollContainer?.scrollLeft); - }; - - const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => { - const scrollContainer = document.getElementById("scroll-container") as HTMLElement; - - if (!scrollContainer) return; - - const clientVisibleWidth: number = scrollContainer?.clientWidth; - let scrollWidth: number = 0; - let daysDifference: number = 0; - - // if (currentView === "hours") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "day") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "week") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "bi_week") - // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - if (currentView === "month") - daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); - // if (currentView === "quarter") - // daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date); - // if (currentView === "year") - // daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date); - - scrollWidth = daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width); - - scrollContainer.scrollLeft = scrollWidth; - }; - - // handling scroll functionality - const onScroll = () => { - const scrollContainer = document.getElementById("scroll-container") as HTMLElement; - - if (!scrollContainer) return; - - const scrollWidth: number = scrollContainer?.scrollWidth; - const clientVisibleWidth: number = scrollContainer?.clientWidth; - const currentScrollPosition: number = scrollContainer?.scrollLeft; - - updateScrollLeft(currentScrollPosition); - - const approxRangeLeft: number = scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth; - const approxRangeRight: number = scrollWidth - (approxRangeLeft + clientVisibleWidth); - - if (currentScrollPosition >= approxRangeRight) updateCurrentViewRenderPayload("right", currentView); - if (currentScrollPosition <= approxRangeLeft) updateCurrentViewRenderPayload("left", currentView); - }; - - return ( -
    - {/* chart header */} -
    - {title && ( -
    -
    {title}
    - {/*
    - Gantt View Beta -
    */} -
    - )} - -
    - {blocks === null ? ( -
    Loading...
    - ) : ( -
    - {blocks.length} {loaderTitle} -
    - )} -
    - -
    - {allViews && - allViews.length > 0 && - // eslint-disable-next-line @typescript-eslint/no-unused-vars - allViews.map((_chatView: any, _idx: any) => ( -
    handleChartView(_chatView?.key)} - > - {_chatView?.title} -
    - ))} -
    - -
    -
    - Today -
    -
    - -
    setFullScreenMode((prevData) => !prevData)} - > - {fullScreenMode ? : } -
    -
    - - {/* content */} -
    -
    -
    -
    {title}
    -
    Duration
    -
    - - {sidebarToRender && sidebarToRender({ title, blockUpdateHandler, blocks, enableReorder })} -
    -
    - {/* {currentView && currentView === "hours" && } */} - {/* {currentView && currentView === "day" && } */} - {/* {currentView && currentView === "week" && } */} - {/* {currentView && currentView === "bi_week" && } */} - {currentView && currentView === "month" && } - {/* {currentView && currentView === "quarter" && } */} - {/* {currentView && currentView === "year" && } */} - - {/* blocks */} - {currentView && currentViewData && ( - - )} -
    -
    -
    - ); -}; diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx new file mode 100644 index 0000000000..35bd6bc07f --- /dev/null +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -0,0 +1,123 @@ +// components +import { + BiWeekChartView, + DayChartView, + GanttChartBlocksList, + GanttChartSidebar, + HourChartView, + IBlockUpdateData, + IGanttBlock, + MonthChartView, + QuarterChartView, + TGanttViews, + WeekChartView, + YearChartView, + useChart, +} from "components/gantt-chart"; +// helpers +import { cn } from "helpers/common.helper"; + +type Props = { + blocks: IGanttBlock[] | null; + blockToRender: (data: any) => React.ReactNode; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + bottomSpacing: boolean; + chartBlocks: IGanttBlock[] | null; + enableBlockLeftResize: boolean; + enableBlockMove: boolean; + enableBlockRightResize: boolean; + enableReorder: boolean; + enableAddBlock: boolean; + itemsContainerWidth: number; + showAllBlocks: boolean; + sidebarToRender: (props: any) => React.ReactNode; + title: string; + updateCurrentViewRenderPayload: (direction: "left" | "right", currentView: TGanttViews) => void; +}; + +export const GanttChartMainContent: React.FC = (props) => { + const { + blocks, + blockToRender, + blockUpdateHandler, + bottomSpacing, + chartBlocks, + enableBlockLeftResize, + enableBlockMove, + enableBlockRightResize, + enableReorder, + enableAddBlock, + itemsContainerWidth, + showAllBlocks, + sidebarToRender, + title, + updateCurrentViewRenderPayload, + } = props; + // chart hook + const { currentView, currentViewData, updateScrollLeft } = useChart(); + // handling scroll functionality + const onScroll = (e: React.UIEvent) => { + const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget; + + updateScrollLeft(scrollLeft); + + const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth; + const approxRangeRight = scrollWidth - (scrollLeft + clientWidth); + + if (approxRangeRight < 1000) updateCurrentViewRenderPayload("right", currentView); + if (approxRangeLeft < 1000) updateCurrentViewRenderPayload("left", currentView); + }; + + const CHART_VIEW_COMPONENTS: { + [key in TGanttViews]: React.FC; + } = { + hours: HourChartView, + day: DayChartView, + week: WeekChartView, + bi_week: BiWeekChartView, + month: MonthChartView, + quarter: QuarterChartView, + year: YearChartView, + }; + + if (!currentView) return null; + const ActiveChartView = CHART_VIEW_COMPONENTS[currentView]; + + return ( +
    + +
    + + {currentViewData && ( + + )} +
    +
    + ); +}; diff --git a/web/components/gantt-chart/chart/month.tsx b/web/components/gantt-chart/chart/month.tsx deleted file mode 100644 index 0b7a4c452d..0000000000 --- a/web/components/gantt-chart/chart/month.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { FC } from "react"; -// hooks -import { useChart } from "../hooks"; -// types -import { IMonthBlock } from "../views"; - -export const MonthChartView: FC = () => { - const { currentViewData, renderView } = useChart(); - - const monthBlocks: IMonthBlock[] = renderView; - - return ( - <> -
    - {monthBlocks && - monthBlocks.length > 0 && - monthBlocks.map((block, _idxRoot) => ( -
    -
    -
    -
    - {block?.title} -
    -
    - -
    - {block?.children && - block?.children.length > 0 && - block?.children.map((monthDay, _idx) => ( -
    -
    - {monthDay.dayData.shortTitle[0]}{" "} - - {monthDay.day} - -
    -
    - ))} -
    -
    - -
    - {block?.children && - block?.children.length > 0 && - block?.children.map((monthDay, _idx) => ( -
    -
    - {/* {monthDay?.today && ( -
    - )} */} -
    -
    - ))} -
    -
    - ))} -
    - - ); -}; diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx new file mode 100644 index 0000000000..4cb6bc10e6 --- /dev/null +++ b/web/components/gantt-chart/chart/root.tsx @@ -0,0 +1,206 @@ +import { FC, useEffect, useState } from "react"; +// components +import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart"; +// views +import { + generateMonthChart, + getNumberOfDaysBetweenTwoDatesInMonth, + getMonthChartItemPositionWidthInMonth, +} from "../views"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; +// data +import { currentViewDataWithView } from "../data"; +// constants +import { SIDEBAR_WIDTH } from "../constants"; + +type ChartViewRootProps = { + border: boolean; + title: string; + loaderTitle: string; + blocks: IGanttBlock[] | null; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blockToRender: (data: any) => React.ReactNode; + sidebarToRender: (props: any) => React.ReactNode; + enableBlockLeftResize: boolean; + enableBlockRightResize: boolean; + enableBlockMove: boolean; + enableReorder: boolean; + enableAddBlock: boolean; + bottomSpacing: boolean; + showAllBlocks: boolean; +}; + +export const ChartViewRoot: FC = (props) => { + const { + border, + title, + blocks = null, + loaderTitle, + blockUpdateHandler, + sidebarToRender, + blockToRender, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + enableReorder, + enableAddBlock, + bottomSpacing, + showAllBlocks, + } = props; + // states + const [itemsContainerWidth, setItemsContainerWidth] = useState(0); + const [fullScreenMode, setFullScreenMode] = useState(false); + const [chartBlocks, setChartBlocks] = useState(null); + // hooks + const { currentView, currentViewData, renderView, dispatch } = useChart(); + + // rendering the block structure + const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => + blocks + ? blocks.map((block: any) => ({ + ...block, + position: getMonthChartItemPositionWidthInMonth(view, block), + })) + : []; + + useEffect(() => { + if (!currentViewData || !blocks) return; + setChartBlocks(() => renderBlockStructure(currentViewData, blocks)); + }, [currentViewData, blocks]); + + const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => { + const selectedCurrentView: TGanttViews = view; + const selectedCurrentViewData: ChartDataType | undefined = + selectedCurrentView && selectedCurrentView === currentViewData?.key + ? currentViewData + : currentViewDataWithView(view); + + if (selectedCurrentViewData === undefined) return; + + let currentRender: any; + if (selectedCurrentView === "month") currentRender = generateMonthChart(selectedCurrentViewData, side); + + // updating the prevData, currentData and nextData + if (currentRender.payload.length > 0) { + if (side === "left") { + dispatch({ + type: "PARTIAL_UPDATE", + payload: { + currentView: selectedCurrentView, + currentViewData: currentRender.state, + renderView: [...currentRender.payload, ...renderView], + }, + }); + updatingCurrentLeftScrollPosition(currentRender.scrollWidth); + setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); + } else if (side === "right") { + dispatch({ + type: "PARTIAL_UPDATE", + payload: { + currentView: view, + currentViewData: currentRender.state, + renderView: [...renderView, ...currentRender.payload], + }, + }); + setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); + } else { + dispatch({ + type: "PARTIAL_UPDATE", + payload: { + currentView: view, + currentViewData: currentRender.state, + renderView: [...currentRender.payload], + }, + }); + setItemsContainerWidth(currentRender.scrollWidth); + setTimeout(() => { + handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate); + }, 50); + } + } + }; + + const handleToday = () => updateCurrentViewRenderPayload(null, currentView); + + // handling the scroll positioning from left and right + useEffect(() => { + handleToday(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updatingCurrentLeftScrollPosition = (width: number) => { + const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; + if (!scrollContainer) return; + + scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft; + setItemsContainerWidth(width + scrollContainer?.scrollLeft); + }; + + const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => { + const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; + if (!scrollContainer) return; + + const clientVisibleWidth: number = scrollContainer?.clientWidth; + let scrollWidth: number = 0; + let daysDifference: number = 0; + + // if (currentView === "hours") + // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); + // if (currentView === "day") + // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); + // if (currentView === "week") + // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); + // if (currentView === "bi_week") + // daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); + if (currentView === "month") + daysDifference = getNumberOfDaysBetweenTwoDatesInMonth(currentState.data.startDate, date); + // if (currentView === "quarter") + // daysDifference = getNumberOfDaysBetweenTwoDatesInQuarter(currentState.data.startDate, date); + // if (currentView === "year") + // daysDifference = getNumberOfDaysBetweenTwoDatesInYear(currentState.data.startDate, date); + + scrollWidth = + daysDifference * currentState.data.width - (clientVisibleWidth / 2 - currentState.data.width) + SIDEBAR_WIDTH / 2; + + scrollContainer.scrollLeft = scrollWidth; + }; + + return ( +
    + setFullScreenMode((prevData) => !prevData)} + handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} + handleToday={handleToday} + loaderTitle={loaderTitle} + title={title} + /> + +
    + ); +}; diff --git a/web/components/gantt-chart/chart/bi-week.tsx b/web/components/gantt-chart/chart/views/bi-week.tsx similarity index 97% rename from web/components/gantt-chart/chart/bi-week.tsx rename to web/components/gantt-chart/chart/views/bi-week.tsx index f4a6080cd4..6e53d5390c 100644 --- a/web/components/gantt-chart/chart/bi-week.tsx +++ b/web/components/gantt-chart/chart/views/bi-week.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "components/gantt-chart"; export const BiWeekChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/chart/day.tsx b/web/components/gantt-chart/chart/views/day.tsx similarity index 98% rename from web/components/gantt-chart/chart/day.tsx rename to web/components/gantt-chart/chart/views/day.tsx index 32b3caca0f..a50b7748ad 100644 --- a/web/components/gantt-chart/chart/day.tsx +++ b/web/components/gantt-chart/chart/views/day.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "../../hooks"; export const DayChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/chart/hours.tsx b/web/components/gantt-chart/chart/views/hours.tsx similarity index 97% rename from web/components/gantt-chart/chart/hours.tsx rename to web/components/gantt-chart/chart/views/hours.tsx index 5693b38b8e..e1fd02e3f9 100644 --- a/web/components/gantt-chart/chart/hours.tsx +++ b/web/components/gantt-chart/chart/views/hours.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "components/gantt-chart"; export const HourChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/chart/views/index.ts b/web/components/gantt-chart/chart/views/index.ts new file mode 100644 index 0000000000..8936623c2d --- /dev/null +++ b/web/components/gantt-chart/chart/views/index.ts @@ -0,0 +1,7 @@ +export * from "./bi-week"; +export * from "./day"; +export * from "./hours"; +export * from "./month"; +export * from "./quarter"; +export * from "./week"; +export * from "./year"; diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx new file mode 100644 index 0000000000..c559e96885 --- /dev/null +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -0,0 +1,74 @@ +import { FC } from "react"; +// hooks +import { useChart } from "components/gantt-chart"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { IMonthBlock } from "../../views"; +// constants +import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants"; + +export const MonthChartView: FC = () => { + // chart hook + const { currentViewData, renderView } = useChart(); + const monthBlocks: IMonthBlock[] = renderView; + + return ( +
    + {monthBlocks?.map((block, rootIndex) => ( +
    +
    +
    +
    + {block?.title} +
    +
    +
    + {block?.children?.map((monthDay, index) => ( +
    +
    + {monthDay.dayData.shortTitle[0]}{" "} + + {monthDay.day} + +
    +
    + ))} +
    +
    +
    + {block?.children?.map((monthDay, index) => ( +
    + {["sat", "sun"].includes(monthDay?.dayData?.shortTitle) && ( +
    + )} +
    + ))} +
    +
    + ))} +
    + ); +}; diff --git a/web/components/gantt-chart/chart/quarter.tsx b/web/components/gantt-chart/chart/views/quarter.tsx similarity index 98% rename from web/components/gantt-chart/chart/quarter.tsx rename to web/components/gantt-chart/chart/views/quarter.tsx index a15f6f34d1..ffbc1cbfe8 100644 --- a/web/components/gantt-chart/chart/quarter.tsx +++ b/web/components/gantt-chart/chart/views/quarter.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "../../hooks"; export const QuarterChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/chart/week.tsx b/web/components/gantt-chart/chart/views/week.tsx similarity index 98% rename from web/components/gantt-chart/chart/week.tsx rename to web/components/gantt-chart/chart/views/week.tsx index b90caf8b7f..8170affa46 100644 --- a/web/components/gantt-chart/chart/week.tsx +++ b/web/components/gantt-chart/chart/views/week.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "../../hooks"; export const WeekChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/chart/year.tsx b/web/components/gantt-chart/chart/views/year.tsx similarity index 98% rename from web/components/gantt-chart/chart/year.tsx rename to web/components/gantt-chart/chart/views/year.tsx index 7c3a34b53d..9dbeedecef 100644 --- a/web/components/gantt-chart/chart/year.tsx +++ b/web/components/gantt-chart/chart/views/year.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; // context -import { useChart } from "../hooks"; +import { useChart } from "../../hooks"; export const YearChartView: FC = () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/web/components/gantt-chart/constants.ts b/web/components/gantt-chart/constants.ts new file mode 100644 index 0000000000..958985cf16 --- /dev/null +++ b/web/components/gantt-chart/constants.ts @@ -0,0 +1,5 @@ +export const BLOCK_HEIGHT = 44; + +export const HEADER_HEIGHT = 60; + +export const SIDEBAR_WIDTH = 360; diff --git a/web/components/gantt-chart/contexts/index.tsx b/web/components/gantt-chart/contexts/index.tsx index 137cc2607c..84e7a19b5e 100644 --- a/web/components/gantt-chart/contexts/index.tsx +++ b/web/components/gantt-chart/contexts/index.tsx @@ -24,6 +24,7 @@ const chartReducer = (state: ChartContextData, action: ChartContextActionPayload const initialView = "month"; export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // states; const [state, dispatch] = useState({ currentView: initialView, currentViewData: currentViewDataWithView(initialView), @@ -31,23 +32,25 @@ export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ allViews: allViewsWithData, activeBlock: null, }); - const [scrollLeft, setScrollLeft] = useState(0); const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { const newState = chartReducer(state, action); - dispatch(() => newState); - return newState; }; - const updateScrollLeft = (scrollLeft: number) => { - setScrollLeft(scrollLeft); - }; + const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft); return ( - + {children} ); diff --git a/web/components/gantt-chart/helpers/block-structure.tsx b/web/components/gantt-chart/helpers/block-structure.ts similarity index 100% rename from web/components/gantt-chart/helpers/block-structure.tsx rename to web/components/gantt-chart/helpers/block-structure.ts diff --git a/web/components/gantt-chart/helpers/draggable.tsx b/web/components/gantt-chart/helpers/draggable.tsx index d2c4448bbc..ac1602346f 100644 --- a/web/components/gantt-chart/helpers/draggable.tsx +++ b/web/components/gantt-chart/helpers/draggable.tsx @@ -1,9 +1,11 @@ import React, { useEffect, useRef, useState } from "react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; +import { ArrowRight } from "lucide-react"; // hooks -import { useChart } from "../hooks"; -// types -import { IGanttBlock } from "../types"; +import { IGanttBlock, useChart } from "components/gantt-chart"; +// helpers +import { cn } from "helpers/common.helper"; +// constants +import { SIDEBAR_WIDTH } from "../constants"; type Props = { block: IGanttBlock; @@ -20,7 +22,7 @@ export const ChartDraggable: React.FC = (props) => { const [isLeftResizing, setIsLeftResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false); const [isMoving, setIsMoving] = useState(false); - const [posFromLeft, setPosFromLeft] = useState(null); + const [isHidden, setIsHidden] = useState(true); // refs const resizableRef = useRef(null); // chart hook @@ -31,12 +33,10 @@ export const ChartDraggable: React.FC = (props) => { let delWidth = 0; - const ganttContainer = document.querySelector("#gantt-container") as HTMLElement; - const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLElement; + const ganttContainer = document.querySelector("#gantt-container") as HTMLDivElement; + const ganttSidebar = document.querySelector("#gantt-sidebar") as HTMLDivElement; - const scrollContainer = document.querySelector("#scroll-container") as HTMLElement; - - if (!ganttContainer || !ganttSidebar || !scrollContainer) return 0; + if (!ganttContainer || !ganttSidebar) return 0; const posFromLeft = e.clientX; // manually scroll to left if reached the left end while dragging @@ -45,7 +45,7 @@ export const ChartDraggable: React.FC = (props) => { delWidth = -5; - scrollContainer.scrollBy(delWidth, 0); + ganttContainer.scrollBy(delWidth, 0); } else delWidth = e.movementX; // manually scroll to right if reached the right end while dragging @@ -55,7 +55,7 @@ export const ChartDraggable: React.FC = (props) => { delWidth = 5; - scrollContainer.scrollBy(delWidth, 0); + ganttContainer.scrollBy(delWidth, 0); } else delWidth = e.movementX; return delWidth; @@ -201,50 +201,61 @@ export const ChartDraggable: React.FC = (props) => { }; // scroll to a hidden block const handleScrollToBlock = () => { - const scrollContainer = document.querySelector("#scroll-container") as HTMLElement; - + const scrollContainer = document.querySelector("#gantt-container") as HTMLDivElement; if (!scrollContainer || !block.position) return; - // update container's scroll position to the block's position scrollContainer.scrollLeft = block.position.marginLeft - 4; }; - // update block position from viewport's left end on scroll - useEffect(() => { - const block = resizableRef.current; - - if (!block) return; - - setPosFromLeft(block.getBoundingClientRect().left); - }, [scrollLeft]); // check if block is hidden on either side const isBlockHiddenOnLeft = block.position?.marginLeft && block.position?.width && scrollLeft > block.position.marginLeft + block.position.width; - const isBlockHiddenOnRight = posFromLeft && window && posFromLeft > window.innerWidth; + + useEffect(() => { + const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; + const resizableBlock = resizableRef.current; + if (!resizableBlock || !intersectionRoot) return; + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + setIsHidden(!entry.isIntersecting); + }); + }, + { + root: intersectionRoot, + rootMargin: `0px 0px 0px -${SIDEBAR_WIDTH}px`, + } + ); + + observer.observe(resizableBlock); + + return () => { + observer.unobserve(resizableBlock); + }; + }, [block.data.name]); return ( <> - {/* move to left side hidden block button */} - {isBlockHiddenOnLeft && ( - - )} - {/* move to right side hidden block button */} - {isBlockHiddenOnRight && ( -
    - -
    + + )}
    = (props) => { onMouseDown={handleBlockLeftResize} onMouseEnter={() => setIsLeftResizing(true)} onMouseLeave={() => setIsLeftResizing(false)} - className="absolute -left-2.5 top-1/2 z-[3] h-full w-6 -translate-y-1/2 cursor-col-resize rounded-md" + className="absolute -left-2.5 top-1/2 -translate-y-1/2 z-[3] h-full w-6 cursor-col-resize rounded-md" />
    )}
    {blockToRender(block.data)} @@ -281,12 +297,15 @@ export const ChartDraggable: React.FC = (props) => { onMouseDown={handleBlockRightResize} onMouseEnter={() => setIsRightResizing(true)} onMouseLeave={() => setIsRightResizing(false)} - className="absolute -right-2.5 top-1/2 z-[2] h-full w-6 -translate-y-1/2 cursor-col-resize rounded-md" + className="absolute -right-2.5 top-1/2 -translate-y-1/2 z-[2] h-full w-6 cursor-col-resize rounded-md" />
    )} diff --git a/web/components/gantt-chart/index.ts b/web/components/gantt-chart/index.ts index ead6960868..54a2cc597a 100644 --- a/web/components/gantt-chart/index.ts +++ b/web/components/gantt-chart/index.ts @@ -1,4 +1,5 @@ export * from "./blocks"; +export * from "./chart"; export * from "./helpers"; export * from "./hooks"; export * from "./root"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index 7673da88e7..2e9a8aca18 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -1,10 +1,8 @@ import { FC } from "react"; // components -import { ChartViewRoot } from "./chart"; +import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; // context import { ChartContextProvider } from "./contexts"; -// types -import { IBlockUpdateData, IGanttBlock } from "./types"; type GanttChartRootProps = { border?: boolean; @@ -18,6 +16,7 @@ type GanttChartRootProps = { enableBlockRightResize?: boolean; enableBlockMove?: boolean; enableReorder?: boolean; + enableAddBlock?: boolean; bottomSpacing?: boolean; showAllBlocks?: boolean; }; @@ -31,10 +30,11 @@ export const GanttChartRoot: FC = (props) => { blockUpdateHandler, sidebarToRender, blockToRender, - enableBlockLeftResize = true, - enableBlockRightResize = true, - enableBlockMove = true, - enableReorder = true, + enableBlockLeftResize = false, + enableBlockRightResize = false, + enableBlockMove = false, + enableReorder = false, + enableAddBlock = false, bottomSpacing = false, showAllBlocks = false, } = props; @@ -53,6 +53,7 @@ export const GanttChartRoot: FC = (props) => { enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} enableReorder={enableReorder} + enableAddBlock={enableAddBlock} bottomSpacing={bottomSpacing} showAllBlocks={showAllBlocks} /> diff --git a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx b/web/components/gantt-chart/sidebar/cycles.tsx similarity index 85% rename from web/components/gantt-chart/sidebar/cycle-sidebar.tsx rename to web/components/gantt-chart/sidebar/cycles.tsx index dddccda5a7..384869a407 100644 --- a/web/components/gantt-chart/sidebar/cycle-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/cycles.tsx @@ -1,4 +1,3 @@ -import { useRouter } from "next/router"; import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks @@ -9,8 +8,11 @@ import { Loader } from "@plane/ui"; import { CycleGanttSidebarBlock } from "components/cycles"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; // types import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; +// constants +import { BLOCK_HEIGHT } from "../constants"; type Props = { title: string; @@ -20,12 +22,8 @@ type Props = { }; export const CycleGanttSidebar: React.FC = (props) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, blockUpdateHandler, blocks, enableReorder } = props; - - const router = useRouter(); - const { cycleId } = router.query; - + const { blockUpdateHandler, blocks, enableReorder } = props; + // chart hook const { activeBlock, dispatch } = useChart(); // update the active block on hover @@ -84,12 +82,7 @@ export const CycleGanttSidebar: React.FC = (props) => { {(droppableProvided) => ( -
    +
    <> {blocks ? ( blocks.map((block, index) => { @@ -104,7 +97,9 @@ export const CycleGanttSidebar: React.FC = (props) => { > {(provided, snapshot) => (
    updateActiveBlock(block)} onMouseLeave={() => updateActiveBlock(null)} ref={provided.innerRef} @@ -112,9 +107,12 @@ export const CycleGanttSidebar: React.FC = (props) => { >
    {enableReorder && ( + )} +
    +
    + +
    + {duration && ( +
    + + {duration} day{duration > 1 ? "s" : ""} + +
    + )} +
    +
    +
    + )} + + ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
    + )} + + + {enableQuickIssueCreate && !disableIssueCreation && ( + + )} + + ); +}); diff --git a/web/components/gantt-chart/sidebar/module-sidebar.tsx b/web/components/gantt-chart/sidebar/modules.tsx similarity index 86% rename from web/components/gantt-chart/sidebar/module-sidebar.tsx rename to web/components/gantt-chart/sidebar/modules.tsx index 8f8788787c..bdf8ca571e 100644 --- a/web/components/gantt-chart/sidebar/module-sidebar.tsx +++ b/web/components/gantt-chart/sidebar/modules.tsx @@ -1,4 +1,3 @@ -import { useRouter } from "next/router"; import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; import { MoreVertical } from "lucide-react"; // hooks @@ -9,8 +8,11 @@ import { Loader } from "@plane/ui"; import { ModuleGanttSidebarBlock } from "components/modules"; // helpers import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { cn } from "helpers/common.helper"; // types import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; +// constants +import { BLOCK_HEIGHT } from "../constants"; type Props = { title: string; @@ -20,12 +22,8 @@ type Props = { }; export const ModuleGanttSidebar: React.FC = (props) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { title, blockUpdateHandler, blocks, enableReorder } = props; - - const router = useRouter(); - const { cycleId } = router.query; - + const { blockUpdateHandler, blocks, enableReorder } = props; + // chart hook const { activeBlock, dispatch } = useChart(); // update the active block on hover @@ -84,12 +82,7 @@ export const ModuleGanttSidebar: React.FC = (props) => { {(droppableProvided) => ( -
    +
    <> {blocks ? ( blocks.map((block, index) => { @@ -104,7 +97,9 @@ export const ModuleGanttSidebar: React.FC = (props) => { > {(provided, snapshot) => (
    updateActiveBlock(block)} onMouseLeave={() => updateActiveBlock(null)} ref={provided.innerRef} @@ -112,9 +107,12 @@ export const ModuleGanttSidebar: React.FC = (props) => { >
    {enableReorder && ( - )} -
    -
    - -
    - {duration !== undefined && ( -
    - - {duration} day{duration > 1 ? "s" : ""} - -
    - )} -
    -
    -
    - )} - - ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - - {enableQuickIssueCreate && !disableIssueCreation && ( - - )} -
    - )} - - - ); -}; diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 28116b3236..1984971d6e 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, useProject, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers @@ -25,6 +25,7 @@ export const PagesHeader = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { setTrackElement } = useEventTracker(); const canUserCreatePage = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
    {canUserCreatePage && (
    -
    diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index d8306ab40e..6b85577f67 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -4,13 +4,18 @@ import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; +// hooks +import { useEventTracker } from "hooks/store"; // components import { BreadcrumbLink } from "components/common"; import { Breadcrumbs } from "@plane/ui"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// constants +import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker"; export const WorkspaceDashboardHeader = () => { // hooks + const { captureEvent } = useEventTracker(); const { resolvedTheme } = useTheme(); return ( @@ -31,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
    diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 82253af88b..998ad268c5 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle // types import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; import { EUserProjectRoles } from "constants/project"; +import { ISSUE_DELETED } from "constants/event-tracker"; type TInboxIssueActionsHeader = { workspaceSlug: string; @@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC = observer((p throw new Error("Missing required parameters"); await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, + } }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, @@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC = observer((p message: "Something went wrong while deleting inbox issue. Please try again.", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: inboxIssueId, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }, diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index 066f172ca1..84c4bef1ea 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core"; import { Button, Input, ToggleSwitch } from "@plane/ui"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { config: { envConfig }, } = useApplication(); const { captureIssueEvent } = useEventTracker(); - const { currentWorkspace } = useWorkspace(); const { control, @@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { handleClose(); } else reset(defaultValues); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "SUCCESS", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }) .catch((error) => { console.error(error); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...formData, state: "FAILED", element: "Inbox page", }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, path: router.pathname, }); }); diff --git a/web/components/issues/attachment/root.tsx b/web/components/issues/attachment/root.tsx index 11d74af0e3..ffa17d3371 100644 --- a/web/components/issues/attachment/root.tsx +++ b/web/components/issues/attachment/root.tsx @@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment uploaded", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment added", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, }); setToastAlert({ @@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC = (props) => { title: "Attachment removed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "attachment", @@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC = (props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: "Issue attachment deleted", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "attachment", diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index ca6d7e0e7b..b7601ef52e 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -4,7 +4,7 @@ import { Controller, useForm } from "react-hook-form"; import useReloadConfirmations from "hooks/use-reload-confirmation"; import debounce from "lodash/debounce"; // components -import { TextArea } from "@plane/ui"; +import { Loader, TextArea } from "@plane/ui"; import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; // types import { TIssue } from "@plane/types"; @@ -12,6 +12,8 @@ import { TIssueOperations } from "./issue-detail"; // services import { FileService } from "services/file.service"; import { useMention, useWorkspace } from "hooks/store"; +import { observer } from "mobx-react"; +import { isNil } from "lodash"; export interface IssueDescriptionFormValues { name: string; @@ -36,7 +38,7 @@ export interface IssueDetailsProps { const fileService = new FileService(); -export const IssueDescriptionForm: FC = (props) => { +export const IssueDescriptionForm: FC = observer((props) => { const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; const workspaceStore = useWorkspace(); const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string; @@ -71,12 +73,20 @@ export const IssueDescriptionForm: FC = (props) => { // editor rerendering on every save useEffect(() => { if (issue.id) { - setLocalIssueDescription({ id: issue.id, description_html: issue.description_html }); setLocalTitleValue(issue.name); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [issue.id]); // TODO: verify the exhaustive-deps warning + useEffect(() => { + if (issue.description_html) { + setLocalIssueDescription((state) => { + if (!isNil(state.description_html)) return state; + return { id: issue.id, description_html: issue.description_html }; + }); + } + }, [issue.description_html]); + const handleDescriptionFormSubmit = useCallback( async (formData: Partial) => { if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; @@ -167,42 +177,48 @@ export const IssueDescriptionForm: FC = (props) => {
    {errors.name ? errors.name.message : null}
    - - !disabled ? ( - { - setShowAlert(true); - setIsSubmitting("submitting"); - onChange(description_html); - debouncedFormSave(); - }} - mentionSuggestions={mentionSuggestions} - mentionHighlights={mentionHighlights} - /> - ) : ( - - ) - } - /> + {issue.description_html ? ( + + !disabled ? ( + { + setShowAlert(true); + setIsSubmitting("submitting"); + onChange(description_html); + debouncedFormSave(); + }} + mentionSuggestions={mentionSuggestions} + mentionHighlights={mentionHighlights} + /> + ) : ( + + ) + } + /> + ) : ( + + + + )}
    ); -}; +}); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 902ba7c252..1fab25d96c 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -16,6 +16,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; import { observer } from "mobx-react"; export type TIssueOperations = { @@ -104,7 +105,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -114,7 +115,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: Object.keys(data).join(","), @@ -140,7 +141,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, path: router.asPath, }); @@ -151,7 +152,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, path: router.asPath, }); @@ -166,7 +167,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -176,7 +177,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -200,7 +201,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -210,7 +211,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "cycle_id", @@ -234,7 +235,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -244,7 +245,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -268,7 +269,7 @@ export const IssueDetailRoot: FC = observer((props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -278,7 +279,7 @@ export const IssueDetailRoot: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, updates: { changed_property: "module_id", @@ -358,7 +359,8 @@ export const IssueDetailRoot: FC = observer((props) => { is_editable={!is_archived && is_editable} />
    -
    = observer((props) => { viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Calendar quick add" }, path: router.asPath, }); @@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { } catch (err: any) { console.error(err); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Calendar quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx index 0dae3c8bd4..c03e865047 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/global-view-root.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import isEqual from "lodash/isEqual"; // hooks -import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; +import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; //ui import { Button } from "@plane/ui"; // components @@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; +// constants +import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker"; type Props = { globalViewId: string; @@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { } = useIssues(EIssuesStoreType.GLOBAL); const { workspaceLabels } = useLabel(); const { globalViewMap, updateGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => { filters: { ...(appliedFilters ?? {}), }, + }).then((res) => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + element: "Spreadsheet view", + }); }); }; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 601205b5c8..95729a103a 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -69,7 +69,7 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan loaderTitle="Issues" blocks={issues ? renderIssueBlocksStructure(issues as TIssue[]) : null} blockUpdateHandler={updateIssueBlockStructure} - blockToRender={(data: TIssue) => } + blockToRender={(data: TIssue) => } sidebarToRender={(props) => ( = observer((props: IBaseGan enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={appliedDisplayFilters?.order_by === "sort_order" && isAllowed} + enableAddBlock={isAllowed} showAllBlocks />
    diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index cf1d5d7009..18a767455c 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -1,33 +1,41 @@ +import { observer } from "mobx-react"; +// hooks +import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; // ui import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; -// types -import { TIssue } from "@plane/types"; -import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; -export const IssueGanttBlock = ({ data }: { data: TIssue }) => { - // hooks +type Props = { + issueId: string; +}; + +export const IssueGanttBlock: React.FC = observer((props) => { + const { issueId } = props; + // store hooks const { router: { workspaceSlug }, } = useApplication(); const { getProjectStates } = useProjectState(); - const { setPeekIssue } = useIssueDetail(); + const { + issue: { getIssueById }, + setPeekIssue, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId); + const stateDetails = + issueDetails && getProjectStates(issueDetails?.project_id)?.find((state) => state?.id == issueDetails?.state_id); const handleIssuePeekOverview = () => workspaceSlug && - data && - data.project_id && - data.id && - setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id }); - - const stateColor = getProjectStates(data?.project_id)?.find((state) => state?.id == data?.state_id)?.color || ""; + issueDetails && + setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); return (
    @@ -35,58 +43,62 @@ export const IssueGanttBlock = ({ data }: { data: TIssue }) => { -
    {data?.name}
    +
    {issueDetails?.name}
    - {renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.target_date ?? "")} + {renderFormattedDate(issueDetails?.start_date ?? "")} to{" "} + {renderFormattedDate(issueDetails?.target_date ?? "")}
    } position="top-left" > -
    {data?.name}
    +
    + {issueDetails?.name} +
    ); -}; +}); // rendering issues on gantt sidebar -export const IssueGanttSidebarBlock = ({ data }: { data: TIssue }) => { - // hooks - const { getProjectStates } = useProjectState(); +export const IssueGanttSidebarBlock: React.FC = observer((props) => { + const { issueId } = props; + // store hooks + const { getStateById } = useProjectState(); const { getProjectById } = useProject(); const { router: { workspaceSlug }, } = useApplication(); - const { setPeekIssue } = useIssueDetail(); + const { + issue: { getIssueById }, + setPeekIssue, + } = useIssueDetail(); + // derived values + const issueDetails = getIssueById(issueId); + const projectDetails = issueDetails && getProjectById(issueDetails?.project_id); + const stateDetails = issueDetails && getStateById(issueDetails?.state_id); const handleIssuePeekOverview = () => workspaceSlug && - data && - data.project_id && - data.id && - setPeekIssue({ workspaceSlug, projectId: data.project_id, issueId: data.id }); - - const currentStateDetails = - getProjectStates(data?.project_id)?.find((state) => state?.id == data?.state_id) || undefined; + issueDetails && + setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); return (
    - {currentStateDetails != undefined && ( - - )} + {stateDetails && }
    - {getProjectById(data?.project_id)?.identifier} {data?.sequence_id} + {projectDetails?.identifier} {issueDetails?.sequence_id}
    - - {data?.name} + + {issueDetails?.name}
    ); -}; +}); diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index e89f606886..1ddd21ce2a 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -11,8 +11,11 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { createIssuePayload } from "helpers/issue.helper"; +import { cn } from "helpers/common.helper"; // types import { IProject, TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -111,7 +114,7 @@ export const GanttQuickAddIssueForm: React.FC = observe quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Gantt quick add" }, path: router.asPath, }); @@ -123,7 +126,7 @@ export const GanttQuickAddIssueForm: React.FC = observe }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, path: router.asPath, }); @@ -136,10 +139,12 @@ export const GanttQuickAddIssueForm: React.FC = observe }; return ( <> -
    - {isOpen ? ( + {isOpen ? ( +
    = observe
    {`Press 'Enter' to add another issue`}
    - ) : ( -
    setIsOpen(true)} - > - - New Issue -
    - )} -
    +
    + ) : ( + + )} ); }); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 64b1322674..83f72d8ea0 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useState } from "react"; +import { FC, useCallback, useRef, useState } from "react"; import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; @@ -25,6 +25,7 @@ import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_DELETED } from "constants/event-tracker"; export interface IBaseKanBanLayout { issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; @@ -94,6 +95,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + const scrollableContainerRef = useRef(null); + // states const [isDragStarted, setIsDragStarted] = useState(false); const [dragState, setDragState] = useState({}); @@ -210,7 +213,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas setDeleteIssueModal(false); setDragState({}); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: dragState.draggedIssueId!, state: "FAILED", element: "Kanban layout drag & drop" }, path: router.asPath, }); @@ -245,7 +248,10 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
    )} -
    +
    {/* drag and delete component */} @@ -289,6 +295,8 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas canEditProperties={canEditProperties} storeType={storeType} addIssuesToView={addIssuesToView} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
    diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 203ac4938b..24cbe99084 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; // hooks @@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; // helper import { cn } from "helpers/common.helper"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface IssueBlockProps { peekIssueId?: string; @@ -25,6 +26,9 @@ interface IssueBlockProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; + issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization } interface IssueDetailsBlockProps { @@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC = memo((props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, + issueIds, } = props; const issue = issuesMap[issueId]; @@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC = memo((props) => { {...provided.dragHandleProps} ref={provided.innerRef} > - {issue.tempId !== undefined && ( -
    - )}
    - + + +
    )} diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index 15c797833a..3746111e59 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { MutableRefObject, memo } from "react"; //types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; @@ -16,6 +16,8 @@ interface IssueBlocksListProps { handleIssues: (issue: TIssue, action: EIssueActions) => void; quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const KanbanIssueBlocksListMemo: React.FC = (props) => { @@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { handleIssues, quickActions, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; return ( @@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { index={index} isDragDisabled={isDragDisabled} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} + issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders /> ); })} diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index de6c1ddae7..f11321944e 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -20,6 +20,7 @@ import { import { EIssueActions } from "../types"; import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { MutableRefObject } from "react"; export interface IGroupByKanBan { issuesMap: IIssueMap; @@ -45,6 +46,8 @@ export interface IGroupByKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } const GroupByKanBan: React.FC = observer((props) => { @@ -67,6 +70,8 @@ const GroupByKanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const member = useMember(); @@ -92,11 +97,7 @@ const GroupByKanBan: React.FC = observer((props) => { const groupByVisibilityToggle = visibilityGroupBy(_list); return ( -
    +
    {sub_group_by === null && (
    = observer((props) => { disableIssueCreation={disableIssueCreation} canEditProperties={canEditProperties} groupByVisibilityToggle={groupByVisibilityToggle} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> )}
    @@ -168,6 +171,8 @@ export interface IKanBan { storeType?: TCreateModalStoreTypes; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanBan: React.FC = observer((props) => { @@ -189,6 +194,8 @@ export const KanBan: React.FC = observer((props) => { storeType, addIssuesToView, canEditProperties, + scrollableContainerRef, + isDragStarted, } = props; const issueKanBanView = useKanbanView(); @@ -213,6 +220,8 @@ export const KanBan: React.FC = observer((props) => { storeType={storeType} addIssuesToView={addIssuesToView} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 1a25c563e7..7cbda05e1e 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { Droppable } from "@hello-pangea/dnd"; // hooks import { useProjectState } from "hooks/store"; @@ -37,6 +38,8 @@ interface IKanbanGroup { disableIssueCreation?: boolean; canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle: boolean; + scrollableContainerRef?: MutableRefObject; + isDragStarted?: boolean; } export const KanbanGroup = (props: IKanbanGroup) => { @@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { disableIssueCreation, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; // hooks const projectState = useProjectState(); @@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => { handleIssues={handleIssues} quickActions={quickActions} canEditProperties={canEditProperties} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} /> {provided.placeholder} diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 8880ca2784..5131634318 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; const Inputs = (props: any) => { const { register, setFocus, projectDetail } = props; @@ -106,7 +108,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser viewId ).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Kanban quick add" }, path: router.asPath, }); @@ -118,7 +120,7 @@ export const KanBanQuickAddIssueForm: React.FC = obser }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Kanban quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 1b9f27828f..5fdb58ef0a 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,3 +1,4 @@ +import { MutableRefObject } from "react"; import { observer } from "mobx-react-lite"; // components import { KanBan } from "./default"; @@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { viewId?: string ) => Promise; viewId?: string; + scrollableContainerRef?: MutableRefObject; } const SubGroupSwimlane: React.FC = observer((props) => { const { @@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, + isDragStarted, } = props; const calculateIssueCount = (column_id: string) => { @@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { addIssuesToView={addIssuesToView} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} + isDragStarted={isDragStarted} />
    )} @@ -183,6 +189,7 @@ export interface IKanBanSwimLanes { ) => Promise; viewId?: string; canEditProperties: (projectId: string | undefined) => boolean; + scrollableContainerRef?: MutableRefObject; } export const KanBanSwimLanes: React.FC = observer((props) => { @@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { addIssuesToView, quickAddCallback, viewId, + scrollableContainerRef, } = props; const member = useMember(); @@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { canEditProperties={canEditProperties} quickAddCallback={quickAddCallback} viewId={viewId} + scrollableContainerRef={scrollableContainerRef} /> )}
    diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 8f661a9e61..b1441cff7d 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { ); return ( - <> -
    - -
    - +
    + +
    ); }); diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 1ade285a99..ceec7b219c 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -48,64 +48,59 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const projectDetails = getProjectById(issue.project_id); return ( - <> -
    - {displayProperties && displayProperties?.key && ( -
    - {projectDetails?.identifier}-{issue.sequence_id} -
    - )} + "last:border-b-transparent": peekIssue?.issueId !== issue.id + })} + > + {displayProperties && displayProperties?.key && ( +
    + {projectDetails?.identifier}-{issue.sequence_id} +
    + )} - {issue?.tempId !== undefined && ( -
    - )} + {issue?.tempId !== undefined && ( +
    + )} - {issue?.is_draft ? ( + {issue?.is_draft ? ( + + {issue.name} + + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > {issue.name} - ) : ( - handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > - - {issue.name} - - - )} + + )} -
    - {!issue?.tempId ? ( - <> - - {quickActions(issue)} - - ) : ( -
    - -
    - )} -
    +
    + {!issue?.tempId ? ( + <> + + {quickActions(issue)} + + ) : ( +
    + +
    + )}
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index 95ee6c7a84..d3c8d14061 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,9 +1,10 @@ -import { FC } from "react"; +import { FC, MutableRefObject } from "react"; // components import { IssueBlock } from "components/issues"; // types import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; interface Props { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -12,27 +13,34 @@ interface Props { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; quickActions: (issue: TIssue) => React.ReactNode; displayProperties: IIssueDisplayProperties | undefined; + containerRef: MutableRefObject; } export const IssueBlocksList: FC = (props) => { - const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props; + const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props; return (
    {issueIds && issueIds.length > 0 ? ( issueIds.map((issueId: string) => { if (!issueId) return null; - return ( - + + + ); }) ) : ( diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index dd6c8da228..373897fda6 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,5 +1,7 @@ +import { useRef } from "react"; // components import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; +import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; // types @@ -10,12 +12,12 @@ import { IIssueDisplayProperties, TIssueMap, TUnGroupedIssues, + IGroupByColumn, } from "@plane/types"; import { EIssueActions } from "../types"; // constants -import { HeaderGroupByCard } from "./headers/group-by-card"; -import { getGroupByColumns } from "../utils"; import { TCreateModalStoreTypes } from "constants/issue"; +import { getGroupByColumns } from "../utils"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -64,9 +66,11 @@ const GroupByList: React.FC = (props) => { const label = useLabel(); const projectState = useProjectState(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const containerRef = useRef(null); - if (!list) return null; + const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + + if (!groups) return null; const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { const defaultState = projectState.projectStates?.find((state) => state.default); @@ -104,11 +108,11 @@ const GroupByList: React.FC = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( -
    - {list && - list.length > 0 && - list.map( - (_list: any) => +
    + {groups && + groups.length > 0 && + groups.map( + (_list: IGroupByColumn) => validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
    @@ -131,6 +135,7 @@ const GroupByList: React.FC = (props) => { quickActions={quickActions} displayProperties={displayProperties} canEditProperties={canEditProperties} + containerRef={containerRef} /> )} diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index dd63f09aa9..8d1ce6d9c5 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { TIssue, IProject } from "@plane/types"; // types import { createIssuePayload } from "helpers/issue.helper"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -103,7 +105,7 @@ export const ListQuickAddIssueForm: FC = observer((props quickAddCallback && (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "List quick add" }, path: router.asPath, }); @@ -115,7 +117,7 @@ export const ListQuickAddIssueForm: FC = observer((props }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "List quick add" }, path: router.asPath, }); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index e0a0dbd5c6..4d851545e9 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -18,6 +18,8 @@ import { import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; +// constants +import { ISSUE_UPDATED } from "constants/event-tracker"; export interface IIssueProperties { issue: TIssue; @@ -40,7 +42,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -54,7 +56,7 @@ export const IssueProperties: React.FC = observer((props) => { const handlePriority = (value: TIssuePriorities) => { handleIssues({ ...issue, priority: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -68,7 +70,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleLabel = (ids: string[]) => { handleIssues({ ...issue, label_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -82,7 +84,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleAssignee = (ids: string[]) => { handleIssues({ ...issue, assignee_ids: ids }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -96,7 +98,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -110,7 +112,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleTargetDate = (date: Date | null) => { handleIssues({ ...issue, target_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { @@ -124,7 +126,7 @@ export const IssueProperties: React.FC = observer((props) => { const handleEstimate = (value: number | null) => { handleIssues({ ...issue, estimate_point: value }).then(() => { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...issue, state: "SUCCESS", element: currentLayout }, path: router.asPath, updates: { diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 0f121bed79..7e14ad3da2 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -4,6 +4,7 @@ import { usePopper } from "react-popper"; import { Check, ChevronDown, Search, Tags } from "lucide-react"; // hooks import { useApplication, useLabel } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; // components import { Combobox } from "@headlessui/react"; import { Tooltip } from "@plane/ui"; @@ -25,6 +26,7 @@ export interface IIssuePropertyLabels { maxRender?: number; noLabelBorder?: boolean; placeholderText?: string; + onClose?: () => void; } export const IssuePropertyLabels: React.FC = observer((props) => { @@ -33,6 +35,7 @@ export const IssuePropertyLabels: React.FC = observer((pro value, defaultOptions = [], onChange, + onClose, disabled, hideDropdownArrow = false, className, @@ -64,6 +67,12 @@ export const IssuePropertyLabels: React.FC = observer((pro } }; + const handleClose = () => { + onClose && onClose(); + }; + + const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false); + const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", modifiers: [ @@ -171,13 +180,14 @@ export const IssuePropertyLabels: React.FC = observer((pro value={value} onChange={onChange} disabled={disabled} + onKeyDownCapture={handleKeyDown} multiple >
    @@ -216,10 +226,10 @@ export const IssuePropertyLabels: React.FC = observer((pro + className={({ active, selected }) => `flex cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 hover:bg-custom-background-80 ${ - selected ? "text-custom-text-100" : "text-custom-text-200" - }` + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` } > {({ selected }) => ( diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index e63a94b8c5..b9450141b9 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props } buttonClassName="text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx index 775275ca4d..98262b5043 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/due-date-column.tsx @@ -9,17 +9,19 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    { const targetDate = data ? renderFormattedPayloadDate(data) : null; onChange( @@ -36,6 +38,7 @@ export const SpreadsheetDueDateColumn: React.FC = observer((props: Props) buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx index 0c86b24c0f..f7a472b49f 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/estimate-column.tsx @@ -6,12 +6,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetEstimateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC = observer((props: Props buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx index 95982ab25a..b7f432385e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/header-column.tsx @@ -20,10 +20,11 @@ interface Props { property: keyof IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; handleDisplayFilterUpdate: (data: Partial) => void; + onClose: () => void; } -export const SpreadsheetHeaderColumn = (props: Props) => { - const { displayFilters, handleDisplayFilterUpdate, property } = props; +export const HeaderColumn = (props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props; const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( "spreadsheetViewSorting", @@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => { return ( @@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
    } + onMenuClose={onClose} placement="bottom-end" closeOnSelect > diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx index 2812fb1ecd..60e429c9fc 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/label-column.tsx @@ -9,12 +9,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetLabelColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; // hooks const { labelMap } = useLabel(); @@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC = observer((props: Props) = projectId={issue.project_id ?? null} value={issue.label_ids} defaultOptions={defaultLabelOptions} - onChange={(data) => onChange(issue, { label_ids: data },{ changed_property: "labels", change_details: data })} + onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })} className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80" buttonClassName="px-2.5 h-full" hideDropdownArrow maxRender={1} disabled={disabled} placeholderText="Select labels" + onClose={onClose} /> ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx index 1961b8717c..b8801559c5 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/priority-column.tsx @@ -7,22 +7,24 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial,updates:any) => void; disabled: boolean; }; export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    onChange(issue, { priority: data },{changed_property:"priority",change_details:data})} + onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })} disabled={disabled} buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx index 076464f270..82c00fc124 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/start-date-column.tsx @@ -9,17 +9,19 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStartDateColumn: React.FC = observer((props: Props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    { const startDate = data ? renderFormattedPayloadDate(data) : null; onChange( @@ -36,6 +38,7 @@ export const SpreadsheetStartDateColumn: React.FC = observer((props: Prop buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx index 83a7c8d0fe..1a029db121 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/state-column.tsx @@ -7,12 +7,13 @@ import { TIssue } from "@plane/types"; type Props = { issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }; export const SpreadsheetStateColumn: React.FC = observer((props) => { - const { issue, onChange, disabled } = props; + const { issue, onChange, disabled, onClose } = props; return (
    @@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC = observer((props) => { buttonVariant="transparent-with-text" buttonClassName="rounded-none text-left" buttonContainerClassName="w-full" + onClose={onClose} />
    ); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx new file mode 100644 index 0000000000..5d2e62fa55 --- /dev/null +++ b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx @@ -0,0 +1,68 @@ +import { useRef } from "react"; +import { useRouter } from "next/router"; +// types +import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +// constants +import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet"; +// components +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +import { useEventTracker } from "hooks/store"; +import { observer } from "mobx-react"; + +type Props = { + displayProperties: IIssueDisplayProperties; + issueDetail: TIssue; + disableUserActions: boolean; + property: keyof IIssueDisplayProperties; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + isEstimateEnabled: boolean; +}; + +export const IssueColumn = observer((props: Props) => { + const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props; + // router + const router = useRouter(); + const tableCellRef = useRef(null); + const { captureIssueEvent } = useEventTracker(); + + const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; + + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + + return ( + + + , 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(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 40ee85df7c..840ea39f9c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -1,17 +1,19 @@ -import { useRef, useState } from "react"; +import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react"; import { useRouter } from "next/router"; 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 RenderIfVisible from "components/core/render-if-visible-HOC"; +import { IssueColumn } from "./issue-column"; // ui import { ControlLink, Tooltip } from "@plane/ui"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; -import { useEventTracker, useIssueDetail, useProject } from "hooks/store"; +import { useIssueDetail, useProject } from "hooks/store"; // helper import { cn } from "helpers/common.helper"; // types @@ -31,6 +33,9 @@ interface Props { portalElement: React.MutableRefObject; nestingLevel: number; issueId: string; + isScrolled: MutableRefObject; + containerRef: MutableRefObject; + issueIds: string[]; } export const SpreadsheetIssueRow = observer((props: Props) => { @@ -43,19 +48,104 @@ export const SpreadsheetIssueRow = observer((props: Props) => { handleIssues, quickActions, canEditProperties, + isScrolled, + containerRef, + issueIds, } = props; + const [isExpanded, setExpanded] = useState(false); + const { subIssues: subIssuesStore } = useIssueDetail(); + + const subIssues = subIssuesStore.subIssuesByIssueId(issueId); + + return ( + <> + {/* first column/ issue name and key column */} + } + changingReference={issueIds} + > + + + + {isExpanded && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssueId: string) => ( + + ))} + + ); +}); + +interface IssueRowDetailsProps { + displayProperties: IIssueDisplayProperties; + isEstimateEnabled: boolean; + quickActions: ( + issue: TIssue, + customActionButton?: React.ReactElement, + portalElement?: HTMLDivElement | null + ) => React.ReactNode; + canEditProperties: (projectId: string | undefined) => boolean; + handleIssues: (issue: TIssue, action: EIssueActions) => Promise; + portalElement: React.MutableRefObject; + nestingLevel: number; + issueId: string; + isScrolled: MutableRefObject; + isExpanded: boolean; + setExpanded: Dispatch>; +} + +const IssueRowDetails = observer((props: IssueRowDetailsProps) => { + const { + displayProperties, + issueId, + isEstimateEnabled, + nestingLevel, + portalElement, + handleIssues, + quickActions, + canEditProperties, + isScrolled, + isExpanded, + setExpanded, + } = props; // router const router = useRouter(); const { workspaceSlug } = router.query; //hooks const { getProjectById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); - const { captureIssueEvent } = useEventTracker(); // states const [isMenuActive, setIsMenuActive] = useState(false); - const [isExpanded, setExpanded] = useState(false); - const menuActionRef = useRef(null); const handleIssuePeekOverview = (issue: TIssue) => { @@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => { const { subIssues: subIssuesStore, issue } = useIssueDetail(); const issueDetail = issue.getIssueById(issueId); - const subIssues = subIssuesStore.subIssuesByIssueId(issueId); const paddingLeft = `${nestingLevel * 54}px`; @@ -91,126 +180,84 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
    ); - if (!issueDetail) return null; const disableUserActions = !canEditProperties(issueDetail.project_id); return ( <> - - {/* first column/ issue name and key column */} - - -
    -
    - - {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} - + +
    +
    + + {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} + - {canEditProperties(issueDetail.project_id) && ( - - )} -
    - - {issueDetail.sub_issues_count > 0 && ( -
    - + {canEditProperties(issueDetail.project_id) && ( + )}
    - - handleIssuePeekOverview(issueDetail)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - > -
    - -
    - {issueDetail.name} -
    -
    -
    -
    - - {/* Rest of the columns */} - {SPREADSHEET_PROPERTY_LIST.map((property) => { - const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; - const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true; - - return ( - - - , 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} - /> - - - ); - })} - - - {isExpanded && - subIssues && - subIssues.length > 0 && - subIssues.map((subIssueId: string) => ( - 0 && ( +
    + +
    + )} +
    +
    + handleIssuePeekOverview(issueDetail)} + className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + > +
    + +
    + {issueDetail.name} +
    +
    +
    +
    + + {/* Rest of the columns */} + {SPREADSHEET_PROPERTY_LIST.map((property) => ( + ))} 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 b0acd7237f..3cba3c6cdb 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 @@ -12,6 +12,8 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { createIssuePayload } from "helpers/issue.helper"; // types import { TIssue } from "@plane/types"; +// constants +import { ISSUE_CREATED } from "constants/event-tracker"; type Props = { formKey: keyof TIssue; @@ -162,7 +164,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => (await quickAddCallback(currentWorkspace.slug, currentProjectDetails.id, { ...payload } as TIssue, viewId).then( (res) => { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Spreadsheet quick add" }, path: router.asPath, }); @@ -175,7 +177,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => }); } catch (err: any) { captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED", element: "Spreadsheet quick add" }, path: router.asPath, }); 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 0000000000..588c7be9ee --- /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 ( + + + { + tableHeaderCellRef?.current?.focus(); + }} + /> + + + ); +}); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx index 704c9f9047..64d1ec0e15 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-header.tsx @@ -6,8 +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; @@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => { return ( - + #ID @@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => { - {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 369e6633cd..5d45157cc4 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -1,10 +1,12 @@ import { observer } from "mobx-react-lite"; +import { MutableRefObject, useEffect, useRef } from "react"; //types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; 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; @@ -20,6 +22,7 @@ type Props = { handleIssues: (issue: TIssue, action: EIssueActions) => Promise; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; + containerRef: MutableRefObject; }; export const SpreadsheetTable = observer((props: Props) => { @@ -33,10 +36,49 @@ export const SpreadsheetTable = observer((props: Props) => { quickActions, handleIssues, canEditProperties, + containerRef, } = props; + // states + const isScrolled = useRef(false); + + const handleScroll = () => { + if (!containerRef.current) return; + const scrollLeft = containerRef.current.scrollLeft; + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firtColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firtColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firtColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } + }; + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); + + return () => { + if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); + }; + }, []); + + const handleKeyBoardNavigation = useTableKeyboardNavigation(); + return ( - +
    { isEstimateEnabled={isEstimateEnabled} handleIssues={handleIssues} portalElement={portalElement} + containerRef={containerRef} + isScrolled={isScrolled} + issueIds={issueIds} /> ))} diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index e99b178500..1ac815cede 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; // components import { Spinner } from "@plane/ui"; @@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC = observer((props) => { enableQuickCreateIssue, disableIssueCreation, } = props; - // states - const isScrolled = useRef(false); // refs const containerRef = useRef(null); const portalRef = useRef(null); @@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC = observer((props) => { const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null; - const handleScroll = () => { - if (!containerRef.current) return; - const scrollLeft = containerRef.current.scrollLeft; - - const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns - const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers - - //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly - if (scrollLeft > 0 !== isScrolled.current) { - const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); - - for (let i = 0; i < firtColumns.length; i++) { - const shadow = i === 0 ? headerShadow : columnShadow; - if (scrollLeft > 0) { - (firtColumns[i] as HTMLElement).style.boxShadow = shadow; - } else { - (firtColumns[i] as HTMLElement).style.boxShadow = "none"; - } - } - isScrolled.current = scrollLeft > 0; - } - }; - - useEffect(() => { - const currentContainerRef = containerRef.current; - - if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); - - return () => { - if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); - }; - }, []); - if (!issueIds || issueIds.length === 0) return (
    @@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC = observer((props) => { quickActions={quickActions} handleIssues={handleIssues} canEditProperties={canEditProperties} + containerRef={containerRef} />
    diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index 83ec363b9d..0c3367dc1b 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -1,10 +1,10 @@ import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui"; -import { ISSUE_PRIORITIES } from "constants/issue"; +import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue"; import { renderEmoji } from "helpers/emoji.helper"; import { IMemberRootStore } from "store/member"; import { IProjectStore } from "store/project/project.store"; import { IStateStore } from "store/state.store"; -import { GroupByColumnTypes, IGroupByColumn } from "@plane/types"; +import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types"; import { STATE_GROUPS } from "constants/state"; import { ILabelStore } from "store/label.store"; diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 02a0873148..97d977acef 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -13,6 +13,8 @@ import { IssueFormRoot } from "./form"; import type { TIssue } from "@plane/types"; // constants import { EIssuesStoreType, TCreateModalStoreTypes } from "constants/issue"; +import { ISSUE_CREATED, ISSUE_UPDATED } from "constants/event-tracker"; + export interface IssuesModalProps { data?: Partial; isOpen: boolean; @@ -157,14 +159,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue created successfully.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); !createMore && handleClose(); return response; @@ -175,14 +172,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue created", + eventName: ISSUE_CREATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; @@ -198,14 +190,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue updated successfully.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); handleClose(); return response; @@ -216,14 +203,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop message: "Issue could not be created. Please try again.", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...payload, state: "FAILED" }, path: router.asPath, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); } }; diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index fefba17130..8c51019384 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -4,6 +4,7 @@ import { useIssueDetail, useProject, useUser } from "hooks/store"; // components import { IssueDescriptionForm, TIssueOperations } from "components/issues"; import { IssueReaction } from "../issue-detail/reactions"; +import { observer } from "mobx-react"; interface IPeekOverviewIssueDetails { workspaceSlug: string; @@ -15,7 +16,7 @@ interface IPeekOverviewIssueDetails { setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } -export const PeekOverviewIssueDetails: FC = (props) => { +export const PeekOverviewIssueDetails: FC = observer((props) => { const { workspaceSlug, projectId, issueId, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; // store hooks const { getProjectById } = useProject(); @@ -23,6 +24,7 @@ export const PeekOverviewIssueDetails: FC = (props) = const { issue: { getIssueById }, } = useIssueDetail(); + // derived values const issue = getIssueById(issueId); if (!issue) return <>; @@ -53,4 +55,4 @@ export const PeekOverviewIssueDetails: FC = (props) = )} ); -}; +}); diff --git a/web/components/issues/peek-overview/root.tsx b/web/components/issues/peek-overview/root.tsx index f14018ed43..b491ebe363 100644 --- a/web/components/issues/peek-overview/root.tsx +++ b/web/components/issues/peek-overview/root.tsx @@ -11,6 +11,7 @@ import { TIssue } from "@plane/types"; // constants import { EUserProjectRoles } from "constants/project"; import { EIssuesStoreType } from "constants/issue"; +import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker"; interface IIssuePeekOverview { is_archived?: boolean; @@ -103,7 +104,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue updated successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: Object.keys(data).join(","), @@ -113,7 +114,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -135,7 +136,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue deleted successfully", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, path: router.asPath, }); @@ -146,7 +147,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue delete failed", }); captureIssueEvent({ - eventName: "Issue deleted", + eventName: ISSUE_DELETED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, path: router.asPath, }); @@ -161,7 +162,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Issue added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -171,7 +172,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -195,7 +196,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -210,7 +211,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Cycle remove from issue failed", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "cycle_id", @@ -229,7 +230,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module added to issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { ...response, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -239,7 +240,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -263,7 +264,7 @@ export const IssuePeekOverview: FC = observer((props) => { message: "Module removed from issue successfully", }); captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" }, updates: { changed_property: "module_id", @@ -273,7 +274,7 @@ export const IssuePeekOverview: FC = observer((props) => { }); } catch (error) { captureIssueEvent({ - eventName: "Issue updated", + eventName: ISSUE_UPDATED, payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" }, updates: { changed_property: "module_id", diff --git a/web/components/modules/delete-module-modal.tsx b/web/components/modules/delete-module-modal.tsx index 2727b4e3be..636a828aee 100644 --- a/web/components/modules/delete-module-modal.tsx +++ b/web/components/modules/delete-module-modal.tsx @@ -11,6 +11,8 @@ import { Button } from "@plane/ui"; import { AlertTriangle } from "lucide-react"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_DELETED } from "constants/event-tracker"; type Props = { data: IModule; @@ -51,7 +53,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module deleted successfully.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "SUCCESS" }, }); }) @@ -62,7 +64,7 @@ export const DeleteModuleModal: React.FC = observer((props) => { message: "Module could not be deleted. Please try again.", }); captureModuleEvent({ - eventName: "Module deleted", + eventName: MODULE_DELETED, payload: { ...data, state: "FAILED" }, }); }) diff --git a/web/components/modules/form.tsx b/web/components/modules/form.tsx index be0792caad..8fa63e826e 100644 --- a/web/components/modules/form.tsx +++ b/web/components/modules/form.tsx @@ -11,7 +11,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { IModule } from "@plane/types"; type Props = { - handleFormSubmit: (values: Partial) => Promise; + handleFormSubmit: (values: Partial, dirtyFields: any) => Promise; handleClose: () => void; status: boolean; projectId: string; @@ -36,7 +36,7 @@ export const ModuleForm: React.FC = ({ data, }) => { const { - formState: { errors, isSubmitting }, + formState: { errors, isSubmitting, dirtyFields }, handleSubmit, watch, control, @@ -53,7 +53,7 @@ export const ModuleForm: React.FC = ({ }); const handleCreateUpdateModule = async (formData: Partial) => { - await handleFormSubmit(formData); + await handleFormSubmit(formData, dirtyFields); reset({ ...defaultValues, diff --git a/web/components/modules/gantt-chart/blocks.tsx b/web/components/modules/gantt-chart/blocks.tsx index 72717f12b8..188d7b130d 100644 --- a/web/components/modules/gantt-chart/blocks.tsx +++ b/web/components/modules/gantt-chart/blocks.tsx @@ -1,52 +1,74 @@ import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +// hooks +import { useApplication, useModule } from "hooks/store"; // ui import { Tooltip, ModuleStatusIcon } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; -// types -import { IModule } from "@plane/types"; // constants import { MODULE_STATUS } from "constants/module"; -export const ModuleGanttBlock = ({ data }: { data: IModule }) => { +type Props = { + moduleId: string; +}; + +export const ModuleGanttBlock: React.FC = observer((props) => { + const { moduleId } = props; + // router const router = useRouter(); - const { workspaceSlug } = router.query; + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getModuleById } = useModule(); + // derived values + const moduleDetails = getModuleById(moduleId); return (
    s.value === data?.status)?.color }} - onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data?.id}`)} + style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === moduleDetails?.status)?.color }} + onClick={() => router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} >
    -
    {data?.name}
    +
    {moduleDetails?.name}
    - {renderFormattedDate(data?.start_date ?? "")} to {renderFormattedDate(data?.target_date ?? "")} + {renderFormattedDate(moduleDetails?.start_date ?? "")} to{" "} + {renderFormattedDate(moduleDetails?.target_date ?? "")}
    } position="top-left" > -
    {data?.name}
    +
    {moduleDetails?.name}
    ); -}; +}); -export const ModuleGanttSidebarBlock = ({ data }: { data: IModule }) => { +export const ModuleGanttSidebarBlock: React.FC = observer((props) => { + const { moduleId } = props; + // router const router = useRouter(); - const { workspaceSlug } = router.query; + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getModuleById } = useModule(); + // derived values + const moduleDetails = getModuleById(moduleId); return (
    router.push(`/${workspaceSlug}/projects/${data?.project}/modules/${data.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${moduleDetails?.project}/modules/${moduleDetails?.id}`)} > - -
    {data.name}
    + +
    {moduleDetails?.name}
    ); -}; +}); diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 53948f71db..8384c164ed 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -47,11 +47,12 @@ export const ModulesListGanttChartView: React.FC = observer(() => { blocks={projectModuleIds ? blockFormat(projectModuleIds) : null} sidebarToRender={(props) => } blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} - blockToRender={(data: IModule) => } + blockToRender={(data: IModule) => } enableBlockLeftResize={isAllowed} enableBlockRightResize={isAllowed} enableBlockMove={isAllowed} enableReorder={isAllowed} + enableAddBlock={isAllowed} showAllBlocks />
    diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index 0852434c3d..7990386df9 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -9,6 +9,8 @@ import useToast from "hooks/use-toast"; import { ModuleForm } from "components/modules"; // types import type { IModule } from "@plane/types"; +// constants +import { MODULE_CREATED, MODULE_UPDATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -59,7 +61,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module created successfully.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...res, state: "SUCCESS" }, }); }) @@ -70,13 +72,13 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be created. Please try again.", }); captureModuleEvent({ - eventName: "Module created", + eventName: MODULE_CREATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleUpdateModule = async (payload: Partial) => { + const handleUpdateModule = async (payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId || !data) return; const selectedProjectId = payload.project ?? projectId.toString(); @@ -90,8 +92,8 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: "Module updated successfully.", }); captureModuleEvent({ - eventName: "Module updated", - payload: { ...res, state: "SUCCESS" }, + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(dirtyFields), state: "SUCCESS" }, }); }) .catch((err) => { @@ -101,20 +103,20 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { message: err.detail ?? "Module could not be updated. Please try again.", }); captureModuleEvent({ - eventName: "Module updated", + eventName: MODULE_UPDATED, payload: { ...data, state: "FAILED" }, }); }); }; - const handleFormSubmit = async (formData: Partial) => { + const handleFormSubmit = async (formData: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; const payload: Partial = { ...formData, }; if (!data) await handleCreateModule(payload); - else await handleUpdateModule(payload); + else await handleUpdateModule(payload, dirtyFields); }; useEffect(() => { diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 3275f1fe08..ce93ff961b 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleCardItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { @@ -84,14 +101,14 @@ export const ModuleCardItem: React.FC = observer((props) => { const handleEditModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setEditModal(true); }; const handleDeleteModule = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - setTrackElement("Modules page board layout"); + setTrackElement("Modules page grid layout"); setDeleteModal(true); }; diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 81d0a6d6ce..79e559ca77 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // constants import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "constants/event-tracker"; type Props = { moduleId: string; @@ -36,7 +37,7 @@ export const ModuleListItem: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureEvent } = useEventTracker(); // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -46,13 +47,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the module to favorites. Please try again.", + addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_FAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); }); - }); }; const handleRemoveFromFavorites = (e: React.MouseEvent) => { @@ -60,13 +69,21 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't remove the module from favorites. Please try again.", + removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), moduleId) + .then(() => { + captureEvent(MODULE_UNFAVORITED, { + module_id: moduleId, + element: "Grid layout", + state: "SUCCESS", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); }); - }); }; const handleCopyText = (e: React.MouseEvent) => { diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index 0109d7c6c5..5e674b303b 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -34,6 +34,7 @@ import { ILinkDetails, IModule, ModuleLink } from "@plane/types"; // constant import { MODULE_STATUS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; +import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { lead: "", @@ -66,7 +67,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule(); - const { setTrackElement } = useEventTracker(); + const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const moduleDetails = getModuleById(moduleId); const { setToastAlert } = useToast(); @@ -77,7 +78,19 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !moduleId) return; - updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data); + updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data) + .then((res) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" }, + }); + }) + .catch((_) => { + captureModuleEvent({ + eventName: MODULE_UPDATED, + payload: { ...data, state: "FAILED" }, + }); + }); }; const handleCreateLink = async (formData: ModuleLink) => { @@ -87,6 +100,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload) .then(() => { + captureEvent(MODULE_LINK_CREATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link created", @@ -109,6 +126,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload) .then(() => { + captureEvent(MODULE_LINK_UPDATED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link updated", @@ -129,6 +150,10 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId) .then(() => { + captureEvent(MODULE_LINK_DELETED, { + module_id: moduleId, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Module link deleted", @@ -187,8 +212,8 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { 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")}`), + start_date: renderFormattedPayloadDate(`${watch("start_date")}`), }); setToastAlert({ type: "success", @@ -294,7 +319,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { ( + render={({ field: { value, onChange } }) => ( void; @@ -29,6 +33,7 @@ type NotificationCardProps = { export const NotificationCard: React.FC = (props) => { const { + selectedTab, notification, isSnoozedTabOpen, closePopover, @@ -38,6 +43,8 @@ export const NotificationCard: React.FC = (props) => { setSelectedNotificationForSnooze, markSnoozeNotification, } = props; + // store hooks + const { captureEvent } = useEventTracker(); const router = useRouter(); const { workspaceSlug } = router.query; @@ -115,6 +122,10 @@ export const NotificationCard: React.FC = (props) => { { markNotificationReadStatus(notification.id); + captureEvent(ISSUE_OPENED, { + issue_id: notification.data.issue.id, + element: "notification", + }); closePopover(); }} href={`/${workspaceSlug}/projects/${notification.project}/${ @@ -301,15 +312,55 @@ export const NotificationCard: React.FC = (props) => { )} -
    - {moreOptions.map((item) => ( +
    + {[ + { + id: 1, + name: notification.read_at ? "Mark as unread" : "Mark as read", + icon: , + onClick: () => { + markNotificationReadStatusToggle(notification.id).then(() => { + captureEvent(NOTIFICATIONS_READ, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); + setToastAlert({ + title: notification.read_at ? "Notification marked as read" : "Notification marked as unread", + type: "success", + }); + }); + }, + }, + { + id: 2, + name: notification.archived_at ? "Unarchive" : "Archive", + icon: notification.archived_at ? ( + + ) : ( + + ), + onClick: () => { + markNotificationArchivedStatus(notification.id).then(() => { + captureEvent(NOTIFICATION_ARCHIVED, { + issue_id: notification.data.issue.id, + tab: selectedTab, + state: "SUCCESS", + }); + setToastAlert({ + title: notification.archived_at ? "Notification un-archived" : "Notification archived", + type: "success", + }); + }); + }, + }, + ].map((item) => ( @@ -156,8 +167,8 @@ export const TourRoot: React.FC = observer((props) => {
    @@ -100,14 +96,14 @@ export const EmailNotificationForm: FC = (props) => control={control} name="state_change" render={({ field: { value, onChange } }) => ( - { setValue("issue_completed", !value); onChange(!value); }} - className="w-3.5 h-3.5 mx-2 cursor-pointer" + className="mx-2" /> )} /> @@ -123,12 +119,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="issue_completed" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} />
    @@ -145,12 +136,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="comment" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} /> @@ -167,12 +153,7 @@ export const EmailNotificationForm: FC = (props) => control={control} name="mention" render={({ field: { value, onChange } }) => ( - onChange(!value)} - className="w-3.5 h-3.5 mx-2 cursor-pointer" - /> + onChange(!value)} className="mx-2" /> )} /> diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index 7d6a0c5e9c..db0c284f23 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -18,6 +18,7 @@ import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { PROJECT_CREATED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -134,13 +135,8 @@ export const CreateProjectModal: FC = observer((props) => { state: "SUCCESS", }; captureProjectEvent({ - eventName: "Project created", + eventName: PROJECT_CREATED, payload: newPayload, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: res.workspace, - }, }); setToastAlert({ type: "success", @@ -160,16 +156,11 @@ export const CreateProjectModal: FC = observer((props) => { message: err.data[key], }); captureProjectEvent({ - eventName: "Project created", + eventName: PROJECT_CREATED, payload: { ...payload, state: "FAILED", - }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, + } }); }); }); diff --git a/web/components/project/delete-project-modal.tsx b/web/components/project/delete-project-modal.tsx index 7e04a6ebd0..791ac3672c 100644 --- a/web/components/project/delete-project-modal.tsx +++ b/web/components/project/delete-project-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import type { IProject } from "@plane/types"; +// constants +import { PROJECT_DELETED } from "constants/event-tracker"; type DeleteProjectModal = { isOpen: boolean; @@ -62,13 +64,8 @@ export const DeleteProjectModal: React.FC = (props) => { handleClose(); captureProjectEvent({ - eventName: "Project deleted", + eventName: PROJECT_DELETED, payload: { ...project, state: "SUCCESS", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); setToastAlert({ type: "success", @@ -78,13 +75,8 @@ export const DeleteProjectModal: React.FC = (props) => { }) .catch(() => { captureProjectEvent({ - eventName: "Project deleted", + eventName: PROJECT_DELETED, payload: { ...project, state: "FAILED", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - }, }); setToastAlert({ type: "error", diff --git a/web/components/project/form.tsx b/web/components/project/form.tsx index f5efa7bc8e..f46dee6b92 100644 --- a/web/components/project/form.tsx +++ b/web/components/project/form.tsx @@ -18,6 +18,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { NETWORK_CHOICES } from "constants/project"; // services import { ProjectService } from "services/project"; +import { PROJECT_UPDATED } from "constants/event-tracker"; export interface IProjectDetailsForm { project: IProject; @@ -45,7 +46,7 @@ export const ProjectDetailsForm: FC = (props) => { setValue, setError, reset, - formState: { errors }, + formState: { errors, dirtyFields }, } = useForm({ defaultValues: { ...project, @@ -77,13 +78,15 @@ export const ProjectDetailsForm: FC = (props) => { return updateProject(workspaceSlug.toString(), project.id, payload) .then((res) => { + const changed_properties = Object.keys(dirtyFields); + console.log(dirtyFields); captureProjectEvent({ - eventName: "Project updated", - payload: { ...res, state: "SUCCESS", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: res.workspace, + eventName: PROJECT_UPDATED, + payload: { + ...res, + changed_properties: changed_properties, + state: "SUCCESS", + element: "Project general settings", }, }); setToastAlert({ @@ -94,13 +97,8 @@ export const ProjectDetailsForm: FC = (props) => { }) .catch((error) => { captureProjectEvent({ - eventName: "Project updated", + eventName: PROJECT_UPDATED, payload: { ...payload, state: "FAILED", element: "Project general settings" }, - group: { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id, - }, }); setToastAlert({ type: "error", @@ -153,7 +151,7 @@ export const ProjectDetailsForm: FC = (props) => {
    {watch("cover_image")!} -
    +
    diff --git a/web/components/project/leave-project-modal.tsx b/web/components/project/leave-project-modal.tsx index 941bbbaa6a..0827568ce5 100644 --- a/web/components/project/leave-project-modal.tsx +++ b/web/components/project/leave-project-modal.tsx @@ -11,6 +11,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import { IProject } from "@plane/types"; +// constants +import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker"; type FormData = { projectName: string; @@ -63,8 +65,9 @@ export const LeaveProjectModal: FC = observer((props) => { .then(() => { handleClose(); router.push(`/${workspaceSlug}/projects`); - captureEvent("Project member leave", { + captureEvent(PROJECT_MEMBER_LEAVE, { state: "SUCCESS", + element: "Project settings members page", }); }) .catch(() => { @@ -73,8 +76,9 @@ export const LeaveProjectModal: FC = observer((props) => { title: "Error!", message: "Something went wrong please try again later.", }); - captureEvent("Project member leave", { + captureEvent(PROJECT_MEMBER_LEAVE, { state: "FAILED", + element: "Project settings members page", }); }); } else { diff --git a/web/components/project/member-list-item.tsx b/web/components/project/member-list-item.tsx index 175cf9bd47..6a27eccd51 100644 --- a/web/components/project/member-list-item.tsx +++ b/web/components/project/member-list-item.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import Link from "next/link"; import { observer } from "mobx-react-lite"; // hooks -import { useMember, useProject, useUser } from "hooks/store"; +import { useEventTracker, useMember, useProject, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ConfirmProjectMemberRemove } from "components/project"; @@ -14,6 +14,7 @@ import { ChevronDown, Dot, XCircle } from "lucide-react"; // constants import { ROLE } from "constants/workspace"; import { EUserProjectRoles } from "constants/project"; +import { PROJECT_MEMBER_LEAVE } from "constants/event-tracker"; type Props = { userId: string; @@ -35,6 +36,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { const { project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, } = useMember(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -48,8 +50,11 @@ export const ProjectMemberListItem: React.FC = observer((props) => { if (userDetails.member.id === currentUser?.id) { await leaveProject(workspaceSlug.toString(), projectId.toString()) .then(async () => { + captureEvent(PROJECT_MEMBER_LEAVE, { + state: "SUCCESS", + element: "Project settings members page", + }); await fetchProjects(workspaceSlug.toString()); - router.push(`/${workspaceSlug}/projects`); }) .catch((err) => diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 39fb5c9741..7c02ce8d07 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -9,9 +9,12 @@ import { useEventTracker, useMember, useUser, useWorkspace } from "hooks/store"; import useToast from "hooks/use-toast"; // ui import { Avatar, Button, CustomSelect, CustomSearchSelect } from "@plane/ui"; +// helpers +import { getUserRole } from "helpers/user.helper"; // constants import { ROLE } from "constants/workspace"; import { EUserProjectRoles } from "constants/project"; +import { PROJECT_MEMBER_ADDED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -49,7 +52,6 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { const { membership: { currentProjectRole }, } = useUser(); - const { currentWorkspace } = useWorkspace(); const { project: { projectMemberIds, bulkAddMembersToProject }, workspace: { workspaceMemberIds, getWorkspaceMemberDetails }, @@ -79,7 +81,7 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { const payload = { ...formData }; await bulkAddMembersToProject(workspaceSlug.toString(), projectId.toString(), payload) - .then((res) => { + .then(() => { if (onSuccess) onSuccess(); onClose(); setToastAlert({ @@ -87,32 +89,23 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { type: "success", message: "Members added successfully.", }); - captureEvent( - "Member added", - { - ...res, - state: "SUCCESS", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); + captureEvent(PROJECT_MEMBER_ADDED, { + members: [ + ...payload.members.map((member) => ({ + member_id: member.member_id, + role: ROLE[member.role], + })), + ], + state: "SUCCESS", + element: "Project settings members page", + }); }) .catch((error) => { console.error(error); - captureEvent( - "Member added", - { - state: "FAILED", - }, - { - isGrouping: true, - groupType: "Workspace_metrics", - groupId: currentWorkspace?.id!, - } - ); + captureEvent(PROJECT_MEMBER_ADDED, { + state: "FAILED", + element: "Project settings members page", + }); }) .finally(() => { reset(defaultValues); diff --git a/web/components/project/settings/features-list.tsx b/web/components/project/settings/features-list.tsx index 34aec4db61..22e69827ef 100644 --- a/web/components/project/settings/features-list.tsx +++ b/web/components/project/settings/features-list.tsx @@ -51,12 +51,11 @@ export const ProjectFeaturesList: FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { currentUser, membership: { currentProjectRole }, } = useUser(); - const { currentWorkspace } = useWorkspace(); const { currentProjectDetails, updateProject } = useProject(); const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; // toast alert @@ -91,14 +90,9 @@ export const ProjectFeaturesList: FC = observer(() => { { - setTrackElement("PROJECT_SETTINGS_FEATURES_PAGE"); captureEvent(`Toggle ${feature.title.toLowerCase()}`, { - workspace_id: currentWorkspace?.id, - workspace_slug: currentWorkspace?.slug, - project_id: currentProjectDetails?.id, - project_name: currentProjectDetails?.name, - project_identifier: currentProjectDetails?.identifier, enabled: !currentProjectDetails?.[feature.property as keyof IProject], + element: "Project settings feature page", }); handleSubmit({ [feature.property]: !currentProjectDetails?.[feature.property as keyof IProject], diff --git a/web/components/states/create-update-state-inline.tsx b/web/components/states/create-update-state-inline.tsx index b12659a81b..037cd483d9 100644 --- a/web/components/states/create-update-state-inline.tsx +++ b/web/components/states/create-update-state-inline.tsx @@ -13,6 +13,7 @@ import { Button, CustomSelect, Input, Tooltip } from "@plane/ui"; import type { IState } from "@plane/types"; // constants import { GROUP_CHOICES } from "constants/project"; +import { STATE_CREATED, STATE_UPDATED } from "constants/event-tracker"; type Props = { data: IState | null; @@ -36,7 +37,7 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; // store hooks - const { captureEvent, setTrackElement } = useEventTracker(); + const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { createState, updateState } = useProjectState(); // toast alert const { setToastAlert } = useToast(); @@ -86,9 +87,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Success!", message: "State created successfully.", }); - captureEvent("State created", { - ...res, - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...res, + state: "SUCCESS", + element: "Project settings states page", + }, }); }) .catch((error) => { @@ -104,8 +109,14 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Error!", message: "State could not be created. Please try again.", }); - captureEvent("State created", { - state: "FAILED", + + captureProjectStateEvent({ + eventName: STATE_CREATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, }); }); }; @@ -116,9 +127,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { await updateState(workspaceSlug.toString(), projectId.toString(), data.id, formData) .then((res) => { handleClose(); - captureEvent("State updated", { - ...res, - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...res, + state: "SUCCESS", + element: "Project settings states page", + }, }); setToastAlert({ type: "success", @@ -139,8 +154,13 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { title: "Error!", message: "State could not be updated. Please try again.", }); - captureEvent("State updated", { - state: "FAILED", + captureProjectStateEvent({ + eventName: STATE_UPDATED, + payload: { + ...formData, + state: "FAILED", + element: "Project settings states page", + }, }); }); }; diff --git a/web/components/states/delete-state-modal.tsx b/web/components/states/delete-state-modal.tsx index 4a04140927..12de386087 100644 --- a/web/components/states/delete-state-modal.tsx +++ b/web/components/states/delete-state-modal.tsx @@ -10,6 +10,8 @@ import useToast from "hooks/use-toast"; import { Button } from "@plane/ui"; // types import type { IState } from "@plane/types"; +// constants +import { STATE_DELETED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -25,7 +27,7 @@ export const DeleteStateModal: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { captureEvent } = useEventTracker(); + const { captureProjectStateEvent } = useEventTracker(); const { deleteState } = useProjectState(); // toast alert const { setToastAlert } = useToast(); @@ -42,8 +44,12 @@ export const DeleteStateModal: React.FC = observer((props) => { await deleteState(workspaceSlug.toString(), data.project_id, data.id) .then(() => { - captureEvent("State deleted", { - state: "SUCCESS", + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...data, + state: "SUCCESS", + }, }); handleClose(); }) @@ -61,8 +67,12 @@ export const DeleteStateModal: React.FC = observer((props) => { title: "Error!", message: "State could not be deleted. Please try again.", }); - captureEvent("State deleted", { - state: "FAILED", + captureProjectStateEvent({ + eventName: STATE_DELETED, + payload: { + ...data, + state: "FAILED", + }, }); }) .finally(() => { diff --git a/web/components/workspace/create-workspace-form.tsx b/web/components/workspace/create-workspace-form.tsx index 8cb29eddeb..b4f1644698 100644 --- a/web/components/workspace/create-workspace-form.tsx +++ b/web/components/workspace/create-workspace-form.tsx @@ -13,6 +13,7 @@ import { Button, CustomSelect, Input } from "@plane/ui"; import { IWorkspace } from "@plane/types"; // constants import { ORGANIZATION_SIZE, RESTRICTED_URLS } from "constants/workspace"; +import { WORKSPACE_CREATED } from "constants/event-tracker"; type Props = { onSubmit?: (res: IWorkspace) => Promise; @@ -48,7 +49,7 @@ export const CreateWorkspaceForm: FC = observer((props) => { // router const router = useRouter(); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { createWorkspace } = useWorkspace(); // toast alert const { setToastAlert } = useToast(); @@ -70,9 +71,13 @@ export const CreateWorkspaceForm: FC = observer((props) => { await createWorkspace(formData) .then(async (res) => { - captureEvent("Workspace created", { - ...res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_CREATED, + payload: { + ...res, + state: "SUCCESS", + element: "Create workspace page", + }, }); setToastAlert({ type: "success", @@ -83,14 +88,18 @@ export const CreateWorkspaceForm: FC = observer((props) => { if (onSubmit) await onSubmit(res); }) .catch(() => { + captureWorkspaceEvent({ + eventName: WORKSPACE_CREATED, + payload: { + state: "FAILED", + element: "Create workspace page", + }, + }); setToastAlert({ type: "error", title: "Error!", message: "Workspace could not be created. Please try again.", }); - captureEvent("Workspace created", { - state: "FAILED", - }); }); } else setSlugError(true); }) @@ -100,9 +109,6 @@ export const CreateWorkspaceForm: FC = observer((props) => { title: "Error!", message: "Some error occurred while creating workspace. Please try again.", }); - captureEvent("Workspace created", { - state: "FAILED", - }); }); }; diff --git a/web/components/workspace/delete-workspace-modal.tsx b/web/components/workspace/delete-workspace-modal.tsx index acfe0648d7..a90ac9cdf8 100644 --- a/web/components/workspace/delete-workspace-modal.tsx +++ b/web/components/workspace/delete-workspace-modal.tsx @@ -11,6 +11,8 @@ import useToast from "hooks/use-toast"; import { Button, Input } from "@plane/ui"; // types import type { IWorkspace } from "@plane/types"; +// constants +import { WORKSPACE_DELETED } from "constants/event-tracker"; type Props = { isOpen: boolean; @@ -28,7 +30,7 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { // router const router = useRouter(); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { deleteWorkspace } = useWorkspace(); // toast alert const { setToastAlert } = useToast(); @@ -59,9 +61,13 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { .then((res) => { handleClose(); router.push("/"); - captureEvent("Workspace deleted", { - res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_DELETED, + payload: { + ...data, + state: "SUCCESS", + element: "Workspace general settings page", + }, }); setToastAlert({ type: "success", @@ -75,8 +81,13 @@ export const DeleteWorkspaceModal: React.FC = observer((props) => { title: "Error!", message: "Something went wrong. Please try again later.", }); - captureEvent("Workspace deleted", { - state: "FAILED", + captureWorkspaceEvent({ + eventName: WORKSPACE_DELETED, + payload: { + ...data, + state: "FAILED", + element: "Workspace general settings page", + }, }); }); }; diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index 9fa5962a3d..76c9bbedf8 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { ChevronDown, Dot, XCircle } from "lucide-react"; // hooks -import { useMember, useUser } from "hooks/store"; +import { useEventTracker, useMember, useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { ConfirmWorkspaceMemberRemove } from "components/workspace"; @@ -12,6 +12,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace"; import { CustomSelect, Tooltip } from "@plane/ui"; // constants import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; +import { WORKSPACE_MEMBER_lEAVE } from "constants/event-tracker"; type Props = { memberId: string; @@ -33,6 +34,7 @@ export const WorkspaceMembersListItem: FC = observer((props) => { const { workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails }, } = useMember(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); // derived values @@ -42,7 +44,13 @@ export const WorkspaceMembersListItem: FC = observer((props) => { if (!workspaceSlug || !currentUserSettings) return; await leaveWorkspace(workspaceSlug.toString()) - .then(() => router.push("/profile")) + .then(() => { + captureEvent(WORKSPACE_MEMBER_lEAVE, { + state: "SUCCESS", + element: "Workspace settings members page", + }); + router.push("/profile"); + }) .catch((err) => setToastAlert({ type: "error", diff --git a/web/components/workspace/settings/workspace-details.tsx b/web/components/workspace/settings/workspace-details.tsx index e0ac68a247..44da4291f6 100644 --- a/web/components/workspace/settings/workspace-details.tsx +++ b/web/components/workspace/settings/workspace-details.tsx @@ -19,6 +19,7 @@ import { copyUrlToClipboard } from "helpers/string.helper"; import { IWorkspace } from "@plane/types"; // constants import { EUserWorkspaceRoles, ORGANIZATION_SIZE } from "constants/workspace"; +import { WORKSPACE_UPDATED } from "constants/event-tracker"; const defaultValues: Partial = { name: "", @@ -37,7 +38,7 @@ export const WorkspaceDetails: FC = observer(() => { const [isImageRemoving, setIsImageRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); // store hooks - const { captureEvent } = useEventTracker(); + const { captureWorkspaceEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -68,9 +69,13 @@ export const WorkspaceDetails: FC = observer(() => { await updateWorkspace(currentWorkspace.slug, payload) .then((res) => { - captureEvent("Workspace updated", { - ...res, - state: "SUCCESS", + captureWorkspaceEvent({ + eventName: WORKSPACE_UPDATED, + payload: { + ...res, + state: "SUCCESS", + element: "Workspace general settings page", + }, }); setToastAlert({ title: "Success", @@ -79,8 +84,12 @@ export const WorkspaceDetails: FC = observer(() => { }); }) .catch((err) => { - captureEvent("Workspace updated", { - state: "FAILED", + captureWorkspaceEvent({ + eventName: WORKSPACE_UPDATED, + payload: { + state: "FAILED", + element: "Workspace general settings page", + }, }); console.error(err); }); diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 4528882ddd..87bb4c8688 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -222,7 +222,6 @@ export const WorkspaceSidebarDropdown = observer(() => {
    setTrackElement("APP_SIDEBAR_WORKSPACE_DROPDOWN")} className="w-full" > { // store hooks const { theme: themeStore } = useApplication(); + const { captureEvent } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -26,10 +28,13 @@ export const WorkspaceSidebarMenu = observer(() => { // computed const workspaceMemberInfo = currentWorkspaceRole || EUserWorkspaceRoles.GUEST; - const handleLinkClick = () => { + const handleLinkClick = (itemKey: string) => { if (window.innerWidth < 768) { themeStore.toggleSidebar(); } + captureEvent(SIDEBAR_CLICKED, { + destination: itemKey, + }); }; return ( @@ -37,11 +42,8 @@ export const WorkspaceSidebarMenu = observer(() => { {SIDEBAR_MENU_ITEMS.map( (link) => workspaceMemberInfo >= link.access && ( - - + handleLinkClick(link.key)}> + { disabled={!themeStore?.sidebarCollapsed} >
    { = observer((props) => { const { workspaceSlug } = router.query; // store hooks const { deleteGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -39,13 +42,23 @@ export const DeleteGlobalViewModal: React.FC = observer((props) => { setIsDeleteLoading(true); await deleteGlobalView(workspaceSlug.toString(), data.id) - .catch(() => + .then(() => { + captureEvent(GLOBAL_VIEW_DELETED, { + view_id: data.id, + state: "SUCCESS", + }); + }) + .catch(() => { + captureEvent(GLOBAL_VIEW_DELETED, { + view_id: data.id, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "Something went wrong while deleting the view. Please try again.", - }) - ) + }); + }) .finally(() => { setIsDeleteLoading(false); handleClose(); diff --git a/web/components/workspace/views/header.tsx b/web/components/workspace/views/header.tsx index 9c9b40c47d..43375cb246 100644 --- a/web/components/workspace/views/header.tsx +++ b/web/components/workspace/views/header.tsx @@ -4,11 +4,12 @@ import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Plus } from "lucide-react"; // store hooks -import { useGlobalView, useUser } from "hooks/store"; +import { useEventTracker, useGlobalView, useUser } from "hooks/store"; // components import { CreateUpdateWorkspaceViewModal } from "components/workspace"; // constants import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; +import { GLOBAL_VIEW_OPENED } from "constants/event-tracker"; const ViewTab = observer((props: { viewId: string }) => { const { viewId } = props; @@ -49,11 +50,19 @@ export const GlobalViewsHeader: React.FC = observer(() => { const { membership: { currentWorkspaceRole }, } = useUser(); + const { captureEvent } = useEventTracker(); // bring the active view to the centre of the header useEffect(() => { if (!globalViewId) return; + captureEvent(GLOBAL_VIEW_OPENED, { + view_id: globalViewId, + view_type: ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId.toString()) + ? "Default" + : "Custom", + }); + const activeTabElement = document.querySelector(`#global-view-${globalViewId.toString()}`); if (activeTabElement) activeTabElement.scrollIntoView({ behavior: "smooth", inline: "center" }); diff --git a/web/components/workspace/views/modal.tsx b/web/components/workspace/views/modal.tsx index b015b4cb65..b66d555fa6 100644 --- a/web/components/workspace/views/modal.tsx +++ b/web/components/workspace/views/modal.tsx @@ -3,12 +3,14 @@ import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import { Dialog, Transition } from "@headlessui/react"; // store hooks -import { useGlobalView } from "hooks/store"; +import { useEventTracker, useGlobalView } from "hooks/store"; import useToast from "hooks/use-toast"; // components import { WorkspaceViewForm } from "components/workspace"; // types import { IWorkspaceView } from "@plane/types"; +// constants +import { GLOBAL_VIEW_CREATED, GLOBAL_VIEW_UPDATED } from "constants/event-tracker"; type Props = { data?: IWorkspaceView; @@ -24,6 +26,7 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) const { workspaceSlug } = router.query; // store hooks const { createGlobalView, updateGlobalView } = useGlobalView(); + const { captureEvent } = useEventTracker(); // toast alert const { setToastAlert } = useToast(); @@ -43,6 +46,11 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) await createGlobalView(workspaceSlug.toString(), payloadData) .then((res) => { + captureEvent(GLOBAL_VIEW_CREATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Success!", @@ -52,13 +60,17 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) router.push(`/${workspaceSlug}/workspace-views/${res.id}`); handleClose(); }) - .catch(() => + .catch(() => { + captureEvent(GLOBAL_VIEW_CREATED, { + applied_filters: payload?.filters, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "View could not be created. Please try again.", - }) - ); + }); + }); }; const handleUpdateView = async (payload: Partial) => { @@ -72,7 +84,12 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) }; await updateGlobalView(workspaceSlug.toString(), data.id, payloadData) - .then(() => { + .then((res) => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: res.id, + applied_filters: res.filters, + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Success!", @@ -80,13 +97,18 @@ export const CreateUpdateWorkspaceViewModal: React.FC = observer((props) }); handleClose(); }) - .catch(() => + .catch(() => { + captureEvent(GLOBAL_VIEW_UPDATED, { + view_id: data.id, + applied_filters: data.filters, + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: "View could not be updated. Please try again.", - }) - ); + }); + }); }; const handleFormSubmit = async (formData: Partial) => { diff --git a/web/components/workspace/views/view-list-item.tsx b/web/components/workspace/views/view-list-item.tsx index 1d9289037d..ad551494b1 100644 --- a/web/components/workspace/views/view-list-item.tsx +++ b/web/components/workspace/views/view-list-item.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2 } from "lucide-react"; // store hooks -import { useGlobalView } from "hooks/store"; +import { useEventTracker, useGlobalView } from "hooks/store"; // components import { CreateUpdateWorkspaceViewModal, DeleteGlobalViewModal } from "components/workspace"; // ui @@ -25,6 +25,7 @@ export const GlobalViewListItem: React.FC = observer((props) => { const { workspaceSlug } = router.query; // store hooks const { getViewDetailsById } = useGlobalView(); + const {setTrackElement} = useEventTracker(); // derived data const view = getViewDetailsById(viewId); @@ -59,6 +60,7 @@ export const GlobalViewListItem: React.FC = observer((props) => { onClick={(e) => { e.preventDefault(); e.stopPropagation(); + setTrackElement("List view"); setUpdateViewModal(true); }} > diff --git a/web/constants/event-tracker.ts b/web/constants/event-tracker.ts index 67f1b1034e..a0bf0b5bbe 100644 --- a/web/constants/event-tracker.ts +++ b/web/constants/event-tracker.ts @@ -2,26 +2,31 @@ 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 getWorkspaceEventPayload = (payload: any) => ({ + workspace_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + organization_size: payload.organization_size, + first_time: payload.first_time, + state: payload.state, + element: payload.element, +}); export const getProjectEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, project_id: payload.id, identifier: payload.identifier, + project_visibility: payload.network == 2 ? "Public" : "Private", + changed_properties: payload.changed_properties, + lead_id: payload.project_lead, created_at: payload.created_at, updated_at: payload.updated_at, state: payload.state, @@ -30,26 +35,43 @@ export const getProjectEventPayload = (payload: any) => ({ export const getCycleEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, - project_id: payload.id, + project_id: payload.project, 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, + changed_properties: payload.changed_properties, state: payload.state, element: payload.element, }); export const getModuleEventPayload = (payload: any) => ({ workspace_id: payload.workspace_id, - project_id: payload.id, + project_id: payload.project, 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, + lead_id: payload.lead, + changed_properties: payload.changed_properties, + member_ids: payload.members, + state: payload.state, + element: payload.element, +}); + +export const getPageEventPayload = (payload: any) => ({ + workspace_id: payload.workspace_id, + project_id: payload.project, + created_at: payload.created_at, + updated_at: payload.updated_at, + access: payload.access === 0 ? "Public" : "Private", + is_locked: payload.is_locked, + archived_at: payload.archived_at, + created_by: payload.created_by, state: payload.state, element: payload.element, }); @@ -71,6 +93,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => { sub_issues_count: payload.sub_issues_count, parent_id: payload.parent_id, project_id: payload.project_id, + workspace_id: payload.workspace_id, priority: payload.priority, state_id: payload.state_id, start_date: payload.start_date, @@ -82,7 +105,7 @@ export const getIssueEventPayload = (props: IssueEventProps) => { view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "", }; - if (eventName === "Issue updated") { + if (eventName === ISSUE_UPDATED) { eventPayload = { ...eventPayload, ...updates, @@ -103,3 +126,99 @@ export const getIssueEventPayload = (props: IssueEventProps) => { } return eventPayload; }; + +export const getProjectStateEventPayload = (payload: any) => { + return { + workspace_id: payload.workspace_id, + project_id: payload.id, + state_id: payload.id, + created_at: payload.created_at, + updated_at: payload.updated_at, + group: payload.group, + color: payload.color, + default: payload.default, + state: payload.state, + element: payload.element, + }; +}; + +// Workspace crud Events +export const WORKSPACE_CREATED = "Workspace created"; +export const WORKSPACE_UPDATED = "Workspace updated"; +export const WORKSPACE_DELETED = "Workspace deleted"; +// Project Events +export const PROJECT_CREATED = "Project created"; +export const PROJECT_UPDATED = "Project updated"; +export const PROJECT_DELETED = "Project deleted"; +// Cycle Events +export const CYCLE_CREATED = "Cycle created"; +export const CYCLE_UPDATED = "Cycle updated"; +export const CYCLE_DELETED = "Cycle deleted"; +export const CYCLE_FAVORITED = "Cycle favorited"; +export const CYCLE_UNFAVORITED = "Cycle unfavorited"; +// Module Events +export const MODULE_CREATED = "Module created"; +export const MODULE_UPDATED = "Module updated"; +export const MODULE_DELETED = "Module deleted"; +export const MODULE_FAVORITED = "Module favorited"; +export const MODULE_UNFAVORITED = "Module unfavorited"; +export const MODULE_LINK_CREATED = "Module link created"; +export const MODULE_LINK_UPDATED = "Module link updated"; +export const MODULE_LINK_DELETED = "Module link deleted"; +// Issue Events +export const ISSUE_CREATED = "Issue created"; +export const ISSUE_UPDATED = "Issue updated"; +export const ISSUE_DELETED = "Issue deleted"; +export const ISSUE_OPENED = "Issue opened"; +// Project State Events +export const STATE_CREATED = "State created"; +export const STATE_UPDATED = "State updated"; +export const STATE_DELETED = "State deleted"; +// Project Page Events +export const PAGE_CREATED = "Page created"; +export const PAGE_UPDATED = "Page updated"; +export const PAGE_DELETED = "Page deleted"; +// Member Events +export const MEMBER_INVITED = "Member invited"; +export const MEMBER_ACCEPTED = "Member accepted"; +export const PROJECT_MEMBER_ADDED = "Project member added"; +export const PROJECT_MEMBER_LEAVE = "Project member leave"; +export const WORKSPACE_MEMBER_lEAVE = "Workspace member leave"; +// Sign-in & Sign-up Events +export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page"; +export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page"; +export const CODE_VERIFIED = "Code verified"; +export const SETUP_PASSWORD = "Password setup"; +export const PASSWORD_CREATE_SELECTED = "Password created"; +export const PASSWORD_CREATE_SKIPPED = "Skipped to setup"; +export const SIGN_IN_WITH_PASSWORD = "Sign in with password"; +export const FORGOT_PASSWORD = "Forgot password clicked"; +export const FORGOT_PASS_LINK = "Forgot password link generated"; +export const NEW_PASS_CREATED = "New password created"; +// Onboarding Events +export const USER_DETAILS = "User details added"; +export const USER_ONBOARDING_COMPLETED = "User onboarding completed"; +// Product Tour Events +export const PRODUCT_TOUR_STARTED = "Product tour started"; +export const PRODUCT_TOUR_COMPLETED = "Product tour completed"; +export const PRODUCT_TOUR_SKIPPED = "Product tour skipped"; +// Dashboard Events +export const CHANGELOG_REDIRECTED = "Changelog redirected"; +export const GITHUB_REDIRECTED = "Github redirected"; +// Sidebar Events +export const SIDEBAR_CLICKED = "Sidenav clicked"; +// Global View Events +export const GLOBAL_VIEW_CREATED = "Global view created"; +export const GLOBAL_VIEW_UPDATED = "Global view updated"; +export const GLOBAL_VIEW_DELETED = "Global view deleted"; +export const GLOBAL_VIEW_OPENED = "Global view opened"; +// Notification Events +export const NOTIFICATION_ARCHIVED = "Notification archived"; +export const NOTIFICATION_SNOOZED = "Notification snoozed"; +export const NOTIFICATION_READ = "Notification marked read"; +export const UNREAD_NOTIFICATIONS = "Unread notifications viewed"; +export const NOTIFICATIONS_READ = "All notifications marked read"; +export const SNOOZED_NOTIFICATIONS= "Snoozed notifications viewed"; +export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed"; +// Groups +export const GROUP_WORKSPACE = "Workspace_metrics"; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index 57dff280ee..ccf609b1fd 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -88,7 +88,7 @@ export const ISSUE_ORDER_BY_OPTIONS: { { key: "-updated_at", title: "Last Updated" }, { key: "start_date", title: "Start Date" }, { key: "target_date", title: "Due Date" }, - { key: "priority", title: "Priority" }, + { key: "-priority", title: "Priority" }, ]; export const ISSUE_FILTER_OPTIONS: { @@ -237,7 +237,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -250,7 +250,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels"], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -265,7 +265,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state", "state_detail.group", "priority", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -280,7 +280,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -293,7 +293,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels"], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -352,7 +352,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state", "priority", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -366,7 +366,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_filters: { group_by: ["state", "priority", "labels", "assignees", "created_by"], sub_group_by: ["state", "priority", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority", "target_date"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], type: [null, "active", "backlog"], }, extra_options: { @@ -389,7 +389,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], display_properties: true, display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -401,7 +401,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], display_properties: false, display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -412,6 +412,13 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }; +export enum EIssueListRow { + HEADER = "HEADER", + ISSUE = "ISSUE", + NO_ISSUES = "NO_ISSUES", + QUICK_ADD = "QUICK_ADD", +} + export const getValueFromObject = (object: Object, key: string): string | number | boolean | null => { const keys = key ? key.split(".") : []; @@ -442,4 +449,4 @@ export const groupReactionEmojis = (reactions: any) => { } return _groupedEmojis; -}; +}; \ No newline at end of file diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 1668f2a1c0..1a0097eb89 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -28,6 +28,7 @@ export const SPREADSHEET_PROPERTY_DETAILS: { icon: FC; Column: React.FC<{ issue: TIssue; + onClose: () => void; onChange: (issue: TIssue, data: Partial, updates: any) => void; disabled: boolean; }>; diff --git a/web/hooks/use-dropdown-key-down.tsx b/web/hooks/use-dropdown-key-down.tsx index 99511b0fc2..228e355751 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 = { - (onEnterKeyDown: () => void, onEscKeyDown: () => void): (event: React.KeyboardEvent) => void; + (onEnterKeyDown: () => void, onEscKeyDown: () => void, stopPropagation?: boolean): ( + event: React.KeyboardEvent + ) => void; }; -export const useDropdownKeyDown: TUseDropdownKeyDown = (onEnterKeyDown, onEscKeyDown) => { +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(); - event.preventDefault(); + stopEventPropagation(event); + onEnterKeyDown(); } else if (event.key === "Escape") { - event.stopPropagation(); - event.preventDefault(); + stopEventPropagation(event); onEscKeyDown(); } }, - [onEnterKeyDown, onEscKeyDown] + [onEnterKeyDown, onEscKeyDown, stopEventPropagation] ); return handleKeyDown; diff --git a/web/hooks/use-table-keyboard-navigation.tsx b/web/hooks/use-table-keyboard-navigation.tsx new file mode 100644 index 0000000000..0d1c26f3c2 --- /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/layouts/app-layout/sidebar.tsx b/web/layouts/app-layout/sidebar.tsx index e211e7884a..0ccbf26065 100644 --- a/web/layouts/app-layout/sidebar.tsx +++ b/web/layouts/app-layout/sidebar.tsx @@ -12,7 +12,7 @@ import { ProjectSidebarList } from "components/project"; import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; -export interface IAppSidebar {} +export interface IAppSidebar { } export const AppSidebar: FC = observer(() => { // store hooks @@ -32,6 +32,9 @@ export const AppSidebar: FC = observer(() => { if (window.innerWidth <= 768) { themStore.toggleSidebar(true); } + if (window.innerWidth > 768) { + themStore.toggleSidebar(false); + } }; handleResize(); window.addEventListener("resize", handleResize); diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index 864c87f27a..64d323cf0d 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -5,7 +5,7 @@ import NProgress from "nprogress"; import { observer } from "mobx-react-lite"; import { ThemeProvider } from "next-themes"; // hooks -import { useApplication, useUser } from "hooks/store"; +import { useApplication, useUser, useWorkspace } from "hooks/store"; // constants import { THEMES } from "constants/themes"; // layouts @@ -37,6 +37,7 @@ export const AppProvider: FC = observer((props) => { currentUser, membership: { currentProjectRole, currentWorkspaceRole }, } = useUser(); + const { currentWorkspace } = useWorkspace(); const { config: { envConfig }, } = useApplication(); @@ -49,6 +50,7 @@ export const AppProvider: FC = observer((props) => { = (props) => { - const { children, user, workspaceRole, projectRole, posthogAPIKey, posthogHost } = props; + const { children, user, workspaceRole, currentWorkspaceId, projectRole, posthogAPIKey, posthogHost } = props; + // states + const [lastWorkspaceId, setLastWorkspaceId] = useState(currentWorkspaceId); // router const router = useRouter(); @@ -25,10 +30,11 @@ const PostHogProvider: FC = (props) => { if (user) { // Identify sends an event, so you want may want to limit how often you call it posthog?.identify(user.email, { - email: user.email, + id: user.id, first_name: user.first_name, last_name: user.last_name, - id: user.id, + email: user.email, + use_case: user.use_case, workspace_role: workspaceRole ? getUserRole(workspaceRole) : undefined, project_role: projectRole ? getUserRole(projectRole) : undefined, }); @@ -45,6 +51,15 @@ const PostHogProvider: FC = (props) => { } }, [posthogAPIKey, posthogHost]); + useEffect(() => { + // Join workspace group on workspace change + if (lastWorkspaceId !== currentWorkspaceId && currentWorkspaceId && user) { + setLastWorkspaceId(currentWorkspaceId); + posthog?.identify(user.email); + posthog?.group(GROUP_WORKSPACE, currentWorkspaceId); + } + }, [currentWorkspaceId, user]); + useEffect(() => { // Track page views const handleRouteChange = () => { diff --git a/web/package.json b/web/package.json index f62768927d..b05616abc0 100644 --- a/web/package.json +++ b/web/package.json @@ -45,7 +45,7 @@ "next-pwa": "^5.6.0", "next-themes": "^0.2.1", "nprogress": "^0.2.0", - "posthog-js": "^1.88.4", + "posthog-js": "^1.105.0", "react": "18.2.0", "react-color": "^2.19.3", "react-datepicker": "^4.8.0", diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx index b935ee238b..1f3204045d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/pages/index.tsx @@ -6,7 +6,7 @@ import useSWR from "swr"; import { observer } from "mobx-react-lite"; import { useTheme } from "next-themes"; // hooks -import { useApplication, useUser } from "hooks/store"; +import { useApplication, useEventTracker, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; import useUserAuth from "hooks/use-user-auth"; // layouts @@ -60,6 +60,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { const { commandPalette: { toggleCreatePageModal }, } = useApplication(); + const { setTrackElement } = useEventTracker(); const { fetchProjectPages, fetchArchivedProjectPages, loader, archivedPageLoader, projectPageIds, archivedPageIds } = useProjectPages(); @@ -214,7 +215,10 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => { description="Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button." primaryButton={{ text: "Create your first page", - onClick: () => toggleCreatePageModal(true), + onClick: () => { + setTrackElement("Pages empty state"); + toggleCreatePageModal(true); + }, }} comicBox={{ title: "A page can be a doc or a doc of docs.", diff --git a/web/pages/[workspaceSlug]/settings/members.tsx b/web/pages/[workspaceSlug]/settings/members.tsx index 1a185e402a..6e9d8d924a 100644 --- a/web/pages/[workspaceSlug]/settings/members.tsx +++ b/web/pages/[workspaceSlug]/settings/members.tsx @@ -16,8 +16,11 @@ import { Button } from "@plane/ui"; // types import { NextPageWithLayout } from "lib/types"; import { IWorkspaceBulkInviteFormData } from "@plane/types"; +// helpers +import { getUserRole } from "helpers/user.helper"; // constants import { EUserWorkspaceRoles } from "constants/workspace"; +import { MEMBER_INVITED } from "constants/event-tracker"; const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { // states @@ -43,7 +46,17 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { return inviteMembersToWorkspace(workspaceSlug.toString(), data) .then(() => { setInviteModal(false); - captureEvent("Member invited", { state: "SUCCESS" }); + captureEvent(MEMBER_INVITED, { + emails: [ + ...data.emails.map((email) => ({ + email: email.email, + role: getUserRole(email.role), + })), + ], + project_id: undefined, + state: "SUCCESS", + element: "Workspace settings member page", + }); setToastAlert({ type: "success", title: "Success!", @@ -51,7 +64,17 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { }); }) .catch((err) => { - captureEvent("Member invited", { state: "FAILED" }); + captureEvent(MEMBER_INVITED, { + emails: [ + ...data.emails.map((email) => ({ + email: email.email, + role: getUserRole(email.role), + })), + ], + project_id: undefined, + state: "FAILED", + element: "Workspace settings member page", + }); setToastAlert({ type: "error", title: "Error!", @@ -84,14 +107,7 @@ const WorkspaceMembersSettingsPage: NextPageWithLayout = observer(() => { />
    {hasAddMemberPermission && ( - )} diff --git a/web/pages/accounts/forgot-password.tsx b/web/pages/accounts/forgot-password.tsx index 8d3c4cd281..07fa86045e 100644 --- a/web/pages/accounts/forgot-password.tsx +++ b/web/pages/accounts/forgot-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -19,6 +20,7 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "lib/types"; +import { FORGOT_PASS_LINK } from "constants/event-tracker"; type TForgotPasswordFormValues = { email: string; @@ -35,6 +37,8 @@ const ForgotPasswordPage: NextPageWithLayout = () => { // router const router = useRouter(); const { email } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // timer @@ -57,6 +61,9 @@ const ForgotPasswordPage: NextPageWithLayout = () => { email: formData.email, }) .then(() => { + captureEvent(FORGOT_PASS_LINK, { + state: "SUCCESS", + }); setToastAlert({ type: "success", title: "Email sent", @@ -65,13 +72,16 @@ const ForgotPasswordPage: NextPageWithLayout = () => { }); setResendCodeTimer(30); }) - .catch((err) => + .catch((err) => { + captureEvent(FORGOT_PASS_LINK, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/accounts/reset-password.tsx b/web/pages/accounts/reset-password.tsx index 9854ec5bb2..c4258f39e4 100644 --- a/web/pages/accounts/reset-password.tsx +++ b/web/pages/accounts/reset-password.tsx @@ -7,6 +7,7 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useSignInRedirection from "hooks/use-sign-in-redirection"; +import { useEventTracker } from "hooks/store"; // layouts import DefaultLayout from "layouts/default-layout"; // components @@ -21,6 +22,8 @@ import { checkEmailValidity } from "helpers/string.helper"; import { NextPageWithLayout } from "lib/types"; // icons import { Eye, EyeOff } from "lucide-react"; +// constants +import { NEW_PASS_CREATED } from "constants/event-tracker"; type TResetPasswordFormValues = { email: string; @@ -41,6 +44,8 @@ const ResetPasswordPage: NextPageWithLayout = () => { const { uidb64, token, email } = router.query; // states const [showPassword, setShowPassword] = useState(false); + // store hooks + const { captureEvent } = useEventTracker(); // toast const { setToastAlert } = useToast(); // sign in redirection hook @@ -66,14 +71,22 @@ const ResetPasswordPage: NextPageWithLayout = () => { await authService .resetPassword(uidb64.toString(), token.toString(), payload) - .then(() => handleRedirection()) - .catch((err) => + .then(() => { + captureEvent(NEW_PASS_CREATED, { + state: "SUCCESS", + }); + handleRedirection(); + }) + .catch((err) => { + captureEvent(NEW_PASS_CREATED, { + state: "FAILED", + }); setToastAlert({ type: "error", title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", - }) - ); + }); + }); }; return ( diff --git a/web/pages/invitations/index.tsx b/web/pages/invitations/index.tsx index 1d8c3e7741..26ced20101 100644 --- a/web/pages/invitations/index.tsx +++ b/web/pages/invitations/index.tsx @@ -23,11 +23,13 @@ import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-l import emptyInvitation from "public/empty-state/invitation.svg"; // helpers import { truncateText } from "helpers/string.helper"; +import { getUserRole } from "helpers/user.helper"; // types import { NextPageWithLayout } from "lib/types"; import type { IWorkspaceMemberInvitation } from "@plane/types"; // constants import { ROLE } from "constants/workspace"; +import { MEMBER_ACCEPTED } from "constants/event-tracker"; // components import { EmptyState } from "components/common"; @@ -40,7 +42,7 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { const [invitationsRespond, setInvitationsRespond] = useState([]); const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); // store hooks - const { captureEvent } = useEventTracker(); + const { captureEvent, joinWorkspaceMetricGroup } = useEventTracker(); const { currentUser, currentUserSettings } = useUser(); // router const router = useRouter(); @@ -81,11 +83,16 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { .then((res) => { mutate("USER_WORKSPACES"); const firstInviteId = invitationsRespond[0]; + const invitation = invitations?.find((i) => i.id === firstInviteId); const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace; - captureEvent("Member accepted", { - ...res, - state: "SUCCESS", + joinWorkspaceMetricGroup(redirectWorkspace?.id); + captureEvent(MEMBER_ACCEPTED, { + member_id: invitation?.id, + role: getUserRole(invitation?.role!), + project_id: undefined, accepted_from: "App", + state: "SUCCESS", + element: "Workspace invitations page", }); userService .updateUser({ last_workspace_id: redirectWorkspace?.id }) @@ -103,6 +110,12 @@ const UserInvitationsPage: NextPageWithLayout = observer(() => { }); }) .catch(() => { + captureEvent(MEMBER_ACCEPTED, { + project_id: undefined, + accepted_from: "App", + state: "FAILED", + element: "Workspace invitations page", + }); setToastAlert({ type: "error", title: "Error!", diff --git a/web/pages/onboarding/index.tsx b/web/pages/onboarding/index.tsx index 5a5911fcae..99886156dc 100644 --- a/web/pages/onboarding/index.tsx +++ b/web/pages/onboarding/index.tsx @@ -24,6 +24,8 @@ import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; // types import { IUser, TOnboardingSteps } from "@plane/types"; import { NextPageWithLayout } from "lib/types"; +// constants +import { USER_ONBOARDING_COMPLETED } from "constants/event-tracker"; // services const workspaceService = new WorkspaceService(); @@ -79,7 +81,7 @@ const OnboardingPage: NextPageWithLayout = observer(() => { await updateUserOnBoard() .then(() => { - captureEvent("User onboarding completed", { + captureEvent(USER_ONBOARDING_COMPLETED, { user_role: user.role, email: user.email, user_id: user.id, diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx index 714d8b5558..7db6df1135 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/pages/profile/preferences/email.tsx @@ -2,6 +2,8 @@ import { ReactElement } from "react"; import useSWR from "swr"; // layouts import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; +// ui +import { Loader } from "@plane/ui"; // components import { EmailNotificationForm } from "components/profile/preferences"; // services @@ -14,10 +16,20 @@ const userService = new UserService(); const ProfilePreferencesThemePage: NextPageWithLayout = () => { // fetching user email notification settings - const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + const { data, isLoading } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => userService.currentUserEmailNotificationSettings() ); + if (isLoading) { + return ( + + + + + + ); + } + if (!data) { return null; } diff --git a/web/services/issue/issue_draft.service.tsx b/web/services/issue/issue_draft.service.ts similarity index 100% rename from web/services/issue/issue_draft.service.tsx rename to web/services/issue/issue_draft.service.ts diff --git a/web/store/event-tracker.store.ts b/web/store/event-tracker.store.ts index 89e279c402..744ad44fba 100644 --- a/web/store/event-tracker.store.ts +++ b/web/store/event-tracker.store.ts @@ -3,39 +3,49 @@ import posthog from "posthog-js"; // stores import { RootStore } from "./root.store"; import { - EventGroupProps, + GROUP_WORKSPACE, + WORKSPACE_CREATED, EventProps, IssueEventProps, getCycleEventPayload, getIssueEventPayload, getModuleEventPayload, getProjectEventPayload, + getProjectStateEventPayload, + getWorkspaceEventPayload, + getPageEventPayload, } from "constants/event-tracker"; export interface IEventTrackerStore { // properties - trackElement: string; + trackElement: string | undefined; // computed - getRequiredPayload: any; + getRequiredProperties: any; // actions + resetSession: () => void; setTrackElement: (element: string) => void; - captureEvent: (eventName: string, payload: object | [] | null, group?: EventGroupProps) => void; + captureEvent: (eventName: string, payload?: any) => void; + joinWorkspaceMetricGroup: (workspaceId?: string) => void; + captureWorkspaceEvent: (props: EventProps) => void; captureProjectEvent: (props: EventProps) => void; captureCycleEvent: (props: EventProps) => void; captureModuleEvent: (props: EventProps) => void; + capturePageEvent: (props: EventProps) => void; captureIssueEvent: (props: IssueEventProps) => void; + captureProjectStateEvent: (props: EventProps) => void; } export class EventTrackerStore implements IEventTrackerStore { - trackElement: string = ""; + trackElement: string | undefined = undefined; rootStore; constructor(_rootStore: RootStore) { makeObservable(this, { // properties trackElement: observable, // computed - getRequiredPayload: computed, + getRequiredProperties: computed, // actions + resetSession: action, setTrackElement: action, captureEvent: action, captureProjectEvent: action, @@ -48,12 +58,12 @@ export class EventTrackerStore implements IEventTrackerStore { /** * @description: Returns the necessary property for the event tracking */ - get getRequiredPayload() { + get getRequiredProperties() { const currentWorkspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; const currentProjectDetails = this.rootStore.projectRoot.project.currentProjectDetails; return { - workspace_id: currentWorkspaceDetails?.id ?? "", - project_id: currentProjectDetails?.id ?? "", + workspace_id: currentWorkspaceDetails?.id, + project_id: currentProjectDetails?.id, }; } @@ -61,42 +71,74 @@ export class EventTrackerStore implements IEventTrackerStore { * @description: Set the trigger point of event. * @param {string} element */ - setTrackElement = (element: string) => { + 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, - }); - } + /** + * @description: Reset the session. + */ + resetSession = () => { + posthog?.reset(); }; - captureEvent = (eventName: string, payload: object | [] | null) => { - posthog?.capture(eventName, { - ...payload, - element: this.trackElement ?? "", + /** + * @description: Creates the workspace metric group. + * @param {string} userEmail + * @param {string} workspaceId + */ + joinWorkspaceMetricGroup = (workspaceId?: string) => { + if (!workspaceId) return; + posthog?.group(GROUP_WORKSPACE, workspaceId, { + date: new Date().toDateString(), + workspace_id: workspaceId, }); }; + /** + * @description: Captures the event. + * @param {string} eventName + * @param {any} payload + */ + captureEvent = (eventName: string, payload?: any) => { + posthog?.capture(eventName, { + ...this.getRequiredProperties, + ...payload, + element: payload?.element ?? this.trackElement, + }); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the workspace crud related events. + * @param {EventProps} props + */ + captureWorkspaceEvent = (props: EventProps) => { + const { eventName, payload } = props; + if (eventName === WORKSPACE_CREATED && payload.state == "SUCCESS") { + this.joinWorkspaceMetricGroup(payload.id); + } + const eventPayload: any = getWorkspaceEventPayload({ + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + /** * @description: Captures the project related events. * @param {EventProps} props */ captureProjectEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getProjectEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -104,17 +146,14 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureCycleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getCycleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); }; /** @@ -122,17 +161,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {EventProps} props */ captureModuleEvent = (props: EventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = getModuleEventPayload({ - ...this.getRequiredPayload, + ...this.getRequiredProperties, ...payload, element: payload.element ?? this.trackElement, }); posthog?.capture(eventName, eventPayload); - this.setTrackElement(""); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the project pages related events. + * @param {EventProps} props + */ + capturePageEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getPageEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; /** @@ -140,16 +191,29 @@ export class EventTrackerStore implements IEventTrackerStore { * @param {IssueEventProps} props */ captureIssueEvent = (props: IssueEventProps) => { - const { eventName, payload, group } = props; - if (group) { - this.postHogGroup(group); - } + const { eventName, payload } = props; const eventPayload: any = { ...getIssueEventPayload(props), - ...this.getRequiredPayload, + ...this.getRequiredProperties, state_group: this.rootStore.state.getStateById(payload.state_id)?.group ?? "", element: payload.element ?? this.trackElement, }; posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); + }; + + /** + * @description: Captures the issue related events. + * @param {IssueEventProps} props + */ + captureProjectStateEvent = (props: EventProps) => { + const { eventName, payload } = props; + const eventPayload: any = getProjectStateEventPayload({ + ...this.getRequiredProperties, + ...payload, + element: payload.element ?? this.trackElement, + }); + posthog?.capture(eventName, eventPayload); + this.setTrackElement(undefined); }; } diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts index 5fdf0df82d..ff5dba9dd2 100644 --- a/web/store/issue/helpers/issue-helper.store.ts +++ b/web/store/issue/helpers/issue-helper.store.ts @@ -1,7 +1,7 @@ -import sortBy from "lodash/sortBy"; +import orderBy from "lodash/orderBy"; import get from "lodash/get"; import indexOf from "lodash/indexOf"; -import reverse from "lodash/reverse"; +import isEmpty from "lodash/isEmpty"; import values from "lodash/values"; // types import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; @@ -144,98 +144,189 @@ export class IssueHelperStore implements TIssueHelperStore { issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => { switch (groupBy) { case "state": - return this.rootStore?.states || []; + return Object.keys(this.rootStore?.stateMap || {}); case "state_detail.group": return Object.keys(STATE_GROUPS); case "priority": return ISSUE_PRIORITIES.map((i) => i.key); case "labels": - return this.rootStore?.labels || []; + return Object.keys(this.rootStore?.labelMap || {}); case "created_by": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "assignees": - return this.rootStore?.members || []; + return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); case "project": - return this.rootStore?.projects || []; + return Object.keys(this.rootStore?.projectMap || {}); default: return []; } }; + /** + * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees + * @param dataType what type of data is being sent + * @param dataIds id/ids of the data that is to be populated + * @param order ascending or descending for arrays of data + * @returns string | string[] of sortable fields to be used for sorting + */ + populateIssueDataForSorting( + dataType: "state_id" | "label_ids" | "assignee_ids", + dataIds: string | string[] | null | undefined, + order?: "asc" | "desc" + ) { + if (!dataIds) return; + + const dataValues: string[] = []; + const isDataIdsArray = Array.isArray(dataIds); + const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; + + switch (dataType) { + case "state_id": + const stateMap = this.rootStore?.stateMap; + if (!stateMap) break; + for (const dataId of dataIdsArray) { + const state = stateMap[dataId]; + if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); + } + break; + case "label_ids": + const labelMap = this.rootStore?.labelMap; + if (!labelMap) break; + for (const dataId of dataIdsArray) { + const label = labelMap[dataId]; + if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); + } + break; + case "assignee_ids": + const memberMap = this.rootStore?.memberMap; + if (!memberMap) break; + for (const dataId of dataIdsArray) { + const member = memberMap[dataId]; + if (memberMap && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); + } + break; + } + + return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; + } + + /** + * This Method is mainly used to filter out empty values in the begining + * @param key key of the value that is to be checked if empty + * @param object any object in which the key's value is to be checked + * @returns 1 if emoty, 0 if not empty + */ + getSortOrderToFilterEmptyValues(key: string, object: any) { + const value = object?.[key]; + + if (typeof value !== "number" && isEmpty(value)) return 1; + + return 0; + } + issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial): TIssue[] => { let array = values(issueObject); - array = reverse(sortBy(array, "created_at")); + array = orderBy(array, "created_at"); + switch (key) { case "sort_order": - return sortBy(array, "sort_order"); - + return orderBy(array, "sort_order"); case "state__name": - return reverse(sortBy(array, "state")); + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"])); case "-state__name": - return sortBy(array, "state"); - + return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]); // dates case "created_at": - return sortBy(array, "created_at"); + return orderBy(array, "created_at"); case "-created_at": - return reverse(sortBy(array, "created_at")); - + return orderBy(array, "created_at", ["desc"]); case "updated_at": - return sortBy(array, "updated_at"); + return orderBy(array, "updated_at"); case "-updated_at": - return reverse(sortBy(array, "updated_at")); - + return orderBy(array, "updated_at", ["desc"]); case "start_date": - return sortBy(array, "start_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below case "-start_date": - return reverse(sortBy(array, "start_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "target_date": - return sortBy(array, "target_date"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below case "-target_date": - return reverse(sortBy(array, "target_date")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); // custom case "priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return reverse(sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority))); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]); } case "-priority": { const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return sortBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); + return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); } // number case "attachment_count": - return sortBy(array, "attachment_count"); + return orderBy(array, "attachment_count"); case "-attachment_count": - return reverse(sortBy(array, "attachment_count")); + return orderBy(array, "attachment_count", ["desc"]); case "estimate_point": - return sortBy(array, "estimate_point"); + return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below case "-estimate_point": - return reverse(sortBy(array, "estimate_point")); + return orderBy( + array, + [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ); case "link_count": - return sortBy(array, "link_count"); + return orderBy(array, "link_count"); case "-link_count": - return reverse(sortBy(array, "link_count")); + return orderBy(array, "link_count", ["desc"]); case "sub_issues_count": - return sortBy(array, "sub_issues_count"); + return orderBy(array, "sub_issues_count"); case "-sub_issues_count": - return reverse(sortBy(array, "sub_issues_count")); + return orderBy(array, "sub_issues_count", ["desc"]); // Array case "labels__name": - return reverse(sortBy(array, "labels")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"), + ]); case "-labels__name": - return sortBy(array, "labels"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"), + ], + ["asc", "desc"] + ); case "assignees__first_name": - return reverse(sortBy(array, "assignees")); + return orderBy(array, [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"), + ]); case "-assignees__first_name": - return sortBy(array, "assignees"); + return orderBy( + array, + [ + this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"), + ], + ["asc", "desc"] + ); default: return array; diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 46605c7716..43a7ca0936 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -4,6 +4,7 @@ import { IssueArchiveService, IssueService } from "services/issue"; // types import { IIssueDetail } from "./root.store"; import { TIssue } from "@plane/types"; +import { computedFn } from "mobx-utils"; export interface IIssueStoreActions { // actions @@ -44,10 +45,10 @@ export class IssueStore implements IIssueStore { } // helper methods - getIssueById = (issueId: string) => { + getIssueById = computedFn((issueId: string) => { if (!issueId) return undefined; return this.rootIssueDetailStore.rootIssueStore.issues.getIssueById(issueId) ?? undefined; - }; + }); // actions fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string, isArchived = false) => { @@ -63,12 +64,12 @@ export class IssueStore implements IIssueStore { if (!issue) throw new Error("Issue not found"); - this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue]); + this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue], true); // store handlers from issue detail // parent if (issue && issue?.parent && issue?.parent?.id) - this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue?.parent]); + this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue.parent]); // assignees // labels // state diff --git a/web/store/issue/issue.store.ts b/web/store/issue/issue.store.ts index 8ee689daf3..36b2d87416 100644 --- a/web/store/issue/issue.store.ts +++ b/web/store/issue/issue.store.ts @@ -10,7 +10,7 @@ export type IIssueStore = { // observables issuesMap: Record; // Record defines issue_id as key and TIssue as value // actions - addIssue(issues: TIssue[]): void; + addIssue(issues: TIssue[], shouldReplace?: boolean): void; updateIssue(issueId: string, issue: Partial): void; removeIssue(issueId: string): void; // helper methods @@ -39,11 +39,11 @@ export class IssueStore implements IIssueStore { * @param {TIssue[]} issues * @returns {void} */ - addIssue = (issues: TIssue[]) => { + addIssue = (issues: TIssue[], shouldReplace = false) => { if (issues && issues.length <= 0) return; runInAction(() => { issues.forEach((issue) => { - if (!this.issuesMap[issue.id]) set(this.issuesMap, issue.id, issue); + if (!this.issuesMap[issue.id] || shouldReplace) set(this.issuesMap, issue.id, issue); }); }); }; diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index b2425757cc..ee2e6d84dd 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -4,7 +4,7 @@ import isEmpty from "lodash/isEmpty"; import { RootStore } from "../root.store"; import { IStateStore, StateStore } from "../state.store"; // issues data store -import { IState } from "@plane/types"; +import { IIssueLabel, IProject, IState, IUserLite } from "@plane/types"; import { IIssueStore, IssueStore } from "./issue.store"; import { IIssueDetail, IssueDetail } from "./issue-details/root.store"; import { IWorkspaceIssuesFilter, WorkspaceIssuesFilter, IWorkspaceIssues, WorkspaceIssues } from "./workspace"; @@ -22,6 +22,7 @@ import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedI import { IDraftIssuesFilter, DraftIssuesFilter, IDraftIssues, DraftIssues } from "./draft"; import { IIssueKanBanViewStore, IssueKanBanViewStore } from "./issue_kanban_view.store"; import { ICalendarStore, CalendarStore } from "./issue_calendar_view.store"; +import { IWorkspaceMembership } from "store/member/workspace-member.store"; export interface IIssueRootStore { currentUserId: string | undefined; @@ -32,11 +33,12 @@ export interface IIssueRootStore { viewId: string | undefined; globalViewId: string | undefined; // all issues view id userId: string | undefined; // user profile detail Id - states: string[] | undefined; + stateMap: Record | undefined; stateDetails: IState[] | undefined; - labels: string[] | undefined; - members: string[] | undefined; - projects: string[] | undefined; + labelMap: Record | undefined; + workSpaceMemberRolesMap: Record | undefined; + memberMap: Record | undefined; + projectMap: Record | undefined; rootStore: RootStore; @@ -83,11 +85,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: string | undefined = undefined; globalViewId: string | undefined = undefined; userId: string | undefined = undefined; - states: string[] | undefined = undefined; + stateMap: Record | undefined = undefined; stateDetails: IState[] | undefined = undefined; - labels: string[] | undefined = undefined; - members: string[] | undefined = undefined; - projects: string[] | undefined = undefined; + labelMap: Record | undefined = undefined; + workSpaceMemberRolesMap: Record | undefined = undefined; + memberMap: Record | undefined = undefined; + projectMap: Record | undefined = undefined; rootStore: RootStore; @@ -133,11 +136,12 @@ export class IssueRootStore implements IIssueRootStore { viewId: observable.ref, userId: observable.ref, globalViewId: observable.ref, - states: observable, + stateMap: observable, stateDetails: observable, - labels: observable, - members: observable, - projects: observable, + labelMap: observable, + memberMap: observable, + workSpaceMemberRolesMap: observable, + projectMap: observable, }); this.rootStore = rootStore; @@ -151,13 +155,14 @@ export class IssueRootStore implements IIssueRootStore { if (rootStore.app.router.viewId) this.viewId = rootStore.app.router.viewId; if (rootStore.app.router.globalViewId) this.globalViewId = rootStore.app.router.globalViewId; if (rootStore.app.router.userId) this.userId = rootStore.app.router.userId; - if (!isEmpty(rootStore?.state?.stateMap)) this.states = Object.keys(rootStore?.state?.stateMap); + if (!isEmpty(rootStore?.state?.stateMap)) this.stateMap = rootStore?.state?.stateMap; if (!isEmpty(rootStore?.state?.projectStates)) this.stateDetails = rootStore?.state?.projectStates; - if (!isEmpty(rootStore?.label?.labelMap)) this.labels = Object.keys(rootStore?.label?.labelMap); + if (!isEmpty(rootStore?.label?.labelMap)) this.labelMap = rootStore?.label?.labelMap; if (!isEmpty(rootStore?.memberRoot?.workspace?.workspaceMemberMap)) - this.members = Object.keys(rootStore?.memberRoot?.workspace?.workspaceMemberMap); + this.workSpaceMemberRolesMap = rootStore?.memberRoot?.workspace?.memberMap || undefined; + if (!isEmpty(rootStore?.memberRoot?.memberMap)) this.memberMap = rootStore?.memberRoot?.memberMap || undefined; if (!isEmpty(rootStore?.projectRoot?.project?.projectMap)) - this.projects = Object.keys(rootStore?.projectRoot?.project?.projectMap); + this.projectMap = rootStore?.projectRoot?.project?.projectMap; }); this.issues = new IssueStore(); diff --git a/web/store/member/workspace-member.store.ts b/web/store/member/workspace-member.store.ts index ff65d0eb99..1dae25bd40 100644 --- a/web/store/member/workspace-member.store.ts +++ b/web/store/member/workspace-member.store.ts @@ -26,6 +26,7 @@ export interface IWorkspaceMemberStore { // computed workspaceMemberIds: string[] | null; workspaceMemberInvitationIds: string[] | null; + memberMap: Record | null; // computed actions getSearchedWorkspaceMemberIds: (searchQuery: string) => string[] | null; getSearchedWorkspaceInvitationIds: (searchQuery: string) => string[] | null; @@ -68,6 +69,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { // computed workspaceMemberIds: computed, workspaceMemberInvitationIds: computed, + memberMap: computed, // actions fetchWorkspaceMembers: action, updateMember: action, @@ -100,6 +102,12 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { return memberIds; } + get memberMap() { + const workspaceSlug = this.routerStore.workspaceSlug; + if (!workspaceSlug) return null; + return this.workspaceMemberMap?.[workspaceSlug] ?? {}; + } + get workspaceMemberInvitationIds() { const workspaceSlug = this.routerStore.workspaceSlug; if (!workspaceSlug) return null; diff --git a/web/store/user/index.ts b/web/store/user/index.ts index b07764a05c..15f9e57728 100644 --- a/web/store/user/index.ts +++ b/web/store/user/index.ts @@ -250,6 +250,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); @@ -264,6 +265,7 @@ export class UserRootStore implements IUserRootStore { this.isUserLoggedIn = false; }); this.membership = new UserMembershipStore(this.rootStore); + this.rootStore.eventTracker.resetSession(); this.rootStore.resetOnSignout(); }); }