Merge branch 'develop' of github.com:makeplane/plane into chore/api_existing_response

This commit is contained in:
pablohashescobar 2024-02-12 11:42:37 +05:30
commit 98de106c89
231 changed files with 7234 additions and 2888 deletions

View File

@ -2,11 +2,6 @@ name: Branch Build
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
branch_name:
description: "Branch Name"
required: true
default: "preview"
push: push:
branches: branches:
- master - master
@ -16,49 +11,71 @@ on:
types: [released, prereleased] types: [released, prereleased]
env: 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: jobs:
branch_build_setup: branch_build_setup:
name: Build-Push Web/Space/API/Proxy Docker Image name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
outputs: 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: branch_build_push_frontend:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
FRONTEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ needs.branch_build_setup.outputs.gh_branch_name }} 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: steps:
- name: Set Frontend Docker Tag - name: Set Frontend Docker Tag
run: | 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 }} 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 TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable
else else
TAG=${{ env.FRONTEND_TAG }} TAG=${{ env.FRONTEND_TAG }}
fi fi
echo "FRONTEND_TAG=${TAG}" >> $GITHUB_ENV 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 - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
@ -67,7 +84,7 @@ jobs:
with: with:
context: . context: .
file: ./web/Dockerfile.web file: ./web/Dockerfile.web
platforms: linux/amd64 platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.FRONTEND_TAG }} tags: ${{ env.FRONTEND_TAG }}
push: true push: true
env: env:
@ -80,33 +97,36 @@ jobs:
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
SPACE_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ needs.branch_build_setup.outputs.gh_branch_name }} 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: steps:
- name: Set Space Docker Tag - name: Set Space Docker Tag
run: | 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 }} 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 TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable
else else
TAG=${{ env.SPACE_TAG }} TAG=${{ env.SPACE_TAG }}
fi fi
echo "SPACE_TAG=${TAG}" >> $GITHUB_ENV 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 - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
@ -115,7 +135,7 @@ jobs:
with: with:
context: . context: .
file: ./space/Dockerfile.space file: ./space/Dockerfile.space
platforms: linux/amd64 platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.SPACE_TAG }} tags: ${{ env.SPACE_TAG }}
push: true push: true
env: env:
@ -128,33 +148,36 @@ jobs:
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
BACKEND_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ needs.branch_build_setup.outputs.gh_branch_name }} 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: steps:
- name: Set Backend Docker Tag - name: Set Backend Docker Tag
run: | 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 }} 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 TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable
else else
TAG=${{ env.BACKEND_TAG }} TAG=${{ env.BACKEND_TAG }}
fi fi
echo "BACKEND_TAG=${TAG}" >> $GITHUB_ENV 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 - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
@ -163,7 +186,7 @@ jobs:
with: with:
context: ./apiserver context: ./apiserver
file: ./apiserver/Dockerfile.api file: ./apiserver/Dockerfile.api
platforms: linux/amd64 platforms: ${{ env.BUILDX_PLATFORMS }}
push: true push: true
tags: ${{ env.BACKEND_TAG }} tags: ${{ env.BACKEND_TAG }}
env: env:
@ -171,38 +194,42 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy: branch_build_push_proxy:
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [branch_build_setup] needs: [branch_build_setup]
env: env:
PROXY_TAG: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ needs.branch_build_setup.outputs.gh_branch_name }} 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: steps:
- name: Set Proxy Docker Tag - name: Set Proxy Docker Tag
run: | 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 }} 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 TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable
else else
TAG=${{ env.PROXY_TAG }} TAG=${{ env.PROXY_TAG }}
fi fi
echo "PROXY_TAG=${TAG}" >> $GITHUB_ENV 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 - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 - name: Check out the repo
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
@ -211,10 +238,11 @@ jobs:
with: with:
context: ./nginx context: ./nginx
file: ./nginx/Dockerfile file: ./nginx/Dockerfile
platforms: linux/amd64 platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.PROXY_TAG }} tags: ${{ env.PROXY_TAG }}
push: true push: true
env: env:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@ -1,6 +1,8 @@
# Python imports # Python imports
import zoneinfo import zoneinfo
import json import json
from urllib.parse import urlparse
# Django imports # Django imports
from django.conf import settings from django.conf import settings
@ -51,6 +53,11 @@ class WebhookMixin:
and self.request.method in ["POST", "PATCH", "DELETE"] and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204] and response.status_code in [200, 201, 204]
): ):
url = request.build_absolute_uri()
parsed_url = urlparse(url)
# Extract the scheme and netloc
scheme = parsed_url.scheme
netloc = parsed_url.netloc
# Push the object to delay # Push the object to delay
send_webhook.delay( send_webhook.delay(
event=self.webhook_event, event=self.webhook_event,
@ -59,6 +66,7 @@ class WebhookMixin:
action=self.request.method, action=self.request.method,
slug=self.workspace_slug, slug=self.workspace_slug,
bulk=self.bulk, bulk=self.bulk,
current_site=f"{scheme}://{netloc}",
) )
return response return response

View File

@ -401,8 +401,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign up",
medium="MAGIC_LINK", medium="Magic link",
first_time=True, first_time=True,
) )
key, token, current_attempt = generate_magic_token(email=email) key, token, current_attempt = generate_magic_token(email=email)
@ -438,8 +438,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium="MAGIC_LINK", medium="Magic link",
first_time=False, first_time=False,
) )
@ -468,8 +468,8 @@ class EmailCheckEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium="EMAIL", medium="Email",
first_time=False, first_time=False,
) )

View File

@ -274,8 +274,8 @@ class SignInEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium="EMAIL", medium="Email",
first_time=False, first_time=False,
) )
@ -349,8 +349,8 @@ class MagicSignInEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium="MAGIC_LINK", medium="Magic link",
first_time=False, first_time=False,
) )

View File

@ -64,6 +64,7 @@ class WebhookMixin:
action=self.request.method, action=self.request.method,
slug=self.workspace_slug, slug=self.workspace_slug,
bulk=self.bulk, bulk=self.bulk,
current_site=request.META.get("HTTP_ORIGIN"),
) )
return response return response

View File

@ -20,6 +20,7 @@ from django.core import serializers
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -312,6 +313,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"labels": label_distribution, "labels": label_distribution,
"completion_chart": {}, "completion_chart": {},
} }
if data[0]["start_date"] and data[0]["end_date"]: if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][ data[0]["distribution"][
"completion_chart" "completion_chart"
@ -840,10 +842,230 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 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 ( if (
new_cycle.end_date is not None new_cycle.end_date is not None
and new_cycle.end_date < timezone.now().date() and new_cycle.end_date < timezone.now().date()

View File

@ -1668,15 +1668,9 @@ class IssueDraftViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.objects.annotate( Issue.objects.filter(
sub_issues_count=Issue.issue_objects.filter( project_id=self.kwargs.get("project_id")
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
) )
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True) .filter(is_draft=True)
.select_related("workspace", "project", "state", "parent") .select_related("workspace", "project", "state", "parent")
@ -1710,7 +1704,7 @@ class IssueDraftViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
) ).distinct()
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
@ -1832,7 +1826,10 @@ class IssueDraftViewSet(BaseViewSet):
notification=True, notification=True,
origin=request.META.get("HTTP_ORIGIN"), origin=request.META.get("HTTP_ORIGIN"),
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) issue = (
self.get_queryset().filter(pk=serializer.data["id"]).first()
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk): def partial_update(self, request, slug, project_id, pk):
@ -1868,10 +1865,13 @@ class IssueDraftViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None): def retrieve(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = self.get_queryset().filter(pk=pk).first()
workspace__slug=slug, project_id=project_id, pk=pk, is_draft=True return Response(
IssueSerializer(
issue, fields=self.fields, expand=self.expand
).data,
status=status.HTTP_200_OK,
) )
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None): def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get( issue = Issue.objects.get(

View File

@ -334,7 +334,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
def get_queryset(self): def get_queryset(self):
return ( return (
Issue.objects.filter( Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id") issue_module__module_id=self.kwargs.get("module_id")

View File

@ -296,7 +296,7 @@ class OauthEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign in",
medium=medium.upper(), medium=medium.upper(),
first_time=False, first_time=False,
) )
@ -427,7 +427,7 @@ class OauthEndpoint(BaseAPIView):
email=email, email=email,
user_agent=request.META.get("HTTP_USER_AGENT"), user_agent=request.META.get("HTTP_USER_AGENT"),
ip=request.META.get("REMOTE_ADDR"), ip=request.META.get("REMOTE_ADDR"),
event_name="SIGN_IN", event_name="Sign up",
medium=medium.upper(), medium=medium.upper(),
first_time=True, first_time=True,
) )

View File

@ -1,5 +1,6 @@
import json
from datetime import datetime from datetime import datetime
from bs4 import BeautifulSoup
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
@ -9,7 +10,6 @@ from django.utils import timezone
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings
# Module imports # Module imports
from plane.db.models import EmailNotificationLog, User, Issue from plane.db.models import EmailNotificationLog, User, Issue
@ -40,7 +40,7 @@ def stack_email_notification():
processed_notifications = [] processed_notifications = []
# Loop through all the issues to create the emails # Loop through all the issues to create the emails
for receiver_id in receivers: for receiver_id in receivers:
# Notifcation triggered for the receiver # Notification triggered for the receiver
receiver_notifications = [ receiver_notifications = [
notification notification
for notification in email_notifications for notification in email_notifications
@ -124,119 +124,153 @@ def create_payload(notification_data):
return data return data
def process_mention(mention_component):
soup = BeautifulSoup(mention_component, 'html.parser')
mentions = soup.find_all('mention-component')
for mention in mentions:
user_id = mention['id']
user = User.objects.get(pk=user_id)
user_name = user.display_name
highlighted_name = f"@{user_name}"
mention.replace_with(highlighted_name)
return str(soup)
def process_html_content(content):
processed_content_list = []
for html_content in content:
processed_content = process_mention(html_content)
processed_content_list.append(processed_content)
return processed_content_list
@shared_task @shared_task
def send_email_notification( def send_email_notification(
issue_id, notification_data, receiver_id, email_notification_ids issue_id, notification_data, receiver_id, email_notification_ids
): ):
ri = redis_instance()
base_api = (ri.get(str(issue_id)).decode())
data = create_payload(notification_data=notification_data)
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
issue = Issue.objects.get(pk=issue_id)
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try: try:
connection = get_connection( ri = redis_instance()
host=EMAIL_HOST, base_api = (ri.get(str(issue_id)).decode())
port=int(EMAIL_PORT), data = create_payload(notification_data=notification_data)
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives( # Get email configurations
subject=subject, (
body=text_content, EMAIL_HOST,
from_email=EMAIL_FROM, EMAIL_HOST_USER,
to=[receiver.email], EMAIL_HOST_PASSWORD,
connection=connection, EMAIL_PORT,
) EMAIL_USE_TLS,
msg.attach_alternative(html_content, "text/html") EMAIL_FROM,
msg.send() ) = get_email_configuration()
EmailNotificationLog.objects.filter( receiver = User.objects.get(pk=receiver_id)
pk__in=email_notification_ids issue = Issue.objects.get(pk=issue_id)
).update(sent_at=timezone.now()) template_data = []
return total_changes = 0
except Exception as e: comments = []
print(e) actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
mention = changes.pop("mention", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
"actor_comments": comment,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
if mention:
mention["new_value"] = process_html_content(mention.get("new_value"))
mention["old_value"] = process_html_content(mention.get("old_value"))
comments.append(
{
"actor_comments": mention,
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
}
)
activity_time = changes.pop("activity_time")
# Parse the input string into a datetime object
formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p")
if changes:
template_data.append(
{
"actor_detail": {
"avatar_url": actor.avatar,
"first_name": actor.first_name,
"last_name": actor.last_name,
},
"changes": changes,
"issue_details": {
"name": issue.name,
"identifier": f"{issue.project.identifier}-{issue.sequence_id}",
},
"activity_time": str(formatted_time),
}
)
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
},
"receiver": {
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
EmailNotificationLog.objects.filter(
pk__in=email_notification_ids
).update(sent_at=timezone.now())
return
except Exception as e:
print(e)
return
except Issue.DoesNotExist:
return return

View File

@ -353,13 +353,18 @@ def track_assignees(
issue_activities, issue_activities,
epoch, epoch,
): ):
requested_assignees = set( requested_assignees = (
[str(asg) for asg in requested_data.get("assignee_ids", [])] set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
) )
current_assignees = set( current_assignees = (
[str(asg) for asg in current_instance.get("assignee_ids", [])] set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
) )
added_assignees = requested_assignees - current_assignees added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees dropped_assginees = current_assignees - requested_assignees
@ -547,6 +552,20 @@ def create_issue_activity(
epoch=epoch, epoch=epoch,
) )
) )
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
if requested_data.get("assignee_ids") is not None:
track_assignees(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
)
def update_issue_activity( def update_issue_activity(

View File

@ -515,7 +515,7 @@ def notifications(
bulk_email_logs.append( bulk_email_logs.append(
EmailNotificationLog( EmailNotificationLog(
triggered_by_id=actor_id, triggered_by_id=actor_id,
receiver_id=subscriber, receiver_id=mention_id,
entity_identifier=issue_id, entity_identifier=issue_id,
entity_name="issue", entity_name="issue",
data={ data={
@ -552,6 +552,7 @@ def notifications(
"old_value": str( "old_value": str(
issue_activity.get("old_value") issue_activity.get("old_value")
), ),
"activity_time": issue_activity.get("created_at"),
}, },
}, },
) )
@ -639,6 +640,7 @@ def notifications(
"old_value": str( "old_value": str(
last_activity.old_value last_activity.old_value
), ),
"activity_time": issue_activity.get("created_at"),
}, },
}, },
) )
@ -695,6 +697,7 @@ def notifications(
"old_value" "old_value"
) )
), ),
"activity_time": issue_activity.get("created_at"),
}, },
}, },
) )

View File

@ -7,6 +7,9 @@ import hmac
# Django imports # Django imports
from django.conf import settings from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import render_to_string
from django.utils.html import strip_tags
# Third party imports # Third party imports
from celery import shared_task from celery import shared_task
@ -22,10 +25,10 @@ from plane.db.models import (
ModuleIssue, ModuleIssue,
CycleIssue, CycleIssue,
IssueComment, IssueComment,
User,
) )
from plane.api.serializers import ( from plane.api.serializers import (
ProjectSerializer, ProjectSerializer,
IssueSerializer,
CycleSerializer, CycleSerializer,
ModuleSerializer, ModuleSerializer,
CycleIssueSerializer, CycleIssueSerializer,
@ -34,6 +37,9 @@ from plane.api.serializers import (
IssueExpandSerializer, IssueExpandSerializer,
) )
# Module imports
from plane.license.utils.instance_value import get_email_configuration
SERIALIZER_MAPPER = { SERIALIZER_MAPPER = {
"project": ProjectSerializer, "project": ProjectSerializer,
"issue": IssueExpandSerializer, "issue": IssueExpandSerializer,
@ -72,7 +78,7 @@ def get_model_data(event, event_id, many=False):
max_retries=5, max_retries=5,
retry_jitter=True, retry_jitter=True,
) )
def webhook_task(self, webhook, slug, event, event_data, action): def webhook_task(self, webhook, slug, event, event_data, action, current_site):
try: try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug) webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
@ -151,7 +157,18 @@ def webhook_task(self, webhook, slug, event, event_data, action):
response_body=str(e), response_body=str(e),
retry_count=str(self.request.retries), retry_count=str(self.request.retries),
) )
# Retry logic
if self.request.retries >= self.max_retries:
Webhook.objects.filter(pk=webhook.id).update(is_active=False)
if webhook:
# send email for the deactivation of the webhook
send_webhook_deactivation_email(
webhook_id=webhook.id,
receiver_id=webhook.created_by_id,
reason=str(e),
current_site=current_site,
)
return
raise requests.RequestException() raise requests.RequestException()
except Exception as e: except Exception as e:
@ -162,7 +179,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task() @shared_task()
def send_webhook(event, payload, kw, action, slug, bulk): def send_webhook(event, payload, kw, action, slug, bulk, current_site):
try: try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True) webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@ -216,6 +233,7 @@ def send_webhook(event, payload, kw, action, slug, bulk):
event=event, event=event,
event_data=data, event_data=data,
action=action, action=action,
current_site=current_site,
) )
except Exception as e: except Exception as e:
@ -223,3 +241,56 @@ def send_webhook(event, payload, kw, action, slug, bulk):
print(e) print(e)
capture_exception(e) capture_exception(e)
return return
@shared_task
def send_webhook_deactivation_email(webhook_id, receiver_id, current_site, reason):
# Get email configurations
(
EMAIL_HOST,
EMAIL_HOST_USER,
EMAIL_HOST_PASSWORD,
EMAIL_PORT,
EMAIL_USE_TLS,
EMAIL_FROM,
) = get_email_configuration()
receiver = User.objects.get(pk=receiver_id)
webhook = Webhook.objects.get(pk=webhook_id)
subject="Webhook Deactivated"
message=f"Webhook {webhook.url} has been deactivated due to failed requests."
# Send the mail
context = {
"email": receiver.email,
"message": message,
"webhook_url":f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
}
html_content = render_to_string(
"emails/notifications/webhook-deactivate.html", context
)
text_content = strip_tags(html_content)
try:
connection = get_connection(
host=EMAIL_HOST,
port=int(EMAIL_PORT),
username=EMAIL_HOST_USER,
password=EMAIL_HOST_PASSWORD,
use_tls=EMAIL_USE_TLS == "1",
)
msg = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=EMAIL_FROM,
to=[receiver.email],
connection=connection,
)
msg.attach_alternative(html_content, "text/html")
msg.send()
return
except Exception as e:
print(e)
return

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.7 on 2024-02-08 09:57
from django.db import migrations
def widgets_filter_change(apps, schema_editor):
Widget = apps.get_model("db", "Widget")
widgets_to_update = []
# Define the filter dictionaries for each widget key
filters_mapping = {
"assigned_issues": {"duration": "none", "tab": "pending"},
"created_issues": {"duration": "none", "tab": "pending"},
"issues_by_state_groups": {"duration": "none"},
"issues_by_priority": {"duration": "none"},
}
# Iterate over widgets and update filters if applicable
for widget in Widget.objects.all():
if widget.key in filters_mapping:
widget.filters = filters_mapping[widget.key]
widgets_to_update.append(widget)
# Bulk update the widgets
Widget.objects.bulk_update(widgets_to_update, ["filters"], batch_size=10)
class Migration(migrations.Migration):
dependencies = [
('db', '0058_alter_moduleissue_issue_and_more'),
]
operations = [
migrations.RunPython(widgets_filter_change)
]

View File

@ -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),
),
]

View File

@ -68,6 +68,7 @@ class Cycle(ProjectBaseModel):
sort_order = models.FloatField(default=65535) sort_order = models.FloatField(default=65535)
external_source = models.CharField(max_length=255, null=True, blank=True) external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True) external_id = models.CharField(max_length=255, blank=True, null=True)
progress_snapshot = models.JSONField(default=dict)
class Meta: class Meta:
verbose_name = "Cycle" verbose_name = "Cycle"

View File

@ -282,10 +282,8 @@ if REDIS_SSL:
redis_url = os.environ.get("REDIS_URL") redis_url = os.environ.get("REDIS_URL")
broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" broker_url = f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
CELERY_BROKER_URL = broker_url CELERY_BROKER_URL = broker_url
CELERY_RESULT_BACKEND = broker_url
else: else:
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_IMPORTS = ( CELERY_IMPORTS = (
"plane.bgtasks.issue_automation_task", "plane.bgtasks.issue_automation_task",

View File

@ -66,7 +66,7 @@
style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px" style="margin-left: 30px; margin-bottom: 20px; margin-top: 20px"
> >
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/plane-logo.png"
width="130" width="130"
height="40" height="40"
border="0" border="0"
@ -108,25 +108,33 @@
margin-bottom: 15px; margin-bottom: 15px;
" "
/> />
{% if actors_involved == 1 %} {% if actors_involved == 1 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
{{summary}} {{summary}}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{{ data.0.actor_detail.first_name}} {% if data|length > 0 %}
{{data.0.actor_detail.last_name}} {{ data.0.actor_detail.first_name}}
</span>. {{data.0.actor_detail.last_name}}
</p> {% else %}
{% else %} {{ comments.0.actor_detail.first_name}}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{comments.0.actor_detail.last_name}}
{{summary}} {% endif %}
<span style="font-size: 1rem; font-weight: 700; line-height: 28px"> </span>.
{{ data.0.actor_detail.first_name}} </p>
{{data.0.actor_detail.last_name }} {% else %}
</span>and others. <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
</p> {{summary}}
{% endif %} <span style="font-size: 1rem; font-weight: 700; line-height: 28px">
{% if data|length > 0 %}
{{ data.0.actor_detail.first_name}}
{{data.0.actor_detail.last_name}}
{% else %}
{{ comments.0.actor_detail.first_name}}
{{comments.0.actor_detail.last_name}}
{% endif %}
</span>and others.
</p>
{% endif %}
<!-- {% if actors_involved == 1 %} <!-- {% if actors_involved == 1 %}
{% if data|length > 0 and comments|length == 0 %} {% if data|length > 0 and comments|length == 0 %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px">
@ -272,7 +280,7 @@
<tr> <tr>
<td> <td>
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/due-date.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -333,7 +341,7 @@
<tr> <tr>
<td> <td>
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/duplicate.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -428,7 +436,7 @@
<tr> <tr>
<td valign="top" style="white-space: nowrap; padding: 0px;"> <td valign="top" style="white-space: nowrap; padding: 0px;">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/assignee.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -524,7 +532,7 @@
<tr> <tr>
<td valign="top" style="white-space: nowrap; padding: 0px;"> <td valign="top" style="white-space: nowrap; padding: 0px;">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/labels.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -621,7 +629,7 @@
<tr> <tr>
<td> <td>
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/state.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -639,15 +647,17 @@
State: State:
</p> </p>
</td> </td>
<td > {% if update.changes.state.old_value.0 == 'Backlog' or update.changes.state.old_value.0 == 'In Progress' or update.changes.state.old_value.0 == 'Done' or update.changes.state.old_value.0 == 'Cancelled' %}
<td>
<img <img
src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}" src="{% if update.changes.state.old_value.0 == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% endif %}{% if update.changes.state.old_value.0 == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% endif %}{% if update.changes.state.old_value.0 == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% endif %}{% if update.changes.state.old_value.0 == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
width="12" width="12"
height="12" height="12"
border="0" border="0"
style="display: block; margin-left: 5px;" style="display: block; margin-left: 5px;"
/> />
</td> </td>
{% endif %}
<td> <td>
<p <p
style=" style="
@ -661,22 +671,24 @@
</td> </td>
<td style="padding-left: 10px; padding-right: 10px;"> <td style="padding-left: 10px; padding-right: 10px;">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
width="16" width="16"
height="16" height="16"
border="0" border="0"
style="display: block;" style="display: block;"
/> />
</td> </td>
<td > {% if update.changes.state.new_value|last == 'Backlog' or update.changes.state.new_value|last == 'In Progress' or update.changes.state.new_value|last == 'Done' or update.changes.state.new_value|last == 'Cancelled' %}
<td>
<img <img
src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.webp{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.webp{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.webp{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.webp{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.webp{% endif %}" src="{% if update.changes.state.new_value|last == 'Backlog' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/backlog.png{% elif update.changes.state.new_value|last == 'In Progress' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/in-progress.png{% elif update.changes.state.new_value|last == 'Todo' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/todo.png{% elif update.changes.state.new_value|last == 'Done' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/done.png{% elif update.changes.state.new_value|last == 'Cancelled' %}https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/cancelled.png{% endif %}"
width="12" width="12"
height="12" height="12"
border="0" border="0"
style="display: block;" style="display: block;"
/> />
</td> </td>
{% endif %}
<td> <td>
<p <p
style=" style="
@ -699,7 +711,7 @@
<tr> <tr>
<td valign="top"> <td valign="top">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/link.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -760,7 +772,7 @@
<tr> <tr>
<td> <td>
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/priority.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"
@ -800,7 +812,7 @@
</td> </td>
<td style="padding-left: 10px; padding-right: 10px;"> <td style="padding-left: 10px; padding-right: 10px;">
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/forward-arrow.png"
width="16" width="16"
height="16" height="16"
border="0" border="0"
@ -838,7 +850,7 @@
<tr style="overflow-wrap: break-word;"> <tr style="overflow-wrap: break-word;">
<td> <td>
<img <img
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.webp" src="https://plane-marketing.s3.ap-south-1.amazonaws.com/plane-assets/emails/blocking.png"
width="12" width="12"
height="12" height="12"
border="0" border="0"

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# Check if the user has sudo access
if command -v curl &> /dev/null; then if command -v curl &> /dev/null; then
sudo curl -sSL \ sudo curl -sSL \
-o /usr/local/bin/plane-app \ -o /usr/local/bin/plane-app \
@ -11,6 +12,6 @@ else
fi fi
sudo chmod +x /usr/local/bin/plane-app 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 plane-app --help

View File

@ -17,7 +17,7 @@ Project management tool from the future
EOF EOF
} }
function update_env_files() { function update_env_file() {
config_file=$1 config_file=$1
key=$2 key=$2
value=$3 value=$3
@ -25,14 +25,16 @@ function update_env_files() {
# Check if the config file exists # Check if the config file exists
if [ ! -f "$config_file" ]; then if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2 echo "Config file not found. Creating a new one..." >&2
touch "$config_file" sudo touch "$config_file"
fi fi
# Check if the key already exists in the config file # Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then if sudo grep "^$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" 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 else
echo "$key=$value" >> "$config_file" # sudo echo "$key=$value" >> "$config_file"
echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null
fi fi
} }
function read_env_file() { function read_env_file() {
@ -42,12 +44,12 @@ function read_env_file() {
# Check if the config file exists # Check if the config file exists
if [ ! -f "$config_file" ]; then if [ ! -f "$config_file" ]; then
echo "Config file not found. Creating a new one..." >&2 echo "Config file not found. Creating a new one..." >&2
touch "$config_file" sudo touch "$config_file"
fi fi
# Check if the key already exists in the config file # Check if the key already exists in the config file
if grep -q "^$key=" "$config_file"; then if sudo grep -q "^$key=" "$config_file"; then
value=$(awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file")
echo "$value" echo "$value"
else else
echo "" echo ""
@ -55,19 +57,19 @@ function read_env_file() {
} }
function update_config() { function update_config() {
config_file="$PLANE_INSTALL_DIR/config.env" config_file="$PLANE_INSTALL_DIR/config.env"
update_env_files "$config_file" "$1" "$2" update_env_file $config_file $1 $2
} }
function read_config() { function read_config() {
config_file="$PLANE_INSTALL_DIR/config.env" config_file="$PLANE_INSTALL_DIR/config.env"
read_env_file "$config_file" "$1" read_env_file $config_file $1
} }
function update_env() { function update_env() {
config_file="$PLANE_INSTALL_DIR/.env" config_file="$PLANE_INSTALL_DIR/.env"
update_env_files "$config_file" "$1" "$2" update_env_file $config_file $1 $2
} }
function read_env() { function read_env() {
config_file="$PLANE_INSTALL_DIR/.env" config_file="$PLANE_INSTALL_DIR/.env"
read_env_file "$config_file" "$1" read_env_file $config_file $1
} }
function show_message() { function show_message() {
print_header print_header
@ -87,14 +89,14 @@ function prepare_environment() {
show_message "Prepare Environment..." >&2 show_message "Prepare Environment..." >&2
show_message "- Updating OS with required tools ✋" >&2 show_message "- Updating OS with required tools ✋" >&2
sudo apt-get update -y &> /dev/null sudo "$PACKAGE_MANAGER" update -y
sudo apt-get upgrade -y &> /dev/null 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 for tool in "${required_tools[@]}"; do
if ! command -v $tool &> /dev/null; then if ! command -v $tool &> /dev/null; then
sudo apt install -y $tool &> /dev/null sudo "$PACKAGE_MANAGER" install -y $tool
fi fi
done done
@ -103,11 +105,30 @@ function prepare_environment() {
# Install Docker if not installed # Install Docker if not installed
if ! command -v docker &> /dev/null; then if ! command -v docker &> /dev/null; then
show_message "- Installing Docker ✋" >&2 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 if [ "$PACKAGE_MANAGER" == "yum" ]; then
dockerd-rootless-setuptool.sh install &> /dev/null 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 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 show_message "- Docker Installed ✅" "replace_last_line" >&2
else else
show_message "- Docker is already installed ✅" >&2 show_message "- Docker is already installed ✅" >&2
@ -127,17 +148,17 @@ function prepare_environment() {
function download_plane() { function download_plane() {
# Download Docker Compose File from github url # Download Docker Compose File from github url
show_message "Downloading Plane Setup Files ✋" >&2 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 \ -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 \ -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 .env does not exists rename variables-upgrade.env to .env
if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then 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 fi
show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2 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 PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp
sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null
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 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 "" >&2
# show_message "Building Plane Images" >&2 # show_message "Building Plane Images" >&2
update_env "DOCKERHUB_USER" "makeplane"
update_env "PULL_POLICY" "always"
CURR_DIR=$(pwd) CURR_DIR=$(pwd)
if [ "$BRANCH" == "master" ]; then if [ "$DEPLOY_BRANCH" == "master" ]; then
update_env "APP_RELEASE" "latest" update_env "APP_RELEASE" "latest"
export APP_RELEASE=latest export APP_RELEASE=latest
else else
update_env "APP_RELEASE" "$BRANCH" update_env "APP_RELEASE" "$DEPLOY_BRANCH"
export APP_RELEASE=$BRANCH export APP_RELEASE=$DEPLOY_BRANCH
fi 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 # 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..." echo "Building Plane Images for $CPU_ARCH is not required. Skipping..."
else else
export DOCKERHUB_USER=myplane export DOCKERHUB_USER=myplane
show_message "Building Plane Images for $CPU_ARCH " >&2 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" update_env "PULL_POLICY" "never"
build_local_image 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 sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml
show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2 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 show_message "Plane Images Downloaded ✅" "replace_last_line" >&2
} }
function configure_plane() { function configure_plane() {
@ -453,9 +475,11 @@ function install() {
show_message "" show_message ""
if [ "$(uname)" == "Linux" ]; then if [ "$(uname)" == "Linux" ]; then
OS="linux" OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ]; then print_header
if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true OS_SUPPORTED=true
show_message "******** Installing Plane ********" show_message "******** Installing Plane ********"
show_message "" show_message ""
@ -488,7 +512,8 @@ function install() {
fi fi
else else
PROGRESS_MSG="❌❌❌ Unsupported OS Detected ❌❌❌" OS_SUPPORTED=false
PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message "" show_message ""
exit 1 exit 1
fi fi
@ -499,12 +524,17 @@ function install() {
fi fi
} }
function upgrade() { function upgrade() {
print_header
if [ "$(uname)" == "Linux" ]; then if [ "$(uname)" == "Linux" ]; then
OS="linux" OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ]; then if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true OS_SUPPORTED=true
show_message "******** Upgrading Plane ********"
show_message ""
prepare_environment prepare_environment
@ -528,53 +558,49 @@ function upgrade() {
exit 1 exit 1
fi fi
else else
PROGRESS_MSG="Unsupported OS Detected" PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message "" show_message ""
exit 1 exit 1
fi fi
else else
PROGRESS_MSG="Unsupported OS Detected : $(uname)" PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
show_message "" show_message ""
exit 1 exit 1
fi fi
} }
function uninstall() { function uninstall() {
print_header
if [ "$(uname)" == "Linux" ]; then if [ "$(uname)" == "Linux" ]; then
OS="linux" OS="linux"
OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release)
# check the OS OS_NAME=$(echo "$OS_NAME" | tr -d '"')
if [ "$OS_NAME" == "ubuntu" ]; then if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] ||
[ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then
OS_SUPPORTED=true OS_SUPPORTED=true
show_message "******** Uninstalling Plane ********" show_message "******** Uninstalling Plane ********"
show_message "" show_message ""
stop_server 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 if ! [ -x "$(command -v docker)" ]; then
echo "DOCKER_NOT_INSTALLED" &> /dev/null echo "DOCKER_NOT_INSTALLED" &> /dev/null
else else
# Ask of user input to confirm uninstall docker ? # 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 if [ $? -eq 0 ]; then
show_message "- Uninstalling Docker ✋" 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 docker images -q | xargs -r sudo docker rmi -f &> /dev/null
sudo apt-get autoremove -y --purge docker-engine docker docker.io docker-ce docker-compose-plugin &> /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 show_message "- Docker Uninstalled ✅" "replace_last_line" >&2
fi fi
fi fi
rm $PLANE_INSTALL_DIR/.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null
rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null
rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null
rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null
# rm -rf $PLANE_INSTALL_DIR &> /dev/null # rm -rf $PLANE_INSTALL_DIR &> /dev/null
show_message "- Configuration Cleaned ✅" show_message "- Configuration Cleaned ✅"
@ -593,12 +619,12 @@ function uninstall() {
show_message "" show_message ""
show_message "" show_message ""
else else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌"
show_message "" show_message ""
exit 1 exit 1
fi fi
else else
PROGRESS_MSG="Unsupported OS Detected : $(uname) ❌" PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌"
show_message "" show_message ""
exit 1 exit 1
fi fi
@ -608,15 +634,15 @@ function start_server() {
env_file="$PLANE_INSTALL_DIR/.env" env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits # check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Starting Plane Server ✋" show_message "Starting Plane Server ($APP_RELEASE) ✋"
docker compose -f $docker_compose_file --env-file=$env_file up -d sudo docker compose -f $docker_compose_file --env-file=$env_file up -d
# Wait for containers to be running # Wait for containers to be running
echo "Waiting for containers to start..." 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 sleep 1
done done
show_message "Plane Server Started ✅" "replace_last_line" >&2 show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2
else else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi fi
@ -626,11 +652,11 @@ function stop_server() {
env_file="$PLANE_INSTALL_DIR/.env" env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits # check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Stopping Plane Server ✋" show_message "Stopping Plane Server ($APP_RELEASE) ✋"
docker compose -f $docker_compose_file --env-file=$env_file down sudo docker compose -f $docker_compose_file --env-file=$env_file down
show_message "Plane Server Stopped ✅" "replace_last_line" >&2 show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2
else 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 fi
} }
function restart_server() { function restart_server() {
@ -638,9 +664,9 @@ function restart_server() {
env_file="$PLANE_INSTALL_DIR/.env" env_file="$PLANE_INSTALL_DIR/.env"
# check if both the files exits # check if both the files exits
if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then
show_message "Restarting Plane Server ✋" show_message "Restarting Plane Server ($APP_RELEASE) ✋"
docker compose -f $docker_compose_file --env-file=$env_file restart sudo docker compose -f $docker_compose_file --env-file=$env_file restart
show_message "Plane Server Restarted ✅" "replace_last_line" >&2 show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2
else else
show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2
fi fi
@ -666,28 +692,45 @@ function show_help() {
} }
function update_installer() { function update_installer() {
show_message "Updating Plane Installer ✋" >&2 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 \ -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 show_message "Plane Installer Updated ✅" "replace_last_line" >&2
} }
export BRANCH=${BRANCH:-master} export DEPLOY_BRANCH=${BRANCH:-master}
export APP_RELEASE=$BRANCH export APP_RELEASE=$DEPLOY_BRANCH
export DOCKERHUB_USER=makeplane export DOCKERHUB_USER=makeplane
export PULL_POLICY=always export PULL_POLICY=always
if [ "$DEPLOY_BRANCH" == "master" ]; then
export APP_RELEASE=latest
fi
PLANE_INSTALL_DIR=/opt/plane PLANE_INSTALL_DIR=/opt/plane
DATA_DIR=$PLANE_INSTALL_DIR/data DATA_DIR=$PLANE_INSTALL_DIR/data
LOG_DIR=$PLANE_INSTALL_DIR/log LOG_DIR=$PLANE_INSTALL_DIR/log
OS_SUPPORTED=false OS_SUPPORTED=false
CPU_ARCH=$(uname -m) CPU_ARCH=$(uname -m)
PROGRESS_MSG="" 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 if [ "$1" == "start" ]; then
start_server start_server
@ -704,7 +747,7 @@ elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then
upgrade upgrade
elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then
uninstall uninstall
elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ] ; then elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then
update_installer update_installer
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
show_help show_help

View File

@ -38,10 +38,6 @@ x-app-env : &app-env
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - 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 # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}

View File

@ -20,8 +20,8 @@ function buildLocalImage() {
DO_BUILD="2" DO_BUILD="2"
else else
printf "\n" >&2 printf "\n" >&2
printf "${YELLOW}You are on ${ARCH} cpu architecture. ${NC}\n" >&2 printf "${YELLOW}You are on ${CPU_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}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 "${YELLOW}This might take ${YELLOW}5-30 min based on your system's hardware configuration. \n ${NC} \n" >&2
printf "\n" >&2 printf "\n" >&2
printf "${GREEN}Select an option to proceed: ${NC}\n" >&2 printf "${GREEN}Select an option to proceed: ${NC}\n" >&2
@ -49,7 +49,7 @@ function buildLocalImage() {
cd $PLANE_TEMP_CODE_DIR cd $PLANE_TEMP_CODE_DIR
if [ "$BRANCH" == "master" ]; if [ "$BRANCH" == "master" ];
then then
APP_RELEASE=latest export APP_RELEASE=latest
fi fi
docker compose -f build.yml build --no-cache >&2 docker compose -f build.yml build --no-cache >&2
@ -149,7 +149,7 @@ function upgrade() {
function askForAction() { function askForAction() {
echo echo
echo "Select a Action you want to perform:" echo "Select a Action you want to perform:"
echo " 1) Install (${ARCH})" echo " 1) Install (${CPU_ARCH})"
echo " 2) Start" echo " 2) Start"
echo " 3) Stop" echo " 3) Stop"
echo " 4) Restart" echo " 4) Restart"
@ -193,8 +193,8 @@ function askForAction() {
} }
# CPU ARCHITECHTURE BASED SETTINGS # CPU ARCHITECHTURE BASED SETTINGS
ARCH=$(uname -m) CPU_ARCH=$(uname -m)
if [ $ARCH == "amd64" ] || [ $ARCH == "x86_64" ]; if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]];
then then
USE_GLOBAL_IMAGES=1 USE_GLOBAL_IMAGES=1
DOCKERHUB_USER=makeplane DOCKERHUB_USER=makeplane
@ -205,6 +205,11 @@ else
PULL_POLICY=never PULL_POLICY=never
fi fi
if [ "$BRANCH" == "master" ];
then
export APP_RELEASE=latest
fi
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME # REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
if [ "$BRANCH" != "master" ]; if [ "$BRANCH" != "master" ];
then then

View File

@ -8,13 +8,13 @@ NGINX_PORT=80
WEB_URL=http://localhost WEB_URL=http://localhost
DEBUG=0 DEBUG=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN="" SENTRY_DSN=
SENTRY_ENVIRONMENT="production" SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID="" GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET="" GITHUB_CLIENT_SECRET=
DOCKERIZED=1 # deprecated DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS #DB SETTINGS
PGHOST=plane-db PGHOST=plane-db
@ -31,19 +31,14 @@ REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/ REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS # EMAIL SETTINGS
EMAIL_HOST="" EMAIL_HOST=
EMAIL_HOST_USER="" EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD="" EMAIL_HOST_PASSWORD=
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>" EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_USE_TLS=1 EMAIL_USE_TLS=1
EMAIL_USE_SSL=0 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 # LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1 ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1 ENABLE_EMAIL_PASSWORD=1
@ -52,13 +47,13 @@ SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS # DATA STORE SETTINGS
USE_MINIO=1 USE_MINIO=1
AWS_REGION="" AWS_REGION=
AWS_ACCESS_KEY_ID="access-key" AWS_ACCESS_KEY_ID=access-key
AWS_SECRET_ACCESS_KEY="secret-key" AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000 AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER="access-key" MINIO_ROOT_USER=access-key
MINIO_ROOT_PASSWORD="secret-key" MINIO_ROOT_PASSWORD=secret-key
BUCKET_NAME=uploads BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880

View File

@ -31,6 +31,7 @@ export interface ICycle {
issue: string; issue: string;
name: string; name: string;
owned_by: string; owned_by: string;
progress_snapshot: TProgressSnapshot;
project: string; project: string;
project_detail: IProjectLite; project_detail: IProjectLite;
status: TCycleGroups; status: TCycleGroups;
@ -49,6 +50,23 @@ export interface ICycle {
workspace_detail: IWorkspaceLite; workspace_detail: IWorkspaceLite;
} }
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 = { export type TAssigneesDistribution = {
assignee_id: string | null; assignee_id: string | null;
avatar: string | null; avatar: string | null;

View File

@ -24,21 +24,21 @@ export type TDurationFilterOptions =
// widget filters // widget filters
export type TAssignedIssuesWidgetFilters = { export type TAssignedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions; duration?: TDurationFilterOptions;
tab?: TIssuesListTypes; tab?: TIssuesListTypes;
}; };
export type TCreatedIssuesWidgetFilters = { export type TCreatedIssuesWidgetFilters = {
target_date?: TDurationFilterOptions; duration?: TDurationFilterOptions;
tab?: TIssuesListTypes; tab?: TIssuesListTypes;
}; };
export type TIssuesByStateGroupsWidgetFilters = { export type TIssuesByStateGroupsWidgetFilters = {
target_date?: TDurationFilterOptions; duration?: TDurationFilterOptions;
}; };
export type TIssuesByPriorityWidgetFilters = { export type TIssuesByPriorityWidgetFilters = {
target_date?: TDurationFilterOptions; duration?: TDurationFilterOptions;
}; };
export type TWidgetFiltersFormData = export type TWidgetFiltersFormData =

View File

@ -221,3 +221,12 @@ export interface IGroupByColumn {
export interface IIssueMap { export interface IIssueMap {
[key: string]: TIssue; [key: string]: TIssue;
} }
export interface IIssueListRow {
id: string;
groupId: string;
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
name?: string;
icon?: ReactElement | undefined;
payload?: Partial<TIssue>;
}

View File

@ -10,7 +10,7 @@ type BreadcrumbsProps = {
const Breadcrumbs = ({ children }: BreadcrumbsProps) => ( const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{React.Children.map(children, (child, index) => ( {React.Children.map(children, (child, index) => (
<div key={index} className="flex flex-wrap items-center gap-2.5"> <div key={index} className="flex items-center gap-2.5">
{child} {child}
{index !== React.Children.count(children) - 1 && ( {index !== React.Children.count(children) - 1 && (
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" /> <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />

View File

@ -15,6 +15,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const { const {
buttonClassName = "", buttonClassName = "",
customButtonClassName = "", customButtonClassName = "",
customButtonTabIndex = 0,
placement, placement,
children, children,
className = "", className = "",
@ -29,6 +30,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
verticalEllipsis = false, verticalEllipsis = false,
portalElement, portalElement,
menuButtonOnClick, menuButtonOnClick,
onMenuClose,
tabIndex, tabIndex,
closeOnSelect, closeOnSelect,
} = props; } = props;
@ -47,18 +49,28 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
setIsOpen(true); setIsOpen(true);
if (referenceElement) referenceElement.focus(); if (referenceElement) referenceElement.focus();
}; };
const closeDropdown = () => setIsOpen(false); const closeDropdown = () => {
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); isOpen && onMenuClose && onMenuClose();
setIsOpen(false);
};
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();
};
useOutsideClickDetector(dropdownRef, closeDropdown); useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = ( let menuItems = (
<Menu.Items <Menu.Items className="fixed z-10" static>
className="fixed z-10"
onClick={() => {
if (closeOnSelect) closeDropdown();
}}
static
>
<div <div
className={cn( className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap", "my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@ -89,7 +101,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
ref={dropdownRef} ref={dropdownRef}
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("relative w-min text-left", className)} className={cn("relative w-min text-left", className)}
onKeyDown={handleKeyDown} onKeyDownCapture={handleKeyDown}
onClick={handleOnClick}
> >
{({ open }) => ( {({ open }) => (
<> <>
@ -98,11 +111,13 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
className={customButtonClassName} className={customButtonClassName}
tabIndex={customButtonTabIndex}
> >
{customButton} {customButton}
</button> </button>
@ -114,7 +129,8 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
@ -122,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 ${ 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" disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
tabIndex={customButtonTabIndex}
> >
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} /> <MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
</button> </button>
@ -138,10 +155,12 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
? "cursor-not-allowed text-custom-text-200" ? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={() => { onClick={(e) => {
e.stopPropagation();
openDropdown(); openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
}} }}
tabIndex={customButtonTabIndex}
> >
{label} {label}
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />} {!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
@ -159,6 +178,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
const MenuItem: React.FC<ICustomMenuItemProps> = (props) => { const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
const { children, onClick, className = "" } = props; const { children, onClick, className = "" } = props;
return ( return (
<Menu.Item as="div"> <Menu.Item as="div">
{({ active, close }) => ( {({ active, close }) => (

View File

@ -3,6 +3,7 @@ import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps { export interface IDropdownProps {
customButtonClassName?: string; customButtonClassName?: string;
customButtonTabIndex?: number;
buttonClassName?: string; buttonClassName?: string;
className?: string; className?: string;
customButton?: JSX.Element; customButton?: JSX.Element;
@ -23,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
noBorder?: boolean; noBorder?: boolean;
verticalEllipsis?: boolean; verticalEllipsis?: boolean;
menuButtonOnClick?: (...args: any) => void; menuButtonOnClick?: (...args: any) => void;
onMenuClose?: () => void;
closeOnSelect?: boolean; closeOnSelect?: boolean;
portalElement?: Element | null; portalElement?: Element | null;
} }

View File

@ -0,0 +1,67 @@
import * as React from "react";
export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
intermediate?: boolean;
className?: string;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>((props, ref) => {
const { id, name, checked, intermediate = false, disabled, className = "", ...rest } = props;
return (
<div className={`relative w-full flex gap-2 ${className}`}>
<input
id={id}
ref={ref}
type="checkbox"
name={name}
checked={checked}
className={`
appearance-none shrink-0 w-4 h-4 border rounded-[3px] focus:outline-1 focus:outline-offset-4 focus:outline-custom-primary-50
${
disabled
? "border-custom-border-200 bg-custom-background-80 cursor-not-allowed"
: `cursor-pointer ${
checked || intermediate
? "border-custom-primary-40 bg-custom-primary-100 hover:bg-custom-primary-200"
: "border-custom-border-300 hover:border-custom-border-400 bg-white"
}`
}
`}
disabled={disabled}
{...rest}
/>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
className={`absolute w-4 h-4 p-0.5 pointer-events-none outline-none ${
disabled ? "stroke-custom-text-400 opacity-40" : "stroke-white"
} ${intermediate && !checked ? "block" : "hidden"}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 8 8"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.75 4H2.25" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
);
});
Checkbox.displayName = "form-checkbox-field";
export { Checkbox };

View File

@ -1,3 +1,4 @@
export * from "./input"; export * from "./input";
export * from "./textarea"; export * from "./textarea";
export * from "./input-color-picker"; export * from "./input-color-picker";
export * from "./checkbox";

View File

@ -1,16 +1,23 @@
import { useCallback } from "react"; import { useCallback } from "react";
type TUseDropdownKeyDown = { type TUseDropdownKeyDown = {
(onOpen: () => void, onClose: () => void, isOpen: boolean): (event: React.KeyboardEvent<HTMLElement>) => void; (
onOpen: () => void,
onClose: () => void,
isOpen: boolean,
selectActiveItem?: () => void
): (event: React.KeyboardEvent<HTMLElement>) => void;
}; };
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen) => { export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => { (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.stopPropagation();
if (!isOpen) { if (!isOpen) {
event.stopPropagation();
onOpen(); onOpen();
} else {
selectActiveItem && selectActiveItem();
} }
} else if (event.key === "Escape" && isOpen) { } else if (event.key === "Escape" && isOpen) {
event.stopPropagation(); event.stopPropagation();

View File

@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// icons // icons
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -34,6 +36,8 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states // states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -63,21 +67,34 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!", title: "Success!",
message: "Password created successfully.", message: "Password created successfully.",
}); });
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "SUCCESS",
first_time: false,
});
await handleSignInRedirection(); await handleSignInRedirection();
}) })
.catch((err) => .catch((err) => {
captureEvent(PASSWORD_CREATE_SELECTED, {
state: "FAILED",
first_time: false,
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) });
); });
}; };
const handleGoToWorkspace = async () => { const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true); setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => {
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: false,
});
setIsGoingToWorkspace(false);
});
}; };
return ( return (

View File

@ -7,7 +7,7 @@ import { Eye, EyeOff, XCircle } from "lucide-react";
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useApplication } from "hooks/store"; import { useApplication, useEventTracker } from "hooks/store";
// components // components
import { ESignInSteps, ForgotPasswordPopover } from "components/account"; import { ESignInSteps, ForgotPasswordPopover } from "components/account";
// ui // ui
@ -16,6 +16,8 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IPasswordSignInData } from "@plane/types"; import { IPasswordSignInData } from "@plane/types";
// constants
import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -46,6 +48,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureEvent } = useEventTracker();
// derived values // derived values
const isSmtpConfigured = envConfig?.is_smtp_configured; const isSmtpConfigured = envConfig?.is_smtp_configured;
// form info // form info
@ -72,7 +75,13 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
await authService await authService
.passwordSignIn(payload) .passwordSignIn(payload)
.then(async () => await onSubmit()) .then(async () => {
captureEvent(SIGN_IN_WITH_PASSWORD, {
state: "SUCCESS",
first_time: false,
});
await onSubmit();
})
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -182,9 +191,10 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
</div> </div>
)} )}
/> />
<div className="w-full text-right mt-2 pb-3"> <div className="mt-2 w-full pb-3 text-right">
{isSmtpConfigured ? ( {isSmtpConfigured ? (
<Link <Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${email}`} href={`/accounts/forgot-password?email=${email}`}
className="text-xs font-medium text-custom-primary-100" className="text-xs font-medium text-custom-primary-100"
> >

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useApplication } from "hooks/store"; import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection"; import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { LatestFeatureBlock } from "components/common"; import { LatestFeatureBlock } from "components/common";
@ -13,6 +13,8 @@ import {
OAuthOptions, OAuthOptions,
SignInOptionalSetPasswordForm, SignInOptionalSetPasswordForm,
} from "components/account"; } from "components/account";
// constants
import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker";
export enum ESignInSteps { export enum ESignInSteps {
EMAIL = "EMAIL", EMAIL = "EMAIL",
@ -32,6 +34,7 @@ export const SignInRoot = observer(() => {
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureEvent } = useEventTracker();
// derived values // derived values
const isSmtpConfigured = envConfig?.is_smtp_configured; const isSmtpConfigured = envConfig?.is_smtp_configured;
@ -110,7 +113,11 @@ export const SignInRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" /> <OAuthOptions handleSignInRedirection={handleRedirection} type="sign_in" />
<p className="text-xs text-onboarding-text-300 text-center mt-6"> <p className="text-xs text-onboarding-text-300 text-center mt-6">
Don{"'"}t have an account?{" "} Don{"'"}t have an account?{" "}
<Link href="/accounts/sign-up" className="text-custom-primary-100 font-medium underline"> <Link
href="/accounts/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="text-custom-primary-100 font-medium underline"
>
Sign up Sign up
</Link> </Link>
</p> </p>

View File

@ -7,12 +7,15 @@ import { UserService } from "services/user.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer"; import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData, IMagicSignInData } from "@plane/types"; import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -41,6 +44,8 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// store hooks
const { captureEvent } = useEventTracker();
// timer // timer
const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30);
// form info // form info
@ -69,17 +74,22 @@ export const SignInUniqueCodeForm: React.FC<Props> = (props) => {
await authService await authService
.magicSignIn(payload) .magicSignIn(payload)
.then(async () => { .then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser(); const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset); await onSubmit(currentUser.is_password_autoset);
}) })
.catch((err) => .catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) });
); });
}; };
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {

View File

@ -4,12 +4,14 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useEventTracker } from "hooks/store";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// constants // constants
import { ESignUpSteps } from "components/account"; import { ESignUpSteps } from "components/account";
import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker";
// icons // icons
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
@ -37,6 +39,8 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
// states // states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false); const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -66,21 +70,34 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
title: "Success!", title: "Success!",
message: "Password created successfully.", message: "Password created successfully.",
}); });
captureEvent(SETUP_PASSWORD, {
state: "SUCCESS",
first_time: true,
});
await handleSignInRedirection(); await handleSignInRedirection();
}) })
.catch((err) => .catch((err) => {
captureEvent(SETUP_PASSWORD, {
state: "FAILED",
first_time: true,
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) });
); });
}; };
const handleGoToWorkspace = async () => { const handleGoToWorkspace = async () => {
setIsGoingToWorkspace(true); setIsGoingToWorkspace(true);
await handleSignInRedirection().finally(() => {
await handleSignInRedirection().finally(() => setIsGoingToWorkspace(false)); captureEvent(PASSWORD_CREATE_SKIPPED, {
state: "SUCCESS",
first_time: true,
});
setIsGoingToWorkspace(false);
});
}; };
return ( return (

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useApplication } from "hooks/store"; import { useApplication, useEventTracker } from "hooks/store";
import useSignInRedirection from "hooks/use-sign-in-redirection"; import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { import {
@ -12,6 +12,8 @@ import {
SignUpUniqueCodeForm, SignUpUniqueCodeForm,
} from "components/account"; } from "components/account";
import Link from "next/link"; import Link from "next/link";
// constants
import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker";
export enum ESignUpSteps { export enum ESignUpSteps {
EMAIL = "EMAIL", EMAIL = "EMAIL",
@ -32,6 +34,7 @@ export const SignUpRoot = observer(() => {
const { const {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureEvent } = useEventTracker();
// step 1 submit handler- email verification // step 1 submit handler- email verification
const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE); const handleEmailVerification = () => setSignInStep(ESignUpSteps.UNIQUE_CODE);
@ -86,7 +89,11 @@ export const SignUpRoot = observer(() => {
<OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" /> <OAuthOptions handleSignInRedirection={handleRedirection} type="sign_up" />
<p className="text-xs text-onboarding-text-300 text-center mt-6"> <p className="text-xs text-onboarding-text-300 text-center mt-6">
Already using Plane?{" "} Already using Plane?{" "}
<Link href="/" className="text-custom-primary-100 font-medium underline"> <Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
className="text-custom-primary-100 font-medium underline"
>
Sign in Sign in
</Link> </Link>
</p> </p>

View File

@ -8,12 +8,15 @@ import { UserService } from "services/user.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useTimer from "hooks/use-timer"; import useTimer from "hooks/use-timer";
import { useEventTracker } from "hooks/store";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// helpers // helpers
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// types // types
import { IEmailCheckData, IMagicSignInData } from "@plane/types"; import { IEmailCheckData, IMagicSignInData } from "@plane/types";
// constants
import { CODE_VERIFIED } from "constants/event-tracker";
type Props = { type Props = {
email: string; email: string;
@ -39,6 +42,8 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
const { email, handleEmailClear, onSubmit } = props; const { email, handleEmailClear, onSubmit } = props;
// states // states
const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); const [isRequestingNewCode, setIsRequestingNewCode] = useState(false);
// store hooks
const { captureEvent } = useEventTracker();
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// timer // timer
@ -69,17 +74,22 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
await authService await authService
.magicSignIn(payload) .magicSignIn(payload)
.then(async () => { .then(async () => {
captureEvent(CODE_VERIFIED, {
state: "SUCCESS",
});
const currentUser = await userService.currentUser(); const currentUser = await userService.currentUser();
await onSubmit(currentUser.is_password_autoset); await onSubmit(currentUser.is_password_autoset);
}) })
.catch((err) => .catch((err) => {
captureEvent(CODE_VERIFIED, {
state: "FAILED",
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) });
); });
}; };
const handleSendNewCode = async (formData: TUniqueCodeFormValues) => { const handleSendNewCode = async (formData: TUniqueCodeFormValues) => {
@ -96,7 +106,6 @@ export const SignUpUniqueCodeForm: React.FC<Props> = (props) => {
title: "Success!", title: "Success!",
message: "A new unique code has been sent to your email.", message: "A new unique code has been sent to your email.",
}); });
reset({ reset({
email: formData.email, email: formData.email,
token: "", token: "",

View File

@ -10,6 +10,8 @@ import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSi
import { IAnalyticsParams } from "@plane/types"; import { IAnalyticsParams } from "@plane/types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
import { useApplication } from "hooks/store";
type Props = { type Props = {
additionalParams?: Partial<IAnalyticsParams>; additionalParams?: Partial<IAnalyticsParams>;
@ -46,11 +48,13 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
); );
const { theme: themeStore } = useApplication();
const isProjectLevel = projectId ? true : false; const isProjectLevel = projectId ? true : false;
return ( return (
<div className={`flex flex-col-reverse overflow-hidden ${fullScreen ? "md:grid md:h-full md:grid-cols-4" : ""}`}> <div className={cn("relative w-full h-full flex overflow-hidden", isProjectLevel ? "flex-col-reverse" : "")}>
<div className="col-span-3 flex h-full flex-col overflow-hidden"> <div className="w-full flex h-full flex-col overflow-hidden">
<CustomAnalyticsSelectBar <CustomAnalyticsSelectBar
control={control} control={control}
setValue={setValue} setValue={setValue}
@ -61,16 +65,22 @@ export const CustomAnalytics: React.FC<Props> = observer((props) => {
<CustomAnalyticsMainContent <CustomAnalyticsMainContent
analytics={analytics} analytics={analytics}
error={analyticsError} error={analyticsError}
fullScreen={fullScreen}
params={params} params={params}
fullScreen={fullScreen}
/> />
</div> </div>
<CustomAnalyticsSidebar
analytics={analytics} <div
params={params} className={cn(
fullScreen={fullScreen} "border-l border-custom-border-200 transition-all",
isProjectLevel={isProjectLevel} !isProjectLevel
/> ? "absolute right-0 top-0 bottom-0 md:relative flex-shrink-0 h-full max-w-[250px] sm:max-w-full"
: ""
)}
style={themeStore.workspaceAnalyticsSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}}
>
<CustomAnalyticsSidebar analytics={analytics} params={params} isProjectLevel={isProjectLevel} />
</div>
</div> </div>
); );
}); });

View File

@ -22,9 +22,8 @@ export const CustomAnalyticsSelectBar: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-3" : "grid-cols-2"} ${ className={`grid items-center gap-4 px-5 py-2.5 ${isProjectLevel ? "grid-cols-1 sm:grid-cols-3" : "grid-cols-2"} ${fullScreen ? "md:py-5 lg:grid-cols-4" : ""
fullScreen ? "md:py-5 lg:grid-cols-4" : "" }`}
}`}
> >
{!isProjectLevel && ( {!isProjectLevel && (
<div> <div>

View File

@ -17,9 +17,9 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
const { getProjectById } = useProject(); const { getProjectById } = useProject();
return ( return (
<div className="hidden h-full overflow-hidden md:flex md:flex-col"> <div className="relative flex flex-col gap-4 h-full">
<h4 className="font-medium">Selected Projects</h4> <h4 className="font-medium">Selected Projects</h4>
<div className="mt-4 h-full space-y-6 overflow-y-auto"> <div className="relative space-y-6 overflow-hidden overflow-y-auto">
{projectIds.map((projectId) => { {projectIds.map((projectId) => {
const project = getProjectById(projectId); const project = getProjectById(projectId);

View File

@ -26,7 +26,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<> <>
{projectId ? ( {projectId ? (
cycleDetails ? ( cycleDetails ? (
<div className="hidden h-full overflow-y-auto md:block"> <div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4> <h4 className="break-words font-medium">Analytics for {cycleDetails.name}</h4>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
@ -52,7 +52,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div> </div>
</div> </div>
) : moduleDetails ? ( ) : moduleDetails ? (
<div className="hidden h-full overflow-y-auto md:block"> <div className="h-full overflow-y-auto">
<h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4> <h4 className="break-words font-medium">Analytics for {moduleDetails.name}</h4>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
@ -78,7 +78,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="hidden h-full overflow-y-auto md:flex md:flex-col"> <div className="h-full overflow-y-auto">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{projectDetails?.emoji ? ( {projectDetails?.emoji ? (
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div> <div className="grid h-6 w-6 flex-shrink-0 place-items-center">{renderEmoji(projectDetails.emoji)}</div>

View File

@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { mutate } from "swr"; import { mutate } from "swr";
@ -19,18 +19,18 @@ import { renderFormattedDate } from "helpers/date-time.helper";
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types";
// fetch-keys // fetch-keys
import { ANALYTICS } from "constants/fetch-keys"; import { ANALYTICS } from "constants/fetch-keys";
import { cn } from "helpers/common.helper";
type Props = { type Props = {
analytics: IAnalyticsResponse | undefined; analytics: IAnalyticsResponse | undefined;
params: IAnalyticsParams; params: IAnalyticsParams;
fullScreen: boolean;
isProjectLevel: boolean; isProjectLevel: boolean;
}; };
const analyticsService = new AnalyticsService(); const analyticsService = new AnalyticsService();
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => { export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, fullScreen, isProjectLevel = false } = props; const { analytics, params, isProjectLevel = false } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
@ -138,18 +138,14 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds;
return ( return (
<div <div className={cn("relative h-full flex w-full gap-2 justify-between items-start px-5 py-4 bg-custom-sidebar-background-100", !isProjectLevel ? "flex-col" : "")}
className={`flex items-center justify-between space-y-2 px-5 py-2.5 ${
fullScreen
? "overflow-hidden border-l border-custom-border-200 md:h-full md:flex-col md:items-start md:space-y-4 md:border-l md:border-custom-border-200 md:py-5"
: ""
}`}
> >
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
<LayersIcon height={14} width={14} /> <LayersIcon height={14} width={14} />
{analytics ? analytics.total : "..."} Issues {analytics ? analytics.total : "..."} <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Issues</div>
</div> </div>
{isProjectLevel && ( {isProjectLevel && (
<div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200"> <div className="flex items-center gap-1 rounded-md bg-custom-background-80 px-3 py-1 text-xs text-custom-text-200">
@ -158,36 +154,36 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
(cycleId (cycleId
? cycleDetails?.created_at ? cycleDetails?.created_at
: moduleId : moduleId
? moduleDetails?.created_at ? moduleDetails?.created_at
: projectDetails?.created_at) ?? "" : projectDetails?.created_at) ?? ""
)} )}
</div> </div>
)} )}
</div> </div>
<div className="h-full w-full overflow-hidden">
{fullScreen ? ( <div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<> <>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} /> <CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
)} )}
<CustomAnalyticsSidebarHeader /> <CustomAnalyticsSidebarHeader />
</> </>
) : null}
</div> </div>
<div className="flex flex-wrap items-center gap-2 justify-self-end">
<div className="flex flex-wrap items-center gap-2 justify-end">
<Button <Button
variant="neutral-primary" variant="neutral-primary"
prependIcon={<RefreshCw className="h-3.5 w-3.5" />} prependIcon={<RefreshCw className="h-3 md:h-3.5 w-3 md:w-3.5" />}
onClick={() => { onClick={() => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutate(ANALYTICS(workspaceSlug.toString(), params)); mutate(ANALYTICS(workspaceSlug.toString(), params));
}} }}
> >
Refresh <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Refresh</div>
</Button> </Button>
<Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}> <Button variant="primary" prependIcon={<Download className="h-3.5 w-3.5" />} onClick={exportAnalytics}>
Export as CSV <div className={cn(isProjectLevel ? "hidden md:block" : "")}>Export as CSV</div>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -20,16 +20,15 @@ export const ProjectAnalyticsModalMainContent: React.FC<Props> = observer((props
return ( return (
<Tab.Group as={React.Fragment}> <Tab.Group as={React.Fragment}>
<Tab.List as="div" className="space-x-2 border-b border-custom-border-200 p-5 pt-0"> <Tab.List as="div" className="flex space-x-2 border-b border-custom-border-200 px-0 md:px-5 py-0 md:py-3">
{ANALYTICS_TABS.map((tab) => ( {ANALYTICS_TABS.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
className={({ selected }) => className={({ selected }) =>
`rounded-3xl border border-custom-border-200 px-4 py-2 text-xs hover:bg-custom-background-80 ${ `rounded-0 w-full md:w-max md:rounded-3xl border-b md:border border-custom-border-200 focus:outline-none px-0 md:px-4 py-2 text-xs hover:bg-custom-background-80 ${selected ? "border-custom-primary-100 text-custom-primary-100 md:bg-custom-background-80 md:text-custom-text-200 md:border-custom-border-200" : "border-transparent"
selected ? "bg-custom-background-80" : ""
}` }`
} }
onClick={() => {}} onClick={() => { }}
> >
{tab.title} {tab.title}
</Tab> </Tab>

View File

@ -38,14 +38,12 @@ export const ProjectAnalyticsModal: React.FC<Props> = observer((props) => {
> >
<Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto"> <Dialog.Panel className="fixed inset-0 z-20 h-full w-full overflow-y-auto">
<div <div
className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${ className={`fixed right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${fullScreen ? "w-full p-2" : "w-full sm:w-full md:w-1/2"
fullScreen ? "w-full p-2" : "w-1/2" }`}
}`}
> >
<div <div
className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${ className={`flex h-full flex-col overflow-hidden border-custom-border-200 bg-custom-background-100 text-left ${fullScreen ? "rounded-lg border" : "border-l"
fullScreen ? "rounded-lg border" : "border-l" }`}
}`}
> >
<ProjectAnalyticsModalHeader <ProjectAnalyticsModalHeader
fullScreen={fullScreen} fullScreen={fullScreen}

View File

@ -163,6 +163,8 @@ export const CommandPalette: FC = observer(() => {
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]); }, [handleKeyDown]);
const isDraftIssue = router?.asPath?.includes("draft-issues") || false;
if (!currentUser) return null; if (!currentUser) return null;
return ( return (
@ -217,6 +219,7 @@ export const CommandPalette: FC = observer(() => {
onClose={() => toggleCreateIssueModal(false)} onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined} data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType} storeType={createIssueStoreType}
isDraft={isDraftIssue}
/> />
{workspaceSlug && projectId && issueId && issueDetails && ( {workspaceSlug && projectId && issueId && issueDetails && (

View File

@ -47,7 +47,7 @@ export const ShortcutsModal: FC<Props> = (props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="h-full w-full"> <Dialog.Panel className="h-full w-full">
<div className="grid h-full w-full place-items-center p-5"> <div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]"> <div className="flex h-[61vh] w-full flex-col space-y-4 overflow-hidden rounded-lg bg-custom-background-100 p-5 shadow-custom-shadow-md transition-all sm:w-[28rem]">
<Dialog.Title as="h3" className="flex justify-between"> <Dialog.Title as="h3" className="flex justify-between">
<span className="text-lg font-medium">Keyboard shortcuts</span> <span className="text-lg font-medium">Keyboard shortcuts</span>

View File

@ -11,7 +11,7 @@ export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props; const { href, label, icon } = props;
return ( return (
<Tooltip tooltipContent={label} position="bottom"> <Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2"> <li className="flex items-center space-x-2" tabIndex={-1}>
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{href ? ( {href ? (
<Link <Link

View File

@ -0,0 +1,80 @@
import { cn } from "helpers/common.helper";
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
type Props = {
defaultHeight?: string;
verticalOffset?: number;
horizonatlOffset?: number;
root?: MutableRefObject<HTMLElement | null>;
children: ReactNode;
as?: keyof JSX.IntrinsicElements;
classNames?: string;
alwaysRender?: boolean;
placeholderChildren?: ReactNode;
pauseHeightUpdateWhileRendering?: boolean;
changingReference?: any;
};
const RenderIfVisible: React.FC<Props> = (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<boolean>(alwaysRender);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(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;

View File

@ -3,7 +3,7 @@ import { Menu } from "lucide-react";
import { useApplication } from "hooks/store"; import { useApplication } from "hooks/store";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer (() => { export const SidebarHamburgerToggle: FC = observer(() => {
const { theme: themStore } = useApplication(); const { theme: themStore } = useApplication();
return ( return (
<div <div

View File

@ -150,7 +150,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color, color: group.color,
})); }));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date ?? new Date()); const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return ( return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow"> <div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">

View File

@ -0,0 +1,168 @@
import { useCallback, useState } from "react";
import router from "next/router";
//components
import { CustomMenu } from "@plane/ui";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
// hooks
import { useIssues, useCycle, useProjectState, useLabel, useMember } from "hooks/store";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "constants/issue";
import { ProjectAnalyticsModal } from "components/analytics";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues";
export const CycleMobileHeader = () => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const { getCycleById } = useCycle();
const layouts = [
{ key: "list", title: "List", icon: List },
{ key: "kanban", title: "Kanban", icon: Kanban },
{ key: "calendar", title: "Calendar", icon: Calendar },
];
const { workspaceSlug, projectId, cycleId } = router.query as {
workspaceSlug: string;
projectId: string;
cycleId: string;
};
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: TIssueLayouts) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
project: { projectMemberIds },
} = useMember();
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) {
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
} else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
return (
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<div className="flex justify-evenly py-2 border-b border-custom-border-200">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-custom-text-200 text-sm"
placement="bottom-start"
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{layouts.map((layout, index) => (
<CustomMenu.MenuItem
onClick={() => {
handleLayoutChange(ISSUE_LAYOUTS[index].key);
}}
className="flex items-center gap-2"
>
<layout.icon className="w-3 h-3" />
<div className="text-custom-text-300">{layout.title}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title="Filters"
placement="bottom-end"
menuButton={
<span className="flex items-center text-custom-text-200 text-sm">
Filters
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
</span>
}
>
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
</div>
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
<FiltersDropdown
title="Display"
placement="bottom-end"
menuButton={
<span className="flex items-center text-custom-text-200 text-sm">
Display
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
</span>
}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
</div>
<span
onClick={() => setAnalyticsModal(true)}
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
>
Analytics
</span>
</div>
</>
);
};

View File

@ -16,6 +16,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// constants // constants
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
//.types //.types
import { TCycleGroups } from "@plane/types"; import { TCycleGroups } from "@plane/types";
@ -33,7 +34,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
// router // router
const router = useRouter(); const router = useRouter();
// store // store
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -90,39 +91,55 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_FAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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<HTMLButtonElement>) => { const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_UNFAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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<HTMLButtonElement>) => { const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTrackElement("Cycles page board layout"); setTrackElement("Cycles page grid layout");
setUpdateModal(true); setUpdateModal(true);
}; };
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => { const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTrackElement("Cycles page board layout"); setTrackElement("Cycles page grid layout");
setDeleteModal(true); setDeleteModal(true);
}; };
@ -137,7 +154,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
return ( return (
<div> <div>

View File

@ -18,6 +18,7 @@ import { CYCLE_STATUS } from "constants/cycle";
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
// types // types
import { TCycleGroups } from "@plane/types"; import { TCycleGroups } from "@plane/types";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type TCyclesListItem = { type TCyclesListItem = {
cycleId: string; cycleId: string;
@ -37,7 +38,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -63,26 +64,42 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_FAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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<HTMLButtonElement>) => { const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).catch(() => { removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId)
setToastAlert({ .then(() => {
type: "error", captureEvent(CYCLE_UNFAVORITED, {
title: "Error!", cycle_id: cycleId,
message: "Couldn't add the cycle to favorites. Please try again.", 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<HTMLButtonElement>) => { const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
@ -140,7 +157,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date ?? new Date()); const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
return ( return (
<> <>
@ -159,10 +176,10 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
projectId={projectId} projectId={projectId}
/> />
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="group flex h-16 w-full items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90"> <div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
<div className="flex w-full items-center gap-3 truncate"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="flex items-center gap-4 truncate"> <div className="relative flex w-full items-center gap-3 overflow-hidden">
<span className="flex-shrink-0"> <div className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}> <CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? ( {isCompleted ? (
progress === 100 ? ( progress === 100 ? (
@ -176,95 +193,97 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
<span className="text-xs text-custom-text-300">{`${progress}%`}</span> <span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</span> </div>
<div className="flex items-center gap-2.5"> <div className="relative flex items-center gap-2.5 overflow-hidden">
<span className="flex-shrink-0"> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" />
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycleDetails.name} position="top"> <Tooltip tooltipContent={cycleDetails.name} position="top">
<span className="truncate text-base font-medium">{cycleDetails.name}</span> <span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium">
{cycleDetails.name}
</span>
</Tooltip> </Tooltip>
</div> </div>
</div>
<button onClick={openCycleOverview} className="z-10 hidden flex-shrink-0 group-hover:flex">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex w-full items-center justify-end gap-2.5 md:w-auto md:flex-shrink-0 "> <button onClick={openCycleOverview} className="invisible z-10 flex-shrink-0 group-hover:visible">
<div className="flex items-center justify-center"> <Info className="h-4 w-4 text-custom-text-400" />
{currentCycle && ( </button>
<span
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</span>
)}
</div> </div>
{renderDate && ( {currentCycle && (
<span className="flex w-40 items-center justify-center gap-2 text-xs text-custom-text-300"> <div
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"} className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
</span> style={{
)} color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
<Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}> }}
<div className="flex w-16 cursor-default items-center justify-center gap-1"> >
{cycleDetails.assignees.length > 0 ? ( {currentCycle.value === "current"
<AvatarGroup showTooltip={false}> ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
{cycleDetails.assignees.map((assignee) => ( : `${currentCycle.label}`}
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div> </div>
</Tooltip> )}
{isEditingAllowed && </div>
(cycleDetails.is_favorite ? ( <div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end ">
<button type="button" onClick={handleRemoveFromFavorites}> <div className="text-xs text-custom-text-300">
<Star className="h-3.5 w-3.5 fill-current text-amber-500" /> {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</button> </div>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
))}
<CustomMenu ellipsis> <div className="relative flex flex-shrink-0 items-center gap-3">
{!isCompleted && isEditingAllowed && ( <Tooltip tooltipContent={`${cycleDetails.assignees.length} Members`}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{isEditingAllowed && (
<> <>
<CustomMenu.MenuItem onClick={handleEditCycle}> {cycleDetails.is_favorite ? (
<span className="flex items-center justify-start gap-2"> <button type="button" onClick={handleRemoveFromFavorites}>
<Pencil className="h-3 w-3" /> <Star className="h-3.5 w-3.5 fill-current text-amber-500" />
<span>Edit cycle</span> </button>
</span> ) : (
</CustomMenu.MenuItem> <button type="button" onClick={handleAddToFavorites}>
<CustomMenu.MenuItem onClick={handleDeleteCycle}> <Star className="h-3.5 w-3.5 text-custom-text-200" />
<span className="flex items-center justify-start gap-2"> </button>
<Trash2 className="h-3 w-3" /> )}
<span>Delete cycle</span>
</span> <CustomMenu ellipsis>
</CustomMenu.MenuItem> {!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</> </>
)} )}
<CustomMenu.MenuItem onClick={handleCopyText}> </div>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
</Link> </Link>

View File

@ -10,6 +10,8 @@ import useToast from "hooks/use-toast";
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// constants
import { CYCLE_DELETED } from "constants/event-tracker";
interface ICycleDelete { interface ICycleDelete {
cycle: ICycle; cycle: ICycle;
@ -45,13 +47,13 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
message: "Cycle deleted successfully.", message: "Cycle deleted successfully.",
}); });
captureCycleEvent({ captureCycleEvent({
eventName: "Cycle deleted", eventName: CYCLE_DELETED,
payload: { ...cycle, state: "SUCCESS" }, payload: { ...cycle, state: "SUCCESS" },
}); });
}) })
.catch(() => { .catch(() => {
captureCycleEvent({ captureCycleEvent({
eventName: "Cycle deleted", eventName: CYCLE_DELETED,
payload: { ...cycle, state: "FAILED" }, payload: { ...cycle, state: "FAILED" },
}); });
}); });

View File

@ -10,7 +10,7 @@ import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
type Props = { type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>; handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
handleClose: () => void; handleClose: () => void;
status: boolean; status: boolean;
projectId: string; projectId: string;
@ -29,7 +29,7 @@ export const CycleForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props; const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
// form data // form data
const { const {
formState: { errors, isSubmitting }, formState: { errors, isSubmitting, dirtyFields },
handleSubmit, handleSubmit,
control, control,
watch, watch,
@ -61,7 +61,7 @@ export const CycleForm: React.FC<Props> = (props) => {
maxDate?.setDate(maxDate.getDate() - 1); maxDate?.setDate(maxDate.getDate() - 1);
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit((formData)=>handleFormSubmit(formData,dirtyFields))}>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
{!status && ( {!status && (

View File

@ -1,11 +1,8 @@
import { FC } from "react"; import { FC } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { KeyedMutator } from "swr";
// hooks // hooks
import { useCycle, useUser } from "hooks/store"; import { useCycle, useUser } from "hooks/store";
// services
import { CycleService } from "services/cycle.service";
// components // components
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
import { CycleGanttBlock } from "components/cycles"; import { CycleGanttBlock } from "components/cycles";
@ -17,14 +14,10 @@ import { EUserProjectRoles } from "constants/project";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
cycleIds: string[]; cycleIds: string[];
mutateCycles?: KeyedMutator<ICycle[]>;
}; };
// services
const cycleService = new CycleService();
export const CyclesListGanttChartView: FC<Props> = observer((props) => { export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const { cycleIds, mutateCycles } = props; const { cycleIds } = props;
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -32,38 +25,15 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getCycleById } = useCycle(); const { getCycleById, updateCycleDetails } = useCycle();
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => { const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
if (!workspaceSlug) return; if (!workspaceSlug || !cycle) return;
mutateCycles &&
mutateCycles((prevData: any) => {
if (!prevData) return prevData;
const newList = prevData.map((p: any) => ({ const payload: any = { ...data };
...p, if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
...(p.id === cycle.id
? {
start_date: payload.start_date ? payload.start_date : p.start_date,
target_date: payload.target_date ? payload.target_date : p.end_date,
sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order,
}
: {}),
}));
if (payload.sort_order) { await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload);
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
}
return newList;
}, false);
const newPayload: any = { ...payload };
if (newPayload.sort_order && payload.sort_order) newPayload.sort_order = payload.sort_order.newSortOrder;
cycleService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload);
}; };
const blockFormat = (blocks: (ICycle | null)[]) => { const blockFormat = (blocks: (ICycle | null)[]) => {

View File

@ -10,6 +10,8 @@ import useLocalStorage from "hooks/use-local-storage";
import { CycleForm } from "components/cycles"; import { CycleForm } from "components/cycles";
// types // types
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types";
// constants
import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker";
type CycleModalProps = { type CycleModalProps = {
isOpen: boolean; isOpen: boolean;
@ -47,7 +49,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: "Cycle created successfully.", message: "Cycle created successfully.",
}); });
captureCycleEvent({ captureCycleEvent({
eventName: "Cycle created", eventName: CYCLE_CREATED,
payload: { ...res, state: "SUCCESS" }, payload: { ...res, state: "SUCCESS" },
}); });
}) })
@ -58,18 +60,23 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: err.detail ?? "Error in creating cycle. Please try again.", message: err.detail ?? "Error in creating cycle. Please try again.",
}); });
captureCycleEvent({ captureCycleEvent({
eventName: "Cycle created", eventName: CYCLE_CREATED,
payload: { ...payload, state: "FAILED" }, payload: { ...payload, state: "FAILED" },
}); });
}); });
}; };
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>) => { const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project ?? projectId.toString(); const selectedProjectId = payload.project ?? projectId.toString();
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) 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({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -77,6 +84,10 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
}); });
}) })
.catch((err) => { .catch((err) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: { ...payload, state: "FAILED" },
});
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
@ -95,7 +106,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
return status; return status;
}; };
const handleFormSubmit = async (formData: Partial<ICycle>) => { const handleFormSubmit = async (formData: Partial<ICycle>, dirtyFields: any) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const payload: Partial<ICycle> = { const payload: Partial<ICycle> = {
@ -119,7 +130,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
} }
if (isDateValid) { if (isDateValid) {
if (data) await handleUpdateCycle(data.id, payload); if (data) await handleUpdateCycle(data.id, payload, dirtyFields);
else { else {
await handleCreateCycle(payload).then(() => { await handleCreateCycle(payload).then(() => {
setCycleTab("all"); setCycleTab("all");

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react"; import { Disclosure, Popover, Transition } from "@headlessui/react";
import isEmpty from "lodash/isEmpty";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
// hooks // hooks
@ -38,6 +39,7 @@ import {
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// constants // constants
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { CYCLE_UPDATED } from "constants/event-tracker";
// fetch-keys // fetch-keys
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
@ -66,7 +68,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
// store hooks // store hooks
const { setTrackElement } = useEventTracker(); const { setTrackElement, captureCycleEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -82,10 +84,32 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
defaultValues, defaultValues,
}); });
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleId) return; 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 = () => { const handleCopyText = () => {
@ -145,10 +169,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValidForExistingCycle) { if (isDateValidForExistingCycle) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -173,10 +200,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValid) { if (isDateValid) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"start_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -218,10 +248,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValidForExistingCycle) { if (isDateValidForExistingCycle) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -245,10 +278,13 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
}); });
if (isDateValid) { if (isDateValid) {
submitChanges({ submitChanges(
start_date: renderFormattedPayloadDate(`${watch("start_date")}`), {
end_date: renderFormattedPayloadDate(`${watch("end_date")}`), start_date: renderFormattedPayloadDate(`${watch("start_date")}`),
}); end_date: renderFormattedPayloadDate(`${watch("end_date")}`),
},
"end_date"
);
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -293,7 +329,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`); const isEndValid = new Date(`${cycleDetails?.end_date}`) >= new Date(`${cycleDetails?.start_date}`);
const progressPercentage = cycleDetails 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; : null;
if (!cycleDetails) if (!cycleDetails)
@ -317,7 +357,15 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount = 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; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
@ -375,8 +423,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
backgroundColor: `${currentCycle.color}20`, backgroundColor: `${currentCycle.color}20`,
}} }}
> >
{currentCycle.value === "current" {currentCycle.value === "current" && daysLeft !== undefined
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}` ? `${daysLeft} ${currentCycle.label}`
: `${currentCycle.label}`} : `${currentCycle.label}`}
</span> </span>
)} )}
@ -567,49 +615,105 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<Transition show={open}> <Transition show={open}>
<Disclosure.Panel> <Disclosure.Panel>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{cycleDetails.distribution?.completion_chart && {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
cycleDetails.start_date && <>
cycleDetails.end_date ? ( {cycleDetails.progress_snapshot.distribution?.completion_chart &&
<div className="h-full w-full pt-4"> cycleDetails.start_date &&
<div className="flex items-start gap-4 py-2 text-xs"> cycleDetails.end_date && (
<div className="flex items-center gap-3 text-custom-text-100"> <div className="h-full w-full pt-4">
<div className="flex items-center justify-center gap-1"> <div className="flex items-start gap-4 py-2 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" /> <div className="flex items-center gap-3 text-custom-text-100">
<span>Ideal</span> <div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.progress_snapshot.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.progress_snapshot.total_issues}
/>
</div>
</div> </div>
<div className="flex items-center justify-center gap-1"> )}
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" /> </>
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
) : ( ) : (
"" <>
{cycleDetails.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date && (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
)}
</>
)} )}
{cycleDetails.total_issues > 0 && cycleDetails.distribution && ( {/* stats */}
<div className="h-full w-full border-t border-custom-border-200 pt-5"> {isCompleted && !isEmpty(cycleDetails.progress_snapshot) ? (
<SidebarProgressStats <>
distribution={cycleDetails.distribution} {cycleDetails.progress_snapshot.total_issues > 0 &&
groupedIssues={{ cycleDetails.progress_snapshot.distribution && (
backlog: cycleDetails.backlog_issues, <div className="h-full w-full border-t border-custom-border-200 pt-5">
unstarted: cycleDetails.unstarted_issues, <SidebarProgressStats
started: cycleDetails.started_issues, distribution={cycleDetails.progress_snapshot.distribution}
completed: cycleDetails.completed_issues, groupedIssues={{
cancelled: cycleDetails.cancelled_issues, backlog: cycleDetails.progress_snapshot.backlog_issues,
}} unstarted: cycleDetails.progress_snapshot.unstarted_issues,
totalIssues={cycleDetails.total_issues} started: cycleDetails.progress_snapshot.started_issues,
isPeekView={Boolean(peekCycle)} completed: cycleDetails.progress_snapshot.completed_issues,
/> cancelled: cycleDetails.progress_snapshot.cancelled_issues,
</div> }}
totalIssues={cycleDetails.progress_snapshot.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
)}
</>
) : (
<>
{cycleDetails.total_issues > 0 && cycleDetails.distribution && (
<div className="h-full w-full border-t border-custom-border-200 pt-5">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
)}
</>
)} )}
</div> </div>
</Disclosure.Panel> </Disclosure.Panel>

View File

@ -13,7 +13,7 @@ import {
WidgetProps, WidgetProps,
} from "components/dashboard/widgets"; } from "components/dashboard/widgets";
// helpers // helpers
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
// types // types
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
// constants // constants
@ -30,8 +30,8 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values // derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => { const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
if (!widgetDetails) return; if (!widgetDetails) return;
@ -43,7 +43,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters, filters,
}); });
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab, issue_type: filters.tab ?? selectedTab,
@ -86,19 +86,19 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" }); handleUpdateFilters({ duration: val, tab: "pending" });
return; return;
} }
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({ handleUpdateFilters({
target_date: val, duration: val,
tab: "upcoming", tab: "upcoming",
}); });
return; return;
} }
handleUpdateFilters({ target_date: val }); handleUpdateFilters({ duration: val });
}} }}
/> />
</div> </div>

View File

@ -13,7 +13,7 @@ import {
WidgetProps, WidgetProps,
} from "components/dashboard/widgets"; } from "components/dashboard/widgets";
// helpers // helpers
import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"; import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
// types // types
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types"; import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
// constants // constants
@ -30,8 +30,8 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values // derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending"; const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none"; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => { const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
if (!widgetDetails) return; if (!widgetDetails) return;
@ -43,7 +43,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters, filters,
}); });
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter); const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
issue_type: filters.tab ?? selectedTab, issue_type: filters.tab ?? selectedTab,
@ -83,19 +83,19 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// switch to pending tab if target date is changed to none // switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") { if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" }); handleUpdateFilters({ duration: val, tab: "pending" });
return; return;
} }
// switch to upcoming tab if target date is changed to other than none // switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({ handleUpdateFilters({
target_date: val, duration: val,
tab: "upcoming", tab: "upcoming",
}); });
return; return;
} }
handleUpdateFilters({ target_date: val }); handleUpdateFilters({ duration: val });
}} }}
/> />
</div> </div>

View File

@ -83,7 +83,7 @@ export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observ
const blockedByIssueProjectDetails = const blockedByIssueProjectDetails =
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null; blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false); const dueBy = findTotalDaysInRange(new Date(issueDetails.target_date ?? ""), new Date(), false) ?? 0;
return ( return (
<ControlLink <ControlLink
@ -212,7 +212,7 @@ export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observe
const projectDetails = getProjectById(issue.project_id); const projectDetails = getProjectById(issue.project_id);
const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false); const dueBy = findTotalDaysInRange(new Date(issue.target_date ?? ""), new Date(), false) ?? 0;
return ( return (
<ControlLink <ControlLink

View File

@ -16,42 +16,40 @@ export const TabsList: React.FC<Props> = observer((props) => {
const { durationFilter, selectedTab } = props; const { durationFilter, selectedTab } = props;
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending")); const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
return ( return (
<Tab.List <Tab.List
as="div" as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 grid" className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{ style={{
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`, gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
}} }}
> >
<div <div
className={cn("absolute bg-custom-background-100 rounded transition-all duration-500 ease-in-out", { className={cn(
// right shadow "absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1, {
// left shadow // right shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0, "shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
})} // left shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
}
)}
style={{ style={{
height: "calc(100% - 1px)", height: "calc(100% - 2px)",
width: `${100 / tabsList.length}%`, width: `calc(${100 / tabsList.length}% - 1px)`,
transform: `translateX(${selectedTabIndex * 100}%)`, transform: `translate(${selectedTabIndex * 100}%, -50%)`,
}} }}
/> />
{tabsList.map((tab) => ( {tabsList.map((tab) => (
<Tab <Tab
key={tab.key} key={tab.key}
className={cn( className={cn(
"relative z-[1] font-semibold text-xs rounded py-1.5 text-custom-text-400 focus:outline-none", "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
"transition duration-500",
{ {
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key, "text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
"hover:text-custom-text-300": selectedTab !== tab.key, "hover:text-custom-text-300": selectedTab !== tab.key,
// // right shadow
// "shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// // left shadow
// "shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
} }
)} )}
> >

View File

@ -73,8 +73,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
const { dashboardId, workspaceSlug } = props; const { dashboardId, workspaceSlug } = props;
// store hooks // store hooks
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => { const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
if (!widgetDetails) return; if (!widgetDetails) return;
@ -84,7 +86,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
filters, filters,
}); });
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(filters.duration ?? selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -92,7 +94,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
}; };
useEffect(() => { useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -139,10 +141,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
Assigned by priority Assigned by priority
</Link> </Link>
<DurationFilterDropdown <DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "none"} value={selectedDuration}
onChange={(val) => onChange={(val) =>
handleUpdateFilters({ handleUpdateFilters({
target_date: val, duration: val,
}) })
} }
/> />

View File

@ -34,6 +34,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
// derived values // derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => { const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
if (!widgetDetails) return; if (!widgetDetails) return;
@ -43,7 +44,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
filters, filters,
}); });
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(filters.duration ?? selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -52,7 +53,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
// fetch widget stats // fetch widget stats
useEffect(() => { useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none"); const filterDates = getCustomDates(selectedDuration);
fetchWidgetStats(workspaceSlug, dashboardId, { fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY, widget_key: WIDGET_KEY,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
@ -138,10 +139,10 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
Assigned by state Assigned by state
</Link> </Link>
<DurationFilterDropdown <DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "none"} value={selectedDuration}
onChange={(val) => onChange={(val) =>
handleUpdateFilters({ handleUpdateFilters({
target_date: val, duration: val,
}) })
} }
/> />

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string | null) => void; onChange: (val: string | null) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: string | null; value: string | null;
}; };
@ -47,6 +48,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Cycle", placeholder = "Cycle",
placement, placement,
projectId, projectId,
@ -123,8 +125,10 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -163,7 +167,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
minDate?: Date; minDate?: Date;
maxDate?: Date; maxDate?: Date;
onChange: (val: Date | null) => void; onChange: (val: Date | null) => void;
onClose?: () => void;
value: Date | string | null; value: Date | string | null;
closeOnSelect?: boolean; closeOnSelect?: boolean;
}; };
@ -42,6 +43,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
minDate, minDate,
maxDate, maxDate,
onChange, onChange,
onClose,
placeholder = "Date", placeholder = "Date",
placement, placement,
showTooltip = false, showTooltip = false,
@ -74,8 +76,10 @@ export const DateDropdown: React.FC<Props> = (props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -112,7 +116,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: number | null) => void; onChange: (val: number | null) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: number | null; value: number | null;
}; };
@ -46,6 +47,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Estimate", placeholder = "Estimate",
placement, placement,
projectId, projectId,
@ -112,8 +114,10 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -152,7 +156,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -162,7 +166,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -21,6 +21,7 @@ import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
type Props = { type Props = {
projectId: string; projectId: string;
onClose?: () => void;
} & MemberDropdownProps; } & MemberDropdownProps;
export const ProjectMemberDropdown: React.FC<Props> = observer((props) => { export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
@ -36,6 +37,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Members", placeholder = "Members",
placement, placement,
projectId, projectId,
@ -105,8 +107,10 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -144,7 +148,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -154,7 +158,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
placeholder?: string; placeholder?: string;
onClose?: () => void;
} & ( } & (
| { | {
multiple: false; multiple: false;

View File

@ -32,6 +32,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Members", placeholder = "Members",
placement, placement,
showTooltip = false, showTooltip = false,
@ -95,8 +96,10 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -134,7 +137,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -144,7 +147,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -24,6 +24,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
projectId: string; projectId: string;
showCount?: boolean; showCount?: boolean;
onClose?: () => void;
} & ( } & (
| { | {
multiple: false; multiple: false;
@ -151,6 +152,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
hideIcon = false, hideIcon = false,
multiple, multiple,
onChange, onChange,
onClose,
placeholder = "Module", placeholder = "Module",
placement, placement,
projectId, projectId,
@ -226,8 +228,10 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -271,7 +275,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -281,7 +285,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
highlightUrgent?: boolean; highlightUrgent?: boolean;
onChange: (val: TIssuePriorities) => void; onChange: (val: TIssuePriorities) => void;
onClose?: () => void;
value: TIssuePriorities; value: TIssuePriorities;
}; };
@ -260,6 +261,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
hideIcon = false, hideIcon = false,
highlightUrgent = true, highlightUrgent = true,
onChange, onChange,
onClose,
placement, placement,
showTooltip = false, showTooltip = false,
tabIndex, tabIndex,
@ -308,8 +310,10 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
onClose && onClose();
}; };
const toggleDropdown = () => { const toggleDropdown = () => {
@ -360,7 +364,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -370,7 +374,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -22,6 +22,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onClose?: () => void;
value: string | null; value: string | null;
}; };
@ -37,6 +38,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placeholder = "Project", placeholder = "Project",
placement, placement,
showTooltip = false, showTooltip = false,
@ -97,7 +99,9 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
}; };
@ -137,7 +141,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -147,7 +151,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -23,6 +23,7 @@ type Props = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onClose?: () => void;
projectId: string; projectId: string;
value: string; value: string;
}; };
@ -39,6 +40,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
dropdownArrowClassName = "", dropdownArrowClassName = "",
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose,
placement, placement,
projectId, projectId,
showTooltip = false, showTooltip = false,
@ -94,7 +96,9 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
}; };
const handleClose = () => { const handleClose = () => {
if (isOpen) setIsOpen(false); if (!isOpen) return;
setIsOpen(false);
onClose && onClose();
if (referenceElement) referenceElement.blur(); if (referenceElement) referenceElement.blur();
}; };
@ -134,7 +138,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
<button <button
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)} className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick} onClick={handleOnClick}
> >
{button} {button}
@ -144,7 +148,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
className={cn( className={cn(
"block h-full max-w-full outline-none", "clickable block h-full max-w-full outline-none",
{ {
"cursor-not-allowed text-custom-text-200": disabled, "cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled, "cursor-pointer": !disabled,

View File

@ -3,8 +3,7 @@ import { TIssue } from "@plane/types";
import { IGanttBlock } from "components/gantt-chart"; import { IGanttBlock } from "components/gantt-chart";
export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] =>
blocks && blocks?.map((block) => ({
blocks.map((block) => ({
data: block, data: block,
id: block.id, id: block.id,
sort_order: block.sort_order, sort_order: block.sort_order,

View File

@ -93,7 +93,7 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
<> <>
{blocks ? ( {blocks ? (
blocks.map((block, index) => { blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); const duration = findTotalDaysInRange(block.start_date, block.target_date);
return ( return (
<Draggable <Draggable
@ -130,9 +130,11 @@ export const CycleGanttSidebar: React.FC<Props> = (props) => {
<div className="flex-grow truncate"> <div className="flex-grow truncate">
<CycleGanttSidebarBlock data={block.data} /> <CycleGanttSidebarBlock data={block.data} />
</div> </div>
<div className="flex-shrink-0 text-sm text-custom-text-200"> {duration !== undefined && (
{duration} day{duration > 1 ? "s" : ""} <div className="flex-shrink-0 text-sm text-custom-text-200">
</div> {duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -93,7 +93,7 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
<> <>
{blocks ? ( {blocks ? (
blocks.map((block, index) => { blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); const duration = findTotalDaysInRange(block.start_date, block.target_date);
return ( return (
<Draggable <Draggable
@ -130,9 +130,11 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
<div className="flex-grow truncate"> <div className="flex-grow truncate">
<ModuleGanttSidebarBlock data={block.data} /> <ModuleGanttSidebarBlock data={block.data} />
</div> </div>
<div className="flex-shrink-0 text-sm text-custom-text-200"> {duration !== undefined && (
{duration} day{duration > 1 ? "s" : ""} <div className="flex-shrink-0 text-sm text-custom-text-200">
</div> {duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -94,7 +94,7 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
<> <>
{blocks ? ( {blocks ? (
blocks.map((block, index) => { blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? ""); const duration = findTotalDaysInRange(block.start_date, block.target_date);
return ( return (
<Draggable <Draggable
@ -131,9 +131,11 @@ export const ProjectViewGanttSidebar: React.FC<Props> = (props) => {
<div className="flex-grow truncate"> <div className="flex-grow truncate">
<IssueGanttSidebarBlock data={block.data} /> <IssueGanttSidebarBlock data={block.data} />
</div> </div>
<div className="flex-shrink-0 text-sm text-custom-text-200"> {duration !== undefined && (
{duration} day{duration > 1 ? "s" : ""} <div className="flex-shrink-0 text-sm text-custom-text-200">
</div> {duration} day{duration > 1 ? "s" : ""}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -119,10 +119,7 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
// hide the block if it doesn't have start and target dates and showAllBlocks is false // hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !isBlockVisibleOnSidebar) return; if (!showAllBlocks && !isBlockVisibleOnSidebar) return;
const duration = const duration = findTotalDaysInRange(block.start_date, block.target_date);
!block.start_date || !block.target_date
? null
: findTotalDaysInRange(block.start_date, block.target_date);
return ( return (
<Draggable <Draggable
@ -166,13 +163,13 @@ export const IssueGanttSidebar: React.FC<Props> = (props) => {
<div className="flex-grow truncate"> <div className="flex-grow truncate">
<IssueGanttSidebarBlock data={block.data} /> <IssueGanttSidebarBlock data={block.data} />
</div> </div>
<div className="flex-shrink-0 text-sm text-custom-text-200"> {duration !== undefined && (
{duration && ( <div className="flex-shrink-0 text-sm text-custom-text-200">
<span> <span>
{duration} day{duration > 1 ? "s" : ""} {duration} day{duration > 1 ? "s" : ""}
</span> </span>
)} </div>
</div> )}
</div> </div>
</div> </div>
</div> </div>

View File

@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common";
// ui // ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui";
// icons // icons
import { ArrowRight, Plus } from "lucide-react"; import { ArrowRight, Plus, PanelRight } from "lucide-react";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants // constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { cn } from "helpers/common.helper";
import { CycleMobileHeader } from "components/cycles/cycle-mobile-header";
const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
// router // router
@ -147,117 +149,136 @@ export const CycleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined} cycleDetails={cycleDetails ?? undefined}
/> />
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 w-full items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-2"> <div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<SidebarHamburgerToggle /> <div className="flex items-center gap-2">
<Breadcrumbs> <SidebarHamburgerToggle />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs>
type="text" <Breadcrumbs.BreadcrumbItem
link={ type="text"
<BreadcrumbLink link={
label={currentProjectDetails?.name ?? "Project"} <span>
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} <span className="hidden md:block">
icon={ <BreadcrumbLink
currentProjectDetails?.emoji ? ( label={currentProjectDetails?.name ?? "Project"}
renderEmoji(currentProjectDetails.emoji) href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
) : currentProjectDetails?.icon_prop ? ( icon={
renderEmoji(currentProjectDetails.icon_prop) currentProjectDetails?.emoji ? (
) : ( renderEmoji(currentProjectDetails.emoji)
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white"> ) : currentProjectDetails?.icon_prop ? (
{currentProjectDetails?.name.charAt(0)} renderEmoji(currentProjectDetails.icon_prop)
</span> ) : (
) <span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
} {currentProjectDetails?.name.charAt(0)}
/> </span>
} )
}
/>
</span>
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
</span>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
<ContrastIcon className="h-3 w-3" />
{cycleDetails?.name && truncateText(cycleDetails.name, 40)}
</>
}
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
>
{currentProjectCycleIds?.map((cycleId) => (
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
))}
</CustomMenu>
}
/>
</Breadcrumbs>
</div>
<div className="hidden md:flex items-center gap-2 ">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/> />
<Breadcrumbs.BreadcrumbItem <FiltersDropdown title="Filters" placement="bottom-end">
type="text" <FilterSelection
link={ filters={issueFilters?.filters ?? {}}
<BreadcrumbLink handleFiltersUpdate={handleFiltersUpdate}
label="Cycles" layoutDisplayFiltersOptions={
href={`/${workspaceSlug}/projects/${projectId}/cycles`} activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} }
/> labels={projectLabels}
} memberIds={projectMemberIds ?? undefined}
/> states={projectStates}
<Breadcrumbs.BreadcrumbItem />
type="component" </FiltersDropdown>
component={ <FiltersDropdown title="Display" placement="bottom-end">
<CustomMenu <DisplayFiltersSelection
label={ layoutDisplayFiltersOptions={
<> activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
<ContrastIcon className="h-3 w-3" /> }
{cycleDetails?.name && truncateText(cycleDetails.name, 40)} displayFilters={issueFilters?.displayFilters ?? {}}
</> handleDisplayFiltersUpdate={handleDisplayFilters}
} displayProperties={issueFilters?.displayProperties ?? {}}
className="ml-1.5 flex-shrink-0" handleDisplayPropertiesUpdate={handleDisplayProperties}
placement="bottom-start" />
> </FiltersDropdown>
{currentProjectCycleIds?.map((cycleId) => (
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
))}
</CustomMenu>
}
/>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
{canUserCreateIssue && ( {canUserCreateIssue && (
<> <>
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm"> <Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics Analytics
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setTrackElement("Cycle issues page"); setTrackElement("Cycle issues page");
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
}} }}
size="sm" size="sm"
prependIcon={<Plus />} prependIcon={<Plus />}
> >
Add Issue Add Issue
</Button> </Button>
</> </>
)} )}
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar}
>
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
</button>
</div>
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80" className="grid md:hidden h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar} onClick={toggleSidebar}
> >
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} /> <PanelRight className={cn("w-4 h-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button> </button>
</div> </div>
<div className="block sm:block md:hidden">
<CycleMobileHeader />
</div>
</div> </div>
</> </>
); );
}); });

View File

@ -1,22 +1,24 @@
import { FC } from "react"; import { FC, useCallback } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react"; import { List, Plus } from "lucide-react";
// hooks // hooks
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
// ui // ui
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui";
// helpers // helpers
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
// components // components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { TCycleLayout } from "@plane/types";
import { CYCLE_VIEW_LAYOUTS } from "constants/cycle";
import useLocalStorage from "hooks/use-local-storage";
export const CyclesHeader: FC = observer(() => { export const CyclesHeader: FC = observer(() => {
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query;
// store hooks // store hooks
const { const {
commandPalette: { toggleCreateCycleModal }, commandPalette: { toggleCreateCycleModal },
@ -30,54 +32,96 @@ export const CyclesHeader: FC = observer(() => {
const canUserCreateCycle = const canUserCreateCycle =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const { workspaceSlug } = router.query as {
workspaceSlug: string;
};
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list");
const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => {
setCycleLayout(_layout);
},
[setCycleLayout]
);
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 items-center justify-between gap-x-2 gap-y-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<SidebarHamburgerToggle /> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div> <SidebarHamburgerToggle />
<Breadcrumbs> <div>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs>
type="text" <Breadcrumbs.BreadcrumbItem
link={ type="text"
<BreadcrumbLink link={
label={currentProjectDetails?.name ?? "Project"} <BreadcrumbLink
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} label={currentProjectDetails?.name ?? "Project"}
icon={ href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
currentProjectDetails?.emoji ? ( icon={
renderEmoji(currentProjectDetails.emoji) currentProjectDetails?.emoji ? (
) : currentProjectDetails?.icon_prop ? ( renderEmoji(currentProjectDetails.emoji)
renderEmoji(currentProjectDetails.icon_prop) ) : currentProjectDetails?.icon_prop ? (
) : ( renderEmoji(currentProjectDetails.icon_prop)
<span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white"> ) : (
{currentProjectDetails?.name.charAt(0)} <span className="flex h-4 w-4 items-center justify-center rounded bg-gray-700 uppercase text-white">
</span> {currentProjectDetails?.name.charAt(0)}
) </span>
} )
/> }
} />
/> }
<Breadcrumbs.BreadcrumbItem />
type="text" <Breadcrumbs.BreadcrumbItem
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />} type="text"
/> link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
</Breadcrumbs> />
</Breadcrumbs>
</div>
</div> </div>
{canUserCreateCycle && (
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
Add Cycle
</Button>
</div>
)}
</div>
<div className="flex justify-center sm:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
// placement="bottom-start"
customButton={
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
</span>
}
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
closeOnSelect
>
{CYCLE_VIEW_LAYOUTS.map((layout) => (
<CustomMenu.MenuItem
onClick={() => {
// handleLayoutChange(ISSUE_LAYOUTS[index].key);
handleCurrentLayout(layout.key as TCycleLayout);
}}
className="flex items-center gap-2"
>
<layout.icon className="w-3 h-3" />
<div className="text-custom-text-300">{layout.title}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div> </div>
{canUserCreateCycle && (
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
prependIcon={<Plus />}
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
Add Cycle
</Button>
</div>
)}
</div> </div>
); );
}); });

View File

@ -23,7 +23,7 @@ import { BreadcrumbLink } from "components/common";
// ui // ui
import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui";
// icons // icons
import { ArrowRight, Plus } from "lucide-react"; import { ArrowRight, PanelRight, Plus } from "lucide-react";
// helpers // helpers
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
@ -32,6 +32,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants // constants
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { cn } from "helpers/common.helper";
import { ModuleMobileHeader } from "components/modules/module-mobile-header";
const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => {
// router // router
@ -150,116 +152,127 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined} moduleDetails={moduleDetails ?? undefined}
/> />
<div className="relative z-10 flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-2"> <div className="flex justify-between border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<SidebarHamburgerToggle /> <div className="flex items-center gap-2">
<Breadcrumbs> <SidebarHamburgerToggle />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs>
type="text" <Breadcrumbs.BreadcrumbItem
link={ type="text"
<BreadcrumbLink link={
label={currentProjectDetails?.name ?? "Project"} <span>
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} <span className="hidden md:block">
icon={ <BreadcrumbLink
currentProjectDetails?.emoji ? ( label={currentProjectDetails?.name ?? "Project"}
renderEmoji(currentProjectDetails.emoji) href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
) : currentProjectDetails?.icon_prop ? ( icon={
renderEmoji(currentProjectDetails.icon_prop) currentProjectDetails?.emoji ? (
) : ( renderEmoji(currentProjectDetails.emoji)
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white"> ) : currentProjectDetails?.icon_prop ? (
{currentProjectDetails?.name.charAt(0)} renderEmoji(currentProjectDetails.icon_prop)
</span> ) : (
) <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{currentProjectDetails?.name.charAt(0)}
</span>
)
}
/>
</span>
<Link href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} className="block md:hidden pl-2 text-custom-text-300">...</Link>
</span>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/modules`}
label="Modules"
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
<DiceIcon className="h-3 w-3" />
{moduleDetails?.name && truncateText(moduleDetails.name, 40)}
</>
}
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
>
{projectModuleIds?.map((moduleId) => (
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
))}
</CustomMenu>
}
/>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2">
<div className="hidden md:flex gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
} }
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/> />
} </FiltersDropdown>
/> <FiltersDropdown title="Display" placement="bottom-end">
<Breadcrumbs.BreadcrumbItem <DisplayFiltersSelection
type="text" layoutDisplayFiltersOptions={
link={ activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/modules`}
label="Modules"
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
<DiceIcon className="h-3 w-3" />
{moduleDetails?.name && truncateText(moduleDetails.name, 40)}
</>
} }
className="ml-1.5 flex-shrink-0" displayFilters={issueFilters?.displayFilters ?? {}}
placement="bottom-start" handleDisplayFiltersUpdate={handleDisplayFilters}
> displayProperties={issueFilters?.displayProperties ?? {}}
{projectModuleIds?.map((moduleId) => ( handleDisplayPropertiesUpdate={handleDisplayProperties}
<ModuleDropdownOption key={moduleId} moduleId={moduleId} /> />
))} </FiltersDropdown>
</CustomMenu> </div>
}
/>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
{canUserCreateIssue && ( {canUserCreateIssue && (
<> <>
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm"> <Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics Analytics
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
setTrackElement("Module issues page"); setTrackElement("Module issues page");
toggleCreateIssueModal(true, EIssuesStoreType.MODULE); toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
}} }}
size="sm" size="sm"
prependIcon={<Plus />} prependIcon={<Plus />}
> >
Add Issue <span className="hidden md:block">Add</span> Issue
</Button> </Button>
</> </>
)} )}
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80" className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar} onClick={toggleSidebar}
> >
<ArrowRight className={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} /> <ArrowRight className={`h-4 w-4 duration-300 hidden md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
</button> <PanelRight className={cn("w-4 h-4 block md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
</div>
</div> </div>
<ModuleMobileHeader />
</div> </div>
</> </>
); );

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { FileText, Plus } from "lucide-react"; import { FileText, Plus } from "lucide-react";
// hooks // hooks
import { useApplication, useProject, useUser } from "hooks/store"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
// ui // ui
import { Breadcrumbs, Button } from "@plane/ui"; import { Breadcrumbs, Button } from "@plane/ui";
// helpers // helpers
@ -25,6 +25,7 @@ export const PagesHeader = observer(() => {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { setTrackElement } = useEventTracker();
const canUserCreatePage = const canUserCreatePage =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
@ -64,7 +65,15 @@ export const PagesHeader = observer(() => {
</div> </div>
{canUserCreatePage && ( {canUserCreatePage && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => toggleCreatePageModal(true)}> <Button
variant="primary"
prependIcon={<Plus />}
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal(true);
}}
>
Create Page Create Page
</Button> </Button>
</div> </div>

View File

@ -103,7 +103,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={
<BreadcrumbLink label="Inbox Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} /> <BreadcrumbLink label="Draft Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
} }
/> />
</Breadcrumbs> </Breadcrumbs>

View File

@ -2,7 +2,7 @@ import { useCallback, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
// hooks // hooks
import { import {
useApplication, useApplication,
@ -29,6 +29,7 @@ import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } f
import { renderEmoji } from "helpers/emoji.helper"; import { renderEmoji } from "helpers/emoji.helper";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { useIssues } from "hooks/store/use-issues"; import { useIssues } from "hooks/store/use-issues";
import { IssuesMobileHeader } from "components/issues/issues-mobile-header";
export const ProjectIssuesHeader: React.FC = observer(() => { export const ProjectIssuesHeader: React.FC = observer(() => {
// states // states
@ -114,118 +115,109 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)} onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined} projectDetails={currentProjectDetails ?? undefined}
/> />
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className=" relative z-10 items-center gap-x-2 gap-y-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
<SidebarHamburgerToggle /> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="block md:hidden"> <SidebarHamburgerToggle />
<button <div>
type="button" <Breadcrumbs>
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200" <Breadcrumbs.BreadcrumbItem
onClick={() => router.back()} type="text"
> link={
<ArrowLeft fontSize={14} strokeWidth={2} /> <BreadcrumbLink
</button> href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails ? (
currentProjectDetails?.emoji ? (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
{renderEmoji(currentProjectDetails.emoji)}
</span>
) : currentProjectDetails?.icon_prop ? (
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
{renderEmoji(currentProjectDetails.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{currentProjectDetails?.name.charAt(0)}
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</div>
{currentProjectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
)}
</div> </div>
<div> <div className="items-center gap-2 hidden md:flex">
<Breadcrumbs> <LayoutSelection
<Breadcrumbs.BreadcrumbItem layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
type="text" onChange={(layout) => handleLayoutChange(layout)}
link={ selectedLayout={activeLayout}
<BreadcrumbLink />
href={`/${workspaceSlug}/projects`} <FiltersDropdown title="Filters" placement="bottom-end">
label={currentProjectDetails?.name ?? "Project"} <FilterSelection
icon={ filters={issueFilters?.filters ?? {}}
currentProjectDetails ? ( handleFiltersUpdate={handleFiltersUpdate}
currentProjectDetails?.emoji ? ( layoutDisplayFiltersOptions={
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase"> activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
{renderEmoji(currentProjectDetails.emoji)}
</span>
) : currentProjectDetails?.icon_prop ? (
<div className="grid h-7 w-7 flex-shrink-0 place-items-center">
{renderEmoji(currentProjectDetails.icon_prop)}
</div>
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
{currentProjectDetails?.name.charAt(0)}
</span>
)
) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" />
</span>
)
}
/>
} }
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/> />
</FiltersDropdown>
<Breadcrumbs.BreadcrumbItem <FiltersDropdown title="Display" placement="bottom-end">
type="text" <DisplayFiltersSelection
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />} layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/> />
</Breadcrumbs> </FiltersDropdown>
</div> </div>
{currentProjectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${currentProjectDetails?.id}`}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
)}
</div>
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
{currentProjectDetails?.inbox_view && inboxDetails && ( {currentProjectDetails?.inbox_view && inboxDetails && (
<Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxDetails?.id}`}>
<span> <span>
<Button variant="neutral-primary" size="sm" className="relative"> <Button variant="neutral-primary" size="sm" className="relative">
Inbox Inbox
{inboxDetails?.pending_issue_count > 0 && ( {inboxDetails?.pending_issue_count > 0 && (
<span className="absolute -right-1.5 -top-1.5 h-4 w-4 rounded-full border border-custom-sidebar-border-200 bg-custom-sidebar-background-80 text-custom-text-100"> <span className="absolute -right-1.5 -top-1.5 h-4 w-4 rounded-full border border-custom-sidebar-border-200 bg-custom-sidebar-background-80 text-custom-text-100">
{inboxDetails?.pending_issue_count} {inboxDetails?.pending_issue_count}
</span> </span>
)} )}
</Button> </Button>
</span> </span>
</Link> </Link>
)} )}
{canUserCreateIssue && ( {canUserCreateIssue && (
<> <>
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm"> <Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics Analytics
</Button> </Button>
<Button <Button
@ -241,6 +233,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
</> </>
)} )}
</div> </div>
<div className="block md:hidden">
<IssuesMobileHeader />
</div>
</div> </div>
</> </>
); );

View File

@ -23,7 +23,7 @@ export const ProjectsHeader = observer(() => {
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div>
<Breadcrumbs> <Breadcrumbs>
@ -34,12 +34,12 @@ export const ProjectsHeader = observer(() => {
</Breadcrumbs> </Breadcrumbs>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex w-full justify-end items-center gap-3">
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( {workspaceProjectIds && workspaceProjectIds?.length > 0 && (
<div className="flex w-full items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400"> <div className=" flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400">
<Search className="h-3.5 w-3.5" /> <Search className="h-3.5" />
<input <input
className="w-full min-w-[234px] border-none bg-transparent text-sm focus:outline-none" className="border-none w-full bg-transparent text-sm focus:outline-none"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search" placeholder="Search"
@ -54,6 +54,7 @@ export const ProjectsHeader = observer(() => {
setTrackElement("Projects page"); setTrackElement("Projects page");
commandPaletteStore.toggleCreateProjectModal(true); commandPaletteStore.toggleCreateProjectModal(true);
}} }}
className="items-center"
> >
Add Project Add Project
</Button> </Button>

View File

@ -1,18 +1,78 @@
// ui // ui
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs, CustomMenu } from "@plane/ui";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
// components // components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { cn } from "helpers/common.helper";
import { FC } from "react";
import { useApplication, useUser } from "hooks/store";
import { ChevronDown, PanelRight } from "lucide-react";
import { observer } from "mobx-react-lite";
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "constants/profile";
import Link from "next/link";
import { useRouter } from "next/router";
export const UserProfileHeader = () => ( type TUserProfileHeader = {
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> type?: string | undefined
}
export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
const { type = undefined } = props
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const AUTHORIZED_ROLES = [20, 15, 10];
const {
membership: { currentWorkspaceRole },
} = useUser();
if (!currentWorkspaceRole) return null;
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
const { theme: themStore } = useApplication();
return (<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div className="flex justify-between w-full">
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} /> <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink href="/profile" label="Activity Overview" />} />
</Breadcrumbs> </Breadcrumbs>
<div className="flex gap-4 md:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-custom-text-200 text-sm"
placement="bottom-start"
customButton={
<div className="flex gap-2 items-center px-2 py-1.5 border border-custom-border-400 rounded-md">
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">{type}</span>
<ChevronDown className="w-4 h-4 text-custom-text-400" />
</div>
}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
<></>
{tabsList.map((tab) => (
<CustomMenu.MenuItem
className="flex items-center gap-2"
>
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`} className="text-custom-text-300 w-full">{tab.label}</Link>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<button className="transition-all block md:hidden" onClick={() => { themStore.toggleProfileSidebar() }}>
<PanelRight className={
cn("w-4 h-4 block md:hidden", !themStore.profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")
} />
</button>
</div>
</div> </div>
</div> </div>
</div> </div>)
); });

View File

@ -1,13 +1,35 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { ArrowLeft, BarChart2 } from "lucide-react"; import { BarChart2, PanelRight } from "lucide-react";
// ui // ui
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs } from "@plane/ui";
// components // components
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
import { cn } from "helpers/common.helper";
import { useEffect } from "react";
export const WorkspaceAnalyticsHeader = () => { export const WorkspaceAnalyticsHeader = observer(() => {
const router = useRouter(); const router = useRouter();
const { analytics_tab } = router.query;
const { theme: themeStore } = useApplication();
useEffect(() => {
const handleToggleWorkspaceAnalyticsSidebar = () => {
if (window && window.innerWidth < 768) {
themeStore.toggleWorkspaceAnalyticsSidebar(true);
}
if (window && themeStore.workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
themeStore.toggleWorkspaceAnalyticsSidebar(false);
}
};
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
handleToggleWorkspaceAnalyticsSidebar();
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
}, [themeStore]);
return ( return (
<> <>
@ -16,16 +38,7 @@ export const WorkspaceAnalyticsHeader = () => {
> >
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div className="block md:hidden"> <div className="flex items-center justify-between w-full">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
@ -34,9 +47,14 @@ export const WorkspaceAnalyticsHeader = () => {
} }
/> />
</Breadcrumbs> </Breadcrumbs>
{analytics_tab === 'custom' &&
<button className="block md:hidden" onClick={() => { themeStore.toggleWorkspaceAnalyticsSidebar() }}>
<PanelRight className={cn("w-4 h-4 block md:hidden", !themeStore.workspaceAnalyticsSidebarCollapsed ? "text-custom-primary-100" : "text-custom-text-200")} />
</button>
}
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
}; });

View File

@ -1,24 +1,26 @@
import { useState } from "react";
import { LayoutGrid, Zap } from "lucide-react"; import { LayoutGrid, Zap } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// images // images
import githubBlackImage from "/public/logos/github-black.png"; import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png"; import githubWhiteImage from "/public/logos/github-white.png";
// hooks
import { useEventTracker } from "hooks/store";
// components // components
import { BreadcrumbLink, ProductUpdatesModal } from "components/common"; import { BreadcrumbLink } from "components/common";
import { Breadcrumbs } from "@plane/ui"; import { Breadcrumbs } from "@plane/ui";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
// constants
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker";
export const WorkspaceDashboardHeader = () => { export const WorkspaceDashboardHeader = () => {
const [isProductUpdatesModalOpen, setIsProductUpdatesModalOpen] = useState(false);
// hooks // hooks
const { captureEvent } = useEventTracker();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
return ( return (
<> <>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} setIsOpen={setIsProductUpdatesModalOpen} /> <div className="relative z-[15] flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="relative z-20 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div>
@ -34,16 +36,26 @@ export const WorkspaceDashboardHeader = () => {
</div> </div>
<div className="flex items-center gap-3 px-3"> <div className="flex items-center gap-3 px-3">
<a <a
onClick={() =>
captureEvent(CHANGELOG_REDIRECTED, {
element: "navbar",
})
}
href="https://plane.so/changelog" href="https://plane.so/changelog"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium" className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
> >
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" /> <Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
{"What's new?"} <span className="hidden text-xs font-medium sm:hidden md:block">{"What's new?"}</span>
</a> </a>
<a <a
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5 text-xs font-medium" onClick={() =>
captureEvent(GITHUB_REDIRECTED, {
element: "navbar",
})
}
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
href="https://github.com/makeplane/plane" href="https://github.com/makeplane/plane"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@ -54,7 +66,7 @@ export const WorkspaceDashboardHeader = () => {
width={16} width={16}
alt="GitHub Logo" alt="GitHub Logo"
/> />
Star us on GitHub <span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
</a> </a>
</div> </div>
</div> </div>

View File

@ -20,6 +20,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle
// types // types
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { ISSUE_DELETED } from "constants/event-tracker";
type TInboxIssueActionsHeader = { type TInboxIssueActionsHeader = {
workspaceSlug: string; workspaceSlug: string;
@ -86,17 +87,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
throw new Error("Missing required parameters"); throw new Error("Missing required parameters");
await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); await removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { payload: {
id: inboxIssueId, id: inboxIssueId,
state: "SUCCESS", state: "SUCCESS",
element: "Inbox page", element: "Inbox page",
}, }
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}); });
router.push({ router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
@ -108,17 +104,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
message: "Something went wrong while deleting inbox issue. Please try again.", message: "Something went wrong while deleting inbox issue. Please try again.",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { payload: {
id: inboxIssueId, id: inboxIssueId,
state: "FAILED", state: "FAILED",
element: "Inbox page", element: "Inbox page",
}, },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
}); });
} }
}, },

View File

@ -18,6 +18,8 @@ import { GptAssistantPopover } from "components/core";
import { Button, Input, ToggleSwitch } from "@plane/ui"; import { Button, Input, ToggleSwitch } from "@plane/ui";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -65,7 +67,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
config: { envConfig }, config: { envConfig },
} = useApplication(); } = useApplication();
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const { currentWorkspace } = useWorkspace();
const { const {
control, control,
@ -94,34 +95,24 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
handleClose(); handleClose();
} else reset(defaultValues); } else reset(defaultValues);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { payload: {
...formData, ...formData,
state: "SUCCESS", state: "SUCCESS",
element: "Inbox page", element: "Inbox page",
}, },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname, path: router.pathname,
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { payload: {
...formData, ...formData,
state: "FAILED", state: "FAILED",
element: "Inbox page", element: "Inbox page",
}, },
group: {
isGrouping: true,
groupType: "Workspace_metrics",
groupId: currentWorkspace?.id!,
},
path: router.pathname, path: router.pathname,
}); });
}); });

View File

@ -38,7 +38,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
title: "Attachment uploaded", title: "Attachment uploaded",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: "Issue attachment added",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "attachment", changed_property: "attachment",
@ -47,7 +47,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: "Issue attachment added",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
}); });
setToastAlert({ setToastAlert({
@ -67,7 +67,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
title: "Attachment removed", title: "Attachment removed",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: "Issue attachment deleted",
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "attachment", changed_property: "attachment",
@ -76,7 +76,7 @@ export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: "Issue attachment deleted",
payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: "attachment", changed_property: "attachment",

View File

@ -1,6 +1,5 @@
export * from "./attachment"; export * from "./attachment";
export * from "./issue-modal"; export * from "./issue-modal";
export * from "./view-select";
export * from "./delete-issue-modal"; export * from "./delete-issue-modal";
export * from "./description-form"; export * from "./description-form";
export * from "./issue-layouts"; export * from "./issue-layouts";

View File

@ -16,6 +16,7 @@ import { TIssue } from "@plane/types";
// constants // constants
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { EIssuesStoreType } from "constants/issue"; import { EIssuesStoreType } from "constants/issue";
import { ISSUE_UPDATED, ISSUE_DELETED } from "constants/event-tracker";
export type TIssueOperations = { export type TIssueOperations = {
fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>; fetch: (workspaceSlug: string, projectId: string, issueId: string) => Promise<void>;
@ -102,7 +103,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} }
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: Object.keys(data).join(","), changed_property: Object.keys(data).join(","),
@ -112,7 +113,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" }, payload: { state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: Object.keys(data).join(","), changed_property: Object.keys(data).join(","),
@ -138,7 +139,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue deleted successfully", message: "Issue deleted successfully",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: router.asPath, path: router.asPath,
}); });
@ -149,7 +150,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue delete failed", message: "Issue delete failed",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue deleted", eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: router.asPath, path: router.asPath,
}); });
@ -164,7 +165,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Issue added to issue successfully", message: "Issue added to issue successfully",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
@ -174,7 +175,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" }, payload: { state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
@ -198,7 +199,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Cycle removed from issue successfully", message: "Cycle removed from issue successfully",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
@ -208,7 +209,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" }, payload: { state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: "cycle_id", changed_property: "cycle_id",
@ -232,7 +233,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Module added to issue successfully", message: "Module added to issue successfully",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { ...response, state: "SUCCESS", element: "Issue detail page" }, payload: { ...response, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "module_id", changed_property: "module_id",
@ -242,7 +243,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: "module_id", changed_property: "module_id",
@ -266,7 +267,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
message: "Module removed from issue successfully", message: "Module removed from issue successfully",
}); });
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: { updates: {
changed_property: "module_id", changed_property: "module_id",
@ -276,7 +277,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = (props) => {
}); });
} catch (error) { } catch (error) {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue updated", eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: { updates: {
changed_property: "module_id", changed_property: "module_id",

View File

@ -13,6 +13,8 @@ import { createIssuePayload } from "helpers/issue.helper";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
// types // types
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// constants
import { ISSUE_CREATED } from "constants/event-tracker";
type Props = { type Props = {
formKey: keyof TIssue; formKey: keyof TIssue;
@ -129,7 +131,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
viewId viewId
).then((res) => { ).then((res) => {
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: "Calendar quick add" }, payload: { ...res, state: "SUCCESS", element: "Calendar quick add" },
path: router.asPath, path: router.asPath,
}); });
@ -142,7 +144,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
captureIssueEvent({ captureIssueEvent({
eventName: "Issue created", eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED", element: "Calendar quick add" }, payload: { ...payload, state: "FAILED", element: "Calendar quick add" },
path: router.asPath, path: router.asPath,
}); });

View File

@ -2,7 +2,7 @@ import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
// hooks // hooks
import { useGlobalView, useIssues, useLabel, useUser } from "hooks/store"; import { useEventTracker, useGlobalView, useIssues, useLabel, useUser } from "hooks/store";
//ui //ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// components // components
@ -11,6 +11,8 @@ import { AppliedFiltersList } from "components/issues";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types"; import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
import { EIssueFilterType, EIssuesStoreType } from "constants/issue"; import { EIssueFilterType, EIssuesStoreType } from "constants/issue";
import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace"; import { DEFAULT_GLOBAL_VIEWS_LIST, EUserWorkspaceRoles } from "constants/workspace";
// constants
import { GLOBAL_VIEW_UPDATED } from "constants/event-tracker";
type Props = { type Props = {
globalViewId: string; globalViewId: string;
@ -27,6 +29,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
} = useIssues(EIssuesStoreType.GLOBAL); } = useIssues(EIssuesStoreType.GLOBAL);
const { workspaceLabels } = useLabel(); const { workspaceLabels } = useLabel();
const { globalViewMap, updateGlobalView } = useGlobalView(); const { globalViewMap, updateGlobalView } = useGlobalView();
const { captureEvent } = useEventTracker();
const { const {
membership: { currentWorkspaceRole }, membership: { currentWorkspaceRole },
} = useUser(); } = useUser();
@ -91,6 +94,13 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
filters: { filters: {
...(appliedFilters ?? {}), ...(appliedFilters ?? {}),
}, },
}).then((res) => {
captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
element: "Spreadsheet view",
});
}); });
}; };

View File

@ -13,10 +13,11 @@ type Props = {
placement?: Placement; placement?: Placement;
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
menuButton?: React.ReactNode;
}; };
export const FiltersDropdown: React.FC<Props> = (props) => { export const FiltersDropdown: React.FC<Props> = (props) => {
const { children, title = "Dropdown", placement, disabled = false, tabIndex } = props; const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -33,7 +34,9 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
return ( return (
<> <>
<Popover.Button as={React.Fragment}> <Popover.Button as={React.Fragment}>
<Button {menuButton ? <button role="button" ref={setReferenceElement}>
{menuButton}
</button> : <Button
disabled={disabled} disabled={disabled}
ref={setReferenceElement} ref={setReferenceElement}
variant="neutral-primary" variant="neutral-primary"
@ -46,7 +49,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
<div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}> <div className={`${open ? "text-custom-text-100" : "text-custom-text-200"}`}>
<span>{title}</span> <span>{title}</span>
</div> </div>
</Button> </Button>}
</Popover.Button> </Popover.Button>
<Transition <Transition
as={Fragment} as={Fragment}

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